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