Changelog
Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Unreleased
0.1.0 - 2026-10-15
Published as pathlib-next up to 0.10.0; from 0.1.0 as pathapi. The released
sections below keep the name they were published under.
Added
supports()andcapabilities(): which protocols a path class implements.pathapi.protocols.supports(obj_or_class, *protocols)is true when every primitive of every protocol named is something other than its default stub, andcapabilities(obj_or_class)lists the protocols that hold. Ask once instead of calling and catchingNotImplementedError. The answer is advisory: a primitive may still refuse an argument. A class whose answer depends on its connection defines_supports(protocol) -> bool | None.- The operations of
Pathare protocols you can mix in.Create(mkdir,touch),Listing(iterdir,glob,rglob,walk),Remove(unlink,rmdir,rm),Readlink(readlink),Symlink(symlink_to),Identity(samefile),Copy,Moveand the pure-path descriptionNamedare inpathapi.protocols, andPathis built from them. A read-only backend can be a class built fromPathname,Listing,StatandBinaryOpenwith noPath:glob,checksum.md5andPathSyncertake it as a source. Chmodis split, and so is every pair of a reader and a writer.Chown(chown()),Owned(owner(),group()),Utime(_utime()),ReadlinkandSymlinkare protocols of their own, sosupports(path, Chmod)asks forchmod()alone and an archive supportsReadlinkand notSymlink.Chmodstill inheritsChownandUtime, sochown,_chownand_utimestay reachable from it.owner(),group(),readlink(),hardlink_to(),absolute()andresolve()on everyPath. A backend supplies_user_name()and_group_name(),readlink(),_hardlink_to()and_cwd();resolve()follows links throughreadlink(), at most 40, and is lexical where a class has none.LocalPathkeeps pathlib's own.- The contract suites' switches
supports_listing,supports_mkdir,supports_delete,supports_utimeandsupports_renamedefault toNone, which askssupports()aboutListing,Create,Remove,UtimeandMove, so a class without a protocol skips those tests with no switch set and the skip names the protocol. Set one toTrueorFalseon the test class to override the answer. providers.primitivemarks a default of a primitive whose body raises an error of its own, sosupports()reads it as absent.providers.derived_from(*primitives)marks a method that only wraps other primitives, so a base class can defineiterdir()over_listdir()andsupports()still says a subclass without_listdir()cannot list.providers.notimplementedtakes a reason:@notimplemented("why")marks a stub thatsupports()reads as absent and whoseNotImplementedErrorcarries the reason; the bare form is unchanged.
Changed
- The distribution, the import package and the repository are
pathapi. Version numbering restarts at 0.1.0.pathlib-next0.10.1 is a final release that depends onpathapiand re-exports it under the old import name with aDeprecationWarning. What to do: installpathapiand change the names in this table.
| Before | After |
|---|---|
pip install pathlib-next |
pip install pathapi |
import pathlib_next, pathlib_next.<module> |
import pathapi, pathapi.<module> |
entry-point group pathlib_next.http_transports |
pathapi.http_transports |
pathlib-next-requests (pathlib_next_requests) |
pathapi-requests (pathapi_requests) |
PATHLIB_NEXT_HTTP_TRANSPORT |
PATHAPI_HTTP_TRANSPORT |
the pathlib-next/<version> user agent, the .pathlib-next-tmp suffix of a PathSyncer temporary sibling |
pathapi/<version>, .pathapi-tmp |
the pathlib_next.sync and pathlib_next.http loggers |
pathapi.sync, pathapi.http |
benchmark result schema pathlib_next.bench/1 |
pathapi.bench/1 |
copy()over an existing file does not callunlink()on a target whose class has noRemove(it replaces the content throughopen()), andmove()raisesNotImplementedErrorbefore copying when the source cannot be removed and has norename()to use. Before, the first raised from theunlink()and the second left the file in two places. What to do: nothing, unless you relied on either.- Every
Pathnow has areadlink(), which raisesNotImplementedErrorwhere the class cannot read links, sohasattr(path, "readlink")no longer tells the two apart. What to do: asksupports(path, Readlink). - A write stream that commits on close (
CommitOnClose, and any stream built onDiscardOnError), or the text file over one, that is garbage collected without being closed is now discarded with aResourceWarninginstead of committed, as awithblock that raised already was. Before, the collector closed it and so uploaded or rewrote what the program never finished. What to do: close the file (usewith); a stream of your own onDiscardOnErrorcan override_holds_uncommitted()to stay silent when it has nothing to commit. _supports(protocol)may make at most one request per backend, cached for the backend's lifetime, and never raises (it answersNonewhen the request fails).
0.10.0 - 2026-10-14
pathlib-next 0.10.0 is the core alone: the path classes, pathlib_next.http,
FTPPath and the archive paths, with no dependency, no extra and no console
script. The URI layer, every scheme that needs a library, and the uripath
command are distributions of their own that depend on it. Under Changed, the
migration comes in this order: the table of names, then every change of
behaviour with what to do about it.
Added
pathlib_next.http: HTTP requests on the standard library, with a transport you can replace, and no URI.import pathlib_next.httpneeds no extra and no third-party library (it loadshttp.clientandssl;import pathlib_nextstill loads neither).HTTPClient(transport=None, *, headers=None, timeout=(10, 60), user_agent=None)sendsrequest(method, url, *, data=None, headers=None, stream=False, allow_redirects=None, timeout, auth=None)through a transport and follows redirects:GET,HEADandOPTIONSup to 30, withAuthorizationdropped for good by a hop to another scheme, host or port; every other method is sent again once after a 307 or 308 to the same origin and is otherwise anOSError(EIO)naming the status. A transport is any object withsend(method, url, *, headers, body=None, timeout, stream=False)that returns anHTTPResponse, and aclose();HTTPTransport(*, ssl_context=None, verify=True, proxies=None)is the built-in one onhttp.client(TLS verified by default,proxies=as an object indexed with the request URL, one idle connection per thread and server). The package also holds the helpers a path built on it needs:raise_for_status,translate_errors(a failed request as theOSErrortypepathlibraises),read_limited,json_bodyanddrop_body(a reply read whole is held toMAX_REPLY_BYTES, 8 MiB after decoding, and the setting is assignable),peer_text,split_userinfo,server_sizeandis_redirect.PATHLIB_NEXT_HTTP_TRANSPORTnames the transport a client builds when it is given none:builtin, or the name of one an installed distribution declares in the entry-point grouppathlib_next.http_transports(requests, from thepathlib-next-requestsdistribution;transport_names()lists them without importing any andget_transport(name)loads one).prepare_request()makes the checks of the transport contract for a transport of your own.pathlib_next.http.testing: the suite that holds a transport to the promises of the built-in one. With the distribution that provides the transport andpytestinstalled,python -m pathlib_next.http.testing --http-transports requests -qruns the tests that depend on the transport against it, over loopback servers scripted at the byte level. The test files ship in the wheel. It is testing support and not part of the stable API: its contents, including test names, fixtures and helper signatures, may change in any release, andpathlib_next.httpnever imports it.pathlib_next.ftp:FTPPathandFTPBackend, an FTP tree that needs no URI and no extra.FTPPath("/pub/x", host="h", user="u", tls=True)builds the path's ownFTPBackend;FTPPath("/pub/x", backend=backend)shares one connection cache between paths built separately (or plugs in aBaseFTPBackendof your own), and giving both is aTypeError. Nothing connects until the first operation.FTPBackend(host, port=21, *, user, password, tls, timeout, ssl_context, verify)keeps a control connection per thread, is a context manager, andclose()ends every connection it holds.str()is the remote path,repr()names the endpoint and never shows a password, and two paths are one tree when they share a backend or name the same host, port, account and security.pathlib_next.FTPPathis imported on first use, soimport pathlib_nextstill loads neitherftplibnorssl.pathlib_next.archive:ZipPath,TarPathandArchivePath, the members of an archive file that is anyPath, with no URI and no extra.ZipPath("d/x", archive="a.zip")takes the archive as aPath, or astroros.PathLike(aLocalPath); the segments are the member path, read from the archive root.ZipPathandTarPathfix the format,ArchivePathreads it from the extension or the first bytes on first use, and building a path reads nothing. One handle serves each archive file and format, so paths built separately see each other's writes. A zip that is a local file is written in place (write_bytes,mkdir,unlink,rmdir,rename,rm(recursive=True),batch()); every other archive is read-only, in memory or on any otherPath.str()is the member path,repr()names the archive, andas_uri()iszip:<archive uri>!/<member>.ArchivePath.backend_for()gives the handle to pass asarchive=.pathlib_next.ZipPath,TarPathandArchivePathare imported on first use.pathlib_next.providers.TreePath, the baseFTPPathand the archive paths are built on, for a path whose names are POSIX segments below the root of a tree a backend serves.pathlib_next.providers.name_stem(),name_suffix()andname_suffixes(). One path component split into stem and suffix aspathlibof the running interpreter splits it, for a class that computes those itself and must agree withPathname.pathlib_next.exceptions:PathError, the base of the exceptions the package defines.NonRelativePatternError(an absolute glob pattern) is now aPathErroras well as aNotImplementedErrorand aValueError, so everyexceptclause that caught it still does. I/O failures stay theOSErrortypespathlibraises.PathError,NonRelativePatternErrorandMemPathare exported frompathlib_next.pathlib_next.mempath,pathlib_next.syncandpathlib_next.testingdeclare__all__, sofrom pathlib_next.sync import *publishesPathSyncer,SyncEventandPathAndStatand nothing else, and a type checker reads those names as the module's exports.pathlib_next.providers.DiscardOnError, a mixin for a write stream that commits when it is closed: its__exit__callsdiscard()when thewithblock raised, then closes.CommitOnCloseinherits it and has adiscard(); see the entry under Changed.
Changed
- Where every name went. Change each import and each install as the table
says. There is no alias for an old name: the old import raises
ModuleNotFoundErrororImportError, and a name that is not in the table was never public. The URI layer, the schemes and the command line are the distributionsuripath,sftppath,objpath,repopathandpathlib-next-requests, which each document their own changes of behaviour; the core's own moves come first.
| Was | Is now |
|---|---|
pathlib_next.utils.sync (PathSyncer, SyncEvent, PathAndStat) |
pathlib_next.sync |
pathlib_next.utils.glob (glob, select, full_match, parse_pattern, ...) |
pathlib_next.glob |
pathlib_next.utils.glob.NonRelativePatternError |
pathlib_next.NonRelativePatternError (defined in pathlib_next.exceptions) |
pathlib_next.utils.checksum (md5, sha256, stream, native) |
pathlib_next.checksum |
pathlib_next.utils.md5, pathlib_next.utils.sha256 |
pathlib_next.checksum.md5, pathlib_next.checksum.sha256 |
pathlib_next.utils.archive, pathlib_next.utils.make_archive, pathlib_next.utils.unpack_archive |
pathlib_next.archive.make_archive, pathlib_next.archive.unpack_archive |
pathlib_next.utils.archive.detect_format (also _detect_format) |
pathlib_next.archive.detect_format |
pathlib_next.utils.stat.FileStat |
pathlib_next.FileStat |
pathlib_next.utils.as_mode |
pathlib_next.providers.parse_mode |
pathlib_next.utils.as_owner |
pathlib_next.providers.parse_owner |
pathlib_next.utils.UNCHANGED |
pathlib_next.providers.UNCHANGED |
pathlib_next.utils.as_error_handler, as_error_predicate, OfferedOnce |
pathlib_next.providers.as_error_handler, as_error_predicate, OfferedOnce |
pathlib_next.utils.is_safe_child_name, is_windows_flavoured |
pathlib_next.providers.is_safe_child_name, is_windows_flavoured |
pathlib_next.utils.notimplemented |
pathlib_next.providers.notimplemented |
pathlib_next.utils.LRU |
pathlib_next.providers.LRU |
pathlib_next.utils._commit.CommitOnClose (also _CommitOnClose) |
pathlib_next.providers.CommitOnClose |
pathlib_next.path.check_follow, contains, copy_error_predicate (also _check_follow, _contains) |
pathlib_next.providers.check_follow, contains, copy_error_predicate |
pathlib_next.utils.sizeof_fmt, pathlib_next.utils.parsedate |
removed: no replacement is public |
the package pathlib_next.uri |
the distribution uripath, import name uripath |
pathlib_next.Uri, pathlib_next.UriPath (and pathlib_next.uri.Uri, UriPath, UriLike) |
uripath.URI, uripath.URIPath, uripath.URILike |
Path, Pathname, FileStat imported from pathlib_next.uri |
the same names in pathlib_next |
pathlib_next.uri.source.Source |
uripath.Source (also uripath.source.Source) |
pathlib_next.uri.source.compose_uri, compose_host, percent_encode, is_drive, SAFE_PATH, ERRORS |
the same names in uripath.source |
pathlib_next.uri.same_authority |
uripath.source.same_authority |
pathlib_next.uri.DerivedBackend |
uripath.schemes.DerivedBackend |
pathlib_next.uri.query.Query; Query(text), Query.decode() |
uripath.Query, no longer a str; Query.parse(text), Query.to_pairs() |
Source.from_str(text, strict); Source.as_str(sanitize=True), as_str(sanitize=False) |
Source.parse(text, *, strict=True); str(source), source.reveal() |
pathlib_next.uri.schemes.file.FileUri, schemes.data.DataUri |
uripath.schemes.FileURIPath, DataURIPath |
schemes.archive.ZipUri, TarUri, ArchiveUri, ArchiveZipUri, ArchiveTarUri |
uripath.schemes.ZipURIPath, TarURIPath, ArchiveURIPath, ArchiveZipURIPath, ArchiveTarURIPath |
schemes.ftp.FtpPath |
uripath.schemes.FTPURIPath for ftp:; pathlib_next.FTPPath for a path with no URI |
schemes.ftp.FtpBackend, BaseFtpBackend |
uripath.schemes.FTPURIBackend, BaseFTPURIBackend (pathlib_next.ftp.FTPBackend belongs to one host) |
schemes.ftp.DEFAULT_TIMEOUT, IDLE_PROBE_SECONDS; schemes.archive.MAX_LINKS, LinkTarget |
pathlib_next.ftp and pathlib_next.archive, which define them |
schemes.archive._base.MEMBER_SPOOL_BYTES |
uripath.schemes.MEMBER_SPOOL_BYTES (pathlib_next.archive.MEMBER_SPOOL_BYTES for ZipPath) |
schemes.http.HttpPath, HttpBackend, HttpWriteStream, HttpAppendStream |
uripath.schemes.HTTPURIPath, HTTPBackend, HTTPWriteStream, HTTPAppendStream |
schemes.http.HTTPTransport, HTTPResponse |
pathlib_next.http.HTTPTransport, HTTPResponse |
schemes.http.MAX_LISTING_BYTES |
pathlib_next.http.MAX_REPLY_BYTES (assign it there) |
schemes.http.RequestsTransport |
pathlib_next_requests.RequestsTransport, from the distribution pathlib-next-requests |
schemes.dav.DavPath |
uripath.schemes.DAVURIPath |
schemes.sftp.SftpPath |
sftppath.SFTPPath for a path built from keywords; sftppath.uri.SFTPURIPath for sftp: |
schemes.sftp.SftpBackend, AsyncsshSftpBackend, BaseSftpBackend, CheckFileSftpBackend |
sftppath.SFTPBackend, AsyncSSHBackend, BaseSFTPBackend, CheckFileSFTPBackend |
schemes.s3.S3Path, S3Backend, BaseS3Backend |
objpath.S3Path, S3Backend, BaseS3Backend; objpath.uri.S3URIPath for s3: |
schemes.gs.GsPath, GsBackend, BaseGsBackend |
objpath.GSPath, GSBackend, BaseGSBackend; objpath.uri.GSURIPath for gs: |
schemes.az.AzPath, AzBackend, BaseAzBackend |
objpath.AZPath, AZBackend, BaseAZBackend; objpath.uri.AZURIPath for az: |
schemes.github.GitHubPath, schemes.gitlab.GitLabPath |
repopath.GitHubPath, GitLabPath for paths with no URI; repopath.uri.GitHubURIPath, GitLabURIPath for github:, gitlab: |
schemes.git.GitPath (git:); GitHubGitPath, GitLabGitPath (git+github:, git+gitlab:) |
repopath.RepoPath, repopath.uri.RepoURIPath (repo:); github: and gitlab: replace git+github: and git+gitlab:; git: itself is gone |
schemes.github.RepoBackend; schemes._gitrepo.BaseRepoBackend; InsecureTransportWarning |
repopath.RepoBackend, BaseRepoBackend, InsecureTransportWarning |
pathlib_next.tools.uripath (main(argv, *, stdin, stdout, stderr), build_parser()) |
uripath.cli.main(argv=None); there is no parser to build |
the script uripath = pathlib_next.tools.uripath:main |
uripath = uripath.cli:main, which needs uripath[cli]; python -m uripath runs it too |
the entry-point group pathlib_next.schemes |
the group uripath.schemes |
the environment variable PATHLIB_NEXT_SFTP_BACKEND |
SFTPPATH_BACKEND, read by sftppath; the old name is not read |
the extra pathlib-next[uri] |
install uripath |
pathlib-next[http] |
nothing for pathlib_next.http; uripath for http: and dav: paths; pathlib-next-requests for a requests.Session or PATHLIB_NEXT_HTTP_TRANSPORT=requests |
pathlib-next[sftp] |
sftppath[paramiko] |
pathlib-next[sftp-async] |
sftppath[asyncssh] |
pathlib-next[s3], [gs], [az] |
objpath[s3], objpath[gs], objpath[az] |
github: and gitlab: through pathlib-next[uri] or [http] |
repopath[uri]; repopath alone for GitHubPath and GitLabPath with no URI |
git://github.com/o/r/f, git+github://host/o/r/f, git+gitlab://host/o/r/f |
repo://github.com/o/r/f, github://host/o/r/f, gitlab://host/o/r/f |
the uripath command, installed with pathlib-next[uri] |
uripath[cli] |
sftp:, s3:, gs: or az: paths through UriPath with the matching extra |
add the uri extra to the provider (sftppath[uri], objpath[uri], repopath[uri]), or install uripath beside it |
pathlib_next.path, pathlib_next.glob, pathlib_next.sync and
pathlib_next.testing are now packages whose private modules hold the code;
they export the same names, and pathlib_next.path.Path is still
pathlib_next.Path. A module or name that starts with an underscore is not
part of the API. Code that builds a class on Path takes its helpers from
pathlib_next.providers.
- A write stream that commits on close is not committed when its with block
raised. FTPPath and the members of a zip (ZipPath, ArchivePath) hold
what is written and send it when the file is closed. A with path.open("wb")
as f: block that wrote part of a file and then raised used to close the
stream on the way out, so the part replaced the complete remote file. Now a
block that ends in an exception (any BaseException, KeyboardInterrupt
included) discards what was written: the target keeps its previous content, or
is not created, and nothing is sent (no STOR or rewrite; a ZipPath inside
batch() does not stage the member, and the block's other changes still
commit). The same holds in text mode, where the text the wrapper still held is
dropped, and for write_bytes(), write_text(), copy() and PathSyncer,
which open their target in a with block. A block that ends normally, and a
stream closed with close() outside a with block, commit as before, and an
error raised by the commit still propagates and is not retried. LocalPath
and MemPath write through as pathlib does and are unchanged: a failed
block leaves what was written. If you relied on the partial upload, close
the stream yourself instead of raising through the block (f.close() commits
what was written). A path of your own whose _open() returns a stream that
commits on close gets the same behaviour by returning
providers.CommitOnClose, or by giving its stream class
providers.DiscardOnError as its first base and a discard() that drops what
was written; a stream that has discard() and not the mixin is discarded in
text mode only. A failed open() of a text file (an unknown encoding=) no
longer creates an empty file on such a backend.
- A path class that does not define __fspath__ is not an os.PathLike.
Every Pathname (MemPath, FTPPath, ZipPath, TarPath, ArchivePath
and any class built on Path) was registered with os.PathLike, so
isinstance(path, os.PathLike) was true while os.fspath(path), open(path)
and pathlib.Path(path) raised NotImplementedError: Method not implemented:
__fspath__. Now only a class that defines __fspath__ is one (LocalPath,
PosixPathname, WindowsPathname, and a class of your own that defines it):
isinstance(MemPath("/a"), os.PathLike) is false and
os.fspath(MemPath("/a")) is a TypeError, as for any object. FsPathLike
stays a protocol for annotations, is no longer a base of Pathname and is no
longer registered with os.PathLike. What to do: catch TypeError where you
caught NotImplementedError from os.fspath(), ask isinstance(path,
os.PathLike) instead of assuming it, and define __fspath__ on a class of
your own that has a local name.
- touch(mode=...) raises where the mode cannot be applied.
MemPath("/f").touch(mode=0o600), and touch() with a mode on any class
without chmod(), created the file and returned None, dropping the mode
without a word. It now raises NotImplementedError and removes the file it
created. An existing file is left as it is, its mode included, as pathlib
does. What to do: call touch() without mode on such a class, and chmod()
where the class has one.
Removed
- The URI layer, its schemes and the
uripathcommand.pathlib_next.uri(Uri,UriPath,Source,Query, every scheme) andpathlib_next.toolsare gone,pathlib_nextno longer exportsUriorUriPath, andimport pathlib_nextnever looks foruritoolsornetimps. The package declares no extra butdevanddocs: the extrasuri,http,sftp,sftp-async,s3,gsandaz, the entry-point grouppathlib_next.schemesand theuripathscript are removed. A project that imported one of those names, installed one of those extras or ran the command installs the distribution the table names. Two distributions defining one script would collide, which is why the command is not kept here.
0.9.14 - 2026-10-12
Added
MemPath.relative_to(other, *, walk_up=False). It raisedNotImplementedError; it now answers asPurePosixPath.relative_todoes, on the path's own names (no..applied):otheris aMemPathof the same backend or astr, and the result is a relativeMemPathon that backend.ValueErrorwhen the path is not belowother, when only one of them is rooted, or when the backends differ.walk_up=Truegives the..-prefixed form on every Python version, not only 3.12 and later. Code that caughtNotImplementedErroraround it can call it directly.copy(on_error=)andcopy_into(on_error=)takerm()'s error predicate.on_error(error, path) -> bool(or a bool) is asked once for each child of a recursive copy that fails, withpaththe source entry:Trueskips it and the copy goes on,Falseraises the error. The same function used to mean opposite things to the two methods (lambda *a: Falsemaderm()raise andcopy()suppress every error), and a predicate written for one raisedTypeErrorin the other.ignore_error=keeps its notify-only callable; useon_error=for a predicate. Passing both raisesTypeErrorbefore anything is copied.sftp:(the asyncssh concurrent walk included),s3:,gs:andaz:accept it, andPathContractgains a test that gives one predicate torm()andcopy().Path.info, and Python 3.14'spathlib.Path.copy()onto anyPath. EveryPath(MemPath,UriPath,SftpPath, ...) hasinfo, on every Python version: an object withexists(),is_dir(),is_file()(each withfollow_symlinks=True) andis_symlink(), the shape of 3.14'spathlib.types.PathInfo. It callsstat()at most once following links and once not, and remembers the answers on that object only, so a newpath.infoasks again.LocalPathkeeps pathlib's own on 3.14. A new hook,Path._copy_from(source, follow_symlinks=True, preserve_metadata=False), letspathlib.Path(f).copy(MemPath("/x"))work on 3.14, where it raisedAttributeError: ... has no attribute '_copy_from': it overwrites a file, raises on an existing directory, and withpreserve_metadata=Truecopies the permission bits and the modification time where the class can set them.Path.copy()of this library is unchanged and does not call it.FileStat.size_known, and listings that know a type but not a size. A listing can now say "this is a file" without inventing its size: a stat withsize_known=Falseanswersis_dir(),is_file(),exists(),walk(),glob()andrm(recursive=True), whilestat()of the listed child asks the server for the real size and time, andPathSyncer, the progress total ofcopy()andquick_checknever read the placeholder.gitlab:blobs, an Apache index with sizes like1.2Kand the stdlib bulleted-list index (http:) used to give no hint at all, so walking a tree cost a request per file; it now costs one per directory. An exact size from a listing that has one stays a full hint. A scheme of your own may yieldFileStat(is_dir=False, size_known=False)from_scandir(); code that reads a listing'sst_sizeorst_mtimeshould checksize_known.PathSyncer(follow_mounts=False)anduripath sync --no-follow-mounts. A mount point found below the target root (a mounted volume, a bind mount) is still synced into by default, asrsyncdoes. Withfollow_mounts=Falseit is an error for that entry instead:OSError(EXDEV)naming it, offered toignore_errorwith eventTypeMismatch, and nothing behind it is listed, written or removed, in a dry run too. A bind mount aliases another directory as a junction does, andrm(recursive=True)already stops at one; this lets a sync withremove_missing=Truerefuse it. A junction is replaced under either value, and the root target is used as given.touch()moves an existing file's time, and a sync leaves its copies with their source's time. The generictouch()of an existing file did nothing; it now sets the access and modification times to now through a new_utime()hook (_utime(*, atime=None, mtime=None), seconds since the epoch,Noneleaves that time alone), implemented byLocalPath,FileUri,MemPath,SftpPathon both client libraries (whole seconds) andFtpPathon a server whoseFEATlistsMFMT(modification time only). A class that does not implement it raisesNotImplementedErrorfrom_utime()andtouch()leaves the file as it was, as before.PathContractgainssupports_utime(defaultTrue; set itFalseon a test class whose backend cannot set a time) and a test behind it.PathSyncer(preserve_mtime=True). A file the sync copies is given the modification time its source reported, where the target's class implements_utime(); a source that reports no time, a target that cannot set one or refuses it, keep the time the copy gave the file, and the sync reports nothing. Directories and links are left alone.quick_check(default on for a pair with a non-local side) could never succeed after this library's own copy, so a remote tree was checksummed, and so downloaded, again by every later run; the second run of an unchanged tree now reads no file.- The names the built-in schemes take from the core are documented, and the
helpers among them are public. The shipped API header gains "For path
implementers" and "For scheme implementers" sections, each hook with its
signature and one line of contract (
_init,_from_parsed_parts,_format_parsed_parts,_make_child_relpath,_rename_target,_pop_stat_hint,_same_location, the backend-state hooks,_anchor_segmentsand the rest). Twelve helpers that were underscore names now have public ones:pathlib_next.path.check_followandcontains,pathlib_next.utils.archive.detect_format,pathlib_next.utils._commit.CommitOnClose,pathlib_next.uri.same_authorityandDerivedBackend, and inpathlib_next.uri.sourcecompose_uri,compose_host,percent_encode,is_drive,SAFE_PATHandERRORS. The underscore spellings (_check_follow,_contains,_encode, ...) still import and are the same objects, so nothing has to change; a scheme written outside this package can use the documented names instead.path.__all__now listscheck_followandcontains. glob(recurse_symlinks=True)andrglob(recurse_symlinks=True)work. They raisedNotImplementedErroron every class.**now descends through symlinks to directories, as pathlib 3.13 does, and the keyword is accepted on every Python version. A loop ends where pathlib 3.13 would follow it until the system refuses: a link to a directory the descent is already inside (st_dev/st_ino) is listed as an entry and not entered, and a backend whose stat has no identity follows at most 40 links in one descent.utils.glob.glob()andutils.glob.select()take the keyword too. The default staysFalse; a caller that caught theNotImplementedErrorto fall back to its own walk can drop the fallback.checksum()andsupported_checksums()ons3:,gs:andaz:. An object-store path now answersNativeChecksumfrom the digest the store keeps, with one request and no byte of the content.S3Pathgives the plain MD5 that the ETag is for an object one request wrote and that KMS or a customer key does not encrypt (checksum("md5")), and the stored full-objectcrc32,crc32c,crc64nvme,sha1orsha256;GsPathgivesmd5Hashandcrc32c;AzPathgives theContent-MD5property. A multipart ETag (...-N), a composite checksum, a GCS object with a content encoding or nomd5Hash, a blob withoutContent-MD5and any other algorithm raiseNotImplementedError, never a guess; a missing path isFileNotFoundErrorand a prefix directoryIsADirectoryError.PathSynceruses them without being told: the secondsync()of an unchanged tree of 100s3:objects to another prefix sent 200GetObjectbefore and sends none. Azure'sContent-MD5is a property a client can set and the service does not verify against the content in every case.copy()of a file within one object store is one request of the store.s3:/gs:(inside a bucket, or between buckets that one client reaches: the same supplied backend, or both paths' default) andaz:(inside a container) issueCopyObject,copy_blobor the blob copy instead of reading the file and writing it back;copy(recursive=True)andmove()of a prefix do so for each file. A file copy ons3:took 6 requests (aGetObjectand aPutObjectamong them) and takes 4, of which one is the copy. The checkscopy()always made run first and in the same order (the same file, a target that is a directory or holds keys, an existing target withoutoverwrite=True); an existing target is replaced by the copy and no longer deleted first, so it is intact when the copy fails;progressis called once with the total; a file named as the root of arecursive=Truecopy is still read and written; a store that answers "not implemented" gets the streamed copy. The copy keeps the object's content type and metadata, which the streamed copy did not.gs:andaz:read and write an object in pieces. Reading asks for ranges of 4 MiB (gs:) or 512 KiB (az:,schemes.az.READ_CHUNK: the SDK holds about nine times a response, assign a larger number to trade memory for requests) as the stream is read, instead of downloading the whole object first; the stream stays seekable andseek()asks nothing until the next read. Writing spools in memory up to 4 MiB and then in a temp file, ass3:does, and uploads on close: a resumable upload in 2 MiB requests ongs:, blocks onaz:. A read is one version of the object: the first request reports its generation (gs:) or ETag (az:) and every later request of that stream requires it, so an object replaced while it is being read raisesOSError(errno.ESTALE,EIOwhere there is none: the object changed while it was being read) instead of returning the bytes of two objects, and one deleted meanwhile isFileNotFoundError; each open is its own snapshot, andseek(n, 2)measures that version. A 32 MiB object is read and written with a peak of under 9 MiB of Python memory against the SDK test servers. Nothing to change; a program that held ags:oraz:file object open for a long time now issues a request per chunk read, and catchesOSErrorif the object may be replaced under it.ArchiveUri.batch()holds the changes to a zip and writes them in one replacement.with zip_path.batch():makes the writes,mkdir(),unlink(),rmdir(),rename()andrm()inside the block change a pending member list that every path of the archive reads, and replaces the archive file once when the outermost block ends: the members, their order and their metadata are what the same changes made one by one leave. An exception in the block leaves the file byte for byte as it was, and so does a file another process replaced meanwhile (OSError(EBUSY)). A block inside another joins it, and another thread's use of the archive waits for the block. A tar, a non-local zip and a nested archive raiseNotImplementedError, as their writes do. Each change patches the pending member list instead of deriving it again: a block of any number of renames, deletes or overwrites derives the list twice. A new hook,Path._write_batch()(documented for path implementers, a block that does nothing by default), is whatcopy(recursive=True)andPathSyncer.sync()enter around their writes to their target.- Symbolic links in
zip:andtar:archives are links. A tar symlink and a zip entry whose Unix mode says link are listed as links:is_symlink()is True,stat(follow_symlinks=False)is a link,readlink()(new on archives, aLinkTarget) is the text it stores, andstat(),exists(),is_dir(),open()anditerdir()follow it inside the archive. A zip link used to read as a file holding its own text, a tar link as the target's content (a loop was aRecursionError, a link to a directoryIsADirectoryError), andln/xbelow a link was not found. A link that is absolute, leaves the archive or points at nothing is nowFileNotFoundErrornaming the link and a loop, or more than 40 links in one lookup,OSError(ELOOP).walk()andglob()list a link once and do not enter it by default; a listing that does follow 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, which answersOSError(ELOOP)to a followingstat()and is an error for that entry in a copy or a sync (passignore_error=orfollow_symlinks=False), so a few links cannot make a recursive operation visit more entries than the members and links times one more than the directory links.rm()removes the entry and never what it points at, andcopy(follow_symlinks=False)and aPathSyncerthat keeps links recreate it. A program that read a zip link's text withread_bytes()now gets its target's content: usereadlink()for the text. Writing through a link, and creating one, raiseNotImplementedError. A tar hard link is unchanged.
Changed
- A sync leaves each copy with its source's modification time
(
preserve_mtime=True, the default). A copy used to carry the time it was written at. Passpreserve_mtime=Falseto keep that.quick_checknow compares two times at the resolution of the coarser side: in whole seconds when either has no fractional part (SFTP, FTP and the object stores report seconds, a local file system fractions), else to a microsecond; times one second apart still differ. A pair that is local on both sides still skips the quick check. move()asks a backend about each end once. A move that falls back to copy and delete stat-ed the source and the target several times each before it started to copy (the same-entry check,move()'s own checks, thencopy()'s). It now takes one stat of each and hands the answer to the same-entry check and to thecopy()it calls, for that call only; nothing is kept on a path. On an object store each stat is a request pair, so a directory move makes fewer requests. Every refusalmove()made is made as before, in the same order. Nothing to change in a caller or a subclass; a class that overridessamefile()is still asked.S3PathandGsPathbuilt withoutbackend=share one default backend per process. Each used to build a backend of its own, and with it a client and a connection pool, as soon as it needed one; a loop that builds a path per key paid for one each. They now use one lazily built, locked backend, asAzPathdoes per account, so two separately built paths use one client and the credentials, region and endpoint that the environment gave the first request are theirs. A supplied backend is unaffected, and so aresame_filesystem(), pickling andcopy.copy. To keep a client of its own, passbackend=S3Backend(...)/GsBackend(...). A suppliedAzBackendis used for whatever account the URI names; the header now says so.- Copying a tree into a zip and syncing onto one rewrite the archive
once.
copy(recursive=True)andPathSyncer.sync()replaced the archive file for every member and directory they wrote (71 times for a 60-file tree, 6 s for 300 small files); they now run insidebatch()and replace it once. A copy or a sync that raises now leaves the archive as it was: the members written before the error used to stay, so a program that read them back after catching the error finds the archive unchanged. A dry run opens no batch. Nothing to change in a caller that does not depend on those partial results. SftpPath.replace()is one atomic request. It sendsposix-rename@openssh.comwhere the server has it, so the file at the target is replaced without being removed first, and a refusal that names its cause (a permission, a missing source) leaves both entries as they were.move(overwrite=True)onto an existing file of the same server uses it: it was a removal followed by a rename, with a window in which the target did not exist, and a failed rename lost the target. A server without the extension, or one that refuses it with a bare failure, gets the removal and rename as before; a directory at the target is checked byPath.replace()first, as before. Nothing to change in a caller.stat()of agithub:file asks withHEAD. It was aGETof the contents object, which carries the file base64-encoded, so everystat(),exists()andis_file()of a file downloaded it. The size now comes from theContent-Lengthof aHEADasked for the raw media type, and a path that does not exist is answered by that request too. A directory other than the root of the repository now costs two requests (theHEAD, then the listing as before), which counts against the API's rate limit; a reply that is not the API's own, or a server that refusesHEAD, is followed by theGETas before. Nothing to change in a caller.- The dates of an HTML index are documented as UTC. An index page
prints them with no zone;
http:has always read them as UTC, so thest_mtimeof a listed entry can be off by the server's offset. The divergences page and the header now say so;quick_check=Falseor a comparison of sizes avoids it.
Fixed
github:no longer reads a proxy's HTML page as the file. A raw read answered200withContent-Type: text/htmland noX-GitHub-Media-Typeheader (a captive portal, a gateway's login page) returned the page as the file's content; it is nowOSError(EIO)naming the path. A reply that carries the header, and so an HTML file of the repository, still reads. The rule has not been checked against the live service. Nothing to change in a caller.- A predicate that raises ends
rm()andPathSyncerwith its own exception. Each directory above the failing entry offered that exception back to the predicate as one more failure, so a predicate that raised for one kind of error could swallow its own exception a level up. It now propagates as it is and is not offered again (also in the asyncssh concurrentrm()). Nothing to change for a caller. - On Python 3.12 a literal pattern component typed in another case yields
the stored name. On a case-insensitive filesystem
glob("SUB")selectedSUBwhere pathlib 3.12 selects the entry as the directory lists it (sub), because 3.12 reads every literal from the listing.native=True(the default) follows 3.12 there; other versions andnative=Falsestill join a literal as typed. Nothing to change for a caller. - From Python 3.13
glob("name/")tests the literal as pathlib does, withos.path.lexists(name + os.sep), so the platform decides what a link is there: on Windows a dangling directory symlink is selected, on macOS a link to a file is.LocalPathselected nothing for either. Linux and earlier versions are unchanged. GsPath.rm(recursive=True)deletes in batches. It sent oneDELETEper blob; it now queues them in batch requests of 100 (a prefix of 250 blobs is 3 requests), offers each key the batch refused toignore_erroronce with its own path, and falls back to one request per key for a chunk the client cannot batch. A blob that is already gone no longer raises, as ons3:andaz:. Nothing to change for a caller.
0.9.13 - 2026-10-09
Added
copy_into(),move_into()andreplace()on everyPath. pathlib 3.14 has them and the generic classes did not (MemPath,UriPath,DavPath,SftpPath, ... raisedAttributeError;LocalPathhadcopy_into()andmove_into()on 3.14 only).copy_into(target_dir, ...)copies under the same name withcopy()'s keywords and defaults,move_into(target_dir, *, overwrite=False)moves withmove()'s, and both return the new path.replace(target)ismove(target, overwrite=True)and returnstarget; a directory is replaced only when empty (OSError(ENOTEMPTY)otherwise, and nothing is removed). Astrdirectory or target stays on the same backend.infois not added.Pathname.is_absolute()has a default: the path has a root. It raisedNotImplementedErrorforMemPathand everyPathnamesubclass that did not override it.- Generic paths can be ordered.
sorted(),<,<=,>and>=raisedTypeErrorforMemPath,Uri,UriPathand every customPath.Pathnamenow orders two paths of the same exact type by the key its==uses (the segments; the URI text forUri), and returnsNotImplementedfor anything else, aspathlibdoes across flavours. A subclass with its own__eq__overrides_order_key()to match it.LocalPathkeepspathlib's ordering. pathlib_next.__version__and__all__on the core modules. The root has__version__, the installed distribution's version ("0+unknown"for a source tree that is not installed). The root,path,fspath,protocols(withfs,ioandchecksum),utils,utils.statandutils.globdeclare__all__with the namessrc/pathlib_next/AGENTS.mddocuments for them. Nothing documented moved.InsecureTransportWarning. ARepoBackend(github:,gitlab:,git:) that is about to send anAuthorizationheader (its token, or one passed asheaders=) to a plainhttp://API root on a host other than loopback now warns with thisUserWarningsubclass (importable frompathlib_next.uri.schemes._gitrepo,.githuband.gitlab); the request is still sent. Use anhttps://api_base, or filter the warning for a trusted network.SftpAuthenticationErrorandSftpHostKeyError. Importable frompathlib_next.uri.schemes.sftp. A refused login is the first (aPermissionError,EACCES) and a host key that is unknown, changed or refused the second (aConnectionError,ECONNABORTED), on both SFTP backends. CatchOSError, or these two to tell a login from a host key.ArchiveUri.refresh(). A remote archive (zip:http://...!/m) is read once and kept while any path to it lives, so a change at its source was never seen by a program that held a path.path.refresh()forgets what is held of the archive, for every path to it, and the next use reads it again. A local archive is checked against its file on every use and needs none.GsBackend(timeout=, retry=). Both go to every call aGsPathmakes (timeoutin seconds or a(connect, read)pair,retryagoogle.api_core.retry.RetryorNonefor none). Without them the SDK's defaults apply, and an unreachable endpoint kept every call waiting for about two minutes. A customBaseGsBackendadds options throughcall_options().schemes.az.COPY_POLL_TIMEOUT. How longAzPath.rename()waits for the server-side copy (300 seconds); set it before the call to change it.pathlib_next.testing: five capability switches and eight tests. For a contract class that subclassesPathContract, the new tests are: a payload of about 290 KiB holding every byte value and runs of CR/LF, written whole and in pieces and read back whole and in parts; names holding a space,#,%,+,&,=and mixed case, stored, listed, renamed and removed as written;stat()of a longer file and of a directory, and a numericst_mtime;_scandir()agreeing withstat()for every child; nothing existing below a file; partialread(n); andrename()onto a non-empty directory raising. The switches, each documented in the module and in the guide:supports_mkdir,supports_deleteandsupports_move(defaultTrue: a backend with no directories, delete or move, such as TFTP, sets themFalseinstead of marking tests as expected failures),supports_unusual_names(defaultTrue), andsupports_question_mark_names(defaultFalse: a Windows file system cannot store a name holding?). A backend that kept passing the old suite and now fails a new test was altering bytes, sizes or names: fix it, or set the switch that names the gap. Withsupports_delete = False,copy(overwrite=True)is no longer exercised;enforces_directory_hierarchy = Falsealso skips the rename-onto-a-directory test.
Changed
from pathlib_next import *publishes only the documented names. It also broughtP,PNand the__future__nameannotations, andfrom pathlib_next.protocols import *andfrom pathlib_next.utils import *brought the modules they import.P,PN,K,Vand the rest stay importable from the module that defines them; a script that relied onfrom pathlib_next import *for one of them imports it by name.- The constructors of
Path,LocalPath,MemPathandFileStatpass mypy and pyright at their defaults. Both readPath("x"),LocalPath("x"),MemPath("/a")andFileStat(...)as constructing an abstract class (the protocol stubs had empty bodies,FileStat's fields were not declared, andPathname's abstract members counted against the barePath, which builds aLocalPath). The stubs now raiseNotImplementedErrorthrough a call,FileStatdeclares its fields,Path.__new__is annotated, and a checker treatsPathname's abstract members as ordinary methods. Nothing changes at run time: ABC enforcement still refuses a subclass that omits one. A consumer can drop a# type: ignore[abstract]on those four constructions.FileStatLike.st_mtimeis annotatedfloat, which is what every backend reports. - On Python 3.14
LocalPath.copy_into()andmove_into()are this library's, not stdlib's. Stdlib's overwrote an existing file, copied directories without being asked andLocalPathanswered differently from every other backend. They now behave asMemPath's do: an existing target raisesFileExistsErrorunlessoverwrite=True, a directory needsrecursive=True, and the new path is returned. Passoverwrite=True, recursive=Truefor the stdlib behaviour.LocalPath.replace()stays stdlib'sos.replace()on every version. with_stem("")raises on 3.13 and later when the name has a suffix, aspathlibdoes there (MemPath("/a/c.txt").with_stem("")was/a/.txt). Before 3.13 it is unchanged.walk()lists a Windows junction infilenamesand does not enter it unlessfollow_symlinks=True, as it already did for a symlink to a directory and aspathlib.Path.walk()does on 3.14. It listed the junction indirnamesand descended: a junction to its own parent was walked 64 levels deep, until the path was too long. A caller that wants the target's files passesfollow_symlinks=True. The header anddocs/divergences.mdno longer say thatLocalPathprotects a following walk from a loop; neither doespathlib.utils.as_mode()andutils.as_owner()refuse what cannot be a mode or an owner.as_mode(-1)returned-1,as_mode("07777777777777")a number wider than any mode, andas_owner(True, False)was(1, 0)on 3.14 and(True, False)on 3.9. Aboolis nowTypeError, a mode outside0..0o177777(ast_modewith its type bits still passes, asos.chmodaccepts it;stat.S_IMODE()drops them) and an id outside0..2**32-1(-1stays "unchanged")ValueError.UriPath(url)type-checks under pyright.UriPath.__new__annotatedschemesmapasdictwith a default ofNone, which pyright rejects for every construction (UriPath("sftp://h/x")); it is now an optional mapping of scheme to class.ReadPathContracthas a pickle test and asupports_pickleswitch. A contract subclass checks thatpickle.loads(pickle.dumps(path))is equal to the path, hashes alike, and for a URI path is placed on the same filesystem. An implementation whose paths cannot pickle setssupports_pickle = Falseon its test class and the test skips.Uri(0),Uri(5)and other falsy non-text arguments raiseTypeError. OnlyNoneand empty text are the empty URI;Uri(0)orUri([])used to be silently the empty URI, andUri(5)said it gotNoneType. The message now names the type that was passed. PassNoneor""for an empty part.- An
http:directory index larger than 8 MiB raises.iterdir(),walk()andglob()read the whole index and parse it at about a second per megabyte with no bound, so a server decided how long a listing took. A body overpathlib_next.uri.schemes.http.MAX_LISTING_BYTES(8 MiB after decoding) is nowOSError(EFBIG), checked againstContent-Lengthbefore the body is read. Assign a larger number to that module constant to list a bigger index. - Listing an
http:subdirectory andstat()of a redirecting URL send fewer requests. A directory without a trailing slash is now asked for with it first (oneGETinstead of aGETand the redirect it earned, per directory of awalk()), falling back to the path as given on a 404, andstat()follows theLocationof the first answer instead of repeating the request that was redirected. - The header and the schemes guide say that only
Authorizationis dropped from a redirectedGET/HEAD.with_session(headers={"X-Api-Key": ...})has always been sent to the new host whenrequestsfollows a redirect to another one; nothing changed in the code. Keep a secret inAuthorizationorauth=, or point the path at the final URL. stat()of agitlab:file asks withHEAD. It was aGETof the files endpoint, whose reply carries the file base64-encoded, so everystat(),exists()andis_file()of a large file (and every file met bywalk(),glob()orcopy(), which get no size from the tree listing) downloaded it. The size now comes from theX-Gitlab-Sizeheader of aHEAD; a reply without it, or a server that refusesHEAD, is followed by theGETof the metadata as before.github:still reads the contents object.- Paths derived from one another on one endpoint share the backend the first
of them builds.
root / nameon a fresh root, orp.parentbesidep, built a backend (anhttp:session, aRepoBackendwith its own default-branch cache, an SFTP connection) per path on first use: 20 children of one root opened 20 connections, one TLS handshake each, where they open one afterroothas done I/O itself, andgitlab:looked the default branch up once per child. They now share one slot for the backend they derive for themselves; deriving a path still builds nothing and imports no I/O library, a thread race builds one, and a supplied backend (backend=,with_backend(),with_session()) is carried as before and never replaced. Paths constructed separately, on another endpoint, or with no source (the result ofrelative_to()) do not share; twoUriPath(url)calls still make two connections, so build the root once and derive from it. uripath.main()returns the exit status of a wrong invocation and of--help. It raisedSystemExit(2)andSystemExit(0), although it is documented to return anint. The console script's exit status is the same; a program that calledmain()and caughtSystemExitreads the returned status instead.- The SSH library's message for a failed SFTP handshake is logged at DEBUG.
A handshake or session failure that is neither a refused login nor a host key
is a
ConnectionAbortedErrorwhose text names only the library's exception class (SFTP connection failed (KeyExchangeFailed)), so the reason was lost. The library's message is now logged on the loggerpathlib_next.sftpat DEBUG, on both backends; the exception is unchanged. The record can hold what the library put in its message, including a credential, so enable that logger for troubleshooting only.
Fixed
copy()makes twostat()calls per file, not three to five. The source's type, mode and size come from onestat()and the target's existence and type from another, and two stats that carryst_dev/st_inoare compared rather than askingsamefile()for stats that aFileStatbackend cannot answer. On a remote scheme each is a request. Everythingcopy()refused it still refuses: the same file under two spellings, an existing target withoutoverwrite=True, a directory in the way. A move by copy and delete saves one more.LRU(maxsize=0)stores nothing. It stored the new value, evicted it at once and handed it toon_evict(a connection cache closed the connection it was about to return).maxsize=Nonewas aTypeError; it is now unbounded, as forfunctools.lru_cache.FileStat(st_mode=0o644)is a file, andFileStat(st_mode=0o755, is_dir=True)a directory.is_dirwas consulted only whenst_modewas empty, so a bare permission mode was neither. The type bits are now ORed in; a mode that carries a type keeps it.setmode()read a block device or a socket as a directory.full_match()on a generic path treats the root as a component of its own.MemPath("/a/b.txt").full_match("*/*/*.txt")wasTrue(the first*matched the empty root),MemPath("/").full_match("/**")wasFalse,MemPath("a/b").full_match("a/./b")wasFalseandMemPath("").full_match("")wasFalse;pathlib3.13 answersFalse,True,True,True. A rooted path now needs a rooted pattern or a leading**that reaches past the root, a lone*does not match it, and.and empty pattern components are ignored.Urishares the fix. A bracket expression still never matches a separator (seedocs/divergences.md). A path oros.PathLikeis accepted as the pattern.- The 3.12
match()pass swaps newlines and separators in both directions. A newline inside a name was read as a separator, so on Python 3.12MemPath("a\nb").match("a/b")wasTrue. Other interpreters are unchanged. LocalPath.symlink_to("./t/")stores./t/on POSIX, aspathlibdoes; it storedt(andt//xast/x) because the text was parsed as a path first. Windows still rewrites a relative target into its own separators, without which the link would not resolve. A genericPathbuilds astrtarget withwith_segments(), so a subclass with per-instance state (aMemPathbackend) no longer receives a target bound to a fresh one.utils.parsedate()says what it accepts.bytes, adatetimeand a tuple of fewer than six items raise, and "31 Feb" rolls over to March; the docstring and the header now say so. Nothing else changed.LocalPath.copy()andmove()arePath.copy()andPath.move()(LocalPath.copy is Path.copy): the two forwarding methods are gone, and with them one call frame. A subclass that overrode the privatePath._is_junction_link()overridesis_dir_binding()instead; the alias is gone.- The header no longer says
LocalPath.is_*()returnFalsefor anyOSErroron every Python version.exists()does;is_dir(),is_file(),is_fifo(),is_socket(),is_block_device()andis_char_device()are stdlib's and, before 3.13, raise for an error such asPermissionError. The behaviour is unchanged. - Joining a
MemPathonto another keeps the left operand's tree.root / MemPath("f.txt"),root.joinpath(MemPath("f.txt"))andMemPath(root, MemPath("f.txt"))addressed the last argument's own empty backend, so.exists()wasFalsefor a filerootheld, andutils.glob.glob(MemPath("*.py"), root_dir=src)found nothing. The firstMemPathargument now names the backend; an explicitbackend=still wins. Passbackend=to place a join on a different tree. MemPathapplies..to the tree./missing/../a.txtexisted,/a.txt/../dwas a directory and/missing/../newwas created as/new, because..was removed from the text first. As on a POSIX filesystem, the directory a..leaves must exist (FileNotFoundError) and be a directory (NotADirectoryError);..above the root stays at the root. A spelling through a directory that exists names the same node as before.- Two
MemPathappend handles both land, and a write never shows a reader an empty file. Each handle opened with"ab"published its own snapshot of the whole file on close, so the second close overwrote the first's bytes (b"02"for appends of1and2to0), and every publish emptied the file before refilling it. A handle now adds only what it wrote, in one step (b"012"), and a write replaces the content in one step. MemPath.rmdir()of the root raisesOSError(EBUSY), notFileNotFoundError. The root exists, andFileNotFoundErrorread as "already gone" to a caller that catches it;root.rm(recursive=True)emptied the tree and then failed with it. The tree is still emptied; catchOSErrorif you call it on a root.MemPath.segmentsis a tuple and an unknown keyword is aTypeError. The list was the path's own:p.segments.append("c")changedstr(p)and its hash.MemPath("/", bakend=b)silently built a fresh backend. Code that mutatedsegmentsor passed a stray keyword was already wrong; read a copy withlist(p.segments).- On Python 3.9 a copied or pickled
MemPathfile keeps itsst_mtime.copy,deepcopyandpickleof aMemFile(and of aMemPathBackend) gave every file the time of the copy on 3.9; 3.14 already kept it. A sync quick check that compares(size, mtime)across a copied tree now sees the same answer on every version. - A sync that changes nothing stats each root once and lists each directory
once.
PathSyncerstat'd every source entry (with the defaultfollow_symlinks=True, although the listing already carried the stat) and every target entry one by one, and withremove_missing=Truelisted each target directory and stat'd its entries a second time: 205 entries in 6 directories cost 206 source and 206 to 411 targetstat()calls. The listing's stat now answers for every entry that is not a link; a link, an entry whose listing stat isNone(unknown) and a target name the listing did not report are stat'd, one call each. On a remote scheme each of those calls was a request. A backend that does not override_scandir()(the default stats every entry itself) now stats the entries of each target directory it syncs into once, instead of each source entry. Withfollow_symlinks=Trueandremove_missing=Truethe target link that is removed is reported tohookandignore_errorby its own stat, not by the stat of what it points at, as it already was withfollow_symlinks=False. - The default
PathSyncerchecksum asks for a native digest only when both sides can give one. For a source with a server-side digest and a target without (LocalPath,MemPath), every compared file computed one digest that was thrown away before both sides were streamed. Whether a side can answer is now checked first, and the second side is asked only when the first answered. - A carried pattern under an extended-length anchor is expanded, and checked
when it is carried.
LocalPath(r"\\?\C:\data\*.py").glob(None)selected nothing (a wildcard-free\\?\C:\data\a.pytoo): the?of the prefix was read as a wildcard. The anchor is no longer searched for one. A carried pattern that is invalid (a**before 3.13) now raises whenglob(None)is called, as a pattern argument does, and not when the result is first iterated. - An explicit
case_sensitive=changes the case rule and nothing else. On Windowscase_sensitive=Truemade..select nothing (glob("sub/../*"),rglob("..")), andcase_sensitive=Falsejoined a literal without looking for it (glob("SUB")yieldedSUB,glob("nul")andglob(" ")yielded a path)..and..stay literal, and a literal is looked for in the listing whenevercase_sensitiveis passed, yielding the name as stored, aspathlibdoes. glob()checks a literal component as the runningpathlibdoes. Before 3.13 one that is not the last must be a directory:a.txt/..,missing/..and**/deep/..selected the base or every directory on 3.9. From 3.12 the last literal is tested without following a link, soglob("dangling")selects a dangling symlink, aspathlibdoes there;glob("dang*")already did. AMemPathfollows the tree for.., which makes the same patterns select nothing there on every version.rglob(".")selects every entry below. It raisedValueError;pathlibglobs**.glob(".")is still aValueError.glob(on_error=)is told about a missing or non-directory base whatever the first component is.glob("*")reported it andglob("**")andglob("**/*")did not. Each failed listing is reported once: a recursive glob called the hook twice for one unreadable directory.- A recursive glob lists each directory once.
glob("**/*.txt")andrglob("*")listed every directory twice, once to find the directories below it and once to match names in it; on a remote scheme each listing is a request. The first listing now serves both. bound_loops=Truekeeps every directory wherest_inois 0 orNone. A filesystem that reports a device but no file index gave every directory the same identity, so each looked like its own ancestor andglob("**/*.conf", bound_loops=True)returned only the top level. Such a path is walked unbounded, as the documentation says.LocalPath.glob()andrglob()raise the audit eventspathlibraises.pathlib.Path.globandpathlib.Path.rglobwere never raised for aLocalPath(os.scandirstill audited each directory). An audit hook written againstpathlibnow sees the same events with the same arguments on the running version.glob(None)has nopathlibcounterpart and raises none.make_archive(format="tar")sizes a member from the bytes it read. The member size wasstat().st_size, so a file whose backend does not know its size (an HTTP file served withoutContent-Lengthreports 0) became an empty member with no error, and a size that was too large failed withunexpected end of data. The content is read once into a buffer, counted, and then written, so the member holds the file. A tar of a large file is read and buffered once more than before.make_archivekeeps directories, times and permissions. A source withempty/andsub/run.sharchived one member, sounpack_archiverebuilt noempty/; every directory is now a member of its own, before what is in it. Members were dated 1980-01-01 (zip) or the epoch (tar) whatever the source said; a member now carries the modification time the source reports, and a source that reports none keeps those two defaults. A tar member from a source that reports no permission bits (MemPath, an HTTP file) was read-only (0o444); it is0o644for a file and0o755for a directory. Code that unpacks these archives needs no change.unpack_archiveskips a tar link that names itself. A symlinka -> a,a -> bwithb -> a, or a hard link to itself ended the extraction withRecursionErrorand no warning. Links are resolved by a loop that follows a chain once, so such a link is skipped with theUserWarningevery other unresolvable link gets, the other members are extracted, and a chain of any length ending at a file is followed.- A host made only of digits is a host.
UriPath("s3://20240101/key")had host""and rendereds3:/key, so a bucket, container or account named with digits addressed nothing (gs://123456/k,az://1234567/c/k,http://12345/xandsftp://u@007/xlikewise). The host is now20240101and the URI renders as written.http://12345:80/xwas already right. - The first unescaped
:of a userinfo separates user from password.sftp://us%3Aer:S3CRET@h/pwas read as useruswith passworder:S3CRETand renderedsftp://us:er:S3CRET@h/p, a different credential from the one written. The user isus:erand the URI rendersus%3Aer:S3CRET.Source.userinfois still the decodeduser:passwordtext (astr);parsed_userinfo()and the composer read the split it recorded, and two endpoints that differ only in that split no longer share a backend. - A port is 0-65535, and a
:must be followed by digits.http://h:99999/parsed,http://h:0/xparsed and rendered without:0(and counted as the same endpoint as no port), a 5000-digit port was an error on 3.14 and a 5000-digit integer on 3.9, andhttp://h:abc/orhttp://h:-1/made the wholeh:abcthe host name. A port above 65535 and a:followed by anything but digits (after a name that is not a bracketed address) are now aValueErrorthat does not echo the input; an empty port (http://h:/) is still no port, and port0is kept. ASourcebuilt by hand with a scheme that is not an RFC 3986 scheme or a port that is not 0-65535 raisesValueErrorwhen it is rendered, whereSource("http", None, "h", "80/evil")used to add a path to the URI. - An IPv6 zone follows
%25.http://[fe80::1%25eth0]/xhad zone25eth0and an unescapedhttp://[fe80::1%eth0]/xwas written back with a bare%. The zone iseth0, and the URI is written[fe80::1%25eth0]whichever spelling it was read from. A zone that was spelled%25followed by nothing is now an error. Source.as_str()andstr(Source)compose likeUri.as_uri(). They went through a second composer: a non-ASCII host came out percent-encoded (http://b%C3%BCcher.example, nowxn--bcher-kva), an empty host gavefile://andx://(nowfile:andx:), and a scheme or port that was not valid raised from one composer and not from the other.Source.from_str()does not echo its input. A path, query or fragment after the authority raisedValueErrorwith the whole string, password included; the message now names the component (found a path). A port out of range does the same.repr()andstr()of aSourcealso hide the whole userinfo of agithub:,gitlab:orgit:source, where the token is the user name and was printed.- A file name holding a lone surrogate can be printed, hashed and sorted. An
NTFS directory may hold
a\ud800b.txt;iterdir()yielded the child, butstr(),repr(),hash(),==andsorted()of it raisedUnicodeEncodeError. Such a name renders as its UTF-8 bytes (%ED%A0%80); an escaped non-UTF-8 byte renders as that byte, as before. - A path of a scheme works without its client library, and the error names the
extra.
UriPath("http://h/x"),https:,dav:,github:,gitlab:andgit:raisedModuleNotFoundError: requestsat construction,s3:did the same forbotocore, so such a URL could not even be parsed or joined;gs:andaz:constructed and failed on the first request withNo module named 'google'. Every scheme class now imports and constructs, joins, compares and renders without its library, and the first operation that needs it raisesImportErrorsaying so and naming the extra (pip install "pathlib-next[http]";s3,gsandazlikewise).import pathlib_next.uriwithouturitoolsraises aModuleNotFoundErrorthat names theuriextra,pathlib_next.UriPathanAttributeErrorthat does, andhasattr(pathlib_next, "UriPath")staysFalse. A program that caughtModuleNotFoundErroraround the construction of a path to find out whether a client is installed now gets theImportErrorat the first operation, andfrom pathlib_next.uri.schemes import HttpPathno longer fails withoutrequests. schemesmap=is an allow-list for every class a path chooses. It was read once, for the first dispatch:zip:opened its outer archive through the global registry (sofile:was reachable although the map omitted it),copy("file:///x")wrote a local file, and joining aUriPathargument,with_source()and astrdestination chose from every registered class. The path now keeps the map, and each of those chooses from it (a scheme the map omits gives a plainUriPath, whose I/O raisesNotImplementedError); an archive path does not reuse a handle another path opened throughfile:. The map restricts dispatch only: it does not stop code that holds a built path or class from using it.- A pickled
UriPathholds its URI and nothing else. The pickle of a path that had a backend carried it: the URI password, the session headers ofwith_session(), S3client_kwargsand an SFTPconnect_optspassword were all in the bytes, azip:path did not pickle at all, and ans3:orgs:path stopped pickling once its client existed. A path now pickles as its URI text (a URI with userinfo pickles with it, since that is the URI) and itsschemesmap; the stat hint is not part of it, a backend the path built for itself is built again when needed, and a backend you supplied is dropped unless its class setspicklable = True. A program that sent a path with a supplied backend to another process and relied on the backend arriving must setpicklable = Trueon the backend class or callwith_backend()on the unpickled path.copy.copy()andcopy.deepcopy()share the backend object (supplied or built by the path) instead of copying a connection, so a deep copy (dataclasses.asdict()makes one) no longer fails on azip:path. Source.is_local()answers for the host alone and forgets. It cached per wholeSource, password included, so three paths differing only in password cost three lookups and the cache kept theSourceobjects (and passwords) alive for the life of the process; an answer never changed even if an interface came or went. The answer is now kept per host for 60 seconds, for at most 256 hosts.- A query built from a mapping or pairs escapes
+and=.Query({"sig": "ab+cd=="})wassig=ab+cd==, which a form-style reader on the server takes asab cd==; a base64 signature or an ISO-8601 offset (+00:00) reached it altered. It is nowsig=ab%2Bcd%3D%3D, anddecode()returns exactly what the encoder was given for any text. Astris still kept byte for byte, anddecode()still follows RFC 3986: a+in received text stays a plus (urllib.parse.parse_qslreads form-encoded text as spaces). A program that compared the encoded text of a query holding+or=in a value must expect the escapes. - A
data:payload may hold an unescaped?.UriPath("data:,a?b")reada, and an SVG payload that starts<?xmlread<, with the rest filed as the URI's query;stat().st_sizewas short too. RFC 2397 has no query, so the query is part of the payload (decoded like the path) and the full text is read. A#still starts the fragment. with_path()andwith_segments()build a URI that prints. On a URI with an authoritywith_path("y")andwith_segments("y")returned a path without its leading/, andrepr(),hash()and==of the result raisedValueError; the slash is now added as the constructor adds it.with_segments()andwith_path()acceptbytes,PurePathandos.PathLiketoo, sosamefile(pathlib.Path(...))no longer raisesTypeError. Astrgiven tosamefile()is still a path, not URI text.Uri.queryis always aQueryandfragmentalways astr.Uri("http://h/x").querywasNone(so.query.to_dict()raisedAttributeError) while the same URI joined with a name had""; both are now an emptyQueryand"".repr()of a URI whose parts were never read showed<pathlib_next.uri.Uri object at 0x...>; it shows the URI. An explicit second initialization of a URI with other parts (with_source()does one) leftas_uri()andnormalized_pathof the old parts in their caches, and one with the same parts reset the caches another thread had filled; the first now clears every cache and the second none.Sourceslices andSource.from_str("file:")equals what a parse gives.source[0:2]raisedTypeError; it is the tuple slice.from_str("file:")had hostNonewhereUri("file:").sourcehas"", so the two compared unequal.- A
strjoin keeps an empty segment inside its key.Uri("http://h/d") / "x//y"washttp://h/d/x/y, whileUri("http://h/d") / Uri("x//y"),Uri("http://h/d", "x//y")andUri("http://h/d/x//y")were allhttp://h/d/x//y, so on an object store a key holding//could not be addressed with/orjoinpath(). The four spellings now give the same URI (http://h/d/x//y), and so does astrdestination ofrename(),copy()andmove().normalized_pathstill collapses, and a name a listing yields as empty is still skipped. Code that relied onp / "a//b"collapsing, aspathlibdoes, strips the empty segment itself. - The ancestors of a UNC
FileUristop at the share root. On WindowsUriPath("file://server/share/a/b").parentsendedfile://server/shareandfile://server/, which names no file system, andparentof the share root climbed out of the share. The share is the anchor, as it is forPureWindowsPath: the ancestors arefile://server/share/aandfile://server/share/, and the share root is its own parent. Off Windows the root of the host still ends the chain. - A
FileUriargument holding a drive restarts a join on Windows.UriPath("file:///C:/d/") / UriPath("file:///D:/f")wasfile:/C:/d/D:/f(the same join with a plainUri, or with thestr"D:/f", was alreadyfile:/D:/f). The argument now restarts the join whichever class carries it. - An error raised while a join is built is reported as that error.
Uri("x") / otheranswered "unsupported operand type(s) for /" for anyTypeErrororNotImplementedErrorraised anywhere in the construction of the result, a bug in a scheme's__init__included;UriPathalready told the two apart. Only an argument the constructor cannot read is unsupported now, for both classes, and/,joinpath()and"prefix" / uriare defined once. - A cut-short, stalled or undecodable
http:directory index raises instead of listing part of it.iterdir(),walk()andglob()read the index withrequests' own.text: on urllib3 1.26 (what a Python 3.9 install with thes3extra gets) a connection that closed early gave the entries that had arrived, whichPathSyncer(remove_missing=True)read as the whole directory and deleted the rest; a stalled body raisedrequests'ConnectionErrorrather thanTimeoutError, and a badContent-Encodingleakedrequests'ContentDecodingError. The index is now read the wayopen()reads a file: a short body isOSError(EIO) and a stall isTimeoutError, both naming the directory. - A DAV listing keeps only the members of the collection it asked about.
A
PROPFINDreply that held members deeper thanDepth: 1, hrefs for another path, another host or the collection spelled differently (a proxy that strips a prefix) listed every one of them as a child by the last segment of its href, so the directory could list itself; an href of""or/made the whole listing raiseNotADirectoryError; and any well-formed XML that was not amultistatus(a login page answered with 200) listed as an empty directory andstat()ed as missing. A member is now listed only when its href, resolved against the collection, names the same host and a direct child; a reply that is not amultistatus, or names neither the collection nor any member of it, isOSError(EIO). - A size a server states that is not a plain number is unknown.
Content-Length: abcmadeHttpPath.stat()raiseValueError, one member with<getcontentlength>abc</...>madeDavPath.iterdir()of the whole collection raise it, and a negative number became a negativest_size. Each is now an unknown size (st_size0, and patch-mode append still refuses aHEADwithout a usableContent-Length). - A listed
http:/dav:name that is not UTF-8 can be opened.caf%E9.txtlisted with U+FFFD in place of the byte and the request for it namedcaf%EF%BF%BD.txt. The name keeps its bytes asUri("http://h/caf%E9.txt")does and the request names the file the server listed. - An
http:directory URL with a query or fragment is a directory.http://h/d/?C=Mandhttp://h/d/#topread as files, sounlink()sent theDELETEit refuses forhttp://h/d/. The path of the final URL decides. - A
PROPFINDbehind a redirect keeps its method and body.requestsfollowed a 302 or 303 as aGET(sostat(),exists()and listing of adav:URL behind one failed or read as missing) and a 301 without the body (the server saw anallproprequest). APROPFINDis now sent again, with its headers and body, after a 301, 302, 303, 307 or 308 to the same scheme, host and port, and refused withOSError(EIO) naming the status and theLocationfor another origin, a second redirect or any other 3xx. Adav:URL that redirects to another origin (http:tohttps:, say) must be spelled with its final scheme and host (davs:). - URL passwords outside Latin-1 are sent as the octets the URL carried.
http://user:caf%C3%A9@host/went out as the single byte E9 (requestsencodes a text credential as Latin-1) and%FFraisedUnicodeEncodeErrorfromstat()before anything was sent, soexists()saidFalse. The percent-escapes are now decoded to bytes andrequestssends those bytes; an ASCII credential is passed as text as before. as_posix()of agithub:,gitlab:orgit:path no longer shows the userinfo.str(),repr()andas_uri(sanitize=True)dropped it butas_posix()renderedTOKEN@github.com:/o/r/f. It isgithub.com:/o/r/fnow;as_uri()still round-trips the userinfo.- A
github:path cannot add to its API URL. Owner and repository were put into the URL raw (github://github.com/o/r%3Fx=1/a.txtasked for/repos/o/r?x=1/contents/a.txt) and a..segment made withwith_name("..")orwith_path()was collapsed by the client into another repository's address, with the token attached. Owner, repository and every path segment are percent-encoded now, and a.or..segment in the path or the ref raisesValueError(exists()answersFalse); the same check coversgitlab:'s file path. open("r+"),open("r+b")onhttp:anddav:raiseNotImplementedError. They returned a read-only stream whose first write failed. Open with"rb"to read. Thegithub:andgitlab:paths already refused them.dav:uploads with thewrite_methodgiven towith_session(). It was accepted and ignored (PUTalways went out).DavPath.stat()also acceptswalk_up_last_modified=likeHttpPath.stat(); the PROPFIND reply already carries the modification time, so it changes nothing.- A
github:orgitlab:API reply that is not the documented shape isOSError(EIO) naming the path. A 200 that was a proxy's HTML page,null, a number, a truncated body, an array where an object belongs or an entry without a name and a type raisedAttributeError,TypeError,KeyErrororrequests'JSONDecodeErrorfromstat(),iterdir()andread_bytes(), andexists()raised too (it only swallowsOSError); a tree reply without atreelisted as an empty directory, and a size that was text brokestat(). Each reply is decoded and checked in one place per provider now,exists()answersFalseandstat()raisesOSError. A raw file body has no shape to check and is returned as sent. DavPath.rename()no longer puts the source's query inDestination. A source such asdav://h/d/f.txt?sig=...sentDestination: .../d/g.txt?sig=..., handing a signature meant for the source to the destination URL. The header carries the target's own query (none for astrtarget) and no fragment, andrename()returns the target path with that query. Pass aUritarget that carries the query a server needs on the destination.- A lost connection, a refused login and an untrusted host key are
OSError, on both SFTP backends.asyncssh.ConnectionLost,HostKeyNotVerifiableandPermissionDenied,paramiko.AuthenticationException,BadHostKeyExceptionand theSSHExceptionof a rejected key crossed the API, and a translated server status was chained to the library's exception. The dropped connection is nowConnectionResetError, the loginSftpAuthenticationError, the keySftpHostKeyError, any other failure of the handshakeConnectionAbortedErrorand a refused connectionConnectionRefusedErroron both; nothing is chained (the library's text can carry credentials). A caller that caughtparamiko.AuthenticationExceptionorasyncssh.PermissionDeniedcatchesSftpAuthenticationErrororPermissionError; one that caughtparamiko.SSHExceptionfor a refused key catchesSftpHostKeyError. After a lost connection no second request is made to find out whether the entry exists (mkdir()of a directory that was created could answerFileExistsError). - A directory holding a name that is not UTF-8 lists on both SFTP backends.
With paramiko one such name raised
UnicodeDecodeErrorout ofiterdir(),walk(),glob()andcopy(); with asyncssh it raisedOSError, whichwalk()andglob()swallowed into an empty result, andrm(recursive=True)could not remove the tree. The name is now the lone-surrogate string of its bytes, beside its siblings, and reads, stats, unlinks and copies by the same bytes. - paramiko's SFTP errors agree with asyncssh's. An "operation unsupported"
status was an
OSErrorwithout anerrno(socopy()to a server that refusesSETSTATraised after the data had been copied and a recursive copy stopped at the first file); it isNotImplementedError, whichcopy()tolerates. Errors now carry the remote path infilename(andfilename2forrename()), and the two-path error of asyncssh no longer prints[WinError None]on Windows. chown(None, gid)andchown(uid, None)onsftp:never send 0 for the id they leave alone. An SFTP version 4 server (asyncssh asks for 4 by default) names the owner and the group instead of numbering them, so every file read as owned by 0 andchown(None, 4242)asked the server to give the file to root. An owner name that is a number is that id; any other name makes the partialchown()raiseNotImplementedErrornaming the field, with nothing sent.chown(uid, gid)is unchanged, and so is a version 3 server's (OpenSSH).- A pickled or deep-copied
sftp:backend works. The defaultssh_configand, for paramiko, the defaultknown_hostswere bare objects compared by identity, so a copy was read as "an iterable of paths" and the first request raisedTypeError. They are the same object after a pickle or a copy. A path pickled with abackend=still carries only its URI andssh_config. SftpPath.with_source()carries the path'sssh_config, and a join onto another host does not.with_source()handed the default back (a path told to read no configuration read~/.ssh/configon the new host), andSftpPath(a, "sftp://other/y")tooka'sssh_configontoother. The configuration now follows a derivation on the same endpoint and is dropped, with the backend, on another host. Two paths of one endpoint whosessh_configdiffers no longer share the backend they build for themselves.checksum()onsftp:asks per algorithm. A server that refused one algorithm with "operation unsupported" (a FIPS build and md5), a bare failure for one file, or a probe bysupported_checksums()(md5 only) switched native checksums off for the whole connection, and every later digest was streamed. Only the algorithm refused is remembered now, a failure for one file refuses nothing, andsupported_checksums()is never empty for a connection that produced a digest. An algorithm other thanmd5,sha1,sha224,sha256,sha384,sha512andcrc32isNotImplementedErrorwithout a request, and a reply is a digest only of the size its algorithm has (an empty reply was returned as''forcrc32).- File objects of the two SFTP backends agree.
write_bytes()andopen("xb").write()returnedNoneon paramiko; they return the count, as on asyncssh.truncate()raisedio.UnsupportedOperationon asyncssh and required a size on paramiko; both truncate, andtruncate()without a size cuts at the current position.fileno()raisesio.UnsupportedOperationon both. - A file left open at exit is written.
path.open("w").write(...)withoutclose()at module scope left an empty file on asyncssh (and, for text, on paramiko) with aRuntimeErrorabout shutting down. Files opened for writing are flushed and closed when the interpreter exits, before its threads stop, waiting at most five seconds for a connection that has hung. - A URI user cannot add arguments to an asyncssh
ProxyCommand. A user such asa -oProxyCommand=calcwas expanded into%rand split afterwards, so the part after the space became an argument of the proxy program (paramiko already refused it). The same users are refused withValueErrorbefore asyncssh is given the user, when the configuration for the host would pass it on; with no%rin theProxyCommand(or no configuration) a user with a space still connects. - Testing the children of a listed
sftp:directory costs no request per child.[c for c in d.iterdir() if c.is_dir()]made one listing and onestatper entry, because the listing's attributes were used only forlstat(). A listed entry that is not a link now answersstat(),is_dir(),is_file()andexists()from the listing (once, as before), so 21 entries cost one request; a link is still followed by a request of its own. The asyncsshcopy(recursive=True)andrm(recursive=True)also decide file or directory from the listing: removing 128 files in 8 directories made 301 requests and now makes 165, the number the genericrm()makes. copy()andrm()on the paramiko backend do not import asyncssh. Both imported it before looking at the backend, so forcingPATHLIB_NEXT_SFTP_BACKEND=paramikostill loaded it on the firstcopy()orrm(), and without it installed the failing import was retried on every call. A backend now says whether it has native tree operations (BaseSftpBackend.supports_tree, withtree_copy()andtree_rm(); False unless a backend sets it); a custom backend needs no change.- A paramiko connection of a thread that has ended is closed. Entries were closed only when the 128-entry cache evicted them, so a program that used SFTP from many short-lived threads held a transport thread and a socket for each until then. The next new connection closes the ones whose thread has ended.
- An ssh_config that was edited is read again by the paramiko backend. The
parsed file was cached by its path for the life of the process, so a changed
Hostblock was ignored until restart. The cache now also keys on the file's modification time and size (a change to anIncluded file alone is not noticed). - An
archive:path reads nothing until it is used.UriPath("archive:http://h/noext!/m")sent a request while it was being built (an outer whose name does not say zip or tar is sniffed), a second one to read the member, and a sniff that failed made that path object a tar for good, so a good zip then failed withtarfile.ReadError. The format is now settled on first use: building, printing, joining, copying and pickling a path send nothing, a non-local outer read to decide is not fetched again, and a failure to read the outer propagates and decides nothing, so the next call tries again. A write through anarchive:path whose outer is missing and has no extension is nowFileNotFoundError(it wasNotImplementedError). - A tar member whose name is not UTF-8 can be printed, hashed and compared.
A tar written in a Latin-1 locale lists
caf\udce9.txt, andstr(),repr(),hash(),==andas_uri()of that path raisedUnicodeEncodeError. The name now keeps its bytes as%XXin the URI (.../caf%E9.txt), as every other scheme composes it, and the URI parses back to the same member. - A damaged archive raises one exception family per format. Besides
zipfile.BadZipFileandtarfile.ReadError,exists(),stat(),iterdir()andread_bytes()leakedzlib.error,EOFError,NotImplementedError("zip file version ...")andgzip.BadGzipFile(anOSError, soexists()answeredFalsefor a corrupt.tar.gz). They are nowBadZipFilefor a zip andReadErrorfor a tar, with the decoder's error as__cause__. Code that caughtzlib.errororEOFErroraround a read of an archive member catches those two instead. An encrypted member is stillRuntimeErrorand an unsupported compression methodNotImplementedError. exists()andis_file()of a zip member no longer raiseKeyErrorwhile another thread rewrites the zip. The name was looked up in one snapshot and its stat read from a handle that had been reopened in between; both now happen under one hold of the handle's lock, and a member that is gone by then isFileNotFoundError.- New members written through
zip:are deflated. A member added withwrite_bytes()oropen("w")was stored uncompressed, so 100 000 bytes of one letter took 100 000 bytes. An overwritten member keeps the compression method it had (a storedmimetypestays stored). - A read-only zip is not replaced on POSIX.
write_bytes(),unlink()andrename()on a zip whose mode forbids writing succeeded, because the archive is replaced by a new file; they raisePermissionErrorbefore anything is written, as on Windows. Replacing the archive still gives it a new inode: another hard link to it keeps the old content. stat()onftp:is one reply where the server offersMLST, and a connection is probed withNOOPonly after it has been idle. A stat sentNOOP,TYPE,PASVandMLSDof the parent directory over a new data connection (the whole listing, so N stats in a directory of N entries were quadratic), andexists()of a missing file sent 10 commands. A server whoseFEATlistsMLST(RFC 3659) is now askedMLSTof the entry itself, a single reply on the control connection;exists()of a missing file isMLST,TYPE,SIZE,CWD. A connection that carried a command withinschemes.ftp.IDLE_PROBE_SECONDS(1 second) is used without aNOOP; one that has been idle longer, or has not been used yet, is probed and replaced if the server dropped it, as before. A server withoutMLSTis read as before: the parent'sMLSD, thenSIZE/CWD.stat(),exists()andis_file()of anftp:file work when its directory holds a name the client cannot decode. The parent's listing failed withOSError(EILSEQ)for every call and each call reconnected. WithMLSTno listing is read; on a server withMLSDand noMLSTthe error makesSIZE/CWDanswer (the listing still drops the connection, so each such stat reconnects).iterdir()of that directory still raisesOSError(EILSEQ).- A malformed
sizefact no longer makes a directory unlistable.size=abcorsize=1.5raisedValueErrorfromiterdir(), a negative size was reported as is, and a size of 400 digits became anint. Asizethat is not a plain non-negative decimal below 2**63 is unknown (st_sizeis 0), as a missing one is. rename()onftp:onto an existing target isFileExistsError. A server that does not replace it (a Windows one answers550 File exists) raisedPermissionErrorwith both files intact. The refusal isFileExistsErrorwhen the target is there, andPermissionErrorstill when the reply names a permission problem or the target is not there. A POSIX server replaces the target, as before.- The connections of a thread that has ended are closed. Twenty short-lived
threads that each used one
ftp:path left twenty logged-in control connections open until 128 newer ones pushed them out, which a server's per-client connection limit can refuse. A thread's connections are now closed when the thread ends, and one that another thread's cache overflow drops while a command is running on it is closed when that command ends. - A
chmod()the FTP server refuses says why. A refusedSITE CHMOD("550 Not enough privileges") raisedNotImplementedError("SITE CHMOD not supported by this server")without the reply. It is stillNotImplementedError(socopy()skips it), now with the server's reply in the message and as__cause__. - Walking, listing and
stat()of an archive no longer scan every name for each directory.iterdir()of a directory compared every member name of the archive against the directory's prefix and thenstat()-ed each child with a second validation of the file, so awalk()grew with directories times members. The members, their directories (those that exist only through their members too) and each entry's stat data are derived once per open handle; a listing costs one validation of the file and work in the size of the directory, and the childreniterdir()returns answer their firststat()from it. - Reading a member of an archive holds a bounded amount of memory. A local
tar:was read whole into memory and kept for the life of the handle, and a member read built the whole member in memory at about twice its size (a zip member that declares 512 MiB peaked at over 1 GiB). A local tar is now read from its file as members are reached, and a member larger thanschemes.archive._base.MEMBER_SPOOL_BYTES(16 MiB) is copied to an unnamed temporary file instead of memory, so reading it costs disk in the size the archive declares. A non-local archive is still read whole into memory. Code that read a member throughopen("rb")is unchanged: the stream is still seekable and independent of the archive. - A zip change copies the entries it does not change as they lie in the
archive.
unlink(),rmdir(),rename(),rm(recursive=True)and an overwrite decompressed and compressed every member of the archive again, so their cost was the archive's uncompressed size, and one encrypted member (or one stored with a compression methodzipfilelacks) made every such change to the archive fail withRuntimeErrororNotImplementedError. Untouched entries are copied without being decompressed, a renamed entry gets a new local header and its data is copied, and a member with a non-ASCII name that the archive stores without the UTF-8 flag gets the flag, as before. stat()of a tar hard link reports the size of its target however the target is spelled. A link tod//real.txtord/./real.txtreported size 0 while reading it returned the data; tar and zip now use the one name rule.archive+zip:andarchive+tar:are second names ofzip:andtar:. Both schemes, both class names (ArchiveZipUri,ArchiveTarUri, now aliases ofZipUriandTarUri) and the printed and pickled form of a path (it keeps the scheme it was written with) are unchanged.type(path)of anarchive+zip:path isZipUri, sorepr()shows that name, andisinstance(path, ArchiveZipUri)is also true for azip:path.- A failed request on
s3:,gs:andaz:is anOSErrorof the type the header promises, and the SDK's exception is not chained. A timeout wasOSError(EIO)ons3:; an unreachable endpoint reachedexists()andwalk(on_error=)as the SDK's own exception ongs:andaz:(after up to two minutes with the SDKs' default retries), so neither saw it. A request that runs out of time is nowTimeoutError, an endpoint that cannot be reachedConnectionError, a connection that breaks while an answer or a body arrivesConnectionResetError, each with the path asfilename; the store's answers stayFileNotFoundError,PermissionErrorandOSError(EIO). The message names the SDK exception's type and not its text, which holds the request URL and with it any query-string credential, and__cause__isNone. CatchOSErroror these subclasses; code that read the SDK exception from__cause__must catch it from the SDK call itself. - An
s3:read cut short or stalled in the body is anOSError. Only the call that opens the object was translated: a server that closed the connection mid-body raised botocore'sResponseStreamingErrorfromread_bytes()andcopy(), and a stalled body botocore'sReadTimeoutErrorwithout the path. They areConnectionResetErrorandTimeoutErrornaming the path. - Reading or writing a bucket or container root, and a path with no bucket or
account.
read_bytes(),write_bytes()oropen("r+")ofs3://bucket/raised an SDK error (botocore'sParamValidationError, aValueErroror an HTTP 400 reply), andS3Path("s3:x").exists()raised the first too. A root is a directory (IsADirectoryError, nothing is sent), and a path with no bucket, or no account and nobackend=, does not exist (FileNotFoundError,exists()isFalse). az://accountcan be listed.is_dir()answeredTruefor it whileiterdir()andwalk()raisedValueError; its children are the containers.rm(recursive=True, missing_ok=True)under a missing bucket or container returns. It raisedFileNotFoundError(a missing key in an existing bucket was already fine).rename()of a missing path onto its own name returned the path; it isFileNotFoundError, and renaming an existing key or prefix onto itself still changes nothing.- A key a batch delete refuses is offered to
ignore_errorwith its own path. Ons3:the refused keys of a 1000-key batch reachedignore_erroras oneOSErrorwithout anerrno, with the directory being removed as the path; each is now aPermissionError(AccessDenied) orOSError(EIO)for its key, offered once with that key's path, and without a handler the first is raised.gs:andaz:offered a failed delete with the directory's path too. - An
S3Backend,GsBackendorAzBackendbuilds one client however many threads ask first, and can be pickled or copied after it built one. Threads racing for the first request each built a client (and, on Azure, a credential). A backend that had made its client could not be pickled or deep-copied (PicklingErrorfor boto3's and Google's clients, aRecursionErrorincopy.deepcopy). A pickled or copied backend keeps its options and builds its own client on first use. A pickledS3Path,GsPathorAzPathstill holds only its URI: the receiving process uses the default backend unless the program supplies one, and noclient_kwargs, credential or client is in the bytes. - Existence probes on
az:ask the service for one item.stat()of a prefix,exists()of a missing path,mkdir(),open("x"), writes andrmdir()listed with the service's default page (up to 5000 entries) to learn whether one blob exists.rename()of a prefix directory on all three stores asked for the key a second time before listing the prefix. AzPath.rename()no longer waits for ever on a pending copy. It waits at mostCOPY_POLL_TIMEOUT, aborts the copy and raisesTimeoutError, the source untouched.uripathexits 141 when the reader of stdout went away, on Windows too. Windows reports a closed pipe asOSError(EINVAL), soread,writeandsyncprinteduripath: OSError: [Errno 22] Invalid argumentand exited 1 or 120 unless the reader left in the middle of a large write. They now exit 141 without a message, as the documentation says.- Without the
uriextra,uripathno longer echoes a URI argument. The error line quoted the whole argument, password included. It now readsthe 'http' scheme needs the 'uri' extra: pip install 'pathlib-next[uri]'. uripathreports a wrong invocation with exit status 2 and the usage.cp -rwith-and an unknown--encodingexited 1 as if the operation had failed. Every flag and positional now has a help line.uripath.main(stderr=)accepts a binary stream. The error line was written as text, so aBytesIOraisedTypeErrorinstead of returning 1.
0.9.12 - 2026-10-08
Added
Path._node_key(), an override hook answering where a path's node lives:(namespace, names), the object two paths share when they resolve names in one tree and the node's normalized position in it.copy()andmove()compare two paths that both answer it across classes.MemPathand the archive paths override it; any other type keeps comparing two paths of one type with==, so nothing needs to change.PathContractcallsrm(recursive=True)withfollow_symlinks=andfollow_binds=(test_rm_recursive_accepts_the_follow_policies, andtest_rm_rejects_a_policy_that_is_not_one). An implementation that overridesrm()must accept both keywords, asPath.rm()does; its contract subclass fails these two tests until it does. No fixture or capability attribute changed.
Changed
- The scheme extras now declare version ranges.
uritools,requests,paramiko,boto3,google-cloud-storage,azure-storage-blob,azure-identityandasyncsshhad no lower bound (asyncsshhad only the Python 3.9 cap) and no upper bound, andnetimpshad only a floor. The ranges are nowuritools>=5.0,<7,netimps>=0.4.0,<0.5(was>=0.3.1),requests>=2.22.0,<3,paramiko>=5.0,<6,asyncssh>=2.20.0,<3(below 2.22 on Python 3.9),boto3>=1.36.0,<2,google-cloud-storage>=3.0,<4,azure-storage-blob>=12.4.0,<13andazure-identity>=1.0,<2. An environment that pins one of them below its floor must raise the pin; one that needs a new major version must wait for a release that admits it. copy(preserve_metadata=True)drops the setuid, setgid and sticky bits between two different classes. A mode read from a tar member, a zip entry or a remote server was applied verbatim, so04755in an archive made a setuid file on the local disk. A copy between two classes now applies the permission bits without those three; a copy within one class keeps them. Runchmod()afterwards where the bit is wanted.move()callsrename()only onto a path of the same class. The default ofPath._rename_compatible()was True, so aPathsubclass with arename()of its own was handed a path of another store and ran the rename inside its own (aMemPathsubclass moved onto aLocalPathlooked for the local path in memory). The target of another class now gets copy + delete. A subclass whoserename()handles other classes overrides_rename_compatible();UriPathandLocalPathdecide for themselves as before.
Fixed
- A
..or.name in a remote listing no longer escapes the tree. Thes3:,gs:,az:,github:andgitlab:listings and the defaultUriPathlisting yielded a name such as..,.,a/bor an empty one as a child (http:,dav:andsftp:already skipped them), and theftp:listing yielded one holding NUL. Joined onto the directory,..named its parent, sowalk()left the directory it was given and never ended,rm(recursive=True)deleted the parent tree, andPathSyncer(remove_missing=True)removed the parent or copied a sibling tree into the destination. All of those listings now skip such a name, andwalk(),rm(recursive=True)andPathSynceralso refuse one that a custom scheme's_scandir()yields, before building any child from it:walk()leaves it out ofdirnames/filenamesand, whenon_erroris given, calls it with aValueError;rm(recursive=True)offers aValueErrortoignore_error(error, directory)once per entry and removes nothing for it;PathSyncerraisesValueErrorthroughignore_errorfor the source's and the target's names alike. A custom_scandir()should skip such names itself (utils.is_safe_child_name()decides). An object key with a.or..segment is no longer listed. - Names Windows rewrites are refused for a Windows destination.
utils.is_safe_child_name(name, windows=True)accepted a name ending in a dot or a space and the reserved device names. Windows storesreport.asreport, socopy(recursive=True)of a tree holding both merged them into one file with no error, and a memberNULof an archive was written to the device. Withwindows=Truethe function now also refuses a trailing dot or space andCON,PRN,AUX,NUL,COM1-COM9andLPT1-LPT9, in any case, with or without an extension (nul.txt);windows=Falseis unchanged.copy(recursive=True)andPathSyncerreport such a name as aValueErrorper entry for a Windows-flavoured target, andunpack_archive()skips such a member for a Windows destination with aUserWarning. Rename a source file with such a name, or extract it on another platform. - A failed
open("a")onhttp:no longer empties the remote file. In the defaultappend_mode="rewrite", a failure of theGETthat reads the current content (a 500, a 403, a timeout, a body cut short) raised to the caller, and then the half-built stream was garbage-collected and uploaded an empty body: the file was replaced by a zero-byte one.open("a")now raises and sends nothing, in both append modes; a write stream whose constructor did not finish never uploads. - A write through a redirect no longer reports success without storing the
bytes.
http:anddav:followed a redirect forPUT,PATCH,DELETE,MKCOLandMOVEthe way a browser does. A 301 re-sent thePUTwithout its body and replaced the file with an empty one; a 302 or 303 turned the request into aGET, sowrite_bytes()returned having written nothing andunlink(),mkdir()andrename()returned having done nothing. Those requests now go out withallow_redirects=False(anallow_redirectsinwith_session()does not apply to them). A 307 or 308 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, a second redirect and a body that cannot be sent twice raiseOSError(EIO) naming the status and theLocation, without its userinfo. A server that redirects writes must be addressed by its final URL. open("x")onhttp:anddav:no longer replaces a file whose existence check failed, and patch-mode append no longer overwrites the start of a file whose size is unknown.open("x")read any error of its existence check (a 5xx, a 403, a 429, a timeout) as "missing" and sent thePUT, replacing the file. It now raises that error and sends nothing, and the upload carriesIf-None-Match: *, which a server that honours it answers with 412, nowFileExistsError. Withappend_mode="patch", aHEADreply withoutContent-Lengthwas read as size 0 and the appended bytes were written at offset 0 (Content-Range: bytes 0-2/*);open("a")now raisesOSErrornaming the path and sends noPATCH.stat()still reportsst_size0 for such a reply. A server whose existence check fails for a missing file needsopen("w")instead ofopen("x"); one that does not state the size of its files needsappend_mode="rewrite".- A session or token no longer follows a path to another endpoint, and the
mirroring idiom keeps the destination's backend. Since 0.9.7, a path that
crossed endpoints (
UriPath(base, "http://other/d/"),base.joinpath(Uri(...), "x"),base / Uri(...)) keptbase's backend for the nextstrjoin,with_name()orcopy()/move()destination written as a URL, until its first read, so anHttpPath.with_session(auth=...)session or agithub://TOKEN@...token was sent to the other host. Anddst_root / src.relative_to(src_root)dropped the backend given todst_rootand built the default one, so the copy ran on the ambient S3 client, or on an HTTP session without its headers. A derived path now takes the backend its source holds only when it is on the same scheme, userinfo, host and port, the rightmost joined path on the result's endpoint supplies it, and the sourceless result ofrelative_to()holds none. A path that crosses endpoints needs its ownwith_session()orwith_backend(). - Deriving a path no longer builds a backend.
parent,parents,with_name(),with_suffix(),with_query(),relative_to(),UriPath(base, x)and a join with aUriorPurePathbuilt the source's backend: the firstparentof ansftp:path imported asyncssh, and raisedImportErrorwhere neither SSH library is installed. They now copy the backend the source already holds, or leave the new path without one until its first I/O. Paths derived before any I/O therefore build their own backend instead of sharing one made for the source; readpath.backendfirst, or passbackend=, to share a connection. - A backend that cannot be weakly referenced no longer turns off the
same-file guard. When
_initbackend()returned adict, anobject()or an instance of a__slots__class without"__weakref__", two separately built paths to one URL were judged to be on different filesystems anda.copy(b, overwrite=True)onto itself was not refused. A path now records that it built its backend and passes the record on, so such a backend reads as derived and the copy is refused. A backend given throughbackend=orwith_backend()still reads as supplied. - Dot segments are removed from a joined path, as the constructor removes
them. From 0.9.7 to 0.9.11,
Uri("http://h/d/") / "../x"(astrjoin onto a path ending in a slash) addressedhttp://h/d/x, because..was resolved against the empty last segment, whileUri("http://h/d/../x")ishttp://h/x, and a join with aUriargument (/ Uri("../x"),Uri(base, "../x")) kepthttp://h/d/../xunresolved. All of them now givehttp://h/x. The dot segments of the whole joined path are removed, soUri("http://h/d/x") / ".."ishttp://h/d/(it washttp://h/d),Uri("http://h/d/x") / "."ishttp://h/d/x/(it washttp://h/d/x) andUri("a") / "../../x"is../x, as the constructor gives forUri("a/../../x")(the join gavex). Code that relied on..after a trailing slash staying inside the directory must join the name instead (base / "x"). A join key that ends in.or..now gives a path ending in a slash, whosenameis empty: use.parentfor the parent, and skip a"."key (base if rel == "." else base / rel) where the result must equalbase. - A percent-encoded dot segment is removed after decoding.
Uri("http://h/safe/%2e%2e/secret")kept the segment.., so.parent,.nameandas_uri()disagreed with the path that was fetched (http://h/secret) and the URI did not parse back to itself. A%2e%2e,.%2eor..%2Fin a parsed URI now resolves like... Astrjoin key is a decoded path, where%2e%2eis still a literal name. - Dot segments never climb above a Windows drive.
UriPath("file:///D:/data/../../x")was\xon the current drive, and a join such asUriPath("file:///C:/d/") / "../../../x"lost the drive the same way. On Windows they now stay onC:/, asPureWindowsPathdoes. FileUri.parentson a Windows drive path ends at the drive root.UriPath(LocalPath("C:/Windows/System32")).parentsended infile:/C:andfile:, two paths relative to the working directory, so an upward search (for p in path.parents: (p / "marker").exists()) looked in the current directory. It is now[file:/C:/Windows, file:/C:/], what repeated.parentandLocalPath.parentsgive. Theparentof a bare drive (file:///C:) is the drive itself; it was the empty path.- A Windows path string works as a
file:join key and as acopy()/move()/rename()destination on Windows. A destination such asstr(pathlib.Path)gives (C:\Temp\x) was joined as one file name and failed withOSError(WinError 123) inFileUri.copy(),move()andrename(), andbase / r"sub\x"named a filesub\x. On Windows afile:path now reads\in such a string as a separator, soC:\Temp\xis an absolute path andsub\xis two names. Elsewhere a backslash is still an ordinary filename character, and every other scheme splits on/only. move(overwrite=True)no longer deletes the source when the target holds it.dst/sub.move(dst, overwrite=True)removed the whole ofdst, the source included, and then raisedFileNotFoundError; the same happened onMemPath. A target that holds the source now raisesOSError(ENOTEMPTY)and one that lies inside itOSError(EINVAL), with both trees intact, before anything is removed. Move the source out first, or name a different target.copy()andmove()recognise one file under another class or spelling.LocalPath(f).move(target, overwrite=True)deletedfwhentargetwas aLocalPathsubclass, afile:URI or apathlib.Pathfor the same file, and so did a relative and an absoluteMemPathof one file (a.txt,/a.txt,/d/../a.txt) and thezip:,archive:and query-carrying spellings of one archive member;copy()across the same spellings was not refused either. The move is now an in-place rename and the copy raisesOSError(EINVAL), with the content kept. A directory reached through a symlink is the same directory, socopy(recursive=True)into it is refused as a copy into itself. A type whose segments spell one node several ways overrides the new_node_key()hook.move()between two hard links of one file ends with the target name. On POSIX it reported success and left both names; on Windows it removed the source even withoutoverwrite. An existing target now needsoverwrite=True(FileExistsErrorotherwise), and the source name is then removed. On POSIX a symlink moved onto the file it points at replaced the file with a link to itself; it now raisesOSError(EINVAL).copy(overwrite=True)removes the target withunlink(missing_ok=True). A backend whose write replaces the file and which cannot delete had to makeunlink()lie; it now implementsunlink()so thatmissing_ok=Truereturns without removing.rm(recursive=True, follow_symlinks=True)works on POSIX. It emptied the directory behind a followed symlink and then calledrmdir()on the link itself, which fails withNotADirectoryErroron Linux and macOS: the files behind the link were gone, the link and the rest of the tree stayed. The link is now unlinked once what is behind it has been removed.rm()appliesfollow_symlinks=andfollow_binds=to the path it is called on. They decided only the entries found on the way down: a symlink named directly was unlinked whateverfollow_symlinkssaid, while a junction named directly honouredfollow_binds. A symlink named directly now follows the same policy:Truewithrecursive=Trueempties what is behind it and then removes it,Noneleaves it, and withoutrecursiveit is only unlinked. A call that passedfollow_symlinks=TrueorNonefor a link it names directly now does what the keyword documents.- An invalid follow policy is raised, not ignored. A callable that
answered anything but
True,FalseorNonehad itsValueErrorreported throughignore_error, soignore_error=Trueleft the tree half removed with no error. The error now reaches the caller, and a keyword value that is not a policy raises before anything is removed. - A class that mixes a concrete
pathlibpath withPathbut notLocalPathhasLocalPath's local primitives. The precedence guard replaced stdlib'sis_junction()andis_mount()with the generic answers (alwaysFalse), sorm(recursive=True)on such a class walked into a junction and deleted the files of the directory it points at, even withfollow_binds=None. It also leftsymlink_to()raisingNotImplementedError,chmod("0644")raisingTypeErroron 3.10 and later,is_dir(follow_symlinks=False)andis_file(follow_symlinks=False)raisingTypeErroron 3.9, andchown()raisingNotImplementedError. Every functionLocalPathdefines is now installed on such a class wherever stdlib or a generic default would answer, from the class itself so the two cannot drift; a method of the class or of a mixin of its own still wins. rm()onsftp:,dav:,s3:,gs:andaz:acceptsfollow_symlinks=andfollow_binds=. The five overrides stopped atignore_error, so naming either keyword raisedTypeErrorbefore any I/O. They take both withPath.rm()'s defaults, check them before sending anything, and forward them wherever the generic removal runs.dav:and the object stores have no symlinks or bindings, so the keywords decide nothing there;sftp:runs its concurrent native walk only for the defaultfollow_symlinks=Falseand the generic walk for any other value.move()keeps a symlink a symlink when it has to copy. Whererename()was unavailable (another device, a backend withoutrename()) the fallback followed the link: the destination became a full copy of the linked tree or file, and so did every link inside a moved tree. The fallback now recreates the links wherever the target's class can hold them, so the result no longer depends on whether the two paths share a filesystem. A target that cannot hold links (MemPath, an object store) still receives the content.copy(recursive=True, preserve_metadata=True)keeps directory permissions. Files kept their mode but directories were created with the default one, so a0o700directory copied as0o755and the files that were private through their parent became reachable; a move of a tree across devices did the same. Each directory now takes the source's reported mode after its children are copied (so a read-only directory still receives them), asshutil.copytreeand pathlib 3.14 do.PathSyncerno longer deletes the target entry behind a source link that cannot be resolved. With the defaultfollow_symlinks=True, a source child that is a symlink to nothing (an unmounted volume, a removed directory) was read as "vanished", andremove_missing=Trueremoved the same-named target entry recursively: a target directory holding the only copy of its files was emptied and the run reported success. (A link in a loop failed the listing of the whole directory instead.) A name the source directory lists is now never missing. Such a link is an error for that entry, offered toignore_errorwith eventSyncEvent.SyncStart; the target entry stays as it is, with or withoutremove_missing, and the rest of the directory is still synced once the policy tolerates it. A sync that met such a link and finished silently now raises (FileNotFoundErrorfor a dangling link, the stat'sOSErrorfor a loop) under the default policy: passignore_error=True(or a callable) to carry on past it, orfollow_symlinks=Falseto mirror the link itself.ignore_errorreceivesPathAndStatentries when the root stat fails. A failingstat()of the rootsourceortargetoffered the caller's raw path objects to the policy while the hook gotPathAndStat, so a policy readingsource.pathorsource.statraisedAttributeErrorand hid the real error. Both arguments are now alwaysPathAndStat(with an unknownstatwhen the stat is what failed).PathSyncerno longer lists, writes or deletes through a Windows junction inside the target. A symlink found below the roottargetwas replaced, but a junction looks like an ordinary directory, soremove_missing=Trueremoved the files of the directory it names and wrote the source's files into it. A junction below the root is now treated like a symlink: it is replaced by a real directory (SyncEvent.TypeMismatch;rmdir()removes the junction and nothing behind it). A mount point inside the target is still synced into, and thetargetpassed tosync()is used as given, junction or not. A sync that relied on writing through a junction must name the junction's directory as its target.- A failed rename fallback in
PathSyncerno longer deletes the only copy of a file. Onto a backend whoserename()refuses an existing target (plain SFTP withoutposix-rename), the old file is removed and the temporary file renamed in its place; when that second rename failed, the clean-up deleted the temporary file too and the directory held neither version. Once the old entry is gone the temporary file (and, replacing a link, the temporary link) is kept and theOSErrornames it;ignore_errorreceives that error. The fallback is now exercised by the tests. - A changed file whose name is near the length limit can be updated. The
temporary sibling is
.NAME.<12 hex digits>.pathlib-next-tmp, 31 characters longer thanNAME, so a file with a 225-character name was created by the first sync and failed (ENAMETOOLONG,WinError 123) on every later one that found it changed.NAMEis now cut so that the temporary name fits in 255 UTF-8 bytes, keeping the random part and the suffix. - The temporary files of a killed
PathSyncerrun are no longer copied onward, and a stale one is removed. The leftover.NAME.<hex>.pathlib-next-tmpof an interrupted transfer stayed beside the intact target for good under the defaultremove_missing=False, and a sync from that directory copied it to the next destination. A name of exactly that form is now the library's own: it is never a sync source, andremove_missing=Trueno longer removes one as "missing" (a transfer running in another process writes one). It is removed from a target directory when it is stale -- not being written by this process, with a known modification time over 24 hours old -- when a changed file or link in that directory is next written, and byremove_missing=True; a younger one, or one whose store reports no modification time, stays, and so does anything that does not match the full pattern. A file you named.x.<12 hex digits>.pathlib-next-tmpby hand is skipped as a source; rename it to have it synced. The removal is aSyncEvent.RemovedMissing. - A
PathSyncerdry run refuses a symlink the target cannot hold, as the real run does. Withfollow_symlinks=False, a symlink in the source and a target that cannot create links (MemPath, an object store),dry_run=Truereturned and reportedSyncEvent.Symlinkwhile the real run raisedNotImplementedError, souripath sync --dry-runprintedwould symlinkfor something the real run refuses. Both now take the decision before anything is touched, offer the sameNotImplementedErrortoignore_errorand fire noSymlinkevent for it; the command's dry run exits 1 with the same message. PathSyncersees one tree under two spellings or classes as overlapping. The overlap check compared two paths of the same class lexically, soMemPath("r")andMemPath("/r")on one backend, one archive directory underzip:andarchive:(or with a query), and aLocalPathand thefile:URI of the same directory were not reported, so a sync could copy a tree onto or into itself. It now asks the questioncopy()andmove()ask about "same file", so all of these raiseValueErrorthroughignore_errorbefore anything is touched; two paths of an unrelated class are still never reported.- A recursive
copy()orrm()on the asyncssh backend has stopped when it raises. After the first error, a timeout or aKeyboardInterruptin the calling thread, the tasks below the failing entry kept copying and deleting on the server in the background, so a caller that retried or cleaned up raced them, and a failed task's own error was logged as never retrieved. Every task of the walk is now cancelled and awaited before the call returns or raises, an exception delivered to the waiting thread cancels the walk like a timeout does, and a destination file whose copy did not complete (an error, a cancel, an interrupt) is removed instead of being left truncated. Code that relied on the work finishing after an error has to wait for the call itself. - The asyncssh
copy(recursive=True, overwrite=True)no longer deletes a destination file before its source is known to be readable. It unlinked an existing destination and only then opened the source, so a source that could not be opened (permission denied, locked) left the destination gone, where the paramiko backend andPath.copy()keep it. The source is opened first. - A recursive
sftp:copy between two paths with different supplied backends goes through the generic copy. Two paths of one authority that hold distinct backends (a different ssh_configHostName, a tunnel, other credentials) made the asyncssh fan-out create the target directory over the target's connection and write every file over the source's. The fan-out now runs only when_same_filesystem()holds, so each file reaches the target's server. - The asyncssh copy applies modes as
Path.copy()does. It sentst_modewith its file-type bits (0o100644,0o40755), created sub-directories without a mode and never set the mode of the directory copied into. It now sends the permission bits only, creates directories with0o777likemkdir(), settles every directory, the target included, after what is in it, and drops the setuid, setgid and sticky bits between two different classes, as the generic copy does. - On asyncssh,
rm(recursive=True)treatsmissing_okandignore_erroras the genericrm()does.missing_ok=Trueswallowed aFileNotFoundErrorfrom an entry that vanished below the path and returned with the tree still there; it now covers the pathrm()was called on only. A callableignore_errorthat returned False was handed the same error again at every enclosing directory (five calls for a file four levels down); each error is offered once. - The asyncssh backend honours an ssh_config
Portfor a host alias. It always passed a port to asyncssh (22 when the URI had none), which outranks the config, sosftp://box/withHost box/HostName 127.0.0.1/Port 2222connected to port 22 and offered the URI's password to whatever answered there. A port is now passed only when the URI names one; the URI's port still outranks the config's. - An
sftp:host that is not a plain host name or address is refused before any connection or config lookup. The host reached the config lookup and then aProxyCommandcommand line unchanged, sosftp://u@host%20--flag/xwithProxyCommand prog %h %pranprog host --flag 22, and a host starting with-arrived as an option.ValueErrornaming the host is now raised where a connection or a config lookup would start, on both backends; building, joining and comparing such a path still work. A host is accepted when it is an IP address (an IPv6 one may carry a zone) or a name of ASCII letters, digits,.,_and-(not first) and the letters and digits of other scripts (an IDN); anything else is refused. - The paramiko backend expands
ProxyCommandtokens with the connection's own values.%pand%rcame from the config or its defaults, sosftp://alice@example.invalid:2222/xwithProxyCommand prog %h %p %rranprog example.invalid 22and the local user name: the tunnel went to port 22 as the local user while the client believed it was talking to port 2222 asalice.%h,%n,%p,%rand%%now expand, in one pass, with the host name, the host as written, the port and the user this connection uses. A user name with white space, a quote, a shell metacharacter or a leading-is refused (ValueError) when the command holds%r. - An
Includeinside aHostorMatchblock stays conditional on the paramiko backend. The included file's own blocks were spliced in as top-level blocks, so aHost *section in a file included fromHost internalset the user andProxyCommandof every host. Each of the included file's blocks now applies only where the enclosing block does, as in OpenSSH. AnIncludeoutside any block is unchanged. - Removing the last member of a zip directory no longer removes the
directory, and
rm(recursive=True)of a zip directory finishes. A directory that exists only through its members (whatZipFile.write()produces) vanished with its last member:unlink()left its parent missing, the next write into it raisedFileNotFoundError, andrm(recursive=True)deleted every member and then failed on thermdir()of a directory that was gone (the archive root failed the same way).unlink(),rename(),rmdir()andrm()now write the directory's own entry (name/) in the rewrite that empties it,rmdir()of an emptied directory or of an empty archive root succeeds, andrm(recursive=True)of a directory is one rewrite of the archive instead of one per member. A tree that holds a member under a file is refused withOSError(ENOTEMPTY)and nothing is removed. A zip edited this way may now hold empty directory entries. ZipUri.rename()checks where it renames to. A destination below a file or below a missing directory was accepted and made a member nothing could list, and a directory could be renamed into its own subtree. It now raisesNotADirectoryError,FileNotFoundErrorandOSError(EINVAL)respectively, aspathlibdoes; create the directory first.- A zip member that the archive spells twice is removed and renamed as one.
In an archive holding
normand./norm,unlink()andrename()acted on the later entry only: the earlier one came back with its older content, andrm(recursive=True)of the enclosing directory failed with "Directory not empty".unlink(),rename(),rmdir()andrm()now act on every entry that names the member, and renaming over a name that has two spellings replaces them all. - A member below a file does not exist. An archive holding a file
yand a membery/zlisted onlyy, yety/zcould still be statted, read and removed, socopy(recursive=True)andwalk()skipped data thatread_bytes()returned.exists(),stat(),open(),unlink()andrename()ofy/znow report it missing (FileNotFoundError;pathlibsaysNotADirectoryErrorthere), onzip:andtar:alike, and writing ormkdir()belowyraisesNotADirectoryError. Remove or renameyto reachy/z. - The root of an archive that is missing, or is not an archive, no longer
exists.
exists(),is_dir()andstat()ofzip:file:///missing.zip!/(and oftar:,archive:and an HTTP outer answering 404) said it was a directory whileiterdir()raised. They now open the archive:exists()isFalsefor a missing one andstat()raisesFileNotFoundError, and a file that is not an archive raiseszipfile.BadZipFileortarfile.ReadError, as a listing and a member'sexists()do.mkdir()of the root of a missing archive raisesFileNotFoundError; an archive is still created by writing its first member. move()of an object-store prefix no longer deletes keys it did not copy, and a recursive copy no longer leaves them behind. Ons3:,gs:andaz:a move copies by walking the prefix one level at a time and then removes everything under it with a flat listing. Keys the walk cannot reach were deleted without being copied: a key with an empty,.or..segment (d//y.txt), the subtree under a key that is also an object (d/logsandd/logs/2026.txt), and aname/key that holds data.copy(recursive=True)skipped the same keys without a word.copy(recursive=True),move()andrm(recursive=True)of a prefix that holds such keys now raiseOSError(EINVAL)naming them and change nothing (a copy writes nothing, a move leaves the source whole).rm(recursive=True, ignore_error=...)is offered the error and, if it is ignored, removes only the keys a walk reaches. Remove the named keys first, orunlink()the object that hides a subtree, and repeat. A prefix of ordinary keys is unchanged, down to the one batched delete.iterdir()andwalk()are unchanged.- On
az:a name that is both a blob and a prefix lists as the blob. The SDK returns every prefix of a page before its blobs, so the prefix won, the blob's content was neither listed nor copied, andstat()disagreed with the listing.iterdir(),walk()andcopy(recursive=True)now show the blob, ass3:andgs:do. - Writing or renaming a file onto an object-store prefix directory is
refused.
write_bytes()ondwhiled/x.txtexisted stored an objectdthat every listing then showed instead of the directory, andrename()of a file ontodstdid the same, leaving the keys below it unreachable byiterdir(),walk(),copy()andsync. Both now raiseIsADirectoryErrorons3:,gs:andaz:, at the cost of one list request peropen("w")andrename(); credentials that may write but not list are not stopped. Remove the directory (rm(recursive=True)) first. mkdir()no longer writes a marker when its existence probe fails. The object stores askedexists(), which reads any error as "missing", so a 403, a 503 or a timeout on the probe wroteexisting.txt/beside the objectexisting.txtand returned. The probe is nowstat(), only a not-found answer means absent, and any other error is raised with nothing written.rmdir()of ags:bucket root or anaz:container root is refused. It returned success and removed nothing; it now raisesPermissionError, asS3Path.rmdir()does for a bucket, and so does a non-recursiverm()of the root.- A failed
ftp:listing no longer leaves the cached connection out of step.ftplibreads a directory listing while the listing is iterated, outside the guard that drops a connection on a failed transfer. A name the client could not decode (UnicodeDecodeError) or a stalled data connection left the server's final reply queued, and every laterstat(),exists(),iterdir()andwalk()on that thread failed withftplib.error_replyuntil an unrelated read or write happened to reconnect. A listing is now read whole inside the guard: a failure mid-transfer drops the connection and the next request reconnects, a name that is not valid UTF-8 raisesOSError(EILSEQ)instead ofUnicodeDecodeError, and a4xxreply toMLSD(425 Can't open data connection) raisesOSError(EAGAIN)instead of a rawftplib.error_temp, as it does for a read or a write. Code that caughtUnicodeDecodeErrororftplib.error_temparound a listing must catchOSError. ftp://hostwith no path lists the root. A path of""sentMLSD,NLST,RETR,STOR,DELE,MKD,RMDandSITE CHMODwith an empty argument, which a server reads as its working directory: the login directory when it is not/, and whatever directory an earlieris_dir()had changed into (CWD) when it is.ftp://host/always listed the root. An empty path now sends/in every command that takes the path, so both spellings name the same directory.
0.9.11 - 2026-09-21
Added
- Native checksums on the asyncssh SFTP backend.
SftpPath.checksum()andsupported_checksums()now send thecheck-file-handleextension onAsyncsshSftpBackend(the backend chosen when asyncssh is installed), as they already did on the paramiko backend. Against a server that implements it (ProFTPD's mod_sftp, for example),PathSyncercompares server-side digests instead of reading both files. OpenSSH does not implement it: the refusal costs one request per connection, and files are compared by streaming as before. A nativechecksum()is not bounded by the backend'stimeout, because the server hashes the whole file before it answers.
Changed
- The
uriextra now requiresnetimps>=0.3.1(was>=0.2.0). An environment that pins an older netimps must raise that pin.
0.9.10 - 2026-09-20
Added
Path._same_filesystem(other), an override hook answering whether two paths of one type resolve their segments in the same place. Path equality ignores that, andcopy(),move()andPathSyncernow ask it before calling two paths the same file, nested, or overlapping. A path type whose instances can front different hosts or stores should override it; the default keeps the previous behaviour.pathlib_next.testing.PathContractgains two tests: a file copied onto a separately built spelling of itself is refused with its content intact, and paths under one root share a filesystem.
Changed
- The built-in
Base*Backendclasses (s3,gs,az,ftp,sftp,github/gitlab) are weakly referenceable, which is how aUriPathtells a backend it derived for itself from one it was given. A custom backend class using__slots__without"__weakref__"still works; it is simply always read as supplied.
Fixed
- A path type that fronts several hosts can transfer between them again.
Since 0.9.4,
copy()/move()raisedOSError(EINVAL)"Source and target are the same file" andPathSyncerraised "source and target overlap" for/app.confon one host onto/app.confon another, whenever the type kept its connection under any attribute other than the private_backendthe guards looked for. Such a type now overrides_same_filesystem(). - Two separately built URIs to the same URL are recognised as the same
file. Each derived its own backend on first use, which the guards read as
two hosts:
UriPath(url).copy(UriPath(url), overwrite=True)was not refused on any scheme withoutst_dev/st_ino(S3 and WebDAV among them), and neither was a sync of a URL onto itself. Only two backends the caller supplied now tell equal URIs apart. One consequence: a path given a backend and a bare path to the same URL are now the same file forcopy()/move(), as they already were forPathSyncer. copy(recursive=True)'s "into itself" check now honours two supplied backends, like the other two guards; it refused a copy between two hosts whose URIs nested.
0.9.9 - 2026-09-17
Changed
- CI gains a URI-only job (
test.yml): the package installed with just theuriextra and no scheme client at all, running the modules that must work without one plus an explicit check that a pure-path join imports nothing. Every other job installs all extras, which is why a join that built a backend -- and so needed paramiko to spell ansftp:path -- survived from 0.9.3 to 0.9.8 behind a green matrix.
Fixed
glob(bound_loops=True)no longer drops a directory shared under two names. It bounded on every identity seen during the walk, so one directory junctioned in as bothsite-aandsite-b-- a deliberate layout, and not a loop -- expanded under the first name only, silently. A loop is a directory reachable BELOW ITSELF, so the bound is now the current descent path rather than everything seen, which is the linefind -Ldraws. The loop case is unchanged: 128 matches become 2. Reported by yaconfiglib against 0.9.7.
Documentation
- Corrected the 0.9.8 entry's provenance. It says 0.9.7 introduced the
join-builds-a-backend defect; it did not. Measured against the published
wheels in a venv with only the
uriextra:UriPath("sftp://h/mnt") / "c"raisesImportErroron 0.9.3 and 0.9.6 as well, so the defect predates the 0.9.7 join rewrite, which preserved it rather than causing it. 0.9.8 fixes it for the first time. (A related and DELIBERATE behaviour, unchanged throughout: constructing anhttp:/s3:path at all requires that scheme's extra, because the scheme module imports its client -- see the extras table in the shipped header.)
0.9.8 - 2026-09-17
Fixed
- Joining a URI path no longer builds a backend. 0.9.7 routed
/andjoinpath()through the child builder a listing uses, which reads thebackendproperty -- and that property CREATES one, so spellingUriPath("sftp://h/x") / "y"imported paramiko and raisedImportErrorwithout the extra (http:wantedrequests,s3:botocore). A join is a pure-path operation and is lazy again; a child still shares its parent's connection when one already exists, which is all the sharing was ever for.
0.9.7 - 2026-09-17
Added
rm(follow_symlinks=, follow_binds=): what a recursive removal does with a symlink, and with a binding (a Windows junction, a mount point). Each takesFalse(the default -- remove the entry itself, never its contents, asrm -rdoes),True(remove what is behind it),None(leave it in place, so the enclosing directory is not empty and reports it), or a callablepolicy(path) -> bool | Noneasked per entry, so one tree can keep one mount and follow another. Named for the keywordstat(),walk()andcopy()already use for the same idea, rather than a second vocabulary.-
Path.is_junction(),Path.is_mount()andPath.is_dir_binding(): a directory that is another tree's second NAME -- a Windows junction, or a mount point such as a Linux bind mount -- is now a first-class concept rather than a private hook used by one call site. Neither is a symlink (is_symlink()is False, a non-following stat reports a plain directory), which is precisely why a symlink check cannot protect a walk from one.is_junction()matches pathlib 3.12's; both are False by default and answered for real byLocalPath/FileUri. -
Path.glob(on_error=)/rglob(on_error=): a hook called ason_error(error)when a directory cannot be listed, the same contract aswalk()andos.walk. Raising from it makes an unreadable directory fatal; returning treats it as empty. Without the hook the listing is skipped silently, as pathlib does and as before -- which left a caller unable to tell an unreadable layer from an absent one.error.filenamenames the directory even when the backend left it unset. Path.glob(bound_loops=)/rglob(bound_loops=): withTrue, a directory is descended at most once per**, keyed on(st_dev, st_ino)and seeded with the starting directory. This bounds a Windows junction loop -- a junction reportsis_symlink() == False, sorecurse_symlinks=Falsecannot see it, and one file in a looping tree matched 64 times (pathlib walks it the same way). DefaultFalsekeeps pathlib's behaviour; a backend whose stat carries no identity is walked unbounded. Both reported by yaconfiglib against 0.9.6.
Fixed
- An SFTP listing is untrusted input. The server chooses the names, and
they were turned into child paths unchecked, so a crafted
../victim.txtmaderm(recursive=True)delete outside the tree it was given and a recursive copy read from outside it (measured on both backends). Names that are not a single component inside the directory are now skipped, asdav:andhttp:already did. The asyncssh fan-out walkers, which bypass_scandir(), filter for themselves. unpack_archive()split a backslash on every platform, so a POSIX member whose name contains one was extracted as two path components and one carrying..was silently dropped -- both legal single filenames there. A backslash now separates only for a Windows destination, which is the rule the function already documented.has_glob_pattern()was True for every Windows extended-length path. The?in a\\?\C:\...anchor (and the\\?\UNC\...form) is a prefix, not a wildcard, so a caller using it to tell a pattern from a plain path got the wrong answer for all of them. The anchor is no longer scanned. Reported by yaconfiglib against 0.9.6.- Writing over an archive member spelled
./f.txtappended a second entry instead of rewriting it, andunlink()then deleted that second entry and reported success while the original content came back. 0.9.5 handed the normalized name to a backend keyed on the raw one; writes now address the entry the archive really holds. - Renaming a directory in such an archive silently did nothing, or
split it in two when its members were spelled inconsistently, while
rename()returned the new path as though it had worked. The backend now receives the exact raw-name mapping instead of a normalized prefix. - Every archive operation rebuilt the member index, so a listing, stat, read or write on a 20k-member archive scanned all 20k names: measured 10x-224x slower than 0.9.4. The index is cached per open handle and dropped whenever the handle is, which every mutation and every external change already go through.
glob(None)ignorednative=, never auto-detected**, and answered differently fromrglob(None)for the same path, because that branch bypassed the pattern parser:LocalPath("/x/**/*.py").glob(None)returned a shallow subset with no error.native=Falsealso left the trailing-**rule following the interpreter, so it did not deliver the one-answer promise it documents; it now pins that rule too./mangled adata:payload (data:,a/../bjoined todata:b/x, not a data URI at all), dropped a scheme's own child handling (gitlab:'s reserved-), still parsed abytesname as URI syntax, and lost per-instance scheme state such asSftpPath'sssh_config. Joins now walk segments through the same builder a listing uses.- A relative
strdestination whose first segment merely contained a colon (notes:draft,Fedora-42:latest.tar) was read as a URI scheme bycopy()/move(); the scheme must now be one a class registers, as theuripathCLI already required. A Windows drive path (C:/Temp/x) restarts the join for afile:path, asPureWindowsPathdoes. rename()andcopy()/move()resolved a relativestrdifferently (rename("../b")sent a literalsub/../b, a different key on an object store); both now resolve it the same way. A same-endpoint URI destination reuses the configured connection instead of opening a second, bare one.
Changed
rm(recursive=True)no longer descends into a mount point. It already removed a Windows junction as a binding rather than walking into it; a POSIX bind mount is the same thing and was walked, so deleting a tree containing one deleted the mounted filesystem's contents. It is nowrmdir()'d like a junction, which fails loudly on a live mount instead.UriPath / "name"andjoinpath()read astras a decoded path, not as URI syntax.base / "cache?v=2"is now the filecache?v=2instead ofbase/cachewith a query;"note#2.txt","a%20b.txt"and"C:/Temp"join verbatim too. This is whatiterdir()always did, so listing a directory and naming the same child by hand finally agree. Pass aUri/UriPathargument for a scheme-aware join (base / UriPath("s3://bucket/key")); that stays the only form that can cross endpoints, and it still drops a credential-bearing backend on the way. Dot segments are removed from the result as before.copy()/move()accept a plain-pathstrdestination. A string with a scheme is a URI, as before, socopy("s3://bucket/key")keeps working; one without is a decoded path on the same endpoint (absolute replaces the path, relative is a sibling, asrename()resolves it) and reuses this path's source and backend.move("b.txt")previously built a sourceless path and failed on its firstexists()call, and a same-host URI destination opened a second connection. A one-letter scheme is treated as a Windows drive, soC:/Temp/xis a path.
0.9.6 - 2026-09-16
Added
Path.glob(None)/rglob(None): expand the pattern the path itself carries (LocalPath("/etc/*.conf").glob(None)), splitting at the first wildcard.glob("")still raisesValueErroras pathlib does --Noneis the spelling that cannot collide with a real pattern -- so this restores what 0.9.4 removed as an explicit, supported form rather than by accident.Path.glob(native=)/rglob(native=), defaultTrue: follow the running interpreter on the two rules pathlib changed mid-series. A trailing/is ignored before 3.11 and selects directories only from 3.11; a component that merely contains**(a**) raisesValueErrorbefore 3.13 and is a plain wildcard from 3.13.native=Falseapplies one rule on every version instead, which is what a caller comparing results across backends or interpreters wants.
Changed
glob()now matches the running interpreter exactly, including on Python 3.9-3.12 where it previously applied its own rule for a trailing/and fora**. Measured with a 46-comparison differential sweep againstpathlib: 3.14 was already identical, and 3.9 went from 4 disagreements to 0. Passnative=Falsefor the previous, version- independent behaviour;pathlib_next.testing's contract suite does.
Documentation
- Named the replacement for
glob(""), which 0.9.4 removed for pathlib parity:pathlib_next.utils.glob.glob(path, recursive=...)expands a pattern the path itself carries, splitting at the first wildcard. The 0.9.4 entry withdrew the capability without naming it, andPath.glob()'s docstring documented theValueErrorbut not the alternative. Reported by yaconfiglib, whosepath.glob("", recursive=...)calls stopped working.
0.9.5 - 2026-09-16
Fixed
- Archive member names are normalized, so the same member is reachable
however the archive was written and whichever format it is. A leading
./(whattar -C dir .,TarFile.add(arcname=".")andshutil.make_archiveput on every member), empty segments (a//b) and interior./..(a/./b,a/b/../c) now resolve to one name for listing and lookup alike. Previously onlytar:stripped a leading./: a zip written that way listed as empty and none of its members could be read under any spelling, while a name such asd//e.txtwas readable but absent from listings.
Changed
- A drive- or backslash-shaped member name is no longer dropped.
C:drive.txtanda\bare ordinary filenames on POSIX, and an archive written there may contain them; they were silently absent from every listing and unreadable on every platform. The rule they were failing is a destination rule, and now lives where the joining happens:Path.copy(recursive=True)refuses a child name that would not stay inside its target (ValueErrorthroughignore_error), which is whatPathSyncerandutils.unpack_archive()already did per destination. Copying such a member onto a Windows path is still refused; copying it to a POSIX path, aMemPathor another archive now works. - A member name that escapes the archive root (
../x,/abs, a..with nothing left to consume) has no name inside the archive: it was already never listed, and is now never readable either. Such a member used to be hidden from listings whileread_bytes()still returned it under its raw name. (Writing to one already failed and created nothing; that is unchanged.) A..that stays inside is resolved rather than rejected (pkg/../ok.txtreadsok.txt), and when two spellings normalize to one name the later member wins, as inzipfile/tarfile. Uri's RFC 3986 dot-segment removal is documented as a divergence frompathlib(docs/divergences.md); the behaviour is unchanged.
0.9.4 - 2026-09-16
Fixed
Path.copy()destroyed or created the target when the source could not be read. It unlinked an existing target (withoverwrite=True) and opened the target for writing before opening the source, so a missing file, a directory withoutrecursive=True, or a source HTTP 404 left the target empty, or left a new 0-byte file that made a retry fail withFileExistsError. The source is now opened first, and a copy that fails mid-stream removes its partial target.- Copying or moving a file onto itself deleted it.
f.copy(f, overwrite=True)(or onto a case-insensitive alias such asF.TXTon Windows/macOS) emptied the file; a case-only rename withmove(overwrite=True)deleted it.copy()now raisesOSError(EINVAL, "Source and target are the same file");move()renames in place. move(overwrite=True)removed the target before checking the source. A missing source, or a file moved onto a directory, deleted the target (including a whole tree) and only then raised. It now raisesFileNotFoundError/IsADirectoryErrorfirst and leaves the target alone. A local file target is replaced atomically withos.replace(), so a locked source on Windows no longer costs the target.rm(recursive=True)deleted files outside the tree through Windows junctions andfile:directory symlinks. A junction reads as a directory to a non-following stat, andUriPath's default_scandir()used a following stat, so both were descended into and their targets' contents deleted. Both are now removed as links.FileUrilistings also reuseLocalPath's scandir metadata (one call per directory).PathSyncer.sync()could delete or write outside its target.- A root source that does not exist now raises
FileNotFoundError. Before, withremove_missing=Trueit deleted the entire target (a typo, an unmounted share, a 404); without it, it reported success. Callers that relied on syncing an absent source as a no-op must now catch the error or passignore_error. - Overlapping source and target (one inside the other, same
implementation and backend) now raise
ValueError. Before, the source could be deleted, or copies nested untilRecursionError. - A child name that would leave the target (
.., a name the parent-name fallback turned into.., or\/:on a Windows target) now raisesValueErrorthroughignore_error. Before, such entries from an S3, SFTP or archive listing were written, or removed, outside the target. - Entries listed with an unknown stat (GitLab blobs, FTP without MLSD) are
now re-stat'd. Before, with
follow_symlinks=Falsenothing was copied andremove_missing=Truedeleted the existing mirror. - A symlink inside the target is replaced by the real file or directory. Before, sync listed, wrote and deleted through it, into whatever it pointed at.
http:/dav:listings yielded.and..as children. wsgidav's parent row (<a href="..">) became a file named.., and unlinking it deleted the parent collection; a./entry madewalk()loop forever; a PROPFIND href%2E%2E/let a recursive copy write outside its destination. Such names are no longer listed.DavPath.unlink()andHttpPath.unlink()deleted whole collections. They sent a bareDELETE, which WebDAV applies recursively;unlink()on a directory, andsymlink_to(force=True)over one, removed the tree. Both now raiseIsADirectoryErrorfor a directory (HttpPathrelies on its HEAD-based directory check).rm(recursive=True)still deletes trees.DavPathread an HTTP error page as file content.open("rb")/read_bytes()/copy()on a missing or forbidden file returned the server's 404/401/500 body. They now raiseFileNotFoundError/PermissionError/OSError.- Reading a local
zip:archive opened it for writing. A read-only zip was unreadable (exists()returnedFalse),exists()on a missing archive created it, and probing a file that is not a zip appended 22 bytes to it. Reads now open the archive read-only; the first write into a missing archive creates it. Probing a non-zip file now raiseszipfile.BadZipFile. - Zip
unlink()/rename()/overwrite reset every other member. The rewrite gave all members the current time, DEFLATE compression and mode 0600, and dropped the archive comment and any leading bytes (a zipapp shebang); it also replaced a symlinked archive with a regular file. Member metadata, the comment, the prefix bytes and the archive's file mode are now kept, and a symlinked archive stays a symlink. - Zip
rename()onto an existing member created a duplicate name, and a later rewrite kept the old content. It now replaces the target (POSIX semantics; seedocs/divergences.md). - Archive listings exposed traversal member names. Members named with
.., an absolute path,\-separated traversal or a drive prefix are no longer listed, soiterdir()/walk()/copy(recursive=True)cannot write outside a destination through them. -
utils.unpack_archive()let crafted members escapedeston Windows (D:evil.txt, and the same-driveC:../C:../x). Such members, and any member with a..part (previously extracted with the..dropped), are now skipped. -
rename()/move()renamed onto the wrong host, bucket or archive. Every scheme renamed through its own connection or bucket with only the target's path:SftpPath/FtpPathmoves to another server renamed on the source server,S3Path/GsPathmoves to another bucket landed in the source bucket (overwriting an existing object of that name there), a zip member moved to a local path was renamed inside the archive, andLocalPath.move()onto a remote path renamed a local file.rename()now raisesNotImplementedErrorfor a target on another endpoint, archive or Azure container, andmove()copies and deletes instead.move()also falls back to copy + delete on a cross-device rename (EXDEV), andSftpPath.copy(recursive=True)uses its concurrent fan-out only for a target on the same host. - Session credentials and tokens followed a join to another host.
base / "http://other/x",UriPath(base, url)andbase.with_source(...)reusedbase's backend, so anHttpPath.with_session(auth=...)session or agithub://TOKEN@...token was sent to the other host. A backend is now reused only for the same scheme, userinfo, host and port. AzPath.rename()with astrtarget always raisedTypeError, and a pending copy crashed withKeyErrorafter starting it.GsPath/AzPath/S3Path.rename()onto the same key deleted the object. Both fixed.FileUri.rename("b.txt")resolved against the process cwd and returned aLocalPath. It now renames within the same directory and returns aFileUri.-
MemPath.copy("/b.txt")/move("/c.txt")wrote into a new, empty in-memory filesystem, andmove()then deleted the source. Astrdestination now stays on the source's backend. -
SftpPath.copy()raisedModuleNotFoundErrorwithout asyncssh (paramiko-onlysftpextra), including every single-file download andPathSyncerwith an SFTP source. - SFTP connections leaked or went stale. A first-call asyncssh
rm(recursive=True)hung for 60 s and deleted nothing; dropped paramiko connections and closed asyncssh SFTP channels were never replaced, so every later call failed andexists()returnedFalse; evicted connections, failed logins and failed SFTP starts leaked sockets and threads; concurrent first calls opened duplicate connections; a recursive copy held two remote handles open per file in the tree;SftpPath(url, ssh_config=...)ignoredssh_config; the paramiko backend ignored ssh_configInclude. UriPath("ftps://...")returned a stubUriPathin a fresh process instead ofFtpPath.- URL credentials leaked into HTTP errors and redirects. They are now sent
as Basic
auth=instead of inside the request URL, WebDAVMOVEDestinationno longer carries them, and translated errors no longer chain therequestsexception (__cause__isNone; the message carries the HTTP status and reason). URL credentials now take priority over a matching~/.netrcentry. -
github:/gitlab:tokens leaked throughstr()/repr()/errors, anduser:TOKEN@hostauthenticated with the username. The token is now read from the password slot when present, and these schemes redact the whole userinfo. -
glob()/rglob()crashed, hung or returned wrong results.glob("**")andglob("dir/**")raisedNotADirectoryErroron any tree containing a file;**followed directory symlinks, so a symlink loop produced duplicates effectively forever; globbing under a missing directory or a file raised instead of yielding nothing; repeated**returned duplicates; a trailing/matched files onMemPathand URI paths;?never matched on aUriPath(read as a query);glob.full_match()slowed down exponentially with repeated**. A trailing**now follows the running Python (files too on 3.13+). match()onMemPath,Uriand everyUriPathdid not follow pathlib. It anchored at the start and let*cross/, and a URI'shost:prefix defeated absolute patterns. It is now pathlib's right-anchored per-segment match; an empty pattern raisesValueError.LocalPathacceptsmatch(case_sensitive=)on 3.9-3.11, and itsfull_match()handles rooted, drive and backslash patterns before 3.13.parents/parentof an absoluteMemPathorUrilost the root.MemPath('/a/b').parentsis now['/a', '/'];Uri('http://h/a').parentishttp://h/; a top-levelFileUri's parent is/(or the drive rootC:/on Windows) instead of resolving to the current directory.relative_to()/is_relative_to()treats3://bucket/http://has the root, sorelative_to(p.parent)andwalk_up=Truework.with_name()/with_stem()/with_suffix()accepted'',.and separators onMemPathandUri, splicingx/yor../../etcinto a path. They now raiseValueErrorlike pathlib.MemPathdid not normalize likePurePosixPath.MemPath('/') / 'a'was//aand unequal toMemPath('/a'); a trailing/changed equality; an absolute join did not reset. All now matchPurePosixPath.Path.touch()made new files world-writable and could truncate existing ones. It chmod'ed every new file to 0o666 ignoring the umask (file:, SFTP, FTP) and treated anystat()error as "missing".modenow defaults toNone(chmod only when passed), an existing file is never truncated, andFileUri.touch()uses pathlib'stouch().copy()made local copies read-only.preserve_metadata=Trueapplied the placeholder 0o444/0o555 mode thatMemPath, HTTP, WebDAV, object stores and archives report, so a copied file could not be overwritten or re-synced. Placeholder modes are no longer applied (FileStat.mode_known).utils.parsedate()read GMT dates as local time, so every HTTP/WebDAVst_mtimewas off by the host's UTC offset, and it raisedOverflowErroron Windows east of UTC. It now returns UTC epoch seconds and passes numbers through.s3://b/dir/(trailing slash) was treated as the marker object: not a directory, andrm(recursive=True)removed only the marker. S3, GCS and Azure keys now drop one trailing/. Azure keys keep interior empty segments (a//b) as written.-
GsBackendrewrote the process-wideSTORAGE_EMULATOR_HOSTand dropped otherclient_options. Keyword arguments now go tostorage.Clientunchanged; for an emulator also passuse_auth_w_custom_endpoint=False. -
URI queries were corrupted on the wire. They were percent-decoded at parse time and re-encoded with
&,=and+treated as safe, so a signed URL'ssig=ab%2Bcd%3D%3Dreached the server asab+cd==and an escaped&split a value;with_query(dict)double-encoded.Uri.queryis now kept as received and sent unchanged (see Changed). - A remote path joined with a relative
pathlib.Pathbecame a local file.UriPath("sftp://h/srv/") / pathlib.Path("etc/x")producedfile:/srv/etc/x, so reads and writes hit the local disk. A relative path now joins like aPurePathand stays on the remote; only an absolute local path becomesfile:.UriPath.joinpath()picks the class from the scheme. - URIs with non-UTF-8 percent-escapes (
caf%E9.html) raisedUnicodeDecodeError; they now construct and round-trip.data:payloads are no longer dot-normalized or decoded twice, and binary payloads work. - Windows
file:URIs:file://localhost/C:/...could not be printed, hashed or compared; afile://<host>/sharewhose host is this machine mapped to the current drive instead of a UNC path. - Scheme registry: a
UriPathsubclass defined after the first dispatch was never found, and every unknown scheme rescanned entry points. open()modes:rt/wtfailed on every non-local backend and invalid modes raisedNotImplementedError; modes are now validated like the built-inopen()(ValueError).open("r+")on buffered backends (FTP, S3, GCS, Azure, local zip members) returned a writable buffer whose writes were discarded; writes are now uploaded on close (orNotImplementedErrorwhere impossible).copy():copy(recursive=True)into its own subtree recursed without limit;copy(follow_symlinks=False)copied the link target's content with the link's 0o777 mode (it now recreates the link, as pathlib 3.14 does);overwrite=Falsewas decided byexists(), which reads a transient 503 as "missing". Downstream classes mixing a concrete stdlib path withPathnow also get pathlib_next'sstat/chmod/glob/walk/_scandir.MemPath:iterdir()on a missing path raisedNotADirectoryError; files always reportedst_mtime=0, soPathSyncerskipped same-size edits.- Archives (
zip:/tar:/archive:): children of the archive root were named/nameand matched no member, soiterdir/glob/recursivecopyfrom the root failed; tarballs with./members (tar -C dir .,shutil.make_archive) were unreadable; adding a zip member rewrote the central directory in place (a crash corrupted the archive); a cached handle ignored changes by other writers and kept the file locked on Windows; archive URIs did not round-trip names with#,?or%; concurrent tar reads returned wrong bytes; stored member modes were ignored;iterdir()on a file returned[].utils.make_archive()now accepts anyPathsource, writes the target only when complete and supports zip64;utils.unpack_archive()accepts non-seekable streams and extracts tar links. - HTTP/WebDAV: gzip-encoded responses were returned compressed (and
rewrite-mode append re-uploaded them); a redirect made
stat()report a directory; listing hints fabricated sizes used as the patch-append offset; mid-body read failures raised urllib3 exceptions;DavPathlisted a directory with a space in its name as its own child, treated a 207 Multi-Status with failed members as success, re-sent a failed PUT at garbage collection and raised rawrequests.HTTPErrors;with_session(headers=...)was replaced by internal headers. - GitHub/GitLab: GitLab listings stopped after 100 entries (and
stat()called later subdirectories missing); GitHub listings stopped at 1,000; self-hosted API roots dropped the port; GitLab rootstat()answered from the URI shape without asking the server. uripathCLI:synccompared sizes only, so same-size edits were never copied;--dry-runprinted nothing;read/cp -buffered whole objects.- FTP: every separately built path opened its own connection; an error
mid-transfer desynchronized the cached connection for good; a write whose
connection timed out while idle lost its data; without MLSD, directories
stat'ed as missing; MLSD mtimes were read as local time; permission errors
surfaced as
FileNotFoundErrorand rawftpliberrors escaped. - S3/GCS/Azure: botocore
ClientErrorescapedexists()/walk()and a 403 read as "missing"; GCS/Azure turned every exception (including a missing SDK) into "does not exist", socopy(overwrite=False)could overwrite;iterdir()on a missing path returned[]andrmdir()/unlink()accepted wrong-type targets; a failed upload was retried at garbage collection over newer data; prefix-directorymove()failed; S3 objects above 5 GiB could not be written or renamed;open("x")was a check-then-put race;AzPathwithoutbackend=ignored the URI's account; Azure recursiverm()stopped at the first failing blob in a batch. PathSyncer: an interrupted copy lost the previous version (it now writes a temporary sibling and renames); preserve mode deleted the target before discovering symlinks were unsupported; dry runs crashed on new subdirectories;RemovedMissingevents carried the parent directory;ignore_errorwas called once per ancestor with the wrong paths; two unknown (0) mtimes counted as "in sync"; FIFOs and devices replaced the target with an empty directory.-
SFTP:
rename()onto an existing file failed with a bareOSError("Failure");unlink(missing_ok=True)skipped dangling symlinks; a relativereadlink()result could not be printed; asyncssh file handles made one round trip per byte inreadline()and an unclosed handle hung interpreter exit for 60 s; the asyncssh recursive copy called a boolignore_errorand ignored the own-subtree and symlink rules; paramiko's native checksum probed an extension OpenSSH does not implement, paying extra round trips per file. -
rename()returnedNoneondav:,s3:,gs:,az:,ftp:andsftp:; it now returns the new path, as pathlib does. - Wrong exception types on
sftp:,dav:,gitlab:andMemPath. Listing orrmdir()of a file raisesNotADirectoryError,rmdir()of a non-empty directoryOSError(ENOTEMPTY)(MemPathraisedFileExistsError), opening or unlinking a directoryIsADirectoryError;dav:reading a collection raised nothing and returned its HTML index, andrmdir()of a missing path raisedNotADirectoryError. paramikoopen("x")returned a file that could not be written. Path.rm(recursive=True, ignore_error=callable)offered a declined error to the callable again from every enclosing directory.uripathcrashed at import without theuriextra, even for local files. Local paths and-now work; a URI argument reports the extra to install. Without the extra it also read every colon name as a URI (uripath read notes:draftasked forpathlib-next[uri]instead of reading the file); the schemes this package registers are now read from its entry points, so a colon name behaves the same in either install.match()disagreed with pathlib on Python 3.12 at the root. 3.12 matches the whole path as one string with its separators swapped for newlines, so"/"is a single newline that"**"matches from either side ("/".match("**"),match("/**")andmatch("**/**")are allTruethere) while"*"never does, and a bracket expression such as"[!a]"consumes the separator itself.MemPath/UriansweredFalsethroughout; 3.12 now runs a port of that algorithm instead of the part-by-part comparison every other version uses.import pathlib_nextimportednetimps(a host-name query at import, slow on Windows), and the firstfile:/data:path importedrequestsandbotocore; both now load on first use.GsPath/AzPathare exported frompathlib_next.uri.schemes.*.local.*files shipped in the sdist and wheel.- The SFTP/FTP/WebDAV examples built URIs from unencoded credentials (a
password containing
/,#,?or@changed the host) and printed the password. benchmarks/bench.pycrashed at import on Python 3.9.-
Docs: the CI benchmark table had its Ubuntu, Windows and macOS columns rotated; the paramiko single-file write/copy slowdown was on Ubuntu.
-
Core, MemPath and URI edge cases.
open()leaked the backend handle when the text wrapper failed; synthesized errors fromrm()/touch()andMemPathlackederrno/filename;copy(progress=)never reported a zero-byte file;samefile(str)lost the backend or host;MemPathhandles appended at the seek position, hid unflushed writes and accepted writes on read handles, and exclusive create/mkdircould both succeed under concurrency;FileStat.from_stat()keptNonefields; checksums failed on FIPS hosts (usedforsecurity=False);is_dir()/is_file()rejectedfollow_symlinks=;"prefix" / pathwas unsupported; suffix/stem ignored 3.14's rules; lazy URI parsing could expose unset components to another thread;Uri.__eq__raised against a relative local path; a//path with no authority could not be rendered; non-ASCII hosts were sent percent-encoded;Uri("/a").is_absolute()wasFalse;data:acceptedr+and discarded writes. - Scheme and CLI edge cases. FTP listed MLSD
cdir/pdirentries named like children; archives nested in archives could not be addressed; GitLabiterdir()on a file yielded nothing; GitHub/GitLab 429 and secondary rate limits were not recognised;git://<ip>raisedAttributeError; a customBaseRepoBackendwithout a cache crashed on GitLab; git-hostingopen("r+")returned a writable buffer;uripath cp -overwrote an existing target without--overwrite, a local name with a colon was treated as a URI, and a closed pipe or Ctrl-C printed errors;HttpPath.iterdir()on a file downloaded it; HTTP 409 on PUT and 410 were mis-mapped; WebDAV read only the first<propstat>; HTTP listings behind a prefix were scoped by the page title; S3/GCS listings disagreed withstat()when a key was both an object and a prefix; GCS/Azure roots always reported existing; asyncssh SFTP errors had noerrno/filename. PathSynceredge cases. Tolerated errors left no trace; a directory that became a file or symlink in the source deleted the target directory even withremove_missing=False;SyncStartpassed raw paths to the hook and dry runs reporteddry_run=Falsefor traversal events; preserved directory symlinks were created as file links on Windows; an identical symlink was recreated on every run;PathAndStat(path)described a symlink itself instead of following it.
Changed
PathSyncerkeeps non-empty target directories on a type change unlessremove_missing=True(IsADirectoryErrorthroughignore_error,SyncEvent.TypeMismatch). Every tolerated error is now logged at WARNING onpathlib_next.syncand reported to the hook asSyncEvent.Error.PathAndStatfollows symlinks by default.PathSyncer.hook()gains a keyword-onlyalways_run.- URI comparisons and rendering:
Uri == <non-URI Pathname>(e.g. aLocalPath) is nowFalse(aUristill equals anotherUrior a URI string); non-ASCII hosts are rendered in IDNA form; an explicitschemesmap=is authoritative (an unknown scheme gives a plainUriPath);with_source()with a scheme-less source returns a plainUriPath;/no longer hides aTypeErrorraised inside a scheme class. data:URIs rejectr+, decode base64 only with;base64, and implytext/plainfor a parameters-only header.uripathexits 141 on a closed stdout and 130 on Ctrl-C.- Package metadata uses PEP 639 (
License-Expression: MIT) instead of theLicense ::classifier, and addsTyping :: Typed,Development Status :: 4 - Betaand Python 3.9-3.14 classifiers. Building from source needshatchling>=1.27. - The
azextra installsazure-identity, which anAzPathwithoutbackend=needs for its default credential. pathlib_next.testingcontracts are stricter: 48PathContracttests (was 20) covering pathlib error types, glob, walk, rename, open modes and recursive copy. A backend that cannot meet a rule sets a capability attribute toFalse(supports_listing,supports_empty_directories,distinguishes_file_types,supports_rename,supports_append,supports_exclusive_create,enforces_directory_hierarchy). The contract root must be a fresh, function-scoped directory populated withpopulate_fixture_tree().test_iterdir_lists_childrenno longer passes wheniterdir()is unimplemented.Uri.queryis the percent-encoded query as received and is sent unchanged;Query(...).decode()/to_dict()decode each name and value once. Astrpassed towith_query()is taken as already encoded; a mapping's keys now escape=. Code that read.queryexpecting decoded text must decode it.- Archive paths raise pathlib's POSIX exception types (
iterdir()on a file, reading orunlink()ing a directory,rmdir()on a file), andmkdir()/new zip members need an existing parent. Ziprename()returns the new path. Archiveas_uri()percent-encodes the member path. uripath synccompares file content by default;--size-onlyrestores the old comparison.--dry-runprints planned changes and-v/--verboseprints changes made.gitlab:reads a-segment at position 3 or later as GitLab's/-/separator (gitlab://host/group/sub/project/-/path).SftpPath.rename()replaces an existing target where the server supportsposix-rename@openssh.com, else raisesFileExistsError.DavPathmaps request errors to pathlib exceptions (PUT/MOVE into a missing parent:FileNotFoundError; 423:PermissionError).Path.glob()/rglob()/LocalPath.glob()include hidden files and directories by default, as pathlib does. Passinclude_hidden=Falsefor the old results.glob("")now raisesValueErrorand an absolute pattern raisesglob.NonRelativePatternError; before,""yielded the base and an absolute pattern listed outside it.- SFTP host keys are verified by default on both backends. Before, any
server key was accepted (asyncssh even overrode ssh_config pinning), so a
man-in-the-middle received the URI password. Now an unknown or changed key
fails before credentials are sent. paramiko uses
~/.ssh/known_hosts, ssh_configUserKnownHostsFileandRejectPolicy. To keep the old behaviour add the host toknown_hosts, or opt out explicitly:SftpBackend(connect_opts, paramiko.AutoAddPolicy(), known_hosts=None)/AsyncsshSftpBackend(connect_opts={"known_hosts": None}). ftps:verifies the server certificate and host name by default. Before, any certificate was accepted and the password sent to it. For a private CA passFtpBackend(ssl_context=ssl.create_default_context(cafile=...));FtpBackend(verify=False)disables verification. Data connections now reuse the TLS session (vsftpd, FileZilla Server).- Network operations have default timeouts. HTTP/WebDAV/github/gitlab:
(10, 60)s connect/read (with_session(..., timeout=...),RepoBackend(timeout=...)); FTP: 30 s (FtpBackend(timeout=...)); paramiko connect/banner/auth/channel-open: 30 s (SftpBackend(..., timeout=...)).timeout=Nonerestores the unbounded wait. Before, a stalled server hung the caller forever. - asyncssh timeouts apply to single requests only
(
AsyncsshSftpBackend(timeout=...), default 60 s). Recursivecopy()/rm()andread_bytes()/write_bytes()no longer raise after 60 s while the work continued in the background. A timed-out request is cancelled and raises the builtinTimeoutErroron every Python version (on 3.9/3.10 it wasconcurrent.futures.TimeoutError). - paramiko backend: ssh_config
ProxyJumpraisesNotImplementedError. Before, it was ignored and the connection went direct. Use asyncssh, aProxyCommand, orconnect_opts["sock"]. - asyncssh
rm()/copy()error callbacks run in a worker thread, so they may call ordinary path methods; a sync SFTP call made on the bridge-loop thread raisesRuntimeErrorinstead of hanging.
Added
SyncEvent.ErrorandSyncEvent.TypeMismatchreporting;str / path(__rtruediv__) onPathnameandUri.pathlib_next.testing.populate_fixture_tree(root)andFIXTURE_TREE.benchmarks/bench.py --save(min/median/max ms per call as JSON underbenchmarks/results/) and--samples.SyncEvent.CompareandSyncEvent.Skipped;uripath sync --size-onlyand-v/--verbose.glob.parse_pattern(),glob.select(),glob.NonRelativePatternError;recurse_symlinks=Falseonglob()/rglob();FileStat.mode_known.close()onSftpBackend/AsyncsshSftpBackend;FtpBackend(timeout=, ssl_context=, verify=);RepoBackend(timeout=);utils.LRU(on_evict=...)andLRU.discard().utils.is_safe_child_name(name, *, windows=False)andutils.is_windows_flavoured(path): check that an untrusted name stays a single component inside its parent before joining it onto a destination.
0.9.3 - 2026-08-16
Fixed
- A
strdestination torename()/symlink_to()was re-parsed as a URI, so part of it was silently discarded. Every scheme resolved the destination by feeding it back through the URI parser (Uri(self.parent, target)forrename(),type(self)(target)insidePath.symlink_to()). That reads a decoded filesystem path as URI syntax: everything from a?or#onward became a query/fragment and was dropped,%xxwas percent-decoded, and a relative destination whose first segment ended in:was read as a scheme. Measured against a real SFTP server (TrueNAS 26.0.0-BETA.1):
| call | file/link actually produced |
|---|---|
rename(".../rn?b.txt") |
.../rn |
rename(".../rn%20b.txt") |
.../rn b.txt |
symlink_to(".../cache?v=2") |
link points at .../cache |
rename("C:/Temp/x.txt") |
/Temp/x.txt (C: taken as a scheme) |
Nothing raised. When something already occupied the truncated name the call
instead failed with a bare OSError: Failure, so the symptom was either
silent misplacement or an unexplained error depending on what happened to
be there. Downstream, a consumer's documented
client.path(x).symlink_to(y) route created a wrong link, and
PathSyncer's symlink_mode="preserve" (which hands symlink_to() the
raw target string readlink() returned) mirrored such a link to the wrong
place.
A str destination is now taken as an already-decoded path — ?, #,
% and : are ordinary filename characters — via the new
Uri._from_decoded_path(), one implementation shared by
Uri._rename_target() (used by SftpPath, FtpPath, DavPath,
S3Path, GsPath, AzPath and ArchiveUri) and by
UriPath._symlink_target(), an override of a new Path._symlink_target()
hook. Relative destinations keep their existing meaning: a rename()
destination is a sibling, a symlink_to() target is stored verbatim and
stays relative. The string is not percent-encoded on the way in, so a
destination that legitimately contains a literal %20 — or a path object
built by a consumer that already encoded it — is not encoded twice.
readlink(), unlink(), rmdir() and hardlink_to() never had this
defect. copy()/move() are deliberately unchanged: their str
destination is still parsed as a URI, which is what makes a cross-scheme
copy("s3://bucket/key") work. See docs/divergences.md.
0.9.2 - 2026-08-16
Fixed
MemPath.open("w")on an existing directory raised nothing and destroyed the tree._open()assigned over whatever was already at the name, soMemPath("dir").write_text(...)replaced a directory and everything under it with a file — silently, in the class the docs present as the reference exemplar for extending this library, and the class used as a mock filesystem in tests. It now raisesIsADirectoryError, aspathlibdoes. The same guard covers the virtual root for every mode, which used to grow a bogus""key in the backend on"w"/"a".- A
MemPathrouted through a file raisedTypeError._parent_container()walked ancestors withpath not in parent, which on abytearrayancestor evaluates"seg" not in bytearray. ThatTypeErrorsails past theOSErrorguard inStat._st_mode(), so evenMemPath("file.txt/sub").exists()crashed instead of returningFalse— a routine shape in glob/walk and inmkdir(parents=True). It now raisesNotADirectoryErrornaming the offending ancestor. Pathnamehad no__eq__/__hash__, so subclasses compared by identity. Every pure subclass that didn't hand-write equality — includingMemPath, and any downstream class subclassingPathdirectly — was unusable as a dict key or set member, andis_relative_to()(which decides via==against freshly built parents) always returnedFalsewithout raising. There is now a default keyed on(type(self), tuple(self.segments)).LocalPath,PosixPathnameandWindowsPathnameare unaffected —pathlib.PurePathprecedesPathnamein their MRO and keeps its own equality — as isUri, which defines one.is_relative_to()normalized astrargument by joining it ontoself.Pathnameusedcls(self, other)andUriusedUri(self, _ROOT, other), soUri("a/b").is_relative_to("a")compared against"a/b/a"/"/a"and answeredFalsewhileUri("a/b").is_relative_to(Uri("a"))answeredTrue— the str and object forms of the same call disagreed. Both now parseotherstandalone, as CPython does. The generic side usesself.with_segments(other)so a subclass's per-instance state (MemPath's backend) survives the normalization.Uri("http://h/a/b").is_relative_to("/a")is stillTrue.LocalPath.chown()leakedAttributeError/LookupErroron Windows.shutil.chownexists there whileos.chowndoes not, so an int id raisedAttributeErrorand a name raised a misleadingLookupError: no such user(with nopwdmodule, every name misses whether or not the user exists). It now raisesNotImplementedError, whichdocs/divergences.mdalready promised and which every other unsupported capability here raises. The all-unchanged no-op still succeeds.
0.9.1 - 2026-08-04
Added
symlink_to(..., force=True)— replace an existing entry at the link path instead of failing. SFTP has no atomic "replace symlink", so every consumer was re-implementing the unlink-then-symlink dance. Implemented at thePathlayer over a new_symlink_to()backend primitive (the same wrapper/primitive split as_mkdir/mkdir), so all backends inherit it. Removes a non-directory entry only, and is documented as non-atomic.chown(uid=None, gid=None)over a new_chown()backend primitive.chownwas the one POSIX permission attributestat()could read but nothing could write back.Nonemeans "leave unchanged"; the normalization lives onPathso each backend receives an already-canonical pair rather than re-deriving the mapping (os.chownwants-1, SFTP omits the field).chmod()accepts a string octal mode ("0755"), normalized with an explicit base-8 parse. A mode string outside[0-7]raises rather than being coerced —int("0755")in decimal is a different mode, which is exactly the wrong-but-plausible failure this guards.
Changed
- Adopted
black, pinned to the 3.9 floor;src/andtests/reformatted.
0.9.0 - 2026-07-29
Added
UriPath.host_fspath(), and__fspath__()now succeeds for schemes with_host_filesystem_path = True(currentlysftp:) instead of unconditionally raisingNotImplementedErrorfor any non-file:scheme.os.fspath()has two consumers -- "open this locally" (where 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) -- and only the second is safe for a scheme likesftp:.host_fspath()is the unambiguous accessor for that second case: it never falls back to local-path semantics the way__fspath__does for thefile:branch.progresscallback forPath.copy()/BinaryOpen.copy().BinaryOpen.copy(target, *, progress=None, chunk_size=shutil.COPY_BUFSIZE)now streams in caller-sized chunks and, whenprogressis given, callsprogress(bytes_copied, total_size)after each chunk (total_sizeis the source'sstat().st_sizewhen available, elseNone).Path.copy(target, ..., progress=None)wraps this with per-file identity:progress(path, bytes_copied, total_size), so arecursive=Truecopy reports which file is being streamed alongside its byte progress, not just an anonymous byte stream.progress=None(the default) is behaviorally identical to before this change -- no per-chunk overhead, sameshutil.copyfileobjbytes-on-wire.SftpPath's asyncssh concurrent fan-out (copy(recursive=True)) does not invokeprogress-- native/batch transfer paths are out of scope for this first cut (seedocs/divergences.md's "Deliberate extensions" section).PathSyncercan now create symlinks ontargetinstead of always rejecting a symlink source. New constructor kwargsymlink_mode("preserve"default,"reject"opt-out), consulted only whenfollow_symlinks=Falseandsource.is_symlink()(with the defaultfollow_symlinks=True, symlinks are still resolved during traversal, unchanged)."preserve"creates a matching symlink ontargetusing the exact raw, unresolved target stringreadlink()returned -- dangling links and relative targets included, never validated or resolved againstsource's parent. Iftarget's implementation has nosymlink_to()at all (every backend exceptLocalPathandSftpPath),"preserve"mode raisesNotImplementedErrorthrough the existingignore_error/hook()flow, same as every other sync branch -- not a silent skip. NewSyncEvent.Symlinkenum member.- Optional backend-native checksum protocol
(
pathlib_next.protocols.checksum.NativeChecksum,checksum(algorithm="md5") -> str). APathsubclass may implement it to compute a file digest server-side instead of streaming the content throughopen("rb")-- implemented onSftpPathagainst the OpenSSHcheck-file@openssh.comSFTP protocol extension (paramiko backend only; the asyncssh backend has no equivalent client-library support and correctly falls back). Not part of the basePath/PathnameABC -- a plainPathhas no.checksumattribute at all. Implementations MUST raiseNotImplementedError(never return a value) when they can't produce a genuine digest under the requested algorithm -- this is what keeps two checksums from ever being compared under a mismatched algorithm, or trusting something hash-shaped but not a real content hash (e.g. S3's ETag for a multipart upload, deliberately not implemented here for exactly that reason -- seedocs/divergences.md). Also adds a companionsupported_checksums() -> frozenset[str]advisory query (defaultfrozenset(), never raises);SftpPath.supported_checksums()is a real per-connection probe against the server (paramiko exposes no cheaper way to know), cached per connection. PathSyncer's default checksum policy now prefers native digests on both sides when available, falling back to streaming (utils.checksum.md5/the new genericutils.checksum.stream) when either side can't produce one under the same algorithm -- never a native-vs-streamed comparison under a mismatched algorithm. A caller-suppliedchecksumcallable is unaffected (invoked exactly as before). Newutils.checksum.native(path, algorithm) -> str | Nonehelper: tries the protocol, returnsNone(never raises) if unsupported.PathSyncer(..., quick_check=True)(new constructor kwarg, defaultTrue): for a sync pair where at least one side is non-local, a metadata-only pre-check (st_size+st_mtime, already-cached, no extra round trip) skips the checksum call entirely when both match; a mismatch always falls through to a real checksum rather than being treated as "changed" on its own. Local-to-local pairs never engage this pre-check.quick_check=Falserestores always-checksum behavior for non-local pairs.
Changed
PathSyncerdefault behavior change:PathSyncer(follow_symlinks= False).sync()on a symlink source previously always raisedNotImplementedError. It now creates a matching symlink ontargetby default (symlink_mode="preserve"); passsymlink_mode="reject"to restore the old unconditional-raise behavior exactly.- The
uriextra now requiresnetimps>=0.2.0(alongsideuritools).Source.is_local()(uri.source) delegates its "is this address mine" check tonetimps.is_local_address(), which enumerates real network interfaces (netimps.get_interfaces()) instead of the previoussocket.getaddrinfo(socket.gethostname(), None)-based approach, which missed addresses not tied to the resolvable hostname (VMs, containers, VPN interfaces, additional NICs on a multi-homed host). The hostname->address step now usesnetimps.resolve()(both"a"/"aaaa"record types;hostis local if ANY resolved address is) instead ofsocket.gethostbyname()--resolve()'s default backend chain (dnspython, then the OS resolver viagetaddrinfo(), thennslookup) covers the same hosts-file/NSS/DNS resolutiongethostbyname()did, and additionally never raises for a name that simply doesn't resolve (resolve()'s contract: always a list, empty on genuine failure) --gethostbyname()raisedsocket.gaierrorfor that case.utils. get_machine_ips()(the old implementation's helper, unused elsewhere in this library) is removed.SftpPath's two backends andFtpPathnow resolve their default ports (22, 21) vianetimps.get_default_port()instead of three separately hardcoded literals.
Fixed
Source.is_local()crashed on a bare IPv6-literalhoststring.Source(scheme, userinfo, "::1", port)(a supported direct-construction pattern --Source's fields are publicNamedTuplefields) raisedsocket.gaierrorinstead of returning a result, becausesocket.gethostbyname()(this method's original hostname-resolution step) is IPv4-only.hostbracket-literals arriving via the normalSource.from_str()/Uri()construction path were unaffected (_decode_host()already parses those into a realIPv6Addressbeforeis_local()ever sees a string).is_local()now triesnetimps.try_parse()first (handles any IP literal directly, string or not) before falling through to hostname resolution.uri.source.Sourceleaked the password instr()/repr().Source.__str__()calleduricompose()with the rawuserinfo(password included) -- a genuinely valid, connectable URI string, not just a debug rendering -- andSourcehad no custom__repr__at all, so theNamedTupledefault rendered every field verbatim too.repr()is what a traceback frame renders, so aSourceanywhere on a failing call stack leaked the credential into logs, even thoughUri.__str__()already redacted. Both now redact the password fromuserinfothe same wayUri.__str__()does; the actual data (.userinfo,.parsed_userinfo(),["userinfo"]) is unaffected, only display is sanitized. This is a behavior change, not purely additive:str(source)(orf"{source}") no longer reconstructs an authenticated URI -- verified nothing in this codebase relied on that (every real connection site reads individualSourcefields, never whole-objectstr()), but a downstream caller that did would need the newSource.as_str(sanitize=True)method instead:sanitize=True(the default, matching__str__) redacts the password;sanitize=Falseis the full, credentialed round trip -- same name/kwarg asUri.as_uri(sanitize=), so both classes work the same way.
0.8.6 - 2026-07-26
Fixed
PathSyncer.sync()raisedTypeErrorinstead of honoringignore_error.sync()'signore_errorparameter defaulted to the boolFalseand the symlink branch called it directly, soPathSyncer(ignore_error=True).sync(src, dst)on a symlink source raisedTypeError: 'bool' object is not callablerather than the intendedNotImplementedError. The parameter now defaults toNone, meaning "use the policy given to__init__" -- it no longer silently shadows a constructor-supplied policy -- and every branch consults one resolved callable. Passing a callable explicitly behaves exactly as before.Path.copy()now accepts a bool forignore_error, matchingPath.rm()'s bool-or-callable contract. Previously only a callable orNonewas handled, socopy(recursive=True, ignore_error=True)broke as soon as a child copy failed.Nonekeeps its documented meaning (fail on the first error), and a callable remains a notification hook whose return value is not consulted, so existing handlers such aserrors.appendare unaffected.- Downstream
Pathsubclasses resolved stdlibpathliboperations instead of pathlib_next's. Concrete path classes mix apathlibclass withpathlib_next.Path, so the MRO decided which library implemented a method -- and which one won changed with the interpreter version. On Python 3.14 the new stdlibcopy()/move()displaced ours, crashing withAttributeError: ... has no attribute '_copy_from'on non-local backends and silently applying stdlib's different timestamp semantics on local ones (making mtime-based syncs converge on 3.14 but never on <=3.13). In the opposite direction, older stdlib lacked keywords this library's protocols promise:exists(follow_symlinks=)(3.12+),read_text/write_text'snewline=(3.13+), andrglob'sinclude_hidden=/recursive=/dironly=extensions (never in stdlib), all raisingTypeErroron the 3.9 floor.Path.__init_subclass__now re-asserts the pathlib_next implementation ofcopy,move,exists,rglob,read_textandwrite_textfor any subclass that would otherwise inherit stdlib's, so downstream implementers get correct behavior without hand-written forwarding methods. A subclass or mixin that defines one of these operations itself is never displaced.
Changed
utils.as_error_handler()centralizesignore_errorbool-to-callable normalization. Callable arities remain deliberately different per call site (rm->(error, path),copy->(error),PathSyncer->(error, source, target, event)); only the bool case is normalized, so no public signature changed.
0.8.5 - 2026-07-26
Fixed
LocalPath.copy()andLocalPath.move()resolved to the incompatible stdlib implementations on Python 3.14. Python 3.14 added methods with those names ahead ofpathlib_next.PathinLocalPath's MRO, so calls using pathlib_next extensions such asoverwrite=orrecursive=failed withTypeError.LocalPathnow routes both methods explicitly through the pathlib_next implementations on every supported Python version.- Generic paths now follow Python 3.14's updated
PurePath.with_suffix(".")behavior while retaining the earlierValueErrorbehavior on older Python versions.
Changed
- Documented that stdlib inheritance is deliberately local-only:
LocalPathis a realpathlib.Path, while URI, in-memory, and other virtual implementations inherit the generic pathlib_next contracts without claiming local-filesystem semantics.
0.8.4 - 2026-07-18
Changed
- Internal tidy-up (no behavior change): removed dead imports (
Sourcein theaz/gs/s3schemes,ioinutils.archive,abcinutils.stat), dropped unused local variables, and de-f-string'd a placeholder-less message. TheRepoBackendre-exports from thegithub/gitlabschemes are retained (public API, marked# noqa).
0.8.3 - 2026-07-18
Changed
AsyncsshSftpBackenddefaultmax_concurrencyraised 8 → 16. A 128-file loopback recursive-copy/rm sweep ofmc ∈ {1,2,4,8,16}(median of 3) showed recursive copy improving monotonically with concurrency (mc=8→16 ≈3% faster on top of mc=1→8 ≈1.13x) and recursive remove flat within noise, with 16 fastest-or-tied and well inside asyncssh's SFTP request window. Exposed asAsyncsshSftpBackend.DEFAULT_MAX_CONCURRENCY; passmax_concurrency=to override. Loopback evidence only — a high-latency remote link may warrant a different value; 16 is a safe modest default, not a tuned optimum.
0.8.2 - 2026-07-18
Fixed
SftpPathcould not be imported or used with an asyncssh-only install (noparamiko).uri/schemes/sftp/__init__.pyimported._paramikoeagerly at module load, and_asyncssh.pyimported the_DEFAULT_SSH_CONFIGsentinel from._paramiko, so merely importingSftpPath(or theAsyncsshSftpBackend) requiredparamikoeven when the caller only wanted the asyncssh backend from thesftp-asyncextra. The paramiko-free bits (the sentinel + config-path normalization) moved to a new_sshconfigmodule, and the paramikoSftpBackendis now imported lazily (via_probe_paramiko, mirroring_probe_asyncssh) only when actually selected.SftpBackend/_DEFAULT_SSH_CONFIGremain importable from the scheme package (PEP 562__getattr__) for backward compatibility.PATHLIB_NEXT_SFTP_BACKEND=paramiko(orautowith neither library) now raises a clearImportErrornaming the missing extra instead of a bareModuleNotFoundErrorat import time. Regression test added (test_sftp_scheme_imports_and_resolves_without_paramiko, runs in a paramiko-masked subprocess).
0.8.1 - 2026-07-16
Fixed
LocalPath.walk()/rm()raisedTypeError: cannot unpack non-iterable DirEntry objecton Python 3.11/3.12. Those stdlib versions define their ownpathlib.Path._scandir()(returning rawos.scandir()DirEntryobjects), which sits ahead of this project's_scandir()inLocalPath's MRO and silently shadowed it -- breaking the(name, FileStat|None)contractwalk()/glob()/rm()expect.LocalPathnow defines its own_scandir()explicitly, reusing eachDirEntry's cachedlstat()so the perf win from_scandir()unification is preserved. On 3.12+, stdlibpathlib.Pathalso defines its ownwalk()ahead of ours in the MRO, and that stdlibwalk()treatsself._scandir()'s return value as a context manager (with scandir_it:) -- our own_scandir()is a plain generator, so stdlib'swalk()raisedTypeError: 'generator' object does not support the context manager protocoleven with the override above.LocalPathnow also overrideswalk()explicitly, routing to this project's own implementation regardless of Python version. Introduced in 0.8.0 (8cdbefa), exposed on the CI 3.11/3.12 legs.Test No-ExtrasCI job was red.tests/test_smoke.pyunconditionally constructed anhttp:///sftp://UriPathin two tests, requiringrequests/paramikoeven though the no-extras job installs neither; a third test wrongly assumedS3Pathrequiresboto3to register (it only needsbotocore, imported lazily inside a method). The two hard tests nowpytest.importorskiptheir extra; theS3Pathcheck now probes forbotocore. Introduced in 0.8.0 (94bd545/8cdbefa), fixed with the expected skip count (2) verified in a real no-extras venv.- Importable on a clean Python 3.9 install.
pathlib_next.utilsusedtyping.ParamSpec(3.10+), falling back totyping_extensions.ParamSpecand then to a baretyping.TypeVar. ATypeVarhas no.args, so the*args: K.argsannotations raisedAttributeError: 'TypeVar' object has no attribute 'args'at import time, makingimport pathlib_nextfail on 3.9 whenevertyping_extensionswas absent. Sincetyping_extensionsis not a runtime dependency, this broke a plainpip install pathlib_nexton 3.9. The final fallback is now a minimalParamSpecshim providing.args/.kwargs, so no runtime dependency is added and 3.10+ keeps usingtyping.ParamSpecunchanged.
0.8.0 - 2026-07-13
Added
uripathcommand-line tool (pathlib_next.tools.uripath) for reading, writing, copying, removing, and syncing local or URI-backed paths.-works as stdin/stdout for byte-stream operations.- Recursive benchmark probes for local, memory, object-store, and SFTP backends, including provider call-shape rows for recursive deletes.
- Provider-native recursive delete overrides for
S3Path,GsPath, andAzPath, with bucket/container-root guards. git:convenience dispatch over the existinggithub:/gitlab:providers, plus explicitgit+github:andgit+gitlab:forms for self-hosted or enterprise instances.git:only auto-detects publicgithub.com/gitlab.com; ambiguous hosts now raise a clearValueErrornaming the explicit alternatives.- HTTP write support (
PUT, customizable toPOSTor other verbs viawrite_methodconfiguration orwith_session()) forHttpPath. - HTTP delete support (
DELETEforunlink()andrmdir()) forHttpPath. - Comprehensive HTTP exception mapping in
HttpPathtranslating client/server/timeout/connection errors into standard built-inOSErrorsubclasses (FileNotFoundError,PermissionError,FileExistsError,TimeoutError,ConnectionError,NotImplementedError, or genericOSError). - Dynamic loading of custom URI scheme plugins via standard Python packaging entry points under the
"pathlib_next.schemes"group, allowing third-party package extensibility. - Lazy-loading for all builtin scheme implementations (s3, sftp, http, etc.) to significantly reduce start-up and import overhead when heavy libraries are not needed.
- MD5 and SHA-256 checksum helpers in
pathlib_next.utils(md5andsha256). - Optional
checksumparameter inPathSyncer, defaulting to the newmd5helper. - Recursive directory copying via
Path.copy(recursive=True). - Support for recursive folder moves falling back to recursive copy + recursive delete when
renameis not supported. - Archive utilities
make_archiveandunpack_archivesupporting ZIP and TAR formats using memory-efficient chunk streaming. - Hierarchical test contracts:
PurePathContract(pure path operations) andReadPathContract(read-only path operations), allowing contract-based verification of read-only and memory/archive paths. - Contract test suites wired for
DataUri,ZipUri,TarUri, andHttpPath. - Dedicated unit tests for
Path.walk(),samefile(), andStatdevice queries. - Comprehensive runnable examples in
examples/for URI schemes: offline (data_and_archive.py) and environment-variable configured ones (ftp_listing.py,webdav_roundtrip.py,s3_listing.py). - Split monolith API reference documentation into per-module pages (
path,uri,mempath,utils,testing). - Complete docstring coverage for all public methods/properties across
Pathname,Path,Uri,UriPath, and protocols, and configuredmkdocsto enforce docstring presence (show_if_no_docstring: false). - Detailed documentation of contract testing levels (
PurePathContract,ReadPathContract,PathContract) in the extending guide. - In-process real-server contract tests for
FtpPath(pyftpdlib),DavPath(wsgidav/cheroot), andS3Path(moto mock_aws):TestFtpContract,TestDavContract,TestS3Contractrun the fullPathContractsuite against live local servers. ftp_server,dav_server, ands3_serverpytest fixtures inconftest.pyserving ephemeral in-process servers with pre-populatedfixture_treecontents.- Entry-point declarations in
pyproject.toml(pathlib_next.schemesgroup) for all built-in schemes, enabling pip-installed external packages to auto-register custom schemes. - Plugin discovery tests in
tests/test_plugins.pycovering_load_entry_point,_load_builtin_scheme, andget_scheme_clsintegration. - Property-based tests (
hypothesis, newdevextra) intests/test_properties.py: URI parse/format round-trip identity, join associativity, and parity withpathlib.PurePosixPathforsegments/name/relative_to/match/is_relative_to. ZipUri/ArchiveUriarchive handle registry: independently-constructedUriPath("zip:...")/"tar:..."instances pointing at the same outer archive now share one_ArchiveBackend(keyed by backend class + outer URI, aweakref.WeakValueDictionary) instead of each opening its own handle -- fixes stale reads and out-of-sync writes across separately-constructed instances. The backend closes its handle automatically (__del__) once every referencing path is garbage-collected.- Full
ZipUriwrite support:unlink(),rmdir()(empty-dir check, mirrorsS3Path.rmdir()),rename()(renames a directory's nested entries too), and overwriting an existing entry's content (previouslyopen("w")on an existing entry silently appended a duplicate zipfile entry instead of replacing it). All four go through a new safe full-archive rewrite (_ZipBackend._rewrite) sincezipfilehas no in-place entry mutation: writes to a temp file beside the outer archive, then atomically replaces it (os.replace). Requires a local (file:) outer archive, same as existing new-entry writes. archive:catch-all URI scheme: auto-detects zip vs. tar for the outer archive (filename extension first, then a magic-byte sniff shared withunpack_archivevia the newutils.archive._detect_formathelper) instead of requiring the caller to know the format up front. Explicitarchive+zip:/archive+tar:forms skip detection outright.archive:...!/xandzip:...!/xpointing at the same outer archive share one backend (same registry aszip:/tar:), and write support (new/overwritten entries,unlink/rmdir/rename) works througharchive:exactly as it does throughzip:when the detected format is zip and the outer archive is local -- tar-detected instances correctly raiseNotImplementedErroron any write attempt.- New
gs:(Google Cloud Storage) andaz:(Azure Blob Storage) URI schemes (GsPath/AzPathinpathlib_next.uri.schemes.gs/.az, withGsBackend/AzBackendfor credential/endpoint override):gs://bucket/key/pathandaz://account/container/key/path. Both support fullPathContract(read/write/list/delete/rename), reusing the prefix-emulation directory semantics ofS3Path(no real directories;is_dir()checks for keys under"<path>/",mkdir()writes a zero-byte"<path>/"marker,rmdir()requires empty). Wired toPathContractagainst faithful in-process fake JSON/XML REST API servers (gcs_api_server/gs_server,az_api_server/az_serverinconftest.py), plus scheme-specific unit tests. Newexamples/gs_listing.py/examples/az_listing.py(env-var gated, fail-soft). LikeS3Path, both cache one service client per backend instance (thread-safe for both SDKs). Report no mtime (st_mtime=0, documented divergence). Each is its ownpyproject.tomlextra:gs(google-cloud-storage) andaz(azure-storage-blob).rename()uses server-side copy+delete (same bucket/container only) instead of the generic download+upload+deletemove()fallback. - New
github:/gitlab:read-only URI schemes (GitHubPathinpathlib_next.uri.schemes.github,GitLabPathinpathlib_next.uri.schemes.gitlab, sharing a private_RepoApiPathbase and a plain-requestsRepoBackend, no PyGithub/python-gitlab SDK):<scheme>://host/owner/repo/path/in/repo?ref=<ref>,refalways optional in the query string.GitHubPathlists via the contents API (one call gives type/size for a whole directory) and reads file bodies via therawmedia type;GitLabPathlists via the tree API (no size, so only directory entries get a stat hint) and reads via the files/rawendpoint, resolving+caching the project's default branch itself whenrefis omitted (GitLab's file endpoints -- unlike its tree endpoint -- 400 ifrefis missing, confirmed live against gitlab.com).hostdefaults to the public SaaS host; any other host is treated as GitHub Enterprise (https://{host}/api/v3) or a self-hosted GitLab (https://{host}/api/v4). Auth via a bearer token (RepoBackend(token=...)) or URI userinfo. Both reuse thehttpextra (no new extra added). Wired toReadPathContractagainst faithful in-process fake API servers (github_api_server/gitlab_api_serverinconftest.py), plus scheme-specific unit tests (ref propagation throughiterdir(), rate-limit/error translation, GitHub Enterprise API-base derivation, GitLab dir-vs-file stat disambiguation). Newexamples/github_listing.py/examples/gitlab_listing.py. - New
sftp-asyncextra: anAsyncsshSftpBackend(asyncssh, async internally, bridged to a sync API through one shared background event loop) alongside the existing paramiko-basedSftpBackend. Auto-selected whenasyncsshis importable (paramiko remains the fallback); override via thePATHLIB_NEXT_SFTP_BACKENDenv var ("paramiko"/"asyncssh"/"auto") or aSftpPath._default_backend_clssubclass hook -- precedence, highest to lowest: explicitbackend=kwarg >_default_backend_cls> env var > auto-detect.PATHLIB_NEXT_SFTP_BACKEND=asyncsshwith the package missing raises immediately rather than silently falling back. Connections are cached per(backend, source)(no thread dimension needed -- one shared connection serves concurrent calls from any calling thread, unlike paramiko's(backend, source, thread)cache). Works on Python 3.9 too via a verified version pin (asyncssh<2.22; currentasyncsshneeds >=3.10) resolved automatically throughpyproject.tomlenvironment markers -- no code branching. NewSftpPath.symlink_to()/readlink()(both backends -- core SFTPv3 operations) andhardlink_to()(asyncssh backend only; paramiko'sSFTPClienthas no hard-link operation, so it raisesNotImplementedErrorimmediately with no server round trip).chmod(follow_symlinks=False)now works on the asyncssh backend (native support) while still raisingNotImplementedErroron paramiko (nolchmodequivalent). This is additive, not a performance change -- the (separate, unscheduled) concurrent-fan-out work that would actually exploit asyncssh's pipelining remains future work.
Changed
- Recursive
Path.rm()now deletes bottom-up using non-following listing metadata where available, avoiding traversal through directory symlinks and reducing extra stat calls for metadata-rich backends. - Asyncssh SFTP recursive copy/remove now use native bounded async helpers for ordinary files/directories instead of recursing through sync path methods on the bridge loop.
PathSyncerreuses child metadata during tree sync when that metadata is consistent with the active symlink-following policy.- Replaced the third-party
htmllistparseandbs4directory listing scraper dependencies with a hand-rolled, zero-dependencyhtml.parser.HTMLParsersubclass (_DirectoryListingParser), dropping both from thehttpextra inpyproject.toml. Verified equivalent output (name/size/modified, both Apache-<pre>and nginx-<table>formats) against the replacedbs4+html5lib+htmllistparseimplementation, and 2.9x-6.2x faster depending on format/listing size (benchmarks/bench.py's8/9entries benchmark the new parser alone going forward, since the old implementation no longer exists in the tree). - Matrix expansion in GitHub Actions CI to test Python 3.10, 3.11, and 3.12 (on Ubuntu).
- Added a "no-extras" CI job to run tests without optional dependencies installed.
PathSyncer.log()now logs throughlogging.getLogger("pathlib_next.sync")atINFOinstead of callingprint()-- stdout consumers must configure logging (e.g.logging.basicConfig()) to see sync progress again.EVENT_LOG_FORMATswitched fromstr.format({event}) to%-style placeholders to match, andlog()remains overridable for custom routing.SyncEventmembers are now numbered sequentially (previously a mix of explicit ints andenum.auto(), which raised aDeprecationWarningon Python 3.13). Values are not part of any documented/persisted contract.- Optimized performance across pure paths and URIs:
- Cache
Uri.segmentsin a slot to avoid re-splitting the path string on every access. - Cache
Uri.suffixandUri.stemin slots. - Optimize
Source.__bool__to use lazy index accesses and avoid tuple iteration. - Short-circuit
Query.__new__when the input is already a matchingQueryinstance. Uri._parse_uri()/Source.from_str(): one-pass component extraction fromuritools.urisplit()'s raw fields instead of calling its sevenget*()accessors, each of which independently re-rpartitions the authority string and re-decodes. Ported (not reinvented) fromuritools.SplitResult's own property/getter logic -- including one of its quirks, reproduced on purpose (seeuri/source.py::_split_authority) -- and verified equivalent by fuzzing 20,000+ generated URIs against uritools as the oracle (tests/test_properties.py, which stays the enforcement mechanism, not just a one-time check).Uri._format_parsed_parts()/DavPath._wire_uri(): direct string assembly instead ofuritools.uricompose()'s full re-validation, for the same reason and with the same fuzzing rigor (uri/source.py::_compose_uri) -- both bypass a general-purpose library's necessarily-defensive validation only where the input is already known-canonical (parsed or otherwise internally normalized), not for arbitrary/untrusted URIs.uritoolsitself is unchanged as a dependency and remains the parsing/composing engine underneath both fast paths -- a hand-rolled RFC 3986 implementation was evaluated and rejected (verdict: slower or not worth the permanent edge-case-ownership cost). Measured on.venv/3.12.10, unique URIs per iteration (a repeated-URI microbenchmark flatters by masking real per-call cost): the fullUri(unique_url).as_uri()round trip (parse + compose, both changes) is ~20-25% faster; the parse side alone, isolated fromUri.__new__'s slot-initialization overhead (unaffected by this work), is ~17-30% faster on its own (seebenchmarks/bench.py's1b/1centries).- New
Path._scandir()/UriPath._scandir()protocol: schemes whose listing call already returns type/size/mtime for every child (HTML directory index, WebDAV PROPFIND, SFTPlistdir_attr, FTP MLSD, an S3list_objects_v2page) can now yield(name, FileStat)pairs directly, andwalk()/glob()answeris_dir()from that instead of astat()round trip per entry -- a remote-tree walk goes from O(entries) requests to O(dirs).HttpPath,DavPath,SftpPath,FtpPath, andS3Pathall adopt it;_listdir()/iterdir()remain fully supported for schemes that don't override_scandir()(no behavior change, no win). On the localhttp_serverbenchmark fixture, HTTP glob/walk over the fixture tree are ~89-94% faster than the already-optimized pre-_scandir()baseline (seebenchmarks/bench.py).HttpPathalso drops its_isdirinstance-cache slot and itsis_dir()/is_file()overrides (now derived generically fromstat(), like every other scheme) in favor of a single-use stat hint seeded by_scandir();DavPath's now-redundantiterdir()/is_dir()/is_file()overrides are removed for the same reason. - Breaking (pre-1.0, no compat shim kept):
uri/schemes/module naming convention -- every module is now named after the main URI scheme it implements (TLS/secondary variants live with their main scheme).webdav.py->dav.py; import frompathlib_next.uri.schemes.dav(the oldpathlib_next.uri.schemes.webdavpath no longer exists).archive.py->archive/package (_base.pyshared machinery,zip.py,tar.py) -- import-compatible for free,pathlib_next.uri.schemes.archivestill resolves (now the package) and re-exportsArchiveUri/ZipUri/TarUri.sftp.py->sftp/package (_paramiko.pyholds the existing paramiko-backedSftpBackend;__init__.pykeepsSftpPath/BaseSftpBackend) -- same free import-compat,pathlib_next.uri.schemes.sftpstill resolves and re-exportsSftpPath/BaseSftpBackend/SftpBackend. Prepares the layout for an upcoming second (asyncssh) backend;SftpBackendgained adefault()classmethod factory soSftpPath._initbackend()doesn't need to importparamikoitself. SftpPath's connection caching moved from an external cache wrappingbackend.client()calls to being each backend's own responsibility (SftpPath._sftpclientis now a trivialself.backend.client(self.source), no per-backend branching). Needed so the new asyncssh backend can use its own(backend, source)-keyed cache (see thesftp-asyncentry above) withoutSftpPathneeding to know which caching scheme applies. Behavior-affecting for customBaseSftpBackendsubclasses: aclient()override that doesn't cache internally will now be called on every_sftpclientaccess, not just on a cache miss --SftpBackend/AsyncsshSftpBackendboth cache internally, so this only matters for third-party/test-double backends.TestSftpContract's in-process test server (tests/conftest.py::sftp_server) is now asyncssh's ownSFTPServer(chrooted tofixture_tree) instead of a ~150-line hand-rolled paramikoServerInterface/SFTPServerInterface-- a client backend choice is independent of which library the test server uses (verified: a paramiko client talks standard SFTP to an asyncssh server fine).TestSftpContractitself is now parametrized across both client backends (paramiko,asyncssh).
Fixed
- Recursive delete on exact object-store keys now treats the exact object as
the addressed path before considering a
"<key>/"prefix tree, preventing accidental prefix-tree deletion forS3Path,GsPath, andAzPath. - Azure recursive delete falls back from
delete_blobs()to per-blob deletion when a provider or emulator rejects the batch API. Uri.relative_to()computed the remaining segments from the raw.segmentsproperty instead of the root-aware_segments_of()helperis_relative_to()already used --Uri("/").segmentsis the 2-tuple("", "")(an artifact of"/".split("/")), sorelative_to(<root>)silently dropped the child's only real segment (e.g.Uri("/a").relative_to(Uri("/"))produced""instead of"a"). Found by the new property-based test suite.TestSftpContract's in-process paramiko test server (tests/conftest.py::sftp_server) deadlocked every real I/O test: its_SSHServer.check_channel_subsystem_request()override returnedname == "sftp"directly instead of delegating toparamiko.ServerInterface's default implementation, which is what actually instantiates and starts the registeredSFTPServerhandler thread (handler.start()). Without it, the channel was reported "hooked up" to the client but nothing server-side ever read from or responded on it, soSFTPClient.from_transport()blocked forever in version negotiation. Fixed by removing the override (the inherited default already does exactly what the removed comment claimed it did).SftpPath._mkdir()/_open(mode="x")propagated a generic, untypedOSError("Failure")when the target already existed -- SFTPv3 has no dedicated "already exists" status code, so paramiko's server-sideconvert_errno()falls through toSFTP_FAILUREforEEXIST(unlikeENOENT, which it does map, giving a properFileNotFoundError). Both now checkself.exists()on failure and raiseFileExistsErrorto match every other scheme'smkdir/touch(exist_ok=False)contract (mirrorsFtpPath._mkdir()'s existing check-after-failure pattern). Found byTestSftpContractonce the deadlock above was fixed and it could actually run.FtpPath.stat()returnedFileNotFoundErrorfor the FTP root path"/"because_mlsd_entry()has no parent directory to query; now usesCWD /to confirm the root exists as a directory.FtpPath.rmdir()propagated rawftplib.error_perm(550) instead ofOSErrorwhen the directory was non-empty, violating the pathlib contract.FtpPath.chmod()raisedftplib.error_permwhen the server rejectedSITE CHMOD(pyftpdlib does not implement it); now converts toNotImplementedErrorsoPath.copy()silently skips the metadata step.pytest filterwarningsupdated to suppressboto3.exceptions.PythonDeprecationWarning(boto3 EOL notice for Python 3.9, inheritsWarningnotDeprecationWarning) andResourceWarningfrom daemon-thread server socket cleanup at GC teardown.- README and docs landing page were still describing the pre-0.6.0 scheme set:
the capability matrix, extras table, and quick starts now cover
data:,ftp(s):,zip:/tar:,dav(s):, ands3:(all shipped in 0.6.0/0.7.0 but previously only documented in the Schemes guide). LRU.maxsizesetter raisedTypeErrorwhen shrinking below the current fill (OrderedDict.pop()was called with thelast=Falsekwarg meant forpopitem()).DavPath.rmdir()mapped directly to WebDAVDELETE, which is recursive by spec (RFC 4918) -- it silently deleted non-empty collections instead of enforcing pathlib's "must be empty" contract like every other scheme. Now does a depth-1 PROPFIND first and raisesOSError(ENOTEMPTY) if children exist. The native recursiveDELETEis still available, and cheaper than the base class's client-side walk, via the newDavPath.rm(recursive=True)override (one request).Uri._make_child_relpath()doubled the join slash for any scheme whosepathalready ends in "/" (e.g.f"{self.path}/{name}"on an HTTP/DAV directory path produced"//name"); also now treats an empty path with an authority present as the same root as"/"(RFC 3986:"http://host"=="http://host/") instead of joining a bare, ambiguous name with no leading slash._DirectoryListingParser._RE_FILESIZE's digit class excluded,-- the<table>path strips commas from cell text before matching, but the<pre>path matches first, so a comma-thousands size like1,024matched only"1", truncatingsizeand leaking,024intodescription.- The RFC-1123 datetime bucket's trailing timezone match
(
... \d{2}:\d{2}:\d{2} .+) used an unbounded, greedy.+that swallowed the rest of the<pre>listing line, including any trailing size/description text on the same row --time.strptime()then raised on the unconverted data, silently droppingmodifiedand every field after it for that entry. Narrowed to\S+(the timezone is one token). _DirectoryListingParser's absolute-href filter was a blanketstartswith('/')-- a reverse-proxied/absolute-URL-configured server rendering every entry (not just the parent-directory link) as an absolute href got back a completely empty listing, with no fallback able to recover it. Scoped the filter to hrefs outside the listing's own directory (parsed from<title>Index of ...</title>) instead, falling back to the old blanket-drop behavior only when no title was parseable.HttpPath.stat()'s post-redirect HEAD re-fetch had no HEAD-405-to-GET fallback, unlike the pre-redirect loop -- a server/proxy that rejects HEAD outright (not just pre-redirect) surfacedPermissionErrorfor a directory that actually exists. Now mirrors the pre-redirect loop's fallback.HttpPath._listdir()now retries once with a trailing slash if the slash-less path 404s (defensive: real redirecting servers already work viarequests' default GET redirect-following, but a non-redirecting server/proxy previously had no fallback at all).HttpWriteStream.close()raised before marking the underlying stream closed on a failed upload, so a secondclose()call (context-manager__exit__cleanup, or GC viaIOBase.__del__) silently retried the PUT. Now marks closed even on failure.HttpPath.rmdir()/DavPath.rmdir()never checkedis_dir()before falling through tounlink()-- an empty directory's listing and a file whose body/PROPFIND response yields zero real entries are indistinguishable from_listdir()alone, so callingrmdir()on a file silently deleted it instead of raisingNotADirectoryError(os.rmdir()'s ENOTDIR contract).
0.7.0 - 2026-07-11
Added (new schemes, optional extras)
dav:/davs:scheme (pathlib_next.uri.schemes.webdav.DavPath): extendsHttpPathwith WebDAV (RFC 4918) PROPFIND for real stat/listdir metadata (replacing HTML-index scraping) and PUT/DELETE/MKCOL/MOVE for full read/write access. Requests go to the equivalenthttp:/https:URL;as_uri()still reportsdav:/davs:. Reuses thehttpextra, no new dependency.rmdir()is recursive by WebDAV spec, unlikepathlib.Path.rmdir()'s "must be empty" contract -- documented, not silent.s3:scheme (pathlib_next.uri.schemes.s3.S3Path,s3://bucket/key/path): read/write/list viaboto3. News3extra. S3 has no real directories --is_dir()is prefix emulation (any object key under"<path>/"),mkdir()creates a zero-byte"<path>/"marker object,rmdir()requires no other keys under that prefix.rename()uses server-sidecopy_object+delete_object(same-bucket only) instead of the generic download+upload+deletemove()fallback.
0.6.0 - 2026-07-11
Added (new schemes, stdlib-only, no new deps)
data:scheme (RFC 2397,pathlib_next.uri.schemes.data.DataUri): read-only, no backend/connection -- the entire file content is embedded in the URI (data:[<mediatype>][;base64],<data>).stat().st_sizeis the decoded payload length;iterdir()raisesNotADirectoryError(it's always a single file); write operations raiseNotImplementedError.ftp:/ftps:scheme (pathlib_next.uri.schemes.ftp.FtpPath): full read/write/list access via stdlibftplib, with a thread-keyed LRU connection cache mirroringsftp.py. Listing/stat prefer MLSD (RFC 3659); servers without it fall back to NLST (listing) and SIZE (file-only stat). Writes buffer in memory and upload via STOR/APPE onclose().chmod()uses the common but non-standardSITE CHMODextension (may not be supported by every server).zip:/tar:archive paths (pathlib_next.uri.schemes.archive):<scheme>:<archive-uri>!/<inner-path>(Java-style!/separator, URI form proposed to and confirmed by the user before implementation). The archive half is itself any absolute URI with an explicit scheme, so archives are readable straight off any other backend (file:,http:,sftp:,ftp:,data:, ...). Read is supported for both schemes. Write iszip:-only, and only for brand-new entries in a local (file:) outer archive (overwriting/deleting/renaming an existing entry would need a full-archive rewrite -- not implemented, raisesNotImplementedError).tar:auto-detects.tar.gz/.tar.bz2/.tar.xzcompression and is always read-only.
0.5.0 - 2026-07-11
Fixed (critical -- found while writing the examples)
Path("...")-- the top-level dispatcher documented in this project's own README quick start and used throughout -- silently dropped its constructor arguments on Python <3.12, leaving a blank instance that crashed withAttributeError: _drvthe moment anything touched it (e.g. the/operator). Masked on 3.12+, where the real parsing happens in__init__(called separately, with the original args, regardless of what__new__did) rather than__new__itself. Every one of the new suite's 300 tests constructed viaLocalPath(...)directly instead, so this went undetected untilexamples/local_and_mem.pyexercised the documentedPath(...)entry point end to end.
Fixed (found by the new test suite, not in the original bug list)
LocalPath.stat()/chmod()inherit directly frompathlib.Pathvia MRO and crashed withTypeErroron Python 3.9 the moment anything passedfollow_symlinks=(e.g.Path.walk()'s defaultfollow_symlinks=False) -- now shimmed withlstat()/lchmod()on <3.10, same as the existingFileUrishim (which now just delegates toLocalPath).MemPath.__init__decided whether to propagate a parent's backend withif _backend and backend is None:-- an empty (but valid) backend dict is falsy, so joining off a freshly-created, emptyMemPathsilently gave the child a disconnected new backend instead of sharing the parent's.MemPath.stat()never setst_sizefor files (always defaulted to0), breaking any size-based checksum comparison (notablyPathSyncer's typical usage).glob()'s core algorithm decided whether to recurse into the parent directory using whether the leaf segment is a wildcard, instead of whether the parent path itself contains one. Since a wildcarded leaf with a literal parent directory is the overwhelmingly common case (glob("*.py")), this always took the "recurse into parent" branch, which only degenerated back to the correct single directory when the parent has a non-empty literal name to re-match against -- true for essentially every real filesystem path except an OS root. It silently returned the wrong result onMemPath's virtual root (empty name).HttpPath.iterdir()gave every subdirectory entry an empty.name: directory-listing entries for subdirectories carry a trailing/(htmllistparse's convention), which wasn't stripped before building the child's path, andPathname.namederives from the last path segment -- empty for a trailing-slash path.SftpPath.rename()resolved a plain string target relative toself(joining it as a child, e.g."/a.txt".rename("b.txt")produced"/a.txt/b.txt") instead ofself's parent (sibling rename).
Added (test suite)
- Full pytest suite (
tests/): pure-path parity againstpathlib.PurePosixPath(test_parity_pure.py), local I/O parity againstpathlib.Path/os.walk(test_parity_io.py), a reusable filesystem-contract mixin run againstLocalPath/MemPath/FileUriand exported aspathlib_next.testing. PathContractfor third-partyPath/UriPathimplementers (test_contract.py), glob vs. stdlib ground truth (test_glob.py), URI parsing/scheme-dispatch/query/source coverage, MemPath- and SFTP-specific unit tests (SFTP mocked, no real server), HTTP tests against a real stdlibThreadingHTTPServer, andPathSyncercoverage. 300 tests, ~85% line coverage, green on both Python 3.9 and 3.13.
Added (docs)
docs/guides/schemes.md(capability matrix per scheme) anddocs/guides/extending.md(both extension tracks, with worked examples andpathlib_next.testing.PathContractusage). Rewrotedocs/index.mdand the README with a 30-second example per scheme and a capability matrix. Class-level docstrings added across the package for the rendered API reference.
Changed
examples/example.py(an unstructured scratch script) split into three focused, runnable examples:examples/local_and_mem.py(self-contained, no network),examples/http_listing.pyandexamples/sftp_sync.py(network-touching, guarded underif __name__ == "__main__", configurable via env vars, fail soft when unreachable/unconfigured).
Added
Pathname.joinpath(),Pathname.full_match()(3.13 parity, supports**matching any number of segments),Pathname.anchor/drive/root(generic derivation for non-local paths),Path.rglob(),read_text(..., newline=)(3.13 parity),Path.samefile()(defaultst_dev/st_inocomparison when the backend'sstat()provides them,NotImplementedErrorotherwise).Path.glob()/LocalPath.glob():recursive=now auto-detects (Trueif the pattern has a"**"component) instead of defaulting toFalse; explicitrecursive=True/Falsestill overrides.Path.copy(): raisesIsADirectoryErrorwhen the target is an existing directory (previously misbehaved); gainedfollow_symlinks=/preserve_metadata=kwargs, named to match CPython 3.14'sPath.copy().docs/divergences.md: registry of every deliberate behavioral divergence frompathlib, with rationale. Linked from the docs nav.
Fixed
Path.mkdir(parents=True)created intermediate parents withexist_ok=False(racy, and wrong when a parent already existed) and dropped the caller'sexist_okon the final retry.Path.touch(exist_ok=False)silently truncated an existing file instead of raisingFileExistsError(pathlib parity).LocalPath.glob()'sdironlyparameter defaulted toFalse, which made theis Nonecheck for trailing-slash directory-only detection dead code.Stat._st_mode()only caughtFileNotFoundError, lettingPermissionErrorand otherOSErrors propagate out ofexists()/is_dir()/etc. where pathlib returnsFalse. Also fixed:follow_symlinkswas accepted but never forwarded to the underlyingstat()call, sois_symlink()never actually inspected the symlink itself.MemPath._open()treated any mode other than"w"as a read, so"a"/"x"silently misbehaved; now dispatchesr/w/x/acorrectly and raisesNotImplementedErrorfor anything else.MemBytesIO.close()usedseek(0);read()instead ofgetvalue(), losing content if the caller's cursor wasn't already at position 0 when closing.MemPath.normalizedmangled".."-escaping paths (e.g."..") into"."; now normalizes against a virtual root so they clamp at the root instead.PathAndStat.__getattr__()returnedNonefor any unrecognized attribute instead of raisingAttributeError, breakinghasattr()-based logic.parsedate(None)/ an unparseable date string returned "now" instead of epoch 0, which could poisonPathSyncer's checksum/freshness comparisons for HTTP sources with noLast-Modifiedheader.HttpPath.stat()used a bareexcept:; cached_isdirfrom a response that hadn't been confirmed successful yet (including 404s); and didn't fall back to GET when a server rejectedHEADwith 405.uri.Queryno longer depends onuritools' private_querydict/_querylisthelpers (reimplemented locally against the publicuriencode()).Urijoin (_load_parts):query/fragmentare now resolved with the same "last segment that actually sets one wins" rule already used forsource(previously any segment, even one with no query/fragment, would blank out an earlier segment's). Join semantics are now documented explicitly: pathlib-joinpath-like, not RFC 3986 reference resolution,..is never resolved during join.Source.is_local()(DNS lookup) andget_machine_ips()are nowfunctools.lru_cached -- previously ran on every call.
Fixed (crash-level bugs)
MemPath.stat()/MemPath._open()returned aFileNotFoundErrorinstance instead of raising it for a missing path, causing an unrelatedAttributeErrordownstream.LRU.invalidate()calledself.lock()instead of usingself.lockas a context manager (RLockisn't callable) -- broke the SFTP client reconnect path.Pathname.match()had reversedisinstance()arguments and compared againststr(self)(which includes scheme/host forUri) instead ofas_posix().- Glob wildcard detection (
WILCARD_PATTERN, renamedWILDCARD_PATTERN, old name kept as an alias) used.match()(anchored) instead of.search(), so patterns like"foo*"weren't recognized as wildcards. Uriwas unhashable (defined__eq__without__hash__);__eq__now also returnsNotImplementedfor non-Pathname/stroperands instead of raising.Uri.is_relative_to()usedstr.startswith()on normalized path strings, so/foo/bar2was incorrectly reported as relative to/foo/bar; now compares path segments.Uri.relative_to(walk_up=True)was dead code -- an early guard raisedValueErrorbefore the walk-up loop ever ran.HttpPath.is_dir()/is_file()tested truthiness of bound methods (self._is_dir,self.is_dir) instead of calling/checking the right attribute, so both always returned truthy nonsense.SftpPath.chmod()didn't acceptfollow_symlinks=, so the inheritedlchmod()crashed withTypeError; now raisesNotImplementedErrorforfollow_symlinks=False(paramiko has nolchmod).SftpPathdefined_rename(), which nothing ever called -- renamed torename()somove()/rename()actually use SFTP's native rename instead of silently falling back to copy+unlink for every move.Uri.__init__()used a bareexcept:aroundPath.as_uri()(nowexcept ValueError:, matching whatas_uri()actually raises for relative paths) and crashed withAttributeErrorwhen constructing from anos.PathLikethat only implements__fspath__(noas_posix()).Path.rm(ignore_error=callable)never actually called the callable -- both branches of its error handler returned the callable object itself.
Fixed (Python 3.9/3.10 compatibility)
- Actual Python 3.9/3.10 runtime compatibility (CI previously only tested 3.11/3.13
and missed these):
LocalPath/Uricase-sensitivity and path-separator detection crashed on 3.9-3.11 (_flavourobject has nonormcase);open(mode="r")crashed on <3.10 (io.text_encodingis 3.10+); glob pattern compilation crashed on <3.11 (re.NOFLAGis 3.11+);FileUri.stat()/chmod()crashed on 3.9 (pathlib.Path.stat/chmodgainedfollow_symlinks=in 3.10; raisesNotImplementedErrorthere forfollow_symlinks=False). LocalPath._path_separatorsreturned the env-var list separator (;/:) instead of the path separator, and could include aNonealtsep on POSIX.
Added
tests/test_smoke.py: regression coverage for README/example snippets across supported Python versions.
0.4.1 - 2026-07-11
Fixed
- Removed explicit
[tool.hatch.build.targets.wheel]packages config that caused hatchling to fail resolvingREADME.mdduring editable installs on CI. - Converted
README.mdfrom a symlink (mode120000) to a regular file, fixinggit checkoutfailures on macOS and Windows runners. - Removed internal tooling references from committed files.
0.4.0 - 2026-07-11
Added
- Standardized repository layout and relocated examples to
examples/directory. - Configured MkDocs documentation site with dynamic API reference using
mkdocstrings. - Added GitHub Actions workflows for matrix testing (
test.yml) and release pipelines (release.yml). - Added typing marker
py.typedfor PEP 561 compliance.
Changed
- Added backward compatibility support for Python 3.9 and 3.10: added
from __future__ import annotationsacross the codebase, refactored runtime-evaluated union types to usetyping.Union, and provided fallbacks forTypeAliasandParamSpec. - Updated package requirement to
requires-python = ">=3.9".
0.3.5 - 2026-07-11
Added
- Split path into protocols that can be standalone.
- Sync error handling.
- Generic Path Protocol based pathlib implementation for URI paths with file access support for sftp, http, file schemes.