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.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.