lsst-resources 29.2025.1700__py3-none-any.whl → 29.2025.4600__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
lsst/resources/file.py CHANGED
@@ -21,6 +21,7 @@ import os.path
21
21
  import posixpath
22
22
  import re
23
23
  import shutil
24
+ import stat
24
25
  import urllib.parse
25
26
  from collections.abc import Iterator
26
27
  from typing import IO, TYPE_CHECKING
@@ -79,7 +80,10 @@ class FileResourcePath(ResourcePath):
79
80
  """Remove the resource."""
80
81
  os.remove(self.ospath)
81
82
 
82
- def _as_local(self, multithreaded: bool = True, tmpdir: ResourcePath | None = None) -> tuple[str, bool]:
83
+ @contextlib.contextmanager
84
+ def _as_local(
85
+ self, multithreaded: bool = True, tmpdir: ResourcePath | None = None
86
+ ) -> Iterator[ResourcePath]:
83
87
  """Return the local path of the file.
84
88
 
85
89
  This is an internal helper for ``as_local()``.
@@ -93,12 +97,10 @@ class FileResourcePath(ResourcePath):
93
97
 
94
98
  Returns
95
99
  -------
96
- path : `str`
97
- The local path to this file.
98
- temporary : `bool`
99
- Always returns the temporary nature of the input file resource.
100
+ local_uri : `ResourcePath`
101
+ A local URI. In this case it will be itself.
100
102
  """
101
- return self.ospath, self.isTemporary
103
+ yield self
102
104
 
103
105
  def read(self, size: int = -1) -> bytes:
104
106
  with open(self.ospath, "rb") as fh:
@@ -106,7 +108,7 @@ class FileResourcePath(ResourcePath):
106
108
 
107
109
  def write(self, data: bytes, overwrite: bool = True) -> None:
108
110
  dir = os.path.dirname(self.ospath)
109
- if not os.path.exists(dir):
111
+ if dir and not os.path.exists(dir):
110
112
  _create_directories(dir)
111
113
  mode = "wb" if overwrite else "xb"
112
114
  with open(self.ospath, mode) as f:
@@ -327,15 +329,38 @@ class FileResourcePath(ResourcePath):
327
329
  # the same output directory. This at least guarantees that
328
330
  # if multiple processes are writing to the same file
329
331
  # simultaneously the file we end up with will not be corrupt.
330
- with self.temporary_uri(prefix=self.parent(), suffix=self.getExtension()) as temp_copy:
331
- shutil.copy(local_src, temp_copy.ospath)
332
- with transaction.undoWith(f"copy from {local_src}", os.remove, newFullPath):
333
- # os.rename works even if the file exists.
334
- # It's possible that another process has copied a file
335
- # in whilst this one was copying. If overwrite
336
- # protection is needed then another stat() call should
337
- # happen here.
338
- os.rename(temp_copy.ospath, newFullPath)
332
+ if overwrite:
333
+ with self.temporary_uri(prefix=self.parent(), suffix=self.getExtension()) as temp_copy:
334
+ shutil.copy(local_src, temp_copy.ospath)
335
+ with transaction.undoWith(f"copy from {local_src}", os.remove, newFullPath):
336
+ os.rename(temp_copy.ospath, newFullPath)
337
+ else:
338
+ # Create the file exclusively to ensure that no others are
339
+ # trying to write.
340
+ temp_path = newFullPath + ".transfer-tmp"
341
+ try:
342
+ with open(temp_path, "x"):
343
+ pass
344
+ except FileExistsError:
345
+ raise FileExistsError(
346
+ f"Another process is writing to '{self}'."
347
+ f" Transfer from {src} cannot be completed."
348
+ )
349
+ with transaction.undoWith(f"copy from {local_src}", os.remove, temp_path):
350
+ # Make sure file is writable, no matter the umask.
351
+ st = os.stat(temp_path)
352
+ os.chmod(temp_path, st.st_mode | stat.S_IWUSR)
353
+ shutil.copy(local_src, temp_path)
354
+ # Use link/remove to atomically and exclusively move the
355
+ # file into place (only one concurrent linker can win).
356
+ try:
357
+ os.link(temp_path, newFullPath)
358
+ except FileExistsError:
359
+ raise FileExistsError(
360
+ f"Another process wrote to '{self}'. Transfer from {src} cannot be completed."
361
+ )
362
+ finally:
363
+ os.remove(temp_path)
339
364
  elif transfer == "link":
