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.
@@ -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
+ ![Swarmauri Logo](https://github.com/swarmauri/swarmauri-sdk/blob/3d4d1cfa949399d7019ae9d8f296afba773dfb7f/assets/swarmauri.brand.theme.svg)
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.5.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,6 @@
1
+ [peagen.plugins.storage_adapters]
2
+ webdav=swarmauri_storage_webdav.webdav_storage_adapter:WebDAVStorageAdapter
3
+
4
+ [swarmauri.storage_adapters]
5
+ WebDAVStorageAdapter=swarmauri_storage_webdav.webdav_storage_adapter:WebDAVStorageAdapter
6
+
@@ -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.