Skip to content

v0.8.0

Choose a tag to compare

@github-actions github-actions released this 13 Jul 05:18
· 470 commits to main since this release

Added

  • uripath command-line tool (pathlib_next.tools.uripath) for reading,
    writing, copying, removing, and syncing local or URI-backed paths. -
    works as stdin/stdout for byte-stream operations.
  • Recursive benchmark probes for local, memory, object-store, and SFTP
    backends, including provider call-shape rows for recursive deletes.
  • Provider-native recursive delete overrides for S3Path, GsPath, and
    AzPath, with bucket/container-root guards.
  • git: convenience dispatch over the existing github:/gitlab: providers, plus explicit git+github: and git+gitlab: forms for self-hosted or enterprise instances. git: only auto-detects public github.com/gitlab.com; ambiguous hosts now raise a clear ValueError naming the explicit alternatives.
  • HTTP write support (PUT, customizable to POST or other verbs via write_method configuration or with_session()) for HttpPath.
  • HTTP delete support (DELETE for unlink() and rmdir()) for HttpPath.
  • Comprehensive HTTP exception mapping in HttpPath translating client/server/timeout/connection errors into standard built-in OSError subclasses (FileNotFoundError, PermissionError, FileExistsError, TimeoutError, ConnectionError, NotImplementedError, or generic OSError).
  • Dynamic loading of custom URI scheme plugins via standard Python packaging entry points under the "pathlib_next.schemes" group, allowing third-party package extensibility.
  • Lazy-loading for all builtin scheme implementations (s3, sftp, http, etc.) to significantly reduce start-up and import overhead when heavy libraries are not needed.
  • MD5 and SHA-256 checksum helpers in pathlib_next.utils (md5 and sha256).
  • Optional checksum parameter in PathSyncer, defaulting to the new md5 helper.
  • Recursive directory copying via Path.copy(recursive=True).
  • Support for recursive folder moves falling back to recursive copy + recursive delete when rename is not supported.
  • Archive utilities make_archive and unpack_archive supporting ZIP and TAR formats using memory-efficient chunk streaming.
  • Hierarchical test contracts: PurePathContract (pure path operations) and ReadPathContract (read-only path operations), allowing contract-based verification of read-only and memory/archive paths.
  • Contract test suites wired for DataUri, ZipUri, TarUri, and HttpPath.
  • Dedicated unit tests for Path.walk(), samefile(), and Stat device queries.
  • Comprehensive runnable examples in examples/ for URI schemes: offline (data_and_archive.py) and environment-variable configured ones (ftp_listing.py, webdav_roundtrip.py, s3_listing.py).
  • Split monolith API reference documentation into per-module pages (path, uri, mempath, utils, testing).
  • Complete docstring coverage for all public methods/properties across Pathname, Path, Uri, UriPath, and protocols, and configured mkdocs to enforce docstring presence (show_if_no_docstring: false).
  • Detailed documentation of contract testing levels (PurePathContract, ReadPathContract, PathContract) in the extending guide.
  • In-process real-server contract tests for FtpPath (pyftpdlib), DavPath (wsgidav/cheroot), and S3Path (moto mock_aws): TestFtpContract, TestDavContract, TestS3Contract run the full PathContract suite against live local servers.
  • ftp_server, dav_server, and s3_server pytest fixtures in conftest.py serving ephemeral in-process servers with pre-populated fixture_tree contents.
  • Entry-point declarations in pyproject.toml (pathlib_next.schemes group) for all built-in schemes, enabling pip-installed external packages to auto-register custom schemes.
  • Plugin discovery tests in tests/test_plugins.py covering _load_entry_point, _load_builtin_scheme, and get_scheme_cls integration.
  • Property-based tests (hypothesis, new dev extra) in tests/test_properties.py: URI parse/format round-trip identity, join associativity, and parity with pathlib.PurePosixPath for segments/name/relative_to/match/is_relative_to.
  • ZipUri/ArchiveUri archive handle registry: independently-constructed UriPath("zip:...")/"tar:..." instances pointing at the same outer archive now share one _ArchiveBackend (keyed by backend class + outer URI, a weakref.WeakValueDictionary) instead of each opening its own handle -- fixes stale reads and out-of-sync writes across separately-constructed instances. The backend closes its handle automatically (__del__) once every referencing path is garbage-collected.
  • Full ZipUri write support: unlink(), rmdir() (empty-dir check, mirrors S3Path.rmdir()), rename() (renames a directory's nested entries too), and overwriting an existing entry's content (previously open("w") on an existing entry silently appended a duplicate zipfile entry instead of replacing it). All four go through a new safe full-archive rewrite (_ZipBackend._rewrite) since zipfile has no in-place entry mutation: writes to a temp file beside the outer archive, then atomically replaces it (os.replace). Requires a local (file:) outer archive, same as existing new-entry writes.
  • archive: catch-all URI scheme: auto-detects zip vs. tar for the outer archive (filename extension first, then a magic-byte sniff shared with unpack_archive via the new utils.archive._detect_format helper) instead of requiring the caller to know the format up front. Explicit archive+zip:/archive+tar: forms skip detection outright. archive:...!/x and zip:...!/x pointing at the same outer archive share one backend (same registry as zip:/tar:), and write support (new/overwritten entries, unlink/rmdir/rename) works through archive: exactly as it does through zip: when the detected format is zip and the outer archive is local -- tar-detected instances correctly raise NotImplementedError on any write attempt.
  • New gs: (Google Cloud Storage) and az: (Azure Blob Storage) URI schemes (GsPath/AzPath in pathlib_next.uri.schemes.gs/.az, with GsBackend/AzBackend for credential/endpoint override): gs://bucket/key/path and az://account/container/key/path. Both support full PathContract (read/write/list/delete/rename), reusing the prefix-emulation directory semantics of S3Path (no real directories; is_dir() checks for keys under "<path>/", mkdir() writes a zero-byte "<path>/" marker, rmdir() requires empty). Wired to PathContract against faithful in-process fake JSON/XML REST API servers (gcs_api_server/gs_server, az_api_server/az_server in conftest.py), plus scheme-specific unit tests. New examples/gs_listing.py/examples/az_listing.py (env-var gated, fail-soft). Like S3Path, both cache one service client per backend instance (thread-safe for both SDKs). Report no mtime (st_mtime=0, documented divergence). Each is its own pyproject.toml extra: gs (google-cloud-storage) and az (azure-storage-blob). rename() uses server-side copy+delete (same bucket/container only) instead of the generic download+upload+delete move() fallback.
  • New github:/gitlab: read-only URI schemes (GitHubPath in pathlib_next.uri.schemes.github, GitLabPath in pathlib_next.uri.schemes.gitlab, sharing a private _RepoApiPath base and a plain-requests RepoBackend, no PyGithub/python-gitlab SDK): <scheme>://host/owner/repo/path/in/repo?ref=<ref>, ref always optional in the query string. GitHubPath lists via the contents API (one call gives type/size for a whole directory) and reads file bodies via the raw media type; GitLabPath lists via the tree API (no size, so only directory entries get a stat hint) and reads via the files /raw endpoint, resolving+caching the project's default branch itself when ref is omitted (GitLab's file endpoints -- unlike its tree endpoint -- 400 if ref is missing, confirmed live against gitlab.com). host defaults to the public SaaS host; any other host is treated as GitHub Enterprise (https://{host}/api/v3) or a self-hosted GitLab (https://{host}/api/v4). Auth via a bearer token (RepoBackend(token=...)) or URI userinfo. Both reuse the http extra (no new extra added). Wired to ReadPathContract against faithful in-process fake API servers (github_api_server/gitlab_api_server in conftest.py), plus scheme-specific unit tests (ref propagation through iterdir(), rate-limit/error translation, GitHub Enterprise API-base derivation, GitLab dir-vs-file stat disambiguation). New examples/github_listing.py/examples/gitlab_listing.py.
  • New sftp-async extra: an AsyncsshSftpBackend (asyncssh, async internally, bridged to a sync API through one shared background event loop) alongside the existing paramiko-based SftpBackend. Auto-selected when asyncssh is importable (paramiko remains the fallback); override via the PATHLIB_NEXT_SFTP_BACKEND env var ("paramiko"/"asyncssh"/"auto") or a SftpPath._default_backend_cls subclass hook -- precedence, highest to lowest: explicit backend= kwarg > _default_backend_cls > env var > auto-detect. PATHLIB_NEXT_SFTP_BACKEND=asyncssh with the package missing raises immediately rather than silently falling back. Connections are cached per (backend, source) (no thread dimension needed -- one shared connection serves concurrent calls from any calling thread, unlike paramiko's (backend, source, thread) cache). Works on Python 3.9 too via a verified version pin (asyncssh<2.22; current asyncssh needs >=3.10) resolved automatically through pyproject.toml environment markers -- no code branching. New SftpPath.symlink_to()/readlink() (both backends -- core SFTPv3 operations) and hardlink_to() (asyncssh backend only; paramiko's SFTPClient has no hard-link operation, so it raises NotImplementedError immediately with no server round trip). chmod(follow_symlinks=False) now works on the asyncssh backend (native support) while still raising NotImplementedError on paramiko (no lchmod equivalent). This is additive, not a performance change -- the (separate, unscheduled) concurrent-fan-out work that would actually exploit asyncssh's pipelining remains future work.

Changed

  • Recursive Path.rm() now deletes bottom-up using non-following listing
    metadata where available, avoiding traversal through directory symlinks
    and reducing extra stat calls for metadata-rich backends.
  • Asyncssh SFTP recursive copy/remove now use native bounded async helpers
    for ordinary files/directories instead of recursing through sync path
    methods on the bridge loop.
  • PathSyncer reuses child metadata during tree sync when that metadata is
    consistent with the active symlink-following policy.
  • Replaced the third-party htmllistparse and bs4 directory listing scraper dependencies with a hand-rolled, zero-dependency html.parser.HTMLParser subclass (_DirectoryListingParser), dropping both from the http extra in pyproject.toml. Verified equivalent output (name/size/modified, both Apache-<pre> and nginx-<table> formats) against the replaced bs4+html5lib+htmllistparse implementation, and 2.9x-6.2x faster depending on format/listing size (benchmarks/bench.py's 8/9 entries benchmark the new parser alone going forward, since the old implementation no longer exists in the tree).
  • Matrix expansion in GitHub Actions CI to test Python 3.10, 3.11, and 3.12 (on Ubuntu).
  • Added a "no-extras" CI job to run tests without optional dependencies installed.
  • PathSyncer.log() now logs through logging.getLogger("pathlib_next.sync")
    at INFO instead of calling print() -- stdout consumers must configure
    logging (e.g. logging.basicConfig()) to see sync progress again.
    EVENT_LOG_FORMAT switched from str.format ({event}) to %-style
    placeholders to match, and log() remains overridable for custom routing.
  • SyncEvent members are now numbered sequentially (previously a mix of
    explicit ints and enum.auto(), which raised a DeprecationWarning on
    Python 3.13). Values are not part of any documented/persisted contract.
  • Optimized performance across pure paths and URIs:
    • Cache Uri.segments in a slot to avoid re-splitting the path string on every access.
    • Cache Uri.suffix and Uri.stem in slots.
    • Optimize Source.__bool__ to use lazy index accesses and avoid tuple iteration.
    • Short-circuit Query.__new__ when the input is already a matching Query instance.
    • Uri._parse_uri()/Source.from_str(): one-pass component extraction from uritools.urisplit()'s raw fields instead of calling its seven get*() accessors, each of which independently re-rpartitions the authority string and re-decodes. Ported (not reinvented) from uritools.SplitResult's own property/getter logic -- including one of its quirks, reproduced on purpose (see uri/source.py::_split_authority) -- and verified equivalent by fuzzing 20,000+ generated URIs against uritools as the oracle (tests/test_properties.py, which stays the enforcement mechanism, not just a one-time check). Uri._format_parsed_parts()/DavPath._wire_uri(): direct string assembly instead of uritools.uricompose()'s full re-validation, for the same reason and with the same fuzzing rigor (uri/source.py::_compose_uri) -- both bypass a general-purpose library's necessarily-defensive validation only where the input is already known-canonical (parsed or otherwise internally normalized), not for arbitrary/untrusted URIs. uritools itself is unchanged as a dependency and remains the parsing/composing engine underneath both fast paths -- a hand-rolled RFC 3986 implementation was evaluated and rejected (verdict: slower or not worth the permanent edge-case-ownership cost). Measured on .venv/3.12.10, unique URIs per iteration (a repeated-URI microbenchmark flatters by masking real per-call cost): the full Uri(unique_url).as_uri() round trip (parse + compose, both changes) is ~20-25% faster; the parse side alone, isolated from Uri.__new__'s slot-initialization overhead (unaffected by this work), is ~17-30% faster on its own (see benchmarks/bench.py's 1b/1c entries).
  • New Path._scandir() / UriPath._scandir() protocol: schemes whose
    listing call already returns type/size/mtime for every child (HTML
    directory index, WebDAV PROPFIND, SFTP listdir_attr, FTP MLSD, an S3
    list_objects_v2 page) can now yield (name, FileStat) pairs directly,
    and walk()/glob() answer is_dir() from that instead of a stat()
    round trip per entry -- a remote-tree walk goes from O(entries) requests
    to O(dirs). HttpPath, DavPath, SftpPath, FtpPath, and S3Path all
    adopt it; _listdir()/iterdir() remain fully supported for schemes that
    don't override _scandir() (no behavior change, no win). On the local
    http_server benchmark fixture, HTTP glob/walk over the fixture tree are
    ~89-94% faster than the already-optimized pre-_scandir() baseline (see
    benchmarks/bench.py). HttpPath also drops its _isdir instance-cache
    slot and its is_dir()/is_file() overrides (now derived generically
    from stat(), like every other scheme) in favor of a single-use stat
    hint seeded by _scandir(); DavPath's now-redundant iterdir()/
    is_dir()/is_file() overrides are removed for the same reason.
  • Breaking (pre-1.0, no compat shim kept): uri/schemes/ module naming convention
    -- every module is now named after the main URI scheme it implements (TLS/secondary
    variants live with their main scheme). webdav.py -> dav.py; import from
    pathlib_next.uri.schemes.dav (the old pathlib_next.uri.schemes.webdav path no
    longer exists). archive.py -> archive/ package (_base.py shared machinery,
    zip.py, tar.py) -- import-compatible for free, pathlib_next.uri.schemes.archive
    still resolves (now the package) and re-exports ArchiveUri/ZipUri/TarUri.
    sftp.py -> sftp/ package (_paramiko.py holds the existing paramiko-backed
    SftpBackend; __init__.py keeps SftpPath/BaseSftpBackend) -- same free
    import-compat, pathlib_next.uri.schemes.sftp still resolves and re-exports
    SftpPath/BaseSftpBackend/SftpBackend. Prepares the layout for an upcoming
    second (asyncssh) backend; SftpBackend gained a default() classmethod factory
    so SftpPath._initbackend() doesn't need to import paramiko itself.
  • SftpPath's connection caching moved from an external cache wrapping backend.client()
    calls to being each backend's own responsibility (SftpPath._sftpclient is now a
    trivial self.backend.client(self.source), no per-backend branching). Needed so the
    new asyncssh backend can use its own (backend, source)-keyed cache (see the
    sftp-async entry above) without SftpPath needing to know which caching scheme
    applies. Behavior-affecting for custom BaseSftpBackend subclasses: a client()
    override that doesn't cache internally will now be called on every _sftpclient
    access, not just on a cache miss -- SftpBackend/AsyncsshSftpBackend both cache
    internally, so this only matters for third-party/test-double backends.
  • TestSftpContract's in-process test server (tests/conftest.py::sftp_server) is now
    asyncssh's own SFTPServer (chrooted to fixture_tree) instead of a ~150-line
    hand-rolled paramiko ServerInterface/SFTPServerInterface -- a client backend choice
    is independent of which library the test server uses (verified: a paramiko client
    talks standard SFTP to an asyncssh server fine). TestSftpContract itself is now
    parametrized across both client backends (paramiko, asyncssh).