340
365
  # Try hard link and if that fails use a symlink
341
366
  with transaction.undoWith(f"link to {local_src}", os.remove, newFullPath):
lsst/resources/gs.py CHANGED
@@ -202,17 +202,20 @@ class GSResourcePath(ResourcePath):
202
202
  # Should this method do anything at all?
203
203
  self.blob.upload_from_string(b"", retry=_RETRY_POLICY)
204
204
 
205
- def _as_local(self, multithreaded: bool = True, tmpdir: ResourcePath | None = None) -> tuple[str, bool]:
205
+ @contextlib.contextmanager
206
+ def _as_local(
207
+ self, multithreaded: bool = True, tmpdir: ResourcePath | None = None
208
+ ) -> Iterator[ResourcePath]:
206
209
  with (
207
- ResourcePath.temporary_uri(prefix=tmpdir, suffix=self.getExtension(), delete=False) as tmp_uri,
210
+ ResourcePath.temporary_uri(prefix=tmpdir, suffix=self.getExtension(), delete=True) as tmp_uri,
208
211
  time_this(log, msg="Downloading %s to local file", args=(self,)),
209
212
  ):
210
213
  try:
211
214
  with tmp_uri.open("wb") as tmpFile:
212
215
  self.blob.download_to_file(tmpFile, retry=_RETRY_POLICY)
216
+ yield tmp_uri
213
217
  except NotFound as e:
214
218
  raise FileNotFoundError(f"No such resource: {self}") from e
215
- return tmp_uri.ospath, True
216
219
 
