swarmauri_storage_webdav 0.11.0.dev1__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.
- swarmauri_storage_webdav/__init__.py +22 -0
- swarmauri_storage_webdav/exceptions.py +17 -0
- swarmauri_storage_webdav/webdav_storage_adapter.py +486 -0
- swarmauri_storage_webdav-0.11.0.dev1.dist-info/METADATA +116 -0
- swarmauri_storage_webdav-0.11.0.dev1.dist-info/RECORD +8 -0
- swarmauri_storage_webdav-0.11.0.dev1.dist-info/WHEEL +4 -0
- swarmauri_storage_webdav-0.11.0.dev1.dist-info/entry_points.txt +6 -0
- swarmauri_storage_webdav-0.11.0.dev1.dist-info/licenses/LICENSE +13 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
2
|
+
|
|
3
|
+
from .exceptions import (
|
|
4
|
+
WebDAVError,
|
|
5
|
+
WebDAVInsufficientStorageError,
|
|
6
|
+
WebDAVLockedError,
|
|
7
|
+
WebDAVPreconditionError,
|
|
8
|
+
)
|
|
9
|
+
from .webdav_storage_adapter import WebDAVStorageAdapter
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"WebDAVError",
|
|
13
|
+
"WebDAVInsufficientStorageError",
|
|
14
|
+
"WebDAVLockedError",
|
|
15
|
+
"WebDAVPreconditionError",
|
|
16
|
+
"WebDAVStorageAdapter",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
__version__ = version("swarmauri_storage_webdav")
|
|
21
|
+
except PackageNotFoundError: # pragma: no cover
|
|
22
|
+
__version__ = "0.0.0"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Exceptions raised by the WebDAV storage adapter."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class WebDAVError(RuntimeError):
|
|
5
|
+
"""Base exception for WebDAV protocol failures."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class WebDAVLockedError(WebDAVError):
|
|
9
|
+
"""Raised when a WebDAV resource is locked."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class WebDAVPreconditionError(WebDAVError):
|
|
13
|
+
"""Raised when a WebDAV precondition fails."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class WebDAVInsufficientStorageError(WebDAVError, OSError):
|
|
17
|
+
"""Raised when the WebDAV server reports insufficient storage."""
|
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
"""WebDAV-backed Swarmauri storage adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import io
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
from pathlib import Path, PurePosixPath
|
|
10
|
+
from typing import BinaryIO, Iterator, Literal
|
|
11
|
+
from urllib.parse import quote, unquote, urlsplit, urlunsplit
|
|
12
|
+
from xml.etree.ElementTree import Element, fromstring
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
from pydantic import SecretStr
|
|
16
|
+
|
|
17
|
+
from swarmauri_base.ComponentBase import ComponentBase
|
|
18
|
+
from swarmauri_base.storage import StorageAdapterBase
|
|
19
|
+
|
|
20
|
+
from .exceptions import (
|
|
21
|
+
WebDAVError,
|
|
22
|
+
WebDAVInsufficientStorageError,
|
|
23
|
+
WebDAVLockedError,
|
|
24
|
+
WebDAVPreconditionError,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@ComponentBase.register_type(StorageAdapterBase, "WebDAVStorageAdapter")
|
|
29
|
+
class WebDAVStorageAdapter(StorageAdapterBase):
|
|
30
|
+
"""Store and retrieve artifacts through a WebDAV collection."""
|
|
31
|
+
|
|
32
|
+
type: Literal["WebDAVStorageAdapter"] = "WebDAVStorageAdapter"
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
base_url: str,
|
|
37
|
+
*,
|
|
38
|
+
prefix: str = "",
|
|
39
|
+
username: str | None = None,
|
|
40
|
+
password: SecretStr | str | None = None,
|
|
41
|
+
bearer_token: SecretStr | str | None = None,
|
|
42
|
+
auth: httpx.Auth | None = None,
|
|
43
|
+
verify: bool | str = True,
|
|
44
|
+
cert: str | tuple[str, str] | None = None,
|
|
45
|
+
timeout: float = 30.0,
|
|
46
|
+
follow_redirects: bool = True,
|
|
47
|
+
headers: dict[str, str] | None = None,
|
|
48
|
+
client: httpx.Client | None = None,
|
|
49
|
+
**kwargs,
|
|
50
|
+
) -> None:
|
|
51
|
+
super().__init__(**kwargs)
|
|
52
|
+
|
|
53
|
+
parsed = urlsplit(base_url)
|
|
54
|
+
if parsed.scheme not in {"http", "https"}:
|
|
55
|
+
raise ValueError("base_url must use http:// or https://")
|
|
56
|
+
if parsed.username is not None or parsed.password is not None:
|
|
57
|
+
raise ValueError("base_url must not contain credentials")
|
|
58
|
+
if parsed.query or parsed.fragment:
|
|
59
|
+
raise ValueError("base_url must not contain a query or fragment")
|
|
60
|
+
|
|
61
|
+
self._base_url = base_url.rstrip("/") + "/"
|
|
62
|
+
self._prefix = self.normalize_prefix(prefix)
|
|
63
|
+
self._owns_client = client is None
|
|
64
|
+
|
|
65
|
+
password_value = self._secret_value(password)
|
|
66
|
+
bearer_value = self._secret_value(bearer_token)
|
|
67
|
+
|
|
68
|
+
if auth is not None and (username or password_value or bearer_value):
|
|
69
|
+
raise ValueError(
|
|
70
|
+
"auth cannot be combined with username, password, "
|
|
71
|
+
"or bearer_token"
|
|
72
|
+
)
|
|
73
|
+
if bearer_value and (username or password_value):
|
|
74
|
+
raise ValueError(
|
|
75
|
+
"bearer_token cannot be combined with username or password"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
request_headers = dict(headers or {})
|
|
79
|
+
if bearer_value:
|
|
80
|
+
request_headers["Authorization"] = f"Bearer {bearer_value}"
|
|
81
|
+
|
|
82
|
+
resolved_auth = auth
|
|
83
|
+
if resolved_auth is None and username is not None:
|
|
84
|
+
resolved_auth = httpx.BasicAuth(username, password_value or "")
|
|
85
|
+
|
|
86
|
+
self._client = client or httpx.Client(
|
|
87
|
+
auth=resolved_auth,
|
|
88
|
+
verify=verify,
|
|
89
|
+
cert=cert,
|
|
90
|
+
timeout=timeout,
|
|
91
|
+
follow_redirects=follow_redirects,
|
|
92
|
+
headers=request_headers,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
@staticmethod
|
|
96
|
+
def _secret_value(value: SecretStr | str | None) -> str | None:
|
|
97
|
+
if isinstance(value, SecretStr):
|
|
98
|
+
return value.get_secret_value() or None
|
|
99
|
+
return value or None
|
|
100
|
+
|
|
101
|
+
def _full_key(self, key: str, *, allow_empty: bool = False) -> str:
|
|
102
|
+
return self.compose_key(
|
|
103
|
+
self._prefix,
|
|
104
|
+
key,
|
|
105
|
+
allow_empty=allow_empty,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def _url_for_full_key(
|
|
109
|
+
self,
|
|
110
|
+
full_key: str,
|
|
111
|
+
*,
|
|
112
|
+
collection: bool = False,
|
|
113
|
+
) -> str:
|
|
114
|
+
normalized = self.normalize_key(full_key, allow_empty=True)
|
|
115
|
+
encoded = "/".join(
|
|
116
|
+
quote(part, safe="") for part in normalized.split("/") if part
|
|
117
|
+
)
|
|
118
|
+
url = self._base_url + encoded
|
|
119
|
+
if collection and not url.endswith("/"):
|
|
120
|
+
url += "/"
|
|
121
|
+
return url
|
|
122
|
+
|
|
123
|
+
def _url(self, key: str, *, collection: bool = False) -> str:
|
|
124
|
+
return self._url_for_full_key(
|
|
125
|
+
self._full_key(key, allow_empty=True),
|
|
126
|
+
collection=collection,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def root_uri(self) -> str:
|
|
131
|
+
"""Return the configured WebDAV collection URI."""
|
|
132
|
+
return self._url("", collection=True)
|
|
133
|
+
|
|
134
|
+
def _raise_for_status(
|
|
135
|
+
self,
|
|
136
|
+
response: httpx.Response,
|
|
137
|
+
*,
|
|
138
|
+
key: str | None = None,
|
|
139
|
+
) -> None:
|
|
140
|
+
status = response.status_code
|
|
141
|
+
target = key or str(response.request.url)
|
|
142
|
+
if status < 400:
|
|
143
|
+
return
|
|
144
|
+
if status == 404:
|
|
145
|
+
raise FileNotFoundError(target)
|
|
146
|
+
if status in {401, 403}:
|
|
147
|
+
raise PermissionError(target)
|
|
148
|
+
if status == 412:
|
|
149
|
+
raise WebDAVPreconditionError(target)
|
|
150
|
+
if status == 423:
|
|
151
|
+
raise WebDAVLockedError(target)
|
|
152
|
+
if status == 507:
|
|
153
|
+
raise WebDAVInsufficientStorageError(target)
|
|
154
|
+
raise WebDAVError(
|
|
155
|
+
f"{response.request.method} {response.request.url} "
|
|
156
|
+
f"returned HTTP {status}"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def capabilities(self) -> set[str]:
|
|
160
|
+
"""Return capabilities advertised by the WebDAV endpoint."""
|
|
161
|
+
response = self._client.request("OPTIONS", self.root_uri)
|
|
162
|
+
self._raise_for_status(response)
|
|
163
|
+
return {
|
|
164
|
+
capability.strip()
|
|
165
|
+
for capability in response.headers.get("DAV", "").split(",")
|
|
166
|
+
if capability.strip()
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
def _ensure_collection(self, full_key: str) -> None:
|
|
170
|
+
"""Create each missing collection beneath the endpoint root."""
|
|
171
|
+
normalized = self.normalize_key(full_key, allow_empty=True)
|
|
172
|
+
current: list[str] = []
|
|
173
|
+
for part in PurePosixPath(normalized).parts:
|
|
174
|
+
current.append(part)
|
|
175
|
+
key = "/".join(current)
|
|
176
|
+
response = self._client.request(
|
|
177
|
+
"MKCOL",
|
|
178
|
+
self._url_for_full_key(key, collection=True),
|
|
179
|
+
)
|
|
180
|
+
if response.status_code not in {201, 405}:
|
|
181
|
+
self._raise_for_status(response, key=key)
|
|
182
|
+
|
|
183
|
+
def mkdir(self, key: str, *, parents: bool = True) -> str:
|
|
184
|
+
"""Create a collection and return its URL."""
|
|
185
|
+
full_key = self._full_key(key)
|
|
186
|
+
if parents:
|
|
187
|
+
self._ensure_collection(full_key)
|
|
188
|
+
else:
|
|
189
|
+
response = self._client.request(
|
|
190
|
+
"MKCOL",
|
|
191
|
+
self._url_for_full_key(full_key, collection=True),
|
|
192
|
+
)
|
|
193
|
+
if response.status_code not in {201, 405}:
|
|
194
|
+
self._raise_for_status(response, key=key)
|
|
195
|
+
return self._url_for_full_key(full_key, collection=True)
|
|
196
|
+
|
|
197
|
+
def upload(self, key: str, data: BinaryIO) -> str:
|
|
198
|
+
"""Upload a stream with PUT and return its resource URL."""
|
|
199
|
+
normalized_key = self.normalize_key(key)
|
|
200
|
+
full_key = self._full_key(normalized_key)
|
|
201
|
+
parent = str(PurePosixPath(full_key).parent)
|
|
202
|
+
if parent != ".":
|
|
203
|
+
self._ensure_collection(parent)
|
|
204
|
+
response = self._client.put(
|
|
205
|
+
self._url_for_full_key(full_key),
|
|
206
|
+
content=data,
|
|
207
|
+
)
|
|
208
|
+
self._raise_for_status(response, key=key)
|
|
209
|
+
return self._url_for_full_key(full_key)
|
|
210
|
+
|
|
211
|
+
def download(self, key: str) -> BinaryIO:
|
|
212
|
+
"""Download a resource into a seekable binary stream."""
|
|
213
|
+
normalized_key = self.normalize_key(key)
|
|
214
|
+
response = self._client.get(self._url(normalized_key))
|
|
215
|
+
self._raise_for_status(response, key=key)
|
|
216
|
+
stream = io.BytesIO(response.content)
|
|
217
|
+
stream.seek(0)
|
|
218
|
+
return stream
|
|
219
|
+
|
|
220
|
+
def _propfind(self, key: str, *, depth: int = 1) -> httpx.Response:
|
|
221
|
+
body = b"""<?xml version="1.0" encoding="utf-8"?>
|
|
222
|
+
<propfind xmlns="DAV:">
|
|
223
|
+
<prop>
|
|
224
|
+
<resourcetype />
|
|
225
|
+
<getcontentlength />
|
|
226
|
+
<getcontenttype />
|
|
227
|
+
<getetag />
|
|
228
|
+
<getlastmodified />
|
|
229
|
+
</prop>
|
|
230
|
+
</propfind>
|
|
231
|
+
"""
|
|
232
|
+
response = self._client.request(
|
|
233
|
+
"PROPFIND",
|
|
234
|
+
self._url(key, collection=True),
|
|
235
|
+
headers={
|
|
236
|
+
"Depth": str(depth),
|
|
237
|
+
"Content-Type": "application/xml; charset=utf-8",
|
|
238
|
+
},
|
|
239
|
+
content=body,
|
|
240
|
+
)
|
|
241
|
+
self._raise_for_status(response, key=key)
|
|
242
|
+
return response
|
|
243
|
+
|
|
244
|
+
def _relative_href(self, href: str) -> str:
|
|
245
|
+
base_path = unquote(urlsplit(self._base_url).path).rstrip("/") + "/"
|
|
246
|
+
href_path = unquote(urlsplit(href).path)
|
|
247
|
+
if not href_path.startswith(base_path):
|
|
248
|
+
raise WebDAVError(
|
|
249
|
+
f"server returned href outside configured root: {href!r}"
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
full_key = href_path[len(base_path) :].strip("/")
|
|
253
|
+
full_key = self.normalize_key(full_key, allow_empty=True)
|
|
254
|
+
if not self._prefix:
|
|
255
|
+
return full_key
|
|
256
|
+
if full_key == self._prefix:
|
|
257
|
+
return ""
|
|
258
|
+
if not full_key.startswith(f"{self._prefix}/"):
|
|
259
|
+
raise WebDAVError(
|
|
260
|
+
f"server returned href outside configured prefix: {href!r}"
|
|
261
|
+
)
|
|
262
|
+
return full_key[len(self._prefix) + 1 :]
|
|
263
|
+
|
|
264
|
+
def _children(self, key: str) -> list[tuple[str, bool]]:
|
|
265
|
+
response = self._propfind(key, depth=1)
|
|
266
|
+
root = self._parse_multistatus(response.content)
|
|
267
|
+
requested = self.normalize_prefix(key)
|
|
268
|
+
children: list[tuple[str, bool]] = []
|
|
269
|
+
for item in root.findall(".//{DAV:}response"):
|
|
270
|
+
href_node = item.find("{DAV:}href")
|
|
271
|
+
if href_node is None or not href_node.text:
|
|
272
|
+
continue
|
|
273
|
+
relative_key = self._relative_href(href_node.text)
|
|
274
|
+
if relative_key == requested:
|
|
275
|
+
continue
|
|
276
|
+
is_collection = (
|
|
277
|
+
item.find(".//{DAV:}resourcetype/{DAV:}collection") is not None
|
|
278
|
+
)
|
|
279
|
+
children.append((relative_key, is_collection))
|
|
280
|
+
return children
|
|
281
|
+
|
|
282
|
+
@staticmethod
|
|
283
|
+
def _parse_multistatus(payload: bytes) -> Element:
|
|
284
|
+
"""Parse DAV XML after rejecting declarations that define entities."""
|
|
285
|
+
lowered = payload.lower()
|
|
286
|
+
if b"<!doctype" in lowered or b"<!entity" in lowered:
|
|
287
|
+
raise WebDAVError("unsafe XML declaration in WebDAV response")
|
|
288
|
+
return fromstring(payload)
|
|
289
|
+
|
|
290
|
+
def iter_prefix(self, prefix: str = "") -> Iterator[str]:
|
|
291
|
+
"""Yield file keys using recursive, bounded Depth: 1 requests."""
|
|
292
|
+
pending = [self.normalize_prefix(prefix)]
|
|
293
|
+
visited: set[str] = set()
|
|
294
|
+
while pending:
|
|
295
|
+
current = pending.pop()
|
|
296
|
+
if current in visited:
|
|
297
|
+
continue
|
|
298
|
+
visited.add(current)
|
|
299
|
+
for key, is_collection in self._children(current):
|
|
300
|
+
if is_collection:
|
|
301
|
+
pending.append(key)
|
|
302
|
+
else:
|
|
303
|
+
yield key
|
|
304
|
+
|
|
305
|
+
def upload_dir(
|
|
306
|
+
self,
|
|
307
|
+
src: str | os.PathLike,
|
|
308
|
+
*,
|
|
309
|
+
prefix: str = "",
|
|
310
|
+
) -> None:
|
|
311
|
+
"""Recursively upload files beneath prefix."""
|
|
312
|
+
base = Path(src)
|
|
313
|
+
if not base.is_dir():
|
|
314
|
+
raise NotADirectoryError(base)
|
|
315
|
+
if prefix:
|
|
316
|
+
self.mkdir(prefix, parents=True)
|
|
317
|
+
for path in base.rglob("*"):
|
|
318
|
+
if path.is_file():
|
|
319
|
+
relative = path.relative_to(base).as_posix()
|
|
320
|
+
key = self.compose_key(prefix, relative)
|
|
321
|
+
with path.open("rb") as handle:
|
|
322
|
+
self.upload(key, handle)
|
|
323
|
+
|
|
324
|
+
def download_dir(
|
|
325
|
+
self,
|
|
326
|
+
prefix: str,
|
|
327
|
+
dest_dir: str | os.PathLike,
|
|
328
|
+
) -> None:
|
|
329
|
+
"""Recursively download files beneath prefix."""
|
|
330
|
+
normalized_prefix = self.normalize_prefix(prefix)
|
|
331
|
+
destination = Path(dest_dir)
|
|
332
|
+
for key in self.iter_prefix(normalized_prefix):
|
|
333
|
+
target_key = key
|
|
334
|
+
if normalized_prefix and key.startswith(f"{normalized_prefix}/"):
|
|
335
|
+
target_key = key[len(normalized_prefix) + 1 :]
|
|
336
|
+
target = self.download_target_for_key(destination, target_key)
|
|
337
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
338
|
+
with self.download(key) as source, target.open("wb") as output:
|
|
339
|
+
shutil.copyfileobj(source, output)
|
|
340
|
+
|
|
341
|
+
async def ensure_bucket(self) -> None:
|
|
342
|
+
"""Ensure the configured prefix collection exists."""
|
|
343
|
+
if self._prefix:
|
|
344
|
+
await asyncio.to_thread(self._ensure_collection, self._prefix)
|
|
345
|
+
else:
|
|
346
|
+
await asyncio.to_thread(self.capabilities)
|
|
347
|
+
|
|
348
|
+
async def put_bytes(
|
|
349
|
+
self,
|
|
350
|
+
object_key: str,
|
|
351
|
+
data: bytes,
|
|
352
|
+
content_type: str,
|
|
353
|
+
) -> None:
|
|
354
|
+
"""Store bytes without blocking the event loop."""
|
|
355
|
+
normalized_key = self.normalize_key(object_key)
|
|
356
|
+
full_key = self._full_key(normalized_key)
|
|
357
|
+
parent = str(PurePosixPath(full_key).parent)
|
|
358
|
+
if parent != ".":
|
|
359
|
+
await asyncio.to_thread(self._ensure_collection, parent)
|
|
360
|
+
|
|
361
|
+
def put() -> None:
|
|
362
|
+
response = self._client.put(
|
|
363
|
+
self._url_for_full_key(full_key),
|
|
364
|
+
content=data,
|
|
365
|
+
headers={"Content-Type": content_type},
|
|
366
|
+
)
|
|
367
|
+
self._raise_for_status(response, key=object_key)
|
|
368
|
+
|
|
369
|
+
await asyncio.to_thread(put)
|
|
370
|
+
|
|
371
|
+
async def get_bytes(self, object_key: str) -> bytes:
|
|
372
|
+
"""Retrieve bytes without blocking the event loop."""
|
|
373
|
+
return await asyncio.to_thread(self.get_blob, object_key)
|
|
374
|
+
|
|
375
|
+
async def get_range(
|
|
376
|
+
self,
|
|
377
|
+
object_key: str,
|
|
378
|
+
start: int,
|
|
379
|
+
length: int,
|
|
380
|
+
) -> bytes:
|
|
381
|
+
"""Retrieve a byte range with an HTTP Range request."""
|
|
382
|
+
if start < 0:
|
|
383
|
+
raise ValueError("start must be >= 0")
|
|
384
|
+
if length <= 0:
|
|
385
|
+
raise ValueError("length must be > 0")
|
|
386
|
+
|
|
387
|
+
def fetch() -> bytes:
|
|
388
|
+
response = self._client.get(
|
|
389
|
+
self._url(object_key),
|
|
390
|
+
headers={"Range": f"bytes={start}-{start + length - 1}"},
|
|
391
|
+
)
|
|
392
|
+
if response.status_code == 416:
|
|
393
|
+
raise ValueError("range start out of bounds")
|
|
394
|
+
self._raise_for_status(response, key=object_key)
|
|
395
|
+
if response.status_code == 206:
|
|
396
|
+
return response.content
|
|
397
|
+
if start >= len(response.content):
|
|
398
|
+
raise ValueError("range start out of bounds")
|
|
399
|
+
return response.content[start : start + length]
|
|
400
|
+
|
|
401
|
+
return await asyncio.to_thread(fetch)
|
|
402
|
+
|
|
403
|
+
async def remove_object(self, object_key: str) -> None:
|
|
404
|
+
"""Delete a resource if it exists."""
|
|
405
|
+
|
|
406
|
+
def remove() -> None:
|
|
407
|
+
response = self._client.delete(self._url(object_key))
|
|
408
|
+
if response.status_code != 404:
|
|
409
|
+
self._raise_for_status(response, key=object_key)
|
|
410
|
+
|
|
411
|
+
await asyncio.to_thread(remove)
|
|
412
|
+
|
|
413
|
+
def _transfer(
|
|
414
|
+
self,
|
|
415
|
+
method: Literal["COPY", "MOVE"],
|
|
416
|
+
source: str,
|
|
417
|
+
destination: str,
|
|
418
|
+
*,
|
|
419
|
+
overwrite: bool,
|
|
420
|
+
) -> str:
|
|
421
|
+
destination_key = self.normalize_key(destination)
|
|
422
|
+
destination_full_key = self._full_key(destination_key)
|
|
423
|
+
parent = str(PurePosixPath(destination_full_key).parent)
|
|
424
|
+
if parent != ".":
|
|
425
|
+
self._ensure_collection(parent)
|
|
426
|
+
response = self._client.request(
|
|
427
|
+
method,
|
|
428
|
+
self._url(source),
|
|
429
|
+
headers={
|
|
430
|
+
"Destination": self._url_for_full_key(destination_full_key),
|
|
431
|
+
"Overwrite": "T" if overwrite else "F",
|
|
432
|
+
},
|
|
433
|
+
)
|
|
434
|
+
self._raise_for_status(response, key=source)
|
|
435
|
+
return self._url_for_full_key(destination_full_key)
|
|
436
|
+
|
|
437
|
+
def copy(
|
|
438
|
+
self,
|
|
439
|
+
source: str,
|
|
440
|
+
destination: str,
|
|
441
|
+
*,
|
|
442
|
+
overwrite: bool = False,
|
|
443
|
+
) -> str:
|
|
444
|
+
"""Copy a resource server-side."""
|
|
445
|
+
return self._transfer("COPY", source, destination, overwrite=overwrite)
|
|
446
|
+
|
|
447
|
+
def move(
|
|
448
|
+
self,
|
|
449
|
+
source: str,
|
|
450
|
+
destination: str,
|
|
451
|
+
*,
|
|
452
|
+
overwrite: bool = False,
|
|
453
|
+
) -> str:
|
|
454
|
+
"""Move a resource server-side."""
|
|
455
|
+
return self._transfer("MOVE", source, destination, overwrite=overwrite)
|
|
456
|
+
|
|
457
|
+
def close(self) -> None:
|
|
458
|
+
"""Close the internally managed HTTP client."""
|
|
459
|
+
if self._owns_client:
|
|
460
|
+
self._client.close()
|
|
461
|
+
|
|
462
|
+
def __enter__(self) -> WebDAVStorageAdapter:
|
|
463
|
+
return self
|
|
464
|
+
|
|
465
|
+
def __exit__(self, *exc_info) -> None:
|
|
466
|
+
self.close()
|
|
467
|
+
|
|
468
|
+
@classmethod
|
|
469
|
+
def from_uri(cls, uri: str) -> WebDAVStorageAdapter:
|
|
470
|
+
"""Create an adapter from a webdav+http(s) URI."""
|
|
471
|
+
parsed = urlsplit(uri)
|
|
472
|
+
schemes = {
|
|
473
|
+
"webdav+http": "http",
|
|
474
|
+
"webdav+https": "https",
|
|
475
|
+
}
|
|
476
|
+
if parsed.scheme not in schemes:
|
|
477
|
+
raise ValueError("URI must use webdav+http:// or webdav+https://")
|
|
478
|
+
netloc = parsed.netloc.rsplit("@", 1)[-1]
|
|
479
|
+
base_url = urlunsplit(
|
|
480
|
+
(schemes[parsed.scheme], netloc, parsed.path, "", "")
|
|
481
|
+
)
|
|
482
|
+
return cls(
|
|
483
|
+
base_url=base_url,
|
|
484
|
+
username=unquote(parsed.username) if parsed.username else None,
|
|
485
|
+
password=unquote(parsed.password) if parsed.password else None,
|
|
486
|
+
)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: swarmauri_storage_webdav
|
|
3
|
+
Version: 0.11.0.dev1
|
|
4
|
+
Summary: WebDAV client storage adapter for SwarmauriSDK
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Keywords: swarmauri,sdk,standards,storage,webdav,dav,http-client,remote-filesystem
|
|
8
|
+
Author: Jacob Stewart
|
|
9
|
+
Author-email: jacob@swarmauri.com
|
|
10
|
+
Requires-Python: >=3.10,<3.13
|
|
11
|
+
Classifier: Development Status :: 1 - Planning
|
|
12
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Requires-Dist: httpx (>=0.27.0,<1)
|
|
21
|
+
Requires-Dist: swarmauri_base (>=0.11.0.dev5)
|
|
22
|
+
Requires-Dist: swarmauri_core (>=0.11.0.dev5)
|
|
23
|
+
Requires-Dist: swarmauri_standard (>=0.11.0.dev6)
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+

|
|
27
|
+
|
|
28
|
+
<p align="center">
|
|
29
|
+
<a href="https://pepy.tech/project/swarmauri_storage_webdav/">
|
|
30
|
+
<img src="https://static.pepy.tech/badge/swarmauri_storage_webdav/month" alt="PyPI - Downloads"/></a>
|
|
31
|
+
<a href="https://hits.sh/github.com/swarmauri/swarmauri-sdk/tree/master/pkgs/standards/swarmauri_storage_webdav/">
|
|
32
|
+
<img alt="Hits" src="https://hits.sh/github.com/swarmauri/swarmauri-sdk/tree/master/pkgs/standards/swarmauri_storage_webdav.svg"/></a>
|
|
33
|
+
<a href="https://pypi.org/project/swarmauri_storage_webdav/">
|
|
34
|
+
<img src="https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12-blue" alt="Supported Python Versions"/></a>
|
|
35
|
+
<a href="https://pypi.org/project/swarmauri_storage_webdav/">
|
|
36
|
+
<img src="https://img.shields.io/pypi/l/swarmauri_storage_webdav" alt="License"/></a>
|
|
37
|
+
<a href="https://pypi.org/project/swarmauri_storage_webdav/">
|
|
38
|
+
<img src="https://img.shields.io/pypi/v/swarmauri_storage_webdav?label=swarmauri_storage_webdav&color=green" alt="Release Version"/></a>
|
|
39
|
+
</p>
|
|
40
|
+
|
|
41
|
+
# Swarmauri Storage WebDAV
|
|
42
|
+
|
|
43
|
+
A WebDAV client storage adapter for SwarmauriSDK workflows that store files
|
|
44
|
+
and directory trees in remote DAV collections.
|
|
45
|
+
|
|
46
|
+
## Features
|
|
47
|
+
|
|
48
|
+
- Implements the Swarmauri storage adapter upload, download, push, pull,
|
|
49
|
+
range-read, byte, and removal workflows.
|
|
50
|
+
- Creates remote collections with `MKCOL` and enumerates directory trees with
|
|
51
|
+
bounded `PROPFIND Depth: 1` requests.
|
|
52
|
+
- Provides capability discovery through `OPTIONS` and server-side `COPY` and
|
|
53
|
+
`MOVE` extensions.
|
|
54
|
+
- Supports Basic, Bearer, custom `httpx.Auth`, TLS verification, and mutual TLS
|
|
55
|
+
client configuration.
|
|
56
|
+
- Normalizes logical keys, percent-encodes URL path segments, rejects path
|
|
57
|
+
traversal, and rejects DTD/entity declarations before parsing multistatus
|
|
58
|
+
XML.
|
|
59
|
+
- Provides discoverable `swarmauri.storage_adapters` and
|
|
60
|
+
`peagen.plugins.storage_adapters` entry points.
|
|
61
|
+
|
|
62
|
+
`LOCK` and `UNLOCK` are not exposed. Their presence alone does not establish
|
|
63
|
+
complete DAV class-2 behavior; lock-token ownership, refresh, timeout, and
|
|
64
|
+
conditional-write semantics require a dedicated contract.
|
|
65
|
+
|
|
66
|
+
## Installation
|
|
67
|
+
|
|
68
|
+
Install this package with `uv` or `pip`.
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
uv add swarmauri_storage_webdav
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
pip install swarmauri_storage_webdav
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Usage
|
|
79
|
+
|
|
80
|
+
Create an adapter for an existing WebDAV collection and optionally scope all
|
|
81
|
+
keys beneath a prefix.
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from swarmauri_storage_webdav import WebDAVStorageAdapter
|
|
85
|
+
|
|
86
|
+
with WebDAVStorageAdapter(
|
|
87
|
+
base_url="https://dav.example.com/remote.php/dav/files/jacob/",
|
|
88
|
+
username="jacob",
|
|
89
|
+
password="secret",
|
|
90
|
+
prefix="artifacts",
|
|
91
|
+
) as storage:
|
|
92
|
+
storage.put_blob("runs/42/result.json", b'{"status":"complete"}')
|
|
93
|
+
payload = storage.get_blob("runs/42/result.json")
|
|
94
|
+
|
|
95
|
+
storage.push("./dist", prefix="releases/0.1.0")
|
|
96
|
+
storage.pull("releases/0.1.0", "./download")
|
|
97
|
+
|
|
98
|
+
print(storage.capabilities())
|
|
99
|
+
print(payload)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
URI construction is also supported. Credentials are removed from the stored
|
|
103
|
+
HTTP endpoint and must not be logged.
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
storage = WebDAVStorageAdapter.from_uri(
|
|
107
|
+
"webdav+https://jacob:secret@dav.example.com/files/jacob/"
|
|
108
|
+
)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
The endpoint root must already exist. `ensure_bucket()` creates the configured
|
|
112
|
+
prefix when one is present; without a prefix it validates the endpoint through
|
|
113
|
+
`OPTIONS`.
|
|
114
|
+
|
|
115
|
+
License: Apache-2.0. See `LICENSE`.
|
|
116
|
+
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
swarmauri_storage_webdav/__init__.py,sha256=czmlhWe6KfeXVv6dcW5oycEFJcUpcg3RB8FDBVjH7pY,543
|
|
2
|
+
swarmauri_storage_webdav/exceptions.py,sha256=eVMCg8Nhvp6rVPLoJXazP3BRBxu-8NwWcb6NTWGMbNk,465
|
|
3
|
+
swarmauri_storage_webdav/webdav_storage_adapter.py,sha256=S6OBorktEBWZsLIhSspGiq7cipz57n8Ari4prTyqlpA,17055
|
|
4
|
+
swarmauri_storage_webdav-0.11.0.dev1.dist-info/METADATA,sha256=rxHLvlB7Py-6w0FjhMWCPUUE0Ei43SMLy1ln0Gtytbs,4517
|
|
5
|
+
swarmauri_storage_webdav-0.11.0.dev1.dist-info/WHEEL,sha256=L-WvLdSBvJWHXv5zrQxDvNRnIgoBzLUpcbuiw19KCuQ,88
|
|
6
|
+
swarmauri_storage_webdav-0.11.0.dev1.dist-info/entry_points.txt,sha256=DIpxYyCT4xqAZiJoJudXYOV4HaSXjIZZoOI299JSdD0,231
|
|
7
|
+
swarmauri_storage_webdav-0.11.0.dev1.dist-info/licenses/LICENSE,sha256=riw65vJ3RAyMUG3NkCRLxFKB8SQc13VDUD5DQa4kD_8,566
|
|
8
|
+
swarmauri_storage_webdav-0.11.0.dev1.dist-info/RECORD,,
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Copyright 2026 Jacob Stewart @ Swarmauri
|
|
2
|
+
|
|
3
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
you may not use this file except in compliance with the License.
|
|
5
|
+
You may obtain a copy of the License at
|
|
6
|
+
|
|
7
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
|
|
9
|
+
Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
See the License for the specific language governing permissions and
|
|
13
|
+
limitations under the License.
|