Coverage for python/lsst/resources/dav.py: 35%
368 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-13 09:52 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-13 09:52 +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__ = ("DavResourcePath",)
16import contextlib
17import datetime
18import functools
19import io
20import logging
21import os
22import re
23import threading
24import urllib
25from collections.abc import Iterator
26from typing import TYPE_CHECKING, Any, BinaryIO, cast
28try:
29 from typing import override # Python 3.12+
30except ImportError:
31 from typing_extensions import override # Python 3.11
33try:
34 import fsspec
35 from fsspec.spec import AbstractFileSystem
36except ImportError:
37 fsspec = None
38 AbstractFileSystem = type
40from ._resourceHandles import ResourceHandleProtocol
41from ._resourceHandles._davResourceHandle import DavReadResourceHandle
42from ._resourcePath import ResourceInfo, ResourcePath, ResourcePathExpression
43from .davutils import (
44 DavClient,
45 DavClientPool,
46 DavConfigPool,
47 DavFileMetadata,
48 normalize_path,
49 normalize_url,
50)
51from .utils import get_tempdir
53if TYPE_CHECKING:
54 from .utils import TransactionProtocol
56from lsst.utils.logging import getLogger
58log = getLogger(__name__)
61@functools.lru_cache
62def _calc_tmpdir_buffer_size(tmpdir: str) -> int:
63 """Compute the block size to use for writing files in `tmpdir` as
64 256 blocks of typical size (i.e. 4096 bytes) or 10 times the file system
65 block size, whichever is higher.
67 This is a reasonable compromise between using memory for buffering and
68 the number of system calls issued to read from or write to temporary
69 files.
70 """
71 fsstats = os.statvfs(tmpdir)
72 return max(10 * fsstats.f_bsize, 256 * 4096)
75class DavResourcePathConfig:
76 """Configuration class to encapsulate the configurable items used by
77 all instances of class `DavResourcePath`.
79 Instantiating this class creates a thread-safe singleton.
80 """
82 _instance = None
83 _lock = threading.Lock()
85 def __new__(cls) -> DavResourcePathConfig:
86 if cls._instance is None: 86 ↛ 91line 86 didn't jump to line 91 because the condition on line 86 was always true
87 with cls._lock:
88 if cls._instance is None: 88 ↛ 91line 88 didn't jump to line 91
89 cls._instance = super().__new__(cls)
91 return cls._instance
93 def __init__(self) -> None:
94 # Path to the local temporary directory all instances of
95 # `DavResourcePath` must use and its associated buffer size (in bytes).
96 self._tmpdir_buffersize: tuple[str, int] | None = None
98 @property
99 def tmpdir_buffersize(self) -> tuple[str, int]:
100 """Return the path to a temporary directory and the preferred buffer
101 size to use when reading/writing files from/to that directory.
102 """
103 if self._tmpdir_buffersize is not None: 103 ↛ 104line 103 didn't jump to line 104 because the condition on line 103 was never true
104 return self._tmpdir_buffersize
106 # Retrieve and cache the path and the blocksize for the temporary
107 # directory if no other thread has done that in the meantime.
108 with DavResourcePathConfig._lock:
109 if self._tmpdir_buffersize is None: 109 ↛ 114line 109 didn't jump to line 114
110 tmpdir = get_tempdir()
111 bufsize = _calc_tmpdir_buffer_size(tmpdir)
112 self._tmpdir_buffersize = (tmpdir, bufsize)
114 return self._tmpdir_buffersize
116 def _destroy(self) -> None:
117 """Destroy this class singleton instance.
119 Helper method to be used in tests to reset global configuration.
120 """
121 with DavResourcePathConfig._lock:
122 DavResourcePathConfig._instance = None
125class DavGlobals:
126 """Helper container to encapsulate all the gloal objects needed by this
127 module.
128 """
130 def __init__(self) -> None:
131 # Client pool used by all DavResourcePath instances.
132 # Use Any as type annotation to keep mypy happy.
133 self._client_pool: Any = None
135 # Configuration used by all DavResourcePath instances.
136 self._config: Any = None
138 # (Re)Initialize the objects above.
139 self._reset()
141 def _reset(self) -> None:
142 """Initialize all the globals.
144 This method is a helper for reinitializing globals in tests.
145 """
146 # Initialize the singleton instance of the webdav endpoint
147 # configuration pool.
148 config_pool: DavConfigPool = DavConfigPool("LSST_RESOURCES_WEBDAV_CONFIG")
150 # Initialize the singleton instance of the webdav client pool. This is
151 # a thread-safe singleton shared by all instances of DavResourcePath.
152 if self._client_pool is not None:
153 self._client_pool._destroy()
155 self._client_pool = DavClientPool(config_pool)
157 # Initialize the singleton instance of the configuration shared
158 # all DavResourcePath objects.
159 if self._config is not None:
160 self._config._destroy()
162 self._config = DavResourcePathConfig()
164 def client_pool(self) -> DavClientPool:
165 """Return the pool of reusable webDAV clients."""
166 return self._client_pool
168 def config(self) -> DavResourcePathConfig:
169 """Return the configuration settings for all `DavResourcePath`
170 objects.
171 """
172 return self._config
175# Convenience object to encapsulate all global objects needed by this module.
176dav_globals: DavGlobals = DavGlobals()
179class DavResourcePath(ResourcePath):
180 """WebDAV resource.
182 Parameters
183 ----------
184 uri : `ResourcePathExpression`
185 URI to store in object.
186 root : `str` or `ResourcePath` or `None`, optional
187 Root for relative URIs. Not used in this constructor.
188 forceAbsolute : `bool`
189 Whether to force absolute URI. A WebDAV URI is always absolute.
190 forceDirectory : `bool` or `None`, optional
191 Whether this URI represents a directory.
192 isTemporary : `bool` or `None`, optional
193 Whether this URI represents a temporary resource.
194 """
196 def __init__(
197 self,
198 uri: ResourcePathExpression,
199 root: str | ResourcePath | None = None,
200 forceAbsolute: bool = True,
201 forceDirectory: bool | None = None,
202 isTemporary: bool | None = None,
203 ) -> None:
204 # Build the internal URL we use to talk to the server, which
205 # uses "http" or "https" as scheme instead of "dav" or "davs".
206 self._internal_url: str = normalize_url(self.geturl())
208 # WebDAV client this path must use to interact with the server.
209 self._dav_client: DavClient | None = None
211 # Retrieve the configuration shared by all instances of this class.
212 self._config: DavResourcePathConfig = dav_globals.config()
214 log.debug("created instance of DavResourcePath %s [%#x]", self, id(self))
216 @classmethod
217 def _fixupPathUri(
218 cls,
219 parsed: urllib.parse.ParseResult,
220 root: ResourcePath | None = None,
221 forceAbsolute: bool = False,
222 forceDirectory: bool | None = None,
223 ) -> tuple[urllib.parse.ParseResult, bool | None]:
224 """Correct any issues with the supplied URI.
226 This function ensures that the path of the URI is normalized.
227 """
228 # Call the superclass' _fixupPathUri.
229 parsed, dirLike = super()._fixupPathUri(parsed, forceDirectory=forceDirectory)
231 # Clean the URL's path and ensure dir-like paths end by "/".
232 path = normalize_path(parsed.path)
233 if dirLike and path != "/":
234 path += "/"
236 return parsed._replace(path=path), dirLike
238 @property
239 def _client(self) -> DavClient:
240 """Return the webDAV client for this resource."""
241 # If we already have a client, use it.
242 if self._dav_client is not None:
243 return self._dav_client
245 # Retrieve the client this resource must use to interact with the
246 # server from the global client pool.
247 self._dav_client = dav_globals.client_pool().get_client_for_url(self._internal_url)
248 return self._dav_client
250 def _stat(self) -> DavFileMetadata:
251 """Retrieve metadata about this resource."""
252 return self._client.stat(self._internal_url)
254 @override
255 def mkdir(self) -> None:
256 """Create the directory resource if it does not already exist."""
257 log.debug("mkdir %s [%#x]", self, id(self))
259 if not self.isdir():
260 raise NotADirectoryError(f"Can not create a directory for file-like URI {self}")
262 stat = self._stat()
263 if stat.is_dir:
264 return
266 if stat.is_file:
267 # A file exists at this path.
268 raise NotADirectoryError(
269 f"Can not create a directory for {self} because a file already exists at that URL"
270 )
272 # The underlying webDAV client will use the knowledge it has about
273 # the specific server to create the requested directory
274 # hierarchy by issueing the minimum possible number of requests.
275 self._client.mkcol(self._internal_url)
277 @override
278 def exists(self) -> bool:
279 """Check that this resource exists."""
280 log.debug("exists %s [%#x]", self, id(self))
282 return self._stat().exists
284 @override
285 def size(self) -> int:
286 """Return the size of the remote resource in bytes."""
287 log.debug("size %s [%#x]", self, id(self))
289 return 0 if self.isdir() else self._client.size(self._internal_url)
291 @override
292 def get_info(self) -> ResourceInfo:
293 """Return lightweight metadata details about this resource."""
294 log.debug("get_info %s [%#x]", self, id(self))
296 info = self._client.info(self._internal_url)
297 if info["type"] is None:
298 raise FileNotFoundError(f"Resource {self} does not exist")
300 return ResourceInfo(
301 uri=str(self),
302 is_file=info["type"] == "file",
303 size=info["size"],
304 last_modified=info["last_modified"],
305 checksums=info["checksums"],
306 )
308 @override
309 def read(self, size: int = -1) -> bytes:
310 """Open the resource and return the contents in bytes.
312 Parameters
313 ----------
314 size : `int`, optional
315 The number of bytes to read. Negative or omitted indicates that
316 all data should be read.
317 """
318 log.debug("read %s [%#x] size=%d", self, id(self), size)
320 # A GET request on a dCache directory returns the contents of the
321 # directory in HTML, to be visualized with a browser. This means
322 # that we need to check first that this resource is not a directory.
323 #
324 # Since isdir() only checks that the URL of the resource ends in "/"
325 # without actually asking the server, this check is not robust.
326 # However, it is a reasonable compromise since it prevents doing
327 # an additional roundtrip to the server to retrieve this resource's
328 # metadata.
329 if self.isdir():
330 raise ValueError(f"method read() is not implemented for directory {self}")
332 if size < 0:
333 # Read the entire file content
334 _, data = self._client.read(self._internal_url)
335 return data
337 # This is a partial read. Retrieve the file size.
338 stat = self._stat()
339 if not stat.is_file:
340 raise FileNotFoundError(f"No file found at {self}")
342 if size == 0 or stat.size == 0:
343 return b""
345 # Read the requested chunk of data and release the backend server.
346 end_range = min(stat.size, size) - 1
347 url, data = self._client.read_range(self._internal_url, start=0, end=end_range)
348 self._client._close(url=url)
349 return data
351 @override
352 @contextlib.contextmanager
353 def _as_local(
354 self, multithreaded: bool = True, tmpdir: ResourcePath | None = None
355 ) -> Iterator[ResourcePath]:
356 """Download object and place in temporary directory.
358 Parameters
359 ----------
360 multithreaded : `bool`, optional
361 If `True` the transfer will be allowed to attempt to improve
362 throughput by using parallel download streams. This may of no
363 effect if the URI scheme does not support parallel streams or
364 if a global override has been applied. If `False` parallel
365 streams will be disabled.
366 tmpdir : `ResourcePath` or `None`, optional
367 Explicit override of the temporary directory to use for remote
368 downloads.
370 Returns
371 -------
372 local_uri : `ResourcePath`
373 A URI to a local POSIX file corresponding to a local temporary
374 downloaded copy of the resource.
375 """
376 log.debug("_as_local %s [%#x] tmpdir: %s", self, id(self), tmpdir)
378 # We need to ensure that this resource is actually a file since
379 # the response to a GET request on a directory may be implemented in
380 # several ways, according to RFC 4818.
381 if self.isdir():
382 raise FileNotFoundError(f"{self} is a directory")
384 if tmpdir is None:
385 local_dir, buffer_size = self._config.tmpdir_buffersize
386 tmpdir = ResourcePath(local_dir, forceDirectory=True)
387 else:
388 buffer_size = _calc_tmpdir_buffer_size(tmpdir.ospath)
390 with ResourcePath.temporary_uri(suffix=self.getExtension(), prefix=tmpdir, delete=True) as tmp_uri:
391 log.debug(
392 "downloading %s [%#x] to local file %s [buffer_size %d]",
393 self,
394 id(self),
395 tmp_uri.ospath,
396 buffer_size,
397 )
398 self._client.download(self._internal_url, tmp_uri.ospath, buffer_size)
399 yield tmp_uri
401 @override
402 def write(self, data: BinaryIO | bytes, overwrite: bool = True) -> None:
403 """Write the supplied bytes to the new resource.
405 Parameters
406 ----------
407 data : `bytes`
408 The bytes to write to the resource. The entire contents of the
409 resource will be replaced.
410 overwrite : `bool`, optional
411 If `True` the resource will be overwritten if it exists. Otherwise
412 the write will fail.
413 """
414 log.debug("write %s [%#x] overwrite=%s", self, id(self), overwrite)
416 if self.isdir():
417 raise ValueError(f"Method write() is not implemented for directory {self}")
419 if not overwrite and self._stat().is_file:
420 raise FileExistsError(f"File {self} exists and overwrite has been disabled")
422 self._client.write(self._internal_url, data)
424 @override
425 def remove(self) -> None:
426 """Remove the resource.
428 If the resource is a directory, it must be empty otherwise this
429 method raises. Removing a non-existent file or directory is not
430 considered an error.
431 """
432 log.debug("remove %s [%#x]", self, id(self))
434 stat = self._stat()
435 if not stat.exists:
436 # There is no resource at this uri. There is nothing to do.
437 return
439 if stat.is_dir:
440 entries = self._client.read_dir(self._internal_url)
441 if len(entries) > 0:
442 raise IsADirectoryError(f"Directory {self} is not empty")
444 # This resource is a either file or an empty directory, we can remove
445 # it.
446 self._client.delete(self._internal_url)
448 def remove_dir(self, recursive: bool = False) -> None:
449 """Remove a directory if empty.
451 Parameters
452 ----------
453 recursive : `bool`
454 If `True` recursively remove all files and directories under this
455 directory.
457 Notes
458 -----
459 This method is not present in the superclass.
460 """
461 log.debug("remove_dir %s [%#x] recursive=%s", self, id(self), recursive)
463 if not self.isdir():
464 raise NotADirectoryError(f"{self} is not a directory")
466 for root, subdirs, files in self.walk():
467 if not recursive and (len(subdirs) > 0 or len(files) > 0):
468 raise IsADirectoryError(f"Directory at {self} is not empty and recursive argument is False")
470 for file in files:
471 root.join(file).remove()
473 for subdir in subdirs:
474 DavResourcePath(root.join(subdir, forceDirectory=True)).remove_dir(recursive=recursive)
476 # Remove empty top directory
477 self.remove()
479 @override
480 def transfer_from(
481 self,
482 src: ResourcePath,
483 transfer: str = "copy",
484 overwrite: bool = False,
485 transaction: TransactionProtocol | None = None,
486 multithreaded: bool = True,
487 ) -> None:
488 """Transfer to this URI from another.
490 Parameters
491 ----------
492 src : `ResourcePath`
493 Source URI.
494 transfer : `str`
495 Mode to use for transferring the resource. Generically there are
496 many standard options: copy, link, symlink, hardlink, relsymlink.
497 Not all URIs support all modes.
498 overwrite : `bool`, optional
499 Allow an existing file to be overwritten. Defaults to `False`.
500 transaction : `~lsst.resources.utils.TransactionProtocol`, optional
501 A transaction object that can (depending on implementation)
502 rollback transfers on error. Not guaranteed to be implemented.
503 multithreaded : `bool`, optional
504 If `True` the transfer will be allowed to attempt to improve
505 throughput by using parallel download streams. This may of no
506 effect if the URI scheme does not support parallel streams or
507 if a global override has been applied. If `False` parallel
508 streams will be disabled.
509 """
510 log.debug(
511 "transfer_from %s [%#x] src=%s transfer=%s overwrite=%s",
512 self,
513 id(self),
514 src,
515 transfer,
516 overwrite,
517 )
519 # Fail early to prevent delays if remote resources are requested.
520 if transfer not in self.transferModes:
521 raise ValueError(f"Transfer mode {transfer} not supported by URI scheme {self.scheme}")
523 # Existence checks cost time so do not call this unless we know
524 # that debugging is enabled.
525 destination_exists = None
526 if log.isEnabledFor(logging.DEBUG):
527 destination_exists = self.exists()
528 log.debug(
529 "Transferring %s [exists: %s] -> %s [exists: %s] (transfer=%s)",
530 src.geturl(),
531 src.exists(),
532 self,
533 destination_exists,
534 transfer,
535 )
537 # Short circuit immediately if the URIs are identical.
538 if self == src:
539 log.debug(
540 "Target and destination URIs are identical: %s, returning immediately."
541 " No further action required.",
542 self,
543 )
544 return
546 if not overwrite:
547 if destination_exists is None:
548 destination_exists = self.exists()
550 if destination_exists:
551 raise FileExistsError(f"Destination path {self} already exists.")
553 if transfer == "auto":
554 transfer = self.transferDefault
556 # We can use webDAV 'COPY' or 'MOVE' if both the current and source
557 # resources are located in the same server.
558 if isinstance(src, type(self)) and self.root_uri() == src.root_uri():
559 log.debug("Transfer from %s to %s [%#x] directly", src, self, id(self))
560 return (
561 self._move_from(src, overwrite=overwrite)
562 if transfer == "move"
563 else self._copy_from(src, overwrite=overwrite)
564 )
566 # For resources of different classes we can perform the copy or move
567 # operation by downloading to a local file and uploading to the
568 # destination.
569 self._copy_via_local(src)
571 # This was an explicit move, try to remove the source.
572 if transfer == "move":
573 src.remove()
575 def _copy_via_local(self, source: ResourcePath) -> None:
576 """Replace the contents of this resource with the contents of a remote
577 resource by using a local temporary file.
579 Parameters
580 ----------
581 source : `ResourcePath`
582 The source of the contents to copy to `self`.
583 """
584 with source.as_local() as local_uri:
585 log.debug(
586 "Transfer from %s to %s [%#x] via local file %s",
587 source.geturl(),
588 self,
589 id(self),
590 local_uri,
591 )
592 with open(local_uri.ospath, "rb") as f:
593 self.write(data=f)
595 def _copy_from(self, source: DavResourcePath, overwrite: bool = False) -> None:
596 """Copy the contents of `source` to this resource. `source` must
597 be a file.
598 """
599 log.debug("_copy_from %s [%#x] source=%s overwrite=%s", self, id(self), source, overwrite)
601 # Copy is only supported for files, not directories.
602 if self.isdir():
603 raise ValueError(f"Copy is not supported because destination {self} is a directory")
605 if source.isdir():
606 raise ValueError(f"Copy is not supported for directory {source}")
608 if not source.exists():
609 raise FileNotFoundError(f"No file found at {source}")
611 # If the server supports file duplication, use that method.
612 if self._client.supports_duplicate:
613 return self._client.duplicate(source._internal_url, self._internal_url, overwrite)
615 # Make this copy via a local file
616 if not overwrite and self.exists():
617 raise FileExistsError(f"Destination path {self} already exists.")
619 self._copy_via_local(source)
621 def _move_from(self, source: DavResourcePath, overwrite: bool = False) -> None:
622 """Send a MOVE webDAV request to replace the contents of this resource
623 with the contents of another resource located in the same server.
625 Parameters
626 ----------
627 source : `DavResourcePath`
628 The source of the contents to move to `self`.
629 """
630 log.debug("_move_from %s [%#x] source=%s overwrite=%s", self, id(self), source, overwrite)
632 # Move is only supported for files, not directories.
633 if self.isdir():
634 raise ValueError(f"Move is not supported for destination directory {self}")
636 if source.isdir():
637 raise ValueError(f"Move is not supported for directory {source}")
639 if not source.exists():
640 raise FileNotFoundError(f"No file found at {source}")
642 self._client.rename(source._internal_url, self._internal_url, overwrite)
644 @override
645 def walk(
646 self, file_filter: str | re.Pattern | None = None
647 ) -> Iterator[list | tuple[ResourcePath, list[str], list[str]]]:
648 """Walk the directory tree returning matching files and directories.
650 Parameters
651 ----------
652 file_filter : `str` or `re.Pattern`, optional
653 Regex to filter out files from the list before it is returned.
655 Yields
656 ------
657 dirpath : `ResourcePath`
658 Current directory being examined.
659 dirnames : `list` of `str`
660 Names of subdirectories within dirpath.
661 filenames : `list` of `str`
662 Names of all the files within dirpath.
663 """
664 if not self.isdir():
665 raise ValueError(f"Can not walk non-directory URI {self}")
667 # We must return no entries for non-existent directories.
668 if not self.exists():
669 return
671 # Retrieve the entries in this directory
672 entries = self._client.read_dir(self._internal_url)
673 files = [e.name for e in entries if e.is_file]
674 subdirs = [e.name for e in entries if e.is_dir]
676 # Filter files
677 if isinstance(file_filter, str):
678 file_filter = re.compile(file_filter)
680 if file_filter is not None:
681 files = [f for f in files if file_filter.search(f)]
683 if not subdirs and not files:
684 return
685 else:
686 yield type(self)(self, forceAbsolute=False, forceDirectory=True), subdirs, files
688 for subdir in subdirs:
689 new_uri = self.join(subdir, forceDirectory=True)
690 yield from new_uri.walk(file_filter)
692 @override
693 def generate_presigned_get_url(self, *, expiration_time_seconds: int) -> str:
694 """Return a pre-signed URL that can be used to retrieve this resource
695 using an HTTP GET without supplying any access credentials.
697 Parameters
698 ----------
699 expiration_time_seconds : `int`
700 Number of seconds until the generated URL is no longer valid.
702 Returns
703 -------
704 url : `str`
705 HTTP URL signed for GET.
706 """
707 return self._client.generate_presigned_get_url(self._internal_url, expiration_time_seconds)
709 @override
710 def generate_presigned_put_url(self, *, expiration_time_seconds: int) -> str:
711 """Return a pre-signed URL that can be used to upload a file to this
712 path using an HTTP PUT without supplying any access credentials.
714 Parameters
715 ----------
716 expiration_time_seconds : `int`
717 Number of seconds until the generated URL is no longer valid.
719 Returns
720 -------
721 url : `str`
722 HTTP URL signed for PUT.
723 """
724 return self._client.generate_presigned_put_url(self._internal_url, expiration_time_seconds)
726 @override
727 def to_fsspec(self) -> tuple[DavFileSystem, str]:
728 """Return an abstract file system and path that can be used by fsspec.
730 Returns
731 -------
732 fs : `fsspec.spec.AbstractFileSystem`
733 A file system object suitable for use with the returned path.
734 path : `str`
735 A path that can be opened by the file system object.
736 """
737 if fsspec is None or not self._client._config.enable_fsspec:
738 raise ImportError("fsspec is not available")
740 log.debug("DavResourcePath.to_fsspec: %s", self)
741 fsys = DavFileSystem(self)
742 return fsys, fsys._path
744 @override
745 @contextlib.contextmanager
746 def _openImpl(
747 self,
748 mode: str = "r",
749 *,
750 encoding: str | None = None,
751 ) -> Iterator[ResourceHandleProtocol]:
752 log.debug("DavResourcePath._openImpl: %s mode: %s", self, mode)
754 if mode in ("rb", "r") and self._client.accepts_ranges(self._internal_url):
755 stat = self._stat()
756 if stat.is_dir:
757 raise OSError(f"open is not implemented for directory {self}")
759 if not stat.is_file:
760 raise FileNotFoundError(f"No such file {self}")
762 with DavReadResourceHandle(mode, log.logger, uri=self, file_size=stat.size) as handle:
763 if mode == "r":
764 # cast because the protocol is compatible, but does not
765 # have BytesIO in the inheritance tree
766 yield io.TextIOWrapper(cast(Any, handle), encoding=encoding)
767 else:
768 yield handle
769 else:
770 with super()._openImpl(mode, encoding=encoding) as handle:
771 yield handle
774class DavFileSystem(AbstractFileSystem):
775 """Minimal fsspec-compatible read-only file system which contains a single
776 file.
778 Parameters
779 ----------
780 uri : `DavResourcePath`
781 URI of the single resource contained in the file system.
782 """
784 protocol = ("davs", "dav")
786 def __init__(self, uri: DavResourcePath):
787 super().__init__()
788 self._uri: DavResourcePath = uri
789 self._path: str = self._uri.geturl()
790 self._size: int | None = None
792 @override
793 def info(self, path: str, **kwargs: Any) -> dict[str, Any]:
794 log.debug("DavFileSystem.info %s", path)
795 if path != self._path:
796 raise FileNotFoundError(path)
798 return {
799 "name": path,
800 "size": self.size(self._path),
801 "type": "file",
802 }
804 @override
805 def ls(self, path: str, detail: bool = True, **kwargs: Any) -> list[str] | list[dict[str, str]]:
806 log.debug("DavFileSystem.ls %s", path)
807 if path != self._path:
808 raise FileNotFoundError(path)
810 return list(self.info(path)) if detail else list(path)
812 @override
813 def modified(self, path: str) -> datetime.datetime:
814 log.debug("DavFileSystem.modified %s", path)
815 if path != self._path:
816 raise FileNotFoundError(path)
818 return self._uri._stat().last_modified
820 @override
821 def size(self, path: str) -> int:
822 log.debug("DavFileSystem.size %s", path)
823 if path != self._path:
824 raise FileNotFoundError(path)
826 if self._size is None:
827 self._size = self._uri.size()
829 return self._size
831 @override
832 def isfile(self, path: str) -> bool:
833 log.debug("DavFileSystem.isfile %s", path)
834 return path == self._path
836 @override
837 def isdir(self, path: str) -> bool:
838 log.debug("DavFileSystem.isdir %s", path)
839 return False
841 @override
842 def exists(self, path: str, **kwargs: Any) -> bool:
843 log.debug("DavFileSystem.exists %s", path)
844 return path == self._path
846 @override
847 def open(
848 self,
849 path: str,
850 mode: str = "rb",
851 encoding: str | None = None,
852 block_size: int | None = None,
853 cache_options: dict[Any, Any] | None = None,
854 compression: str | None = None,
855 **kwargs: Any,
856 ) -> DavReadResourceHandle | io.TextIOWrapper:
857 log.debug(
858 "DavFileSystem.open path: %s mode: %s encoding: %s blocksize: %s",
859 path,
860 mode,
861 encoding,
862 block_size,
863 )
864 if path != self._path:
865 raise FileNotFoundError(f"File {path} does not exist")
867 if mode not in ("rb", "r"):
868 raise OSError(f"Opening {path} for writing is not supported")
870 handle = DavReadResourceHandle(mode, log.logger, self._uri, self.size(self._path))
871 if mode == "rb":
872 return handle
873 else:
874 return io.TextIOWrapper(cast(Any, handle), encoding=encoding)
876 @property
877 def fsid(self) -> Any:
878 return "davs"
880 @override
881 def mkdir(self, path: str, create_parents: bool = True, **kwargs: Any) -> None:
882 raise NotImplementedError
884 @override
885 def makedirs(self, path: str, exist_ok: bool = False) -> None:
886 raise NotImplementedError
888 @override
889 def rmdir(self, path: str) -> None:
890 raise NotImplementedError
892 @override
893 def walk(
894 self,
895 path: str,
896 maxdepth: int | None = None,
897 topdown: bool = True,
898 on_error: str = "omit",
899 **kwargs: Any,
900 ) -> None:
901 raise NotImplementedError
903 @override
904 def find(
905 self,
906 path: str,
907 maxdepth: int | None = None,
908 withdirs: bool = False,
909 detail: bool = False,
910 **kwargs: Any,
911 ) -> None:
912 raise NotImplementedError
914 @override
915 def du(
916 self,
917 path: str,
918 total: bool = True,
919 maxdepth: int | None = None,
920 withdirs: bool = False,
921 **kwargs: Any,
922 ) -> None:
923 raise NotImplementedError
925 @override
926 def glob(self, path: str, maxdepth: int | None = None, **kwargs: Any) -> None:
927 raise NotImplementedError
929 @override
930 def rm_file(self, path: str) -> None:
931 raise NotImplementedError
933 @override
934 def rm(self, path: str, recursive: bool = False, maxdepth: int | None = None) -> None:
935 raise NotImplementedError
937 @override
938 def touch(self, path: str, truncate: bool = True, **kwargs: Any) -> None:
939 raise NotImplementedError
941 @override
942 def ukey(self, path: str) -> None:
943 raise NotImplementedError
945 @override
946 def created(self, path: str) -> None:
947 raise NotImplementedError