Skip to content

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() and capabilities(): 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, and capabilities(obj_or_class) lists the protocols that hold. Ask once instead of calling and catching NotImplementedError. 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 Path are 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, Move and the pure-path description Named are in pathapi.protocols, and Path is built from them. A read-only backend can be a class built from Pathname, Listing, Stat and BinaryOpen with no Path: glob, checksum.md5 and PathSyncer take it as a source.
  • Chmod is split, and so is every pair of a reader and a writer. Chown (chown()), Owned (owner(), group()), Utime (_utime()), Readlink and Symlink are protocols of their own, so supports(path, Chmod) asks for chmod() alone and an archive supports Readlink and not Symlink. Chmod still inherits Chown and Utime, so chown, _chown and _utime stay reachable from it.
  • owner(), group(), readlink(), hardlink_to(), absolute() and resolve() on every Path. A backend supplies _user_name() and _group_name(), readlink(), _hardlink_to() and _cwd(); resolve() follows links through readlink(), at most 40, and is lexical where a class has none. LocalPath keeps pathlib's own.
  • The contract suites' switches supports_listing, supports_mkdir, supports_delete, supports_utime and supports_rename default to None, which asks supports() about Listing, Create, Remove, Utime and Move, so a class without a protocol skips those tests with no switch set and the skip names the protocol. Set one to True or False on the test class to override the answer.
  • providers.primitive marks a default of a primitive whose body raises an error of its own, so supports() reads it as absent.
  • providers.derived_from(*primitives) marks a method that only wraps other primitives, so a base class can define iterdir() over _listdir() and supports() still says a subclass without _listdir() cannot list.
  • providers.notimplemented takes a reason: @notimplemented("why") marks a stub that supports() reads as absent and whose NotImplementedError carries 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-next 0.10.1 is a final release that depends on pathapi and re-exports it under the old import name with a DeprecationWarning. What to do: install pathapi and 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 call unlink() on a target whose class has no Remove (it replaces the content through open()), and move() raises NotImplementedError before copying when the source cannot be removed and has no rename() to use. Before, the first raised from the unlink() and the second left the file in two places. What to do: nothing, unless you relied on either.
  • Every Path now has a readlink(), which raises NotImplementedError where the class cannot read links, so hasattr(path, "readlink") no longer tells the two apart. What to do: ask supports(path, Readlink).
  • A write stream that commits on close (CommitOnClose, and any stream built on DiscardOnError), or the text file over one, that is garbage collected without being closed is now discarded with a ResourceWarning instead of committed, as a with block 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 (use with); a stream of your own on DiscardOnError can 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 answers None when 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.http needs no extra and no third-party library (it loads http.client and ssl; import pathlib_next still loads neither). HTTPClient(transport=None, *, headers=None, timeout=(10, 60), user_agent=None) sends request(method, url, *, data=None, headers=None, stream=False, allow_redirects=None, timeout, auth=None) through a transport and follows redirects: GET, HEAD and OPTIONS up to 30, with Authorization dropped 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 an OSError(EIO) naming the status. A transport is any object with send(method, url, *, headers, body=None, timeout, stream=False) that returns an HTTPResponse, and a close(); HTTPTransport(*, ssl_context=None, verify=True, proxies=None) is the built-in one on http.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 the OSError type pathlib raises), read_limited, json_body and drop_body (a reply read whole is held to MAX_REPLY_BYTES, 8 MiB after decoding, and the setting is assignable), peer_text, split_userinfo, server_size and is_redirect. PATHLIB_NEXT_HTTP_TRANSPORT names the transport a client builds when it is given none: builtin, or the name of one an installed distribution declares in the entry-point group pathlib_next.http_transports (requests, from the pathlib-next-requests distribution; transport_names() lists them without importing any and get_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 and pytest installed, python -m pathlib_next.http.testing --http-transports requests -q runs 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, and pathlib_next.http never imports it.
  • pathlib_next.ftp: FTPPath and FTPBackend, an FTP tree that needs no URI and no extra. FTPPath("/pub/x", host="h", user="u", tls=True) builds the path's own FTPBackend; FTPPath("/pub/x", backend=backend) shares one connection cache between paths built separately (or plugs in a BaseFTPBackend of your own), and giving both is a TypeError. 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, and close() 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.FTPPath is imported on first use, so import pathlib_next still loads neither ftplib nor ssl.
  • pathlib_next.archive: ZipPath, TarPath and ArchivePath, the members of an archive file that is any Path, with no URI and no extra. ZipPath("d/x", archive="a.zip") takes the archive as a Path, or a str or os.PathLike (a LocalPath); the segments are the member path, read from the archive root. ZipPath and TarPath fix the format, ArchivePath reads 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 other Path. str() is the member path, repr() names the archive, and as_uri() is zip:<archive uri>!/<member>. ArchivePath.backend_for() gives the handle to pass as archive=. pathlib_next.ZipPath, TarPath and ArchivePath are imported on first use.
  • pathlib_next.providers.TreePath, the base FTPPath and 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() and name_suffixes(). One path component split into stem and suffix as pathlib of the running interpreter splits it, for a class that computes those itself and must agree with Pathname.
  • pathlib_next.exceptions: PathError, the base of the exceptions the package defines. NonRelativePatternError (an absolute glob pattern) is now a PathError as well as a NotImplementedError and a ValueError, so every except clause that caught it still does. I/O failures stay the OSError types pathlib raises. PathError, NonRelativePatternError and MemPath are exported from pathlib_next.
  • pathlib_next.mempath, pathlib_next.sync and pathlib_next.testing declare __all__, so from pathlib_next.sync import * publishes PathSyncer, SyncEvent and PathAndStat and 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__ calls discard() when the with block raised, then closes. CommitOnClose inherits it and has a discard(); 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 ModuleNotFoundError or ImportError, and a name that is not in the table was never public. The URI layer, the schemes and the command line are the distributions uripath, sftppath, objpath, repopath and pathlib-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 uripath command. pathlib_next.uri (Uri, UriPath, Source, Query, every scheme) and pathlib_next.tools are gone, pathlib_next no longer exports Uri or UriPath, and import pathlib_next never looks for uritools or netimps. The package declares no extra but dev and docs: the extras uri, http, sftp, sftp-async, s3, gs and az, the entry-point group pathlib_next.schemes and the uripath script 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 raised NotImplementedError; it now answers as PurePosixPath.relative_to does, on the path's own names (no .. applied): other is a MemPath of the same backend or a str, and the result is a relative MemPath on that backend. ValueError when the path is not below other, when only one of them is rooted, or when the backends differ. walk_up=True gives the ..-prefixed form on every Python version, not only 3.12 and later. Code that caught NotImplementedError around it can call it directly.
  • copy(on_error=) and copy_into(on_error=) take rm()'s error predicate. on_error(error, path) -> bool (or a bool) is asked once for each child of a recursive copy that fails, with path the source entry: True skips it and the copy goes on, False raises the error. The same function used to mean opposite things to the two methods (lambda *a: False made rm() raise and copy() suppress every error), and a predicate written for one raised TypeError in the other. ignore_error= keeps its notify-only callable; use on_error= for a predicate. Passing both raises TypeError before anything is copied. sftp: (the asyncssh concurrent walk included), s3:, gs: and az: accept it, and PathContract gains a test that gives one predicate to rm() and copy().
  • Path.info, and Python 3.14's pathlib.Path.copy() onto any Path. Every Path (MemPath, UriPath, SftpPath, ...) has info, on every Python version: an object with exists(), is_dir(), is_file() (each with follow_symlinks=True) and is_symlink(), the shape of 3.14's pathlib.types.PathInfo. It calls stat() at most once following links and once not, and remembers the answers on that object only, so a new path.info asks again. LocalPath keeps pathlib's own on 3.14. A new hook, Path._copy_from(source, follow_symlinks=True, preserve_metadata=False), lets pathlib.Path(f).copy(MemPath("/x")) work on 3.14, where it raised AttributeError: ... has no attribute '_copy_from': it overwrites a file, raises on an existing directory, and with preserve_metadata=True copies 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 with size_known=False answers is_dir(), is_file(), exists(), walk(), glob() and rm(recursive=True), while stat() of the listed child asks the server for the real size and time, and PathSyncer, the progress total of copy() and quick_check never read the placeholder. gitlab: blobs, an Apache index with sizes like 1.2K and 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 yield FileStat(is_dir=False, size_known=False) from _scandir(); code that reads a listing's st_size or st_mtime should check size_known.
  • PathSyncer(follow_mounts=False) and uripath sync --no-follow-mounts. A mount point found below the target root (a mounted volume, a bind mount) is still synced into by default, as rsync does. With follow_mounts=False it is an error for that entry instead: OSError(EXDEV) naming it, offered to ignore_error with event TypeMismatch, and nothing behind it is listed, written or removed, in a dry run too. A bind mount aliases another directory as a junction does, and rm(recursive=True) already stops at one; this lets a sync with remove_missing=True refuse 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 generic touch() 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, None leaves that time alone), implemented by LocalPath, FileUri, MemPath, SftpPath on both client libraries (whole seconds) and FtpPath on a server whose FEAT lists MFMT (modification time only). A class that does not implement it raises NotImplementedError from _utime() and touch() leaves the file as it was, as before. PathContract gains supports_utime (default True; set it False on 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_segments and the rest). Twelve helpers that were underscore names now have public ones: pathlib_next.path.check_follow and contains, pathlib_next.utils.archive.detect_format, pathlib_next.utils._commit.CommitOnClose, pathlib_next.uri.same_authority and DerivedBackend, and in pathlib_next.uri.source compose_uri, compose_host, percent_encode, is_drive, SAFE_PATH and ERRORS. 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 lists check_follow and contains.
  • glob(recurse_symlinks=True) and rglob(recurse_symlinks=True) work. They raised NotImplementedError on 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() and utils.glob.select() take the keyword too. The default stays False; a caller that caught the NotImplementedError to fall back to its own walk can drop the fallback.
  • checksum() and supported_checksums() on s3:, gs: and az:. An object-store path now answers NativeChecksum from the digest the store keeps, with one request and no byte of the content. S3Path gives 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-object crc32, crc32c, crc64nvme, sha1 or sha256; GsPath gives md5Hash and crc32c; AzPath gives the Content-MD5 property. A multipart ETag (...-N), a composite checksum, a GCS object with a content encoding or no md5Hash, a blob without Content-MD5 and any other algorithm raise NotImplementedError, never a guess; a missing path is FileNotFoundError and a prefix directory IsADirectoryError. PathSyncer uses them without being told: the second sync() of an unchanged tree of 100 s3: objects to another prefix sent 200 GetObject before and sends none. Azure's Content-MD5 is 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) and az: (inside a container) issue CopyObject, copy_blob or the blob copy instead of reading the file and writing it back; copy(recursive=True) and move() of a prefix do so for each file. A file copy on s3: took 6 requests (a GetObject and a PutObject among them) and takes 4, of which one is the copy. The checks copy() always made run first and in the same order (the same file, a target that is a directory or holds keys, an existing target without overwrite=True); an existing target is replaced by the copy and no longer deleted first, so it is intact when the copy fails; progress is called once with the total; a file named as the root of a recursive=True copy 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: and az: 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 and seek() asks nothing until the next read. Writing spools in memory up to 4 MiB and then in a temp file, as s3: does, and uploads on close: a resumable upload in 2 MiB requests on gs:, blocks on az:. 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 raises OSError (errno.ESTALE, EIO where there is none: the object changed while it was being read) instead of returning the bytes of two objects, and one deleted meanwhile is FileNotFoundError; each open is its own snapshot, and seek(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 a gs: or az: file object open for a long time now issues a request per chunk read, and catches OSError if 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() and rm() 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 raise NotImplementedError, 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 what copy(recursive=True) and PathSyncer.sync() enter around their writes to their target.
  • Symbolic links in zip: and tar: 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, a LinkTarget) is the text it stores, and stat(), exists(), is_dir(), open() and iterdir() 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 a RecursionError, a link to a directory IsADirectoryError), and ln/x below a link was not found. A link that is absolute, leaves the archive or points at nothing is now FileNotFoundError naming the link and a loop, or more than 40 links in one lookup, OSError(ELOOP). walk() and glob() 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 answers OSError(ELOOP) to a following stat() and is an error for that entry in a copy or a sync (pass ignore_error= or follow_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, and copy(follow_symlinks=False) and a PathSyncer that keeps links recreate it. A program that read a zip link's text with read_bytes() now gets its target's content: use readlink() for the text. Writing through a link, and creating one, raise NotImplementedError. 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. Pass preserve_mtime=False to keep that. quick_check now 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, then copy()'s). It now takes one stat of each and hands the answer to the same-entry check and to the copy() 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 refusal move() made is made as before, in the same order. Nothing to change in a caller or a subclass; a class that overrides samefile() is still asked.
  • S3Path and GsPath built without backend= 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, as AzPath does 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 are same_filesystem(), pickling and copy.copy. To keep a client of its own, pass backend=S3Backend(...)/GsBackend(...). A supplied AzBackend is 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) and PathSyncer.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 inside batch() 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 sends posix-rename@openssh.com where 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 by Path.replace() first, as before. Nothing to change in a caller.
  • stat() of a github: file asks with HEAD. It was a GET of the contents object, which carries the file base64-encoded, so every stat(), exists() and is_file() of a file downloaded it. The size now comes from the Content-Length of a HEAD asked 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 (the HEAD, 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 refuses HEAD, is followed by the GET as 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 the st_mtime of a listed entry can be off by the server's offset. The divergences page and the header now say so; quick_check=False or a comparison of sizes avoids it.

Fixed

  • github: no longer reads a proxy's HTML page as the file. A raw read answered 200 with Content-Type: text/html and no X-GitHub-Media-Type header (a captive portal, a gateway's login page) returned the page as the file's content; it is now OSError(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() and PathSyncer with 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 concurrent rm()). 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") selected SUB where 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 and native=False still join a literal as typed. Nothing to change for a caller.
  • From Python 3.13 glob("name/") tests the literal as pathlib does, with os.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. LocalPath selected nothing for either. Linux and earlier versions are unchanged.
  • GsPath.rm(recursive=True) deletes in batches. It sent one DELETE per blob; it now queues them in batch requests of 100 (a prefix of 250 blobs is 3 requests), offers each key the batch refused to ignore_error once 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 on s3: and az:. Nothing to change for a caller.

0.9.13 - 2026-10-09

