megfile 4.2.4__py3-none-any.whl → 5.0.0__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.
megfile/webdav_path.py ADDED
@@ -0,0 +1,952 @@
1
+ import hashlib
2
+ import io
3
+ import os
4
+ import re
5
+ import shlex
6
+ import subprocess
7
+ import time
8
+ from functools import cached_property
9
+ from logging import getLogger as get_logger
10
+ from typing import IO, BinaryIO, Callable, Iterable, Iterator, List, Optional, Tuple
11
+ from urllib.parse import quote, unquote, urlsplit, urlunsplit
12
+
13
+ import dateutil.parser
14
+ from webdav3.client import Client as WebdavClient
15
+ from webdav3.client import WebDavXmlUtils
16
+ from webdav3.exceptions import (
17
+ RemoteResourceNotFound,
18
+ ResponseErrorCode,
19
+ WebDavException,
20
+ )
21
+ from webdav3.urn import Urn
22
+
23
+ from megfile.config import (
24
+ READER_BLOCK_SIZE,
25
+ READER_MAX_BUFFER_SIZE,
26
+ WEBDAV_MAX_RETRY_TIMES,
27
+ )
28
+ from megfile.errors import (
29
+ SameFileError,
30
+ _create_missing_ok_generator,
31
+ http_should_retry,
32
+ patch_method,
33
+ )
34
+ from megfile.interfaces import (
35
+ ContextIterator,
36
+ FileEntry,
37
+ PathLike,
38
+ StatResult,
39
+ )
40
+ from megfile.lib.compare import is_same_file
41
+ from megfile.lib.compat import fspath
42
+ from megfile.lib.fnmatch import translate
43
+ from megfile.lib.glob import has_magic
44
+ from megfile.lib.joinpath import uri_join, uri_norm
45
+ from megfile.lib.webdav_memory_handler import WebdavMemoryHandler, _webdav_stat
46
+ from megfile.lib.webdav_prefetch_reader import WebdavPrefetchReader
47
+ from megfile.pathlike import URIPath
48
+ from megfile.smart_path import SmartPath
49
+ from megfile.utils import (
50
+ _is_pickle,
51
+ binary_open,
52
+ calculate_md5,
53
+ copyfileobj,
54
+ thread_local,
55
+ )
56
+
57
+ _logger = get_logger(__name__)
58
+
59
+ __all__ = [
60
+ "WebdavPath",
61
+ "is_webdav",
62
+ ]
63
+
64
+ WEBDAV_USERNAME = "WEBDAV_USERNAME"
65
+ WEBDAV_PASSWORD = "WEBDAV_PASSWORD"
66
+ WEBDAV_TOKEN = "WEBDAV_TOKEN"
67
+ WEBDAV_TOKEN_COMMAND = "WEBDAV_TOKEN_COMMAND"
68
+ WEBDAV_TIMEOUT = "WEBDAV_TIMEOUT"
69
+
70
+
71
+ def _make_stat(info: dict) -> StatResult:
72
+ """Convert WebDAV info dict to StatResult"""
73
+ size = int(info.get("size") or 0)
74
+ # WebDAV returns datetime objects, convert to timestamp
75
+ mtime_str = info.get("modified", "")
76
+ try:
77
+ mtime = dateutil.parser.parse(mtime_str).timestamp()
78
+ except Exception:
79
+ mtime = 0.0
80
+
81
+ isdir = info.get("is_dir", False)
82
+
83
+ return StatResult(
84
+ size=size,
85
+ mtime=mtime,
86
+ isdir=isdir,
87
+ islnk=False, # WebDAV doesn't support symlinks
88
+ extra=info,
89
+ )
90
+
91
+
92
+ def _make_entry(info: dict, root_relative: str, root_absolute: str) -> FileEntry:
93
+ path = info.get("path", "").rstrip("/")
94
+ name = os.path.basename(path)
95
+ return FileEntry(
96
+ name,
97
+ os.path.join(root_absolute, os.path.relpath(path, root_relative)),
98
+ _make_stat(info),
99
+ )
100
+
101
+
102
+ def provide_connect_info(
103
+ hostname: str,
104
+ username: Optional[str] = None,
105
+ password: Optional[str] = None,
106
+ token: Optional[str] = None,
107
+ token_command: Optional[str] = None,
108
+ ) -> dict:
109
+ """Provide connection info for WebDAV client"""
110
+ if not username:
111
+ username = os.getenv(WEBDAV_USERNAME)
112
+ if not password:
113
+ password = os.getenv(WEBDAV_PASSWORD)
114
+ if not token:
115
+ token = os.getenv(WEBDAV_TOKEN)
116
+ if not token_command:
117
+ token_command = os.getenv(WEBDAV_TOKEN_COMMAND)
118
+
119
+ timeout = int(os.getenv(WEBDAV_TIMEOUT, "30"))
120
+
121
+ options = {
122
+ "webdav_hostname": hostname,
123
+ "webdav_timeout": timeout,
124
+ "webdav_disable_check": True,
125
+ }
126
+
127
+ if token_command:
128
+ options["webdav_token_command"] = token_command
129
+ elif token:
130
+ options["webdav_token"] = token
131
+ elif username and password:
132
+ options["webdav_login"] = username
133
+ options["webdav_password"] = password
134
+
135
+ return options
136
+
137
+
138
+ def _patch_execute_request(
139
+ client: WebdavClient,
140
+ status_forcelist: Iterable[int] = (500, 502, 503, 504),
141
+ max_retries: int = WEBDAV_MAX_RETRY_TIMES,
142
+ ) -> WebdavClient:
143
+ def webdav_update_token_by_command():
144
+ cmds = shlex.split(client.webdav.token_command)
145
+ client.webdav.token_command_last_call = time.time()
146
+ client.webdav.token = subprocess.check_output(cmds).decode().strip()
147
+
148
+ def webdav_should_retry(error: Exception) -> bool:
149
+ if http_should_retry(error):
150
+ return True
151
+ if (
152
+ isinstance(error, ResponseErrorCode)
153
+ and error.code == 401 # pytype: disable=attribute-error
154
+ ):
155
+ token_command = client.webdav.token_command # pyre-ignore[16]
156
+ last_call = client.webdav.token_command_last_call # pyre-ignore[16]
157
+ if token_command is not None and time.time() - last_call > 5:
158
+ webdav_update_token_by_command()
159
+ return True
160
+ return False
161
+
162
+ def after_callback(response, *args, **kwargs):
163
+ if response.status_code in status_forcelist:
164
+ response.raise_for_status()
165
+ return response
166
+
167
+ def before_callback(action, path, data=None, headers_ext=None):
168
+ # refresh token if needed
169
+ if client.webdav.token_command is not None and not client.webdav.token:
170
+ webdav_update_token_by_command()
171
+ _logger.debug(
172
+ "send http request: %s %r, with parameters: %s, headers: %s",
173
+ action,
174
+ path,
175
+ data,
176
+ headers_ext,
177
+ )
178
+
179
+ def retry_callback(error, action, path, data=None, headers_ext=None):
180
+ if data and hasattr(data, "seek"):
181
+ data.seek(0)
182
+ elif isinstance(data, Iterator):
183
+ _logger.warning("Can not retry http request with iterator data")
184
+ raise
185
+
186
+ client.execute_request = patch_method(
187
+ client.execute_request,
188
+ max_retries=max_retries,
189
+ should_retry=webdav_should_retry,
190
+ before_callback=before_callback,
191
+ after_callback=after_callback,
192
+ retry_callback=retry_callback,
193
+ )
194
+
195
+ return client
196
+
197
+
198
+ def _get_webdav_client(
199
+ hostname: str,
200
+ username: Optional[str] = None,
201
+ password: Optional[str] = None,
202
+ token: Optional[str] = None,
203
+ token_command: Optional[str] = None,
204
+ ) -> WebdavClient:
205
+ """Get WebDAV client"""
206
+ options = provide_connect_info(hostname, username, password, token, token_command)
207
+ client = WebdavClient(options)
208
+ client.webdav.token_command = options.pop( # pyre-ignore[16]
209
+ "webdav_token_command", None
210
+ )
211
+ client.webdav.token_command_last_call = 0 # pyre-ignore[16]
212
+ client = _patch_execute_request(client)
213
+ return client
214
+
215
+
216
+ def get_webdav_client(
217
+ hostname: str,
218
+ username: Optional[str] = None,
219
+ password: Optional[str] = None,
220
+ token: Optional[str] = None,
221
+ token_command: Optional[str] = None,
222
+ ) -> WebdavClient:
223
+ """Get cached WebDAV client"""
224
+ return thread_local(
225
+ f"webdav_client:{hostname},{username},{password},{token},{token_command}",
226
+ _get_webdav_client,
227
+ hostname,
228
+ username,
229
+ password,
230
+ token,
231
+ )
232
+
233
+
234
+ def is_webdav(path: PathLike) -> bool:
235
+ """Test if a path is WebDAV path
236
+
237
+ :param path: Path to be tested
238
+ :returns: True if a path is WebDAV path, else False
239
+ """
240
+ path = fspath(path)
241
+ parts = urlsplit(path)
242
+ return parts.scheme in ("webdav", "webdavs")
243
+
244
+
245
+ def _webdav_scan_pairs(
246
+ src_url: PathLike, dst_url: PathLike
247
+ ) -> Iterator[Tuple[PathLike, PathLike]]:
248
+ for src_file_path in WebdavPath(src_url).scan():
249
+ content_path = src_file_path[len(fspath(src_url)) :]
250
+ if len(content_path) > 0:
251
+ dst_file_path = (
252
+ WebdavPath(dst_url).joinpath(content_path).path_with_protocol
253
+ )
254
+ else:
255
+ dst_file_path = dst_url
256
+ yield src_file_path, dst_file_path
257
+
258
+
259
+ def _webdav_scan(client: WebdavClient, remote_path: str) -> List[dict]:
260
+ directory_urn = Urn(remote_path, directory=True)
261
+ if directory_urn.path() != WebdavClient.root and not client.check(
262
+ directory_urn.path()
263
+ ):
264
+ raise RemoteResourceNotFound(directory_urn.path())
265
+
266
+ path = Urn.normalize_path(client.get_full_path(directory_urn))
267
+ response = client.execute_request(
268
+ action="list", path=directory_urn.quote(), headers_ext=["Depth: infinity"]
269
+ )
270
+ subfiles = WebDavXmlUtils.parse_get_list_info_response(response.content)
271
+ return [
272
+ subfile
273
+ for subfile in subfiles
274
+ if Urn.compare_path(path, subfile.get("path")) is False
275
+ ]
276
+
277
+
278
+ def _webdav_scandir(client: WebdavClient, remote_path: str) -> List[dict]:
279
+ return client.list(remote_path, get_info=True)
280
+
281
+
282
+ def _webdav_split_magic(path: str) -> Tuple[str, str]:
283
+ parts = path.split("/")
284
+ for i in range(0, len(parts)):
285
+ if has_magic(parts[i]):
286
+ return "/".join(parts[:i]), "/".join(parts[i:])
287
+ return path, ""
288
+
289
+
290
+ def _webdav_check_accept_ranges(client: WebdavClient, remote_path: str):
291
+ urn = Urn(remote_path)
292
+ response = client.execute_request(action="download", path=urn.quote())
293
+ response.close()
294
+ headers = response.headers
295
+ return headers.get("Accept-Ranges") == "bytes"
296
+
297
+
298
+ @SmartPath.register
299
+ class WebdavPath(URIPath):
300
+ """WebDAV protocol
301
+
302
+ uri format:
303
+ - webdav://[username[:password]@]hostname[:port]/file_path
304
+ - webdavs://[username[:password]@]hostname[:port]/file_path
305
+ """
306
+
307
+ protocol = "webdav"
308
+
309
+ def __init__(self, path: "PathLike", *other_paths: "PathLike"):
310
+ super().__init__(path, *other_paths)
311
+ parts = urlsplit(self.path)
312
+ self._urlsplit_parts = parts
313
+
314
+ # Normalize scheme to webdav/webdavs
315
+ if parts.scheme == "http":
316
+ self._webdav_scheme = "webdav"
317
+ elif parts.scheme == "https":
318
+ self._webdav_scheme = "webdavs"
319
+ else:
320
+ self._webdav_scheme = parts.scheme
321
+
322
+ # Build hostname with scheme
323
+ scheme_for_hostname = "https" if self._webdav_scheme == "webdavs" else "http"
324
+ self._hostname = f"{scheme_for_hostname}://{parts.hostname}"
325
+ if parts.port:
326
+ self._hostname += f":{parts.port}"
327
+
328
+ self._remote_path = unquote(parts.path) if parts.path else "/"
329
+
330
+ @cached_property
331
+ def parts(self) -> Tuple[str, ...]:
332
+ """A tuple giving access to the path's various components"""
333
+ parts = [urlunsplit(self._urlsplit_parts._replace(path=""))]
334
+ path = self._urlsplit_parts.path.lstrip("/")
335
+ if path != "":
336
+ parts.extend(path.split("/"))
337
+ return tuple(parts)
338
+
339
+ @property
340
+ def _client(self) -> WebdavClient:
341
+ return get_webdav_client(
342
+ hostname=self._hostname,
343
+ username=self._urlsplit_parts.username,
344
+ password=self._urlsplit_parts.password,
345
+ )
346
+
347
+ def _generate_path_object(self, webdav_path: str):
348
+ """Generate a new WebdavPath object with the given path"""
349
+ # Ensure path starts with /
350
+ if not webdav_path.startswith("/"):
351
+ webdav_path = "/" + webdav_path
352
+
353
+ new_parts = self._urlsplit_parts._replace(
354
+ scheme=self._webdav_scheme, path=quote(webdav_path, safe="/")
355
+ )
356
+ return self.from_path(urlunsplit(new_parts)) # pyre-ignore[6]
357
+
358
+ def exists(self, followlinks: bool = False) -> bool:
359
+ """
360
+ Test if the path exists
361
+
362
+ :param followlinks: Ignored for WebDAV (no symlink support)
363
+ :returns: True if the path exists, else False
364
+ """
365
+ try:
366
+ _webdav_stat(self._client, self._remote_path)
367
+ return True
368
+ except RemoteResourceNotFound:
369
+ return False
370
+
371
+ def getmtime(self, follow_symlinks: bool = False) -> float:
372
+ """
373
+ Get last-modified time of the file on the given path (in Unix timestamp format).
374
+
375
+ :returns: last-modified time
376
+ """
377
+ return self.stat(follow_symlinks=follow_symlinks).mtime
378
+
379
+ def getsize(self, follow_symlinks: bool = False) -> int:
380
+ """
381
+ Get file size on the given file path (in bytes).
382
+
383
+ :returns: File size
384
+ """
385
+ return self.stat(follow_symlinks=follow_symlinks).size
386
+
387
+ def glob(
388
+ self, pattern, recursive: bool = True, missing_ok: bool = True
389
+ ) -> List["WebdavPath"]:
390
+ """Return path list in ascending alphabetical order,
391
+ in which path matches glob pattern
392
+
393
+ :param pattern: Glob the given relative pattern
394
+ :param recursive: If False, `**` will not search directory recursively
395
+ :param missing_ok: If False and target path doesn't match any file,
396
+ raise FileNotFoundError
397
+ :returns: A list contains paths match `pathname`
398
+ """
399
+ return list(
400
+ sorted(
401
+ self.iglob(pattern=pattern, recursive=recursive, missing_ok=missing_ok)
402
+ )
403
+ )
404
+
405
+ def glob_stat(
406
+ self, pattern, recursive: bool = True, missing_ok: bool = True
407
+ ) -> Iterator[FileEntry]:
408
+ """Return a list contains tuples of path and file stat,
409
+ in ascending alphabetical order, in which path matches glob pattern
410
+
411
+ :param pattern: Glob the given relative pattern
412
+ :param recursive: If False, `**` will not search directory recursively
413
+ :param missing_ok: If False and target path doesn't match any file,
414
+ raise FileNotFoundError
415
+ :returns: An iterator contains tuples of path and file stat
416
+ """
417
+ remote_path = self._remote_path
418
+ if pattern:
419
+ remote_path = os.path.join(remote_path, pattern)
420
+ remote_path, pattern = _webdav_split_magic(remote_path)
421
+ root = os.path.relpath(remote_path, self._remote_path)
422
+ root = uri_join(self.path_with_protocol, root)
423
+ root = uri_norm(root)
424
+ pattern = re.compile(translate(pattern))
425
+ scan_func = _webdav_scan if recursive else _webdav_scandir
426
+ for info in scan_func(self._client, remote_path):
427
+ entry = _make_entry(info, remote_path, root)
428
+ relative = os.path.relpath(entry.path, root)
429
+ if not pattern.match(relative):
430
+ continue
431
+ yield entry
432
+
433
+ def iglob(
434
+ self, pattern, recursive: bool = True, missing_ok: bool = True
435
+ ) -> Iterator["WebdavPath"]:
436
+ """Return path iterator in ascending alphabetical order,
437
+ in which path matches glob pattern
438
+
439
+ :param pattern: Glob the given relative pattern
440
+ :param recursive: If False, `**` will not search directory recursively
441
+ :param missing_ok: If False and target path doesn't match any file,
442
+ raise FileNotFoundError
443
+ :returns: An iterator contains paths match `pathname`
444
+ """
445
+ for file_entry in self.glob_stat(
446
+ pattern=pattern,
447
+ recursive=recursive,
448
+ missing_ok=missing_ok,
449
+ ):
450
+ yield self.from_path(file_entry.path)
451
+
452
+ def is_dir(self, followlinks: bool = False) -> bool:
453
+ """
454
+ Test if a path is directory
455
+
456
+ :param followlinks: Ignored for WebDAV
457
+ :returns: True if the path is a directory, else False
458
+ """
459
+ try:
460
+ return _webdav_stat(self._client, self._remote_path)["is_dir"]
461
+ except RemoteResourceNotFound:
462
+ return False
463
+
464
+ def is_file(self, followlinks: bool = False) -> bool:
465
+ """
466
+ Test if a path is file
467
+
468
+ :param followlinks: Ignored for WebDAV
469
+ :returns: True if the path is a file, else False
470
+ """
471
+ try:
472
+ return not _webdav_stat(self._client, self._remote_path)["is_dir"]
473
+ except RemoteResourceNotFound:
474
+ return False
475
+
476
+ def listdir(self) -> List[str]:
477
+ """
478
+ Get all contents of given WebDAV path.
479
+ The result is in ascending alphabetical order.
480
+
481
+ :returns: All contents in ascending alphabetical order
482
+ """
483
+ with self.scandir() as entries:
484
+ return sorted([entry.name for entry in entries])
485
+
486
+ def iterdir(self) -> Iterator["WebdavPath"]:
487
+ """
488
+ Get all contents of given WebDAV path.
489
+
490
+ :returns: All contents have in the path.
491
+ """
492
+ with self.scandir() as entries:
493
+ for entry in entries:
494
+ yield self.joinpath(entry.name)
495
+
496
+ def load(self) -> BinaryIO:
497
+ """Read all content on specified path and write into memory
498
+
499
+ User should close the BinaryIO manually
500
+
501
+ :returns: Binary stream
502
+ """
503
+ with self.open(mode="rb") as f:
504
+ data = f.read()
505
+ return io.BytesIO(data)
506
+
507
+ def mkdir(self, mode=0o777, parents: bool = False, exist_ok: bool = False):
508
+ """
509
+ Make a directory on WebDAV
510
+
511
+ :param mode: Ignored for WebDAV
512
+ :param parents: If parents is true, any missing parents are created
513
+ :param exist_ok: If False and target directory exists, raise FileExistsError
514
+ """
515
+ if self.exists():
516
+ if not exist_ok:
517
+ raise FileExistsError(f"File exists: '{self.path_with_protocol}'")
518
+ return
519
+
520
+ if parents:
521
+ parent_path_objects = []
522
+ for parent_path_object in self.parents:
523
+ if parent_path_object.exists():
524
+ break
525
+ else:
526
+ parent_path_objects.append(parent_path_object)
527
+ for parent_path_object in parent_path_objects[::-1]:
528
+ parent_path_object.mkdir(mode=mode, parents=False, exist_ok=True)
529
+
530
+ try:
531
+ self._client.mkdir(self._remote_path)
532
+ except WebDavException:
533
+ # Catch exception when mkdir concurrently
534
+ if not self.exists():
535
+ raise
536
+
537
+ def realpath(self) -> str:
538
+ """Return the real path of given path
539
+
540
+ :returns: Real path of given path
541
+ """
542
+ return self.path_with_protocol
543
+
544
+ def _is_same_backend(self, other: "WebdavPath") -> bool:
545
+ """Check if two paths are on the same WebDAV backend"""
546
+ return (
547
+ self._hostname == other._hostname
548
+ and self._urlsplit_parts.username == other._urlsplit_parts.username
549
+ and self._urlsplit_parts.password == other._urlsplit_parts.password
550
+ )
551
+
552
+ def _is_same_protocol(self, path):
553
+ """Check if path is a WebDAV path"""
554
+ return is_webdav(path)
555
+
556
+ def rename(self, dst_path: PathLike, overwrite: bool = True) -> "WebdavPath":
557
+ """
558
+ Rename file on WebDAV
559
+
560
+ :param dst_path: Given destination path
561
+ :param overwrite: whether or not overwrite file when exists
562
+ """
563
+ if not self._is_same_protocol(dst_path):
564
+ raise OSError("Not a %s path: %r" % (self.protocol, dst_path))
565
+
566
+ dst_path = self.from_path(str(dst_path).rstrip("/"))
567
+
568
+ if self._is_same_backend(dst_path):
569
+ if overwrite:
570
+ dst_path.remove(missing_ok=True)
571
+ self._client.move(
572
+ self._remote_path, dst_path._remote_path, overwrite=overwrite
573
+ )
574
+ else:
575
+ if self.is_dir():
576
+ for file_entry in self.scandir():
577
+ self.from_path(file_entry.path).rename(
578
+ dst_path.joinpath(file_entry.name), overwrite=overwrite
579
+ )
580
+ self.rmdir()
581
+ else:
582
+ if overwrite or not dst_path.exists():
583
+ with self.open("rb") as fsrc:
584
+ with dst_path.open("wb") as fdst:
585
+ copyfileobj(fsrc, fdst)
586
+ self.unlink()
587
+
588
+ return dst_path
589
+
590
+ def replace(self, dst_path: PathLike, overwrite: bool = True) -> "WebdavPath":
591
+ """
592
+ Move file on WebDAV
593
+
594
+ :param dst_path: Given destination path
595
+ :param overwrite: whether or not overwrite file when exists
596
+ """
597
+ return self.rename(dst_path=dst_path, overwrite=overwrite)
598
+
599
+ def remove(self, missing_ok: bool = False) -> None:
600
+ """
601
+ Remove the file or directory on WebDAV
602
+
603
+ :param missing_ok: if False and target file/directory not exists,
604
+ raise FileNotFoundError
605
+ """
606
+ if missing_ok and not self.exists():
607
+ return
608
+ try:
609
+ self._client.clean(self._remote_path)
610
+ except RemoteResourceNotFound:
611
+ if not missing_ok:
612
+ raise FileNotFoundError(f"No such file: '{self.path_with_protocol}'")
613
+
614
+ def scan(self, missing_ok: bool = True, followlinks: bool = False) -> Iterator[str]:
615
+ """
616
+ Iteratively traverse only files in given directory, in alphabetical order.
617
+
618
+ :param missing_ok: If False and there's no file, raise FileNotFoundError
619
+ :returns: A file path generator
620
+ """
621
+ scan_stat_iter = self.scan_stat(missing_ok=missing_ok, followlinks=followlinks)
622
+
623
+ for file_entry in scan_stat_iter:
624
+ yield file_entry.path
625
+
626
+ def scan_stat(
627
+ self, missing_ok: bool = True, followlinks: bool = False
628
+ ) -> Iterator[FileEntry]:
629
+ """
630
+ Iteratively traverse only files in given directory, in alphabetical order.
631
+
632
+ :param missing_ok: If False and there's no file, raise FileNotFoundError
633
+ :returns: A file path generator yielding FileEntry objects
634
+ """
635
+
636
+ def create_generator() -> Iterator[FileEntry]:
637
+ if not self.exists():
638
+ return
639
+
640
+ if self.is_file():
641
+ yield FileEntry(
642
+ self.name,
643
+ self.path_with_protocol,
644
+ self.stat(),
645
+ )
646
+ return
647
+
648
+ for info in _webdav_scan(self._client, self._remote_path):
649
+ entry = _make_entry(info, self._remote_path, self.path_with_protocol)
650
+ if entry.is_dir():
651
+ continue
652
+ yield entry
653
+
654
+ return _create_missing_ok_generator(
655
+ create_generator(),
656
+ missing_ok,
657
+ FileNotFoundError("No match any file in: %r" % self.path_with_protocol),
658
+ )
659
+
660
+ def scandir(self) -> ContextIterator:
661
+ """
662
+ Get all content of given file path.
663
+
664
+ :returns: An iterator contains all contents
665
+ """
666
+ if not self.exists():
667
+ raise FileNotFoundError(
668
+ f"No such file or directory: '{self.path_with_protocol}'"
669
+ )
670
+ if not self.is_dir():
671
+ raise NotADirectoryError(f"Not a directory: '{self.path_with_protocol}'")
672
+
673
+ def create_generator():
674
+ for info in _webdav_scandir(self._client, self._remote_path):
675
+ yield _make_entry(info, self._remote_path, self.path_with_protocol)
676
+
677
+ return ContextIterator(create_generator())
678
+
679
+ def stat(self, follow_symlinks=True) -> StatResult:
680
+ """
681
+ Get StatResult of file on WebDAV
682
+
683
+ :returns: StatResult
684
+ """
685
+ try:
686
+ info = _webdav_stat(self._client, self._remote_path)
687
+ return _make_stat(info)
688
+ except RemoteResourceNotFound:
689
+ raise FileNotFoundError(f"No such file: '{self.path_with_protocol}'")
690
+
691
+ def unlink(self, missing_ok: bool = False) -> None:
692
+ """
693
+ Remove the file on WebDAV
694
+
695
+ :param missing_ok: if False and target file not exists, raise FileNotFoundError
696
+ """
697
+ if missing_ok and not self.exists():
698
+ return
699
+ try:
700
+ self._client.clean(self._remote_path)
701
+ except RemoteResourceNotFound:
702
+ if not missing_ok:
703
+ raise FileNotFoundError(f"No such file: '{self.path_with_protocol}'")
704
+
705
+ def walk(
706
+ self, followlinks: bool = False
707
+ ) -> Iterator[Tuple[str, List[str], List[str]]]:
708
+ """
709
+ Generate the file names in a directory tree by walking the tree top-down.
710
+
711
+ :param followlinks: Ignored for WebDAV
712
+ :returns: A 3-tuple generator (root, dirs, files)
713
+ """
714
+ if not self.exists():
715
+ return
716
+ if self.is_file():
717
+ return
718
+
719
+ stack = [self._remote_path]
720
+ while stack:
721
+ root = stack.pop()
722
+ dirs, files = [], []
723
+
724
+ root_path = self._generate_path_object(root)
725
+ for entry in root_path.scandir():
726
+ if entry.is_dir():
727
+ dirs.append(entry.name)
728
+ else:
729
+ files.append(entry.name)
730
+
731
+ dirs = sorted(dirs)
732
+ files = sorted(files)
733
+
734
+ yield root_path.path_with_protocol, dirs, files
735
+
736
+ stack.extend(
737
+ (os.path.join(root, directory) for directory in reversed(dirs))
738
+ )
739
+
740
+ def resolve(self, strict=False) -> "WebdavPath":
741
+ """Return the absolute path
742
+
743
+ :param strict: Ignored for WebDAV
744
+ :return: Absolute path
745
+ """
746
+ return self
747
+
748
+ def md5(self, recalculate: bool = False, followlinks: bool = False):
749
+ """
750
+ Calculate the md5 value of the file
751
+
752
+ :param recalculate: Ignored for WebDAV
753
+ :param followlinks: Ignored for WebDAV
754
+ :returns: md5 of file
755
+ """
756
+ if self.is_dir():
757
+ hash_md5 = hashlib.md5()
758
+ for file_name in self.listdir():
759
+ chunk = (
760
+ self.joinpath(file_name)
761
+ .md5(recalculate=recalculate, followlinks=followlinks)
762
+ .encode()
763
+ )
764
+ hash_md5.update(chunk)
765
+ return hash_md5.hexdigest()
766
+
767
+ with self.open("rb") as src:
768
+ md5 = calculate_md5(src)
769
+ return md5
770
+
771
+ def is_symlink(self) -> bool:
772
+ """WebDAV doesn't support symlinks
773
+
774
+ :return: Always False
775
+ """
776
+ return False
777
+
778
+ def cwd(self) -> "WebdavPath":
779
+ """Return current working directory (root path)
780
+
781
+ :returns: Root WebDAV path
782
+ """
783
+ return self._generate_path_object("/")
784
+
785
+ def save(self, file_object: BinaryIO):
786
+ """Write the opened binary stream to path
787
+
788
+ :param file_object: stream to be read
789
+ """
790
+ with self.open(mode="wb") as output:
791
+ output.write(file_object.read())
792
+
793
+ @binary_open
794
+ def open(
795
+ self,
796
+ mode: str = "rb",
797
+ *,
798
+ max_workers: Optional[int] = None,
799
+ max_buffer_size: int = READER_MAX_BUFFER_SIZE,
800
+ block_forward: Optional[int] = None,
801
+ block_size: int = READER_BLOCK_SIZE,
802
+ **kwargs,
803
+ ) -> IO:
804
+ """Open a file on the path.
805
+
806
+ :param mode: Mode to open file
807
+ :param buffering: buffering policy
808
+ :param encoding: encoding for text mode
809
+ :param errors: error handling for text mode
810
+ :returns: File-Like object
811
+ """
812
+ if "x" in mode:
813
+ if self.exists():
814
+ raise FileExistsError("File exists: %r" % self.path_with_protocol)
815
+ if "w" in mode or "x" in mode or "a" in mode:
816
+ if self.is_dir():
817
+ raise IsADirectoryError("Is a directory: %r" % self.path_with_protocol)
818
+ self.parent.mkdir(parents=True, exist_ok=True)
819
+ elif not self.exists():
820
+ raise FileNotFoundError("No such file: %r" % self.path_with_protocol)
821
+
822
+ if mode == "rb":
823
+ if _webdav_check_accept_ranges(self._client, self._remote_path):
824
+ reader = WebdavPrefetchReader(
825
+ self._remote_path,
826
+ client=self._client,
827
+ block_size=block_size,
828
+ max_buffer_size=max_buffer_size,
829
+ block_forward=block_forward,
830
+ max_retries=WEBDAV_MAX_RETRY_TIMES,
831
+ max_workers=max_workers,
832
+ )
833
+ if _is_pickle(reader):
834
+ reader = io.BufferedReader(reader) # type: ignore
835
+ return reader
836
+
837
+ return WebdavMemoryHandler(
838
+ self._remote_path,
839
+ mode,
840
+ webdav_client=self._client,
841
+ name=self.path_with_protocol,
842
+ )
843
+
844
+ def chmod(self, mode: int, *, follow_symlinks: bool = True):
845
+ """
846
+ WebDAV doesn't support chmod
847
+
848
+ :param mode: Ignored
849
+ :param follow_symlinks: Ignored
850
+ """
851
+ _logger.warning("WebDAV does not support chmod operation")
852
+
853
+ def absolute(self) -> "WebdavPath":
854
+ """
855
+ Make the path absolute
856
+
857
+ :returns: Absolute path
858
+ """
859
+ return self
860
+
861
+ def rmdir(self):
862
+ """
863
+ Remove this directory. The directory must be empty.
864
+ """
865
+ if len(self.listdir()) > 0:
866
+ raise OSError(f"Directory not empty: '{self.path_with_protocol}'")
867
+ self._client.clean(self._remote_path)
868
+
869
+ def copy(
870
+ self,
871
+ dst_path: PathLike,
872
+ callback: Optional[Callable[[int], None]] = None,
873
+ followlinks: bool = False,
874
+ overwrite: bool = True,
875
+ ):
876
+ """
877
+ Copy the file to the given destination path.
878
+
879
+ :param dst_path: The destination path
880
+ :param callback: Optional callback for progress
881
+ :param followlinks: Ignored for WebDAV
882
+ :param overwrite: whether to overwrite existing file
883
+ """
884
+ if not self._is_same_protocol(dst_path):
885
+ raise OSError("Not a %s path: %r" % (self.protocol, dst_path))
886
+ if str(dst_path).endswith("/"):
887
+ raise IsADirectoryError("Is a directory: %r" % dst_path)
888
+
889
+ if self.is_dir():
890
+ raise IsADirectoryError("Is a directory: %r" % self.path_with_protocol)
891
+
892
+ if not overwrite and self.from_path(dst_path).exists():
893
+ return
894
+
895
+ self.from_path(os.path.dirname(fspath(dst_path))).makedirs(exist_ok=True)
896
+ dst_path = self.from_path(dst_path)
897
+
898
+ if self._is_same_backend(dst_path):
899
+ if self._remote_path == dst_path._remote_path:
900
+ raise SameFileError(
901
+ f"'{self.path}' and '{dst_path.path}' are the same file"
902
+ )
903
+ self._client.copy(self._remote_path, dst_path._remote_path)
904
+ if callback:
905
+ callback(self.stat().size)
906
+ else:
907
+ with self.open("rb") as fsrc:
908
+ with dst_path.open("wb") as fdst:
909
+ copyfileobj(fsrc, fdst, callback)
910
+
911
+ def sync(
912
+ self,
913
+ dst_path: PathLike,
914
+ followlinks: bool = False,
915
+ force: bool = False,
916
+ overwrite: bool = True,
917
+ ):
918
+ """Copy file/directory to dst_path
919
+
920
+ :param dst_path: Given destination path
921
+ :param followlinks: Ignored for WebDAV
922
+ :param force: Sync forcibly
923
+ :param overwrite: whether to overwrite existing file
924
+ """
925
+ if not self._is_same_protocol(dst_path):
926
+ raise OSError("Not a %s path: %r" % (self.protocol, dst_path))
927
+
928
+ for src_file_path, dst_file_path in _webdav_scan_pairs(
929
+ self.path_with_protocol, dst_path
930
+ ):
931
+ dst_path_obj = self.from_path(dst_file_path)
932
+ src_path_obj = self.from_path(src_file_path)
933
+
934
+ if force:
935
+ pass
936
+ elif not overwrite and dst_path_obj.exists():
937
+ continue
938
+ elif dst_path_obj.exists() and is_same_file(
939
+ src_path_obj.stat(), dst_path_obj.stat(), "copy"
940
+ ):
941
+ continue
942
+
943
+ src_path_obj.copy(
944
+ dst_file_path,
945
+ followlinks=followlinks,
946
+ overwrite=True,
947
+ )
948
+
949
+
950
+ @SmartPath.register
951
+ class WebdavsPath(WebdavPath):
952
+ protocol = "webdavs"