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