Repository navigation
v0.5.0
Fixed (critical -- found while writing Phase 6 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 Phase 5's 300
tests constructed viaLocalPath(...)directly instead, so this went
undetected untilexamples/local_and_mem.pyexercised the documented
Path(...)entry point end to end.
Fixed (found by the new Phase 5 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 passed
follow_symlinks=(e.g.Path.walk()'s defaultfollow_symlinks=False)
-- now shimmed withlstat()/lchmod()on <3.10, same as the existing
FileUrishim (which now just delegates toLocalPath).MemPath.__init__decided whether to propagate a parent's backend with
if _backend and backend is None:-- an empty (but valid) backend dict is
falsy, so joining off a freshly-created, 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 against
LocalPath/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 stdlib
ThreadingHTTPServer, 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) and
docs/guides/extending.md(both extension tracks, with worked examples
andpathlib_next.testing.PathContractusage). Rewrotedocs/index.md
and the README with a 30-second example per scheme and a capability
matrix. Class-level docstrings added across the package for the rendered
API reference.
Changed
examples/example.py(an unstructured scratch script) split into three
focused, runnable examples:examples/local_and_mem.py(self-contained,
no network),examples/http_listing.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()(default
st_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's
exist_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 the
is Nonecheck for trailing-slash directory-only detection dead code.Stat._st_mode()only caughtFileNotFoundError, lettingPermissionError
and 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 raises
NotImplementedErrorfor anything else.MemBytesIO.close()used
seek(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/_querylist
helpers (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 now
functools.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 against
str(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 raised
ValueErrorbefore 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 raisesNotImplementedErrorfor
follow_symlinks=False(paramiko has nolchmod).SftpPathdefined_rename(), which nothing ever called -- renamed to
rename()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()(now
except ValueError:, matching whatas_uri()actually raises for relative
paths) and crashed withAttributeErrorwhen constructing from an
os.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; raises
NotImplementedErrorthere 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.
Full Changelog: v0.4.1...v0.5.0