Divergences from pathlib
uripath targets pathlib.Path parity: same method names, signatures,
semantics and exception types wherever a pathlib.Path equivalent exists.
Extensions (extra optional kwargs, new methods like rm()/sync) are
allowed. Any behavioral divergence from pathlib on a method that exists
in both must be listed here -- no silent divergence.
The divergences below apply to URI/URIPath and the schemes of this package.
The ones of the classes that live in pathapi are on its documentation
site, https://jose-pr.github.io/pathapi/divergences/: MemPath, the
generic Path operations, and what FTPURIPath, ZipURIPath, TarURIPath
and ArchiveURIPath inherit from FTPPath and the archive paths (renaming onto
an existing target, the mode of a listed entry, the exception types of an
archive, link members, member names, and a member below a file).
Type relationships
Stdlib inheritance is deliberately limited to local filesystem paths:
URI and URIPath subclass the generic pathapi.Pathname and
pathapi.Path, not pathlib.Path/PurePath. A plain stdlib
pathlib.Path is not a pathapi.Path.
The generic classes cannot safely inherit the stdlib classes: pathlib parses
OS-specific path syntax and supplies operations whose semantics assume a local
filesystem, neither of which applies to a URI. Code accepting every
implementation should type against pathapi.Path or its documented
protocols; code requiring an OS path should type against pathlib.Path.
Differences
| Method | pathlib behavior | Our behavior | Why |
|---|---|---|---|
URI("http://h/d/").name (trailing /) |
PurePosixPath("d/").name == "d" |
A trailing / is kept: name is "" and parent is http://h/d. |
For HTTP/WebDAV a trailing slash is how a directory URL is spelled; normalizing it away changes which URL is requested. |
URI.query |
N/A (pathlib has no query) | The query is kept exactly as received (percent-encoded) and sent unchanged; Query.to_pairs()/to_dict() decode (a Query is a value, not a str: compare str(uri.query)). URI.parts is (source, path, query, fragment), not path segments (segments is). A %2F in a path decodes to / and is not distinguishable from a separator. |
Decoding at parse time and re-encoding changed what reached the server (a signed URL's %2B became +, an escaped & split a value). The decoded-path model cannot keep %2F distinct. |
URI("a").parent |
PurePosixPath("a").parent == PurePosixPath(".") |
URI("a").parent has path "" (URI(""), which round-trips) |
URI has no cwd-relative concept of "." -- an empty path is the URI-natural "no path" representation. Changing this would make URI("") non-idempotent under .parent. |
Query and fragment through /, joinpath(), parent, parents, with_name(), with_stem(), with_suffix(), with_segments(), relative_to() and the children of iterdir()/glob()/walk() of a URI/URIPath |
N/A (pathlib has no query/fragment) | Every derivation that changes only the path keeps the query and the fragment of the path it starts from: URIPath("http://h/a.txt?x=1#frag").with_suffix(".py") is http://h/a.py?x=1#frag, http://h/d?sig=abc / "f" is http://h/d/f?sig=abc, and p.parent / p.name == p. A URI argument of a join is joined as the constructor joins it (the last segment that sets a query decides). The one exception is the children of an http:/dav: listing, which are the hrefs of the reply resolved against the directory and so carry neither: http://h/d/?sort=name lists http://h/d/f.txt, while http://h/d/?sort=name / "f.txt" keeps ?sort=name. A str destination of rename(), copy() and move() is another resource and takes neither. |
A token in the query (a signature, ?token=) names the container and has to follow every name taken from it, and a path that changes in one derivation and not in another makes p.parent / p.name a different URL. A listing names its entries itself: the query that shapes an index (?sort=name, a page number) is addressed to the listing, and sending it with the request for a file is not what the server's own link says. |
A str destination to rename()/symlink_to() on a URIPath |
pathlib.Path.rename(str)/symlink_to(str) take the string as a path, verbatim; a relative one is resolved against the cwd |
The string is taken as an already-decoded path, never re-parsed as URI syntax: ?, #, % and : are ordinary filename characters, so rename("rn?b.txt") renames to rn?b.txt. A relative rename() destination resolves against self.parent (sibling rename), since a URI has no cwd; on Windows a file: path also reads \ in that string as a separator; a relative symlink_to() target is stored verbatim and stays relative, exactly as pathlib does. The str destination of copy()/move() is read by shape instead (see the copy(str) / move(str) destination row). |
Gives pathlib parity on the two methods whose destination is unambiguously a path on the same host. Re-parsing it as a URI would discard everything from a ?/# onward and percent-decode the rest -- silently, so a rename would land on a different file and a symlink would point somewhere else. Percent-encoding the string before parsing was rejected: it double-encodes a name that legitimately contains a literal %20, and it puts a copy of the safe set in every consumer. The parse is bypassed instead -- URI._from_decoded_path(), one implementation for every scheme. |
Unknown keyword arguments of a URIPath constructor |
pathlib.Path("x", foo=1) raises TypeError (3.12+) |
Same, naming the keyword (URIPath("http://h/x", backnd=b)), except for the path of a scheme that another distribution implements and that is not installed (sftp:, s3:, gs:, az:), which takes any keyword. |
The keywords of such a scheme are that distribution's to name; the path cannot do I/O without it, and the first operation that needs the server says what to install. |
URI == str |
PurePath("/x") == "/x" is false: a path equals only a path |
A URI equals a str that parses to the same URI: URI("file:///x") == "file:///x", URI("http://h/a b") == "http://h/a%20b". Text that is not a URI (http://h:99999/) is not equal and does not raise. A str that is not in canonical form hashes differently from the URI it equals. |
URIs travel as text in configuration, logs and tests, and a URI that is false against its own text made uri == text useless; comparing the written spelling made equality depend on how a URL was typed. |
FileURIPath.as_uri() |
pathlib.Path.as_uri(): file:///etc/passwd, file:///C:/Windows/win.ini |
The same text for a path built from a local path object, because an empty authority is kept (Source.host == "", distinct from none: file:/x is written file:/x). The characters !$&'()*+,;=:@ stay literal in the path, where pathlib percent-encodes them; both spellings read back as the same file. |
file:///x and file:/x are the same file, but a URI is written as it was read, so that text that goes in comes out and uri.startswith("file://") keeps matching what pathlib produces. |
DataURIPath names, parents, joins and read_text() |
A path has a name, a parent and children; read_text() decodes with the locale |
The path is the payload of a single file, so name, stem, suffix, suffixes, parent, parents, segments, with_name(), with_stem(), with_suffix(), with_segments(), /, joinpath(), relative_to(), is_relative_to() and match() raise NotImplementedError. open() in text mode and read_text() decode with the charset the URI declares (DataURIPath.charset; UTF-8 when it declares none, a superset of the RFC's US-ASCII default). |
A / inside a payload is data, not a separator: d / "x" named a different file, and parent of a base64 payload was a truncated one. RFC 2397 gives the charset of a text payload, and the locale is a property of the machine, not of the file. |
A with block over open("w") that raised, on a store that commits on close (http:, dav:, ftp:, zip:) |
A local file keeps what was written before the error: write(b"tru") then a raise leaves tru |
The stream is discarded before it is closed, in binary and in text mode (any exception, KeyboardInterrupt included), so the target keeps its previous content, or is not created. close() outside a block and a block that ended normally commit once. |
These stores replace the whole object when the stream is closed, so closing after a failure turned a complete file into a partial one; a local file is truncated as the program writes, and there is nothing to replace. |
The case of a host name (Source.host, as_uri(), ==) |
N/A (pathlib has no host). PureWindowsPath("//Server/Share") keeps its spelling and compares without regard to case. |
The same: Source.host and the text keep the spelling that was given (wss://root@nasA:8443), and ==, hash(), ordering, is_relative_to(), backend sharing, rename and same-file decisions treat http://NASA/x and http://nasa/x as one resource. Two Source values compare as tuples, exactly; same_authority() folds the host. |
A host name is case-insensitive (RFC 3986 3.2.2), and lower-casing it in the parser lost what the caller wrote (a name shown to a person, a key in a configuration), while every comparison still has to ignore the case. |
Query / Source |
N/A | Our own extensions | N/A -- pure extensions, no pathlib equivalent. |
URIPath.rename(target) onto another location |
pathlib.Path.rename: OSError(EXDEV) across filesystems |
Raises NotImplementedError when target is on another endpoint (scheme, userinfo, host or port), another archive, or another Azure container. move() treats that as "rename unsupported" and copies + deletes instead. FileURIPath.rename() follows the same rule, resolves a relative str against self.parent (not the process cwd) and returns a FileURIPath. |
Every scheme renamed with its own connection or bucket and only target.path, so a cross-host or cross-bucket rename silently landed on the source side (and could overwrite an unrelated object there). NotImplementedError is the library's established fallback signal for move(). |
An empty segment in a URI/URIPath join (p / "a//b") |
PurePosixPath("d") / "a//b" is d/a/b: pathlib collapses empty segments |
The empty segment is kept, whichever way the path is spelled: URI("http://h/d") / "a//b", URI("http://h/d", "a//b"), URI("http://h/d") / URI("a//b") and URI("http://h/d/a//b") are all http://h/d/a//b. segments holds the empty element, the parent of a//b is a/, and normalized_path still collapses. A name an iterdir() listing yields as empty is skipped, never joined. |
RFC 3986 3.3 makes an empty segment a segment, and on an object store (s3:, gs: and az:, provided by objpath) d//y.txt and d/y.txt are two keys, so a key that holds one could not be addressed with /. |
URI / "name", joinpath("name") |
pathlib joins the string as path segments |
Same: a str is an already-decoded path, so ?, #, % and a leading C: are ordinary filename characters (base / "cache?v=2" is the file cache?v=2, not a query). Pass a URI/URIPath argument for a scheme-aware join (base / URIPath("s3://bucket/key") crosses to S3, and drops a credential-bearing backend on the way). Dot segments in the result are still removed. |
A join names a child, and iterdir() already named it literally -- listing a directory holding cache?v=2 gave a working path while typing the same name by hand gave /mnt/cache. Only the explicit URI form reaches another host, so a str cannot carry a session or token off the endpoint it belongs to. |
copy(str) / move(str) destination |
shutil resolves a relative destination against the process cwd |
Read by shape: with a scheme (s3://bucket/key, data:,abc) it is a URI, so a cross-scheme copy works; without one it is a decoded path on the SAME endpoint -- absolute replaces the path, relative is a sibling, as rename() resolves it -- keeping this path's source and backend. A one-letter scheme is read as a drive, not a scheme, so C:/Temp/x is a decoded path: on a file: path the Windows path C:/Temp/x, on any other scheme a relative path, a sibling (http://h/d/C:/Temp/x). On Windows a file: path also reads \ as a separator, so str(path) of a Windows path works (C:\Temp\x); on other platforms a backslash is a filename character, and every other scheme splits on / only. |
A URI has no cwd, and parsing every string as a URI would turn move("b.txt") into a sourceless path that could not do I/O at all, while a same-host URI destination would open a second connection. |
URI path dot segments |
pathlib keeps .. lexically (PurePosixPath("a/../b") is a/../b) |
URI removes dot segments as RFC 3986 5.2.4 requires of a URI reference, in the constructor and in every join, whether the argument is a str or a URI: URI("a/../b") is b, URI("a/b/..") is a/, URI("http://h/x") / "a/../b" is http://h/x/b, and URI("http://h/d/") / "../x", URI("http://h/d/", "../x"), URI("http://h/d/") / URI("../x") and URI("http://h/d/../x") are all http://h/x. A join is pathlib-style concatenation first (/d/ + ../x is /d/../x) and the dot segments of that whole path are removed, so URI("http://h/d/x") / ".." is http://h/d/, as URI("http://h/d/x/..") is. A .. that would pass the root of an absolute path is dropped (URI("http://h/../x") is http://h/x); a relative path keeps its leading .. (URI("a") / "../../x" is ../x). A percent-encoded dot segment (%2e%2e, ..%2F) is removed after decoding, so URI(u.as_uri()) == u; a str join key is a decoded path, where %2e%2e is an ordinary name. A file: path on Windows keeps its drive: .. never climbs above C:/. MemPath follows pathlib instead. |
A URI is resolved, not spelled: .. in a URI reference has a defined meaning that servers, caches and proxies already apply, so keeping it lexically would address a different resource than the same string typed into a browser. Resolving in the constructor but not in a join made one string address two resources depending on how it was built. |
| Nested archive URIs | N/A | Each leading archive scheme in <archive-uri> consumes one !/ (zip:zip:file:///outer.zip!/inner.zip!/x.txt); a member name containing !/ is written %21/. Nested archives are read-only. |
The first !/ was always taken as the separator, so an archive inside an archive could not be addressed. |
HTTPURIPath.iterdir() on a file |
pathlib raises NotADirectoryError |
Raises NotADirectoryError for a non-HTML response, without downloading it. An HTML file cannot be told apart from an index page and lists as empty. |
HTTP has no directory type; the response content type is the only signal. |
HTTPURIPath.iterdir() on a very large index |
pathlib reads a directory incrementally and has no size limit | An index body is read whole (about a second per megabyte to parse) and one over pathapi.http.MAX_REPLY_BYTES (8 MiB, assign a larger number to allow more) raises OSError(EFBIG); a body that is cut short or stalls raises OSError(EIO)/TimeoutError and never lists the part that arrived. |
A page of links has no end marker, so a listing cut short looks complete, and PathSyncer(remove_missing=True) would delete what it did not list; an unbounded body also lets a server decide how long a listing takes. |
HTTPURIPath st_mtime of a listed entry |
A file system keeps one instant for an entry | An index page prints a date with no zone, and it is read as UTC, so the st_mtime of an entry listed from it can be off by the server's offset from UTC. stat() of the entry reads Last-Modified, which names its zone. Compare sizes, or give PathSyncer quick_check=False, when the zone matters. |
A page says nothing about the zone it printed in, and a listing is the only way to learn a whole directory in one request. |
HTTPURIPath open("a") default append mode |
POSIX O_APPEND is atomic (all appends serialized) |
Default "rewrite" mode is non-atomic (GET existing + append in client memory + PUT full body) -- concurrent appenders can race | HTTP has no native append primitive; rewrite mode trades atomicity for universality (works on any server that supports PUT). Opt-in "patch" mode using Content-Range PATCH is atomic on servers that support it (use with_transport(..., append_mode="patch")), and never falls back to rewrite: a rejected PATCH raises PermissionError for 401/403/405/501 and OSError(EIO) for other statuses (e.g. 400/415/416). |
HTTPURIPath/DAVURIPath writes, unlink(), mkdir(), rename() and PROPFIND answered by a redirect |
A write to a path that exists either stores the bytes or raises; no redirect is involved | A 307 or 308 (for PROPFIND also a 301, 302 or 303) to the same scheme, host and port is sent once more with the same method and body; any other 3xx, a redirect to another origin and a second redirect raise OSError(EIO) naming the status and the Location (userinfo removed). allow_redirects in with_session() does not apply to these requests. PROPFIND is sent again with its body after a 301, 302, 303, 307 or 308 to the same origin, and refused with OSError(EIO) for another one; GET, HEAD and OPTIONS follow redirects as before. |
requests follows a redirect as a browser does: a 301 re-sends a PUT without its body, a 302 or 303 turns it into a GET, and the final 2xx then reads as a successful write. Re-sending only a 307 or 308 to the same origin keeps the request intact and keeps credentials where the caller put them. |
URI/URIPath.__str__() |
str(pathlib.Path) round-trips the full path |
Drops the password from userinfo (sftp://u:pw@h/p -> "sftp://u@h/p" via as_uri(sanitize=True)) -- reparsing the result gives a different, unauthenticated URI |
str() is what logging/printing reach for; a credentialed URI landing in a log line is worse than a str() that doesn't round-trip. Use as_uri(sanitize=False) for the full URI including credentials. |
URI/URIPath.__fspath__() |
os.fspath(pathlib.Path) always succeeds with a locally-openable path |
Raises NotImplementedError for any non-file scheme, except schemes with _host_filesystem_path = True (sftp:, provided by sftppath, is one; none in this package), which return .path -- a path meaningful on the URI's own host, not the local machine |
os.fspath() has two consumers: "open this locally" (where returning a remote path would silently read the wrong file) and "build a command line for a process that runs on the path's host" (subprocess, remote executors) -- correct for the second consumer, wrong for the first. Schemes opt in via _host_filesystem_path only when .path genuinely is a host filesystem path. host_fspath() is the unambiguous accessor for the second use case: it never falls back to treating a path as local. |
uripath.source.Source.__str__()/__repr__() |
N/A (pathlib has no authority) | Both redact the password from userinfo the same way URI.__str__() does (root:secret -> root; the whole userinfo for repo:, github: and gitlab:, as for their paths) -- .userinfo/.parsed_userinfo()/["userinfo"] (the actual data-access API) still return the real password; only display is redacted. str(source) is not a connectable URI -- use Source.reveal() for the full round trip (it mirrors URI.as_uri(sanitize=False)). |
repr() is what a traceback frame renders, so a Source anywhere on a failing call stack would put the password in the log even though URI.__str__() redacts; a caller who saw URI redact reasonably assumes the layer beneath it does too. Same rationale as the URI.__str__() row above. |