Fixed

  • Recursive delete on exact object-store keys now treats the exact object as
    the addressed path before considering a "<key>/" prefix tree, preventing
    accidental prefix-tree deletion for S3Path, GsPath, and AzPath.
  • Azure recursive delete falls back from delete_blobs() to per-blob
    deletion when a provider or emulator rejects the batch API.
  • Uri.relative_to() computed the remaining segments from the raw .segments property instead of the root-aware _segments_of() helper is_relative_to() already used -- Uri("/").segments is the 2-tuple ("", "") (an artifact of "/".split("/")), so relative_to(<root>) silently dropped the child's only real segment (e.g. Uri("/a").relative_to(Uri("/")) produced "" instead of "a"). Found by the new property-based test suite.
  • TestSftpContract's in-process paramiko test server (tests/conftest.py::sftp_server) deadlocked every real I/O test: its _SSHServer.check_channel_subsystem_request() override returned name == "sftp" directly instead of delegating to paramiko.ServerInterface's default implementation, which is what actually instantiates and starts the registered SFTPServer handler thread (handler.start()). Without it, the channel was reported "hooked up" to the client but nothing server-side ever read from or responded on it, so SFTPClient.from_transport() blocked forever in version negotiation. Fixed by removing the override (the inherited default already does exactly what the removed comment claimed it did).
  • SftpPath._mkdir()/_open(mode="x") propagated a generic, untyped OSError("Failure") when the target already existed -- SFTPv3 has no dedicated "already exists" status code, so paramiko's server-side convert_errno() falls through to SFTP_FAILURE for EEXIST (unlike ENOENT, which it does map, giving a proper FileNotFoundError). Both now check self.exists() on failure and raise FileExistsError to match every other scheme's mkdir/touch(exist_ok=False) contract (mirrors FtpPath._mkdir()'s existing check-after-failure pattern). Found by TestSftpContract once the deadlock above was fixed and it could actually run.
  • FtpPath.stat() returned FileNotFoundError for the FTP root path "/" because _mlsd_entry() has no parent directory to query; now uses CWD / to confirm the root exists as a directory.
  • FtpPath.rmdir() propagated raw ftplib.error_perm (550) instead of OSError when the directory was non-empty, violating the pathlib contract.
  • FtpPath.chmod() raised ftplib.error_perm when the server rejected SITE CHMOD (pyftpdlib does not implement it); now converts to NotImplementedError so Path.copy() silently skips the metadata step.
  • pytest filterwarnings updated to suppress boto3.exceptions.PythonDeprecationWarning (boto3 EOL notice for Python 3.9, inherits Warning not DeprecationWarning) and ResourceWarning from daemon-thread server socket cleanup at GC teardown.
  • README and docs landing page were still describing the pre-0.6.0 scheme set:
    the capability matrix, extras table, and quick starts now cover data:,
    ftp(s):, zip:/tar:, dav(s):, and s3: (all shipped in 0.6.0/0.7.0
    but previously only documented in the Schemes guide).
  • LRU.maxsize setter raised TypeError when shrinking below the current
    fill (OrderedDict.pop() was called with the last=False kwarg meant for
    popitem()).
  • DavPath.rmdir() mapped directly to WebDAV DELETE, which is recursive
    by spec (RFC 4918) -- it silently deleted non-empty collections instead
    of enforcing pathlib's "must be empty" contract like every other scheme.
    Now does a depth-1 PROPFIND first and raises OSError (ENOTEMPTY) if
    children exist. The native recursive DELETE is still available, and
    cheaper than the base class's client-side walk, via the new
    DavPath.rm(recursive=True) override (one request).
  • Uri._make_child_relpath() doubled the join slash for any scheme whose
    path already ends in "/" (e.g. f"{self.path}/{name}" on an HTTP/DAV
    directory path produced "//name"); also now treats an empty path with
    an authority present as the same root as "/" (RFC 3986:
    "http://host" == "http://host/") instead of joining a bare, ambiguous
    name with no leading slash.
  • _DirectoryListingParser._RE_FILESIZE's digit class excluded , -- the
    <table> path strips commas from cell text before matching, but the
    <pre> path matches first, so a comma-thousands size like 1,024
    matched only "1", truncating size and leaking ,024 into
    description.
  • The RFC-1123 datetime bucket's trailing timezone match
    (... \d{2}:\d{2}:\d{2} .+) used an unbounded, greedy .+ that
    swallowed the rest of the <pre> listing line, including any trailing
    size/description text on the same row -- time.strptime() then raised
    on the unconverted data, silently dropping modified and every
    field after it for that entry. Narrowed to \S+ (the timezone is one
    token).
  • _DirectoryListingParser's absolute-href filter was a blanket
    startswith('/') -- a reverse-proxied/absolute-URL-configured server
    rendering every entry (not just the parent-directory link) as an
    absolute href got back a completely empty listing, with no fallback
    able to recover it. Scoped the filter to hrefs outside the listing's own
    directory (parsed from <title>Index of ...</title>) instead, falling
    back to the old blanket-drop behavior only when no title was parseable.
  • HttpPath.stat()'s post-redirect HEAD re-fetch had no HEAD-405-to-GET
    fallback, unlike the pre-redirect loop -- a server/proxy that rejects
    HEAD outright (not just pre-redirect) surfaced PermissionError for a
    directory that actually exists. Now mirrors the pre-redirect loop's
    fallback.
  • HttpPath._listdir() now retries once with a trailing slash if the
    slash-less path 404s (defensive: real redirecting servers already work
    via requests' default GET redirect-following, but a non-redirecting
    server/proxy previously had no fallback at all).
  • HttpWriteStream.close() raised before marking the underlying stream
    closed on a failed upload, so a second close() call (context-manager
    __exit__ cleanup, or GC via IOBase.__del__) silently retried the PUT.
    Now marks closed even on failure.
  • HttpPath.rmdir()/DavPath.rmdir() never checked is_dir() before
    falling through to unlink() -- an empty directory's listing and a
    file whose body/PROPFIND response yields zero real entries are
    indistinguishable from _listdir() alone, so calling rmdir() on a
    file silently deleted it instead of raising NotADirectoryError
    (os.rmdir()'s ENOTDIR contract).

Full Changelog: v0.7.0...v0.8.0