217
220
  def transfer_from(
218
221
  self,
lsst/resources/http.py CHANGED
@@ -59,7 +59,7 @@ from lsst.utils.timer import time_this
59
59
  from ._resourceHandles import ResourceHandleProtocol
60
60
  from ._resourceHandles._httpResourceHandle import HttpReadResourceHandle, parse_content_range_header
61
61
  from ._resourcePath import ResourcePath
62
- from .utils import get_tempdir
62
+ from .utils import _get_num_workers, get_tempdir
63
63
 
64
64
  if TYPE_CHECKING:
65
65
  from .utils import TransactionProtocol
@@ -112,13 +112,6 @@ def _calc_tmpdir_buffer_size(tmpdir: str) -> int:
112
112
  return max(10 * fsstats.f_bsize, 256 * 4096)
113
113
 
114
114
 
115
- def _dav_to_http(url: str) -> str:
116
- """Convert dav scheme in URL to http scheme."""
117
- if url.startswith("dav"):
118
- url = "http" + url.removeprefix("dav")
119
- return url
120
-
121
-
122
115
  class HttpResourcePathConfig:
123
116
  """Configuration class to encapsulate the configurable items used by class
124
117
  HttpResourcePath.
@@ -165,14 +158,14 @@ class HttpResourcePathConfig:
165
158
  if self._front_end_connections is not None:
166
159
  return self._front_end_connections
167
160
 
161
+ default_pool_size = max(_get_num_workers(), self.DEFAULT_FRONTEND_PERSISTENT_CONNECTIONS)
162
+
168
163
  try:
169
164
  self._front_end_connections = int(
170
- os.environ.get(
171
- "LSST_HTTP_FRONTEND_PERSISTENT_CONNECTIONS", self.DEFAULT_FRONTEND_PERSISTENT_CONNECTIONS
172
- )
165
+ os.environ.get("LSST_HTTP_FRONTEND_PERSISTENT_CONNECTIONS", default_pool_size)
173
166
  )
174
167
  except ValueError:
175
- self._front_end_connections = self.DEFAULT_FRONTEND_PERSISTENT_CONNECTIONS
168
+ self._front_end_connections = default_pool_size
176
169
 
177
170
  return self._front_end_connections
178
171
 
@@ -182,14 +175,14 @@ class HttpResourcePathConfig:
182
175
  if self._back_end_connections is not None:
183
176
  return self._back_end_connections
184
177
 
178
+ default_pool_size = max(_get_num_workers(), self.DEFAULT_FRONTEND_PERSISTENT_CONNECTIONS)
179
+
185
180
  try:
186
181
  self._back_end_connections = int(
187
- os.environ.get(
188
- "LSST_HTTP_BACKEND_PERSISTENT_CONNECTIONS", self.DEFAULT_BACKEND_PERSISTENT_CONNECTIONS
189
- )
182
+ os.environ.get("LSST_HTTP_BACKEND_PERSISTENT_CONNECTIONS", default_pool_size)
190
183
  )
191
184
  except ValueError:
192
- self._back_end_connections = self.DEFAULT_BACKEND_PERSISTENT_CONNECTIONS
185
+ self._back_end_connections = default_pool_size
193
186
 
194
187
  return self._back_end_connections
195
188
 
@@ -437,9 +430,7 @@ def _get_dav_and_server_headers(path: ResourcePath | str) -> tuple[str | None, s
437
430
  config = HttpResourcePathConfig()
438
431
  with SessionStore(config=config).get(path) as session:
439
432
  resp = session.options(
440
- _dav_to_http(str(path)),
441
- stream=False,
442
- timeout=config.timeout,
433
+ str(path), stream=False, timeout=config.timeout, headers=path._extra_headers
443
434
  )
444
435
 
445
436
  dav_header = server_header = None
@@ -597,7 +588,7 @@ class SessionStore:
597
588
  def _make_session(self, rpath: ResourcePath) -> requests.Session:
598
589
  """Make a new session configured from values from the environment."""
599
590
  session = requests.Session()
600
- root_uri = _dav_to_http(str(rpath.root_uri()))
591
+ root_uri = str(rpath.root_uri())
601
592
  log.debug("Creating new HTTP session for endpoint %s ...", root_uri)
602
593
  retries = Retry(
603
594
  # Total number of retries to allow. Takes precedence over other
@@ -657,9 +648,8 @@ class SessionStore:
657
648
  # from request to request. Systematically persisting connections to
658
649
  # those servers may exhaust their capabilities when there are thousands
659
650
  # of simultaneous clients.
660
- scheme = _dav_to_http(rpath.scheme)
661
651
  session.mount(
662
- f"{scheme}://",
652
+ f"{rpath.scheme}://",
663
653
  HTTPAdapter(
664
654
  pool_connections=self._num_pools,
665
655
  pool_maxsize=0,
@@ -767,6 +757,42 @@ class HttpResourcePath(ResourcePath):
767
757
  a HTTP URL. The value of the variable is not inspected.
768
758
  """
769
759
 
760
+ @staticmethod
761
+ def create_http_resource_path(
762
+ path: str, *, extra_headers: dict[str, str] | None = None
763
+ ) -> HttpResourcePath:
764
+ """Create an instance of `HttpResourcePath` with additional
765
+ HTTP-specific configuration.
766
+
767
+ Parameters
768
+ ----------
769
+ path : `str`
770
+ HTTP URL to be wrapped in a `ResourcePath` instance.
771
+ extra_headers : `dict` [ `str`, `str` ], optional
772
+ Additional headers that will be sent with every HTTP request made
773
+ by this `ResourcePath`. These override any headers that may be
774
+ generated internally by `HttpResourcePath` (e.g. authentication
775
+ headers).
776
+
777
+ Returns
778
+ -------
779
+ instance : `ResourcePath`
780
+ Newly-created `HttpResourcePath` instance.
781
+
782
+ Notes
783
+ -----
784
+ Most users should use the `ResourcePath` constructor, instead.
785
+ """
786
+ # Make sure we instantiate ResourcePath using a string to guarantee we
787
+ # get a new ResourcePath. If we accidentally provided a ResourcePath
788
+ # instance instead, the ResourcePath constructor sometimes returns
789
+ # the original object and we would be modifying an object that is
790
+ # supposed to be immutable.
791
+ instance = ResourcePath(str(path))
792
+ assert isinstance(instance, HttpResourcePath)
793
+ instance._extra_headers = extra_headers
794
+ return instance
795
+
770
796
  # WebDAV servers known to be able to sign URLs. The values are lowercased
771
797
  # server identifiers retrieved from the 'Server' header included in
772
798
  # the response to a HTTP OPTIONS request.
@@ -813,39 +839,48 @@ class HttpResourcePath(ResourcePath):
813
839
  # and is shared by all instances of this class.
814
840
  _tcp_connector: TCPConnector | None = None
815
841
 
842
+ # Additional headers added to every request.
843
+ _extra_headers: dict[str, str] | None = None
844
+
816
845
  @property
817
- def metadata_session(self) -> requests.Session:
846
+ def metadata_session(self) -> _SessionWrapper:
818
847
  """Client session to send requests which do not require upload or
819
848
  download of data, i.e. mostly metadata requests.
820
849
  """
850
+ session = None
821
851
  if hasattr(self, "_metadata_session"):
822
852
  if HttpResourcePath._pid == os.getpid():
823
- return self._metadata_session
853
+ session = self._metadata_session
824
854
  else:
825
855
  # The metadata session we have in cache was likely created by
826
856
  # a parent process. Discard all the sessions in that store.
827
857
  self._metadata_session_store.clear()
828
858
 
829
859
  # Retrieve a new metadata session.
830
- HttpResourcePath._pid = os.getpid()
831
- self._metadata_session: requests.Session = self._metadata_session_store.get(self)
832
- return self._metadata_session
860
+ if session is None:
861
+ HttpResourcePath._pid = os.getpid()
862
+ session = self._metadata_session_store.get(self)
863
+ self._metadata_session: requests.Session = session
864
+ return _SessionWrapper(session, extra_headers=self._extra_headers)
833
865
 
834
866
  @property
835
- def data_session(self) -> requests.Session:
867
+ def data_session(self) -> _SessionWrapper:
836
868
  """Client session for uploading and downloading data."""
869
+ session = None
837
870
  if hasattr(self, "_data_session"):
838
871
  if HttpResourcePath._pid == os.getpid():
839
- return self._data_session
872
+ session = self._data_session
840
873
  else:
841
874
  # The data session we have in cache was likely created by
842
875
  # a parent process. Discard all the sessions in that store.
843
876
  self._data_session_store.clear()
844
877
 
845
878
  # Retrieve a new data session.
846
- HttpResourcePath._pid = os.getpid()
847
- self._data_session: requests.Session = self._data_session_store.get(self)
848
- return self._data_session
879
+ if session is None:
880
+ HttpResourcePath._pid = os.getpid()
881
+ session = self._data_session_store.get(self)
882
+ self._data_session: requests.Session = session
883
+ return _SessionWrapper(session, extra_headers=self._extra_headers)
849
884
 
850
885
  def _clear_sessions(self) -> None:
851
886
  """Close the socket connections that are still open.
@@ -1127,7 +1162,7 @@ class HttpResourcePath(ResourcePath):
1127
1162
  stream = size > 0
1128
1163
  with self.data_session as session:
1129
1164
  with time_this(log, msg="GET %s", args=(self,)):
1130
- resp = session.get(_dav_to_http(self.geturl()), stream=stream, timeout=self._config.timeout)
1165
+ resp = session.get(self.geturl(), stream=stream, timeout=self._config.timeout)
1131
1166
 
1132
1167
  if resp.status_code != requests.codes.ok: # 200
1133
1168
  raise FileNotFoundError(
@@ -1343,16 +1378,12 @@ class HttpResourcePath(ResourcePath):
1343
1378
  path : `str`
1344
1379
  A path that can be opened by the file system object.
1345
1380
  """
1346
- if (
1347
- fsspec is None
1348
- or not self.is_webdav_endpoint
1349
- or self.server not in HttpResourcePath.SUPPORTED_URL_SIGNERS
1350
- ):
1351
- if self.scheme.startswith("dav") and fsspec:
1352
- # Not webdav so convert to http.
1353
- return fsspec.url_to_fs(_dav_to_http(self.geturl()))
1381
+ if fsspec is None:
1354
1382
  return super().to_fsspec()
1355
1383
 
1384
+ if not self.is_webdav_endpoint or self.server not in HttpResourcePath.SUPPORTED_URL_SIGNERS:
1385
+ return fsspec.url_to_fs(self.geturl(), client_kwargs={"headers": self._extra_headers})
1386
+
1356
1387
  if self.isdir():
1357
1388
  raise NotImplementedError(
1358
1389
  f"method HttpResourcePath.to_fsspec() not implemented for directory {self}"
@@ -1494,7 +1525,10 @@ class HttpResourcePath(ResourcePath):
1494
1525
  except json.JSONDecodeError:
1495
1526
  raise ValueError(f"could not deserialize response to POST request for URL {self}")
1496
1527
 
1497
- def _as_local(self, multithreaded: bool = True, tmpdir: ResourcePath | None = None) -> tuple[str, bool]:
1528
+ @contextlib.contextmanager
1529
+ def _as_local(
1530
+ self, multithreaded: bool = True, tmpdir: ResourcePath | None = None
1531
+ ) -> Iterator[ResourcePath]:
1498
1532
  """Download object over HTTP and place in temporary directory.
1499
1533
 
1500
1534
  Parameters
@@ -1511,16 +1545,15 @@ class HttpResourcePath(ResourcePath):
1511
1545
 
1512
1546
  Returns
1513
1547
  -------
1514
- path : `str`
1515
- Path to local temporary file.
1516
- temporary : `bool`
1517
- Always returns `True`. This is always a temporary file.
1548
+ local_uri : `ResourcePath`
1549
+ A URI to a local POSIX file corresponding to a local temporary
1550
+ downloaded copy of the resource.
1518
1551
  """
1519
1552
  # Use the session as a context manager to ensure that connections
1520
1553
  # to both the front end and back end servers are closed after the
1521
1554
  # download operation is finished.
1522
1555
  with self.data_session as session:
1523
- resp = session.get(_dav_to_http(self.geturl()), stream=True, timeout=self._config.timeout)
1556
+ resp = session.get(self.geturl(), stream=True, timeout=self._config.timeout)
1524
1557
  if resp.status_code != requests.codes.ok:
1525
1558
  raise FileNotFoundError(
1526
1559
  f"Unable to download resource {self}; status: {resp.status_code} {resp.reason}"
@@ -1533,7 +1566,7 @@ class HttpResourcePath(ResourcePath):
1533
1566
  buffer_size = _calc_tmpdir_buffer_size(tmpdir.ospath)
1534
1567
 
1535
1568
  with ResourcePath.temporary_uri(
1536
- suffix=self.getExtension(), prefix=tmpdir, delete=False
1569
+ suffix=self.getExtension(), prefix=tmpdir, delete=True
1537
1570
  ) as tmp_uri:
1538
1571
  expected_length = int(resp.headers.get("Content-Length", "-1"))
1539
1572
  with time_this(
@@ -1549,20 +1582,20 @@ class HttpResourcePath(ResourcePath):
1549
1582
  tmpFile.write(chunk)
1550
1583
  content_length += len(chunk)
1551
1584
 
1552
- # Check that the expected and actual content lengths match. Perform
1553
- # this check only when the contents of the file was not encoded by
1554
- # the server.
1555
- if (
1556
- "Content-Encoding" not in resp.headers
1557
- and expected_length >= 0
1558
- and expected_length != content_length
1559
- ):
1560
- raise ValueError(
1561
- f"Size of downloaded file does not match value in Content-Length header for {self}: "
1562
- f"expecting {expected_length} and got {content_length} bytes"
1563
- )
1585
+ # Check that the expected and actual content lengths match.
1586
+ # Perform this check only when the contents of the file was not
1587
+ # encoded by the server.
1588
+ if (
1589
+ "Content-Encoding" not in resp.headers
1590
+ and expected_length >= 0
1591
+ and expected_length != content_length
1592
+ ):
1593
+ raise ValueError(
1594
+ f"Size of downloaded file does not match value in Content-Length header for {self}: "
1595
+ f"expecting {expected_length} and got {content_length} bytes"
1596
+ )
1564
1597
 
1565
- return tmpFile.name, True
1598
+ yield tmp_uri
1566
1599
 
1567
1600
  def _send_webdav_request(
1568
1601
  self,
@@ -1570,7 +1603,7 @@ class HttpResourcePath(ResourcePath):
1570
1603
  url: str | None = None,
1571
1604
  headers: dict[str, str] | None = None,
1572
1605
  body: str | None = None,
1573
- session: requests.Session | None = None,
1606
+ session: _SessionWrapper | None = None,
1574
1607
  timeout: tuple[float, float] | None = None,
1575
1608
  ) -> requests.Response:
1576
1609
  """Send a webDAV request and correctly handle redirects.
@@ -1633,7 +1666,7 @@ class HttpResourcePath(ResourcePath):
1633
1666
  for _ in range(max_redirects := 5):
1634
1667
  resp = session.request(
1635
1668
  method,
1636
- _dav_to_http(url),
1669
+ url,
1637
1670
  data=body,
1638
1671
  headers=headers,
1639
1672
  stream=False,
@@ -1791,7 +1824,7 @@ class HttpResourcePath(ResourcePath):
1791
1824
  src : `HttpResourcePath`
1792
1825
  The source of the contents to move to `self`.
1793
1826
  """
1794
- headers = {"Destination": _dav_to_http(self.geturl())}
1827
+ headers = {"Destination": self.geturl()}
1795
1828
  resp = self._send_webdav_request(method, url=src.geturl(), headers=headers, session=self.data_session)
1796
1829
  if resp.status_code in (requests.codes.created, requests.codes.no_content):
1797
1830
  return
@@ -1898,7 +1931,7 @@ class HttpResourcePath(ResourcePath):
1898
1931
  if self._config.send_expect_on_put or self.server == "dcache":
1899
1932
  headers["Expect"] = "100-continue"
1900
1933
 
1901
- url = _dav_to_http(self.geturl())
1934
+ url = self.geturl()
1902
1935
 
1903
1936
  # Use the session as a context manager to ensure the underlying
1904
1937
  # connections are closed after finishing uploading the data.
@@ -1991,6 +2024,10 @@ class HttpResourcePath(ResourcePath):
1991
2024
  with super()._openImpl(mode, encoding=encoding) as http_handle:
1992
2025
  yield http_handle
1993
2026
 
2027
+ def _copy_extra_attributes(self, original_uri: ResourcePath) -> None:
2028
+ assert isinstance(original_uri, HttpResourcePath)
2029
+ self._extra_headers = original_uri._extra_headers
2030
+
1994
2031
 
1995
2032
  def _dump_response(resp: requests.Response) -> None:
1996
2033
  """Log the contents of a HTTP or webDAV request and its response.
@@ -2201,3 +2238,95 @@ class DavProperty:
2201
2238
  @property
2202
2239
  def href(self) -> str:
2203
2240
  return self._href
2241
+
2242
+
2243
+ class _SessionWrapper(contextlib.AbstractContextManager):
2244
+ """Wraps a `requests.Session` to allow header values to be injected with
2245
+ all requests.
2246
+
2247
+ Notes
2248
+ -----
2249
+ `requests.Session` already has a feature for setting headers globally, but
2250
+ our session objects are global and authorization headers can vary for each
2251
+ HttpResourcePath instance.
2252
+ """
2253
+
2254
+ def __init__(self, session: requests.Session, *, extra_headers: dict[str, str] | None) -> None:
2255
+ self._session = session
2256
+ self._extra_headers = extra_headers
2257
+
2258
+ def __enter__(self) -> _SessionWrapper:
2259
+ self._session.__enter__()
2260
+ return self
2261
+
2262
+ def __exit__(
2263
+ self,
2264
+ exc_type: Any,
2265
+ exc_value: Any,
2266
+ traceback: Any,
2267
+ ) -> None:
2268
+ return self._session.__exit__(exc_type, exc_value, traceback)
2269
+
2270
+ def get(
2271
+ self,
2272
+ url: str,
2273
+ *,
2274
+ timeout: tuple[float, float],
2275
+ allow_redirects: bool = True,
2276
+ stream: bool,
2277
+ headers: dict[str, str] | None = None,
2278
+ ) -> requests.Response:
2279
+ return self._session.get(
2280
+ url,
2281
+ timeout=timeout,
2282
+ allow_redirects=allow_redirects,
2283
+ stream=stream,
2284
+ headers=self._augment_headers(headers),
2285
+ )
2286
+
2287
+ def head(
2288
+ self,
2289
+ url: str,
2290
+ *,
2291
+ timeout: tuple[float, float],
2292
+ allow_redirects: bool,
2293
+ stream: bool,
2294
+ headers: dict[str, str] | None = None,
2295
+ ) -> requests.Response:
2296
+ return self._session.head(
2297
+ url,
2298
+ timeout=timeout,
2299
+ allow_redirects=allow_redirects,
2300
+ stream=stream,
2301
+ headers=self._augment_headers(headers),
2302
+ )
2303
+
2304
+ def request(
2305
+ self,
2306
+ method: str,
2307
+ url: str,
2308
+ *,
2309
+ data: str | bytes | BinaryIO | None,
2310
+ timeout: tuple[float, float],
2311
+ allow_redirects: bool,
2312
+ stream: bool,
2313
+ headers: dict[str, str] | None = None,
2314
+ ) -> requests.Response:
2315
+ return self._session.request(
2316
+ method,
2317
+ url,
2318
+ data=data,
2319
+ timeout=timeout,
2320
+ allow_redirects=allow_redirects,
2321
+ stream=stream,
2322
+ headers=self._augment_headers(headers),
2323
+ )
2324
+
2325
+ def _augment_headers(self, headers: dict[str, str] | None) -> dict[str, str]:
2326
+ if headers is None:
2327
+ headers = {}
2328
+
2329
+ if self._extra_headers is not None:
2330
+ headers = headers | self._extra_headers
2331
+
2332
+ return headers
lsst/resources/mem.py CHANGED
@@ -13,6 +13,9 @@ from __future__ import annotations
13
13
 
14
14
  __all__ = ("InMemoryResourcePath",)
15
15
 
16
+ import contextlib
17
+ from collections.abc import Iterator
18
+
16
19
  from ._resourcePath import ResourcePath
17
20
 
18
21
 
@@ -27,5 +30,8 @@ class InMemoryResourcePath(ResourcePath):
27
30
  """Test for existence and always return False."""
28
31
  return True
29
32
 
30
- def _as_local(self, multithreaded: bool = True, tmpdir: ResourcePath | None = None) -> tuple[str, bool]:
33
+ @contextlib.contextmanager
34
+ def _as_local(
35
+ self, multithreaded: bool = True, tmpdir: ResourcePath | None = None
36
+ ) -> Iterator[ResourcePath]:
31
37
  raise RuntimeError(f"Do not know how to retrieve data for URI '{self}'")