Divergences from pathlib
pathapi 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.
LocalPath is pathlib.WindowsPath/pathlib.PosixPath with our Path mixed
in via MRO, so unless noted otherwise it behaves exactly like pathlib.Path
(it inherits the real implementation for anything not explicitly overridden).
The divergences below apply to MemPath, FTPPath and the archive paths
(ZipPath, TarPath, ArchivePath). The classes of other distributions built
on pathapi (URIs, SFTP, object stores, repository hosts) record their own
divergences in those distributions' documentation; for the URI classes that is
https://jose-pr.github.io/uripath/divergences/.
Type relationships
Stdlib inheritance is deliberately limited to local filesystem paths:
LocalPathsubclasses bothpathlib.Pathandpathapi.Path.PosixPathnameandWindowsPathnamesubclass the matching stdlibPurePathclasses andpathapi.Pathname.MemPath,FTPPath, the archive paths, and custom virtual or remote implementations subclass the generic pathapi bases, notpathlib.Path/PurePath.- A plain stdlib
pathlib.Pathis not apathapi.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, archive member, object-store key,
or in-memory path. Registering stdlib paths as virtual pathapi.Path
subclasses would likewise promise pathapi's extended operation contract on
Python versions where stdlib paths do not implement it. Code accepting every
implementation should type against pathapi.Path or its documented
protocols; code requiring an OS path should type against pathlib.Path.
Operation precedence in Path subclasses
Because concrete classes mix a pathlib class with pathapi.Path, the
MRO alone would decide which library implements a given method -- and which
one wins changes with the interpreter version, since stdlib pathlib keeps
gaining and changing methods. That produced version-dependent behavior in
both directions: CPython 3.14's new copy()/move() displaced ours (loudly
on non-local backends, silently and with different timestamp semantics on
local ones), while pre-3.12/3.13 stdlib lacked keywords our protocols
promise (exists(follow_symlinks=), read_text/write_text's newline=).
pathapi.Path.__init_subclass__ therefore re-asserts the pathapi
implementation of copy, move, copy_into, move_into, exists, rglob,
read_text, write_text and symlink_to for any subclass that would otherwise inherit stdlib's. This
applies automatically to downstream classes built with the documented
composition pattern (class X(PosixPathname, Path)), so implementers do not
have to hand-write forwarding methods.
Only stdlib pathlib is displaced: a subclass or mixin that defines one of
these operations itself always keeps its own implementation.
| Method | pathlib behavior | Our behavior | Why |
|---|---|---|---|
Path.glob() / rglob() edge cases |
Version-dependent pathlib rules | Hidden entries are included by default (pathlib parity; include_hidden=False filters them). recurse_symlinks=True is accepted on every version and, where it would loop, stops earlier than pathlib 3.13, which follows a loop until the system refuses it: a link to a directory the descent is already inside (compared by (st_dev, st_ino)) is an entry but is not entered, and where the stat carries no identity no more than 40 links are followed in one descent. A final literal with a trailing separator is tested as pathlib tests it from 3.13 (os.path.lexists(name + os.sep), so the platform decides what a link is there: Windows selects a dangling directory link, macOS a link to a file) for LocalPath and stdlib paths only; any other class, and native=False, follow the link. A trailing ** follows the running interpreter (files too on 3.13+). The two rules pathlib changed mid-series follow the running interpreter by default (native=True): a trailing / is ignored before 3.11 and selects directories only from 3.11, and a** raises ValueError before 3.13 and is a plain wildcard from 3.13. native=False applies one rule on every version instead -- trailing / always selects directories only, a** is always a plain wildcard. A path that two ** components reach along several splits is yielded once (glob("**/*/**/*")): pathlib 3.13 and later yields it once per split, earlier versions deduplicate as this does. An absolute pattern raises NonRelativePatternError, a NotImplementedError (what pathlib raises) and a ValueError; a pattern with no component (., ./) raises ValueError, where pathlib 3.9 raises IndexError. A bad pattern raises when glob() is called; pathlib before 3.13 raises when the result is first iterated. A MemPath applies .. to the tree as a POSIX filesystem does, so a.txt/.. and missing/.. select nothing; pathlib 3.13 and later on Windows tests the joined text, which Windows resolves by its text, and selects the base. |
Following links is only safe with a bound, and most backends' stats lack st_dev/st_ino: identity where the stat has it, the operating system's own limit of 40 links in one resolution otherwise. The native default keeps LocalPath answering exactly what the pathlib beside it answers; native=False is for a caller that wants one answer across backends and interpreters, which is what the contract suite asserts. |
Path.glob(on_error=) / rglob(on_error=) |
pathlib skips an unreadable directory silently, with no hook |
Same default, plus a hook: on_error(error) is called per failed listing (the contract walk() and os.walk use), so a caller can log it or raise it. error.filename names the directory. |
"Silently nothing" is the worst answer for a caller assembling something from a tree -- a configuration layer, a source list -- because the result is incomplete and nothing says so. The default stays pathlib's. |
Path.glob(bound_loops=) / rglob(bound_loops=) |
pathlib walks a directory loop until the recursion limit (and on Windows/3.9 eventually raises WinError 1921 on the over-long path) |
bound_loops=True skips a directory whose (st_dev, st_ino) is already on the current descent path -- reachable below itself, which is what a loop is. A directory deliberately reachable under two sibling names still expands under both. Default False keeps pathlib's behaviour. |
A Windows junction reports is_symlink() == False, so recurse_symlinks=False cannot see it and an ordinary config tree with a junction back into itself matched one file 128 times (and failed outright at WinError 1921 on the path length). Identity is the only signal that works, and the ancestor chain is the part of it that means "loop" -- keying on everything seen also dropped a shared layer junctioned in twice. Backends whose stats lack identity are unaffected. |
Path.glob(None) / rglob(None) |
pathlib has no such form (its pattern is always applied to a directory) |
None expands the pattern THIS PATH CARRIES: LocalPath("/etc/*.conf").glob(None) splits at the first wildcard and globs from there (glob.glob()). glob("") still raises ValueError, as pathlib does. The path's anchor (a drive, an extended-length prefix) is never read as a wildcard. It raises no audit event: glob(pattern) and rglob(pattern) raise the ones pathlib raises. |
An extension: a path that is itself a pattern is a common shape for config and CLI inputs, and before 0.9.4 glob("") was the accidental spelling for it. None cannot collide with a real pattern, so parity is untouched. |
Path.copy(follow_symlinks=False) on a symlink |
CPython 3.14: copies the link as a link | Same: the link is recreated (not its metadata). Where the source cannot readlink() or the target cannot create links, raises NotImplementedError instead of copying content. copy(recursive=True) into its own subtree raises OSError(EINVAL) before creating anything. |
Copying the link target's content under the link's permissions produced a 0o777 regular file. |
FTPPath.rename(target) onto an existing target |
POSIX rename(2): replaces; Windows: FileExistsError |
The server decides: a POSIX server replaces the target, a Windows one answers 550 File exists, which is FileExistsError when the target is there (a refusal whose reply names a permission problem stays PermissionError). Nothing is replaced by the client. |
RNTO has no replace flag and a server's refusal carries no code for "exists"; reporting it as PermissionError hid the cause. |
Archive (ZipPath/TarPath) exception types and writes |
pathlib on Windows raises PermissionError for some directory operations |
POSIX types on every platform (NotADirectoryError, IsADirectoryError, ENOTEMPTY); mkdir()/new members need an existing parent. Adding a zip member copies the archive's bytes once (temp file + os.replace). ZipPath.batch() holds the changes made inside a block and replaces the archive once when it ends; an exception in the block leaves the file as it was. copy(recursive=True) into a zip and PathSyncer onto one run inside it, so a copy or sync that raises leaves the archive unchanged, where another backend keeps the children written before the error. |
An archive has no platform; crash-safety needs a full copy, and a copy per member made a 300-file tree 300 copies. |
Link members of ZipPath/TarPath |
A symlink is a file system entry with its own lstat(); the target is outside the archive's own tree when it is absolute or climbs out |
A tar symlink and a zip entry whose Unix mode says link are links: listed with S_IFLNK, readlink() gives the stored text, and a lookup follows links inside the archive only. One that is absolute, leaves the root or points at nothing is FileNotFoundError naming the link, a loop or more than 40 links in one lookup OSError(ELOOP). readlink() returns a PurePosixPath whose path is the text as stored. A listing that follows links (walk(follow_symlinks=True), glob(recurse_symlinks=True), copy(recursive=True), PathSyncer) follows one directory link per path and never a link to the directory that holds it or to one above it: the following stat() of such a link is OSError(ELOOP), so it is an entry and not a directory, and the entries visited are at most (members + links) * (directory links + 1); spell the path to go further. Nothing is written through a link (NotImplementedError), no link can be created, and a tar hard link stays a regular member that shares another's data. |
Following a link out of an archive would read or write the machine around it, and a few links to . made a following walk visit one path per route, up to the link bound each; a zip link was a file holding its own text and a tar link read as its target or as a RecursionError. |
unpack_archive() tar links |
tarfile.extractall creates links |
Hard links and in-archive symlinks to regular files are extracted as regular files; other links are skipped with a UserWarning. |
Works on every Path destination, most of which cannot create links. |
FTPPath.stat().st_mode |
pathlib reports the real mode | Taken from MLSD unix.mode, else derived from the perm fact (read/write bits only). |
MLSD is the only mode source most servers offer. |
Path.touch(mode=None, exist_ok=True) on non-local backends |
pathlib.Path.touch(mode=0o666) creates with the mode masked by the umask and updates an existing file's mtime |
mode defaults to None: a new file keeps the backend's default permissions, and an explicit mode is applied with chmod() unmasked (a remote umask is unknown); a class that cannot chmod() raises NotImplementedError and removes the file it created, so a mode that was asked for is never silently dropped. An existing file's access and modification times are set to now through the overridable _utime() hook (MemPath and FTPPath on a server that lists MFMT implement it: whole seconds, no access time on FTP); a class that cannot set a time leaves the file as it is. LocalPath uses pathlib's touch() unchanged. |
Always chmod'ing 0o666 left new files world-writable on FTP and other remote backends; a remote backend cannot apply the local umask. |
Path.__iter__ |
pathlib.Path is not iterable (no __iter__) |
iter(path) is path.iterdir() |
Deliberate extension for ergonomic for child in path: loops. Caution: on remote backends (FTP) this is a network call. User decision, 2026-07-11. |
Path.copy(target, ...) |
CPython 3.14 Path.copy(target, *, follow_symlinks=True, preserve_metadata=False): overwrites an existing file, copies a directory tree without being asked, returns the new path |
Ours predates 3.14. Signature: copy(target, *, overwrite=False, follow_symlinks=True, preserve_metadata=True, recursive=False, ignore_error=None, progress=None, on_error=None); returns None. ignore_error= keeps its notify-only callable, and on_error= is rm()'s predicate (error, path) -> bool for a child error of a recursive copy (True skips the entry, False raises, a predicate that raises ends the copy with its own exception); passing both raises TypeError. An existing target raises FileExistsError unless overwrite=True; a directory needs recursive=True. overwrite=True unlinks an existing non-directory target with unlink(missing_ok=True), but only once the source is open (a missing or unreadable source never destroys or creates the target; a copy that fails mid-stream removes its partial target; a backend whose write replaces the file and which cannot delete makes unlink(missing_ok=True) return without removing); copying onto the same file, or a directory into itself, raises OSError(EINVAL): two files of this machine (LocalPath, a subclass, pathlib.Path, a class that names a local file through filepath) are compared by identity whatever their classes, a type that answers the overridable _node_key() hook (MemPath, archive members) by its normalized node, and anything else by samefile() where stat() has st_dev/st_ino, otherwise equal paths of one type that the overridable _same_filesystem() hook places on the same host or store (two paths of one class that name one server, built separately, count as the same file); preserve_metadata defaults to True (opposite of 3.14) and only propagates st_mode, not timestamps/xattrs: the permission bits of a file, and of each directory of a recursive copy once its children are copied (as shutil.copytree and 3.14 do), with the setuid, setgid and sticky bits dropped when source and target are different classes; a recreated symlink (follow_symlinks=False) carries only its target text, not its own mode, times or owner; a dangling symlink at the target is written through, as shutil.copyfile and 3.14 do, with or without overwrite, while a live one counts as existing and overwrite=True replaces the link itself |
Argument names aligned with 3.14 where cheap; preserve_metadata=True default kept for backward compat with this method's pre-existing (pre-3.14-alignment) behavior of always copying the mode bits. Full metadata preservation (timestamps, xattrs) is not implemented. |
Path.move(target, ...) |
CPython 3.14 Path.move(target): os.replace semantics (replaces an existing file), falls back to copy + delete across filesystems, returns the new path |
Ours predates 3.14 and keeps overwrite=False: an existing target raises FileExistsError. Returns whatever rename() returns (None on the copy fallback). Our own extension: tries rename() (by default only onto a path of the same class; LocalPath and classes for URIs decide for themselves), falls back to copy+unlink, which recreates a symlink and the links inside a moved tree wherever the target can hold links, so the result does not depend on whether rename() was available. Validates before touching the target: a missing source raises FileNotFoundError, a file onto a directory raises IsADirectoryError, a target that holds the source (OSError(ENOTEMPTY)), lies inside it (OSError(EINVAL)) or is a link to or from the same file (OSError(EINVAL)) is refused with both trees intact, and the same file under another spelling (a case-only rename, another class, a relative MemPath, another spelling of the same archive) is renamed in place. "Same" is decided as for copy(). Two hard links of one file are two names: an existing target needs overwrite=True, and the source name is then removed. overwrite=True replaces a local file target atomically (os.replace); on other backends it unlinks the target immediately before rename(), so a failing rename can still lose it. |
N/A -- pure extension, no pathlib method to diverge from. Removing the target before checking the source deleted it on a typo or a locked source. |
Path.is_junction() / is_mount() / is_dir_binding() |
pathlib has is_junction() (3.12+) and is_mount(), but only for local paths and with no shared concept |
Both are part of the Path contract (False by default, answered by LocalPath), and is_dir_binding() is their union: a directory that is another tree's second NAME rather than part of this one. A junction is the Windows spelling of a bind mount, and neither is a symlink -- is_symlink() is False and a non-following stat reports a plain directory. |
Walking code needs the distinction: a symlink announces itself and every walker here already declines to follow one, while a binding looks exactly like an ordinary directory and its contents belong to whoever mounted or junctioned it. |
Path.rm(recursive=, missing_ok=, ignore_error=) |
Not in pathlib (closest: shutil.rmtree) |
Our own extension. Recursive removal deletes bottom-up and uses non-following stat/listing metadata, so a symlink to a directory -- or a binding (is_dir_binding(): a Windows junction, a mount point), which a non-following stat still reports as a directory -- is unlinked or rmdir()'d rather than traversed -- rm -r semantics. follow_symlinks=/follow_binds= select that per call: False (default), True (remove what is behind it, then the entry: a followed symlink is unlinked), None (leave it), or a callable asked per entry -- the same keyword stat()/walk()/copy() use for the same idea. They decide the path rm() is called on as they decide an entry met on the way down. A policy that is not one of those, or a callable's answer that is not, raises ValueError and is never offered to ignore_error. Every class that overrides rm() takes both keywords; a backend with no symlinks or bindings decides nothing with them, and one with a concurrent native walk runs it only for follow_symlinks=False. rmdir() on a live mount fails, which is the intended answer. |
N/A -- pure extension. Non-following recursive deletion avoids deleting through symlinked directory targets and lets backends with metadata-rich listings remove trees without a stat round trip per child. |
Path.symlink_to(target, target_is_directory=False, *, force=False) |
pathlib.Path.symlink_to(target, target_is_directory=False) -- raises FileExistsError if anything already exists at the link path |
Adds a keyword-only force=. force=False (the default) is stdlib-exact. force=True unlinks an existing non-directory entry at the link path first, then creates the symlink; an existing directory is never removed and the underlying error propagates. Not atomic: no filesystem or transport offers "replace a symlink" as one operation, so the path briefly does not exist between the unlink and the symlink. |
Additive extension (an extra optional kwarg, per the parity contract). No backend can offer this atomically, so every consumer was re-implementing the same unlink-then-symlink dance -- it is path semantics, not transport semantics, so it belongs at the Path layer where one implementation serves every backend. Backends implement only the _symlink_to() primitive (same _mkdir/_open shape) and get force= for free. Because no stdlib version accepts the keyword, symlink_to is in _OPERATION_NAMES so LocalPath honors it too. |
Path.chown(uid=None, gid=None, *, follow_symlinks=True) |
Not in pathlib at all -- it has owner()/group() readers but no writer (the stdlib writers are os.chown/shutil.chown, which are functions over a path, not path methods) |
Our own extension. None (default) leaves a field unchanged; -1 is accepted as an alias for None (os.chown's own sentinel); an int is a uid/gid and a str is a user/group name. A call where both fields are unchanged short-circuits without touching the backend. Implemented for LocalPath (via shutil.chown, or os.lchown for follow_symlinks=False) and by classes of other distributions whose backend can set an owner; NotImplementedError elsewhere -- including LocalPath on a platform without os.chown (Windows), where shutil.chown exists but cannot work. |
Ownership was the one permission attribute stat() could report (st_uid/st_gid) that nothing could write back. The valuable part is centralizing the "unchanged" sentinel on Path (providers.parse_owner()): os.chown spells it -1, SFTP omits the field, other middlewares use None -- normalizing per-scheme would be three chances to disagree. Backends implement _chown() and receive an already-canonical pair. A backend that sends uid/gid as one paired attribute (SFTPv3) reads the current owner for whichever field is unchanged rather than guessing a value, and raises NotImplementedError without sending anything where the server does not report that field as a number. |
Path.chmod(mode, ...) accepting a str |
mode must be an int; a str raises TypeError |
Additionally accepts a str, parsed as octal: "0755", "755" and 0o755 all mean the same thing. An optional 0o prefix is allowed; any character outside [0-7] raises ValueError rather than being coerced. |
The string form is how modes are written in chmod(1), Ansible, Dockerfiles and shell scripts, so config-driven callers arrive holding one. Accepted only with an explicit base 8 (providers.parse_mode()), never a plain int(): int("0755") in decimal is 755 == 0o1363, a different and valid mode, so a fallback to decimal would set plausible-but-wrong permissions with nothing raising -- which is exactly why stdlib refuses strings. Parsing in one shared helper is what makes the base non-negotiable across the five backends that implement chmod directly. |
PathSyncer |
N/A | Our own extension | N/A -- a pure extension, no pathlib equivalent. |
Path.exists() / is_*() on a stat error other than "not found" |
pathlib 3.9-3.12 re-raise errors outside ENOENT/ENOTDIR/EBADF/ELOOP (e.g. PermissionError); 3.13+ return False |
Every OSError/ValueError from stat() returns False on every Python version, including LocalPath.exists() on 3.9-3.12. |
One rule across backends and versions, matching current pathlib. |
PathSyncer directory → file/symlink type change |
N/A (closest: rsync, which will not delete a non-empty directory without --delete/--force) |
With remove_missing=False, a non-empty target directory is not replaced when the source entry at that name became a file or symlink: IsADirectoryError goes through ignore_error (SyncEvent.TypeMismatch) and the directory is kept. Empty directories, or remove_missing=True, are replaced. |
remove_missing=False means "never delete target-only data"; replacing the directory silently deleted its whole subtree. |
Path.copy(recursive=True) child names |
shutil.copytree joins whatever the listing yields |
A child name that would not stay inside target is refused with ValueError through ignore_error: .., and when the target reads names with Windows rules \, :, a trailing dot or space, and the reserved device names (CON, PRN, AUX, NUL, COM1-COM9, LPT1-LPT9, with or without an extension). |
The names come from listings the destination does not control (an archive, an HTTP index, an object-store key); on a Windows target "C:x" joins to a drive-relative path outside the destination entirely, and "report." is stored as "report", so two sources merge into one file. PathSyncer and utils.unpack_archive() already applied this per destination. |
Listed child names in walk(), rm(recursive=True) and PathSyncer |
A local directory never lists ., .., an empty name or a name holding a separator, so os.walk, shutil.rmtree and os.scandir take names as given |
A listed name that is not one path component is refused before any child is built from it. walk() leaves it out of dirnames and filenames and, when on_error is given, calls it with a ValueError; rm(recursive=True) raises ValueError through ignore_error(error, directory) and removes nothing for it; PathSyncer raises ValueError through ignore_error for the source's and the target's listing alike. PathSyncer also refuses a name Windows would rewrite when the target is Windows-flavoured, as copy(recursive=True) does; walk() and rm() apply only the one-component rule, since they act on names the directory itself listed. The listing of every class of this package already skips such a name. |
A remote listing is chosen by the server, or by whoever wrote an object key, and a joined .. leaves the directory: a recursive removal or a remove_missing sync reached outside the tree it was given. |
| Archive member names | N/A (zipfile/tarfile expose the raw name as written) |
Normalized as POSIX relative paths for every format: a leading ./, empty segments and interior ./.. resolve, so one member has one name and listings and lookups agree; the raw spelling still addresses it, and the later of two members that normalize alike wins. A name that escapes the root (../x, /abs) has no name inside the archive at all (it exists only in the archive's own records), while a drive- or backslash-shaped name is a normal member (an ordinary POSIX filename) that only a Windows destination refuses to receive. |
The same file was reachable or not depending on how the writer spelled it: a zip written by shutil.make_archive-style ./ prefixes listed as empty, and zip and tar disagreed about identical archives. |
| Archive member below a file; the archive root | Path("file/child").stat() raises NotADirectoryError on POSIX; a real directory outlives the files in it; rmdir() of / is refused |
A member below a file (y/z next to a file y) does not exist: stat(), open(), unlink() and rename() raise FileNotFoundError, and no listing shows it. The archive root is the archive: rmdir() of an empty root succeeds and removes nothing, and its exists()/stat() open the archive (a missing one raises FileNotFoundError, a file that is not an archive zipfile.BadZipFile / tarfile.ReadError). Removing a file never removes its directory: a directory that exists only through its members gets its own entry (name/) when the last member goes. |
A listing and a lookup have to agree, and FileNotFoundError is the one answer a member that cannot be listed gets, as for a name that escapes the root. An archive has no directory to keep a removed file's parent alive, so the entry is written in the rewrite that removes the member. |
The POSIX // root |
PurePosixPath("//") keeps // as a root distinct from / (POSIX leaves exactly two leading slashes implementation-defined; three or more collapse) |
The generic classes do not model it: MemPath("//") collapses to /, and a class for URIs reads // as the start of an authority (RFC 3986). match() therefore disagrees with pathlib on that one path. LocalPath/WindowsPathname are unaffected (they inherit pathlib's parsing, where //server/share is a UNC drive). |
A distinct double-slash root has no meaning for an in-memory tree or a URI, and for a URI it cannot: //a/b must read a as a host. Modelling it would change segment normalization everywhere (parents, relative_to, is_absolute, every class) to serve a spelling no backend can use. |
PathSyncer.sync() safety checks |
N/A (closest: rsync, which refuses a missing source) |
A root source that does not exist raises FileNotFoundError; overlapping source/target (one inside the other, or two names of one file) raise ValueError, decided as copy() and move() decide "same file": two files of this machine by where each really is whatever their classes, a type that answers the overridable _node_key() (MemPath, archive members) by its normalized node across spellings, and anything else only between two paths of one type that the overridable Path._same_filesystem() hook places on the same host or store; a child name that would leave target (.., or \/: on a Windows target) raises ValueError; a symlink found inside target is replaced by the real entry instead of being followed, and so is a Windows junction below the root, which is another directory's second name: it is removed with rmdir() (event TypeMismatch) and never listed, written or deleted through, while a mount point below the root (a mounted volume, a bind mount) is part of the tree and is synced into, as rsync does and unlike rm(recursive=True), which stops at one, unless follow_mounts=False (uripath sync --no-follow-mounts), which makes it an OSError(EXDEV) for that entry through ignore_error (event TypeMismatch) with nothing listed, written or removed behind it (the root target is used as given, as for a symlink); a name the source lists is never "missing", so a listed link that cannot be resolved (dangling, a loop, an unmounted volume) is an error for that entry (event SyncStart) and the same-named target entry is kept, with or without remove_missing. All go through ignore_error. Behaviour change: a missing root source used to be a silent no-op, or with remove_missing=True deleted the whole target. |
A typo, unmounted share or HTTP 404 read as "empty source" and wiped the destination; names from a remote listing or archive could write outside it; a link or junction inside the destination redirected writes and deletes outside it; a link to an unmounted volume read as "the entry is gone" and remove_missing deleted the target's copy. |
ZipPath.rename(target) onto an existing member |
pathlib.Path.rename: replaces a file on POSIX, raises FileExistsError on Windows |
POSIX semantics on every platform: a file replaces a file, a directory replaces an empty directory; file onto directory raises IsADirectoryError, directory onto file NotADirectoryError, onto a non-empty directory OSError(ENOTEMPTY). |
A zip has no platform; replacing in the same rewrite is the only way to avoid a duplicate member name (which previously hid the renamed data). |
PathSyncer temporary file for a changed file or link |
N/A (closest: rsync, which writes .NAME.XXXXXX, removes it when a transfer fails and leaves it after a kill) |
A changed file is written beside its target as .NAME.<12 hex>.pathapi-tmp (NAME cut so the whole name stays within 255 UTF-8 bytes) and renamed over it. A name of exactly that form is the library's own: it is never a sync source and never removed as "missing". One is removed from a target directory only when stale -- no sync of the process is writing it and its modification time is known and over a day old -- when a changed file or link of that directory is next written and by remove_missing=True; a younger one, or one whose age is unknown, stays. Where a backend's rename() refuses an existing target the old file is removed first; if the second rename fails the new version stays under its temporary name and the error names it. |
A killed transfer left its temporary file beside an intact target for good, and the next sync copied it onward; a name within 30 characters of the limit could never be updated; the fallback deleted the only copy of a file; and a leftover removed by age alone could have been a transfer running in another process. |
PathSyncer(follow_symlinks=False).sync() on a symlink source |
N/A (no pathlib equivalent) | Previously always raised NotImplementedError. Now controlled by the new symlink_mode constructor kwarg ("preserve" default, "reject" opt-out): "preserve" creates a matching symlink on target with the same raw, unresolved target string readlink() returned (dangling links and relative targets included, never validated/resolved); "reject" restores the exact old unconditional-raise behavior. If target can't create symlinks at all (every backend except LocalPath and those of other distributions that hold links), "preserve" also raises NotImplementedError, through the same ignore_error/hook() machinery as every other sync branch, not a silent skip, and a dry run raises it too (it used to report would symlink). This is a default-behavior change, not a pure extension -- flagged here because existing callers relying on the old unconditional raise (e.g. to detect and skip symlinks) must now pass symlink_mode="reject" explicitly. |
Faithful one-way tree mirroring needs symlinks preserved as symlinks by default, not silently dropped/erroring -- discovered via a real cross-host sync use case in a downstream tool. follow_symlinks=True (unchanged default) still resolves through symlinks during traversal, so this only affects callers who already opted into follow_symlinks=False. User decision, 2026-07-28. |
PathSyncer(preserve_mtime=True) modification times |
N/A (closest: rsync, which sets the time of a copy only with -t/-a) |
A file the sync copies is given the modification time its source reported, through the overridable _utime() hook, where the target's class can set one (LocalPath, MemPath, FTPPath on a server that lists MFMT, and the classes of other distributions that implement _utime()): directories and links are left alone. A source that reports no time, and a target that cannot set one or refuses, keep the time the copy gave them, without an error. quick_check compares the two times at the resolution of the coarser side: whole seconds when either has no fractional part, else to a microsecond. Behaviour change: a copy used to carry the time it was written at; preserve_mtime=False restores that. |
quick_check is on by default but could never succeed after this library's own copy (the copy carried its write time), so every later sync of a remote tree checksummed (downloaded) every file again. |
<, <=, >, >= between generic paths |
PurePath orders paths of one flavour by their case-folded parts; PureWindowsPath("a") < PurePosixPath("a") raises TypeError |
Pathname orders two paths of the same exact type by the key its == uses: the segments (MemPath, custom classes), or whatever a class compares instead (the text of a URI, so the host counts). Any other operand, a subclass against its base included, gives NotImplemented and so TypeError, as pathlib does across flavours. Segments compare as strings, case-sensitively, whatever the class's case rule. LocalPath, PosixPathname and WindowsPathname keep pathlib's own ordering. |
sorted(p.iterdir()) is an everyday idiom, and a type that has == and hash and no order cannot be sorted. The key is the one == and hash already use, so two paths are never equal and ordered apart. |
FTPPath, ZipPath, TarPath, ArchivePath: ==, rooted and relative spellings |
PurePosixPath("a") != PurePosixPath("/a"), and equal paths are the same file because there is one filesystem |
== and hash compare the names only (as MemPath does), so two paths of different servers or archives are equal; copy(), move() and PathSyncer ask the overridable _same_filesystem() and _node_key() hooks before they call two equal paths one file. a/b and /a/b are two spellings of one node, because the tree has no working directory and is read from its root: the server root for an FTPPath, the archive root for the archive paths (_node_key() is equal; == is not). An FTPPath sends .. as written, and an archive path applies it to the member name and refuses a name that leaves the archive. |
A remote tree and an archive hold names, not a current directory, and carry their connection or handle in the object (the model is MemPath). Making == compare the server too would make a path unequal to its own copy on a second connection, and would break is_relative_to(), which decides by ==. |
full_match() on a generic path, with a bracket expression |
PurePath.full_match (3.13+) compiles the whole pattern to one regular expression, so a negated bracket such as [!x] can match a separator: PurePosixPath("a/b").full_match("a[!x]b") and PurePosixPath("/").full_match("[!a]") are True |
Pathname.full_match compares one component at a time and a bracket expression never matches a separator: both are False |
A component-wise automaton keeps ** linear in the path and the pattern (a regular expression backtracks exponentially on repeated **). A pattern that reads a separator out of a bracket is not a pattern anyone writes on purpose. Everything else, the root included, answers as pathlib does. |
copy_into(), move_into(), replace() |
3.14: copy_into(target_dir, **kwargs) and move_into(target_dir) call copy()/move() and return the new path; copy() overwrites an existing file and copies trees. replace(target) is os.replace() and returns the target |
Defined once on Path over copy()/move(), on every backend and version. copy_into(target_dir, *, overwrite=False, follow_symlinks=True, preserve_metadata=True, recursive=False, ignore_error=None, progress=None, on_error=None) has the keywords and the defaults of copy(): an existing target needs overwrite=True and a directory needs recursive=True. move_into(target_dir, *, overwrite=False) has those of move(). Both return the new path. replace(target) is move(target, overwrite=True) and returns target: a directory is replaced only when empty (OSError(ENOTEMPTY) otherwise, nothing removed). On 3.14 LocalPath uses these rather than stdlib's copy_into/move_into; LocalPath.replace stays stdlib's os.replace() on every version. |
One behaviour on every backend, interpreter and Path subclass, and one that cannot overwrite or recurse by accident (3.14's default replaces a file silently). A caller that wants 3.14's behaviour passes overwrite=True, recursive=True. |
Path.info and pathlib.Path(...).copy(target) onto a Path of this library (3.14) |
3.14 Path.info is a PathInfo kept on the path (its answers are cached for the life of the path object); pathlib.Path.copy(target) calls target._copy_from(source, **kwargs) on any target with with_segments(), so a target without it raises AttributeError |
info is defined on every Path, on every version: an object with exists(), is_dir(), is_file() (each with follow_symlinks=True) and is_symlink(), over at most one following and one non-following stat(). A path holds no state, so its answers are cached on the info object, not on the path: each path.info asks again. LocalPath keeps stdlib's info on 3.14. Path._copy_from(source, follow_symlinks=True, preserve_metadata=False) makes a stdlib path's copy() work onto any Path of this library (overwrites a file and raises on an existing directory, as 3.14 does; preserve_metadata copies the permission bits and the modification time where the class can set them). A class that mixes a stdlib path keeps stdlib's. The private pathlib ABCs (_ReadablePath, ...) and __open_rb__/__open_wb__ are not implemented. |
Code written for 3.14's PathInfo and Path.copy() accepts these paths. Only the hooks Path.copy() reads from its target are carried, since the ABCs are private in 3.14 and have changed between pathlib-abc releases. |
Path.walk() and a Windows junction |
3.14: Path.walk() (os.walk with symlinks as files) lists a junction in filenames and does not enter it unless follow_symlinks=True |
A non-following walk() lists a junction in filenames and does not enter it, on every version and for every class whose is_junction() says so (LocalPath, and a class for file: URIs). follow_symlinks=True enters it, as pathlib does. |
A junction, like a symlink, is a second name for another tree; a junction that points at its own parent was walked 64 levels deep, until the path got too long. 3.14's behaviour is the safe one and the only one that can be had on 3.9-3.11, where Path.walk does not exist. |
LocalPath.symlink_to(target) with a relative str target, on Windows |
pathlib stores the text as given, ../real.txt included, and Windows cannot resolve a link whose relative target spells its separators / |
The path class rewrites the text into Windows separators (`.. | |
eal.txt) before the link is made; on POSIX the text is stored as given | A link that cannot be followed is never what a caller wants, andPathSyncerrecreates links fromas_posix()` text. |
|||
A with block that raises while writing to FTPPath or ZipPath |
The file is written as the program goes, so a block that raised leaves it holding what was written until then (and truncated, for "w") |
A backend that holds the write and commits it when the file is closed (an FTP STOR, a rewrite of an archive; in other distributions an HTTP PUT or an object-store upload) commits nothing when the block ended in an exception, any BaseException included: the target keeps its previous content, and is not created if it was absent. A block that ended normally, and a file closed with close() outside a with block, commit as before; an error raised by the commit itself propagates and is not retried. LocalPath, MemPath and an SFTP path write through as pathlib does, so a failed block leaves what was written there. write_bytes(), write_text(), copy() and PathSyncer open their target in a with block and get the behaviour. |
Such a backend has no partial state a reader could use: committing what a producer wrote before it failed replaces a complete remote file with a truncated one, which nothing can undo. A backend that writes through cannot take back what it has already written, and there pathlib's behaviour is the contract. |
Explicitly out of scope (not implemented on Pathname/Path)
These pathlib.Path methods are not part of the generic Pathname/Path
contract, or only through a primitive a backend may leave out, because they don't have a portable meaning across arbitrary
virtual and remote backends (a MemPath or an HTTP URL has no filesystem-relative
cwd, no symlinks, no OS-level owner/group). LocalPath gets every one of
these for free from pathlib.Path via MRO -- this list only describes what
MemPath, FTPPath and the archive paths (and custom Path subclasses in general) don't get:
resolve(),absolute(),readlink(),hardlink_to(),owner(),group()exist on everyPath, each a protocol (Resolve,Readlink,Hardlink,Owned), and need a primitive the backend supplies; until it does the method raisesNotImplementedError, andprotocols.supports()says whether it did.absolute()needs_cwd()(MemPathand the tree paths answer with their root, which a relative path is joined to),resolve()follows links throughreadlink(), at most 40, and is lexical (..applied, nothing read) on a class that cannot read links; a loop raises whatpathlibraises on the running interpreter.readlink()returns a path of the same class whose text is the link's.hardlink_to()needs_hardlink_to(),owner()/group()need_user_name()/_group_name()and astat()carryingst_uid/st_gid.symlink_to(target, target_is_directory=False, *, force=False)exists on everyPath, but onlyLocalPathand the classes of other distributions whose backend has links implement its_symlink_to()primitive; other backends raiseNotImplementedError. The archive paths implementreadlink()for the link members they hold.LocalPathkeepspathlib's own of each of these.expanduser(),Path.cwd(),Path.home()-- inherently tied to the local OS/filesystem, meaningless for a URI or in-memory path.walk(..., follow_symlinks=True)symlink-cycle protection --walk()itself is implemented (seePath.walk), but cycle detection when following symlinks is not, for any class:LocalPathruns the samewalk(), andpathlib.Path.walk()keeps no record of the directories it has visited either. Withoutfollow_symlinksthere is no loop to protect against (a symlink or a junction is not entered).Path.from_uri()andinfo(3.14) -- a class for URIs takes one in its constructor, andinfois a cached-stat object tied topathlib's own protocol.
Deliberate extensions (new methods/kwargs, not divergences)
These don't diverge from any existing pathlib behavior (pathlib has no equivalent, or the kwarg is new/optional) -- listed for completeness, not because a behavioral decision needed documenting:
joinpath(*args),rglob(pattern),full_match(pattern)(3.13 parity),anchor/drive/root/is_absolute()onPathname(generic derivation:rootis"/"when the first segment is empty, else"";driveis always"";is_absolute()is whether there is a root),read_text(..., newline=)(3.13 parity) -- all additive, no divergence.Path.glob()/LocalPath.glob():recursive=defaults to auto-detect (Trueif the pattern contains a"**"component, elseFalse) instead of pathlib's implicit-always-recursive-on-**with no override. Passingrecursive=False/Trueexplicitly always wins over the auto-detect. User decision, 2026-07-11.include_hidden=/dironly=are documented extensions beyond pathlib'sglob()signature. Caution: on remote backends (FTP), a recursive glob walks the whole remote subtree.BinaryOpen.copy(target, *, progress=None, chunk_size=shutil.COPY_BUFSIZE)/Path.copy(target, ..., progress=None): optional progress-reporting hook, no pathlib equivalent.BinaryOpen.copy()'sprogress(bytes_copied, total_size)fires after each chunk (total_sizeisNonewhen the source doesn't implementStatorstat()fails);Path.copy()'sprogress(path, bytes_copied, total_size)adds the sourcePathbeing streamed, so arecursive=Truecopy can report per-file identity alongside byte progress.chunk_sizeis now caller-visible (previously hardcoded toshutil.copyfileobj's default).progress=None(the default) is byte-for-byte identical to the priorshutil.copyfileobjbehavior -- no per-chunk overhead when unused. Known limitation: native/batch transfer paths that bypass the generic streaming copy -- none in this package, but a class of another distribution may have one (copy(recursive=True)on a directory) -- do not invokeprogress; this was a deliberate scope decision for the first cut (generic-stream-only), not an oversight. 2026-07-28.protocols.checksum.NativeChecksum(checksum(algorithm="md5") -> str) -- an entirely new, optional protocol with no pathlib equivalent. APathsubclass may implement it to compute a file digest server-side (e.g. an SFTP path against a server implementing the filexfer draft'scheck-file-handleextension; OpenSSH does not) instead of streaming the content throughopen("rb"). Not mixed into the basePath/PathnameABC -- most backends never implement it, and a plainPathhas no.checksumattribute at all. Hard contract, not a style choice: an implementation MUST raiseNotImplementedError(never return a value) when it cannot produce a genuine content digest under the requestedalgorithm-- this is what keepsPathSyncer(see its class docstring,sync.py) from ever comparing a native digest to a streamed one under a mismatched algorithm, or trusting something hash-shaped but not actually a content hash (e.g. an object store's ETag for a multipart upload, which a path class must refuse for exactly that reason: it offers the ETag only when it is the plain MD5 of the content).checksum.md5/sha256/stream(the pre-existing streaming helpers) are unaffected and keep working for direct callers that don't go through the protocol orPathSyncer.supported_checksums() -> frozenset[str](defaultfrozenset()) is a companion advisory capability query -- never raises, lets a caller pick a shared algorithm across two paths before calling anything expensive, but is advisory only:checksum()'s ownNotImplementedErrorcontract remains authoritative regardless of what this advertises.PathSyncer(..., quick_check=True)-- a new constructor kwarg, no pathlib equivalent. For any sync pair where at least one side is non-local, a metadata-only pre-check (st_size+st_mtime, from already-cached listing metadata, no extra round trip) skips the checksum call entirely when both already match; a mismatch always falls through to a real checksum rather than being treated as "changed" on its own. Times match at the resolution of the coarser side: in whole seconds when either has no fractional part, else to a microsecond. Local-to-local pairs never engage this pre-check.quick_check=Falserestores always-checksum behavior for non-local pairs too. User decision, 2026-07-28.