Coverage for tests/test_dav.py: 14%
597 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-19 01:29 -0800
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-19 01:29 -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.
12import concurrent
13import hashlib
14import io
15import os.path
16import random
17import shutil
18import socket
19import stat
20import string
21import tempfile
22import time
23import unittest
24import zlib
25from collections.abc import Callable
26from datetime import datetime
27from threading import Thread
28from typing import Any, cast
29from zipfile import ZipFile, ZipInfo
31try:
32 from cheroot import wsgi
33 from wsgidav.wsgidav_app import WsgiDAVApp
34except ImportError:
35 WsgiDAVApp = None
37try:
38 import fsspec
39except ImportError:
40 fsspec = None
41 AbstractFileSystem = type
43from lsst.resources import ResourcePath
44from lsst.resources._resourceHandles._davResourceHandle import (
45 DavReadResourceHandle,
46)
47from lsst.resources.dav import (
48 DavResourcePathConfig,
49 dav_globals,
50)
51from lsst.resources.davutils import (
52 DavConfig,
53 DavConfigPool,
54 TokenAuthorizer,
55)
56from lsst.resources.tests import GenericReadWriteTestCase, GenericTestCase
57from lsst.resources.utils import get_tempdir, makeTestTempDir, removeTestTempDir
59TESTDIR = os.path.abspath(os.path.dirname(__file__))
62class GenericDavTestCase(GenericTestCase, unittest.TestCase):
63 """Generic tests of dav URIs."""
65 scheme = "dav"
66 netloc = "host.example.org"
68 def test_dav_root_uri(self):
69 root_uri_test_cases = {
70 # input : expected
71 "dav://host.example.org": "dav://host.example.org/",
72 "dav://host.example.org/some/path": "dav://host.example.org/",
73 "dav://host.example.org:12345": "dav://host.example.org:12345/",
74 "dav://host.example.org:12345/some/path": "dav://host.example.org:12345/",
75 "dav://user:password@host.example.org": "dav://user:password@host.example.org/",
76 "dav://user:password@host.example.org/some/path": "dav://user:password@host.example.org/",
77 "dav://user:password@host.example.org:12345/some/path": "dav://user:password@host.example.org:12345/",
78 "dav://user:password@host.example.org/some/path#fragment": "dav://user:password@host.example.org/",
79 "dav://user:password@host.example.org/some/path?param=value": "dav://user:password@host.example.org/",
80 "dav://user:password@host.example.org/some/path;parameters": "dav://user:password@host.example.org/",
81 }
82 for path, expected in root_uri_test_cases.items():
83 self.assertEqual(ResourcePath(expected), ResourcePath(path).root_uri())
85 clean_path_test_cases = {
86 # input : expected
87 "dav://host.example.org/": "dav://host.example.org/",
88 "dav://host.example.org/some/path": "dav://host.example.org/some/path",
89 "dav://host.example.org////some/path///": "dav://host.example.org/some/path/",
90 "dav://host.example.org/some/path///": "dav://host.example.org/some/path/",
91 "dav://host.example.org/some/./././path": "dav://host.example.org/some/path",
92 "dav://host.example.org/a/b/c/d/../../": "dav://host.example.org/a/b/",
93 "dav://host.example.org/a/b/c/d/../../../../../../": "dav://host.example.org/",
94 }
95 for path, expected in clean_path_test_cases.items():
96 self.assertEqual(ResourcePath(path).geturl(), expected)
99class DavReadWriteTestCase(GenericReadWriteTestCase, unittest.TestCase):
100 """Test with a real webDAV server, as opposed to mocking responses."""
102 scheme = "dav"
103 local_files_to_remove: list[str] = []
104 MEGABYTE: int = 1024 * 1024
106 @classmethod
107 def setUpClass(cls):
108 cls.webdav_tmpdir = tempfile.mkdtemp(prefix="webdav-server-test-")
109 cls.server_thread = None
111 # Reinitialize globals.
112 dav_globals._reset()
114 # Should we test against a running server?
115 #
116 # This is convenient for testing against real servers in the
117 # developer environment by initializing the environment variable
118 # LSST_RESOURCES_DAV_TEST_SERVER_URL with the URL of the server, e.g.
119 # dav://host.example.org:1234/path/to/top/dir
120 if (test_endpoint := os.getenv("LSST_RESOURCES_DAV_TEST_SERVER_URL")) is not None:
121 # Run this test case against the specified server.
122 uri = ResourcePath(test_endpoint)
123 cls.scheme = uri.scheme
124 cls.netloc = uri.netloc
125 cls.base_path = uri.path
126 elif WsgiDAVApp is not None:
127 # WsgiDAVApp is available, launch a local server in its own
128 # thread to expose a local temporary directory and run this
129 # test case against it.
130 cls.port_number = cls._get_port_number()
131 cls.stop_webdav_server = False
132 cls.server_thread = Thread(
133 target=cls._serve_webdav,
134 args=(cls, cls.webdav_tmpdir, cls.port_number, lambda: cls.stop_webdav_server),
135 daemon=True,
136 )
137 cls.server_thread.start()
139 # Wait for it to start
140 time.sleep(1)
142 # Initialize the server endpoint
143 cls.netloc = f"127.0.0.1:{cls.port_number}"
144 else:
145 cls.skipTest(
146 cls,
147 "neither WsgiDAVApp is available nor a webDAV test endpoint is configured to test against",
148 )
150 @classmethod
151 def tearDownClass(cls):
152 # Stop the WsgiDAVApp server, if any
153 if WsgiDAVApp is not None:
154 # Shut down of the webdav server and wait for the thread to exit
155 cls.stop_webdav_server = True
156 if cls.server_thread is not None:
157 cls.server_thread.join()
159 # Remove local temporary files
160 for file in cls.local_files_to_remove:
161 if os.path.exists(file):
162 os.remove(file)
164 # Remove temp dir
165 if cls.webdav_tmpdir:
166 shutil.rmtree(cls.webdav_tmpdir, ignore_errors=True)
168 def tearDown(self):
169 if self.tmpdir:
170 self.tmpdir.remove_dir(recursive=True)
172 super().tearDown()
174 def test_dav_file_handle(self):
175 # Upload a new file with known contents.
176 contents = "These are some \n bytes to read"
177 remote_file = self.tmpdir.join(self._get_file_name())
178 self.assertIsNone(remote_file.write(data=contents, overwrite=True))
180 # Test that the correct handle is returned.
181 with remote_file.open("rb") as handle:
182 self.assertIsInstance(handle, DavReadResourceHandle)
184 # Test reading byte ranges works
185 with remote_file.open("rb") as handle:
186 sub_contents = contents[:10]
187 handle = cast(DavReadResourceHandle, handle)
188 result = handle.read(len(sub_contents)).decode()
189 self.assertEqual(result, sub_contents)
191 # Verify the position.
192 self.assertEqual(handle.tell(), len(sub_contents))
194 # Jump back to the beginning and test if reading the whole file
195 # prompts the internal buffer to be read.
196 handle.seek(0)
197 self.assertEqual(handle.tell(), 0)
198 result = handle.read().decode()
199 self.assertEqual(result, contents)
201 # Check that flush works on read-only handle.
202 handle.flush()
204 # Verify reading as a string handle works as expected.
205 with remote_file.open("r") as handle:
206 self.assertIsInstance(handle, io.TextIOWrapper)
208 handle = cast(io.TextIOWrapper, handle)
209 self.assertIsInstance(handle.buffer, DavReadResourceHandle)
211 # Check if string methods work.
212 result = handle.read()
213 self.assertEqual(result, contents)
215 # Check that flush works on read-only handle.
216 handle.flush()
218 # Verify that write modes invoke the default base method
219 with remote_file.open("w") as handle:
220 self.assertIsInstance(handle, io.StringIO)
222 def test_dav_mkdir(self):
223 # Check creation and deletion of an empty directory
224 subdir = self.tmpdir.join(self._get_dir_name(), forceDirectory=True)
225 self.assertIsNone(subdir.mkdir())
226 self.assertTrue(subdir.exists())
227 self.assertTrue(subdir.isdir())
228 self.assertEqual(subdir.size(), 0)
230 # Creating an existing remote directory must succeed
231 self.assertIsNone(subdir.mkdir())
233 # Deleting an existing directory must succeed
234 self.assertIsNone(subdir.remove())
236 # Deleting a non-existing directory must succeed
237 subir_not_exists = self.tmpdir.join(self._get_dir_name(), forceDirectory=True)
238 self.assertIsNone(subir_not_exists.remove())
240 # Attempting to create a directory at a path where a file exists
241 # must raise
242 file = self.tmpdir.join(self._get_file_name(), forceDirectory=False)
243 file.write(data=None, overwrite=True)
244 self.assertTrue(file.exists())
246 existing_file = self.tmpdir.join(file.basename(), forceDirectory=True)
247 with self.assertRaises(NotADirectoryError):
248 self.assertIsNone(existing_file.mkdir())
250 # mkdir must create all missing ancestors
251 subsubdir = subdir.join("a/b/c/d/e", forceDirectory=True)
252 self.assertIsNone(subsubdir.mkdir())
253 self.assertTrue(subsubdir.exists())
255 def test_dav_upload_download(self):
256 # Test upload a randomly-generated file via write() with and without
257 # overwrite
258 local_file, file_size = self._generate_file()
259 with open(local_file, "rb") as f:
260 data = f.read()
262 remote_file = self.tmpdir.join(self._get_file_name())
263 self.assertIsNone(remote_file.write(data, overwrite=True))
264 self.assertTrue(remote_file.exists())
265 self.assertEqual(remote_file.size(), file_size)
267 # Write without overwrite must raise since target file exists
268 with self.assertRaises(FileExistsError):
269 remote_file.write(data, overwrite=False)
271 # Download the file we just uploaded. Compute and compare a digest of
272 # the uploaded and downloaded data and ensure they match
273 downloaded_data = remote_file.read()
274 self.assertEqual(len(downloaded_data), file_size)
275 upload_digest = self._compute_digest(data)
276 download_digest = self._compute_digest(downloaded_data)
277 self.assertEqual(upload_digest, download_digest)
278 os.remove(local_file)
280 def test_dav_as_local(self):
281 contents = str.encode("12345")
282 remote_file = self.tmpdir.join(self._get_file_name())
283 self.assertIsNone(remote_file.write(data=contents, overwrite=True))
284 self.assertTrue(remote_file.exists())
285 self.assertEqual(remote_file.size(), len(contents))
287 with remote_file._as_local() as local_uri:
288 self.assertTrue(local_uri.isTemporary)
289 self.assertTrue(os.path.exists(local_uri.ospath))
290 self.assertTrue(os.stat(local_uri.ospath).st_size, len(contents))
291 self.assertEqual(local_uri.read(), contents)
292 self.assertFalse(local_uri.exists())
294 def test_dav_size(self):
295 # Retrieving the size of a non-existent file must raise.
296 remote_file = self.tmpdir.join(self._get_file_name())
297 with self.assertRaises(FileNotFoundError):
298 remote_file.size()
300 # The size of a directory using a file-like path must be zero
301 remote_dir = self.tmpdir.join(self._get_dir_name(), forceDirectory=True)
302 self.assertIsNone(remote_dir.mkdir())
303 self.assertTrue(remote_dir.exists())
304 self.assertEqual(remote_dir.size(), 0)
306 dir_as_file = ResourcePath(remote_dir.geturl().rstrip("/"), forceDirectory=False)
307 self.assertEqual(dir_as_file.size(), 0)
309 def test_dav_upload_creates_dir(self):
310 # Uploading a file to a non existing directory must ensure its
311 # parent directories are automatically created and upload succeeds
312 non_existing_dir = self.tmpdir.join(self._get_dir_name(), forceDirectory=True)
313 non_existing_dir = non_existing_dir.join(self._get_dir_name(), forceDirectory=True)
314 non_existing_dir = non_existing_dir.join(self._get_dir_name(), forceDirectory=True)
315 remote_file = non_existing_dir.join(self._get_file_name())
317 local_file, file_size = self._generate_file()
318 with open(local_file, "rb") as f:
319 data = f.read()
320 self.assertIsNone(remote_file.write(data, overwrite=True))
322 self.assertTrue(remote_file.exists())
323 self.assertEqual(remote_file.size(), file_size)
324 self.assertTrue(remote_file.parent().exists())
326 downloaded_data = remote_file.read()
327 upload_digest = self._compute_digest(data)
328 download_digest = self._compute_digest(downloaded_data)
329 self.assertEqual(upload_digest, download_digest)
331 def test_dav_transfer_from(self):
332 # Transfer from local file via "copy", with and without overwrite
333 remote_file = self.tmpdir.join(self._get_file_name())
334 local_file, _ = self._generate_file()
335 source_file = ResourcePath(local_file)
336 self.assertIsNone(remote_file.transfer_from(source_file, transfer="copy", overwrite=True))
337 self.assertTrue(remote_file.exists())
338 self.assertEqual(remote_file.size(), source_file.size())
339 with self.assertRaises(FileExistsError):
340 remote_file.transfer_from(ResourcePath(local_file), transfer="copy", overwrite=False)
342 # Transfer from remote file via "copy", with and without overwrite
343 source_file = remote_file
344 target_file = self.tmpdir.join(self._get_file_name())
345 self.assertIsNone(target_file.transfer_from(source_file, transfer="copy", overwrite=True))
346 self.assertTrue(target_file.exists())
347 self.assertEqual(target_file.size(), source_file.size())
349 # Transfer without overwrite must raise since target resource exists
350 with self.assertRaises(FileExistsError):
351 target_file.transfer_from(source_file, transfer="copy", overwrite=False)
353 # Test transfer from local file via "move", with and without overwrite
354 source_file = ResourcePath(local_file)
355 source_size = source_file.size()
356 target_file = self.tmpdir.join(self._get_file_name())
357 self.assertIsNone(target_file.transfer_from(source_file, transfer="move", overwrite=True))
358 self.assertTrue(target_file.exists())
359 self.assertEqual(target_file.size(), source_size)
360 self.assertFalse(source_file.exists())
362 # Test transfer without overwrite must raise since target resource
363 # exists
364 local_file, file_size = self._generate_file()
365 with self.assertRaises(FileExistsError):
366 source_file = ResourcePath(local_file)
367 target_file.transfer_from(source_file, transfer="move", overwrite=False)
369 # Test transfer from remote file via "move" with and without overwrite
370 # must succeed
371 source_file = target_file
372 source_size = source_file.size()
373 target_file = self.tmpdir.join(self._get_file_name())
374 self.assertIsNone(target_file.transfer_from(source_file, transfer="move", overwrite=True))
375 self.assertTrue(target_file.exists())
376 self.assertEqual(target_file.size(), source_size)
377 self.assertFalse(source_file.exists())
379 # Transfer without overwrite must raise since target resource exists
380 with self.assertRaises(FileExistsError):
381 source_file = ResourcePath(local_file)
382 target_file.transfer_from(source_file, transfer="move", overwrite=False)
384 def test_dav_handle(self):
385 # Resource handle must succeed
386 remote_file = self.tmpdir.join(self._get_file_name())
387 data = "abcdefghi"
388 self.assertIsNone(remote_file.write(data, overwrite=True))
389 with remote_file.open("rb") as handle:
390 handle.seek(1)
391 self.assertEqual(handle.read(4).decode("utf-8"), data[1:5])
393 # If we read the whole file, ensure we cache its contents
394 with remote_file.open("rb") as handle:
395 handle.read()
396 self.assertIsNotNone(handle._cache)
398 handle.seek(1)
399 self.assertEqual(handle.read(4).decode("utf-8"), data[1:5])
401 # Upload a multi-megabyte file and ensure a partial read succeeds
402 # without caching the entire file.
403 data = io.BytesIO(b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09" * self.MEGABYTE)
404 self.assertIsNone(remote_file.write(data, overwrite=True))
405 file_size: int = remote_file.size()
406 file_offset: int = random.randint(self.MEGABYTE, file_size)
407 bytes_to_read: int = random.randint(self.MEGABYTE // 2, self.MEGABYTE)
408 with remote_file.open("rb") as handle:
409 data.seek(file_offset)
410 handle.seek(file_offset)
411 self.assertEqual(handle.tell(), data.tell())
412 self.assertEqual(handle.read(bytes_to_read), data.read(bytes_to_read))
413 self.assertIsNone(handle._cache)
415 # Test readinto()
416 with remote_file.open("rb") as handle:
417 buffer = bytearray(random.randint(self.MEGABYTE, 2 * self.MEGABYTE))
418 offset = random.randint(file_size // 3, file_size // 2)
420 # Check the returned read count is as expected
421 handle.seek(offset)
422 count = handle.readinto(buffer)
423 self.assertEqual(count, len(buffer))
425 # Check the contents of the returned buffer is as expected
426 handle.seek(offset)
427 self.assertTrue(handle.read(count) == buffer)
429 def test_dav_repeated_write(self):
430 data = io.BytesIO(b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09" * self.MEGABYTE)
431 remote_file = self.tmpdir.join(self._get_file_name())
433 # Consecutive writes to the same file must succeed. It was noticed
434 # that XRootD server sometimes fail this operation with an error
435 # like:
436 #
437 # status 423 [Output file /path/to/file is already opened by
438 # 1 writer; open denied.]
439 self.assertIsNone(remote_file.write(data, overwrite=True))
440 self.assertIsNone(remote_file.write(data, overwrite=True))
441 self.assertIsNone(remote_file.write(data, overwrite=True))
443 def test_dav_remove(self):
444 # Deletion of an existing remote file must succeed
445 local_file, file_size = self._generate_file()
446 with open(local_file, "rb") as f:
447 remote_file = self.tmpdir.join(self._get_file_name())
448 self.assertIsNone(remote_file.write(f, overwrite=True))
450 self.assertTrue(remote_file.exists())
451 self.assertEqual(remote_file.size(), file_size)
452 self.assertIsNone(remote_file.remove())
454 # Deletion of a non-existing remote file must succeed
455 non_existing_file = self.tmpdir.join(self._get_file_name())
456 self.assertIsNone(non_existing_file.remove())
458 # Deletion of a non-empty remote directory must raise
459 subdir = self.tmpdir.join(self._get_dir_name(), forceDirectory=True)
460 self.assertIsNone(subdir.mkdir())
461 self.assertTrue(subdir.exists())
462 with open(local_file, "rb") as f:
463 remote_file = subdir.join("file_to_remove")
464 remote_file.write(f, overwrite=True)
465 self.assertTrue(remote_file.exists())
466 self.assertEqual(remote_file.size(), file_size)
468 with self.assertRaises(IsADirectoryError):
469 subdir.remove()
470 self.assertTrue(subdir.exists())
472 # Recursively removing a deep hierarchy of directories must succeed
473 top = subdir
474 for level in ["one", "two", "three", "four"]:
475 subdir = subdir.join(level, forceDirectory=True)
476 subdir.mkdir()
477 self.assertTrue(subdir.exists())
478 self.assertTrue(subdir.isdir())
480 top.remove_dir(recursive=True)
481 self.assertFalse(top.exists())
483 def test_dav_to_fsspec(self):
484 if fsspec is None:
485 self.skipTest("fsspec not available")
487 # Upload a randomly-generated file via write() with overwrite.
488 local_file, file_size = self._generate_file()
489 with open(local_file, "rb") as f:
490 data = f.read()
492 remote_file = self.tmpdir.join(self._get_file_name())
493 self.assertIsNone(remote_file.write(data, overwrite=True))
494 self.assertTrue(remote_file.exists())
495 self.assertEqual(remote_file.size(), file_size)
497 # Ensure that the contents of the remote file we just
498 # uploaded is identical to the contents of that file when
499 # retrieved via fsspec.open(), with and without a context manager.
500 fsys, path = remote_file.to_fsspec()
501 file = fsys.open(path)
502 self.assertEqual(data, file.read())
503 file.close()
505 with fsys.open(path) as file:
506 self.assertEqual(data, file.read())
508 # Ensure the file system inherits from `fsspec.AbstractFileSystem`
509 # as parquet expects that.
510 self.assertTrue(isinstance(fsys, fsspec.AbstractFileSystem))
512 # Ensure properties of the remote file are consistent with those
513 # same properties retrieved via the file system.
514 self.assertTrue(path, remote_file.geturl())
515 self.assertTrue(fsys.exists(path))
516 self.assertTrue(fsys.isfile(path))
517 self.assertFalse(fsys.isdir(path))
518 self.assertEqual(fsys.size(path), remote_file.size())
519 self.assertEqual(remote_file._stat().last_modified, fsys.modified(path))
521 info = fsys.info(path)
522 self.assertTrue(info["name"], path)
523 self.assertTrue(info["type"], "file")
524 self.assertTrue(info["size"], remote_file.size())
526 # Ensure that the file system raises with methods attempting to
527 # modifying the file system.
528 with self.assertRaises(NotImplementedError):
529 fsys.rm(path)
531 # Ensure that the file system raises with methods not implemented.
532 with self.assertRaises(NotImplementedError):
533 fsys.fsid()
535 not_implemented_methods = [
536 fsys.mkdir,
537 fsys.makedirs,
538 fsys.rmdir,
539 fsys.walk,
540 fsys.find,
541 fsys.walk,
542 fsys.find,
543 fsys.du,
544 fsys.glob,
545 fsys.rm_file,
546 fsys.rm,
547 fsys.touch,
548 fsys.ukey,
549 fsys.created,
550 ]
551 for method in not_implemented_methods:
552 with self.assertRaises(NotImplementedError):
553 method(path="xxx")
555 # Ensure that the file system raises with methods with any path
556 # different from the only file returned by to_fsspec().
557 file_not_found_methods = [fsys.info, fsys.ls, fsys.modified, fsys.size]
558 for method in file_not_found_methods:
559 with self.assertRaises(FileNotFoundError):
560 method(path="xxx")
562 def test_dav_parquet_read(self):
563 # Check we can read a parquet file via to_fsspec()
565 if fsspec is None:
566 self.skipTest("fsspec not available")
568 try:
569 import numpy as np
570 import pyarrow as pa # type: ignore
571 import pyarrow.parquet as pq # type: ignore
573 # Create a local parquet file and upload it. Ensure it is bigger
574 # than the default buffer size so that it is not entirely
575 # cached when read by parquet library.
576 num_rows = 1_000_000
577 data = {
578 "one": np.arange(num_rows, dtype=np.int32),
579 "two": np.arange(num_rows, dtype=np.int64),
580 "three": np.arange(num_rows, dtype=np.float64),
581 }
582 table = pa.Table.from_pydict(data)
583 local_file = self._make_local_temp_file()
584 pq.write_table(table, local_file)
585 local_file_size = os.stat(local_file).st_size
587 remote_file = self.tmpdir.join("file.parquet")
588 with open(local_file, "rb") as file:
589 remote_file.write(file, overwrite=True)
591 self.assertTrue(remote_file.exists())
592 self.assertEqual(remote_file.size(), local_file_size)
594 # Read the remote file we just uploaded via parquet, using
595 # similar function as used by
596 # `lsst.daf.butler.formatters.ParquetFormatter`.
597 fsys, path = remote_file.to_fsspec()
598 schema = pq.read_schema(path, filesystem=fsys)
599 for column in data.keys():
600 self.assertTrue(column in schema.names)
602 table = pq.read_table(path, filesystem=fsys, use_threads=True, use_pandas_metadata=False)
603 for column in data.keys():
604 self.assertTrue(column in table.column_names)
605 self.assertEqual(table.num_rows, num_rows)
606 self.assertEqual(table.num_columns, len(data.keys()))
608 # Convert the parquet table to a Python dictionnary and compare
609 # its contents with the data originally used to create the
610 # parquet file.
611 data_from_parquet = table.to_pydict()
612 for column in data.keys():
613 self.assertTrue(np.array_equal(data[column], data_from_parquet[column]))
615 except ImportError:
616 self.skipTest("numpy or pyarrow are not available")
618 def test_dav_zip(self):
619 # Check we can read back a zip file
621 # Create a local zip file composed of a random number of identical
622 # files.
623 local_file, local_file_size = self._generate_file()
624 with open(local_file, "rb") as lf:
625 local_file_digest = self._compute_digest(lf.read())
627 num_members = random.randint(10, 20)
628 basename = os.path.basename(local_file)
629 member_names = [f"{basename}-{i}" for i in range(num_members)]
630 zip_file_name = self._make_local_temp_file()
631 with ZipFile(zip_file_name, mode="w") as zf:
632 for name in member_names:
633 zf.write(local_file, name)
635 # Upload the zip file to the server
636 with open(zip_file_name, mode="rb") as file:
637 remote_zip_file = self.tmpdir.join("example.zip")
638 self.assertIsNone(remote_zip_file.write(file, overwrite=True))
639 self.assertEqual(os.stat(zip_file_name).st_size, remote_zip_file.size())
641 # Read the zip file back and check its contents.
642 with remote_zip_file.open("rb") as fd:
643 # Check the names of member files match
644 zf = ZipFile(fd)
645 zip_members = [info.filename for info in zf.infolist()]
646 for name in member_names:
647 self.assertTrue(name in zip_members)
649 # Check the sizes of member files match
650 for file_size in [info.file_size for info in zf.infolist()]:
651 self.assertTrue(file_size, local_file_size)
653 # Check that the contents of a randomly-selected member file
654 # is identical to the original file.
655 random_member = random.choice(zf.infolist())
656 with zf.open(random_member) as member:
657 self.assertEqual(local_file_digest, self._compute_digest(member.read()))
659 # Concurrently read all the members of the remote zip file
660 def download_zip_member(zfile: ZipFile, zinfo: ZipInfo) -> tuple[int, str]:
661 # Download member of `zfile` designated by `zinfo` and return
662 # its size in bytes and a checksum of its contents.
663 with zfile.open(zinfo.filename) as member:
664 data = member.read()
665 return len(data), self._compute_digest(data)
667 with remote_zip_file.open("rb") as fd:
668 zfile = ZipFile(fd)
669 executor = concurrent.futures.ThreadPoolExecutor(max_workers=10)
670 futures = [executor.submit(download_zip_member, zfile, zinfo) for zinfo in zfile.infolist()]
672 # Gather results and check they match the expected values
673 for future in concurrent.futures.as_completed(futures):
674 member_size, member_digest = future.result()
675 self.assertEqual(member_size, local_file_size)
676 self.assertEqual(member_digest, local_file_digest)
678 def test_dav_info(self):
679 def check_metadata_fields(metadata: dict[str, Any]):
680 for field in ("name", "size", "type", "last_modified", "checksums"):
681 self.assertTrue(field in metadata)
683 # Retrieve and check metadata details about an non-existing object
684 subdir = self.tmpdir.join("inexistent", forceDirectory=True)
685 metadata = subdir.info()
686 check_metadata_fields(metadata)
687 self.assertEqual(metadata["size"], None)
688 self.assertEqual(metadata["type"], None)
689 self.assertEqual(len(metadata["checksums"]), 0)
690 self.assertEqual(metadata["last_modified"], datetime.min)
692 # Retrieve and check metadata details about an existing directory
693 subdir = self.tmpdir.join(self._get_dir_name(), forceDirectory=True)
694 self.assertIsNone(subdir.mkdir())
695 self.assertTrue(subdir.exists())
696 metadata = subdir.info()
697 check_metadata_fields(metadata)
699 self.assertEqual(metadata["size"], 0)
700 self.assertEqual(metadata["type"], "directory")
701 self.assertEqual(len(metadata["checksums"]), 0)
702 self.assertEqual(metadata["last_modified"], subdir._stat().last_modified)
704 # Retrieve and check metadata details about existing file
705 local_file, local_file_size = self._generate_file()
706 with open(local_file, "rb") as file:
707 content = file.read()
708 md5_checksum = self._compute_digest(content, algorithm="md5")
709 adler32_checksum = self._compute_digest(content, algorithm="adler32")
711 remote_file = self.tmpdir.join("example.data")
712 with open(local_file, mode="rb") as file:
713 self.assertIsNone(remote_file.write(file, overwrite=True))
714 self.assertEqual(os.stat(local_file).st_size, remote_file.size())
716 metadata = remote_file.info()
717 check_metadata_fields(metadata)
718 self.assertEqual(metadata["size"], local_file_size)
719 self.assertEqual(metadata["type"], "file")
720 self.assertEqual(metadata["last_modified"], remote_file._stat().last_modified)
722 checksums = metadata["checksums"]
723 if "md5" in checksums:
724 self.assertEqual(checksums["md5"], md5_checksum)
725 if "adler32" in checksums:
726 self.assertEqual(checksums["adler32"], adler32_checksum)
728 @classmethod
729 def _get_port_number(cls) -> int:
730 """Return a port number the webDAV server can use to listen to."""
731 s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
732 s.bind(("127.0.0.1", 0))
733 s.listen()
734 port = s.getsockname()[1]
735 s.close()
736 return port
738 def _serve_webdav(self, local_path: str, port: int, stop_webdav_server: Callable[[], bool]):
739 """Start a local webDAV server, listening on http://localhost:port
740 and exposing local_path.
742 This server only runs when this test class is instantiated,
743 and then shuts down. The server must be started is a separate thread.
745 Parameters
746 ----------
747 port : `int`
748 The port number on which the server should listen
749 local_path : `str`
750 Path to an existing local directory for the server to expose.
751 stop_webdav_server : `Callable[[], bool]`
752 Boolean function which returns True when the server should be
753 stopped.
754 """
755 try:
756 # Start the wsgi server in a separate thread
757 config = {
758 "host": "127.0.0.1",
759 "port": port,
760 "provider_mapping": {"/": local_path},
761 "http_authenticator": {"domain_controller": None},
762 "simple_dc": {"user_mapping": {"*": True}},
763 "verbose": 0,
764 "lock_storage": False,
765 "dir_browser": {
766 "enable": False,
767 "ms_sharepoint_support": False,
768 "libre_office_support": False,
769 "response_trailer": False,
770 "davmount_links": False,
771 },
772 }
773 server = wsgi.Server(wsgi_app=WsgiDAVApp(config), bind_addr=(config["host"], config["port"]))
774 t = Thread(target=server.start, daemon=True)
775 t.start()
777 # Shut down the server when done: stop_webdav_server() returns
778 # True when this test suite is being teared down
779 while not stop_webdav_server():
780 time.sleep(1)
781 except KeyboardInterrupt:
782 # Caught Ctrl-C, shut down the server
783 pass
784 finally:
785 server.stop()
786 t.join()
788 @classmethod
789 def _get_name(cls, prefix: str) -> str:
790 alphabet = string.ascii_lowercase + string.digits
791 return f"{prefix}-" + "".join(random.choices(alphabet, k=8))
793 @classmethod
794 def _get_dir_name(cls) -> str:
795 """Return a randomly selected name for a file"""
796 return cls._get_name(prefix="dir")
798 @classmethod
799 def _get_file_name(cls) -> str:
800 """Return a randomly selected name for a file"""
801 return cls._get_name(prefix="file")
803 def _generate_file(self, remove_when_done=True) -> tuple[str, int]:
804 """Create a local file of random size with random contents.
806 Returns
807 -------
808 path : `str`
809 Path to local temporary file. The caller is responsible for
810 removing the file when appropriate.
811 size : `int`
812 Size of the generated file, in bytes.
813 """
814 megabyte = 1024 * 1024
815 size = random.randint(2 * megabyte, 5 * megabyte)
816 tmpfile, path = tempfile.mkstemp()
817 self.assertEqual(os.write(tmpfile, os.urandom(size)), size)
818 os.close(tmpfile)
820 if remove_when_done:
821 DavReadWriteTestCase.local_files_to_remove.append(path)
823 return path, size
825 def _make_local_temp_file(self, remove_when_done=True) -> str:
826 """Create an empty local temporary file.
828 Returns
829 -------
830 path : `str`
831 Path to local temporary file. The caller is responsible for
832 removing the file when appropriate.
833 """
834 tmpfile, path = tempfile.mkstemp()
835 os.close(tmpfile)
837 if remove_when_done:
838 DavReadWriteTestCase.local_files_to_remove.append(path)
840 return path
842 @classmethod
843 def _compute_digest(cls, data: bytes, algorithm: str = "sha256") -> str:
844 """Compute a hash of data."""
845 match algorithm:
846 case "sha256" | "md5":
847 m = hashlib.new(algorithm)
848 m.update(data)
849 return m.hexdigest().lower()
850 case "adler32":
851 return f"{zlib.adler32(data):08x}"
852 case _:
853 raise ValueError(f"unsupported checksum algorithm {algorithm}")
855 @classmethod
856 def _is_server_running(cls, port: int) -> bool:
857 """Return True if there is a server listening on local address
858 127.0.0.1:<port>.
859 """
860 with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
861 try:
862 s.connect(("127.0.0.1", port))
863 return True
864 except ConnectionRefusedError:
865 return False
868class DavResourcePathConfigTestCase(unittest.TestCase):
869 """Test for the DavResourcePathConfig class."""
871 def setUp(self):
872 # Prepare temporary directory
873 self.tmpdir = ResourcePath(makeTestTempDir(TESTDIR))
875 # Reinitialize globals.
876 dav_globals._reset()
878 def tearDown(self):
879 # Clean up temporary directory
880 if self.tmpdir and self.tmpdir.isLocal:
881 removeTestTempDir(self.tmpdir.ospath)
883 # Reinitialize globals.
884 dav_globals._reset()
886 def test_dav_tmpdir_buffersize_default(self):
887 # Ensure that the configuration is initialized with the temporary
888 # directory extracted from the environment.
889 config: DavResourcePathConfig = dav_globals.config()
890 tmpdir, _ = config.tmpdir_buffersize
891 self.assertEqual(tmpdir, get_tempdir())
894class DavConfigPoolTestCase(unittest.TestCase):
895 """Test for the DavConfig class."""
897 @classmethod
898 def setUpClass(cls):
899 cls.tmpdir = tempfile.mkdtemp(prefix="webdav-config-test-")
901 @classmethod
902 def tearDownClass(cls):
903 if cls.tmpdir:
904 shutil.rmtree(cls.tmpdir, ignore_errors=True)
906 def tearDown(self):
907 if self.tmpdir:
908 shutil.rmtree(self.tmpdir)
910 def setUp(self):
911 self.tmpdir = makeTestTempDir(TESTDIR)
912 # Reinitialize globals.
913 dav_globals._reset()
915 def test_dav_default_config(self):
916 """Ensure default configuration is used by default."""
917 # Ensure the variable LSST_RESOURCES_WEBDAV_CONFIG is not initialized
918 # so that we use the default configuration.
919 with unittest.mock.patch.dict(os.environ, {}, clear=True):
920 config_pool: DavConfigPool = DavConfigPool()
921 config = config_pool.get_config_for_url("davs://example.org")
922 self.assertEqual(config.retries, DavConfig.DEFAULT_RETRIES)
923 self.assertEqual(config.timeout_connect, DavConfig.DEFAULT_TIMEOUT_CONNECT)
924 self.assertEqual(config.timeout_read, DavConfig.DEFAULT_TIMEOUT_READ)
925 self.assertEqual(config.token, DavConfig.DEFAULT_TOKEN)
926 self.assertEqual(
927 config.persistent_connections_frontend, DavConfig.DEFAULT_PERSISTENT_CONNECTIONS_FRONTEND
928 )
929 self.assertEqual(
930 config.persistent_connections_backend, DavConfig.DEFAULT_PERSISTENT_CONNECTIONS_BACKEND
931 )
933 def test_dav_configuration_file_does_not_exist(self):
934 # Ensure an exception is raised if the configuration file pointed to
935 # by the environment variable does not exist.
936 with unittest.mock.patch.dict(os.environ, {"MY_VAR": "/does/not/exist"}, clear=True):
937 with self.assertRaises(FileNotFoundError):
938 DavConfigPool("MY_VAR")
940 def test_dav_configuration_file(self):
941 """Ensure the specified configuration file is used."""
942 config_contents: str = r"""
943- base_url: "davs://host1.example.org:1234/"
944 persistent_connections_frontend: 10
945 persistent_connections_backend: 100
946 timeout_connect: 20.0
947 timeout_read: 120.0
948 retries: 3
949 retry_backoff_min: 1.0
950 retry_backoff_max: 3.0
951 user_cert: "${X509_USER_PROXY}"
952 user_key: "${X509_USER_PROXY}"
953 trusted_authorities: "/etc/grid-security/certificates"
954 buffer_size: 5
955 enable_fsspec: false
956 collect_memory_usage: false
957 request_checksum: "md5"
958- base_url: "davs://host2.example.org:4321/"
959 persistent_connections_frontend: 1
960 persistent_connections_backend: 2
961- base_url: "davs://host3.example.org:4321/"
962 token: "ABCDEF"
963"""
965 config_file = self._create_config(config_contents)
966 with unittest.mock.patch.dict(os.environ, {"LSST_RESOURCES_WEBDAV_CONFIG": config_file}, clear=True):
967 config_pool: DavConfigPool = DavConfigPool("LSST_RESOURCES_WEBDAV_CONFIG")
969 # Tests for base URL 'davs://host1.example.org:1234'
970 config = config_pool.get_config_for_url("davs://host1.example.org:1234/any/path")
971 self.assertEqual(config.base_url, "https://host1.example.org:1234/")
972 self.assertEqual(config.retries, 3)
973 self.assertEqual(config.token, DavConfig.DEFAULT_TOKEN)
974 self.assertEqual(config.user_cert, "${X509_USER_PROXY}")
975 self.assertEqual(config.trusted_authorities, "/etc/grid-security/certificates")
976 self.assertFalse(config.enable_fsspec)
977 self.assertFalse(config.collect_memory_usage)
978 self.assertEqual(config.request_checksum, "md5")
980 # Tests for base URL 'davs://host2.example.org:4321/'
981 config = config_pool.get_config_for_url("davs://host2.example.org:4321")
982 self.assertEqual(config.base_url, "https://host2.example.org:4321/")
983 self.assertEqual(config.persistent_connections_frontend, 1)
984 self.assertEqual(config.persistent_connections_backend, 2)
986 # Tests for base URL 'davs://host3.example.org:4321/'
987 config = config_pool.get_config_for_url("davs://host3.example.org:4321")
988 self.assertEqual(config.base_url, "https://host3.example.org:4321/")
989 self.assertEqual(config.token, "ABCDEF")
990 self.assertEqual(config.retries, DavConfig.DEFAULT_RETRIES)
992 def test_dav_repeated_configurations(self):
993 """Ensure duplicated endpoint errors are detected in configuration
994 file.
995 """
996 config_contents: str = r"""
997- base_url: "davs://host1.example.org:1234/"
998- base_url: "davs://host1.example.org:1234/"
999"""
1000 config_file = self._create_config(config_contents)
1001 with unittest.mock.patch.dict(os.environ, {"MY_VAR": config_file}, clear=True):
1002 with self.assertRaises(ValueError):
1003 DavConfigPool("MY_VAR")
1005 def _create_config(self, config: str) -> str:
1006 with tempfile.NamedTemporaryFile(mode="wt", dir=self.tmpdir, delete=False) as f:
1007 f.write(config)
1008 return f.name
1011class DavTokenAuthorizerTestCase(unittest.TestCase):
1012 """Test for the TokenAuthorizer class."""
1014 def setUp(self):
1015 self.tmpdir = ResourcePath(makeTestTempDir(TESTDIR))
1016 self.token = "ABCDE1234"
1018 def tearDown(self):
1019 if self.tmpdir and self.tmpdir.isLocal:
1020 removeTestTempDir(self.tmpdir.ospath)
1022 def test_dav_empty_token(self):
1023 """Ensure that when no token is provided the headers are not
1024 modified.
1025 """
1026 authorizer = TokenAuthorizer()
1027 headers = {}
1028 authorizer.set_authorization(headers)
1029 self.assertIsNone(headers.get("Authorization"))
1031 def test_dav_token_value(self):
1032 """Ensure that when a token value is provided, the 'Authorization'
1033 header is added to the requests.
1034 """
1035 authorizer = TokenAuthorizer(self.token)
1036 headers = {}
1037 authorizer.set_authorization(headers)
1038 self.assertEqual(headers.get("Authorization"), f"Bearer {self.token}")
1040 def test_dav_token_file(self):
1041 """Ensure when the provided token is a file path, its contents is
1042 correctly added as calue of the 'Authorization' header.
1043 """
1044 with tempfile.NamedTemporaryFile(mode="wt", dir=self.tmpdir.ospath, delete=False) as f:
1045 f.write(self.token)
1046 token_file_path = f.name
1048 # Ensure the request's "Authorization" header is set with the right
1049 # token value
1050 os.chmod(token_file_path, stat.S_IRUSR)
1051 authorizer = TokenAuthorizer(token_file_path)
1052 headers = {}
1053 authorizer.set_authorization(headers)
1054 self.assertEqual(headers.get("Authorization"), f"Bearer {self.token}")
1056 # Ensure an exception is raised if either group or other can read the
1057 # token file
1058 for mode in (stat.S_IRGRP, stat.S_IWGRP, stat.S_IXGRP, stat.S_IROTH, stat.S_IWOTH, stat.S_IXOTH):
1059 os.chmod(token_file_path, stat.S_IRUSR | mode)
1060 with self.assertRaises(PermissionError):
1061 TokenAuthorizer(token_file_path)
1064if __name__ == "__main__":
1065 unittest.main()