megfile 4.2.4__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/__init__.py +5 -0
- megfile/fs.py +14 -1
- megfile/fs_path.py +46 -9
- megfile/interfaces.py +33 -0
- megfile/lib/joinpath.py +13 -0
- megfile/lib/s3_buffered_writer.py +13 -0
- megfile/lib/s3_limited_seekable_writer.py +2 -0
- megfile/s3_path.py +4 -1
- megfile/sftp2_path.py +9 -3
- megfile/sftp_path.py +1 -1
- megfile/version.py +1 -1
- megfile/webdav.py +552 -0
- megfile/webdav_path.py +958 -0
- {megfile-4.2.4.dist-info → megfile-4.2.5.dist-info}/METADATA +4 -1
- {megfile-4.2.4.dist-info → megfile-4.2.5.dist-info}/RECORD +20 -18
- {megfile-4.2.4.dist-info → megfile-4.2.5.dist-info}/WHEEL +0 -0
- {megfile-4.2.4.dist-info → megfile-4.2.5.dist-info}/entry_points.txt +0 -0
- {megfile-4.2.4.dist-info → megfile-4.2.5.dist-info}/licenses/LICENSE +0 -0
- {megfile-4.2.4.dist-info → megfile-4.2.5.dist-info}/licenses/LICENSE.pyre +0 -0
- {megfile-4.2.4.dist-info → megfile-4.2.5.dist-info}/top_level.txt +0 -0
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"
|