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/_resourceHandles/_davResourceHandle.py +197 -0
- lsst/resources/_resourceHandles/_fileResourceHandle.py +1 -1
- lsst/resources/_resourceHandles/_httpResourceHandle.py +7 -4
- lsst/resources/_resourceHandles/_s3ResourceHandle.py +3 -17
- lsst/resources/_resourcePath.py +311 -79
- lsst/resources/dav.py +912 -0
- lsst/resources/davutils.py +2659 -0
- lsst/resources/file.py +41 -16
- lsst/resources/gs.py +6 -3
- lsst/resources/http.py +194 -65
- lsst/resources/mem.py +7 -1
- lsst/resources/s3.py +141 -15
- lsst/resources/s3utils.py +8 -1
- lsst/resources/schemeless.py +6 -3
- lsst/resources/tests.py +66 -12
- lsst/resources/utils.py +43 -0
- lsst/resources/version.py +1 -1
- {lsst_resources-29.2025.1700.dist-info → lsst_resources-29.2025.4600.dist-info}/METADATA +3 -3
- lsst_resources-29.2025.4600.dist-info/RECORD +31 -0
- {lsst_resources-29.2025.1700.dist-info → lsst_resources-29.2025.4600.dist-info}/WHEEL +1 -1
- lsst_resources-29.2025.1700.dist-info/RECORD +0 -28
- {lsst_resources-29.2025.1700.dist-info → lsst_resources-29.2025.4600.dist-info}/licenses/COPYRIGHT +0 -0
- {lsst_resources-29.2025.1700.dist-info → lsst_resources-29.2025.4600.dist-info}/licenses/LICENSE +0 -0
- {lsst_resources-29.2025.1700.dist-info → lsst_resources-29.2025.4600.dist-info}/top_level.txt +0 -0
- {lsst_resources-29.2025.1700.dist-info → lsst_resources-29.2025.4600.dist-info}/zip-safe +0 -0
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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
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=
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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) ->
|
|
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
|
-
|
|
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
|
-
|
|
831
|
-
|
|
832
|
-
|
|
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) ->
|
|
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
|
-
|
|
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
|
-
|
|
847
|
-
|
|
848
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
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(
|
|
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=
|
|
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
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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":
|
|
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 =
|
|
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
|
-
|
|
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}'")
|