webdav-rfc4918
A secure-by-default WebDAV client for Python, built on RFC 4918.
Filesystem-style API, the raw protocol when you need it, a dav command, and TLS/mTLS support.
webdav-rfc4918 implements RFC 4918 for Python. webdav.FileSystem gives file-system-shaped access to a server (ls, open, walk, upload_file, ...); webdav.Session gives protocol-level access (the WebDAV verbs, raw responses, status codes) for when you need it.
- The whole protocol: every WebDAV method, multistatus parsing,
DepthandOverwrite,propname/allprop/include, extended MKCOL (RFC 5689), class 2 locking withIfheaders, conditional writes. Tested on Python 3.11 to 3.14 against a real WsgiDAV server and against a real Apachemod_davinstance (an independent implementation, cross-checked separately - known Apache-specific behavior is documented, not silently papered over), and against the oldestrequests/urllib3it supports. - Secure by default: a malicious server is the attacker this library is written against - TLS verification, safe redirect handling and bounded responses are all on by default, and turning any of them off is loud, never silent (details below).
- One filesystem API, two ways to call it: module-level functions for a single request,
FileSystemfor several - same names, same arguments, same return values (a test compares the signatures). - Consistent return types:
FileSystemoperations return plain values and raise aWebDAVError;Sessionverbs return aResponseand never raise unless asked - no call changes its result type depending on its arguments.
Requires Python 3.11 or newer. It is built on requests and urllib3.
Quick Start
pip install webdav-rfc4918
import webdav
auth = ("username", "password")
# One-off calls
webdav.mkdir("https://webdav.example.org/New/", auth=auth)
webdav.upload_file("Gorilla.jpg", "https://webdav.example.org/Photos/Gorilla.jpg", auth=auth)
webdav.ls("https://webdav.example.org/Photos/", auth=auth) # a list of Resource objects
# Several calls: a FileSystem keeps the connection open
with webdav.FileSystem("https://webdav.example.org", auth=auth) as fs:
fs.exists("Documents/Readme.md")
fs.ls("Photos")
fs.upload_file("Gorilla.jpg", "Photos/Gorilla.jpg")
fs.download_file("Photos/Gorilla.jpg", "copy.jpg")
# Protocol-level control: the raw WebDAV verbs, raw responses
with webdav.Session("https://webdav.example.org", auth=auth) as session:
response = session.propfind("/Photos/", depth=1)
response.multistatus.responses # the parsed 207 body
Session and FileSystem
Two peer classes, one rule each - neither lives inside the other:
| Names | Returns | On an error status | |
|---|---|---|---|
Session (verbs) |
get put delete head options propfind proppatch mkcol copy move lock unlock request |
a webdav.Response (a requests.Response) |
nothing is raised, like requests - call raise_for_status(), or pass raise_on_error=True (per call, or to the Session) |
FileSystem |
ls info exists isdir isfile get_props set_props mkdir remove copy move open walk upload_file download_file locked ... |
plain values | a WebDAVError is raised (also an HTTPError, where a status caused it) |
Default to FileSystem (or its module-level mirror) - it reads like ordinary Python filesystem code. Reach for Session directly only when you need the raw Response/status code, or there is no file-system operation for what you want - there is no module-level one-off for it, open one explicitly. Use both together, sharing one connection (and its locks), with FileSystem.from_session(session); fs.session is the session a FileSystem uses.
ls returns a list of Resource objects and info returns one. A Resource is its name (a str), so it can be handed to any other method unchanged, and it carries what the server reported:
for entry in fs.ls("Photos"):
print(entry, entry.is_dir, entry.size, entry.modified)
More of the same vocabulary:
for path, dirs, files in fs.walk("Photos"): # like os.walk: one Depth: 1 request per collection;
dirs[:] = [d for d in dirs if d != "Photos/tmp"] # the members are the Resources ls() returns
with fs.open("notes.txt", "w") as f: # uploaded when the block ends cleanly
f.write("hello")
session.put("/a.txt", data=b"new", if_match=etag) # only replace what you last saw
session.put("/b.txt", data=b"x", overwrite=False) # only create (If-None-Match: *)
fs.copy("/a.txt", "/c.txt", overwrite=True) # copy and replace (the default is Overwrite: F)
session.propfind("/a.txt", depth=0, prop_name=True) # the names of the properties, without values
Paths are plain names, percent-encoded for you exactly once: fs.upload_file(local, "100%.txt") writes a file called 100%.txt. A full URL (https://...) is used as written; a query goes in params=, not in the path. Secondary arguments are keyword-only, and the ones that matter for safety are required (propfind(url, depth=1)).
Errors
Everything the library raises is a WebDAVError, which is also a requests.RequestException - code that already catches requests' errors catches these too. An error status raises the exception for that status:
try:
fs.info("missing.txt")
except webdav.ResourceNotFoundError: # 404
...
except webdav.ResourceLockedError: # 423
...
except webdav.HTTPStatusError as exc: # any other status; exc.status_code, exc.response
...
except webdav.WebDAVError: # anything else the library refuses or cannot read
...
ResourceAlreadyExistsError (an upload or mkdir that must not replace), PreconditionFailedError (412), MultiStatusError (a 207 that reports a failure for some resource), RedirectNotFollowedError and MalformedResponseError (an answer that is not what RFC 4918 promises) are there too. A wrong argument is a ValueError or TypeError, as usual.
Configuration
Session(...), FileSystem(...) and every one-off function take the same options, and all but tls and trusted_redirect_origins can be changed on a session afterwards. A value that cannot work is refused when it is set:
| Option | Default | |
|---|---|---|
auth, headers |
none | credentials (never put them in a URL) and default headers - sent to your own origin only |
verify, cert, tls |
True, none, none |
server verification, client certificate, TLSOptions |
timeout |
(10, 60) |
connect and read timeout, in seconds |
max_response_time |
300 |
a deadline for the whole request, in seconds |
max_response_size |
64 MiB | body size after decompression; None lifts it |
redirect_policy, trusted_redirect_origins |
SAME_ORIGIN, none |
see Redirects |
max_redirects |
5 |
redirects in a row before a request is refused as a loop |
retry |
True |
retries safe requests on 429/5xx and dropped connections; False, or your own |
chunk_size |
4 MiB | for streamed uploads and downloads |
raise_on_error |
False |
raise on an error status (Session verbs) |
See Session and FileSystem for all of them.
Locking
Class 2 locking (RFC 4918 §7) with the If header handled for you:
with webdav.Session("https://webdav.example.org", auth=auth) as session:
fs = webdav.FileSystem.from_session(session)
with fs.locked("Documents/report.docx", lock_timeout=300) as lock:
# writes through this session to the locked path (or, for a locked
# collection, to its members) carry the lock token automatically
fs.upload_file("report.docx", "Documents/report.docx", overwrite=True)
fs.refresh_lock("Documents/report.docx", lock.token, lock_timeout=300)
# released on exit, even if the block raised
A lock times out, and nothing refreshes it for you: writes after that fail with 412, which is how you learn the lock is gone. Locking a path that does not exist yet creates an empty resource there, as the RFC requires. At the protocol level, session.lock(url) records what the server granted - so the writes that follow carry its token - and session.unlock(url, token) releases it. See Locking.
Security
A WebDAV server - or a redirect to one - can be hostile. The defaults assume so:
- TLS verification is on by default, and turning it off is never quiet.
verify=False(andNone,0,"") is accepted - it's your call to make, not this library's to forbid - but every use raises and logs aTLSHardeningDisabledWarning: its own warning class, not a subclass of anythingurllib3/requestsdefine, so a plainurllib3.disable_warnings()can't silence it as a side effect. The TLS 1.2 floor and strict certificate-chain checking (TLSOptions) get the same treatment. Prefer a CA file for a private CA over disabling verification.REQUESTS_CA_BUNDLE,CURL_CA_BUNDLEand~/.netrcare ignored either way. - Redirects are yours to allow. Only same-origin redirects are followed by default (
RedirectPolicy.SAME_ORIGIN). A hop to another origin never carries credentials, cookies, session headers, a client certificate or a lock token;https→httpis never followed, under any policy, not even for an explicitly trusted target.NEVER,WHITELISTandALLare there when you need them - see Redirects. - Bounded responses.
max_response_size(stacked content-codings are refused),max_response_time(headers included),max_redirects, aRetry-Afterwaited for at most 30 s, 200 000<response>elements per multistatus, limits onwalk. All can be tightened. A streamed download is bounded per read, not in size. - No accidental overwrites, no accidental retries.
copy/move/uploads don't overwrite unless told to, and the root of the session is never removed, copied or moved (aClientErrorbefore anything is sent);download_filewrites to a temporary file and moves it into place only once complete, never through a symlink; a write is never retried (a retriedmkdirwould report "exists", a retriedremove"not found" - the opposite of what happened). - A mistake fails loudly. A typo in an option (
allow_redirect=False) is aTypeError, not a request that quietly does the opposite; a flag has to be a realbool; every limit is checked when it is set.
Credentials are never logged, never appear in exception messages, and a URL carrying them (https://user:pw@host/) is refused in favor of auth=. A Session can be copied and pickled (to hand it to another process, for example); a pickle contains its credentials in clear text, so do not store or send one anywhere untrusted. See SECURITY.md for how to report a vulnerability, and the CHANGELOG for the full list of decisions.
from webdav import RedirectPolicy, Session
# Trust a signed-upload gateway - your credentials still go only to your own origin.
session = Session(
"https://webdav.example.org",
redirect_policy=RedirectPolicy.WHITELIST,
trusted_redirect_origins=["https://storage.example.com"],
)
# mTLS with a private CA
session = Session(
"https://webdav.example.org",
cert=("client.crt", "client.key"),
verify="ca-bundle.pem",
)
Encrypted private keys, CRL checking and cipher/TLS-version restriction are configured with TLSOptions - see TLS.
Command line
The dav command is part of the package:
dav ls webdav://webdav.example.org/Photos
dav get webdav://webdav.example.org/report.pdf ./report.pdf
dav put ./report.pdf webdav://webdav.example.org/report.pdf
Also info, cat, mkdir, rm, mv and cp. Authentication is --user and --password (or $WEBDAV_USER / $WEBDAV_PASSWORD), and the connection options - mTLS, redirect policy, limits - have flags too. See the CLI reference, or dav <command> --help.
fsspec
An optional fsspec filesystem, for projects (pandas, dask, ...) that want a WebDAV server behind the standard storage-backend interface instead of this library's own API:
pip install webdav-rfc4918[fsspec]
from webdav.fsspec import WebdavFileSystem
fs = WebdavFileSystem("https://webdav.example.org", auth=auth)
fs.ls("Photos", detail=False) # ['/Photos/Gorilla.jpg', ...]
Importing it registers "webdavs" with fsspec (not "webdav" - already mapped by fsspec to webdav4 by default; see fsspec for why). Paths start at the root of the base_url (/Photos/Gorilla.jpg), as fsspec expects of a filesystem with a root. Checked against fsspec's own conformance test suite.
Documentation
Session and FileSystem · Locking · Redirects · TLS · CLI · fsspec
Build the docs yourself with hatch run docs:build.
Development
hatch run lint:check # formatting, linting, type checks, tests
hatch test # the tests, against a real WsgiDAV server
An opt-in compliance check against Apache mod_dav is described in docs/apache-compliance-check.md. See CHANGELOG.md for the release history. Licensed under the MIT License.
Metadata
Release files for webdav-rfc4918 1.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| webdav_rfc4918-1.0.0.tar.gz | 254.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| webdav_rfc4918-1.0.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 401.6 kB
Release files / webdav_rfc4918-1.0.0.tar.gz
| Download URL | webdav_rfc4918-1.0.0.tar.gz |
|---|---|
| Size | 254.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
11b5cf28cc20665029c9dc5374ed05402a1775e93bcc0e9a6a411b87c86afde1
|
|
BLAKE2b-256 checksum How to use checksums |
05d37294a5de1d57db4f57ef8cdb8003272a16647647c8bf80f1c0d7caae58ec
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 1, 2026.
Transparency logRelease files / webdav_rfc4918-1.0.0-py3-none-any.whl
| Download URL | webdav_rfc4918-1.0.0-py3-none-any.whl |
|---|---|
| Size | 147.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
88d20696dde5c6974cac080f8d786732a2a76d6af13213b172d15bc3633775c9
|
|
BLAKE2b-256 checksum How to use checksums |
cf7e210182c5268e5901c3f3cc998d3c8f85310e11efa500712224493839c57c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 1, 2026.
Transparency log