Added

  • copy_into(), move_into() and replace() on every Path. pathlib 3.14 has them and the generic classes did not (MemPath, UriPath, DavPath, SftpPath, ... raised AttributeError; LocalPath had copy_into() and move_into() on 3.14 only). copy_into(target_dir, ...) copies under the same name with copy()'s keywords and defaults, move_into(target_dir, *, overwrite=False) moves with move()'s, and both return the new path. replace(target) is move(target, overwrite=True) and returns target; a directory is replaced only when empty (OSError(ENOTEMPTY) otherwise, and nothing is removed). A str directory or target stays on the same backend. info is not added.
  • Pathname.is_absolute() has a default: the path has a root. It raised NotImplementedError for MemPath and every Pathname subclass that did not override it.
  • Generic paths can be ordered. sorted(), <, <=, > and >= raised TypeError for MemPath, Uri, UriPath and every custom Path. Pathname now orders two paths of the same exact type by the key its == uses (the segments; the URI text for Uri), and returns NotImplemented for anything else, as pathlib does across flavours. A subclass with its own __eq__ overrides _order_key() to match it. LocalPath keeps pathlib'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 (with fs, io and checksum), utils, utils.stat and utils.glob declare __all__ with the names src/pathlib_next/AGENTS.md documents for them. Nothing documented moved.
  • InsecureTransportWarning. A RepoBackend (github:, gitlab:, git:) that is about to send an Authorization header (its token, or one passed as headers=) to a plain http:// API root on a host other than loopback now warns with this UserWarning subclass (importable from pathlib_next.uri.schemes._gitrepo, .github and .gitlab); the request is still sent. Use an https:// api_base, or filter the warning for a trusted network.
  • SftpAuthenticationError and SftpHostKeyError. Importable from pathlib_next.uri.schemes.sftp. A refused login is the first (a PermissionError, EACCES) and a host key that is unknown, changed or refused the second (a ConnectionError, ECONNABORTED), on both SFTP backends. Catch OSError, 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 a GsPath makes (timeout in seconds or a (connect, read) pair, retry a google.api_core.retry.Retry or None for none). Without them the SDK's defaults apply, and an unreachable endpoint kept every call waiting for about two minutes. A custom BaseGsBackend adds options through call_options().
  • schemes.az.COPY_POLL_TIMEOUT. How long AzPath.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 subclasses PathContract, 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 numeric st_mtime; _scandir() agreeing with stat() for every child; nothing existing below a file; partial read(n); and rename() onto a non-empty directory raising. The switches, each documented in the module and in the guide: supports_mkdir, supports_delete and supports_move (default True: a backend with no directories, delete or move, such as TFTP, sets them False instead of marking tests as expected failures), supports_unusual_names (default True), and supports_question_mark_names (default False: 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. With supports_delete = False, copy(overwrite=True) is no longer exercised; enforces_directory_hierarchy = False also skips the rename-onto-a-directory test.

Changed

  • from pathlib_next import * publishes only the documented names. It also brought P, PN and the __future__ name annotations, and from pathlib_next.protocols import * and from pathlib_next.utils import * brought the modules they import. P, PN, K, V and the rest stay importable from the module that defines them; a script that relied on from pathlib_next import * for one of them imports it by name.
  • The constructors of Path, LocalPath, MemPath and FileStat pass mypy and pyright at their defaults. Both read Path("x"), LocalPath("x"), MemPath("/a") and FileStat(...) as constructing an abstract class (the protocol stubs had empty bodies, FileStat's fields were not declared, and Pathname's abstract members counted against the bare Path, which builds a LocalPath). The stubs now raise NotImplementedError through a call, FileStat declares its fields, Path.__new__ is annotated, and a checker treats Pathname'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_mtime is annotated float, which is what every backend reports.
  • On Python 3.14 LocalPath.copy_into() and move_into() are this library's, not stdlib's. Stdlib's overwrote an existing file, copied directories without being asked and LocalPath answered differently from every other backend. They now behave as MemPath's do: an existing target raises FileExistsError unless overwrite=True, a directory needs recursive=True, and the new path is returned. Pass overwrite=True, recursive=True for the stdlib behaviour. LocalPath.replace() stays stdlib's os.replace() on every version.
  • with_stem("") raises on 3.13 and later when the name has a suffix, as pathlib does there (MemPath("/a/c.txt").with_stem("") was /a/.txt). Before 3.13 it is unchanged.
  • walk() lists a Windows junction in filenames and does not enter it unless follow_symlinks=True, as it already did for a symlink to a directory and as pathlib.Path.walk() does on 3.14. It listed the junction in dirnames and 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 passes follow_symlinks=True. The header and docs/divergences.md no longer say that LocalPath protects a following walk from a loop; neither does pathlib.
  • utils.as_mode() and utils.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, and as_owner(True, False) was (1, 0) on 3.14 and (True, False) on 3.9. A bool is now TypeError, a mode outside 0..0o177777 (a st_mode with its type bits still passes, as os.chmod accepts it; stat.S_IMODE() drops them) and an id outside 0..2**32-1 (-1 stays "unchanged") ValueError.
  • UriPath(url) type-checks under pyright. UriPath.__new__ annotated schemesmap as dict with a default of None, which pyright rejects for every construction (UriPath("sftp://h/x")); it is now an optional mapping of scheme to class.
  • ReadPathContract has a pickle test and a supports_pickle switch. A contract subclass checks that pickle.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 sets supports_pickle = False on its test class and the test skips.
  • Uri(0), Uri(5) and other falsy non-text arguments raise TypeError. Only None and empty text are the empty URI; Uri(0) or Uri([]) used to be silently the empty URI, and Uri(5) said it got NoneType. The message now names the type that was passed. Pass None or "" for an empty part.
  • An http: directory index larger than 8 MiB raises. iterdir(), walk() and glob() 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 over pathlib_next.uri.schemes.http.MAX_LISTING_BYTES (8 MiB after decoding) is now OSError (EFBIG), checked against Content-Length before the body is read. Assign a larger number to that module constant to list a bigger index.
  • Listing an http: subdirectory and stat() of a redirecting URL send fewer requests. A directory without a trailing slash is now asked for with it first (one GET instead of a GET and the redirect it earned, per directory of a walk()), falling back to the path as given on a 404, and stat() follows the Location of the first answer instead of repeating the request that was redirected.
  • The header and the schemes guide say that only Authorization is dropped from a redirected GET/HEAD. with_session(headers={"X-Api-Key": ...}) has always been sent to the new host when requests follows a redirect to another one; nothing changed in the code. Keep a secret in Authorization or auth=, or point the path at the final URL.
  • stat() of a gitlab: file asks with HEAD. It was a GET of the files endpoint, whose reply carries the file base64-encoded, so every stat(), exists() and is_file() of a large file (and every file met by walk(), glob() or copy(), which get no size from the tree listing) downloaded it. The size now comes from the X-Gitlab-Size header of a HEAD; a reply without it, or a server that refuses HEAD, is followed by the GET of 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 / name on a fresh root, or p.parent beside p, built a backend (an http: session, a RepoBackend with 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 after root has done I/O itself, and gitlab: 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 of relative_to()) do not share; two UriPath(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 raised SystemExit(2) and SystemExit(0), although it is documented to return an int. The console script's exit status is the same; a program that called main() and caught SystemExit reads 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 ConnectionAbortedError whose 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 logger pathlib_next.sftp at 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 two stat() calls per file, not three to five. The source's type, mode and size come from one stat() and the target's existence and type from another, and two stats that carry st_dev/st_ino are compared rather than asking samefile() for stats that a FileStat backend cannot answer. On a remote scheme each is a request. Everything copy() refused it still refuses: the same file under two spellings, an existing target without overwrite=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 to on_evict (a connection cache closed the connection it was about to return). maxsize=None was a TypeError; it is now unbounded, as for functools.lru_cache.
  • FileStat(st_mode=0o644) is a file, and FileStat(st_mode=0o755, is_dir=True) a directory. is_dir was consulted only when st_mode was 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") was True (the first * matched the empty root), MemPath("/").full_match("/**") was False, MemPath("a/b").full_match("a/./b") was False and MemPath("").full_match("") was False; pathlib 3.13 answers False, 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. Uri shares the fix. A bracket expression still never matches a separator (see docs/divergences.md). A path or os.PathLike is 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.12 MemPath("a\nb").match("a/b") was True. Other interpreters are unchanged.
  • LocalPath.symlink_to("./t/") stores ./t/ on POSIX, as pathlib does; it stored t (and t//x as t/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 generic Path builds a str target with with_segments(), so a subclass with per-instance state (a MemPath backend) no longer receives a target bound to a fresh one.
  • utils.parsedate() says what it accepts. bytes, a datetime and 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() and move() are Path.copy() and Path.move() (LocalPath.copy is Path.copy): the two forwarding methods are gone, and with them one call frame. A subclass that overrode the private Path._is_junction_link() overrides is_dir_binding() instead; the alias is gone.
  • The header no longer says LocalPath.is_*() return False for any OSError on every Python version. exists() does; is_dir(), is_file(), is_fifo(), is_socket(), is_block_device() and is_char_device() are stdlib's and, before 3.13, raise for an error such as PermissionError. The behaviour is unchanged.
  • Joining a MemPath onto another keeps the left operand's tree. root / MemPath("f.txt"), root.joinpath(MemPath("f.txt")) and MemPath(root, MemPath("f.txt")) addressed the last argument's own empty backend, so .exists() was False for a file root held, and utils.glob.glob(MemPath("*.py"), root_dir=src) found nothing. The first MemPath argument now names the backend; an explicit backend= still wins. Pass backend= to place a join on a different tree.
  • MemPath applies .. to the tree. /missing/../a.txt existed, /a.txt/../d was a directory and /missing/../new was 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 MemPath append 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 of 1 and 2 to 0), 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 raises OSError(EBUSY), not FileNotFoundError. The root exists, and FileNotFoundError read 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; catch OSError if you call it on a root.
  • MemPath.segments is a tuple and an unknown keyword is a TypeError. The list was the path's own: p.segments.append("c") changed str(p) and its hash. MemPath("/", bakend=b) silently built a fresh backend. Code that mutated segments or passed a stray keyword was already wrong; read a copy with list(p.segments).
  • On Python 3.9 a copied or pickled MemPath file keeps its st_mtime. copy, deepcopy and pickle of a MemFile (and of a MemPathBackend) 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. PathSyncer stat'd every source entry (with the default follow_symlinks=True, although the listing already carried the stat) and every target entry one by one, and with remove_missing=True listed each target directory and stat'd its entries a second time: 205 entries in 6 directories cost 206 source and 206 to 411 target stat() calls. The listing's stat now answers for every entry that is not a link; a link, an entry whose listing stat is None (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. With follow_symlinks=True and remove_missing=True the target link that is removed is reported to hook and ignore_error by its own stat, not by the stat of what it points at, as it already was with follow_symlinks=False.
  • The default PathSyncer checksum 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.py too): 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 when glob(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 Windows case_sensitive=True made .. select nothing (glob("sub/../*"), rglob("..")), and case_sensitive=False joined a literal without looking for it (glob("SUB") yielded SUB, glob("nul") and glob(" ") yielded a path). . and .. stay literal, and a literal is looked for in the listing whenever case_sensitive is passed, yielding the name as stored, as pathlib does.
  • glob() checks a literal component as the running pathlib does. 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, so glob("dangling") selects a dangling symlink, as pathlib does there; glob("dang*") already did. A MemPath follows the tree for .., which makes the same patterns select nothing there on every version.
  • rglob(".") selects every entry below. It raised ValueError; pathlib globs **. glob(".") is still a ValueError.
  • glob(on_error=) is told about a missing or non-directory base whatever the first component is. glob("*") reported it and glob("**") and glob("**/*") 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") and rglob("*") 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=True keeps every directory where st_ino is 0 or None. A filesystem that reports a device but no file index gave every directory the same identity, so each looked like its own ancestor and glob("**/*.conf", bound_loops=True) returned only the top level. Such a path is walked unbounded, as the documentation says.
  • LocalPath.glob() and rglob() raise the audit events pathlib raises. pathlib.Path.glob and pathlib.Path.rglob were never raised for a LocalPath (os.scandir still audited each directory). An audit hook written against pathlib now sees the same events with the same arguments on the running version. glob(None) has no pathlib counterpart and raises none.
  • make_archive(format="tar") sizes a member from the bytes it read. The member size was stat().st_size, so a file whose backend does not know its size (an HTTP file served without Content-Length reports 0) became an empty member with no error, and a size that was too large failed with unexpected 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_archive keeps directories, times and permissions. A source with empty/ and sub/run.sh archived one member, so unpack_archive rebuilt no empty/; 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 is 0o644 for a file and 0o755 for a directory. Code that unpacks these archives needs no change.
  • unpack_archive skips a tar link that names itself. A symlink a -> a, a -> b with b -> a, or a hard link to itself ended the extraction with RecursionError and no warning. Links are resolved by a loop that follows a chain once, so such a link is skipped with the UserWarning every 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 rendered s3:/key, so a bucket, container or account named with digits addressed nothing (gs://123456/k, az://1234567/c/k, http://12345/x and sftp://u@007/x likewise). The host is now 20240101 and the URI renders as written. http://12345:80/x was already right.
  • The first unescaped : of a userinfo separates user from password. sftp://us%3Aer:S3CRET@h/p was read as user us with password er:S3CRET and rendered sftp://us:er:S3CRET@h/p, a different credential from the one written. The user is us:er and the URI renders us%3Aer:S3CRET. Source.userinfo is still the decoded user:password text (a str); 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/x parsed 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, and http://h:abc/ or http://h:-1/ made the whole h:abc the host name. A port above 65535 and a : followed by anything but digits (after a name that is not a bracketed address) are now a ValueError that does not echo the input; an empty port (http://h:/) is still no port, and port 0 is kept. A Source built by hand with a scheme that is not an RFC 3986 scheme or a port that is not 0-65535 raises ValueError when it is rendered, where Source("http", None, "h", "80/evil") used to add a path to the URI.
  • An IPv6 zone follows %25. http://[fe80::1%25eth0]/x had zone 25eth0 and an unescaped http://[fe80::1%eth0]/x was written back with a bare %. The zone is eth0, and the URI is written [fe80::1%25eth0] whichever spelling it was read from. A zone that was spelled %25 followed by nothing is now an error.
  • Source.as_str() and str(Source) compose like Uri.as_uri(). They went through a second composer: a non-ASCII host came out percent-encoded (http://b%C3%BCcher.example, now xn--bcher-kva), an empty host gave file:// and x:// (now file: and x:), 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 raised ValueError with the whole string, password included; the message now names the component (found a path). A port out of range does the same. repr() and str() of a Source also hide the whole userinfo of a github:, gitlab: or git: 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, but str(), repr(), hash(), == and sorted() of it raised UnicodeEncodeError. 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: and git: raised ModuleNotFoundError: requests at construction, s3: did the same for botocore, so such a URL could not even be parsed or joined; gs: and az: constructed and failed on the first request with No module named 'google'. Every scheme class now imports and constructs, joins, compares and renders without its library, and the first operation that needs it raises ImportError saying so and naming the extra (pip install "pathlib-next[http]"; s3, gs and az likewise). import pathlib_next.uri without uritools raises a ModuleNotFoundError that names the uri extra, pathlib_next.UriPath an AttributeError that does, and hasattr(pathlib_next, "UriPath") stays False. A program that caught ModuleNotFoundError around the construction of a path to find out whether a client is installed now gets the ImportError at the first operation, and from pathlib_next.uri.schemes import HttpPath no longer fails without requests.
  • 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 (so file: was reachable although the map omitted it), copy("file:///x") wrote a local file, and joining a UriPath argument, with_source() and a str destination 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 plain UriPath, whose I/O raises NotImplementedError); an archive path does not reuse a handle another path opened through file:. The map restricts dispatch only: it does not stop code that holds a built path or class from using it.
  • A pickled UriPath holds its URI and nothing else. The pickle of a path that had a backend carried it: the URI password, the session headers of with_session(), S3 client_kwargs and an SFTP connect_opts password were all in the bytes, a zip: path did not pickle at all, and an s3: or gs: 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 its schemesmap; 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 sets picklable = True. A program that sent a path with a supplied backend to another process and relied on the backend arriving must set picklable = True on the backend class or call with_backend() on the unpickled path. copy.copy() and copy.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 a zip: path.
  • Source.is_local() answers for the host alone and forgets. It cached per whole Source, password included, so three paths differing only in password cost three lookups and the cache kept the Source objects (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=="}) was sig=ab+cd==, which a form-style reader on the server takes as ab cd==; a base64 signature or an ISO-8601 offset (+00:00) reached it altered. It is now sig=ab%2Bcd%3D%3D, and decode() returns exactly what the encoder was given for any text. A str is still kept byte for byte, and decode() still follows RFC 3986: a + in received text stays a plus (urllib.parse.parse_qsl reads 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") read a, and an SVG payload that starts <?xml read <, with the rest filed as the URI's query; stat().st_size was 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() and with_segments() build a URI that prints. On a URI with an authority with_path("y") and with_segments("y") returned a path without its leading /, and repr(), hash() and == of the result raised ValueError; the slash is now added as the constructor adds it. with_segments() and with_path() accept bytes, PurePath and os.PathLike too, so samefile(pathlib.Path(...)) no longer raises TypeError. A str given to samefile() is still a path, not URI text.
  • Uri.query is always a Query and fragment always a str. Uri("http://h/x").query was None (so .query.to_dict() raised AttributeError) while the same URI joined with a name had ""; both are now an empty Query and "". 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) left as_uri() and normalized_path of 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.
  • Source slices and Source.from_str("file:") equals what a parse gives. source[0:2] raised TypeError; it is the tuple slice. from_str("file:") had host None where Uri("file:").source has "", so the two compared unequal.
  • A str join keeps an empty segment inside its key. Uri("http://h/d") / "x//y" was http://h/d/x/y, while Uri("http://h/d") / Uri("x//y"), Uri("http://h/d", "x//y") and Uri("http://h/d/x//y") were all http://h/d/x//y, so on an object store a key holding // could not be addressed with / or joinpath(). The four spellings now give the same URI (http://h/d/x//y), and so does a str destination of rename(), copy() and move(). normalized_path still collapses, and a name a listing yields as empty is still skipped. Code that relied on p / "a//b" collapsing, as pathlib does, strips the empty segment itself.
  • The ancestors of a UNC FileUri stop at the share root. On Windows UriPath("file://server/share/a/b").parents ended file://server/share and file://server/, which names no file system, and parent of the share root climbed out of the share. The share is the anchor, as it is for PureWindowsPath: the ancestors are file://server/share/a and file://server/share/, and the share root is its own parent. Off Windows the root of the host still ends the chain.
  • A FileUri argument holding a drive restarts a join on Windows. UriPath("file:///C:/d/") / UriPath("file:///D:/f") was file:/C:/d/D:/f (the same join with a plain Uri, or with the str "D:/f", was already file:/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") / other answered "unsupported operand type(s) for /" for any TypeError or NotImplementedError raised anywhere in the construction of the result, a bug in a scheme's __init__ included; UriPath already told the two apart. Only an argument the constructor cannot read is unsupported now, for both classes, and /, joinpath() and "prefix" / uri are defined once.
  • A cut-short, stalled or undecodable http: directory index raises instead of listing part of it. iterdir(), walk() and glob() read the index with requests' own .text: on urllib3 1.26 (what a Python 3.9 install with the s3 extra gets) a connection that closed early gave the entries that had arrived, which PathSyncer(remove_missing=True) read as the whole directory and deleted the rest; a stalled body raised requests' ConnectionError rather than TimeoutError, and a bad Content-Encoding leaked requests' ContentDecodingError. The index is now read the way open() reads a file: a short body is OSError (EIO) and a stall is TimeoutError, both naming the directory.
  • A DAV listing keeps only the members of the collection it asked about. A PROPFIND reply that held members deeper than Depth: 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 raise NotADirectoryError; and any well-formed XML that was not a multistatus (a login page answered with 200) listed as an empty directory and stat()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 a multistatus, or names neither the collection nor any member of it, is OSError (EIO).
  • A size a server states that is not a plain number is unknown. Content-Length: abc made HttpPath.stat() raise ValueError, one member with <getcontentlength>abc</...> made DavPath.iterdir() of the whole collection raise it, and a negative number became a negative st_size. Each is now an unknown size (st_size 0, and patch-mode append still refuses a HEAD without a usable Content-Length).
  • A listed http:/dav: name that is not UTF-8 can be opened. caf%E9.txt listed with U+FFFD in place of the byte and the request for it named caf%EF%BF%BD.txt. The name keeps its bytes as Uri("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=M and http://h/d/#top read as files, so unlink() sent the DELETE it refuses for http://h/d/. The path of the final URL decides.
  • A PROPFIND behind a redirect keeps its method and body. requests followed a 302 or 303 as a GET (so stat(), exists() and listing of a dav: URL behind one failed or read as missing) and a 301 without the body (the server saw an allprop request). A PROPFIND is 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 with OSError (EIO) naming the status and the Location for another origin, a second redirect or any other 3xx. A dav: URL that redirects to another origin (http: to https:, 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 (requests encodes a text credential as Latin-1) and %FF raised UnicodeEncodeError from stat() before anything was sent, so exists() said False. The percent-escapes are now decoded to bytes and requests sends those bytes; an ASCII credential is passed as text as before.
  • as_posix() of a github:, gitlab: or git: path no longer shows the userinfo. str(), repr() and as_uri(sanitize=True) dropped it but as_posix() rendered TOKEN@github.com:/o/r/f. It is github.com:/o/r/f now; 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.txt asked for /repos/o/r?x=1/contents/a.txt) and a .. segment made with with_name("..") or with_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 raises ValueError (exists() answers False); the same check covers gitlab:'s file path.
  • open("r+"), open("r+b") on http: and dav: raise NotImplementedError. They returned a read-only stream whose first write failed. Open with "rb" to read. The github: and gitlab: paths already refused them.
  • dav: uploads with the write_method given to with_session(). It was accepted and ignored (PUT always went out). DavPath.stat() also accepts walk_up_last_modified= like HttpPath.stat(); the PROPFIND reply already carries the modification time, so it changes nothing.
  • A github: or gitlab: API reply that is not the documented shape is OSError (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 raised AttributeError, TypeError, KeyError or requests' JSONDecodeError from stat(), iterdir() and read_bytes(), and exists() raised too (it only swallows OSError); a tree reply without a tree listed as an empty directory, and a size that was text broke stat(). Each reply is decoded and checked in one place per provider now, exists() answers False and stat() raises OSError. A raw file body has no shape to check and is returned as sent.
  • DavPath.rename() no longer puts the source's query in Destination. A source such as dav://h/d/f.txt?sig=... sent Destination: .../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 a str target) and no fragment, and rename() returns the target path with that query. Pass a Uri target 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, HostKeyNotVerifiable and PermissionDenied, paramiko.AuthenticationException, BadHostKeyException and the SSHException of a rejected key crossed the API, and a translated server status was chained to the library's exception. The dropped connection is now ConnectionResetError, the login SftpAuthenticationError, the key SftpHostKeyError, any other failure of the handshake ConnectionAbortedError and a refused connection ConnectionRefusedError on both; nothing is chained (the library's text can carry credentials). A caller that caught paramiko.AuthenticationException or asyncssh.PermissionDenied catches SftpAuthenticationError or PermissionError; one that caught paramiko.SSHException for a refused key catches SftpHostKeyError. After a lost connection no second request is made to find out whether the entry exists (mkdir() of a directory that was created could answer FileExistsError).
  • A directory holding a name that is not UTF-8 lists on both SFTP backends. With paramiko one such name raised UnicodeDecodeError out of iterdir(), walk(), glob() and copy(); with asyncssh it raised OSError, which walk() and glob() swallowed into an empty result, and rm(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 OSError without an errno (so copy() to a server that refuses SETSTAT raised after the data had been copied and a recursive copy stopped at the first file); it is NotImplementedError, which copy() tolerates. Errors now carry the remote path in filename (and filename2 for rename()), and the two-path error of asyncssh no longer prints [WinError None] on Windows.
  • chown(None, gid) and chown(uid, None) on sftp: 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 and chown(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 partial chown() raise NotImplementedError naming 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 default ssh_config and, for paramiko, the default known_hosts were bare objects compared by identity, so a copy was read as "an iterable of paths" and the first request raised TypeError. They are the same object after a pickle or a copy. A path pickled with a backend= still carries only its URI and ssh_config.
  • SftpPath.with_source() carries the path's ssh_config, and a join onto another host does not. with_source() handed the default back (a path told to read no configuration read ~/.ssh/config on the new host), and SftpPath(a, "sftp://other/y") took a's ssh_config onto other. The configuration now follows a derivation on the same endpoint and is dropped, with the backend, on another host. Two paths of one endpoint whose ssh_config differs no longer share the backend they build for themselves.
  • checksum() on sftp: 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 by supported_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, and supported_checksums() is never empty for a connection that produced a digest. An algorithm other than md5, sha1, sha224, sha256, sha384, sha512 and crc32 is NotImplementedError without a request, and a reply is a digest only of the size its algorithm has (an empty reply was returned as '' for crc32).
  • File objects of the two SFTP backends agree. write_bytes() and open("xb").write() returned None on paramiko; they return the count, as on asyncssh. truncate() raised io.UnsupportedOperation on asyncssh and required a size on paramiko; both truncate, and truncate() without a size cuts at the current position. fileno() raises io.UnsupportedOperation on both.
  • A file left open at exit is written. path.open("w").write(...) without close() at module scope left an empty file on asyncssh (and, for text, on paramiko) with a RuntimeError about 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 as a -oProxyCommand=calc was expanded into %r and split afterwards, so the part after the space became an argument of the proxy program (paramiko already refused it). The same users are refused with ValueError before asyncssh is given the user, when the configuration for the host would pass it on; with no %r in the ProxyCommand (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 one stat per entry, because the listing's attributes were used only for lstat(). A listed entry that is not a link now answers stat(), is_dir(), is_file() and exists() from the listing (once, as before), so 21 entries cost one request; a link is still followed by a request of its own. The asyncssh copy(recursive=True) and rm(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 generic rm() makes.
  • copy() and rm() on the paramiko backend do not import asyncssh. Both imported it before looking at the backend, so forcing PATHLIB_NEXT_SFTP_BACKEND=paramiko still loaded it on the first copy() or rm(), 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, with tree_copy() and tree_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 Host block was ignored until restart. The cache now also keys on the file's modification time and size (a change to an Included 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 with tarfile.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 an archive: path whose outer is missing and has no extension is now FileNotFoundError (it was NotImplementedError).
  • 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, and str(), repr(), hash(), == and as_uri() of that path raised UnicodeEncodeError. The name now keeps its bytes as %XX in 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.BadZipFile and tarfile.ReadError, exists(), stat(), iterdir() and read_bytes() leaked zlib.error, EOFError, NotImplementedError("zip file version ...") and gzip.BadGzipFile (an OSError, so exists() answered False for a corrupt .tar.gz). They are now BadZipFile for a zip and ReadError for a tar, with the decoder's error as __cause__. Code that caught zlib.error or EOFError around a read of an archive member catches those two instead. An encrypted member is still RuntimeError and an unsupported compression method NotImplementedError.
  • exists() and is_file() of a zip member no longer raise KeyError while 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 is FileNotFoundError.
  • New members written through zip: are deflated. A member added with write_bytes() or open("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 stored mimetype stays stored).
  • A read-only zip is not replaced on POSIX. write_bytes(), unlink() and rename() on a zip whose mode forbids writing succeeded, because the archive is replaced by a new file; they raise PermissionError before 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() on ftp: is one reply where the server offers MLST, and a connection is probed with NOOP only after it has been idle. A stat sent NOOP, TYPE, PASV and MLSD of the parent directory over a new data connection (the whole listing, so N stats in a directory of N entries were quadratic), and exists() of a missing file sent 10 commands. A server whose FEAT lists MLST (RFC 3659) is now asked MLST of the entry itself, a single reply on the control connection; exists() of a missing file is MLST, TYPE, SIZE, CWD. A connection that carried a command within schemes.ftp.IDLE_PROBE_SECONDS (1 second) is used without a NOOP; 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 without MLST is read as before: the parent's MLSD, then SIZE/CWD.
  • stat(), exists() and is_file() of an ftp: file work when its directory holds a name the client cannot decode. The parent's listing failed with OSError(EILSEQ) for every call and each call reconnected. With MLST no listing is read; on a server with MLSD and no MLST the error makes SIZE/CWD answer (the listing still drops the connection, so each such stat reconnects). iterdir() of that directory still raises OSError(EILSEQ).
  • A malformed size fact no longer makes a directory unlistable. size=abc or size=1.5 raised ValueError from iterdir(), a negative size was reported as is, and a size of 400 digits became an int. A size that is not a plain non-negative decimal below 2**63 is unknown (st_size is 0), as a missing one is.
  • rename() on ftp: onto an existing target is FileExistsError. A server that does not replace it (a Windows one answers 550 File exists) raised PermissionError with both files intact. The refusal is FileExistsError when the target is there, and PermissionError still 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 refused SITE CHMOD ("550 Not enough privileges") raised NotImplementedError("SITE CHMOD not supported by this server") without the reply. It is still NotImplementedError (so copy() 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 then stat()-ed each child with a second validation of the file, so a walk() 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 children iterdir() returns answer their first stat() 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 than schemes.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 through open("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 method zipfile lacks) made every such change to the archive fail with RuntimeError or NotImplementedError. 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 to d//real.txt or d/./real.txt reported size 0 while reading it returned the data; tar and zip now use the one name rule.
  • archive+zip: and archive+tar: are second names of zip: and tar:. Both schemes, both class names (ArchiveZipUri, ArchiveTarUri, now aliases of ZipUri and TarUri) and the printed and pickled form of a path (it keeps the scheme it was written with) are unchanged. type(path) of an archive+zip: path is ZipUri, so repr() shows that name, and isinstance(path, ArchiveZipUri) is also true for a zip: path.
  • A failed request on s3:, gs: and az: is an OSError of the type the header promises, and the SDK's exception is not chained. A timeout was OSError(EIO) on s3:; an unreachable endpoint reached exists() and walk(on_error=) as the SDK's own exception on gs: and az: (after up to two minutes with the SDKs' default retries), so neither saw it. A request that runs out of time is now TimeoutError, an endpoint that cannot be reached ConnectionError, a connection that breaks while an answer or a body arrives ConnectionResetError, each with the path as filename; the store's answers stay FileNotFoundError, PermissionError and OSError(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__ is None. Catch OSError or 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 an OSError. Only the call that opens the object was translated: a server that closed the connection mid-body raised botocore's ResponseStreamingError from read_bytes() and copy(), and a stalled body botocore's ReadTimeoutError without the path. They are ConnectionResetError and TimeoutError naming the path.
  • Reading or writing a bucket or container root, and a path with no bucket or account. read_bytes(), write_bytes() or open("r+") of s3://bucket/ raised an SDK error (botocore's ParamValidationError, a ValueError or an HTTP 400 reply), and S3Path("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 no backend=, does not exist (FileNotFoundError, exists() is False).
  • az://account can be listed. is_dir() answered True for it while iterdir() and walk() raised ValueError; its children are the containers.
  • rm(recursive=True, missing_ok=True) under a missing bucket or container returns. It raised FileNotFoundError (a missing key in an existing bucket was already fine). rename() of a missing path onto its own name returned the path; it is FileNotFoundError, and renaming an existing key or prefix onto itself still changes nothing.
  • A key a batch delete refuses is offered to ignore_error with its own path. On s3: the refused keys of a 1000-key batch reached ignore_error as one OSError without an errno, with the directory being removed as the path; each is now a PermissionError (AccessDenied) or OSError(EIO) for its key, offered once with that key's path, and without a handler the first is raised. gs: and az: offered a failed delete with the directory's path too.
  • An S3Backend, GsBackend or AzBackend builds 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 (PicklingError for boto3's and Google's clients, a RecursionError in copy.deepcopy). A pickled or copied backend keeps its options and builds its own client on first use. A pickled S3Path, GsPath or AzPath still holds only its URI: the receiving process uses the default backend unless the program supplies one, and no client_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 and rmdir() 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 most COPY_POLL_TIMEOUT, aborts the copy and raises TimeoutError, the source untouched.
  • uripath exits 141 when the reader of stdout went away, on Windows too. Windows reports a closed pipe as OSError(EINVAL), so read, write and sync printed uripath: OSError: [Errno 22] Invalid argument and 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 uri extra, uripath no longer echoes a URI argument. The error line quoted the whole argument, password included. It now reads the 'http' scheme needs the 'uri' extra: pip install 'pathlib-next[uri]'.
  • uripath reports a wrong invocation with exit status 2 and the usage. cp -r with - and an unknown --encoding exited 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 a BytesIO raised TypeError instead 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() and move() compare two paths that both answer it across classes. MemPath and the archive paths override it; any other type keeps comparing two paths of one type with ==, so nothing needs to change.
  • PathContract calls rm(recursive=True) with follow_symlinks= and follow_binds= (test_rm_recursive_accepts_the_follow_policies, and test_rm_rejects_a_policy_that_is_not_one). An implementation that overrides rm() must accept both keywords, as Path.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-identity and asyncssh had no lower bound (asyncssh had only the Python 3.9 cap) and no upper bound, and netimps had only a floor. The ranges are now uritools>=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,<13 and azure-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, so 04755 in 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. Run chmod() afterwards where the bit is wanted.
  • move() calls rename() only onto a path of the same class. The default of Path._rename_compatible() was True, so a Path subclass with a rename() of its own was handed a path of another store and ran the rename inside its own (a MemPath subclass moved onto a LocalPath looked for the local path in memory). The target of another class now gets copy + delete. A subclass whose rename() handles other classes overrides _rename_compatible(); UriPath and LocalPath decide for themselves as before.

Fixed

  • A .. or . name in a remote listing no longer escapes the tree. The s3:, gs:, az:, github: and gitlab: listings and the default UriPath listing yielded a name such as .., ., a/b or an empty one as a child (http:, dav: and sftp: already skipped them), and the ftp: listing yielded one holding NUL. Joined onto the directory, .. named its parent, so walk() left the directory it was given and never ended, rm(recursive=True) deleted the parent tree, and PathSyncer(remove_missing=True) removed the parent or copied a sibling tree into the destination. All of those listings now skip such a name, and walk(), rm(recursive=True) and PathSyncer also refuse one that a custom scheme's _scandir() yields, before building any child from it: walk() leaves it out of dirnames/filenames and, when on_error is given, calls it with a ValueError; rm(recursive=True) offers a ValueError to ignore_error(error, directory) once per entry and removes nothing for it; PathSyncer raises ValueError through ignore_error for 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 stores report. as report, so copy(recursive=True) of a tree holding both merged them into one file with no error, and a member NUL of an archive was written to the device. With windows=True the function now also refuses a trailing dot or space and CON, PRN, AUX, NUL, COM1-COM9 and LPT1-LPT9, in any case, with or without an extension (nul.txt); windows=False is unchanged. copy(recursive=True) and PathSyncer report such a name as a ValueError per entry for a Windows-flavoured target, and unpack_archive() skips such a member for a Windows destination with a UserWarning. Rename a source file with such a name, or extract it on another platform.
  • A failed open("a") on http: no longer empties the remote file. In the default append_mode="rewrite", a failure of the GET that 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: and dav: followed a redirect for PUT, PATCH, DELETE, MKCOL and MOVE the way a browser does. A 301 re-sent the PUT without its body and replaced the file with an empty one; a 302 or 303 turned the request into a GET, so write_bytes() returned having written nothing and unlink(), mkdir() and rename() returned having done nothing. Those requests now go out with allow_redirects=False (an allow_redirects in with_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 raise OSError (EIO) naming the status and the Location, without its userinfo. A server that redirects writes must be addressed by its final URL.
  • open("x") on http: and dav: 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 the PUT, replacing the file. It now raises that error and sends nothing, and the upload carries If-None-Match: *, which a server that honours it answers with 412, now FileExistsError. With append_mode="patch", a HEAD reply without Content-Length was read as size 0 and the appended bytes were written at offset 0 (Content-Range: bytes 0-2/*); open("a") now raises OSError naming the path and sends no PATCH. stat() still reports st_size 0 for such a reply. A server whose existence check fails for a missing file needs open("w") instead of open("x"); one that does not state the size of its files needs append_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(...)) kept base's backend for the next str join, with_name() or copy()/move() destination written as a URL, until its first read, so an HttpPath.with_session(auth=...) session or a github://TOKEN@... token was sent to the other host. And dst_root / src.relative_to(src_root) dropped the backend given to dst_root and 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 of relative_to() holds none. A path that crosses endpoints needs its own with_session() or with_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 a Uri or PurePath built the source's backend: the first parent of an sftp: path imported asyncssh, and raised ImportError where 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; read path.backend first, or pass backend=, to share a connection.
  • A backend that cannot be weakly referenced no longer turns off the same-file guard. When _initbackend() returned a dict, an object() or an instance of a __slots__ class without "__weakref__", two separately built paths to one URL were judged to be on different filesystems and a.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 through backend= or with_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" (a str join onto a path ending in a slash) addressed http://h/d/x, because .. was resolved against the empty last segment, while Uri("http://h/d/../x") is http://h/x, and a join with a Uri argument (/ Uri("../x"), Uri(base, "../x")) kept http://h/d/../x unresolved. All of them now give http://h/x. The dot segments of the whole joined path are removed, so Uri("http://h/d/x") / ".." is http://h/d/ (it was http://h/d), Uri("http://h/d/x") / "." is http://h/d/x/ (it was http://h/d/x) and Uri("a") / "../../x" is ../x, as the constructor gives for Uri("a/../../x") (the join gave x). 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, whose name is empty: use .parent for the parent, and skip a "." key (base if rel == "." else base / rel) where the result must equal base.
  • A percent-encoded dot segment is removed after decoding. Uri("http://h/safe/%2e%2e/secret") kept the segment .., so .parent, .name and as_uri() disagreed with the path that was fetched (http://h/secret) and the URI did not parse back to itself. A %2e%2e, .%2e or ..%2F in a parsed URI now resolves like ... A str join key is a decoded path, where %2e%2e is still a literal name.
  • Dot segments never climb above a Windows drive. UriPath("file:///D:/data/../../x") was \x on the current drive, and a join such as UriPath("file:///C:/d/") / "../../../x" lost the drive the same way. On Windows they now stay on C:/, as PureWindowsPath does.
  • FileUri.parents on a Windows drive path ends at the drive root. UriPath(LocalPath("C:/Windows/System32")).parents ended in file:/C: and file:, 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 .parent and LocalPath.parents give. The parent of 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 a copy()/move()/rename() destination on Windows. A destination such as str(pathlib.Path) gives (C:\Temp\x) was joined as one file name and failed with OSError (WinError 123) in FileUri.copy(), move() and rename(), and base / r"sub\x" named a file sub\x. On Windows a file: path now reads \ in such a string as a separator, so C:\Temp\x is an absolute path and sub\x is 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 of dst, the source included, and then raised FileNotFoundError; the same happened on MemPath. A target that holds the source now raises OSError(ENOTEMPTY) and one that lies inside it OSError(EINVAL), with both trees intact, before anything is removed. Move the source out first, or name a different target.
  • copy() and move() recognise one file under another class or spelling. LocalPath(f).move(target, overwrite=True) deleted f when target was a LocalPath subclass, a file: URI or a pathlib.Path for the same file, and so did a relative and an absolute MemPath of one file (a.txt, /a.txt, /d/../a.txt) and the zip:, 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 raises OSError(EINVAL), with the content kept. A directory reached through a symlink is the same directory, so copy(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 without overwrite. An existing target now needs overwrite=True (FileExistsError otherwise), 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 raises OSError(EINVAL).
  • copy(overwrite=True) removes the target with unlink(missing_ok=True). A backend whose write replaces the file and which cannot delete had to make unlink() lie; it now implements unlink() so that missing_ok=True returns without removing.
  • rm(recursive=True, follow_symlinks=True) works on POSIX. It emptied the directory behind a followed symlink and then called rmdir() on the link itself, which fails with NotADirectoryError on 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() applies follow_symlinks= and follow_binds= to the path it is called on. They decided only the entries found on the way down: a symlink named directly was unlinked whatever follow_symlinks said, while a junction named directly honoured follow_binds. A symlink named directly now follows the same policy: True with recursive=True empties what is behind it and then removes it, None leaves it, and without recursive it is only unlinked. A call that passed follow_symlinks=True or None for 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, False or None had its ValueError reported through ignore_error, so ignore_error=True left 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 pathlib path with Path but not LocalPath has LocalPath's local primitives. The precedence guard replaced stdlib's is_junction() and is_mount() with the generic answers (always False), so rm(recursive=True) on such a class walked into a junction and deleted the files of the directory it points at, even with follow_binds=None. It also left symlink_to() raising NotImplementedError, chmod("0644") raising TypeError on 3.10 and later, is_dir(follow_symlinks=False) and is_file(follow_symlinks=False) raising TypeError on 3.9, and chown() raising NotImplementedError. Every function LocalPath defines 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() on sftp:, dav:, s3:, gs: and az: accepts follow_symlinks= and follow_binds=. The five overrides stopped at ignore_error, so naming either keyword raised TypeError before any I/O. They take both with Path.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 default follow_symlinks=False and the generic walk for any other value.
  • move() keeps a symlink a symlink when it has to copy. Where rename() was unavailable (another device, a backend without rename()) 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 a 0o700 directory copied as 0o755 and 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), as shutil.copytree and pathlib 3.14 do.
  • PathSyncer no longer deletes the target entry behind a source link that cannot be resolved. With the default follow_symlinks=True, a source child that is a symlink to nothing (an unmounted volume, a removed directory) was read as "vanished", and remove_missing=True removed 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 to ignore_error with event SyncEvent.SyncStart; the target entry stays as it is, with or without remove_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 (FileNotFoundError for a dangling link, the stat's OSError for a loop) under the default policy: pass ignore_error=True (or a callable) to carry on past it, or follow_symlinks=False to mirror the link itself.
  • ignore_error receives PathAndStat entries when the root stat fails. A failing stat() of the root source or target offered the caller's raw path objects to the policy while the hook got PathAndStat, so a policy reading source.path or source.stat raised AttributeError and hid the real error. Both arguments are now always PathAndStat (with an unknown stat when the stat is what failed).
  • PathSyncer no longer lists, writes or deletes through a Windows junction inside the target. A symlink found below the root target was replaced, but a junction looks like an ordinary directory, so remove_missing=True removed 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 the target passed to sync() 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 PathSyncer no longer deletes the only copy of a file. Onto a backend whose rename() refuses an existing target (plain SFTP without posix-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 the OSError names it; ignore_error receives 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 than NAME, 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. NAME is 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 PathSyncer run are no longer copied onward, and a stale one is removed. The leftover .NAME.<hex>.pathlib-next-tmp of an interrupted transfer stayed beside the intact target for good under the default remove_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, and remove_missing=True no 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 by remove_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-tmp by hand is skipped as a source; rename it to have it synced. The removal is a SyncEvent.RemovedMissing.
  • A PathSyncer dry run refuses a symlink the target cannot hold, as the real run does. With follow_symlinks=False, a symlink in the source and a target that cannot create links (MemPath, an object store), dry_run=True returned and reported SyncEvent.Symlink while the real run raised NotImplementedError, so uripath sync --dry-run printed would symlink for something the real run refuses. Both now take the decision before anything is touched, offer the same NotImplementedError to ignore_error and fire no Symlink event for it; the command's dry run exits 1 with the same message.
  • PathSyncer sees one tree under two spellings or classes as overlapping. The overlap check compared two paths of the same class lexically, so MemPath("r") and MemPath("/r") on one backend, one archive directory under zip: and archive: (or with a query), and a LocalPath and the file: URI of the same directory were not reported, so a sync could copy a tree onto or into itself. It now asks the question copy() and move() ask about "same file", so all of these raise ValueError through ignore_error before anything is touched; two paths of an unrelated class are still never reported.
  • A recursive copy() or rm() on the asyncssh backend has stopped when it raises. After the first error, a timeout or a KeyboardInterrupt in 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 and Path.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_config HostName, 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 sent st_mode with 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 with 0o777 like mkdir(), 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) treats missing_ok and ignore_error as the generic rm() does. missing_ok=True swallowed a FileNotFoundError from an entry that vanished below the path and returned with the tree still there; it now covers the path rm() was called on only. A callable ignore_error that 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 Port for a host alias. It always passed a port to asyncssh (22 when the URI had none), which outranks the config, so sftp://box/ with Host box / HostName 127.0.0.1 / Port 2222 connected 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 a ProxyCommand command line unchanged, so sftp://u@host%20--flag/x with ProxyCommand prog %h %p ran prog host --flag 22, and a host starting with - arrived as an option. ValueError naming 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 ProxyCommand tokens with the connection's own values. %p and %r came from the config or its defaults, so sftp://alice@example.invalid:2222/x with ProxyCommand prog %h %p %r ran prog example.invalid 22 and the local user name: the tunnel went to port 22 as the local user while the client believed it was talking to port 2222 as alice. %h, %n, %p, %r and %% 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 Include inside a Host or Match block stays conditional on the paramiko backend. The included file's own blocks were spliced in as top-level blocks, so a Host * section in a file included from Host internal set the user and ProxyCommand of every host. Each of the included file's blocks now applies only where the enclosing block does, as in OpenSSH. An Include outside 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 (what ZipFile.write() produces) vanished with its last member: unlink() left its parent missing, the next write into it raised FileNotFoundError, and rm(recursive=True) deleted every member and then failed on the rmdir() of a directory that was gone (the archive root failed the same way). unlink(), rename(), rmdir() and rm() 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, and rm(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 with OSError(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 raises NotADirectoryError, FileNotFoundError and OSError(EINVAL) respectively, as pathlib does; create the directory first.
  • A zip member that the archive spells twice is removed and renamed as one. In an archive holding norm and ./norm, unlink() and rename() acted on the later entry only: the earlier one came back with its older content, and rm(recursive=True) of the enclosing directory failed with "Directory not empty". unlink(), rename(), rmdir() and rm() 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 y and a member y/z listed only y, yet y/z could still be statted, read and removed, so copy(recursive=True) and walk() skipped data that read_bytes() returned. exists(), stat(), open(), unlink() and rename() of y/z now report it missing (FileNotFoundError; pathlib says NotADirectoryError there), on zip: and tar: alike, and writing or mkdir() below y raises NotADirectoryError. Remove or rename y to reach y/z.
  • The root of an archive that is missing, or is not an archive, no longer exists. exists(), is_dir() and stat() of zip:file:///missing.zip!/ (and of tar:, archive: and an HTTP outer answering 404) said it was a directory while iterdir() raised. They now open the archive: exists() is False for a missing one and stat() raises FileNotFoundError, and a file that is not an archive raises zipfile.BadZipFile or tarfile.ReadError, as a listing and a member's exists() do. mkdir() of the root of a missing archive raises FileNotFoundError; 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. On s3:, gs: and az: 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/logs and d/logs/2026.txt), and a name/ key that holds data. copy(recursive=True) skipped the same keys without a word. copy(recursive=True), move() and rm(recursive=True) of a prefix that holds such keys now raise OSError(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, or unlink() the object that hides a subtree, and repeat. A prefix of ordinary keys is unchanged, down to the one batched delete. iterdir() and walk() 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, and stat() disagreed with the listing. iterdir(), walk() and copy(recursive=True) now show the blob, as s3: and gs: do.
  • Writing or renaming a file onto an object-store prefix directory is refused. write_bytes() on d while d/x.txt existed stored an object d that every listing then showed instead of the directory, and rename() of a file onto dst did the same, leaving the keys below it unreachable by iterdir(), walk(), copy() and sync. Both now raise IsADirectoryError on s3:, gs: and az:, at the cost of one list request per open("w") and rename(); 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 asked exists(), which reads any error as "missing", so a 403, a 503 or a timeout on the probe wrote existing.txt/ beside the object existing.txt and returned. The probe is now stat(), only a not-found answer means absent, and any other error is raised with nothing written.
  • rmdir() of a gs: bucket root or an az: container root is refused. It returned success and removed nothing; it now raises PermissionError, as S3Path.rmdir() does for a bucket, and so does a non-recursive rm() of the root.
  • A failed ftp: listing no longer leaves the cached connection out of step. ftplib reads 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 later stat(), exists(), iterdir() and walk() on that thread failed with ftplib.error_reply until 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 raises OSError(EILSEQ) instead of UnicodeDecodeError, and a 4xx reply to MLSD (425 Can't open data connection) raises OSError(EAGAIN) instead of a raw ftplib.error_temp, as it does for a read or a write. Code that caught UnicodeDecodeError or ftplib.error_temp around a listing must catch OSError.
  • ftp://host with no path lists the root. A path of "" sent MLSD, NLST, RETR, STOR, DELE, MKD, RMD and SITE CHMOD with an empty argument, which a server reads as its working directory: the login directory when it is not /, and whatever directory an earlier is_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() and supported_checksums() now send the check-file-handle extension on AsyncsshSftpBackend (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), PathSyncer compares 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 native checksum() is not bounded by the backend's timeout, because the server hashes the whole file before it answers.

Changed

  • The uri extra now requires netimps>=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, and copy(), move() and PathSyncer now 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.PathContract gains 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*Backend classes (s3, gs, az, ftp, sftp, github/gitlab) are weakly referenceable, which is how a UriPath tells 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() raised OSError(EINVAL) "Source and target are the same file" and PathSyncer raised "source and target overlap" for /app.conf on one host onto /app.conf on another, whenever the type kept its connection under any attribute other than the private _backend the 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 without st_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 for copy()/move(), as they already were for PathSyncer.
  • 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 the uri extra 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 an sftp: 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 both site-a and site-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 line find -L draws. 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 uri extra: UriPath("sftp://h/mnt") / "c" raises ImportError on 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 an http:/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 / and joinpath() through the child builder a listing uses, which reads the backend property -- and that property CREATES one, so spelling UriPath("sftp://h/x") / "y" imported paramiko and raised ImportError without the extra (http: wanted requests, 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 takes False (the default -- remove the entry itself, never its contents, as rm -r does), True (remove what is behind it), None (leave it in place, so the enclosing directory is not empty and reports it), or a callable policy(path) -> bool | None asked per entry, so one tree can keep one mount and follow another. Named for the keyword stat(), walk() and copy() already use for the same idea, rather than a second vocabulary.
  • Path.is_junction(), Path.is_mount() and Path.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 by LocalPath/FileUri.

  • Path.glob(on_error=) / rglob(on_error=): a hook called as on_error(error) when a directory cannot be listed, the same contract as walk() and os.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.filename names the directory even when the backend left it unset.

  • Path.glob(bound_loops=) / rglob(bound_loops=): with True, 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 reports is_symlink() == False, so recurse_symlinks=False cannot see it, and one file in a looping tree matched 64 times (pathlib walks it the same way). Default False keeps 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.txt made rm(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, as dav: and http: 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.txt appended a second entry instead of rewriting it, and unlink() 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) ignored native=, never auto-detected **, and answered differently from rglob(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=False also 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 a data: payload (data:,a/../b joined to data:b/x, not a data URI at all), dropped a scheme's own child handling (gitlab:'s reserved -), still parsed a bytes name as URI syntax, and lost per-instance scheme state such as SftpPath's ssh_config. Joins now walk segments through the same builder a listing uses.
  • A relative str destination whose first segment merely contained a colon (notes:draft, Fedora-42:latest.tar) was read as a URI scheme by copy()/move(); the scheme must now be one a class registers, as the uripath CLI already required. A Windows drive path (C:/Temp/x) restarts the join for a file: path, as PureWindowsPath does.
  • rename() and copy()/move() resolved a relative str differently (rename("../b") sent a literal sub/../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 now rmdir()'d like a junction, which fails loudly on a live mount instead.
  • UriPath / "name" and joinpath() read a str as a decoded path, not as URI syntax. base / "cache?v=2" is now the file cache?v=2 instead of base/cache with a query; "note#2.txt", "a%20b.txt" and "C:/Temp" join verbatim too. This is what iterdir() always did, so listing a directory and naming the same child by hand finally agree. Pass a Uri/UriPath argument 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-path str destination. A string with a scheme is a URI, as before, so copy("s3://bucket/key") keeps working; one without is a decoded path on the same endpoint (absolute replaces the path, relative is a sibling, as rename() resolves it) and reuses this path's source and backend. move("b.txt") previously built a sourceless path and failed on its first exists() call, and a same-host URI destination opened a second connection. A one-letter scheme is treated as a Windows drive, so C:/Temp/x is 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 raises ValueError as pathlib does -- None is 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=), default True: 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**) raises ValueError before 3.13 and is a plain wildcard from 3.13. native=False applies 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 for a**. Measured with a 46-comparison differential sweep against pathlib: 3.14 was already identical, and 3.9 went from 4 disagreements to 0. Pass native=False for 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, and Path.glob()'s docstring documented the ValueError but not the alternative. Reported by yaconfiglib, whose path.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 ./ (what tar -C dir ., TarFile.add(arcname=".") and shutil.make_archive put 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 only tar: 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 as d//e.txt was readable but absent from listings.

Changed

  • A drive- or backslash-shaped member name is no longer dropped. C:drive.txt and a\b are 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 (ValueError through ignore_error), which is what PathSyncer and utils.unpack_archive() already did per destination. Copying such a member onto a Windows path is still refused; copying it to a POSIX path, a MemPath or 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 while read_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.txt reads ok.txt), and when two spellings normalize to one name the later member wins, as in zipfile/tarfile.
  • Uri's RFC 3986 dot-segment removal is documented as a divergence from pathlib (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 (with overwrite=True) and opened the target for writing before opening the source, so a missing file, a directory without recursive=True, or a source HTTP 404 left the target empty, or left a new 0-byte file that made a retry fail with FileExistsError. 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 as F.TXT on Windows/macOS) emptied the file; a case-only rename with move(overwrite=True) deleted it. copy() now raises OSError(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 raises FileNotFoundError / IsADirectoryError first and leaves the target alone. A local file target is replaced atomically with os.replace(), so a locked source on Windows no longer costs the target.
  • rm(recursive=True) deleted files outside the tree through Windows junctions and file: directory symlinks. A junction reads as a directory to a non-following stat, and UriPath's default _scandir() used a following stat, so both were descended into and their targets' contents deleted. Both are now removed as links. FileUri listings also reuse LocalPath'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, with remove_missing=True it 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 pass ignore_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 until RecursionError.
  • A child name that would leave the target (.., a name the parent-name fallback turned into .., or \/: on a Windows target) now raises ValueError through ignore_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=False nothing was copied and remove_missing=True deleted 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 made walk() loop forever; a PROPFIND href %2E%2E/ let a recursive copy write outside its destination. Such names are no longer listed.
  • DavPath.unlink() and HttpPath.unlink() deleted whole collections. They sent a bare DELETE, which WebDAV applies recursively; unlink() on a directory, and symlink_to(force=True) over one, removed the tree. Both now raise IsADirectoryError for a directory (HttpPath relies on its HEAD-based directory check). rm(recursive=True) still deletes trees.
  • DavPath read 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 raise FileNotFoundError / PermissionError / OSError.
  • Reading a local zip: archive opened it for writing. A read-only zip was unreadable (exists() returned False), 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 raises zipfile.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; see docs/divergences.md).
  • Archive listings exposed traversal member names. Members named with .., an absolute path, \-separated traversal or a drive prefix are no longer listed, so iterdir()/walk()/copy(recursive=True) cannot write outside a destination through them.
  • utils.unpack_archive() let crafted members escape dest on Windows (D:evil.txt, and the same-drive C:../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/FtpPath moves to another server renamed on the source server, S3Path/GsPath moves 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, and LocalPath.move() onto a remote path renamed a local file. rename() now raises NotImplementedError for a target on another endpoint, archive or Azure container, and move() copies and deletes instead. move() also falls back to copy + delete on a cross-device rename (EXDEV), and SftpPath.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) and base.with_source(...) reused base's backend, so an HttpPath.with_session(auth=...) session or a github://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 a str target always raised TypeError, and a pending copy crashed with KeyError after 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 a LocalPath. It now renames within the same directory and returns a FileUri.
  • MemPath.copy("/b.txt")/move("/c.txt") wrote into a new, empty in-memory filesystem, and move() then deleted the source. A str destination now stays on the source's backend.

  • SftpPath.copy() raised ModuleNotFoundError without asyncssh (paramiko-only sftp extra), including every single-file download and PathSyncer with 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 and exists() returned False; 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=...) ignored ssh_config; the paramiko backend ignored ssh_config Include.
  • UriPath("ftps://...") returned a stub UriPath in a fresh process instead of FtpPath.
  • URL credentials leaked into HTTP errors and redirects. They are now sent as Basic auth= instead of inside the request URL, WebDAV MOVE Destination no longer carries them, and translated errors no longer chain the requests exception (__cause__ is None; the message carries the HTTP status and reason). URL credentials now take priority over a matching ~/.netrc entry.
  • github:/gitlab: tokens leaked through str()/repr()/errors, and user:TOKEN@host authenticated 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("**") and glob("dir/**") raised NotADirectoryError on 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 on MemPath and URI paths; ? never matched on a UriPath (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() on MemPath, Uri and every UriPath did not follow pathlib. It anchored at the start and let * cross /, and a URI's host: prefix defeated absolute patterns. It is now pathlib's right-anchored per-segment match; an empty pattern raises ValueError. LocalPath accepts match(case_sensitive=) on 3.9-3.11, and its full_match() handles rooted, drive and backslash patterns before 3.13.
  • parents/parent of an absolute MemPath or Uri lost the root. MemPath('/a/b').parents is now ['/a', '/']; Uri('http://h/a').parent is http://h/; a top-level FileUri's parent is / (or the drive root C:/ on Windows) instead of resolving to the current directory. relative_to()/is_relative_to() treat s3://bucket/http://h as the root, so relative_to(p.parent) and walk_up=True work.
  • with_name()/with_stem()/with_suffix() accepted '', . and separators on MemPath and Uri, splicing x/y or ../../etc into a path. They now raise ValueError like pathlib.
  • MemPath did not normalize like PurePosixPath. MemPath('/') / 'a' was //a and unequal to MemPath('/a'); a trailing / changed equality; an absolute join did not reset. All now match PurePosixPath.
  • 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 any stat() error as "missing". mode now defaults to None (chmod only when passed), an existing file is never truncated, and FileUri.touch() uses pathlib's touch().
  • copy() made local copies read-only. preserve_metadata=True applied the placeholder 0o444/0o555 mode that MemPath, 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/WebDAV st_mtime was off by the host's UTC offset, and it raised OverflowError on 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, and rm(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.
  • GsBackend rewrote the process-wide STORAGE_EMULATOR_HOST and dropped other client_options. Keyword arguments now go to storage.Client unchanged; for an emulator also pass use_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's sig=ab%2Bcd%3D%3D reached the server as ab+cd== and an escaped & split a value; with_query(dict) double-encoded. Uri.query is now kept as received and sent unchanged (see Changed).

  • A remote path joined with a relative pathlib.Path became a local file. UriPath("sftp://h/srv/") / pathlib.Path("etc/x") produced file:/srv/etc/x, so reads and writes hit the local disk. A relative path now joins like a PurePath and stays on the remote; only an absolute local path becomes file:. UriPath.joinpath() picks the class from the scheme.
  • URIs with non-UTF-8 percent-escapes (caf%E9.html) raised UnicodeDecodeError; 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; a file://<host>/share whose host is this machine mapped to the current drive instead of a UNC path.
  • Scheme registry: a UriPath subclass defined after the first dispatch was never found, and every unknown scheme rescanned entry points.
  • open() modes: rt/wt failed on every non-local backend and invalid modes raised NotImplementedError; modes are now validated like the built-in open() (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 (or NotImplementedError where 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=False was decided by exists(), which reads a transient 503 as "missing". Downstream classes mixing a concrete stdlib path with Path now also get pathlib_next's stat/chmod/glob/walk/_scandir.
  • MemPath: iterdir() on a missing path raised NotADirectoryError; files always reported st_mtime=0, so PathSyncer skipped same-size edits.
  • Archives (zip:/tar:/archive:): children of the archive root were named /name and matched no member, so iterdir/glob/recursive copy from 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 any Path source, 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; DavPath listed 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 raw requests.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 root stat() answered from the URI shape without asking the server.
  • uripath CLI: sync compared sizes only, so same-size edits were never copied; --dry-run printed 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 FileNotFoundError and raw ftplib errors escaped.
  • S3/GCS/Azure: botocore ClientError escaped exists()/walk() and a 403 read as "missing"; GCS/Azure turned every exception (including a missing SDK) into "does not exist", so copy(overwrite=False) could overwrite; iterdir() on a missing path returned [] and rmdir()/unlink() accepted wrong-type targets; a failed upload was retried at garbage collection over newer data; prefix-directory move() failed; S3 objects above 5 GiB could not be written or renamed; open("x") was a check-then-put race; AzPath without backend= ignored the URI's account; Azure recursive rm() 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; RemovedMissing events carried the parent directory; ignore_error was 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 bare OSError("Failure"); unlink(missing_ok=True) skipped dangling symlinks; a relative readlink() result could not be printed; asyncssh file handles made one round trip per byte in readline() and an unclosed handle hung interpreter exit for 60 s; the asyncssh recursive copy called a bool ignore_error and 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() returned None on dav:, s3:, gs:, az:, ftp: and sftp:; it now returns the new path, as pathlib does.

  • Wrong exception types on sftp:, dav:, gitlab: and MemPath. Listing or rmdir() of a file raises NotADirectoryError, rmdir() of a non-empty directory OSError(ENOTEMPTY) (MemPath raised FileExistsError), opening or unlinking a directory IsADirectoryError; dav: reading a collection raised nothing and returned its HTML index, and rmdir() of a missing path raised NotADirectoryError. paramiko open("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.
  • uripath crashed at import without the uri extra, 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:draft asked for pathlib-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("/**") and match("**/**") are all True there) while "*" never does, and a bracket expression such as "[!a]" consumes the separator itself. MemPath/Uri answered False throughout; 3.12 now runs a port of that algorithm instead of the part-by-part comparison every other version uses.
  • import pathlib_next imported netimps (a host-name query at import, slow on Windows), and the first file:/data: path imported requests and botocore; both now load on first use. GsPath/AzPath are exported from pathlib_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.py crashed 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 from rm()/touch() and MemPath lacked errno/filename; copy(progress=) never reported a zero-byte file; samefile(str) lost the backend or host; MemPath handles appended at the seek position, hid unflushed writes and accepted writes on read handles, and exclusive create/mkdir could both succeed under concurrency; FileStat.from_stat() kept None fields; checksums failed on FIPS hosts (usedforsecurity=False); is_dir()/is_file() rejected follow_symlinks=; "prefix" / path was 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() was False; data: accepted r+ and discarded writes.

  • Scheme and CLI edge cases. FTP listed MLSD cdir/pdir entries named like children; archives nested in archives could not be addressed; GitLab iterdir() on a file yielded nothing; GitHub/GitLab 429 and secondary rate limits were not recognised; git://<ip> raised AttributeError; a custom BaseRepoBackend without a cache crashed on GitLab; git-hosting open("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 with stat() when a key was both an object and a prefix; GCS/Azure roots always reported existing; asyncssh SFTP errors had no errno/filename.
  • PathSyncer edge cases. Tolerated errors left no trace; a directory that became a file or symlink in the source deleted the target directory even with remove_missing=False; SyncStart passed raw paths to the hook and dry runs reported dry_run=False for 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

  • PathSyncer keeps non-empty target directories on a type change unless remove_missing=True (IsADirectoryError through ignore_error, SyncEvent.TypeMismatch). Every tolerated error is now logged at WARNING on pathlib_next.sync and reported to the hook as SyncEvent.Error. PathAndStat follows symlinks by default. PathSyncer.hook() gains a keyword-only always_run.
  • URI comparisons and rendering: Uri == <non-URI Pathname> (e.g. a LocalPath) is now False (a Uri still equals another Uri or a URI string); non-ASCII hosts are rendered in IDNA form; an explicit schemesmap= is authoritative (an unknown scheme gives a plain UriPath); with_source() with a scheme-less source returns a plain UriPath; / no longer hides a TypeError raised inside a scheme class.
  • data: URIs reject r+, decode base64 only with ;base64, and imply text/plain for a parameters-only header.
  • uripath exits 141 on a closed stdout and 130 on Ctrl-C.
  • Package metadata uses PEP 639 (License-Expression: MIT) instead of the License :: classifier, and adds Typing :: Typed, Development Status :: 4 - Beta and Python 3.9-3.14 classifiers. Building from source needs hatchling>=1.27.
  • The az extra installs azure-identity, which an AzPath without backend= needs for its default credential.
  • pathlib_next.testing contracts are stricter: 48 PathContract tests (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 to False (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 with populate_fixture_tree(). test_iterdir_lists_children no longer passes when iterdir() is unimplemented.
  • Uri.query is the percent-encoded query as received and is sent unchanged; Query(...).decode()/to_dict() decode each name and value once. A str passed to with_query() is taken as already encoded; a mapping's keys now escape =. Code that read .query expecting decoded text must decode it.
  • Archive paths raise pathlib's POSIX exception types (iterdir() on a file, reading or unlink()ing a directory, rmdir() on a file), and mkdir()/new zip members need an existing parent. Zip rename() returns the new path. Archive as_uri() percent-encodes the member path.
  • uripath sync compares file content by default; --size-only restores the old comparison. --dry-run prints planned changes and -v/--verbose prints 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 supports posix-rename@openssh.com, else raises FileExistsError.
  • DavPath maps 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. Pass include_hidden=False for the old results. glob("") now raises ValueError and an absolute pattern raises glob.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_config UserKnownHostsFile and RejectPolicy. To keep the old behaviour add the host to known_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 pass FtpBackend(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=None restores the unbounded wait. Before, a stalled server hung the caller forever.
  • asyncssh timeouts apply to single requests only (AsyncsshSftpBackend(timeout=...), default 60 s). Recursive copy()/rm() and read_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 builtin TimeoutError on every Python version (on 3.9/3.10 it was concurrent.futures.TimeoutError).
  • paramiko backend: ssh_config ProxyJump raises NotImplementedError. Before, it was ignored and the connection went direct. Use asyncssh, a ProxyCommand, or connect_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 raises RuntimeError instead of hanging.

Added

  • SyncEvent.Error and SyncEvent.TypeMismatch reporting; str / path (__rtruediv__) on Pathname and Uri.
  • pathlib_next.testing.populate_fixture_tree(root) and FIXTURE_TREE.
  • benchmarks/bench.py --save (min/median/max ms per call as JSON under benchmarks/results/) and --samples.
  • SyncEvent.Compare and SyncEvent.Skipped; uripath sync --size-only and -v/--verbose.
  • glob.parse_pattern(), glob.select(), glob.NonRelativePatternError; recurse_symlinks=False on glob()/rglob(); FileStat.mode_known.
  • close() on SftpBackend/AsyncsshSftpBackend; FtpBackend(timeout=, ssl_context=, verify=); RepoBackend(timeout=); utils.LRU(on_evict=...) and LRU.discard().
  • utils.is_safe_child_name(name, *, windows=False) and utils.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 str destination to rename()/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) for rename(), type(self)(target) inside Path.symlink_to()). That reads a decoded filesystem path as URI syntax: everything from a ? or # onward became a query/fragment and was dropped, %xx was 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, so MemPath("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 raises IsADirectoryError, as pathlib does. The same guard covers the virtual root for every mode, which used to grow a bogus "" key in the backend on "w"/"a".
  • A MemPath routed through a file raised TypeError. _parent_container() walked ancestors with path not in parent, which on a bytearray ancestor evaluates "seg" not in bytearray. That TypeError sails past the OSError guard in Stat._st_mode(), so even MemPath("file.txt/sub").exists() crashed instead of returning False — a routine shape in glob/walk and in mkdir(parents=True). It now raises NotADirectoryError naming the offending ancestor.
  • Pathname had no __eq__/__hash__, so subclasses compared by identity. Every pure subclass that didn't hand-write equality — including MemPath, and any downstream class subclassing Path directly — was unusable as a dict key or set member, and is_relative_to() (which decides via == against freshly built parents) always returned False without raising. There is now a default keyed on (type(self), tuple(self.segments)). LocalPath, PosixPathname and WindowsPathname are unaffected — pathlib.PurePath precedes Pathname in their MRO and keeps its own equality — as is Uri, which defines one.
  • is_relative_to() normalized a str argument by joining it onto self. Pathname used cls(self, other) and Uri used Uri(self, _ROOT, other), so Uri("a/b").is_relative_to("a") compared against "a/b/a" / "/a" and answered False while Uri("a/b").is_relative_to(Uri("a")) answered True — the str and object forms of the same call disagreed. Both now parse other standalone, as CPython does. The generic side uses self.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 still True.
  • LocalPath.chown() leaked AttributeError/LookupError on Windows. shutil.chown exists there while os.chown does not, so an int id raised AttributeError and a name raised a misleading LookupError: no such user (with no pwd module, every name misses whether or not the user exists). It now raises NotImplementedError, which docs/divergences.md already 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 the Path layer 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. chown was the one POSIX permission attribute stat() could read but nothing could write back. None means "leave unchanged"; the normalization lives on Path so each backend receives an already-canonical pair rather than re-deriving the mapping (os.chown wants -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/ and tests/ reformatted.

0.9.0 - 2026-07-29

Added

  • UriPath.host_fspath(), and __fspath__() now succeeds for schemes with _host_filesystem_path = True (currently sftp:) instead of unconditionally raising NotImplementedError for 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 like sftp:. host_fspath() is the unambiguous accessor for that second case: it never falls back to local-path semantics the way __fspath__ does for the file: branch.
  • progress callback for Path.copy()/BinaryOpen.copy(). BinaryOpen.copy(target, *, progress=None, chunk_size=shutil.COPY_BUFSIZE) now streams in caller-sized chunks and, when progress is given, calls progress(bytes_copied, total_size) after each chunk (total_size is the source's stat().st_size when available, else None). Path.copy(target, ..., progress=None) wraps this with per-file identity: progress(path, bytes_copied, total_size), so a recursive=True copy 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, same shutil.copyfileobj bytes-on-wire. SftpPath's asyncssh concurrent fan-out (copy(recursive=True)) does not invoke progress -- native/batch transfer paths are out of scope for this first cut (see docs/divergences.md's "Deliberate extensions" section).
  • PathSyncer can now create symlinks on target instead of always rejecting a symlink source. New constructor kwarg symlink_mode ("preserve" default, "reject" opt-out), consulted only when follow_symlinks=False and source.is_symlink() (with the default follow_symlinks=True, symlinks are still resolved during traversal, unchanged). "preserve" creates a matching symlink on target using the exact raw, unresolved target string readlink() returned -- dangling links and relative targets included, never validated or resolved against source's parent. If target's implementation has no symlink_to() at all (every backend except LocalPath and SftpPath), "preserve" mode raises NotImplementedError through the existing ignore_error/hook() flow, same as every other sync branch -- not a silent skip. New SyncEvent.Symlink enum member.
  • Optional backend-native checksum protocol (pathlib_next.protocols.checksum.NativeChecksum, checksum(algorithm="md5") -> str). A Path subclass may implement it to compute a file digest server-side instead of streaming the content through open("rb") -- implemented on SftpPath against the OpenSSH check-file@openssh.com SFTP protocol extension (paramiko backend only; the asyncssh backend has no equivalent client-library support and correctly falls back). Not part of the base Path/Pathname ABC -- a plain Path has no .checksum attribute at all. Implementations MUST raise NotImplementedError (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 -- see docs/divergences.md). Also adds a companion supported_checksums() -> frozenset[str] advisory query (default frozenset(), 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 generic utils.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-supplied checksum callable is unaffected (invoked exactly as before). New utils.checksum.native(path, algorithm) -> str | None helper: tries the protocol, returns None (never raises) if unsupported.
  • PathSyncer(..., quick_check=True) (new constructor kwarg, default True): 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=False restores always-checksum behavior for non-local pairs.

Changed

  • PathSyncer default behavior change: PathSyncer(follow_symlinks= False).sync() on a symlink source previously always raised NotImplementedError. It now creates a matching symlink on target by default (symlink_mode="preserve"); pass symlink_mode="reject" to restore the old unconditional-raise behavior exactly.
  • The uri extra now requires netimps>=0.2.0 (alongside uritools). Source.is_local() (uri.source) delegates its "is this address mine" check to netimps.is_local_address(), which enumerates real network interfaces (netimps.get_interfaces()) instead of the previous socket.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 uses netimps.resolve() (both "a"/"aaaa" record types; host is local if ANY resolved address is) instead of socket.gethostbyname() -- resolve()'s default backend chain (dnspython, then the OS resolver via getaddrinfo(), then nslookup) covers the same hosts-file/NSS/DNS resolution gethostbyname() did, and additionally never raises for a name that simply doesn't resolve (resolve()'s contract: always a list, empty on genuine failure) -- gethostbyname() raised socket.gaierror for that case. utils. get_machine_ips() (the old implementation's helper, unused elsewhere in this library) is removed. SftpPath's two backends and FtpPath now resolve their default ports (22, 21) via netimps.get_default_port() instead of three separately hardcoded literals.

Fixed

  • Source.is_local() crashed on a bare IPv6-literal host string. Source(scheme, userinfo, "::1", port) (a supported direct-construction pattern -- Source's fields are public NamedTuple fields) raised socket.gaierror instead of returning a result, because socket.gethostbyname() (this method's original hostname-resolution step) is IPv4-only. host bracket-literals arriving via the normal Source.from_str()/Uri() construction path were unaffected (_decode_host() already parses those into a real IPv6Address before is_local() ever sees a string). is_local() now tries netimps.try_parse() first (handles any IP literal directly, string or not) before falling through to hostname resolution.
  • uri.source.Source leaked the password in str()/repr(). Source.__str__() called uricompose() with the raw userinfo (password included) -- a genuinely valid, connectable URI string, not just a debug rendering -- and Source had no custom __repr__ at all, so the NamedTuple default rendered every field verbatim too. repr() is what a traceback frame renders, so a Source anywhere on a failing call stack leaked the credential into logs, even though Uri.__str__() already redacted. Both now redact the password from userinfo the same way Uri.__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) (or f"{source}") no longer reconstructs an authenticated URI -- verified nothing in this codebase relied on that (every real connection site reads individual Source fields, never whole-object str()), but a downstream caller that did would need the new Source.as_str(sanitize=True) method instead: sanitize=True (the default, matching __str__) redacts the password; sanitize=False is the full, credentialed round trip -- same name/kwarg as Uri.as_uri(sanitize=), so both classes work the same way.

0.8.6 - 2026-07-26

Fixed

  • PathSyncer.sync() raised TypeError instead of honoring ignore_error. sync()'s ignore_error parameter defaulted to the bool False and the symlink branch called it directly, so PathSyncer(ignore_error=True).sync(src, dst) on a symlink source raised TypeError: 'bool' object is not callable rather than the intended NotImplementedError. The parameter now defaults to None, 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 for ignore_error, matching Path.rm()'s bool-or-callable contract. Previously only a callable or None was handled, so copy(recursive=True, ignore_error=True) broke as soon as a child copy failed. None keeps 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 as errors.append are unaffected.
  • Downstream Path subclasses resolved stdlib pathlib operations instead of pathlib_next's. Concrete path classes mix a pathlib class with pathlib_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 stdlib copy()/move() displaced ours, crashing with AttributeError: ... 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's newline= (3.13+), and rglob's include_hidden=/recursive=/dironly= extensions (never in stdlib), all raising TypeError on the 3.9 floor. Path.__init_subclass__ now re-asserts the pathlib_next implementation of copy, move, exists, rglob, read_text and write_text for 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() centralizes ignore_error bool-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() and LocalPath.move() resolved to the incompatible stdlib implementations on Python 3.14. Python 3.14 added methods with those names ahead of pathlib_next.Path in LocalPath's MRO, so calls using pathlib_next extensions such as overwrite= or recursive= failed with TypeError. LocalPath now 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 earlier ValueError behavior on older Python versions.

Changed

  • Documented that stdlib inheritance is deliberately local-only: LocalPath is a real pathlib.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 (Source in the az/gs/s3 schemes, io in utils.archive, abc in utils.stat), dropped unused local variables, and de-f-string'd a placeholder-less message. The RepoBackend re-exports from the github/gitlab schemes are retained (public API, marked # noqa).

0.8.3 - 2026-07-18

Changed

  • AsyncsshSftpBackend default max_concurrency raised 8 → 16. A 128-file loopback recursive-copy/rm sweep of mc ∈ {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 as AsyncsshSftpBackend.DEFAULT_MAX_CONCURRENCY; pass max_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

  • SftpPath could not be imported or used with an asyncssh-only install (no paramiko). uri/schemes/sftp/__init__.py imported ._paramiko eagerly at module load, and _asyncssh.py imported the _DEFAULT_SSH_CONFIG sentinel from ._paramiko, so merely importing SftpPath (or the AsyncsshSftpBackend) required paramiko even when the caller only wanted the asyncssh backend from the sftp-async extra. The paramiko-free bits (the sentinel + config-path normalization) moved to a new _sshconfig module, and the paramiko SftpBackend is now imported lazily (via _probe_paramiko, mirroring _probe_asyncssh) only when actually selected. SftpBackend/_DEFAULT_SSH_CONFIG remain importable from the scheme package (PEP 562 __getattr__) for backward compatibility. PATHLIB_NEXT_SFTP_BACKEND=paramiko (or auto with neither library) now raises a clear ImportError naming the missing extra instead of a bare ModuleNotFoundError at 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() raised TypeError: cannot unpack non-iterable DirEntry object on Python 3.11/3.12. Those stdlib versions define their own pathlib.Path._scandir() (returning raw os.scandir() DirEntry objects), which sits ahead of this project's _scandir() in LocalPath's MRO and silently shadowed it -- breaking the (name, FileStat|None) contract walk()/glob()/rm() expect. LocalPath now defines its own _scandir() explicitly, reusing each DirEntry's cached lstat() so the perf win from _scandir() unification is preserved. On 3.12+, stdlib pathlib.Path also defines its own walk() ahead of ours in the MRO, and that stdlib walk() treats self._scandir()'s return value as a context manager (with scandir_it:) -- our own _scandir() is a plain generator, so stdlib's walk() raised TypeError: 'generator' object does not support the context manager protocol even with the override above. LocalPath now also overrides walk() 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-Extras CI job was red. tests/test_smoke.py unconditionally constructed an http:///sftp:// UriPath in two tests, requiring requests/paramiko even though the no-extras job installs neither; a third test wrongly assumed S3Path requires boto3 to register (it only needs botocore, imported lazily inside a method). The two hard tests now pytest.importorskip their extra; the S3Path check now probes for botocore. 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.utils used typing.ParamSpec (3.10+), falling back to typing_extensions.ParamSpec and then to a bare typing.TypeVar. A TypeVar has no .args, so the *args: K.args annotations raised AttributeError: 'TypeVar' object has no attribute 'args' at import time, making import pathlib_next fail on 3.9 whenever typing_extensions was absent. Since typing_extensions is not a runtime dependency, this broke a plain pip install pathlib_next on 3.9. The final fallback is now a minimal ParamSpec shim providing .args/.kwargs, so no runtime dependency is added and 3.10+ keeps using typing.ParamSpec unchanged.

0.8.0 - 2026-07-13

Added

  • uripath command-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, and AzPath, with bucket/container-root guards.
  • git: convenience dispatch over the existing github:/gitlab: providers, plus explicit git+github: and git+gitlab: forms for self-hosted or enterprise instances. git: only auto-detects public github.com/gitlab.com; ambiguous hosts now raise a clear ValueError naming the explicit alternatives.
  • HTTP write support (PUT, customizable to POST or other verbs via write_method configuration or with_session()) for HttpPath.
  • HTTP delete support (DELETE for unlink() and rmdir()) for HttpPath.
  • Comprehensive HTTP exception mapping in HttpPath translating client/server/timeout/connection errors into standard built-in OSError subclasses (FileNotFoundError, PermissionError, FileExistsError, TimeoutError, ConnectionError, NotImplementedError, or generic OSError).
  • 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 (md5 and sha256).
  • Optional checksum parameter in PathSyncer, defaulting to the new md5 helper.
  • Recursive directory copying via Path.copy(recursive=True).
  • Support for recursive folder moves falling back to recursive copy + recursive delete when rename is not supported.
  • Archive utilities make_archive and unpack_archive supporting ZIP and TAR formats using memory-efficient chunk streaming.
  • Hierarchical test contracts: PurePathContract (pure path operations) and ReadPathContract (read-only path operations), allowing contract-based verification of read-only and memory/archive paths.
  • Contract test suites wired for DataUri, ZipUri, TarUri, and HttpPath.
  • Dedicated unit tests for Path.walk(), samefile(), and Stat device 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 configured mkdocs to 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), and S3Path (moto mock_aws): TestFtpContract, TestDavContract, TestS3Contract run the full PathContract suite against live local servers.
  • ftp_server, dav_server, and s3_server pytest fixtures in conftest.py serving ephemeral in-process servers with pre-populated fixture_tree contents.
  • Entry-point declarations in pyproject.toml (pathlib_next.schemes group) for all built-in schemes, enabling pip-installed external packages to auto-register custom schemes.
  • Plugin discovery tests in tests/test_plugins.py covering _load_entry_point, _load_builtin_scheme, and get_scheme_cls integration.
  • Property-based tests (hypothesis, new dev extra) in tests/test_properties.py: URI parse/format round-trip identity, join associativity, and parity with pathlib.PurePosixPath for segments/name/relative_to/match/is_relative_to.
  • ZipUri/ArchiveUri archive handle registry: independently-constructed UriPath("zip:...")/"tar:..." instances pointing at the same outer archive now share one _ArchiveBackend (keyed by backend class + outer URI, a weakref.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 ZipUri write support: unlink(), rmdir() (empty-dir check, mirrors S3Path.rmdir()), rename() (renames a directory's nested entries too), and overwriting an existing entry's content (previously open("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) since zipfile has 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 with unpack_archive via the new utils.archive._detect_format helper) instead of requiring the caller to know the format up front. Explicit archive+zip:/archive+tar: forms skip detection outright. archive:...!/x and zip:...!/x pointing at the same outer archive share one backend (same registry as zip:/tar:), and write support (new/overwritten entries, unlink/rmdir/rename) works through archive: exactly as it does through zip: when the detected format is zip and the outer archive is local -- tar-detected instances correctly raise NotImplementedError on any write attempt.
  • New gs: (Google Cloud Storage) and az: (Azure Blob Storage) URI schemes (GsPath/AzPath in pathlib_next.uri.schemes.gs/.az, with GsBackend/AzBackend for credential/endpoint override): gs://bucket/key/path and az://account/container/key/path. Both support full PathContract (read/write/list/delete/rename), reusing the prefix-emulation directory semantics of S3Path (no real directories; is_dir() checks for keys under "<path>/", mkdir() writes a zero-byte "<path>/" marker, rmdir() requires empty). Wired to PathContract against faithful in-process fake JSON/XML REST API servers (gcs_api_server/gs_server, az_api_server/az_server in conftest.py), plus scheme-specific unit tests. New examples/gs_listing.py/examples/az_listing.py (env-var gated, fail-soft). Like S3Path, both cache one service client per backend instance (thread-safe for both SDKs). Report no mtime (st_mtime=0, documented divergence). Each is its own pyproject.toml extra: gs (google-cloud-storage) and az (azure-storage-blob). rename() uses server-side copy+delete (same bucket/container only) instead of the generic download+upload+delete move() fallback.
  • New github:/gitlab: read-only URI schemes (GitHubPath in pathlib_next.uri.schemes.github, GitLabPath in pathlib_next.uri.schemes.gitlab, sharing a private _RepoApiPath base and a plain-requests RepoBackend, no PyGithub/python-gitlab SDK): <scheme>://host/owner/repo/path/in/repo?ref=<ref>, ref always optional in the query string. GitHubPath lists via the contents API (one call gives type/size for a whole directory) and reads file bodies via the raw media type; GitLabPath lists via the tree API (no size, so only directory entries get a stat hint) and reads via the files /raw endpoint, resolving+caching the project's default branch itself when ref is omitted (GitLab's file endpoints -- unlike its tree endpoint -- 400 if ref is missing, confirmed live against gitlab.com). host defaults 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 the http extra (no new extra added). Wired to ReadPathContract against faithful in-process fake API servers (github_api_server/gitlab_api_server in conftest.py), plus scheme-specific unit tests (ref propagation through iterdir(), rate-limit/error translation, GitHub Enterprise API-base derivation, GitLab dir-vs-file stat disambiguation). New examples/github_listing.py/examples/gitlab_listing.py.
  • New sftp-async extra: an AsyncsshSftpBackend (asyncssh, async internally, bridged to a sync API through one shared background event loop) alongside the existing paramiko-based SftpBackend. Auto-selected when asyncssh is importable (paramiko remains the fallback); override via the PATHLIB_NEXT_SFTP_BACKEND env var ("paramiko"/"asyncssh"/"auto") or a SftpPath._default_backend_cls subclass hook -- precedence, highest to lowest: explicit backend= kwarg > _default_backend_cls > env var > auto-detect. PATHLIB_NEXT_SFTP_BACKEND=asyncssh with 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; current asyncssh needs >=3.10) resolved automatically through pyproject.toml environment markers -- no code branching. New SftpPath.symlink_to()/readlink() (both backends -- core SFTPv3 operations) and hardlink_to() (asyncssh backend only; paramiko's SFTPClient has no hard-link operation, so it raises NotImplementedError immediately with no server round trip). chmod(follow_symlinks=False) now works on the asyncssh backend (native support) while still raising NotImplementedError on paramiko (no lchmod equivalent). 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.
  • PathSyncer reuses child metadata during tree sync when that metadata is consistent with the active symlink-following policy.
  • Replaced the third-party htmllistparse and bs4 directory listing scraper dependencies with a hand-rolled, zero-dependency html.parser.HTMLParser subclass (_DirectoryListingParser), dropping both from the http extra in pyproject.toml. Verified equivalent output (name/size/modified, both Apache-<pre> and nginx-<table> formats) against the replaced bs4+html5lib+htmllistparse implementation, and 2.9x-6.2x faster depending on format/listing size (benchmarks/bench.py's 8/9 entries 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 through logging.getLogger("pathlib_next.sync") at INFO instead of calling print() -- stdout consumers must configure logging (e.g. logging.basicConfig()) to see sync progress again. EVENT_LOG_FORMAT switched from str.format ({event}) to %-style placeholders to match, and log() remains overridable for custom routing.
  • SyncEvent members are now numbered sequentially (previously a mix of explicit ints and enum.auto(), which raised a DeprecationWarning on Python 3.13). Values are not part of any documented/persisted contract.
  • Optimized performance across pure paths and URIs:
  • Cache Uri.segments in a slot to avoid re-splitting the path string on every access.
  • Cache Uri.suffix and Uri.stem in slots.
  • Optimize Source.__bool__ to use lazy index accesses and avoid tuple iteration.
  • Short-circuit Query.__new__ when the input is already a matching Query instance.
  • Uri._parse_uri()/Source.from_str(): one-pass component extraction from uritools.urisplit()'s raw fields instead of calling its seven get*() accessors, each of which independently re-rpartitions the authority string and re-decodes. Ported (not reinvented) from uritools.SplitResult's own property/getter logic -- including one of its quirks, reproduced on purpose (see uri/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 of uritools.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. uritools itself 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 full Uri(unique_url).as_uri() round trip (parse + compose, both changes) is ~20-25% faster; the parse side alone, isolated from Uri.__new__'s slot-initialization overhead (unaffected by this work), is ~17-30% faster on its own (see benchmarks/bench.py's 1b/1c entries).
  • New Path._scandir() / UriPath._scandir() protocol: schemes whose listing call already returns type/size/mtime for every child (HTML directory index, WebDAV PROPFIND, SFTP listdir_attr, FTP MLSD, an S3 list_objects_v2 page) can now yield (name, FileStat) pairs directly, and walk()/glob() answer is_dir() from that instead of a stat() round trip per entry -- a remote-tree walk goes from O(entries) requests to O(dirs). HttpPath, DavPath, SftpPath, FtpPath, and S3Path all adopt it; _listdir()/iterdir() remain fully supported for schemes that don't override _scandir() (no behavior change, no win). On the local http_server benchmark fixture, HTTP glob/walk over the fixture tree are ~89-94% faster than the already-optimized pre-_scandir() baseline (see benchmarks/bench.py). HttpPath also drops its _isdir instance-cache slot and its is_dir()/is_file() overrides (now derived generically from stat(), like every other scheme) in favor of a single-use stat hint seeded by _scandir(); DavPath's now-redundant iterdir()/ 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 from pathlib_next.uri.schemes.dav (the old pathlib_next.uri.schemes.webdav path no longer exists). archive.py -> archive/ package (_base.py shared machinery, zip.py, tar.py) -- import-compatible for free, pathlib_next.uri.schemes.archive still resolves (now the package) and re-exports ArchiveUri/ZipUri/TarUri. sftp.py -> sftp/ package (_paramiko.py holds the existing paramiko-backed SftpBackend; __init__.py keeps SftpPath/BaseSftpBackend) -- same free import-compat, pathlib_next.uri.schemes.sftp still resolves and re-exports SftpPath/BaseSftpBackend/SftpBackend. Prepares the layout for an upcoming second (asyncssh) backend; SftpBackend gained a default() classmethod factory so SftpPath._initbackend() doesn't need to import paramiko itself.
  • SftpPath's connection caching moved from an external cache wrapping backend.client() calls to being each backend's own responsibility (SftpPath._sftpclient is now a trivial self.backend.client(self.source), no per-backend branching). Needed so the new asyncssh backend can use its own (backend, source)-keyed cache (see the sftp-async entry above) without SftpPath needing to know which caching scheme applies. Behavior-affecting for custom BaseSftpBackend subclasses: a client() override that doesn't cache internally will now be called on every _sftpclient access, not just on a cache miss -- SftpBackend/AsyncsshSftpBackend both 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 own SFTPServer (chrooted to fixture_tree) instead of a ~150-line hand-rolled paramiko ServerInterface/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). TestSftpContract itself 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 for S3Path, GsPath, and AzPath.
  • 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 .segments property instead of the root-aware _segments_of() helper is_relative_to() already used -- Uri("/").segments is the 2-tuple ("", "") (an artifact of "/".split("/")), so relative_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 returned name == "sftp" directly instead of delegating to paramiko.ServerInterface's default implementation, which is what actually instantiates and starts the registered SFTPServer handler 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, so SFTPClient.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, untyped OSError("Failure") when the target already existed -- SFTPv3 has no dedicated "already exists" status code, so paramiko's server-side convert_errno() falls through to SFTP_FAILURE for EEXIST (unlike ENOENT, which it does map, giving a proper FileNotFoundError). Both now check self.exists() on failure and raise FileExistsError to match every other scheme's mkdir/touch(exist_ok=False) contract (mirrors FtpPath._mkdir()'s existing check-after-failure pattern). Found by TestSftpContract once the deadlock above was fixed and it could actually run.
  • FtpPath.stat() returned FileNotFoundError for the FTP root path "/" because _mlsd_entry() has no parent directory to query; now uses CWD / to confirm the root exists as a directory.
  • FtpPath.rmdir() propagated raw ftplib.error_perm (550) instead of OSError when the directory was non-empty, violating the pathlib contract.
  • FtpPath.chmod() raised ftplib.error_perm when the server rejected SITE CHMOD (pyftpdlib does not implement it); now converts to NotImplementedError so Path.copy() silently skips the metadata step.
  • pytest filterwarnings updated to suppress boto3.exceptions.PythonDeprecationWarning (boto3 EOL notice for Python 3.9, inherits Warning not DeprecationWarning) and ResourceWarning from 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):, and s3: (all shipped in 0.6.0/0.7.0 but previously only documented in the Schemes guide).
  • LRU.maxsize setter raised TypeError when shrinking below the current fill (OrderedDict.pop() was called with the last=False kwarg meant for popitem()).
  • DavPath.rmdir() mapped directly to WebDAV DELETE, 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 raises OSError (ENOTEMPTY) if children exist. The native recursive DELETE is still available, and cheaper than the base class's client-side walk, via the new DavPath.rm(recursive=True) override (one request).
  • Uri._make_child_relpath() doubled the join slash for any scheme whose path already 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 like 1,024 matched only "1", truncating size and leaking ,024 into description.
  • 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 dropping modified and every field after it for that entry. Narrowed to \S+ (the timezone is one token).
  • _DirectoryListingParser's absolute-href filter was a blanket startswith('/') -- 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) surfaced PermissionError for 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 via requests' 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 second close() call (context-manager __exit__ cleanup, or GC via IOBase.__del__) silently retried the PUT. Now marks closed even on failure.
  • HttpPath.rmdir()/DavPath.rmdir() never checked is_dir() before falling through to unlink() -- an empty directory's listing and a file whose body/PROPFIND response yields zero real entries are indistinguishable from _listdir() alone, so calling rmdir() on a file silently deleted it instead of raising NotADirectoryError (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): extends HttpPath with 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 equivalent http:/https: URL; as_uri() still reports dav:/davs:. Reuses the http extra, no new dependency. rmdir() is recursive by WebDAV spec, unlike pathlib.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 via boto3. New s3 extra. 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-side copy_object+delete_object (same-bucket only) instead of the generic download+upload+delete move() 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_size is the decoded payload length; iterdir() raises NotADirectoryError (it's always a single file); write operations raise NotImplementedError.
  • ftp:/ftps: scheme (pathlib_next.uri.schemes.ftp.FtpPath): full read/write/list access via stdlib ftplib, with a thread-keyed LRU connection cache mirroring sftp.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 on close(). chmod() uses the common but non-standard SITE CHMOD extension (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 is zip:-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, raises NotImplementedError). tar: auto-detects .tar.gz/.tar.bz2/.tar.xz compression 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 with AttributeError: _drv the 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 via LocalPath(...) directly instead, so this went undetected until examples/local_and_mem.py exercised the documented Path(...) entry point end to end.

Fixed (found by the new test suite, not in the original bug list)

  • LocalPath.stat()/chmod() inherit directly from pathlib.Path via MRO and crashed with TypeError on Python 3.9 the moment anything passed follow_symlinks= (e.g. Path.walk()'s default follow_symlinks=False) -- now shimmed with lstat()/lchmod() on <3.10, same as the existing FileUri shim (which now just delegates to LocalPath).
  • MemPath.__init__ decided whether to propagate a parent's backend with if _backend and backend is None: -- an empty (but valid) backend dict is falsy, so joining off a freshly-created, empty MemPath silently gave the child a disconnected new backend instead of sharing the parent's.
  • MemPath.stat() never set st_size for files (always defaulted to 0), breaking any size-based checksum comparison (notably PathSyncer'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 on MemPath'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, and Pathname.name derives from the last path segment -- empty for a trailing-slash path.
  • SftpPath.rename() resolved a plain string target relative to self (joining it as a child, e.g. "/a.txt".rename("b.txt") produced "/a.txt/b.txt") instead of self's parent (sibling rename).

Added (test suite)

  • Full pytest suite (tests/): pure-path parity against pathlib.PurePosixPath (test_parity_pure.py), local I/O parity against pathlib.Path/os.walk (test_parity_io.py), a reusable filesystem-contract mixin run against LocalPath/MemPath/FileUri and exported as pathlib_next.testing. PathContract for third-party Path/UriPath implementers (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 stdlib ThreadingHTTPServer, and PathSyncer coverage. 300 tests, ~85% line coverage, green on both Python 3.9 and 3.13.

Added (docs)

  • docs/guides/schemes.md (capability matrix per scheme) and docs/guides/extending.md (both extension tracks, with worked examples and pathlib_next.testing.PathContract usage). Rewrote docs/index.md and 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.py and examples/sftp_sync.py (network-touching, guarded under if __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() (default st_dev/st_ino comparison when the backend's stat() provides them, NotImplementedError otherwise).
  • Path.glob()/LocalPath.glob(): recursive= now auto-detects (True if the pattern has a "**" component) instead of defaulting to False; explicit recursive=True/False still overrides.
  • Path.copy(): raises IsADirectoryError when the target is an existing directory (previously misbehaved); gained follow_symlinks=/ preserve_metadata= kwargs, named to match CPython 3.14's Path.copy().
  • docs/divergences.md: registry of every deliberate behavioral divergence from pathlib, with rationale. Linked from the docs nav.

Fixed

  • Path.mkdir(parents=True) created intermediate parents with exist_ok=False (racy, and wrong when a parent already existed) and dropped the caller's exist_ok on the final retry.
  • Path.touch(exist_ok=False) silently truncated an existing file instead of raising FileExistsError (pathlib parity).
  • LocalPath.glob()'s dironly parameter defaulted to False, which made the is None check for trailing-slash directory-only detection dead code.
  • Stat._st_mode() only caught FileNotFoundError, letting PermissionError and other OSErrors propagate out of exists()/is_dir()/etc. where pathlib returns False. Also fixed: follow_symlinks was accepted but never forwarded to the underlying stat() call, so is_symlink() never actually inspected the symlink itself.
  • MemPath._open() treated any mode other than "w" as a read, so "a"/"x" silently misbehaved; now dispatches r/w/x/a correctly and raises NotImplementedError for anything else. MemBytesIO.close() used seek(0);read() instead of getvalue(), losing content if the caller's cursor wasn't already at position 0 when closing.
  • MemPath.normalized mangled ".."-escaping paths (e.g. "..") into "."; now normalizes against a virtual root so they clamp at the root instead.
  • PathAndStat.__getattr__() returned None for any unrecognized attribute instead of raising AttributeError, breaking hasattr()-based logic.
  • parsedate(None) / an unparseable date string returned "now" instead of epoch 0, which could poison PathSyncer's checksum/freshness comparisons for HTTP sources with no Last-Modified header.
  • HttpPath.stat() used a bare except:; cached _isdir from a response that hadn't been confirmed successful yet (including 404s); and didn't fall back to GET when a server rejected HEAD with 405.
  • uri.Query no longer depends on uritools' private _querydict/_querylist helpers (reimplemented locally against the public uriencode()).
  • Uri join (_load_parts): query/fragment are now resolved with the same "last segment that actually sets one wins" rule already used for source (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) and get_machine_ips() are now functools.lru_cached -- previously ran on every call.

Fixed (crash-level bugs)

  • MemPath.stat()/MemPath._open() returned a FileNotFoundError instance instead of raising it for a missing path, causing an unrelated AttributeError downstream.
  • LRU.invalidate() called self.lock() instead of using self.lock as a context manager (RLock isn't callable) -- broke the SFTP client reconnect path.
  • Pathname.match() had reversed isinstance() arguments and compared against str(self) (which includes scheme/host for Uri) instead of as_posix().
  • Glob wildcard detection (WILCARD_PATTERN, renamed WILDCARD_PATTERN, old name kept as an alias) used .match() (anchored) instead of .search(), so patterns like "foo*" weren't recognized as wildcards.
  • Uri was unhashable (defined __eq__ without __hash__); __eq__ now also returns NotImplemented for non-Pathname/str operands instead of raising.
  • Uri.is_relative_to() used str.startswith() on normalized path strings, so /foo/bar2 was incorrectly reported as relative to /foo/bar; now compares path segments.
  • Uri.relative_to(walk_up=True) was dead code -- an early guard raised ValueError before 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 accept follow_symlinks=, so the inherited lchmod() crashed with TypeError; now raises NotImplementedError for follow_symlinks=False (paramiko has no lchmod).
  • SftpPath defined _rename(), which nothing ever called -- renamed to rename() so move()/rename() actually use SFTP's native rename instead of silently falling back to copy+unlink for every move.
  • Uri.__init__() used a bare except: around Path.as_uri() (now except ValueError:, matching what as_uri() actually raises for relative paths) and crashed with AttributeError when constructing from an os.PathLike that only implements __fspath__ (no as_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/Uri case-sensitivity and path-separator detection crashed on 3.9-3.11 (_flavour object has no normcase); open(mode="r") crashed on <3.10 (io.text_encoding is 3.10+); glob pattern compilation crashed on <3.11 (re.NOFLAG is 3.11+); FileUri.stat()/chmod() crashed on 3.9 (pathlib.Path.stat/chmod gained follow_symlinks= in 3.10; raises NotImplementedError there for follow_symlinks=False).
  • LocalPath._path_separators returned the env-var list separator (;/:) instead of the path separator, and could include a None altsep 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 resolving README.md during editable installs on CI.
  • Converted README.md from a symlink (mode 120000) to a regular file, fixing git checkout failures 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.typed for PEP 561 compliance.

Changed

  • Added backward compatibility support for Python 3.9 and 3.10: added from __future__ import annotations across the codebase, refactored runtime-evaluated union types to use typing.Union, and provided fallbacks for TypeAlias and ParamSpec.
  • 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.