Repository navigation
v0.8.0
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, and
AzPath, 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).
- Cache
- 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 S3
list_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 local
http_serverbenchmark fixture, HTTP glob/walk over the fixture tree are
~89-94% faster than the already-optimized pre-_scandir()baseline (see
benchmarks/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 from
pathlib_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.archive
still resolves (now the package) and re-exportsArchiveUri/ZipUri/TarUri.
sftp.py->sftp/package (_paramiko.pyholds the existing paramiko-backed
SftpBackend;__init__.pykeepsSftpPath/BaseSftpBackend) -- same free
import-compat,pathlib_next.uri.schemes.sftpstill resolves and re-exports
SftpPath/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 the
sftp-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_sftpclient
access, 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 coverdata:,
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 for
popitem()).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 new
DavPath.rm(recursive=True)override (one request).Uri._make_child_relpath()doubled the join slash for any scheme whose
pathalready 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,024
matched only"1", truncatingsizeand leaking,024into
description.- The RFC-1123 datetime bucket's trailing timezone match
(... \d{2}:\d{2}:\d{2} .+) used an unbounded, greedy.+that
swallowed the rest of the<pre>listing line, including any trailing
size/description text on the same row --time.strptime()then raised
on the unconverted data, silently droppingmodifiedand every
field after it for that entry. Narrowed to\S+(the timezone is one
token). _DirectoryListingParser's absolute-href filter was a blanket
startswith('/')-- a reverse-proxied/absolute-URL-configured server
rendering every entry (not just the parent-directory link) as an
absolute href got back a completely empty listing, with no fallback
able to recover it. Scoped the filter to hrefs outside the listing's own
directory (parsed from<title>Index of ...</title>) instead, falling
back to the old blanket-drop behavior only when no title was parseable.HttpPath.stat()'s post-redirect HEAD re-fetch had no HEAD-405-to-GET
fallback, unlike the pre-redirect loop -- a server/proxy that rejects
HEAD outright (not just pre-redirect) 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).
Full Changelog: v0.7.0...v0.8.0