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()andsupported_checksums()now send thecheck-file-handleextension onAsyncsshSftpBackend(the backend chosen when asyncssh is installed), as they already did on the paramiko backend. Against a server that implements it (ProFTPD's mod_sftp, for example),PathSyncercompares server-side digests instead of reading both files. OpenSSH does not implement it: the refusal costs one request per connection, and files are compared by streaming as before. A nativechecksum()is not bounded by the backend'stimeout, because the server hashes the whole file before it answers.
Changed
- The
uriextra now requiresnetimps>=0.3.1(was>=0.2.0). An environment that pins an older netimps must raise that pin.
0.9.10 - 2026-09-20
Added
Path._same_filesystem(other), an override hook answering whether two paths of one type resolve their segments in the same place. Path equality ignores that, andcopy(),move()andPathSyncernow ask it before calling two paths the same file, nested, or overlapping. A path type whose instances can front different hosts or stores should override it; the default keeps the previous behaviour.pathlib_next.testing.PathContractgains two tests: a file copied onto a separately built spelling of itself is refused with its content intact, and paths under one root share a filesystem.
Changed
- The built-in
Base*Backendclasses (s3,gs,az,ftp,sftp,github/gitlab) are weakly referenceable, which is how aUriPathtells a backend it derived for itself from one it was given. A custom backend class using__slots__without"__weakref__"still works; it is simply always read as supplied.
Fixed
- A path type that fronts several hosts can transfer between them again.
Since 0.9.4,
copy()/move()raisedOSError(EINVAL)"Source and target are the same file" andPathSyncerraised "source and target overlap" for/app.confon one host onto/app.confon another, whenever the type kept its connection under any attribute other than the private_backendthe guards looked for. Such a type now overrides_same_filesystem(). - Two separately built URIs to the same URL are recognised as the same
file. Each derived its own backend on first use, which the guards read as
two hosts:
UriPath(url).copy(UriPath(url), overwrite=True)was not refused on any scheme withoutst_dev/st_ino(S3 and WebDAV among them), and neither was a sync of a URL onto itself. Only two backends the caller supplied now tell equal URIs apart. One consequence: a path given a backend and a bare path to the same URL are now the same file forcopy()/move(), as they already were forPathSyncer. copy(recursive=True)'s "into itself" check now honours two supplied backends, like the other two guards; it refused a copy between two hosts whose URIs nested.
0.9.9 - 2026-09-17
Changed
- CI gains a URI-only job (
test.yml): the package installed with just theuriextra and no scheme client at all, running the modules that must work without one plus an explicit check that a pure-path join imports nothing. Every other job installs all extras, which is why a join that built a backend -- and so needed paramiko to spell ansftp:path -- survived from 0.9.3 to 0.9.8 behind a green matrix.
Fixed
glob(bound_loops=True)no longer drops a directory shared under two names. It bounded on every identity seen during the walk, so one directory junctioned in as bothsite-aandsite-b-- a deliberate layout, and not a loop -- expanded under the first name only, silently. A loop is a directory reachable BELOW ITSELF, so the bound is now the current descent path rather than everything seen, which is the linefind -Ldraws. The loop case is unchanged: 128 matches become 2. Reported by yaconfiglib against 0.9.7.
Documentation
- Corrected the 0.9.8 entry's provenance. It says 0.9.7 introduced the
join-builds-a-backend defect; it did not. Measured against the published
wheels in a venv with only the
uriextra:UriPath("sftp://h/mnt") / "c"raisesImportErroron 0.9.3 and 0.9.6 as well, so the defect predates the 0.9.7 join rewrite, which preserved it rather than causing it. 0.9.8 fixes it for the first time. (A related and DELIBERATE behaviour, unchanged throughout: constructing anhttp:/s3:path at all requires that scheme's extra, because the scheme module imports its client -- see the extras table in the shipped header.)
0.9.8 - 2026-09-17
Fixed
- Joining a URI path no longer builds a backend. 0.9.7 routed
/andjoinpath()through the child builder a listing uses, which reads thebackendproperty -- and that property CREATES one, so spellingUriPath("sftp://h/x") / "y"imported paramiko and raisedImportErrorwithout the extra (http:wantedrequests,s3:botocore). A join is a pure-path operation and is lazy again; a child still shares its parent's connection when one already exists, which is all the sharing was ever for.
0.9.7 - 2026-09-17
Added
rm(follow_symlinks=, follow_binds=): what a recursive removal does with a symlink, and with a binding (a Windows junction, a mount point). Each takesFalse(the default -- remove the entry itself, never its contents, asrm -rdoes),True(remove what is behind it),None(leave it in place, so the enclosing directory is not empty and reports it), or a callablepolicy(path) -> bool | Noneasked per entry, so one tree can keep one mount and follow another. Named for the keywordstat(),walk()andcopy()already use for the same idea, rather than a second vocabulary.-
Path.is_junction(),Path.is_mount()andPath.is_dir_binding(): a directory that is another tree's second NAME -- a Windows junction, or a mount point such as a Linux bind mount -- is now a first-class concept rather than a private hook used by one call site. Neither is a symlink (is_symlink()is False, a non-following stat reports a plain directory), which is precisely why a symlink check cannot protect a walk from one.is_junction()matches pathlib 3.12's; both are False by default and answered for real byLocalPath/FileUri. -
Path.glob(on_error=)/rglob(on_error=): a hook called ason_error(error)when a directory cannot be listed, the same contract aswalk()andos.walk. Raising from it makes an unreadable directory fatal; returning treats it as empty. Without the hook the listing is skipped silently, as pathlib does and as before -- which left a caller unable to tell an unreadable layer from an absent one.error.filenamenames the directory even when the backend left it unset. Path.glob(bound_loops=)/rglob(bound_loops=): withTrue, a directory is descended at most once per**, keyed on(st_dev, st_ino)and seeded with the starting directory. This bounds a Windows junction loop -- a junction reportsis_symlink() == False, sorecurse_symlinks=Falsecannot see it, and one file in a looping tree matched 64 times (pathlib walks it the same way). DefaultFalsekeeps pathlib's behaviour; a backend whose stat carries no identity is walked unbounded. Both reported by yaconfiglib against 0.9.6.
Fixed
- An SFTP listing is untrusted input. The server chooses the names, and
they were turned into child paths unchecked, so a crafted
../victim.txtmaderm(recursive=True)delete outside the tree it was given and a recursive copy read from outside it (measured on both backends). Names that are not a single component inside the directory are now skipped, asdav:andhttp:already did. The asyncssh fan-out walkers, which bypass_scandir(), filter for themselves. unpack_archive()split a backslash on every platform, so a POSIX member whose name contains one was extracted as two path components and one carrying..was silently dropped -- both legal single filenames there. A backslash now separates only for a Windows destination, which is the rule the function already documented.has_glob_pattern()was True for every Windows extended-length path. The?in a\\?\C:\...anchor (and the\\?\UNC\...form) is a prefix, not a wildcard, so a caller using it to tell a pattern from a plain path got the wrong answer for all of them. The anchor is no longer scanned. Reported by yaconfiglib against 0.9.6.- Writing over an archive member spelled
./f.txtappended a second entry instead of rewriting it, andunlink()then deleted that second entry and reported success while the original content came back. 0.9.5 handed the normalized name to a backend keyed on the raw one; writes now address the entry the archive really holds. - Renaming a directory in such an archive silently did nothing, or
split it in two when its members were spelled inconsistently, while
rename()returned the new path as though it had worked. The backend now receives the exact raw-name mapping instead of a normalized prefix. - Every archive operation rebuilt the member index, so a listing, stat, read or write on a 20k-member archive scanned all 20k names: measured 10x-224x slower than 0.9.4. The index is cached per open handle and dropped whenever the handle is, which every mutation and every external change already go through.
glob(None)ignorednative=, never auto-detected**, and answered differently fromrglob(None)for the same path, because that branch bypassed the pattern parser:LocalPath("/x/**/*.py").glob(None)returned a shallow subset with no error.native=Falsealso left the trailing-**rule following the interpreter, so it did not deliver the one-answer promise it documents; it now pins that rule too./mangled adata:payload (data:,a/../bjoined todata:b/x, not a data URI at all), dropped a scheme's own child handling (gitlab:'s reserved-), still parsed abytesname as URI syntax, and lost per-instance scheme state such asSftpPath'sssh_config. Joins now walk segments through the same builder a listing uses.- A relative
strdestination whose first segment merely contained a colon (notes:draft,Fedora-42:latest.tar) was read as a URI scheme bycopy()/move(); the scheme must now be one a class registers, as theuripathCLI already required. A Windows drive path (C:/Temp/x) restarts the join for afile:path, asPureWindowsPathdoes. rename()andcopy()/move()resolved a relativestrdifferently (rename("../b")sent a literalsub/../b, a different key on an object store); both now resolve it the same way. A same-endpoint URI destination reuses the configured connection instead of opening a second, bare one.
Changed
rm(recursive=True)no longer descends into a mount point. It already removed a Windows junction as a binding rather than walking into it; a POSIX bind mount is the same thing and was walked, so deleting a tree containing one deleted the mounted filesystem's contents. It is nowrmdir()'d like a junction, which fails loudly on a live mount instead.UriPath / "name"andjoinpath()read astras a decoded path, not as URI syntax.base / "cache?v=2"is now the filecache?v=2instead ofbase/cachewith a query;"note#2.txt","a%20b.txt"and"C:/Temp"join verbatim too. This is whatiterdir()always did, so listing a directory and naming the same child by hand finally agree. Pass aUri/UriPathargument for a scheme-aware join (base / UriPath("s3://bucket/key")); that stays the only form that can cross endpoints, and it still drops a credential-bearing backend on the way. Dot segments are removed from the result as before.copy()/move()accept a plain-pathstrdestination. A string with a scheme is a URI, as before, socopy("s3://bucket/key")keeps working; one without is a decoded path on the same endpoint (absolute replaces the path, relative is a sibling, asrename()resolves it) and reuses this path's source and backend.move("b.txt")previously built a sourceless path and failed on its firstexists()call, and a same-host URI destination opened a second connection. A one-letter scheme is treated as a Windows drive, soC:/Temp/xis a path.
0.9.6 - 2026-09-16
Added
Path.glob(None)/rglob(None): expand the pattern the path itself carries (LocalPath("/etc/*.conf").glob(None)), splitting at the first wildcard.glob("")still raisesValueErroras pathlib does --Noneis the spelling that cannot collide with a real pattern -- so this restores what 0.9.4 removed as an explicit, supported form rather than by accident.Path.glob(native=)/rglob(native=), defaultTrue: follow the running interpreter on the two rules pathlib changed mid-series. A trailing/is ignored before 3.11 and selects directories only from 3.11; a component that merely contains**(a**) raisesValueErrorbefore 3.13 and is a plain wildcard from 3.13.native=Falseapplies one rule on every version instead, which is what a caller comparing results across backends or interpreters wants.
Changed
glob()now matches the running interpreter exactly, including on Python 3.9-3.12 where it previously applied its own rule for a trailing/and fora**. Measured with a 46-comparison differential sweep againstpathlib: 3.14 was already identical, and 3.9 went from 4 disagreements to 0. Passnative=Falsefor the previous, version- independent behaviour;pathlib_next.testing's contract suite does.
Documentation
- Named the replacement for
glob(""), which 0.9.4 removed for pathlib parity:pathlib_next.utils.glob.glob(path, recursive=...)expands a pattern the path itself carries, splitting at the first wildcard. The 0.9.4 entry withdrew the capability without naming it, andPath.glob()'s docstring documented theValueErrorbut not the alternative. Reported by yaconfiglib, whosepath.glob("", recursive=...)calls stopped working.
0.9.5 - 2026-09-16
Fixed
- Archive member names are normalized, so the same member is reachable
however the archive was written and whichever format it is. A leading
./(whattar -C dir .,TarFile.add(arcname=".")andshutil.make_archiveput on every member), empty segments (a//b) and interior./..(a/./b,a/b/../c) now resolve to one name for listing and lookup alike. Previously onlytar:stripped a leading./: a zip written that way listed as empty and none of its members could be read under any spelling, while a name such asd//e.txtwas readable but absent from listings.
Changed
- A drive- or backslash-shaped member name is no longer dropped.
C:drive.txtanda\bare ordinary filenames on POSIX, and an archive written there may contain them; they were silently absent from every listing and unreadable on every platform. The rule they were failing is a destination rule, and now lives where the joining happens:Path.copy(recursive=True)refuses a child name that would not stay inside its target (ValueErrorthroughignore_error), which is whatPathSyncerandutils.unpack_archive()already did per destination. Copying such a member onto a Windows path is still refused; copying it to a POSIX path, aMemPathor another archive now works. - A member name that escapes the archive root (
../x,/abs, a..with nothing left to consume) has no name inside the archive: it was already never listed, and is now never readable either. Such a member used to be hidden from listings whileread_bytes()still returned it under its raw name. (Writing to one already failed and created nothing; that is unchanged.) A..that stays inside is resolved rather than rejected (pkg/../ok.txtreadsok.txt), and when two spellings normalize to one name the later member wins, as inzipfile/tarfile. Uri's RFC 3986 dot-segment removal is documented as a divergence frompathlib(docs/divergences.md); the behaviour is unchanged.
0.9.4 - 2026-09-16
Fixed
Path.copy()destroyed or created the target when the source could not be read. It unlinked an existing target (withoverwrite=True) and opened the target for writing before opening the source, so a missing file, a directory withoutrecursive=True, or a source HTTP 404 left the target empty, or left a new 0-byte file that made a retry fail withFileExistsError. The source is now opened first, and a copy that fails mid-stream removes its partial target.- Copying or moving a file onto itself deleted it.
f.copy(f, overwrite=True)(or onto a case-insensitive alias such asF.TXTon Windows/macOS) emptied the file; a case-only rename withmove(overwrite=True)deleted it.copy()now raisesOSError(EINVAL, "Source and target are the same file");move()renames in place. move(overwrite=True)removed the target before checking the source. A missing source, or a file moved onto a directory, deleted the target (including a whole tree) and only then raised. It now raisesFileNotFoundError/IsADirectoryErrorfirst and leaves the target alone. A local file target is replaced atomically withos.replace(), so a locked source on Windows no longer costs the target.rm(recursive=True)deleted files outside the tree through Windows junctions andfile:directory symlinks. A junction reads as a directory to a non-following stat, andUriPath's default_scandir()used a following stat, so both were descended into and their targets' contents deleted. Both are now removed as links.FileUrilistings also reuseLocalPath's scandir metadata (one call per directory).PathSyncer.sync()could delete or write outside its target.- A root source that does not exist now raises
FileNotFoundError. Before, withremove_missing=Trueit deleted the entire target (a typo, an unmounted share, a 404); without it, it reported success. Callers that relied on syncing an absent source as a no-op must now catch the error or passignore_error. - Overlapping source and target (one inside the other, same
implementation and backend) now raise
ValueError. Before, the source could be deleted, or copies nested untilRecursionError. - A child name that would leave the target (
.., a name the parent-name fallback turned into.., or\/:on a Windows target) now raisesValueErrorthroughignore_error. Before, such entries from an S3, SFTP or archive listing were written, or removed, outside the target. - Entries listed with an unknown stat (GitLab blobs, FTP without MLSD) are
now re-stat'd. Before, with
follow_symlinks=Falsenothing was copied andremove_missing=Truedeleted the existing mirror. - A symlink inside the target is replaced by the real file or directory. Before, sync listed, wrote and deleted through it, into whatever it pointed at.
http:/dav:listings yielded.and..as children. wsgidav's parent row (<a href="..">) became a file named.., and unlinking it deleted the parent collection; a./entry madewalk()loop forever; a PROPFIND href%2E%2E/let a recursive copy write outside its destination. Such names are no longer listed.DavPath.unlink()andHttpPath.unlink()deleted whole collections. They sent a bareDELETE, which WebDAV applies recursively;unlink()on a directory, andsymlink_to(force=True)over one, removed the tree. Both now raiseIsADirectoryErrorfor a directory (HttpPathrelies on its HEAD-based directory check).rm(recursive=True)still deletes trees.DavPathread an HTTP error page as file content.open("rb")/read_bytes()/copy()on a missing or forbidden file returned the server's 404/401/500 body. They now raiseFileNotFoundError/PermissionError/OSError.- Reading a local
zip:archive opened it for writing. A read-only zip was unreadable (exists()returnedFalse),exists()on a missing archive created it, and probing a file that is not a zip appended 22 bytes to it. Reads now open the archive read-only; the first write into a missing archive creates it. Probing a non-zip file now raiseszipfile.BadZipFile. - Zip
unlink()/rename()/overwrite reset every other member. The rewrite gave all members the current time, DEFLATE compression and mode 0600, and dropped the archive comment and any leading bytes (a zipapp shebang); it also replaced a symlinked archive with a regular file. Member metadata, the comment, the prefix bytes and the archive's file mode are now kept, and a symlinked archive stays a symlink. - Zip
rename()onto an existing member created a duplicate name, and a later rewrite kept the old content. It now replaces the target (POSIX semantics; seedocs/divergences.md). - Archive listings exposed traversal member names. Members named with
.., an absolute path,\-separated traversal or a drive prefix are no longer listed, soiterdir()/walk()/copy(recursive=True)cannot write outside a destination through them. -
utils.unpack_archive()let crafted members escapedeston Windows (D:evil.txt, and the same-driveC:../C:../x). Such members, and any member with a..part (previously extracted with the..dropped), are now skipped. -
rename()/move()renamed onto the wrong host, bucket or archive. Every scheme renamed through its own connection or bucket with only the target's path:SftpPath/FtpPathmoves to another server renamed on the source server,S3Path/GsPathmoves to another bucket landed in the source bucket (overwriting an existing object of that name there), a zip member moved to a local path was renamed inside the archive, andLocalPath.move()onto a remote path renamed a local file.rename()now raisesNotImplementedErrorfor a target on another endpoint, archive or Azure container, andmove()copies and deletes instead.move()also falls back to copy + delete on a cross-device rename (EXDEV), andSftpPath.copy(recursive=True)uses its concurrent fan-out only for a target on the same host. - Session credentials and tokens followed a join to another host.
base / "http://other/x",UriPath(base, url)andbase.with_source(...)reusedbase's backend, so anHttpPath.with_session(auth=...)session or agithub://TOKEN@...token was sent to the other host. A backend is now reused only for the same scheme, userinfo, host and port. AzPath.rename()with astrtarget always raisedTypeError, and a pending copy crashed withKeyErrorafter starting it.GsPath/AzPath/S3Path.rename()onto the same key deleted the object. Both fixed.FileUri.rename("b.txt")resolved against the process cwd and returned aLocalPath. It now renames within the same directory and returns aFileUri.-
MemPath.copy("/b.txt")/move("/c.txt")wrote into a new, empty in-memory filesystem, andmove()then deleted the source. Astrdestination now stays on the source's backend. -
SftpPath.copy()raisedModuleNotFoundErrorwithout asyncssh (paramiko-onlysftpextra), including every single-file download andPathSyncerwith an SFTP source. - SFTP connections leaked or went stale. A first-call asyncssh
rm(recursive=True)hung for 60 s and deleted nothing; dropped paramiko connections and closed asyncssh SFTP channels were never replaced, so every later call failed andexists()returnedFalse; evicted connections, failed logins and failed SFTP starts leaked sockets and threads; concurrent first calls opened duplicate connections; a recursive copy held two remote handles open per file in the tree;SftpPath(url, ssh_config=...)ignoredssh_config; the paramiko backend ignored ssh_configInclude. UriPath("ftps://...")returned a stubUriPathin a fresh process instead ofFtpPath.- URL credentials leaked into HTTP errors and redirects. They are now sent
as Basic
auth=instead of inside the request URL, WebDAVMOVEDestinationno longer carries them, and translated errors no longer chain therequestsexception (__cause__isNone; the message carries the HTTP status and reason). URL credentials now take priority over a matching~/.netrcentry. -
github:/gitlab:tokens leaked throughstr()/repr()/errors, anduser:TOKEN@hostauthenticated with the username. The token is now read from the password slot when present, and these schemes redact the whole userinfo. -
glob()/rglob()crashed, hung or returned wrong results.glob("**")andglob("dir/**")raisedNotADirectoryErroron any tree containing a file;**followed directory symlinks, so a symlink loop produced duplicates effectively forever; globbing under a missing directory or a file raised instead of yielding nothing; repeated**returned duplicates; a trailing/matched files onMemPathand URI paths;?never matched on aUriPath(read as a query);glob.full_match()slowed down exponentially with repeated**. A trailing**now follows the running Python (files too on 3.13+). match()onMemPath,Uriand everyUriPathdid not follow pathlib. It anchored at the start and let*cross/, and a URI'shost:prefix defeated absolute patterns. It is now pathlib's right-anchored per-segment match; an empty pattern raisesValueError.LocalPathacceptsmatch(case_sensitive=)on 3.9-3.11, and itsfull_match()handles rooted, drive and backslash patterns before 3.13.parents/parentof an absoluteMemPathorUrilost the root.MemPath('/a/b').parentsis now['/a', '/'];Uri('http://h/a').parentishttp://h/; a top-levelFileUri's parent is/(or the drive rootC:/on Windows) instead of resolving to the current directory.relative_to()/is_relative_to()treats3://bucket/http://has the root, sorelative_to(p.parent)andwalk_up=Truework.with_name()/with_stem()/with_suffix()accepted'',.and separators onMemPathandUri, splicingx/yor../../etcinto a path. They now raiseValueErrorlike pathlib.MemPathdid not normalize likePurePosixPath.MemPath('/') / 'a'was//aand unequal toMemPath('/a'); a trailing/changed equality; an absolute join did not reset. All now matchPurePosixPath.Path.touch()made new files world-writable and could truncate existing ones. It chmod'ed every new file to 0o666 ignoring the umask (file:, SFTP, FTP) and treated anystat()error as "missing".modenow defaults toNone(chmod only when passed), an existing file is never truncated, andFileUri.touch()uses pathlib'stouch().copy()made local copies read-only.preserve_metadata=Trueapplied the placeholder 0o444/0o555 mode thatMemPath, HTTP, WebDAV, object stores and archives report, so a copied file could not be overwritten or re-synced. Placeholder modes are no longer applied (FileStat.mode_known).utils.parsedate()read GMT dates as local time, so every HTTP/WebDAVst_mtimewas off by the host's UTC offset, and it raisedOverflowErroron Windows east of UTC. It now returns UTC epoch seconds and passes numbers through.s3://b/dir/(trailing slash) was treated as the marker object: not a directory, andrm(recursive=True)removed only the marker. S3, GCS and Azure keys now drop one trailing/. Azure keys keep interior empty segments (a//b) as written.-
GsBackendrewrote the process-wideSTORAGE_EMULATOR_HOSTand dropped otherclient_options. Keyword arguments now go tostorage.Clientunchanged; for an emulator also passuse_auth_w_custom_endpoint=False. -
URI queries were corrupted on the wire. They were percent-decoded at parse time and re-encoded with
&,=and+treated as safe, so a signed URL'ssig=ab%2Bcd%3D%3Dreached the server asab+cd==and an escaped&split a value;with_query(dict)double-encoded.Uri.queryis now kept as received and sent unchanged (see Changed). - A remote path joined with a relative
pathlib.Pathbecame a local file.UriPath("sftp://h/srv/") / pathlib.Path("etc/x")producedfile:/srv/etc/x, so reads and writes hit the local disk. A relative path now joins like aPurePathand stays on the remote; only an absolute local path becomesfile:.UriPath.joinpath()picks the class from the scheme. - URIs with non-UTF-8 percent-escapes (
caf%E9.html) raisedUnicodeDecodeError; they now construct and round-trip.data:payloads are no longer dot-normalized or decoded twice, and binary payloads work. - Windows
file:URIs:file://localhost/C:/...could not be printed, hashed or compared; afile://<host>/sharewhose host is this machine mapped to the current drive instead of a UNC path. - Scheme registry: a
UriPathsubclass defined after the first dispatch was never found, and every unknown scheme rescanned entry points. open()modes:rt/wtfailed on every non-local backend and invalid modes raisedNotImplementedError; modes are now validated like the built-inopen()(ValueError).open("r+")on buffered backends (FTP, S3, GCS, Azure, local zip members) returned a writable buffer whose writes were discarded; writes are now uploaded on close (orNotImplementedErrorwhere impossible).copy():copy(recursive=True)into its own subtree recursed without limit;copy(follow_symlinks=False)copied the link target's content with the link's 0o777 mode (it now recreates the link, as pathlib 3.14 does);overwrite=Falsewas decided byexists(), which reads a transient 503 as "missing". Downstream classes mixing a concrete stdlib path withPathnow also get pathlib_next'sstat/chmod/glob/walk/_scandir.MemPath:iterdir()on a missing path raisedNotADirectoryError; files always reportedst_mtime=0, soPathSyncerskipped same-size edits.- Archives (
zip:/tar:/archive:): children of the archive root were named/nameand matched no member, soiterdir/glob/recursivecopyfrom the root failed; tarballs with./members (tar -C dir .,shutil.make_archive) were unreadable; adding a zip member rewrote the central directory in place (a crash corrupted the archive); a cached handle ignored changes by other writers and kept the file locked on Windows; archive URIs did not round-trip names with#,?or%; concurrent tar reads returned wrong bytes; stored member modes were ignored;iterdir()on a file returned[].utils.make_archive()now accepts anyPathsource, writes the target only when complete and supports zip64;utils.unpack_archive()accepts non-seekable streams and extracts tar links. - HTTP/WebDAV: gzip-encoded responses were returned compressed (and
rewrite-mode append re-uploaded them); a redirect made
stat()report a directory; listing hints fabricated sizes used as the patch-append offset; mid-body read failures raised urllib3 exceptions;DavPathlisted a directory with a space in its name as its own child, treated a 207 Multi-Status with failed members as success, re-sent a failed PUT at garbage collection and raised rawrequests.HTTPErrors;with_session(headers=...)was replaced by internal headers. - GitHub/GitLab: GitLab listings stopped after 100 entries (and
stat()called later subdirectories missing); GitHub listings stopped at 1,000; self-hosted API roots dropped the port; GitLab rootstat()answered from the URI shape without asking the server. uripathCLI:synccompared sizes only, so same-size edits were never copied;--dry-runprinted nothing;read/cp -buffered whole objects.- FTP: every separately built path opened its own connection; an error
mid-transfer desynchronized the cached connection for good; a write whose
connection timed out while idle lost its data; without MLSD, directories
stat'ed as missing; MLSD mtimes were read as local time; permission errors
surfaced as
FileNotFoundErrorand rawftpliberrors escaped. - S3/GCS/Azure: botocore
ClientErrorescapedexists()/walk()and a 403 read as "missing"; GCS/Azure turned every exception (including a missing SDK) into "does not exist", socopy(overwrite=False)could overwrite;iterdir()on a missing path returned[]andrmdir()/unlink()accepted wrong-type targets; a failed upload was retried at garbage collection over newer data; prefix-directorymove()failed; S3 objects above 5 GiB could not be written or renamed;open("x")was a check-then-put race;AzPathwithoutbackend=ignored the URI's account; Azure recursiverm()stopped at the first failing blob in a batch. PathSyncer: an interrupted copy lost the previous version (it now writes a temporary sibling and renames); preserve mode deleted the target before discovering symlinks were unsupported; dry runs crashed on new subdirectories;RemovedMissingevents carried the parent directory;ignore_errorwas called once per ancestor with the wrong paths; two unknown (0) mtimes counted as "in sync"; FIFOs and devices replaced the target with an empty directory.-
SFTP:
rename()onto an existing file failed with a bareOSError("Failure");unlink(missing_ok=True)skipped dangling symlinks; a relativereadlink()result could not be printed; asyncssh file handles made one round trip per byte inreadline()and an unclosed handle hung interpreter exit for 60 s; the asyncssh recursive copy called a boolignore_errorand ignored the own-subtree and symlink rules; paramiko's native checksum probed an extension OpenSSH does not implement, paying extra round trips per file. -
rename()returnedNoneondav:,s3:,gs:,az:,ftp:andsftp:; it now returns the new path, as pathlib does. - Wrong exception types on
sftp:,dav:,gitlab:andMemPath. Listing orrmdir()of a file raisesNotADirectoryError,rmdir()of a non-empty directoryOSError(ENOTEMPTY)(MemPathraisedFileExistsError), opening or unlinking a directoryIsADirectoryError;dav:reading a collection raised nothing and returned its HTML index, andrmdir()of a missing path raisedNotADirectoryError. paramikoopen("x")returned a file that could not be written. Path.rm(recursive=True, ignore_error=callable)offered a declined error to the callable again from every enclosing directory.uripathcrashed at import without theuriextra, even for local files. Local paths and-now work; a URI argument reports the extra to install. Without the extra it also read every colon name as a URI (uripath read notes:draftasked forpathlib-next[uri]instead of reading the file); the schemes this package registers are now read from its entry points, so a colon name behaves the same in either install.match()disagreed with pathlib on Python 3.12 at the root. 3.12 matches the whole path as one string with its separators swapped for newlines, so"/"is a single newline that"**"matches from either side ("/".match("**"),match("/**")andmatch("**/**")are allTruethere) while"*"never does, and a bracket expression such as"[!a]"consumes the separator itself.MemPath/UriansweredFalsethroughout; 3.12 now runs a port of that algorithm instead of the part-by-part comparison every other version uses.import pathlib_nextimportednetimps(a host-name query at import, slow on Windows), and the firstfile:/data:path importedrequestsandbotocore; both now load on first use.GsPath/AzPathare exported frompathlib_next.uri.schemes.*.local.*files shipped in the sdist and wheel.- The SFTP/FTP/WebDAV examples built URIs from unencoded credentials (a
password containing
/,#,?or@changed the host) and printed the password. benchmarks/bench.pycrashed at import on Python 3.9.-
Docs: the CI benchmark table had its Ubuntu, Windows and macOS columns rotated; the paramiko single-file write/copy slowdown was on Ubuntu.
-
Core, MemPath and URI edge cases.
open()leaked the backend handle when the text wrapper failed; synthesized errors fromrm()/touch()andMemPathlackederrno/filename;copy(progress=)never reported a zero-byte file;samefile(str)lost the backend or host;MemPathhandles appended at the seek position, hid unflushed writes and accepted writes on read handles, and exclusive create/mkdircould both succeed under concurrency;FileStat.from_stat()keptNonefields; checksums failed on FIPS hosts (usedforsecurity=False);is_dir()/is_file()rejectedfollow_symlinks=;"prefix" / pathwas unsupported; suffix/stem ignored 3.14's rules; lazy URI parsing could expose unset components to another thread;Uri.__eq__raised against a relative local path; a//path with no authority could not be rendered; non-ASCII hosts were sent percent-encoded;Uri("/a").is_absolute()wasFalse;data:acceptedr+and discarded writes. - Scheme and CLI edge cases. FTP listed MLSD
cdir/pdirentries named like children; archives nested in archives could not be addressed; GitLabiterdir()on a file yielded nothing; GitHub/GitLab 429 and secondary rate limits were not recognised;git://<ip>raisedAttributeError; a customBaseRepoBackendwithout a cache crashed on GitLab; git-hostingopen("r+")returned a writable buffer;uripath cp -overwrote an existing target without--overwrite, a local name with a colon was treated as a URI, and a closed pipe or Ctrl-C printed errors;HttpPath.iterdir()on a file downloaded it; HTTP 409 on PUT and 410 were mis-mapped; WebDAV read only the first<propstat>; HTTP listings behind a prefix were scoped by the page title; S3/GCS listings disagreed withstat()when a key was both an object and a prefix; GCS/Azure roots always reported existing; asyncssh SFTP errors had noerrno/filename. PathSynceredge cases. Tolerated errors left no trace; a directory that became a file or symlink in the source deleted the target directory even withremove_missing=False;SyncStartpassed raw paths to the hook and dry runs reporteddry_run=Falsefor traversal events; preserved directory symlinks were created as file links on Windows; an identical symlink was recreated on every run;PathAndStat(path)described a symlink itself instead of following it.
Changed
PathSyncerkeeps non-empty target directories on a type change unlessremove_missing=True(IsADirectoryErrorthroughignore_error,SyncEvent.TypeMismatch). Every tolerated error is now logged at WARNING onpathlib_next.syncand reported to the hook asSyncEvent.Error.PathAndStatfollows symlinks by default.PathSyncer.hook()gains a keyword-onlyalways_run.- URI comparisons and rendering:
Uri == <non-URI Pathname>(e.g. aLocalPath) is nowFalse(aUristill equals anotherUrior a URI string); non-ASCII hosts are rendered in IDNA form; an explicitschemesmap=is authoritative (an unknown scheme gives a plainUriPath);with_source()with a scheme-less source returns a plainUriPath;/no longer hides aTypeErrorraised inside a scheme class. data:URIs rejectr+, decode base64 only with;base64, and implytext/plainfor a parameters-only header.uripathexits 141 on a closed stdout and 130 on Ctrl-C.- Package metadata uses PEP 639 (
License-Expression: MIT) instead of theLicense ::classifier, and addsTyping :: Typed,Development Status :: 4 - Betaand Python 3.9-3.14 classifiers. Building from source needshatchling>=1.27. - The
azextra installsazure-identity, which anAzPathwithoutbackend=needs for its default credential. pathlib_next.testingcontracts are stricter: 48PathContracttests (was 20) covering pathlib error types, glob, walk, rename, open modes and recursive copy. A backend that cannot meet a rule sets a capability attribute toFalse(supports_listing,supports_empty_directories,distinguishes_file_types,supports_rename,supports_append,supports_exclusive_create,enforces_directory_hierarchy). The contract root must be a fresh, function-scoped directory populated withpopulate_fixture_tree().test_iterdir_lists_childrenno longer passes wheniterdir()is unimplemented.Uri.queryis the percent-encoded query as received and is sent unchanged;Query(...).decode()/to_dict()decode each name and value once. Astrpassed towith_query()is taken as already encoded; a mapping's keys now escape=. Code that read.queryexpecting decoded text must decode it.- Archive paths raise pathlib's POSIX exception types (
iterdir()on a file, reading orunlink()ing a directory,rmdir()on a file), andmkdir()/new zip members need an existing parent. Ziprename()returns the new path. Archiveas_uri()percent-encodes the member path. uripath synccompares file content by default;--size-onlyrestores the old comparison.--dry-runprints planned changes and-v/--verboseprints changes made.gitlab:reads a-segment at position 3 or later as GitLab's/-/separator (gitlab://host/group/sub/project/-/path).SftpPath.rename()replaces an existing target where the server supportsposix-rename@openssh.com, else raisesFileExistsError.DavPathmaps request errors to pathlib exceptions (PUT/MOVE into a missing parent:FileNotFoundError; 423:PermissionError).Path.glob()/rglob()/LocalPath.glob()include hidden files and directories by default, as pathlib does. Passinclude_hidden=Falsefor the old results.glob("")now raisesValueErrorand an absolute pattern raisesglob.NonRelativePatternError; before,""yielded the base and an absolute pattern listed outside it.- SFTP host keys are verified by default on both backends. Before, any
server key was accepted (asyncssh even overrode ssh_config pinning), so a
man-in-the-middle received the URI password. Now an unknown or changed key
fails before credentials are sent. paramiko uses
~/.ssh/known_hosts, ssh_configUserKnownHostsFileandRejectPolicy. To keep the old behaviour add the host toknown_hosts, or opt out explicitly:SftpBackend(connect_opts, paramiko.AutoAddPolicy(), known_hosts=None)/AsyncsshSftpBackend(connect_opts={"known_hosts": None}). ftps:verifies the server certificate and host name by default. Before, any certificate was accepted and the password sent to it. For a private CA passFtpBackend(ssl_context=ssl.create_default_context(cafile=...));FtpBackend(verify=False)disables verification. Data connections now reuse the TLS session (vsftpd, FileZilla Server).- Network operations have default timeouts. HTTP/WebDAV/github/gitlab:
(10, 60)s connect/read (with_session(..., timeout=...),RepoBackend(timeout=...)); FTP: 30 s (FtpBackend(timeout=...)); paramiko connect/banner/auth/channel-open: 30 s (SftpBackend(..., timeout=...)).timeout=Nonerestores the unbounded wait. Before, a stalled server hung the caller forever. - asyncssh timeouts apply to single requests only
(
AsyncsshSftpBackend(timeout=...), default 60 s). Recursivecopy()/rm()andread_bytes()/write_bytes()no longer raise after 60 s while the work continued in the background. A timed-out request is cancelled and raises the builtinTimeoutErroron every Python version (on 3.9/3.10 it wasconcurrent.futures.TimeoutError). - paramiko backend: ssh_config
ProxyJumpraisesNotImplementedError. Before, it was ignored and the connection went direct. Use asyncssh, aProxyCommand, orconnect_opts["sock"]. - asyncssh
rm()/copy()error callbacks run in a worker thread, so they may call ordinary path methods; a sync SFTP call made on the bridge-loop thread raisesRuntimeErrorinstead of hanging.
Added
SyncEvent.ErrorandSyncEvent.TypeMismatchreporting;str / path(__rtruediv__) onPathnameandUri.pathlib_next.testing.populate_fixture_tree(root)andFIXTURE_TREE.benchmarks/bench.py --save(min/median/max ms per call as JSON underbenchmarks/results/) and--samples.SyncEvent.CompareandSyncEvent.Skipped;uripath sync --size-onlyand-v/--verbose.glob.parse_pattern(),glob.select(),glob.NonRelativePatternError;recurse_symlinks=Falseonglob()/rglob();FileStat.mode_known.close()onSftpBackend/AsyncsshSftpBackend;FtpBackend(timeout=, ssl_context=, verify=);RepoBackend(timeout=);utils.LRU(on_evict=...)andLRU.discard().utils.is_safe_child_name(name, *, windows=False)andutils.is_windows_flavoured(path): check that an untrusted name stays a single component inside its parent before joining it onto a destination.
0.9.3 - 2026-08-16
Fixed
- A
strdestination torename()/symlink_to()was re-parsed as a URI, so part of it was silently discarded. Every scheme resolved the destination by feeding it back through the URI parser (Uri(self.parent, target)forrename(),type(self)(target)insidePath.symlink_to()). That reads a decoded filesystem path as URI syntax: everything from a?or#onward became a query/fragment and was dropped,%xxwas percent-decoded, and a relative destination whose first segment ended in:was read as a scheme. Measured against a real SFTP server (TrueNAS 26.0.0-BETA.1):
| call | file/link actually produced |
|---|---|
rename(".../rn?b.txt") |
.../rn |
rename(".../rn%20b.txt") |
.../rn b.txt |
symlink_to(".../cache?v=2") |
link points at .../cache |
rename("C:/Temp/x.txt") |
/Temp/x.txt (C: taken as a scheme) |
Nothing raised. When something already occupied the truncated name the call
instead failed with a bare OSError: Failure, so the symptom was either
silent misplacement or an unexplained error depending on what happened to
be there. Downstream, a consumer's documented
client.path(x).symlink_to(y) route created a wrong link, and
PathSyncer's symlink_mode="preserve" (which hands symlink_to() the
raw target string readlink() returned) mirrored such a link to the wrong
place.
A str destination is now taken as an already-decoded path — ?, #,
% and : are ordinary filename characters — via the new
Uri._from_decoded_path(), one implementation shared by
Uri._rename_target() (used by SftpPath, FtpPath, DavPath,
S3Path, GsPath, AzPath and ArchiveUri) and by
UriPath._symlink_target(), an override of a new Path._symlink_target()
hook. Relative destinations keep their existing meaning: a rename()
destination is a sibling, a symlink_to() target is stored verbatim and
stays relative. The string is not percent-encoded on the way in, so a
destination that legitimately contains a literal %20 — or a path object
built by a consumer that already encoded it — is not encoded twice.
readlink(), unlink(), rmdir() and hardlink_to() never had this
defect. copy()/move() are deliberately unchanged: their str
destination is still parsed as a URI, which is what makes a cross-scheme
copy("s3://bucket/key") work. See docs/divergences.md.
0.9.2 - 2026-08-16
Fixed
MemPath.open("w")on an existing directory raised nothing and destroyed the tree._open()assigned over whatever was already at the name, soMemPath("dir").write_text(...)replaced a directory and everything under it with a file — silently, in the class the docs present as the reference exemplar for extending this library, and the class used as a mock filesystem in tests. It now raisesIsADirectoryError, aspathlibdoes. The same guard covers the virtual root for every mode, which used to grow a bogus""key in the backend on"w"/"a".- A
MemPathrouted through a file raisedTypeError._parent_container()walked ancestors withpath not in parent, which on abytearrayancestor evaluates"seg" not in bytearray. ThatTypeErrorsails past theOSErrorguard inStat._st_mode(), so evenMemPath("file.txt/sub").exists()crashed instead of returningFalse— a routine shape in glob/walk and inmkdir(parents=True). It now raisesNotADirectoryErrornaming the offending ancestor. Pathnamehad no__eq__/__hash__, so subclasses compared by identity. Every pure subclass that didn't hand-write equality — includingMemPath, and any downstream class subclassingPathdirectly — was unusable as a dict key or set member, andis_relative_to()(which decides via==against freshly built parents) always returnedFalsewithout raising. There is now a default keyed on(type(self), tuple(self.segments)).LocalPath,PosixPathnameandWindowsPathnameare unaffected —pathlib.PurePathprecedesPathnamein their MRO and keeps its own equality — as isUri, which defines one.is_relative_to()normalized astrargument by joining it ontoself.Pathnameusedcls(self, other)andUriusedUri(self, _ROOT, other), soUri("a/b").is_relative_to("a")compared against"a/b/a"/"/a"and answeredFalsewhileUri("a/b").is_relative_to(Uri("a"))answeredTrue— the str and object forms of the same call disagreed. Both now parseotherstandalone, as CPython does. The generic side usesself.with_segments(other)so a subclass's per-instance state (MemPath's backend) survives the normalization.Uri("http://h/a/b").is_relative_to("/a")is stillTrue.LocalPath.chown()leakedAttributeError/LookupErroron Windows.shutil.chownexists there whileos.chowndoes not, so an int id raisedAttributeErrorand a name raised a misleadingLookupError: no such user(with nopwdmodule, every name misses whether or not the user exists). It now raisesNotImplementedError, whichdocs/divergences.mdalready promised and which every other unsupported capability here raises. The all-unchanged no-op still succeeds.
0.9.1 - 2026-08-04
Added
symlink_to(..., force=True)— replace an existing entry at the link path instead of failing. SFTP has no atomic "replace symlink", so every consumer was re-implementing the unlink-then-symlink dance. Implemented at thePathlayer over a new_symlink_to()backend primitive (the same wrapper/primitive split as_mkdir/mkdir), so all backends inherit it. Removes a non-directory entry only, and is documented as non-atomic.chown(uid=None, gid=None)over a new_chown()backend primitive.chownwas the one POSIX permission attributestat()could read but nothing could write back.Nonemeans "leave unchanged"; the normalization lives onPathso each backend receives an already-canonical pair rather than re-deriving the mapping (os.chownwants-1, SFTP omits the field).chmod()accepts a string octal mode ("0755"), normalized with an explicit base-8 parse. A mode string outside[0-7]raises rather than being coerced —int("0755")in decimal is a different mode, which is exactly the wrong-but-plausible failure this guards.
Changed
- Adopted
black, pinned to the 3.9 floor;src/andtests/reformatted.
0.9.0 - 2026-07-29
Added
UriPath.host_fspath(), and__fspath__()now succeeds for schemes with_host_filesystem_path = True(currentlysftp:) instead of unconditionally raisingNotImplementedErrorfor any non-file:scheme.os.fspath()has two consumers -- "open this locally" (where a remote path would silently read the wrong file) and "build a command line for a process that runs on the path's host" (subprocess, remote executors) -- and only the second is safe for a scheme likesftp:.host_fspath()is the unambiguous accessor for that second case: it never falls back to local-path semantics the way__fspath__does for thefile:branch.progresscallback forPath.copy()/BinaryOpen.copy().BinaryOpen.copy(target, *, progress=None, chunk_size=shutil.COPY_BUFSIZE)now streams in caller-sized chunks and, whenprogressis given, callsprogress(bytes_copied, total_size)after each chunk (total_sizeis the source'sstat().st_sizewhen available, elseNone).Path.copy(target, ..., progress=None)wraps this with per-file identity:progress(path, bytes_copied, total_size), so arecursive=Truecopy reports which file is being streamed alongside its byte progress, not just an anonymous byte stream.progress=None(the default) is behaviorally identical to before this change -- no per-chunk overhead, sameshutil.copyfileobjbytes-on-wire.SftpPath's asyncssh concurrent fan-out (copy(recursive=True)) does not invokeprogress-- native/batch transfer paths are out of scope for this first cut (seedocs/divergences.md's "Deliberate extensions" section).PathSyncercan now create symlinks ontargetinstead of always rejecting a symlink source. New constructor kwargsymlink_mode("preserve"default,"reject"opt-out), consulted only whenfollow_symlinks=Falseandsource.is_symlink()(with the defaultfollow_symlinks=True, symlinks are still resolved during traversal, unchanged)."preserve"creates a matching symlink ontargetusing the exact raw, unresolved target stringreadlink()returned -- dangling links and relative targets included, never validated or resolved againstsource's parent. Iftarget's implementation has nosymlink_to()at all (every backend exceptLocalPathandSftpPath),"preserve"mode raisesNotImplementedErrorthrough the existingignore_error/hook()flow, same as every other sync branch -- not a silent skip. NewSyncEvent.Symlinkenum member.- Optional backend-native checksum protocol
(
pathlib_next.protocols.checksum.NativeChecksum,checksum(algorithm="md5") -> str). APathsubclass may implement it to compute a file digest server-side instead of streaming the content throughopen("rb")-- implemented onSftpPathagainst the OpenSSHcheck-file@openssh.comSFTP protocol extension (paramiko backend only; the asyncssh backend has no equivalent client-library support and correctly falls back). Not part of the basePath/PathnameABC -- a plainPathhas no.checksumattribute at all. Implementations MUST raiseNotImplementedError(never return a value) when they can't produce a genuine digest under the requested algorithm -- this is what keeps two checksums from ever being compared under a mismatched algorithm, or trusting something hash-shaped but not a real content hash (e.g. S3's ETag for a multipart upload, deliberately not implemented here for exactly that reason -- seedocs/divergences.md). Also adds a companionsupported_checksums() -> frozenset[str]advisory query (defaultfrozenset(), never raises);SftpPath.supported_checksums()is a real per-connection probe against the server (paramiko exposes no cheaper way to know), cached per connection. PathSyncer's default checksum policy now prefers native digests on both sides when available, falling back to streaming (utils.checksum.md5/the new genericutils.checksum.stream) when either side can't produce one under the same algorithm -- never a native-vs-streamed comparison under a mismatched algorithm. A caller-suppliedchecksumcallable is unaffected (invoked exactly as before). Newutils.checksum.native(path, algorithm) -> str | Nonehelper: tries the protocol, returnsNone(never raises) if unsupported.PathSyncer(..., quick_check=True)(new constructor kwarg, defaultTrue): for a sync pair where at least one side is non-local, a metadata-only pre-check (st_size+st_mtime, already-cached, no extra round trip) skips the checksum call entirely when both match; a mismatch always falls through to a real checksum rather than being treated as "changed" on its own. Local-to-local pairs never engage this pre-check.quick_check=Falserestores always-checksum behavior for non-local pairs.
Changed
PathSyncerdefault behavior change:PathSyncer(follow_symlinks= False).sync()on a symlink source previously always raisedNotImplementedError. It now creates a matching symlink ontargetby default (symlink_mode="preserve"); passsymlink_mode="reject"to restore the old unconditional-raise behavior exactly.- The
uriextra now requiresnetimps>=0.2.0(alongsideuritools).Source.is_local()(uri.source) delegates its "is this address mine" check tonetimps.is_local_address(), which enumerates real network interfaces (netimps.get_interfaces()) instead of the previoussocket.getaddrinfo(socket.gethostname(), None)-based approach, which missed addresses not tied to the resolvable hostname (VMs, containers, VPN interfaces, additional NICs on a multi-homed host). The hostname->address step now usesnetimps.resolve()(both"a"/"aaaa"record types;hostis local if ANY resolved address is) instead ofsocket.gethostbyname()--resolve()'s default backend chain (dnspython, then the OS resolver viagetaddrinfo(), thennslookup) covers the same hosts-file/NSS/DNS resolutiongethostbyname()did, and additionally never raises for a name that simply doesn't resolve (resolve()'s contract: always a list, empty on genuine failure) --gethostbyname()raisedsocket.gaierrorfor that case.utils. get_machine_ips()(the old implementation's helper, unused elsewhere in this library) is removed.SftpPath's two backends andFtpPathnow resolve their default ports (22, 21) vianetimps.get_default_port()instead of three separately hardcoded literals.
Fixed
Source.is_local()crashed on a bare IPv6-literalhoststring.Source(scheme, userinfo, "::1", port)(a supported direct-construction pattern --Source's fields are publicNamedTuplefields) raisedsocket.gaierrorinstead of returning a result, becausesocket.gethostbyname()(this method's original hostname-resolution step) is IPv4-only.hostbracket-literals arriving via the normalSource.from_str()/Uri()construction path were unaffected (_decode_host()already parses those into a realIPv6Addressbeforeis_local()ever sees a string).is_local()now triesnetimps.try_parse()first (handles any IP literal directly, string or not) before falling through to hostname resolution.uri.source.Sourceleaked the password instr()/repr().Source.__str__()calleduricompose()with the rawuserinfo(password included) -- a genuinely valid, connectable URI string, not just a debug rendering -- andSourcehad no custom__repr__at all, so theNamedTupledefault rendered every field verbatim too.repr()is what a traceback frame renders, so aSourceanywhere on a failing call stack leaked the credential into logs, even thoughUri.__str__()already redacted. Both now redact the password fromuserinfothe same wayUri.__str__()does; the actual data (.userinfo,.parsed_userinfo(),["userinfo"]) is unaffected, only display is sanitized. This is a behavior change, not purely additive:str(source)(orf"{source}") no longer reconstructs an authenticated URI -- verified nothing in this codebase relied on that (every real connection site reads individualSourcefields, never whole-objectstr()), but a downstream caller that did would need the newSource.as_str(sanitize=True)method instead:sanitize=True(the default, matching__str__) redacts the password;sanitize=Falseis the full, credentialed round trip -- same name/kwarg asUri.as_uri(sanitize=), so both classes work the same way.
0.8.6 - 2026-07-26
Fixed
PathSyncer.sync()raisedTypeErrorinstead of honoringignore_error.sync()'signore_errorparameter defaulted to the boolFalseand the symlink branch called it directly, soPathSyncer(ignore_error=True).sync(src, dst)on a symlink source raisedTypeError: 'bool' object is not callablerather than the intendedNotImplementedError. The parameter now defaults toNone, meaning "use the policy given to__init__" -- it no longer silently shadows a constructor-supplied policy -- and every branch consults one resolved callable. Passing a callable explicitly behaves exactly as before.Path.copy()now accepts a bool forignore_error, matchingPath.rm()'s bool-or-callable contract. Previously only a callable orNonewas handled, socopy(recursive=True, ignore_error=True)broke as soon as a child copy failed.Nonekeeps its documented meaning (fail on the first error), and a callable remains a notification hook whose return value is not consulted, so existing handlers such aserrors.appendare unaffected.- Downstream
Pathsubclasses resolved stdlibpathliboperations instead of pathlib_next's. Concrete path classes mix apathlibclass withpathlib_next.Path, so the MRO decided which library implemented a method -- and which one won changed with the interpreter version. On Python 3.14 the new stdlibcopy()/move()displaced ours, crashing withAttributeError: ... has no attribute '_copy_from'on non-local backends and silently applying stdlib's different timestamp semantics on local ones (making mtime-based syncs converge on 3.14 but never on <=3.13). In the opposite direction, older stdlib lacked keywords this library's protocols promise:exists(follow_symlinks=)(3.12+),read_text/write_text'snewline=(3.13+), andrglob'sinclude_hidden=/recursive=/dironly=extensions (never in stdlib), all raisingTypeErroron the 3.9 floor.Path.__init_subclass__now re-asserts the pathlib_next implementation ofcopy,move,exists,rglob,read_textandwrite_textfor any subclass that would otherwise inherit stdlib's, so downstream implementers get correct behavior without hand-written forwarding methods. A subclass or mixin that defines one of these operations itself is never displaced.
Changed
utils.as_error_handler()centralizesignore_errorbool-to-callable normalization. Callable arities remain deliberately different per call site (rm->(error, path),copy->(error),PathSyncer->(error, source, target, event)); only the bool case is normalized, so no public signature changed.
0.8.5 - 2026-07-26
Fixed
LocalPath.copy()andLocalPath.move()resolved to the incompatible stdlib implementations on Python 3.14. Python 3.14 added methods with those names ahead ofpathlib_next.PathinLocalPath's MRO, so calls using pathlib_next extensions such asoverwrite=orrecursive=failed withTypeError.LocalPathnow routes both methods explicitly through the pathlib_next implementations on every supported Python version.- Generic paths now follow Python 3.14's updated
PurePath.with_suffix(".")behavior while retaining the earlierValueErrorbehavior on older Python versions.
Changed
- Documented that stdlib inheritance is deliberately local-only:
LocalPathis a realpathlib.Path, while URI, in-memory, and other virtual implementations inherit the generic pathlib_next contracts without claiming local-filesystem semantics.
0.8.4 - 2026-07-18
Changed
- Internal tidy-up (no behavior change): removed dead imports (
Sourcein theaz/gs/s3schemes,ioinutils.archive,abcinutils.stat), dropped unused local variables, and de-f-string'd a placeholder-less message. TheRepoBackendre-exports from thegithub/gitlabschemes are retained (public API, marked# noqa).
0.8.3 - 2026-07-18
Changed
AsyncsshSftpBackenddefaultmax_concurrencyraised 8 → 16. A 128-file loopback recursive-copy/rm sweep ofmc ∈ {1,2,4,8,16}(median of 3) showed recursive copy improving monotonically with concurrency (mc=8→16 ≈3% faster on top of mc=1→8 ≈1.13x) and recursive remove flat within noise, with 16 fastest-or-tied and well inside asyncssh's SFTP request window. Exposed asAsyncsshSftpBackend.DEFAULT_MAX_CONCURRENCY; passmax_concurrency=to override. Loopback evidence only — a high-latency remote link may warrant a different value; 16 is a safe modest default, not a tuned optimum.
0.8.2 - 2026-07-18
Fixed
SftpPathcould not be imported or used with an asyncssh-only install (noparamiko).uri/schemes/sftp/__init__.pyimported._paramikoeagerly at module load, and_asyncssh.pyimported the_DEFAULT_SSH_CONFIGsentinel from._paramiko, so merely importingSftpPath(or theAsyncsshSftpBackend) requiredparamikoeven when the caller only wanted the asyncssh backend from thesftp-asyncextra. The paramiko-free bits (the sentinel + config-path normalization) moved to a new_sshconfigmodule, and the paramikoSftpBackendis now imported lazily (via_probe_paramiko, mirroring_probe_asyncssh) only when actually selected.SftpBackend/_DEFAULT_SSH_CONFIGremain importable from the scheme package (PEP 562__getattr__) for backward compatibility.PATHLIB_NEXT_SFTP_BACKEND=paramiko(orautowith neither library) now raises a clearImportErrornaming the missing extra instead of a bareModuleNotFoundErrorat import time. Regression test added (test_sftp_scheme_imports_and_resolves_without_paramiko, runs in a paramiko-masked subprocess).
0.8.1 - 2026-07-16
Fixed
LocalPath.walk()/rm()raisedTypeError: cannot unpack non-iterable DirEntry objecton Python 3.11/3.12. Those stdlib versions define their ownpathlib.Path._scandir()(returning rawos.scandir()DirEntryobjects), which sits ahead of this project's_scandir()inLocalPath's MRO and silently shadowed it -- breaking the(name, FileStat|None)contractwalk()/glob()/rm()expect.LocalPathnow defines its own_scandir()explicitly, reusing eachDirEntry's cachedlstat()so the perf win from_scandir()unification is preserved. On 3.12+, stdlibpathlib.Pathalso defines its ownwalk()ahead of ours in the MRO, and that stdlibwalk()treatsself._scandir()'s return value as a context manager (with scandir_it:) -- our own_scandir()is a plain generator, so stdlib'swalk()raisedTypeError: 'generator' object does not support the context manager protocoleven with the override above.LocalPathnow also overrideswalk()explicitly, routing to this project's own implementation regardless of Python version. Introduced in 0.8.0 (8cdbefa), exposed on the CI 3.11/3.12 legs.Test No-ExtrasCI job was red.tests/test_smoke.pyunconditionally constructed anhttp:///sftp://UriPathin two tests, requiringrequests/paramikoeven though the no-extras job installs neither; a third test wrongly assumedS3Pathrequiresboto3to register (it only needsbotocore, imported lazily inside a method). The two hard tests nowpytest.importorskiptheir extra; theS3Pathcheck now probes forbotocore. Introduced in 0.8.0 (94bd545/8cdbefa), fixed with the expected skip count (2) verified in a real no-extras venv.- Importable on a clean Python 3.9 install.
pathlib_next.utilsusedtyping.ParamSpec(3.10+), falling back totyping_extensions.ParamSpecand then to a baretyping.TypeVar. ATypeVarhas no.args, so the*args: K.argsannotations raisedAttributeError: 'TypeVar' object has no attribute 'args'at import time, makingimport pathlib_nextfail on 3.9 whenevertyping_extensionswas absent. Sincetyping_extensionsis not a runtime dependency, this broke a plainpip install pathlib_nexton 3.9. The final fallback is now a minimalParamSpecshim providing.args/.kwargs, so no runtime dependency is added and 3.10+ keeps usingtyping.ParamSpecunchanged.
0.8.0 - 2026-07-13
Added
uripathcommand-line tool (pathlib_next.tools.uripath) for reading, writing, copying, removing, and syncing local or URI-backed paths.-works as stdin/stdout for byte-stream operations.- Recursive benchmark probes for local, memory, object-store, and SFTP backends, including provider call-shape rows for recursive deletes.
- Provider-native recursive delete overrides for
S3Path,GsPath, andAzPath, with bucket/container-root guards. git:convenience dispatch over the existinggithub:/gitlab:providers, plus explicitgit+github:andgit+gitlab:forms for self-hosted or enterprise instances.git:only auto-detects publicgithub.com/gitlab.com; ambiguous hosts now raise a clearValueErrornaming the explicit alternatives.- HTTP write support (
PUT, customizable toPOSTor other verbs viawrite_methodconfiguration orwith_session()) forHttpPath. - HTTP delete support (
DELETEforunlink()andrmdir()) forHttpPath. - Comprehensive HTTP exception mapping in
HttpPathtranslating client/server/timeout/connection errors into standard built-inOSErrorsubclasses (FileNotFoundError,PermissionError,FileExistsError,TimeoutError,ConnectionError,NotImplementedError, or genericOSError). - Dynamic loading of custom URI scheme plugins via standard Python packaging entry points under the
"pathlib_next.schemes"group, allowing third-party package extensibility. - Lazy-loading for all builtin scheme implementations (s3, sftp, http, etc.) to significantly reduce start-up and import overhead when heavy libraries are not needed.
- MD5 and SHA-256 checksum helpers in
pathlib_next.utils(md5andsha256). - Optional
checksumparameter inPathSyncer, defaulting to the newmd5helper. - Recursive directory copying via
Path.copy(recursive=True). - Support for recursive folder moves falling back to recursive copy + recursive delete when
renameis not supported. - Archive utilities
make_archiveandunpack_archivesupporting ZIP and TAR formats using memory-efficient chunk streaming. - Hierarchical test contracts:
PurePathContract(pure path operations) andReadPathContract(read-only path operations), allowing contract-based verification of read-only and memory/archive paths. - Contract test suites wired for
DataUri,ZipUri,TarUri, andHttpPath. - Dedicated unit tests for
Path.walk(),samefile(), andStatdevice queries. - Comprehensive runnable examples in
examples/for URI schemes: offline (data_and_archive.py) and environment-variable configured ones (ftp_listing.py,webdav_roundtrip.py,s3_listing.py). - Split monolith API reference documentation into per-module pages (
path,uri,mempath,utils,testing). - Complete docstring coverage for all public methods/properties across
Pathname,Path,Uri,UriPath, and protocols, and configuredmkdocsto enforce docstring presence (show_if_no_docstring: false). - Detailed documentation of contract testing levels (
PurePathContract,ReadPathContract,PathContract) in the extending guide. - In-process real-server contract tests for
FtpPath(pyftpdlib),DavPath(wsgidav/cheroot), andS3Path(moto mock_aws):TestFtpContract,TestDavContract,TestS3Contractrun the fullPathContractsuite against live local servers. ftp_server,dav_server, ands3_serverpytest fixtures inconftest.pyserving ephemeral in-process servers with pre-populatedfixture_treecontents.- Entry-point declarations in
pyproject.toml(pathlib_next.schemesgroup) for all built-in schemes, enabling pip-installed external packages to auto-register custom schemes. - Plugin discovery tests in
tests/test_plugins.pycovering_load_entry_point,_load_builtin_scheme, andget_scheme_clsintegration. - Property-based tests (
hypothesis, newdevextra) intests/test_properties.py: URI parse/format round-trip identity, join associativity, and parity withpathlib.PurePosixPathforsegments/name/relative_to/match/is_relative_to. ZipUri/ArchiveUriarchive handle registry: independently-constructedUriPath("zip:...")/"tar:..."instances pointing at the same outer archive now share one_ArchiveBackend(keyed by backend class + outer URI, aweakref.WeakValueDictionary) instead of each opening its own handle -- fixes stale reads and out-of-sync writes across separately-constructed instances. The backend closes its handle automatically (__del__) once every referencing path is garbage-collected.- Full
ZipUriwrite support:unlink(),rmdir()(empty-dir check, mirrorsS3Path.rmdir()),rename()(renames a directory's nested entries too), and overwriting an existing entry's content (previouslyopen("w")on an existing entry silently appended a duplicate zipfile entry instead of replacing it). All four go through a new safe full-archive rewrite (_ZipBackend._rewrite) sincezipfilehas no in-place entry mutation: writes to a temp file beside the outer archive, then atomically replaces it (os.replace). Requires a local (file:) outer archive, same as existing new-entry writes. archive:catch-all URI scheme: auto-detects zip vs. tar for the outer archive (filename extension first, then a magic-byte sniff shared withunpack_archivevia the newutils.archive._detect_formathelper) instead of requiring the caller to know the format up front. Explicitarchive+zip:/archive+tar:forms skip detection outright.archive:...!/xandzip:...!/xpointing at the same outer archive share one backend (same registry aszip:/tar:), and write support (new/overwritten entries,unlink/rmdir/rename) works througharchive:exactly as it does throughzip:when the detected format is zip and the outer archive is local -- tar-detected instances correctly raiseNotImplementedErroron any write attempt.- New
gs:(Google Cloud Storage) andaz:(Azure Blob Storage) URI schemes (GsPath/AzPathinpathlib_next.uri.schemes.gs/.az, withGsBackend/AzBackendfor credential/endpoint override):gs://bucket/key/pathandaz://account/container/key/path. Both support fullPathContract(read/write/list/delete/rename), reusing the prefix-emulation directory semantics ofS3Path(no real directories;is_dir()checks for keys under"<path>/",mkdir()writes a zero-byte"<path>/"marker,rmdir()requires empty). Wired toPathContractagainst faithful in-process fake JSON/XML REST API servers (gcs_api_server/gs_server,az_api_server/az_serverinconftest.py), plus scheme-specific unit tests. Newexamples/gs_listing.py/examples/az_listing.py(env-var gated, fail-soft). LikeS3Path, both cache one service client per backend instance (thread-safe for both SDKs). Report no mtime (st_mtime=0, documented divergence). Each is its ownpyproject.tomlextra:gs(google-cloud-storage) andaz(azure-storage-blob).rename()uses server-side copy+delete (same bucket/container only) instead of the generic download+upload+deletemove()fallback. - New
github:/gitlab:read-only URI schemes (GitHubPathinpathlib_next.uri.schemes.github,GitLabPathinpathlib_next.uri.schemes.gitlab, sharing a private_RepoApiPathbase and a plain-requestsRepoBackend, no PyGithub/python-gitlab SDK):<scheme>://host/owner/repo/path/in/repo?ref=<ref>,refalways optional in the query string.GitHubPathlists via the contents API (one call gives type/size for a whole directory) and reads file bodies via therawmedia type;GitLabPathlists via the tree API (no size, so only directory entries get a stat hint) and reads via the files/rawendpoint, resolving+caching the project's default branch itself whenrefis omitted (GitLab's file endpoints -- unlike its tree endpoint -- 400 ifrefis missing, confirmed live against gitlab.com).hostdefaults to the public SaaS host; any other host is treated as GitHub Enterprise (https://{host}/api/v3) or a self-hosted GitLab (https://{host}/api/v4). Auth via a bearer token (RepoBackend(token=...)) or URI userinfo. Both reuse thehttpextra (no new extra added). Wired toReadPathContractagainst faithful in-process fake API servers (github_api_server/gitlab_api_serverinconftest.py), plus scheme-specific unit tests (ref propagation throughiterdir(), rate-limit/error translation, GitHub Enterprise API-base derivation, GitLab dir-vs-file stat disambiguation). Newexamples/github_listing.py/examples/gitlab_listing.py. - New
sftp-asyncextra: anAsyncsshSftpBackend(asyncssh, async internally, bridged to a sync API through one shared background event loop) alongside the existing paramiko-basedSftpBackend. Auto-selected whenasyncsshis importable (paramiko remains the fallback); override via thePATHLIB_NEXT_SFTP_BACKENDenv var ("paramiko"/"asyncssh"/"auto") or aSftpPath._default_backend_clssubclass hook -- precedence, highest to lowest: explicitbackend=kwarg >_default_backend_cls> env var > auto-detect.PATHLIB_NEXT_SFTP_BACKEND=asyncsshwith the package missing raises immediately rather than silently falling back. Connections are cached per(backend, source)(no thread dimension needed -- one shared connection serves concurrent calls from any calling thread, unlike paramiko's(backend, source, thread)cache). Works on Python 3.9 too via a verified version pin (asyncssh<2.22; currentasyncsshneeds >=3.10) resolved automatically throughpyproject.tomlenvironment markers -- no code branching. NewSftpPath.symlink_to()/readlink()(both backends -- core SFTPv3 operations) andhardlink_to()(asyncssh backend only; paramiko'sSFTPClienthas no hard-link operation, so it raisesNotImplementedErrorimmediately with no server round trip).chmod(follow_symlinks=False)now works on the asyncssh backend (native support) while still raisingNotImplementedErroron paramiko (nolchmodequivalent). This is additive, not a performance change -- the (separate, unscheduled) concurrent-fan-out work that would actually exploit asyncssh's pipelining remains future work.
Changed
- Recursive
Path.rm()now deletes bottom-up using non-following listing metadata where available, avoiding traversal through directory symlinks and reducing extra stat calls for metadata-rich backends. - Asyncssh SFTP recursive copy/remove now use native bounded async helpers for ordinary files/directories instead of recursing through sync path methods on the bridge loop.
PathSyncerreuses child metadata during tree sync when that metadata is consistent with the active symlink-following policy.- Replaced the third-party
htmllistparseandbs4directory listing scraper dependencies with a hand-rolled, zero-dependencyhtml.parser.HTMLParsersubclass (_DirectoryListingParser), dropping both from thehttpextra inpyproject.toml. Verified equivalent output (name/size/modified, both Apache-<pre>and nginx-<table>formats) against the replacedbs4+html5lib+htmllistparseimplementation, and 2.9x-6.2x faster depending on format/listing size (benchmarks/bench.py's8/9entries benchmark the new parser alone going forward, since the old implementation no longer exists in the tree). - Matrix expansion in GitHub Actions CI to test Python 3.10, 3.11, and 3.12 (on Ubuntu).
- Added a "no-extras" CI job to run tests without optional dependencies installed.
PathSyncer.log()now logs throughlogging.getLogger("pathlib_next.sync")atINFOinstead of callingprint()-- stdout consumers must configure logging (e.g.logging.basicConfig()) to see sync progress again.EVENT_LOG_FORMATswitched fromstr.format({event}) to%-style placeholders to match, andlog()remains overridable for custom routing.SyncEventmembers are now numbered sequentially (previously a mix of explicit ints andenum.auto(), which raised aDeprecationWarningon Python 3.13). Values are not part of any documented/persisted contract.- Optimized performance across pure paths and URIs:
- Cache
Uri.segmentsin a slot to avoid re-splitting the path string on every access. - Cache
Uri.suffixandUri.stemin slots. - Optimize
Source.__bool__to use lazy index accesses and avoid tuple iteration. - Short-circuit
Query.__new__when the input is already a matchingQueryinstance. Uri._parse_uri()/Source.from_str(): one-pass component extraction fromuritools.urisplit()'s raw fields instead of calling its sevenget*()accessors, each of which independently re-rpartitions the authority string and re-decodes. Ported (not reinvented) fromuritools.SplitResult's own property/getter logic -- including one of its quirks, reproduced on purpose (seeuri/source.py::_split_authority) -- and verified equivalent by fuzzing 20,000+ generated URIs against uritools as the oracle (tests/test_properties.py, which stays the enforcement mechanism, not just a one-time check).Uri._format_parsed_parts()/DavPath._wire_uri(): direct string assembly instead ofuritools.uricompose()'s full re-validation, for the same reason and with the same fuzzing rigor (uri/source.py::_compose_uri) -- both bypass a general-purpose library's necessarily-defensive validation only where the input is already known-canonical (parsed or otherwise internally normalized), not for arbitrary/untrusted URIs.uritoolsitself is unchanged as a dependency and remains the parsing/composing engine underneath both fast paths -- a hand-rolled RFC 3986 implementation was evaluated and rejected (verdict: slower or not worth the permanent edge-case-ownership cost). Measured on.venv/3.12.10, unique URIs per iteration (a repeated-URI microbenchmark flatters by masking real per-call cost): the fullUri(unique_url).as_uri()round trip (parse + compose, both changes) is ~20-25% faster; the parse side alone, isolated fromUri.__new__'s slot-initialization overhead (unaffected by this work), is ~17-30% faster on its own (seebenchmarks/bench.py's1b/1centries).- New
Path._scandir()/UriPath._scandir()protocol: schemes whose listing call already returns type/size/mtime for every child (HTML directory index, WebDAV PROPFIND, SFTPlistdir_attr, FTP MLSD, an S3list_objects_v2page) can now yield(name, FileStat)pairs directly, andwalk()/glob()answeris_dir()from that instead of astat()round trip per entry -- a remote-tree walk goes from O(entries) requests to O(dirs).HttpPath,DavPath,SftpPath,FtpPath, andS3Pathall adopt it;_listdir()/iterdir()remain fully supported for schemes that don't override_scandir()(no behavior change, no win). On the localhttp_serverbenchmark fixture, HTTP glob/walk over the fixture tree are ~89-94% faster than the already-optimized pre-_scandir()baseline (seebenchmarks/bench.py).HttpPathalso drops its_isdirinstance-cache slot and itsis_dir()/is_file()overrides (now derived generically fromstat(), like every other scheme) in favor of a single-use stat hint seeded by_scandir();DavPath's now-redundantiterdir()/is_dir()/is_file()overrides are removed for the same reason. - Breaking (pre-1.0, no compat shim kept):
uri/schemes/module naming convention -- every module is now named after the main URI scheme it implements (TLS/secondary variants live with their main scheme).webdav.py->dav.py; import frompathlib_next.uri.schemes.dav(the oldpathlib_next.uri.schemes.webdavpath no longer exists).archive.py->archive/package (_base.pyshared machinery,zip.py,tar.py) -- import-compatible for free,pathlib_next.uri.schemes.archivestill resolves (now the package) and re-exportsArchiveUri/ZipUri/TarUri.sftp.py->sftp/package (_paramiko.pyholds the existing paramiko-backedSftpBackend;__init__.pykeepsSftpPath/BaseSftpBackend) -- same free import-compat,pathlib_next.uri.schemes.sftpstill resolves and re-exportsSftpPath/BaseSftpBackend/SftpBackend. Prepares the layout for an upcoming second (asyncssh) backend;SftpBackendgained adefault()classmethod factory soSftpPath._initbackend()doesn't need to importparamikoitself. SftpPath's connection caching moved from an external cache wrappingbackend.client()calls to being each backend's own responsibility (SftpPath._sftpclientis now a trivialself.backend.client(self.source), no per-backend branching). Needed so the new asyncssh backend can use its own(backend, source)-keyed cache (see thesftp-asyncentry above) withoutSftpPathneeding to know which caching scheme applies. Behavior-affecting for customBaseSftpBackendsubclasses: aclient()override that doesn't cache internally will now be called on every_sftpclientaccess, not just on a cache miss --SftpBackend/AsyncsshSftpBackendboth cache internally, so this only matters for third-party/test-double backends.TestSftpContract's in-process test server (tests/conftest.py::sftp_server) is now asyncssh's ownSFTPServer(chrooted tofixture_tree) instead of a ~150-line hand-rolled paramikoServerInterface/SFTPServerInterface-- a client backend choice is independent of which library the test server uses (verified: a paramiko client talks standard SFTP to an asyncssh server fine).TestSftpContractitself is now parametrized across both client backends (paramiko,asyncssh).
Fixed
- Recursive delete on exact object-store keys now treats the exact object as
the addressed path before considering a
"<key>/"prefix tree, preventing accidental prefix-tree deletion forS3Path,GsPath, andAzPath. - Azure recursive delete falls back from
delete_blobs()to per-blob deletion when a provider or emulator rejects the batch API. Uri.relative_to()computed the remaining segments from the raw.segmentsproperty instead of the root-aware_segments_of()helperis_relative_to()already used --Uri("/").segmentsis the 2-tuple("", "")(an artifact of"/".split("/")), sorelative_to(<root>)silently dropped the child's only real segment (e.g.Uri("/a").relative_to(Uri("/"))produced""instead of"a"). Found by the new property-based test suite.TestSftpContract's in-process paramiko test server (tests/conftest.py::sftp_server) deadlocked every real I/O test: its_SSHServer.check_channel_subsystem_request()override returnedname == "sftp"directly instead of delegating toparamiko.ServerInterface's default implementation, which is what actually instantiates and starts the registeredSFTPServerhandler thread (handler.start()). Without it, the channel was reported "hooked up" to the client but nothing server-side ever read from or responded on it, soSFTPClient.from_transport()blocked forever in version negotiation. Fixed by removing the override (the inherited default already does exactly what the removed comment claimed it did).SftpPath._mkdir()/_open(mode="x")propagated a generic, untypedOSError("Failure")when the target already existed -- SFTPv3 has no dedicated "already exists" status code, so paramiko's server-sideconvert_errno()falls through toSFTP_FAILUREforEEXIST(unlikeENOENT, which it does map, giving a properFileNotFoundError). Both now checkself.exists()on failure and raiseFileExistsErrorto match every other scheme'smkdir/touch(exist_ok=False)contract (mirrorsFtpPath._mkdir()'s existing check-after-failure pattern). Found byTestSftpContractonce the deadlock above was fixed and it could actually run.FtpPath.stat()returnedFileNotFoundErrorfor the FTP root path"/"because_mlsd_entry()has no parent directory to query; now usesCWD /to confirm the root exists as a directory.FtpPath.rmdir()propagated rawftplib.error_perm(550) instead ofOSErrorwhen the directory was non-empty, violating the pathlib contract.FtpPath.chmod()raisedftplib.error_permwhen the server rejectedSITE CHMOD(pyftpdlib does not implement it); now converts toNotImplementedErrorsoPath.copy()silently skips the metadata step.pytest filterwarningsupdated to suppressboto3.exceptions.PythonDeprecationWarning(boto3 EOL notice for Python 3.9, inheritsWarningnotDeprecationWarning) andResourceWarningfrom daemon-thread server socket cleanup at GC teardown.- README and docs landing page were still describing the pre-0.6.0 scheme set:
the capability matrix, extras table, and quick starts now cover
data:,ftp(s):,zip:/tar:,dav(s):, ands3:(all shipped in 0.6.0/0.7.0 but previously only documented in the Schemes guide). LRU.maxsizesetter raisedTypeErrorwhen shrinking below the current fill (OrderedDict.pop()was called with thelast=Falsekwarg meant forpopitem()).DavPath.rmdir()mapped directly to WebDAVDELETE, which is recursive by spec (RFC 4918) -- it silently deleted non-empty collections instead of enforcing pathlib's "must be empty" contract like every other scheme. Now does a depth-1 PROPFIND first and raisesOSError(ENOTEMPTY) if children exist. The native recursiveDELETEis still available, and cheaper than the base class's client-side walk, via the newDavPath.rm(recursive=True)override (one request).Uri._make_child_relpath()doubled the join slash for any scheme whosepathalready ends in "/" (e.g.f"{self.path}/{name}"on an HTTP/DAV directory path produced"//name"); also now treats an empty path with an authority present as the same root as"/"(RFC 3986:"http://host"=="http://host/") instead of joining a bare, ambiguous name with no leading slash._DirectoryListingParser._RE_FILESIZE's digit class excluded,-- the<table>path strips commas from cell text before matching, but the<pre>path matches first, so a comma-thousands size like1,024matched only"1", truncatingsizeand leaking,024intodescription.- The RFC-1123 datetime bucket's trailing timezone match
(
... \d{2}:\d{2}:\d{2} .+) used an unbounded, greedy.+that swallowed the rest of the<pre>listing line, including any trailing size/description text on the same row --time.strptime()then raised on the unconverted data, silently droppingmodifiedand every field after it for that entry. Narrowed to\S+(the timezone is one token). _DirectoryListingParser's absolute-href filter was a blanketstartswith('/')-- a reverse-proxied/absolute-URL-configured server rendering every entry (not just the parent-directory link) as an absolute href got back a completely empty listing, with no fallback able to recover it. Scoped the filter to hrefs outside the listing's own directory (parsed from<title>Index of ...</title>) instead, falling back to the old blanket-drop behavior only when no title was parseable.HttpPath.stat()'s post-redirect HEAD re-fetch had no HEAD-405-to-GET fallback, unlike the pre-redirect loop -- a server/proxy that rejects HEAD outright (not just pre-redirect) surfacedPermissionErrorfor a directory that actually exists. Now mirrors the pre-redirect loop's fallback.HttpPath._listdir()now retries once with a trailing slash if the slash-less path 404s (defensive: real redirecting servers already work viarequests' default GET redirect-following, but a non-redirecting server/proxy previously had no fallback at all).HttpWriteStream.close()raised before marking the underlying stream closed on a failed upload, so a secondclose()call (context-manager__exit__cleanup, or GC viaIOBase.__del__) silently retried the PUT. Now marks closed even on failure.HttpPath.rmdir()/DavPath.rmdir()never checkedis_dir()before falling through tounlink()-- an empty directory's listing and a file whose body/PROPFIND response yields zero real entries are indistinguishable from_listdir()alone, so callingrmdir()on a file silently deleted it instead of raisingNotADirectoryError(os.rmdir()'s ENOTDIR contract).
0.7.0 - 2026-07-11
Added (new schemes, optional extras)
dav:/davs:scheme (pathlib_next.uri.schemes.webdav.DavPath): extendsHttpPathwith WebDAV (RFC 4918) PROPFIND for real stat/listdir metadata (replacing HTML-index scraping) and PUT/DELETE/MKCOL/MOVE for full read/write access. Requests go to the equivalenthttp:/https:URL;as_uri()still reportsdav:/davs:. Reuses thehttpextra, no new dependency.rmdir()is recursive by WebDAV spec, unlikepathlib.Path.rmdir()'s "must be empty" contract -- documented, not silent.s3:scheme (pathlib_next.uri.schemes.s3.S3Path,s3://bucket/key/path): read/write/list viaboto3. News3extra. S3 has no real directories --is_dir()is prefix emulation (any object key under"<path>/"),mkdir()creates a zero-byte"<path>/"marker object,rmdir()requires no other keys under that prefix.rename()uses server-sidecopy_object+delete_object(same-bucket only) instead of the generic download+upload+deletemove()fallback.
0.6.0 - 2026-07-11
Added (new schemes, stdlib-only, no new deps)
data:scheme (RFC 2397,pathlib_next.uri.schemes.data.DataUri): read-only, no backend/connection -- the entire file content is embedded in the URI (data:[<mediatype>][;base64],<data>).stat().st_sizeis the decoded payload length;iterdir()raisesNotADirectoryError(it's always a single file); write operations raiseNotImplementedError.ftp:/ftps:scheme (pathlib_next.uri.schemes.ftp.FtpPath): full read/write/list access via stdlibftplib, with a thread-keyed LRU connection cache mirroringsftp.py. Listing/stat prefer MLSD (RFC 3659); servers without it fall back to NLST (listing) and SIZE (file-only stat). Writes buffer in memory and upload via STOR/APPE onclose().chmod()uses the common but non-standardSITE CHMODextension (may not be supported by every server).zip:/tar:archive paths (pathlib_next.uri.schemes.archive):<scheme>:<archive-uri>!/<inner-path>(Java-style!/separator, URI form proposed to and confirmed by the user before implementation). The archive half is itself any absolute URI with an explicit scheme, so archives are readable straight off any other backend (file:,http:,sftp:,ftp:,data:, ...). Read is supported for both schemes. Write iszip:-only, and only for brand-new entries in a local (file:) outer archive (overwriting/deleting/renaming an existing entry would need a full-archive rewrite -- not implemented, raisesNotImplementedError).tar:auto-detects.tar.gz/.tar.bz2/.tar.xzcompression and is always read-only.
0.5.0 - 2026-07-11
Fixed (critical -- found while writing the examples)
Path("...")-- the top-level dispatcher documented in this project's own README quick start and used throughout -- silently dropped its constructor arguments on Python <3.12, leaving a blank instance that crashed withAttributeError: _drvthe moment anything touched it (e.g. the/operator). Masked on 3.12+, where the real parsing happens in__init__(called separately, with the original args, regardless of what__new__did) rather than__new__itself. Every one of the new suite's 300 tests constructed viaLocalPath(...)directly instead, so this went undetected untilexamples/local_and_mem.pyexercised the documentedPath(...)entry point end to end.
Fixed (found by the new test suite, not in the original bug list)
LocalPath.stat()/chmod()inherit directly frompathlib.Pathvia MRO and crashed withTypeErroron Python 3.9 the moment anything passedfollow_symlinks=(e.g.Path.walk()'s defaultfollow_symlinks=False) -- now shimmed withlstat()/lchmod()on <3.10, same as the existingFileUrishim (which now just delegates toLocalPath).MemPath.__init__decided whether to propagate a parent's backend withif _backend and backend is None:-- an empty (but valid) backend dict is falsy, so joining off a freshly-created, emptyMemPathsilently gave the child a disconnected new backend instead of sharing the parent's.MemPath.stat()never setst_sizefor files (always defaulted to0), breaking any size-based checksum comparison (notablyPathSyncer's typical usage).glob()'s core algorithm decided whether to recurse into the parent directory using whether the leaf segment is a wildcard, instead of whether the parent path itself contains one. Since a wildcarded leaf with a literal parent directory is the overwhelmingly common case (glob("*.py")), this always took the "recurse into parent" branch, which only degenerated back to the correct single directory when the parent has a non-empty literal name to re-match against -- true for essentially every real filesystem path except an OS root. It silently returned the wrong result onMemPath's virtual root (empty name).HttpPath.iterdir()gave every subdirectory entry an empty.name: directory-listing entries for subdirectories carry a trailing/(htmllistparse's convention), which wasn't stripped before building the child's path, andPathname.namederives from the last path segment -- empty for a trailing-slash path.SftpPath.rename()resolved a plain string target relative toself(joining it as a child, e.g."/a.txt".rename("b.txt")produced"/a.txt/b.txt") instead ofself's parent (sibling rename).
Added (test suite)
- Full pytest suite (
tests/): pure-path parity againstpathlib.PurePosixPath(test_parity_pure.py), local I/O parity againstpathlib.Path/os.walk(test_parity_io.py), a reusable filesystem-contract mixin run againstLocalPath/MemPath/FileUriand exported aspathlib_next.testing. PathContractfor third-partyPath/UriPathimplementers (test_contract.py), glob vs. stdlib ground truth (test_glob.py), URI parsing/scheme-dispatch/query/source coverage, MemPath- and SFTP-specific unit tests (SFTP mocked, no real server), HTTP tests against a real stdlibThreadingHTTPServer, andPathSyncercoverage. 300 tests, ~85% line coverage, green on both Python 3.9 and 3.13.
Added (docs)
docs/guides/schemes.md(capability matrix per scheme) anddocs/guides/extending.md(both extension tracks, with worked examples andpathlib_next.testing.PathContractusage). Rewrotedocs/index.mdand the README with a 30-second example per scheme and a capability matrix. Class-level docstrings added across the package for the rendered API reference.
Changed
examples/example.py(an unstructured scratch script) split into three focused, runnable examples:examples/local_and_mem.py(self-contained, no network),examples/http_listing.pyandexamples/sftp_sync.py(network-touching, guarded underif __name__ == "__main__", configurable via env vars, fail soft when unreachable/unconfigured).
Added
Pathname.joinpath(),Pathname.full_match()(3.13 parity, supports**matching any number of segments),Pathname.anchor/drive/root(generic derivation for non-local paths),Path.rglob(),read_text(..., newline=)(3.13 parity),Path.samefile()(defaultst_dev/st_inocomparison when the backend'sstat()provides them,NotImplementedErrorotherwise).Path.glob()/LocalPath.glob():recursive=now auto-detects (Trueif the pattern has a"**"component) instead of defaulting toFalse; explicitrecursive=True/Falsestill overrides.Path.copy(): raisesIsADirectoryErrorwhen the target is an existing directory (previously misbehaved); gainedfollow_symlinks=/preserve_metadata=kwargs, named to match CPython 3.14'sPath.copy().docs/divergences.md: registry of every deliberate behavioral divergence frompathlib, with rationale. Linked from the docs nav.
Fixed
Path.mkdir(parents=True)created intermediate parents withexist_ok=False(racy, and wrong when a parent already existed) and dropped the caller'sexist_okon the final retry.Path.touch(exist_ok=False)silently truncated an existing file instead of raisingFileExistsError(pathlib parity).LocalPath.glob()'sdironlyparameter defaulted toFalse, which made theis Nonecheck for trailing-slash directory-only detection dead code.Stat._st_mode()only caughtFileNotFoundError, lettingPermissionErrorand otherOSErrors propagate out ofexists()/is_dir()/etc. where pathlib returnsFalse. Also fixed:follow_symlinkswas accepted but never forwarded to the underlyingstat()call, sois_symlink()never actually inspected the symlink itself.MemPath._open()treated any mode other than"w"as a read, so"a"/"x"silently misbehaved; now dispatchesr/w/x/acorrectly and raisesNotImplementedErrorfor anything else.MemBytesIO.close()usedseek(0);read()instead ofgetvalue(), losing content if the caller's cursor wasn't already at position 0 when closing.MemPath.normalizedmangled".."-escaping paths (e.g."..") into"."; now normalizes against a virtual root so they clamp at the root instead.PathAndStat.__getattr__()returnedNonefor any unrecognized attribute instead of raisingAttributeError, breakinghasattr()-based logic.parsedate(None)/ an unparseable date string returned "now" instead of epoch 0, which could poisonPathSyncer's checksum/freshness comparisons for HTTP sources with noLast-Modifiedheader.HttpPath.stat()used a bareexcept:; cached_isdirfrom a response that hadn't been confirmed successful yet (including 404s); and didn't fall back to GET when a server rejectedHEADwith 405.uri.Queryno longer depends onuritools' private_querydict/_querylisthelpers (reimplemented locally against the publicuriencode()).Urijoin (_load_parts):query/fragmentare now resolved with the same "last segment that actually sets one wins" rule already used forsource(previously any segment, even one with no query/fragment, would blank out an earlier segment's). Join semantics are now documented explicitly: pathlib-joinpath-like, not RFC 3986 reference resolution,..is never resolved during join.Source.is_local()(DNS lookup) andget_machine_ips()are nowfunctools.lru_cached -- previously ran on every call.
Fixed (crash-level bugs)
MemPath.stat()/MemPath._open()returned aFileNotFoundErrorinstance instead of raising it for a missing path, causing an unrelatedAttributeErrordownstream.LRU.invalidate()calledself.lock()instead of usingself.lockas a context manager (RLockisn't callable) -- broke the SFTP client reconnect path.Pathname.match()had reversedisinstance()arguments and compared againststr(self)(which includes scheme/host forUri) instead ofas_posix().- Glob wildcard detection (
WILCARD_PATTERN, renamedWILDCARD_PATTERN, old name kept as an alias) used.match()(anchored) instead of.search(), so patterns like"foo*"weren't recognized as wildcards. Uriwas unhashable (defined__eq__without__hash__);__eq__now also returnsNotImplementedfor non-Pathname/stroperands instead of raising.Uri.is_relative_to()usedstr.startswith()on normalized path strings, so/foo/bar2was incorrectly reported as relative to/foo/bar; now compares path segments.Uri.relative_to(walk_up=True)was dead code -- an early guard raisedValueErrorbefore the walk-up loop ever ran.HttpPath.is_dir()/is_file()tested truthiness of bound methods (self._is_dir,self.is_dir) instead of calling/checking the right attribute, so both always returned truthy nonsense.SftpPath.chmod()didn't acceptfollow_symlinks=, so the inheritedlchmod()crashed withTypeError; now raisesNotImplementedErrorforfollow_symlinks=False(paramiko has nolchmod).SftpPathdefined_rename(), which nothing ever called -- renamed torename()somove()/rename()actually use SFTP's native rename instead of silently falling back to copy+unlink for every move.Uri.__init__()used a bareexcept:aroundPath.as_uri()(nowexcept ValueError:, matching whatas_uri()actually raises for relative paths) and crashed withAttributeErrorwhen constructing from anos.PathLikethat only implements__fspath__(noas_posix()).Path.rm(ignore_error=callable)never actually called the callable -- both branches of its error handler returned the callable object itself.
Fixed (Python 3.9/3.10 compatibility)
- Actual Python 3.9/3.10 runtime compatibility (CI previously only tested 3.11/3.13
and missed these):
LocalPath/Uricase-sensitivity and path-separator detection crashed on 3.9-3.11 (_flavourobject has nonormcase);open(mode="r")crashed on <3.10 (io.text_encodingis 3.10+); glob pattern compilation crashed on <3.11 (re.NOFLAGis 3.11+);FileUri.stat()/chmod()crashed on 3.9 (pathlib.Path.stat/chmodgainedfollow_symlinks=in 3.10; raisesNotImplementedErrorthere forfollow_symlinks=False). LocalPath._path_separatorsreturned the env-var list separator (;/:) instead of the path separator, and could include aNonealtsep on POSIX.
Added
tests/test_smoke.py: regression coverage for README/example snippets across supported Python versions.
0.4.1 - 2026-07-11
Fixed
- Removed explicit
[tool.hatch.build.targets.wheel]packages config that caused hatchling to fail resolvingREADME.mdduring editable installs on CI. - Converted
README.mdfrom a symlink (mode120000) to a regular file, fixinggit checkoutfailures on macOS and Windows runners. - Removed internal tooling references from committed files.
0.4.0 - 2026-07-11
Added
- Standardized repository layout and relocated examples to
examples/directory. - Configured MkDocs documentation site with dynamic API reference using
mkdocstrings. - Added GitHub Actions workflows for matrix testing (
test.yml) and release pipelines (release.yml). - Added typing marker
py.typedfor PEP 561 compliance.
Changed
- Added backward compatibility support for Python 3.9 and 3.10: added
from __future__ import annotationsacross the codebase, refactored runtime-evaluated union types to usetyping.Union, and provided fallbacks forTypeAliasandParamSpec. - Updated package requirement to
requires-python = ">=3.9".
0.3.5 - 2026-07-11
Added
- Split path into protocols that can be standalone.
- Sync error handling.
- Generic Path Protocol based pathlib implementation for URI paths with file access support for sftp, http, file schemes.