pathapi-uri 0.1.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.
- pathapi_uri-0.1.0.dist-info/METADATA +259 -0
- pathapi_uri-0.1.0.dist-info/RECORD +48 -0
- pathapi_uri-0.1.0.dist-info/WHEEL +4 -0
- pathapi_uri-0.1.0.dist-info/entry_points.txt +17 -0
- pathapi_uri-0.1.0.dist-info/licenses/LICENSE +21 -0
- uripath/AGENTS.md +1094 -0
- uripath/README.md +213 -0
- uripath/__init__.py +27 -0
- uripath/__main__.py +7 -0
- uripath/_pathtext.py +32 -0
- uripath/_rfc3986.py +67 -0
- uripath/_transfer.py +108 -0
- uripath/_uri.py +1007 -0
- uripath/_version.py +11 -0
- uripath/cli/__init__.py +54 -0
- uripath/cli/_common.py +48 -0
- uripath/cli/_root.py +70 -0
- uripath/cli/cp.py +61 -0
- uripath/cli/read.py +22 -0
- uripath/cli/rm.py +38 -0
- uripath/cli/sync.py +78 -0
- uripath/cli/write.py +49 -0
- uripath/exceptions.py +38 -0
- uripath/py.typed +1 -0
- uripath/query.py +240 -0
- uripath/schemes/__init__.py +120 -0
- uripath/schemes/_archive.py +338 -0
- uripath/schemes/_data.py +99 -0
- uripath/schemes/_dav.py +459 -0
- uripath/schemes/_delegate.py +127 -0
- uripath/schemes/_elsewhere.py +102 -0
- uripath/schemes/_file.py +186 -0
- uripath/schemes/_ftp.py +200 -0
- uripath/schemes/_http/__init__.py +4 -0
- uripath/schemes/_http/_backend.py +132 -0
- uripath/schemes/_http/_headers.py +8 -0
- uripath/schemes/_http/_listing.py +403 -0
- uripath/schemes/_http/_path.py +371 -0
- uripath/schemes/_http/_settings.py +6 -0
- uripath/schemes/_http/_streams.py +230 -0
- uripath/schemes/_httpdate.py +48 -0
- uripath/schemes/_path.py +629 -0
- uripath/schemes/_registry.py +134 -0
- uripath/source/__init__.py +20 -0
- uripath/source/_compose.py +212 -0
- uripath/source/_locality.py +44 -0
- uripath/source/_parse.py +183 -0
- uripath/source/_source.py +239 -0
uripath/schemes/_dav.py
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import errno as _errno
|
|
4
|
+
import os as _os
|
|
5
|
+
import typing as _ty
|
|
6
|
+
import urllib.parse as _urlparse
|
|
7
|
+
import xml.etree.ElementTree as _ET
|
|
8
|
+
|
|
9
|
+
from pathapi import FileStat
|
|
10
|
+
from pathapi import providers as _providers
|
|
11
|
+
from pathapi.providers import check_follow
|
|
12
|
+
from .._uri import URI
|
|
13
|
+
from ..source import compose_uri
|
|
14
|
+
from . import _httpdate
|
|
15
|
+
from pathapi.http import (
|
|
16
|
+
drop_body,
|
|
17
|
+
os_error,
|
|
18
|
+
peer_text,
|
|
19
|
+
raise_for_status,
|
|
20
|
+
read_limited,
|
|
21
|
+
server_size,
|
|
22
|
+
split_userinfo,
|
|
23
|
+
translate_errors,
|
|
24
|
+
)
|
|
25
|
+
from ._http._headers import _IDENTITY_ENCODING
|
|
26
|
+
from ._http._path import HTTPURIPath
|
|
27
|
+
from ._http._streams import _EXCLUSIVE_CREATE, _UploadStream, _response_reader
|
|
28
|
+
|
|
29
|
+
_NS = {"D": "DAV:"}
|
|
30
|
+
_MULTISTATUS = "{DAV:}multistatus"
|
|
31
|
+
|
|
32
|
+
_PROPFIND_BODY = b"""<?xml version="1.0" encoding="utf-8"?>
|
|
33
|
+
<D:propfind xmlns:D="DAV:">
|
|
34
|
+
<D:prop>
|
|
35
|
+
<D:resourcetype/>
|
|
36
|
+
<D:getcontentlength/>
|
|
37
|
+
<D:getlastmodified/>
|
|
38
|
+
</D:prop>
|
|
39
|
+
</D:propfind>"""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _found_props(elem) -> "list":
|
|
43
|
+
"""The `<D:prop>` elements of every successful `<D:propstat>` of a
|
|
44
|
+
`<D:response>`. RFC 4918 groups properties into one propstat per status
|
|
45
|
+
(200 for found, 404 for missing) in no fixed order, so reading only the
|
|
46
|
+
first one could read the 404 group; a propstat with no status counts."""
|
|
47
|
+
props = []
|
|
48
|
+
for propstat in elem.findall("D:propstat", _NS):
|
|
49
|
+
code = _status_code(propstat.findtext("D:status", namespaces=_NS))
|
|
50
|
+
prop = propstat.find("D:prop", _NS)
|
|
51
|
+
if prop is not None and (code is None or 200 <= code < 300):
|
|
52
|
+
props.append(prop)
|
|
53
|
+
return props
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _find_prop(props, name: str):
|
|
57
|
+
for prop in props:
|
|
58
|
+
found = prop.find(name, _NS)
|
|
59
|
+
if found is not None:
|
|
60
|
+
return found
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _parse_response(elem) -> "tuple[str, bool, int, str]":
|
|
65
|
+
href = elem.findtext("D:href", namespaces=_NS) or ""
|
|
66
|
+
props = _found_props(elem)
|
|
67
|
+
resourcetype = _find_prop(props, "D:resourcetype")
|
|
68
|
+
is_dir = (
|
|
69
|
+
resourcetype is not None and resourcetype.find("D:collection", _NS) is not None
|
|
70
|
+
)
|
|
71
|
+
size_elem = _find_prop(props, "D:getcontentlength")
|
|
72
|
+
# A missing, non-numeric or negative length is no size.
|
|
73
|
+
size = server_size(size_elem.text if size_elem is not None else None) or 0
|
|
74
|
+
lm_elem = _find_prop(props, "D:getlastmodified")
|
|
75
|
+
lm = lm_elem.text if lm_elem is not None else None
|
|
76
|
+
# Still percent-encoded: decoding before `urlsplit()` would read a
|
|
77
|
+
# literal "#" or "?" in a name as URL syntax.
|
|
78
|
+
return href, is_dir, size, lm
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _decoded_segments(path: str) -> "list[str]":
|
|
82
|
+
"""The decoded segments of a percent-encoded URL path, a trailing slash
|
|
83
|
+
ignored. A segment that was `%2F` stays one segment. Undecodable bytes
|
|
84
|
+
are kept (`surrogateescape`) as the URI layer keeps them."""
|
|
85
|
+
return [
|
|
86
|
+
_urlparse.unquote(segment, errors="surrogateescape")
|
|
87
|
+
for segment in path.rstrip("/").split("/")
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _status_code(text: "str | None") -> "int | None":
|
|
92
|
+
# "HTTP/1.1 423 Locked"
|
|
93
|
+
parts = (text or "").split()
|
|
94
|
+
if len(parts) >= 2 and parts[1].isdigit():
|
|
95
|
+
return int(parts[1])
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _multistatus_failures(content: bytes) -> "list[tuple[str, int]] | None":
|
|
100
|
+
"""`(href, status)` of every failed member of a 207 reply, or `None`
|
|
101
|
+
when the body is not a parseable multistatus."""
|
|
102
|
+
try:
|
|
103
|
+
root = _ET.fromstring(content)
|
|
104
|
+
except _ET.ParseError:
|
|
105
|
+
return None
|
|
106
|
+
failures = []
|
|
107
|
+
for response in root.findall("D:response", _NS):
|
|
108
|
+
href = _urlparse.unquote(response.findtext("D:href", namespaces=_NS) or "")
|
|
109
|
+
statuses = [response.findtext("D:status", namespaces=_NS)] + [
|
|
110
|
+
propstat.findtext("D:status", namespaces=_NS)
|
|
111
|
+
for propstat in response.findall("D:propstat", _NS)
|
|
112
|
+
]
|
|
113
|
+
for text in statuses:
|
|
114
|
+
code = _status_code(text)
|
|
115
|
+
if code is not None and code >= 400:
|
|
116
|
+
failures.append((href, code))
|
|
117
|
+
break
|
|
118
|
+
return failures
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _raise_for_multistatus(resp, path) -> None:
|
|
122
|
+
"""RFC 4918 9.6.1/9.9.4: DELETE and MOVE answer 207 when a member of
|
|
123
|
+
the collection could not be processed -- a partial failure, not a
|
|
124
|
+
success."""
|
|
125
|
+
failures = _multistatus_failures(read_limited(resp, path))
|
|
126
|
+
if failures == []:
|
|
127
|
+
return
|
|
128
|
+
detail = (
|
|
129
|
+
", ".join(
|
|
130
|
+
f"{peer_text(href, 200)} (HTTP {code})" for href, code in failures[:10]
|
|
131
|
+
)
|
|
132
|
+
+ (f" and {len(failures) - 10} more" if len(failures) > 10 else "")
|
|
133
|
+
if failures
|
|
134
|
+
else "unparseable 207 Multi-Status reply"
|
|
135
|
+
)
|
|
136
|
+
message = f"Partial failure for {path}: {detail}"
|
|
137
|
+
if failures and all(code in (401, 403, 423) for _href, code in failures):
|
|
138
|
+
raise PermissionError(_errno.EACCES, message)
|
|
139
|
+
raise OSError(_errno.EIO, message)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class _DAVWriteStream(_UploadStream):
|
|
143
|
+
def __init__(self, path: "DAVURIPath", exclusive: bool = False):
|
|
144
|
+
super().__init__(path)
|
|
145
|
+
# `exclusive` (mode "x"): see `HTTPWriteStream`.
|
|
146
|
+
self._exclusive = exclusive
|
|
147
|
+
self._ready = True
|
|
148
|
+
|
|
149
|
+
def _upload(self, data):
|
|
150
|
+
# 409: an intermediate collection is missing (RFC 4918 9.7.1).
|
|
151
|
+
statuses = {409: FileNotFoundError}
|
|
152
|
+
extra = {}
|
|
153
|
+
if self._exclusive:
|
|
154
|
+
statuses[412] = FileExistsError
|
|
155
|
+
extra = _EXCLUSIVE_CREATE
|
|
156
|
+
path = self._path
|
|
157
|
+
path._dav_request(
|
|
158
|
+
path.backend.write_method, data=data, statuses=statuses, **extra
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class DAVURIPath(HTTPURIPath):
|
|
163
|
+
"""`dav:`/`davs:` scheme: WebDAV (RFC 4918) over HTTP(S). Extends
|
|
164
|
+
`HTTPURIPath` with PROPFIND (stat/listdir -- real directory metadata,
|
|
165
|
+
replacing `HTTPURIPath`'s HTML-index scraping) and PUT/DELETE/MKCOL/MOVE
|
|
166
|
+
(full write support). Requests go to the equivalent `http:`/`https:`
|
|
167
|
+
URL (`_wire_uri()`); `as_uri()` still reports `dav:`/`davs:`. Reuses
|
|
168
|
+
`HTTPURIPath`'s `HTTPBackend` (transport + settings) -- no new dependency.
|
|
169
|
+
|
|
170
|
+
`rmdir()` enforces pathlib's "must be empty" contract with a depth-1
|
|
171
|
+
PROPFIND before DELETE (WebDAV `DELETE` is recursive by spec, RFC 4918,
|
|
172
|
+
unlike `pathlib.Path.rmdir()`). The native recursive DELETE is still
|
|
173
|
+
available -- and cheaper than the base class's client-side walk -- via
|
|
174
|
+
`rm(recursive=True)`, overridden below to issue a single request.
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
__SCHEMES = ("dav", "davs")
|
|
178
|
+
__slots__ = ()
|
|
179
|
+
|
|
180
|
+
def _wire_uri(self) -> str:
|
|
181
|
+
# Keeps the userinfo: `HTTPBackend.request` strips it from the URL it sends and
|
|
182
|
+
# turns it into `auth=`. Direct string assembly, not uricompose(), as this runs on
|
|
183
|
+
# every request and every part is this instance's own parsed state (`_compose_uri`).
|
|
184
|
+
scheme = "https" if self.source.scheme == "davs" else "http"
|
|
185
|
+
return compose_uri(
|
|
186
|
+
scheme,
|
|
187
|
+
self.source.userinfo,
|
|
188
|
+
self.source.host,
|
|
189
|
+
self.source.port,
|
|
190
|
+
self.path,
|
|
191
|
+
str(self.query) or None,
|
|
192
|
+
self.fragment or None,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
def _dav_request(self, method, *, statuses=None, keep=False, **kwargs):
|
|
196
|
+
"""Send `method` to this resource with pathlib exception types:
|
|
197
|
+
`statuses` maps a status code to the exception class raised for it
|
|
198
|
+
(called with `self`), ahead of `translate_errors`' generic
|
|
199
|
+
mapping; a 207 to DELETE/MOVE raises for its failed members; 423
|
|
200
|
+
Locked is a PermissionError. The body of the reply is dropped (see
|
|
201
|
+
`drop_body`) unless `keep`, when the caller reads it."""
|
|
202
|
+
statuses = {
|
|
203
|
+
404: FileNotFoundError,
|
|
204
|
+
401: PermissionError,
|
|
205
|
+
403: PermissionError,
|
|
206
|
+
423: PermissionError,
|
|
207
|
+
**(statuses or {}),
|
|
208
|
+
}
|
|
209
|
+
with translate_errors(self):
|
|
210
|
+
resp = self.backend.request(method, self._wire_uri(), stream=True, **kwargs)
|
|
211
|
+
try:
|
|
212
|
+
error = statuses.get(resp.status)
|
|
213
|
+
if error is not None:
|
|
214
|
+
if isinstance(error, type):
|
|
215
|
+
# `(errno, strerror, filename)`, as pathlib raises it.
|
|
216
|
+
raise os_error(error, self)
|
|
217
|
+
raise error(self)
|
|
218
|
+
if resp.status == 207 and method in ("DELETE", "MOVE"):
|
|
219
|
+
_raise_for_multistatus(resp, self)
|
|
220
|
+
raise_for_status(resp)
|
|
221
|
+
except BaseException:
|
|
222
|
+
drop_body(resp)
|
|
223
|
+
raise
|
|
224
|
+
if not keep:
|
|
225
|
+
drop_body(resp)
|
|
226
|
+
return resp
|
|
227
|
+
|
|
228
|
+
def _propfind(self, depth="0"):
|
|
229
|
+
return self._propfind_reply(depth)[0]
|
|
230
|
+
|
|
231
|
+
def _propfind_reply(self, depth: str) -> "tuple[_ET.Element, str]":
|
|
232
|
+
"""The `<D:multistatus>` element of a PROPFIND reply, and the URL
|
|
233
|
+
that answered it (not this path's own after a redirect)."""
|
|
234
|
+
resp = self._dav_request(
|
|
235
|
+
"PROPFIND",
|
|
236
|
+
headers={"Depth": depth, "Content-Type": "application/xml"},
|
|
237
|
+
data=_PROPFIND_BODY,
|
|
238
|
+
keep=True,
|
|
239
|
+
)
|
|
240
|
+
body = read_limited(resp, self)
|
|
241
|
+
try:
|
|
242
|
+
root = _ET.fromstring(body)
|
|
243
|
+
except _ET.ParseError:
|
|
244
|
+
root = None
|
|
245
|
+
if root is None or root.tag != _MULTISTATUS:
|
|
246
|
+
# A non-WebDAV endpoint or proxy answering 200 with HTML or other XML: an I/O error,
|
|
247
|
+
# so `exists()`/`is_dir()` report False instead of leaking a SyntaxError subclass,
|
|
248
|
+
# and a login page is not an empty directory.
|
|
249
|
+
raise OSError(_errno.EIO, f"Invalid PROPFIND response for {self}")
|
|
250
|
+
return root, resp.url or self._wire_uri()
|
|
251
|
+
|
|
252
|
+
def stat(self, *, follow_symlinks=True, walk_up_last_modified=False):
|
|
253
|
+
# `walk_up_last_modified` has nothing to do: the reply carries the
|
|
254
|
+
# modification time.
|
|
255
|
+
hint = self._pop_stat_hint()
|
|
256
|
+
if hint is not None:
|
|
257
|
+
return hint
|
|
258
|
+
root = self._propfind(depth="0")
|
|
259
|
+
responses = root.findall("D:response", _NS)
|
|
260
|
+
if not responses:
|
|
261
|
+
raise FileNotFoundError(self)
|
|
262
|
+
_href, is_dir, size, lm = _parse_response(responses[0])
|
|
263
|
+
return FileStat(st_size=size, st_mtime=_httpdate.parsedate(lm), is_dir=is_dir)
|
|
264
|
+
|
|
265
|
+
def _scandir(self):
|
|
266
|
+
# One PROPFIND (Depth: 1) already carries type/size/mtime for every
|
|
267
|
+
# child -- reuse it instead of `iterdir()` + a stat per child.
|
|
268
|
+
root, answered = self._propfind_reply("1")
|
|
269
|
+
collection = _urlparse.urlsplit(answered)
|
|
270
|
+
# Compared segment by segment, decoded: the wire path is
|
|
271
|
+
# percent-encoded, so a directory named "my dir" otherwise listed
|
|
272
|
+
# itself as a child.
|
|
273
|
+
here = _decoded_segments(collection.path)
|
|
274
|
+
host = (collection.hostname or "").lower()
|
|
275
|
+
base = collection._replace(
|
|
276
|
+
path=collection.path.rstrip("/") + "/", query="", fragment=""
|
|
277
|
+
).geturl()
|
|
278
|
+
selves = []
|
|
279
|
+
members = []
|
|
280
|
+
replies = 0
|
|
281
|
+
for elem in root.findall("D:response", _NS):
|
|
282
|
+
replies += 1
|
|
283
|
+
href, is_dir, size, lm = _parse_response(elem)
|
|
284
|
+
if not href.strip():
|
|
285
|
+
continue
|
|
286
|
+
try:
|
|
287
|
+
# Split first, decode after: a literal "#"/"?" in a name
|
|
288
|
+
# arrives encoded and must stay part of the name. Resolved
|
|
289
|
+
# against the collection, as RFC 4918 8.3 reads a reference.
|
|
290
|
+
where = _urlparse.urlsplit(_urlparse.urljoin(base, href.strip()))
|
|
291
|
+
except ValueError:
|
|
292
|
+
continue
|
|
293
|
+
if (where.hostname or "").lower() != host:
|
|
294
|
+
continue
|
|
295
|
+
segments = _decoded_segments(where.path)
|
|
296
|
+
if segments == here:
|
|
297
|
+
selves.append(is_dir)
|
|
298
|
+
elif segments[:-1] == here:
|
|
299
|
+
# A direct member only: a deeper one (a server that ignores
|
|
300
|
+
# the Depth) or one elsewhere is not listed here.
|
|
301
|
+
members.append((segments[-1], is_dir, size, lm))
|
|
302
|
+
if replies and not (selves or members):
|
|
303
|
+
raise OSError(
|
|
304
|
+
_errno.EIO,
|
|
305
|
+
f"The PROPFIND reply for {self} names neither the collection "
|
|
306
|
+
"nor any member of it",
|
|
307
|
+
)
|
|
308
|
+
if selves and not any(selves):
|
|
309
|
+
# The "." entry describing self, per RFC 4918. A Depth:1
|
|
310
|
+
# PROPFIND on a non-collection answers with only this entry,
|
|
311
|
+
# which read as an empty directory.
|
|
312
|
+
raise NotADirectoryError(
|
|
313
|
+
_errno.ENOTDIR, _os.strerror(_errno.ENOTDIR), str(self)
|
|
314
|
+
)
|
|
315
|
+
for name, is_dir, size, lm in members:
|
|
316
|
+
# The href is untrusted: an entry such as `%2E%2E/` decodes to
|
|
317
|
+
# "..", which let a recursive copy write outside its destination.
|
|
318
|
+
if _providers.is_safe_child_name(name):
|
|
319
|
+
yield name, FileStat(
|
|
320
|
+
st_size=size, st_mtime=_httpdate.parsedate(lm), is_dir=is_dir
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
def _listdir(self):
|
|
324
|
+
for name, _stat_ in self._scandir():
|
|
325
|
+
yield name
|
|
326
|
+
|
|
327
|
+
def _open(self, mode="r", buffering=-1):
|
|
328
|
+
if mode == "r":
|
|
329
|
+
req = self._get(self._wire_uri(), headers=_IDENTITY_ENCODING)
|
|
330
|
+
content_type = req.headers.get("Content-Type", "")
|
|
331
|
+
if content_type.split(";")[0].strip().lower() == "text/html":
|
|
332
|
+
# GET on a collection is server-defined (RFC 4918 9.4) and
|
|
333
|
+
# usually an HTML index, which was read back as content.
|
|
334
|
+
# Only an HTML answer pays the PROPFIND that tells them apart.
|
|
335
|
+
try:
|
|
336
|
+
is_dir = self.stat().is_dir()
|
|
337
|
+
except OSError:
|
|
338
|
+
is_dir = False
|
|
339
|
+
if is_dir:
|
|
340
|
+
req.close()
|
|
341
|
+
raise IsADirectoryError(
|
|
342
|
+
_errno.EISDIR, _os.strerror(_errno.EISDIR), str(self)
|
|
343
|
+
)
|
|
344
|
+
return _response_reader(self, req, buffering)
|
|
345
|
+
if mode not in ("w", "x"):
|
|
346
|
+
raise NotImplementedError(f"open(mode={mode!r})")
|
|
347
|
+
if mode == "x":
|
|
348
|
+
self._ensure_missing()
|
|
349
|
+
return _DAVWriteStream(self, exclusive=mode == "x")
|
|
350
|
+
|
|
351
|
+
def _mkdir(self, mode):
|
|
352
|
+
self._dav_request(
|
|
353
|
+
"MKCOL",
|
|
354
|
+
statuses={
|
|
355
|
+
409: FileNotFoundError,
|
|
356
|
+
405: FileExistsError,
|
|
357
|
+
501: FileExistsError,
|
|
358
|
+
},
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
def unlink(self, missing_ok=False):
|
|
362
|
+
# WebDAV DELETE on a collection is recursive (RFC 4918), and pathlib's unlink()
|
|
363
|
+
# never removes a directory: check with a Depth:0 PROPFIND first. The recursive
|
|
364
|
+
# DELETE stays available as `rm(recursive=True)`.
|
|
365
|
+
try:
|
|
366
|
+
root = self._propfind(depth="0")
|
|
367
|
+
except FileNotFoundError:
|
|
368
|
+
if missing_ok:
|
|
369
|
+
return
|
|
370
|
+
raise
|
|
371
|
+
responses = root.findall("D:response", _NS)
|
|
372
|
+
if responses and _parse_response(responses[0])[1]:
|
|
373
|
+
raise IsADirectoryError(
|
|
374
|
+
_errno.EISDIR, _os.strerror(_errno.EISDIR), str(self)
|
|
375
|
+
)
|
|
376
|
+
self._delete(missing_ok=missing_ok)
|
|
377
|
+
|
|
378
|
+
def _delete(self, missing_ok=False):
|
|
379
|
+
# The raw DELETE, with no collection guard: `rmdir()` has already
|
|
380
|
+
# verified an empty directory before calling it.
|
|
381
|
+
try:
|
|
382
|
+
self._dav_request("DELETE")
|
|
383
|
+
except FileNotFoundError:
|
|
384
|
+
if not missing_ok:
|
|
385
|
+
raise
|
|
386
|
+
|
|
387
|
+
def rmdir(self):
|
|
388
|
+
# A PROPFIND Depth:1 on a non-collection returns only its own "." entry, which
|
|
389
|
+
# _scandir() filters, so a *file* looks like an empty directory to _listdir() and
|
|
390
|
+
# rmdir() would DELETE it. stat(), not is_dir(): a missing path is FileNotFoundError.
|
|
391
|
+
if not self.stat().is_dir():
|
|
392
|
+
raise NotADirectoryError(
|
|
393
|
+
_errno.ENOTDIR, _os.strerror(_errno.ENOTDIR), str(self)
|
|
394
|
+
)
|
|
395
|
+
for _ in self._listdir():
|
|
396
|
+
raise OSError(_errno.ENOTEMPTY, "Directory not empty", str(self))
|
|
397
|
+
self._delete()
|
|
398
|
+
|
|
399
|
+
def rm(
|
|
400
|
+
self,
|
|
401
|
+
/,
|
|
402
|
+
recursive: bool = False,
|
|
403
|
+
missing_ok: bool = False,
|
|
404
|
+
ignore_error: "bool | _ty.Callable[[Exception, DAVURIPath], bool]" = False,
|
|
405
|
+
*,
|
|
406
|
+
follow_symlinks=False,
|
|
407
|
+
follow_binds=False,
|
|
408
|
+
):
|
|
409
|
+
# A collection holds no symlinks or bindings: the policies are
|
|
410
|
+
# checked and have nothing to decide.
|
|
411
|
+
check_follow("follow_symlinks", follow_symlinks)
|
|
412
|
+
check_follow("follow_binds", follow_binds)
|
|
413
|
+
if not recursive:
|
|
414
|
+
return super().rm(
|
|
415
|
+
recursive=recursive,
|
|
416
|
+
missing_ok=missing_ok,
|
|
417
|
+
ignore_error=ignore_error,
|
|
418
|
+
follow_symlinks=follow_symlinks,
|
|
419
|
+
follow_binds=follow_binds,
|
|
420
|
+
)
|
|
421
|
+
# WebDAV DELETE is recursive by spec (RFC 4918): one request here
|
|
422
|
+
# replaces the base implementation's client-side stat+walk+unlink.
|
|
423
|
+
try:
|
|
424
|
+
try:
|
|
425
|
+
self._dav_request("DELETE")
|
|
426
|
+
except FileNotFoundError:
|
|
427
|
+
if not missing_ok:
|
|
428
|
+
raise
|
|
429
|
+
except Exception as error:
|
|
430
|
+
onerror = (
|
|
431
|
+
ignore_error
|
|
432
|
+
if callable(ignore_error)
|
|
433
|
+
else (lambda _e, _p: bool(ignore_error))
|
|
434
|
+
)
|
|
435
|
+
if not onerror(error, self):
|
|
436
|
+
raise
|
|
437
|
+
|
|
438
|
+
def rename(self, target: "DAVURIPath | URI | str"):
|
|
439
|
+
target = self._rename_target(target)
|
|
440
|
+
# The new path carries the target's own query, not this path's: a
|
|
441
|
+
# signature or token in the query is good for the source only.
|
|
442
|
+
moved = self._from_parsed_parts(
|
|
443
|
+
self.source, target.path, target.query, target.fragment
|
|
444
|
+
)
|
|
445
|
+
# No userinfo in the header: the credentials already travel as `auth=` and a
|
|
446
|
+
# header value ends up in server and proxy logs. No fragment either: a server
|
|
447
|
+
# does not resolve it.
|
|
448
|
+
dest = split_userinfo(moved.with_fragment("")._wire_uri())[0]
|
|
449
|
+
self._dav_request(
|
|
450
|
+
"MOVE",
|
|
451
|
+
headers={"Destination": dest, "Overwrite": "F"},
|
|
452
|
+
statuses={
|
|
453
|
+
# 409: the destination's parent collection is missing.
|
|
454
|
+
409: lambda _self: os_error(FileNotFoundError, target),
|
|
455
|
+
412: lambda _self: os_error(FileExistsError, target),
|
|
456
|
+
},
|
|
457
|
+
)
|
|
458
|
+
# pathlib returns the new path.
|
|
459
|
+
return moved
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib as _contextlib
|
|
4
|
+
import typing as _ty
|
|
5
|
+
|
|
6
|
+
from pathapi.protocols import supports as _supports
|
|
7
|
+
|
|
8
|
+
from ._path import URIPath
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _scandir(self):
|
|
12
|
+
with self._delegated():
|
|
13
|
+
yield from self._core_path._scandir()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _listdir(self):
|
|
17
|
+
for name, _stat in self._scandir():
|
|
18
|
+
yield name
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _forward(name: str):
|
|
22
|
+
"""A method that calls the core path's method of the same name, with the
|
|
23
|
+
errors it raises naming this path."""
|
|
24
|
+
|
|
25
|
+
def method(self, *args, **kwargs):
|
|
26
|
+
with self._delegated():
|
|
27
|
+
return getattr(self._core_path, name)(*args, **kwargs)
|
|
28
|
+
|
|
29
|
+
method.__name__ = name
|
|
30
|
+
return method
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
#: Every hook a scheme may delegate, by name; `_DELEGATED` of the class says
|
|
34
|
+
#: which of them it does, so a hook the core path lacks stays unimplemented.
|
|
35
|
+
_FORWARDERS: "dict[str, object]" = {
|
|
36
|
+
name: _forward(name)
|
|
37
|
+
for name in (
|
|
38
|
+
"_open",
|
|
39
|
+
"_mkdir",
|
|
40
|
+
"unlink",
|
|
41
|
+
"rmdir",
|
|
42
|
+
"chmod",
|
|
43
|
+
"_chown",
|
|
44
|
+
"_utime",
|
|
45
|
+
"_symlink_to",
|
|
46
|
+
"readlink",
|
|
47
|
+
"_write_batch",
|
|
48
|
+
"_same_filesystem",
|
|
49
|
+
"_node_key",
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
_FORWARDERS["_scandir"] = _scandir
|
|
53
|
+
_FORWARDERS["_listdir"] = _listdir
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class DelegatingURIPath(URIPath):
|
|
57
|
+
"""A `URIPath` whose I/O is done by a core path that needs no URI.
|
|
58
|
+
|
|
59
|
+
A scheme names the hooks it hands over in `_DELEGATED` and builds the core
|
|
60
|
+
path for its URI in `_make_core()`; everything about URIs (parsing,
|
|
61
|
+
joining, backends, the text of the path) stays here. The core path is made
|
|
62
|
+
once per instance and used for every call, and an `OSError` it raises names
|
|
63
|
+
this path, not the core one."""
|
|
64
|
+
|
|
65
|
+
__slots__ = ("_core",)
|
|
66
|
+
_DELEGATED: "_ty.Sequence[str]" = ()
|
|
67
|
+
#: Whether an `OSError` of the core path is rewritten to name this path; a
|
|
68
|
+
#: core path that is itself the user's file (`LocalPath`) keeps its own.
|
|
69
|
+
_RELABEL = True
|
|
70
|
+
|
|
71
|
+
def __init_subclass__(cls, **kwargs):
|
|
72
|
+
super().__init_subclass__(**kwargs)
|
|
73
|
+
for name in cls.__dict__.get("_DELEGATED", ()):
|
|
74
|
+
if name not in cls.__dict__:
|
|
75
|
+
setattr(cls, name, _FORWARDERS[name])
|
|
76
|
+
|
|
77
|
+
def _make_core(self):
|
|
78
|
+
"""The core path this URI names."""
|
|
79
|
+
raise NotImplementedError
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def _core_path(self):
|
|
83
|
+
core = self._core
|
|
84
|
+
if core is None:
|
|
85
|
+
core = self._core = self._make_core()
|
|
86
|
+
return core
|
|
87
|
+
|
|
88
|
+
@_contextlib.contextmanager
|
|
89
|
+
def _delegated(self, *extra: "tuple[str, str]"):
|
|
90
|
+
"""Runs a call of the core path; an `OSError` that names the core path
|
|
91
|
+
(or a pair of `extra` `(core text, this text)`) names this one."""
|
|
92
|
+
if not self._RELABEL:
|
|
93
|
+
yield
|
|
94
|
+
return
|
|
95
|
+
try:
|
|
96
|
+
yield
|
|
97
|
+
except OSError as error:
|
|
98
|
+
core = str(self._core_path)
|
|
99
|
+
names = {**({core: str(self)} if core else {}), **dict(extra)}
|
|
100
|
+
_relabel(error, names)
|
|
101
|
+
raise
|
|
102
|
+
|
|
103
|
+
def _supports(self, protocol):
|
|
104
|
+
"""What the core path supports, for a protocol this class has a hook for;
|
|
105
|
+
`None` (the class's own answer, which is no) for one it has not."""
|
|
106
|
+
if not _supports(type(self), protocol):
|
|
107
|
+
return None
|
|
108
|
+
return _supports(self._core_path, protocol)
|
|
109
|
+
|
|
110
|
+
def stat(self, *, follow_symlinks=True):
|
|
111
|
+
hint = self._pop_stat_hint()
|
|
112
|
+
if hint is not None and not (follow_symlinks and hint.is_symlink()):
|
|
113
|
+
return hint
|
|
114
|
+
with self._delegated():
|
|
115
|
+
return self._core_path.stat(follow_symlinks=follow_symlinks)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _relabel(error: OSError, names: "dict[str, str]") -> None:
|
|
119
|
+
for attribute in ("filename", "filename2"):
|
|
120
|
+
value = getattr(error, attribute)
|
|
121
|
+
if value in names:
|
|
122
|
+
setattr(error, attribute, names[value])
|
|
123
|
+
if any(isinstance(arg, str) and arg in names for arg in error.args):
|
|
124
|
+
error.args = tuple(
|
|
125
|
+
names[arg] if isinstance(arg, str) and arg in names else arg
|
|
126
|
+
for arg in error.args
|
|
127
|
+
)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Schemes that other distributions provide.
|
|
2
|
+
|
|
3
|
+
`uripath` knows their names. A path of one of these schemes builds, joins,
|
|
4
|
+
compares and prints like any other, and the first operation that needs the
|
|
5
|
+
server raises an `ImportError` that names what to install.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from ._path import URIPath
|
|
11
|
+
|
|
12
|
+
#: Scheme -> (the distribution that provides it, its import name, what to run
|
|
13
|
+
#: to get it). A name listed here is known without the distribution: a path of
|
|
14
|
+
#: it is built, and its I/O raises an `ImportError` naming what to install. An
|
|
15
|
+
#: installed distribution registers its own class (an entry point of
|
|
16
|
+
#: `uripath.schemes`), which is found first.
|
|
17
|
+
SCHEMES = {
|
|
18
|
+
"sftp": (
|
|
19
|
+
"sftppath",
|
|
20
|
+
"sftppath",
|
|
21
|
+
'pip install "sftppath[paramiko]" (or "sftppath[asyncssh]")',
|
|
22
|
+
),
|
|
23
|
+
"s3": (
|
|
24
|
+
"objpath",
|
|
25
|
+
"objpath",
|
|
26
|
+
'pip install "objpath[s3]"',
|
|
27
|
+
),
|
|
28
|
+
"gs": (
|
|
29
|
+
"objpath",
|
|
30
|
+
"objpath",
|
|
31
|
+
'pip install "objpath[gs]"',
|
|
32
|
+
),
|
|
33
|
+
"az": (
|
|
34
|
+
"objpath",
|
|
35
|
+
"objpath",
|
|
36
|
+
'pip install "objpath[az]"',
|
|
37
|
+
),
|
|
38
|
+
"repo": ("repopath", "repopath", "pip install repopath"),
|
|
39
|
+
"github": ("repopath", "repopath", "pip install repopath"),
|
|
40
|
+
"gitlab": ("repopath", "repopath", "pip install repopath"),
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
#: The schemes of that table whose userinfo is an access token and not a user
|
|
44
|
+
#: name: `str()` and `repr()` of a `Source` show none of it. Known here, and
|
|
45
|
+
#: not only to the distribution that provides them, so that no display form of a
|
|
46
|
+
#: `Source` shows a token before that distribution is loaded.
|
|
47
|
+
TOKEN_SCHEMES = frozenset({"repo", "github", "gitlab"})
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def not_installed(scheme: str) -> ImportError:
|
|
51
|
+
"""The `ImportError` for a scheme another distribution provides."""
|
|
52
|
+
distribution, package, install = SCHEMES[scheme]
|
|
53
|
+
return ImportError(
|
|
54
|
+
f"the {scheme!r} scheme is provided by the {distribution} distribution: "
|
|
55
|
+
f"{install}",
|
|
56
|
+
name=package,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _unavailable(name: str):
|
|
61
|
+
"""A hook of `URIPath` that does the one thing a path of a missing scheme
|
|
62
|
+
can do about I/O."""
|
|
63
|
+
|
|
64
|
+
def hook(self, *args, **kwargs):
|
|
65
|
+
raise not_installed(self.source.scheme.lower())
|
|
66
|
+
|
|
67
|
+
hook.__name__ = name
|
|
68
|
+
return hook
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ProvidedElsewhere(URIPath):
|
|
72
|
+
"""The class of a path whose scheme another distribution provides and that is
|
|
73
|
+
not installed. It registers no scheme: an installed distribution registers
|
|
74
|
+
its own class, which is found first. Every primitive of `URIPath` raises
|
|
75
|
+
the `ImportError` of `not_installed()`, so each operation derived from one
|
|
76
|
+
does too."""
|
|
77
|
+
|
|
78
|
+
__slots__ = ()
|
|
79
|
+
#: The keyword arguments of the real class are that distribution's to name.
|
|
80
|
+
_OPTIONS = None
|
|
81
|
+
|
|
82
|
+
def _initbackend(self):
|
|
83
|
+
raise not_installed(self.source.scheme.lower())
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
for _name in (
|
|
87
|
+
"stat",
|
|
88
|
+
"_open",
|
|
89
|
+
"_listdir",
|
|
90
|
+
"_scandir",
|
|
91
|
+
"unlink",
|
|
92
|
+
"_mkdir",
|
|
93
|
+
"rmdir",
|
|
94
|
+
"rename",
|
|
95
|
+
"chmod",
|
|
96
|
+
"_chown",
|
|
97
|
+
"_utime",
|
|
98
|
+
"readlink",
|
|
99
|
+
"_symlink_to",
|
|
100
|
+
):
|
|
101
|
+
setattr(ProvidedElsewhere, _name, _unavailable(_name))
|
|
102
|
+
del _name
|