Skip to content

v0.9.7

Choose a tag to compare

@github-actions github-actions released this 16 Sep 17:20

Added

  • rm(follow_symlinks=, follow_binds=): what a recursive removal does
    with a symlink, and with a binding (a Windows junction, a mount point).
    Each takes False (the default -- remove the entry itself, never its
    contents, as rm -r does), True (remove what is behind it), None
    (leave it in place, so the enclosing directory is not empty and reports
    it), or a callable policy(path) -> bool | None asked per entry, so one
    tree can keep one mount and follow another. Named for the keyword
    stat(), walk() and copy() already use for the same idea, rather
    than a second vocabulary.

  • Path.is_junction(), Path.is_mount() and Path.is_dir_binding():
    a directory that is another tree's second NAME -- a Windows junction, or
    a mount point such as a Linux bind mount -- is now a first-class concept
    rather than a private hook used by one call site. Neither is a symlink
    (is_symlink() is False, a non-following stat reports a plain
    directory), which is precisely why a symlink check cannot protect a walk
    from one. is_junction() matches pathlib 3.12's; both are False by
    default and answered for real by LocalPath/FileUri.

  • Path.glob(on_error=) / rglob(on_error=): a hook called as
    on_error(error) when a directory cannot be listed, the same contract as
    walk() and os.walk. Raising from it makes an unreadable directory
    fatal; returning treats it as empty. Without the hook the listing is
    skipped silently, as pathlib does and as before -- which left a caller
    unable to tell an unreadable layer from an absent one. error.filename
    names the directory even when the backend left it unset.

  • Path.glob(bound_loops=) / rglob(bound_loops=): with True, a
    directory is descended at most once per **, keyed on
    (st_dev, st_ino) and seeded with the starting directory. This bounds a
    Windows junction loop -- a junction reports is_symlink() == False, so
    recurse_symlinks=False cannot see it, and one file in a looping tree
    matched 64 times (pathlib walks it the same way). Default False keeps
    pathlib's behaviour; a backend whose stat carries no identity is walked
    unbounded. Both reported by yaconfiglib against 0.9.6.

Fixed

  • An SFTP listing is untrusted input. The server chooses the names, and
    they were turned into child paths unchecked, so a crafted ../victim.txt
    made rm(recursive=True) delete outside the tree it was given and a
    recursive copy read from outside it (measured on both backends). Names
    that are not a single component inside the directory are now skipped, as
    dav: and http: already did. The asyncssh fan-out walkers, which bypass
    _scandir(), filter for themselves.
  • unpack_archive() split a backslash on every platform, so a POSIX
    member whose name contains one was extracted as two path components and
    one carrying .. was silently dropped -- both legal single filenames
    there. A backslash now separates only for a Windows destination, which is
    the rule the function already documented.
  • has_glob_pattern() was True for every Windows extended-length path.
    The ? in a \\?\C:\... anchor (and the \\?\UNC\... form) is a prefix,
    not a wildcard, so a caller using it to tell a pattern from a plain path
    got the wrong answer for all of them. The anchor is no longer scanned.
    Reported by yaconfiglib against 0.9.6.
  • Writing over an archive member spelled ./f.txt appended a second
    entry instead of rewriting it
    , and unlink() then deleted that second
    entry and reported success while the original content came back. 0.9.5
    handed the normalized name to a backend keyed on the raw one; writes now
    address the entry the archive really holds.
  • Renaming a directory in such an archive silently did nothing, or
    split it in two when its members were spelled inconsistently, while
    rename() returned the new path as though it had worked. The backend now
    receives the exact raw-name mapping instead of a normalized prefix.
  • Every archive operation rebuilt the member index, so a listing, stat,
    read or write on a 20k-member archive scanned all 20k names: measured
    10x-224x slower than 0.9.4. The index is cached per open handle and
    dropped whenever the handle is, which every mutation and every external
    change already go through.
  • glob(None) ignored native=, never auto-detected **, and answered
    differently from rglob(None)
    for the same path, because that branch
    bypassed the pattern parser: LocalPath("/x/**/*.py").glob(None) returned
    a shallow subset with no error. native=False also left the trailing-**
    rule following the interpreter, so it did not deliver the one-answer
    promise it documents; it now pins that rule too.
  • / mangled a data: payload (data:,a/../b joined to data:b/x,
    not a data URI at all), dropped a scheme's own child handling (gitlab:'s
    reserved -), still parsed a bytes name as URI syntax, and lost
    per-instance scheme state such as SftpPath's ssh_config. Joins now
    walk segments through the same builder a listing uses.
  • A relative str destination whose first segment merely contained a
    colon
    (notes:draft, Fedora-42:latest.tar) was read as a URI scheme
    by copy()/move(); the scheme must now be one a class registers, as
    the uripath CLI already required. A Windows drive path (C:/Temp/x)
    restarts the join for a file: path, as PureWindowsPath does.
  • rename() and copy()/move() resolved a relative str differently
    (rename("../b") sent a literal sub/../b, a different key on an object
    store); both now resolve it the same way. A same-endpoint URI destination
    reuses the configured connection instead of opening a second, bare one.

Changed

  • rm(recursive=True) no longer descends into a mount point. It
    already removed a Windows junction as a binding rather than walking into
    it; a POSIX bind mount is the same thing and was walked, so deleting a
    tree containing one deleted the mounted filesystem's contents. It is now
    rmdir()'d like a junction, which fails loudly on a live mount instead.
  • UriPath / "name" and joinpath() read a str as a decoded path,
    not as URI syntax. base / "cache?v=2" is now the file cache?v=2
    instead of base/cache with a query; "note#2.txt", "a%20b.txt" and
    "C:/Temp" join verbatim too. This is what iterdir() always did, so
    listing a directory and naming the same child by hand finally agree. Pass
    a Uri/UriPath argument for a scheme-aware join
    (base / UriPath("s3://bucket/key")); that stays the only form that can
    cross endpoints, and it still drops a credential-bearing backend on the
    way. Dot segments are removed from the result as before.
  • copy()/move() accept a plain-path str destination. A string with
    a scheme is a URI, as before, so copy("s3://bucket/key") keeps working;
    one without is a decoded path on the same endpoint (absolute replaces the
    path, relative is a sibling, as rename() resolves it) and reuses this
    path's source and backend. move("b.txt") previously built a sourceless
    path and failed on its first exists() call, and a same-host URI
    destination opened a second connection. A one-letter scheme is treated as
    a Windows drive, so C:/Temp/x is a path.

Full Changelog: v0.9.6...v0.9.7