Skip to content

v0.9.12

Choose a tag to compare

@github-actions github-actions released this 08 Oct 03:15

Added

  • Path._node_key(), an override hook answering where a path's node
    lives: (namespace, names), the object two paths share when they resolve
    names in one tree and the node's normalized position in it. copy() and
    move() compare two paths that both answer it across classes. MemPath
    and the archive paths override it; any other type keeps comparing two paths
    of one type with ==, so nothing needs to change.
  • PathContract calls rm(recursive=True) with follow_symlinks= and
    follow_binds=
    (test_rm_recursive_accepts_the_follow_policies, and
    test_rm_rejects_a_policy_that_is_not_one). An implementation that
    overrides rm() must accept both keywords, as Path.rm() does; its contract
    subclass fails these two tests until it does. No fixture or capability
    attribute changed.

Changed

  • The scheme extras now declare version ranges. uritools, requests,
    paramiko, boto3, google-cloud-storage, azure-storage-blob,
    azure-identity and asyncssh had no lower bound (asyncssh had only the
    Python 3.9 cap) and no upper bound, and netimps had only a floor. The
    ranges are now uritools>=5.0,<7, netimps>=0.4.0,<0.5 (was >=0.3.1),
    requests>=2.22.0,<3, paramiko>=5.0,<6, asyncssh>=2.20.0,<3 (below 2.22
    on Python 3.9), boto3>=1.36.0,<2, google-cloud-storage>=3.0,<4,
    azure-storage-blob>=12.4.0,<13 and azure-identity>=1.0,<2. An
    environment that pins one of them below its floor must raise the pin; one
    that needs a new major version must wait for a release that admits it.
  • copy(preserve_metadata=True) drops the setuid, setgid and sticky bits
    between two different classes.
    A mode read from a tar member, a zip entry
    or a remote server was applied verbatim, so 04755 in an archive made a
    setuid file on the local disk. A copy between two classes now applies the
    permission bits without those three; a copy within one class keeps them.
    Run chmod() afterwards where the bit is wanted.
  • move() calls rename() only onto a path of the same class. The default
    of Path._rename_compatible() was True, so a Path subclass with a
    rename() of its own was handed a path of another store and ran the rename
    inside its own (a MemPath subclass moved onto a LocalPath looked for the
    local path in memory). The target of another class now gets copy + delete.
    A subclass whose rename() handles other classes overrides
    _rename_compatible(); UriPath and LocalPath decide for themselves as
    before.

Fixed

  • A .. or . name in a remote listing no longer escapes the tree. The
    s3:, gs:, az:, github: and gitlab: listings and the default
    UriPath listing yielded a name such as .., ., a/b or an empty one as
    a child (http:, dav: and sftp: already skipped them), and the ftp:
    listing yielded one holding NUL. Joined onto the directory, .. named its
    parent, so walk() left the directory it was given and never ended,
    rm(recursive=True) deleted the parent tree, and
    PathSyncer(remove_missing=True) removed the parent or copied a sibling
    tree into the destination. All of those listings now skip such a name, and
    walk(), rm(recursive=True) and PathSyncer also refuse one that a custom
    scheme's _scandir() yields, before building any child from it: walk()
    leaves it out of dirnames/filenames and, when on_error is given, calls
    it with a ValueError; rm(recursive=True) offers a ValueError to
    ignore_error(error, directory) once per entry and removes nothing for it;
    PathSyncer raises ValueError through ignore_error for the source's and
    the target's names alike. A custom _scandir() should skip such names
    itself (utils.is_safe_child_name() decides). An object key with a . or
    .. segment is no longer listed.
  • Names Windows rewrites are refused for a Windows destination.
    utils.is_safe_child_name(name, windows=True) accepted a name ending in a
    dot or a space and the reserved device names. Windows stores report. as
    report, so copy(recursive=True) of a tree holding both merged them into
    one file with no error, and a member NUL of an archive was written to the
    device.
    With windows=True the function now also refuses a trailing dot or space
    and CON, PRN, AUX, NUL, COM1-COM9 and LPT1-LPT9, in any case,
    with or without an extension (nul.txt); windows=False is unchanged.
    copy(recursive=True) and PathSyncer report such a name as a
    ValueError per entry for a Windows-flavoured target, and
    unpack_archive() skips such a member for a Windows destination with a
    UserWarning. Rename a source file with such a name, or extract it on
    another platform.
  • A failed open("a") on http: no longer empties the remote file. In the
    default append_mode="rewrite", a failure of the GET that reads the
    current content (a 500, a 403, a timeout, a body cut short) raised to the
    caller, and then the half-built stream was garbage-collected and uploaded
    an empty body: the file was replaced by a zero-byte one. open("a") now
    raises and sends nothing, in both append modes; a write stream whose
    constructor did not finish never uploads.
  • A write through a redirect no longer reports success without storing the
    bytes.
    http: and dav: followed a redirect for PUT, PATCH,
    DELETE, MKCOL and MOVE the way a browser does. A 301 re-sent the
    PUT without its body and replaced the file with an empty one; a 302 or
    303 turned the request into a GET, so write_bytes() returned having
    written nothing and unlink(), mkdir() and rename() returned having
    done nothing. Those requests now go out with allow_redirects=False
    (an allow_redirects in with_session() does not apply to them). A 307
    or 308 to the same scheme, host and port is sent once more with the same
    method and body; any other 3xx, a redirect to another origin, a second
    redirect and a body that cannot be sent twice raise OSError (EIO)
    naming the status and the Location, without its userinfo. A server that
    redirects writes must be addressed by its final URL.
  • open("x") on http: and dav: no longer replaces a file whose
    existence check failed, and patch-mode append no longer overwrites the start
    of a file whose size is unknown.
    open("x") read any error of its
    existence check (a 5xx, a 403, a 429, a timeout) as "missing" and sent the
    PUT, replacing the file. It now raises that error and sends nothing, and
    the upload carries If-None-Match: *, which a server that honours it
    answers with 412, now FileExistsError. With append_mode="patch", a
    HEAD reply without Content-Length was read as size 0 and the appended
    bytes were written at offset 0 (Content-Range: bytes 0-2/*); open("a")
    now raises OSError naming the path and sends no PATCH. stat() still
    reports st_size 0 for such a reply. A server whose existence check fails
    for a missing file needs open("w") instead of open("x"); one that does
    not state the size of its files needs append_mode="rewrite".
  • A session or token no longer follows a path to another endpoint, and the
    mirroring idiom keeps the destination's backend.
    Since 0.9.7, a path that
    crossed endpoints (UriPath(base, "http://other/d/"), base.joinpath(Uri(...), "x"), base / Uri(...)) kept base's backend for the next str join,
    with_name() or copy()/move() destination written as a URL, until its
    first read, so an HttpPath.with_session(auth=...) session or a
    github://TOKEN@... token was sent to the other host. And
    dst_root / src.relative_to(src_root) dropped the backend given to
    dst_root and built the default one, so the copy ran on the ambient S3
    client, or on an HTTP session without its headers. A derived path now takes
    the backend its source holds only when it is on the same scheme, userinfo,
    host and port, the rightmost joined path on the result's endpoint
    supplies it, and the sourceless result of relative_to() holds none. A
    path that crosses endpoints needs its own with_session() or
    with_backend().
  • Deriving a path no longer builds a backend. parent, parents,
    with_name(), with_suffix(), with_query(), relative_to(),
    UriPath(base, x) and a join with a Uri or PurePath built the source's
    backend: the first parent of an sftp: path imported asyncssh, and
    raised ImportError where neither SSH library is installed. They now copy
    the backend the source already holds, or leave the new path without one
    until its first I/O. Paths derived before any I/O therefore
    build their own backend instead of sharing one made for the source; read
    path.backend first, or pass backend=, to share a connection.
  • A backend that cannot be weakly referenced no longer turns off the
    same-file guard.
    When _initbackend() returned a dict, an object() or
    an instance of a __slots__ class without "__weakref__", two separately
    built paths to one URL were judged to be on different filesystems and
    a.copy(b, overwrite=True) onto itself was not refused. A path now records
    that it built its backend and passes the record on, so such a backend reads
    as derived and the copy is refused. A backend given through backend= or
    with_backend() still reads as supplied.
  • Dot segments are removed from a joined path, as the constructor removes
    them.
    From 0.9.7 to 0.9.11, Uri("http://h/d/") / "../x" (a str join
    onto a path ending in a slash) addressed http://h/d/x, because .. was
    resolved against the empty last segment, while Uri("http://h/d/../x") is
    http://h/x, and a join with a Uri argument (/ Uri("../x"),
    Uri(base, "../x")) kept http://h/d/../x unresolved. All of them now give
    http://h/x. The dot segments of the whole joined path are removed, so
    Uri("http://h/d/x") / ".." is http://h/d/ (it was http://h/d),
    Uri("http://h/d/x") / "." is http://h/d/x/ (it was http://h/d/x) and
    Uri("a") / "../../x" is ../x, as the constructor gives for
    Uri("a/../../x") (the join gave x). Code that relied on .. after a
    trailing slash staying inside the directory must join the name instead
    (base / "x"). A join key that ends in . or .. now gives a path ending
    in a slash, whose name is empty: use .parent for the parent, and skip a
    "." key (base if rel == "." else base / rel) where the result must
    equal base.
  • A percent-encoded dot segment is removed after decoding.
    Uri("http://h/safe/%2e%2e/secret") kept the segment .., so .parent,
    .name and as_uri() disagreed with the path that was fetched
    (http://h/secret) and the URI did not parse back to itself. A %2e%2e,
    .%2e or ..%2F in a parsed URI now resolves like ... A str join key
    is a decoded path, where %2e%2e is still a literal name.
  • Dot segments never climb above a Windows drive.
    UriPath("file:///D:/data/../../x") was \x on the current drive, and a
    join such as UriPath("file:///C:/d/") / "../../../x" lost the drive the
    same way. On Windows they now stay on C:/, as PureWindowsPath does.
  • FileUri.parents on a Windows drive path ends at the drive root.
    UriPath(LocalPath("C:/Windows/System32")).parents ended in file:/C: and
    file:, two paths relative to the working directory, so an upward search
    (for p in path.parents: (p / "marker").exists()) looked in the current
    directory. It is now [file:/C:/Windows, file:/C:/], what repeated
    .parent and LocalPath.parents give. The parent of a bare drive
    (file:///C:) is the drive itself; it was the empty path.
  • A Windows path string works as a file: join key and as a
    copy()/move()/rename() destination on Windows.
    A destination such
    as str(pathlib.Path) gives (C:\Temp\x) was joined as one file name and
    failed with OSError (WinError 123) in FileUri.copy(), move() and
    rename(), and base / r"sub\x" named a file sub\x. On Windows a
    file: path now reads \ in such a string as a separator, so
    C:\Temp\x is an absolute path and sub\x is two names. Elsewhere a
    backslash is still an ordinary filename character, and every other scheme
    splits on / only.
  • move(overwrite=True) no longer deletes the source when the target holds
    it.
    dst/sub.move(dst, overwrite=True) removed the whole of dst, the
    source included, and then raised FileNotFoundError; the same happened on
    MemPath. A target that holds the source now raises
    OSError(ENOTEMPTY) and one that lies inside it OSError(EINVAL), with both
    trees intact, before anything is removed. Move the source out first, or
    name a different target.
  • copy() and move() recognise one file under another class or
    spelling.
    LocalPath(f).move(target, overwrite=True) deleted f when
    target was a LocalPath subclass, a file: URI or a pathlib.Path for
    the same file, and so did a relative and an absolute MemPath of one file
    (a.txt, /a.txt, /d/../a.txt) and the zip:, archive: and
    query-carrying spellings of one archive member; copy() across the same
    spellings was not refused either. The move is now an in-place rename and the
    copy raises OSError(EINVAL), with the content kept. A directory reached
    through a symlink is the same directory, so copy(recursive=True) into
    it is refused as a copy into itself. A type whose segments spell one node
    several ways overrides the new _node_key() hook.
  • move() between two hard links of one file ends with the target name.
    On POSIX it reported success and left both names; on Windows it removed the
    source even without overwrite. An existing target now needs
    overwrite=True (FileExistsError otherwise), and the source name is then
    removed. On POSIX a symlink moved onto the file it points at replaced the
    file with a link to itself; it now raises OSError(EINVAL).
  • copy(overwrite=True) removes the target with unlink(missing_ok=True).
    A backend whose write replaces the file and which cannot delete had to make
    unlink() lie; it now implements unlink() so that missing_ok=True
    returns without removing.
  • rm(recursive=True, follow_symlinks=True) works on POSIX. It emptied
    the directory behind a followed symlink and then called rmdir() on the
    link itself, which fails with NotADirectoryError on Linux and macOS: the
    files behind the link were gone, the link and the rest of the tree stayed.
    The link is now unlinked once what is behind it has been removed.
  • rm() applies follow_symlinks= and follow_binds= to the path it is
    called on.
    They decided only the entries found on the way down: a symlink
    named directly was unlinked whatever follow_symlinks said, while a
    junction named directly honoured follow_binds. A symlink named directly
    now follows the same policy: True with recursive=True empties what is
    behind it and then removes it, None leaves it, and without recursive
    it is only unlinked. A call that passed follow_symlinks=True or None for
    a link it names directly now does what the keyword documents.
  • An invalid follow policy is raised, not ignored. A callable that
    answered anything but True, False or None had its ValueError
    reported through ignore_error, so ignore_error=True left the tree half
    removed with no error. The error now reaches the caller, and a keyword
    value that is not a policy raises before anything is removed.
  • A class that mixes a concrete pathlib path with Path but not
    LocalPath has LocalPath's local primitives.
    The precedence guard
    replaced stdlib's is_junction() and is_mount() with the generic
    answers (always False), so rm(recursive=True) on such a class walked
    into a junction and deleted the files of the directory it points at, even
    with follow_binds=None. It also left symlink_to() raising
    NotImplementedError, chmod("0644") raising TypeError on 3.10 and
    later, is_dir(follow_symlinks=False) and is_file(follow_symlinks=False)
    raising TypeError on 3.9, and chown() raising NotImplementedError.
    Every function LocalPath defines is now installed on such a class
    wherever stdlib or a generic default would answer, from the class itself
    so the two cannot drift; a method of the class or of a mixin of its own
    still wins.
  • rm() on sftp:, dav:, s3:, gs: and az: accepts
    follow_symlinks= and follow_binds=.
    The five overrides stopped at
    ignore_error, so naming either keyword raised TypeError before any I/O.
    They take both with Path.rm()'s defaults, check them before sending
    anything, and forward them wherever the generic removal runs. dav: and the
    object stores have no symlinks or bindings, so the keywords decide nothing
    there; sftp: runs its concurrent native walk only for the default
    follow_symlinks=False and the generic walk for any other value.
  • move() keeps a symlink a symlink when it has to copy. Where rename()
    was unavailable (another device, a backend without rename()) the fallback
    followed the link: the destination became a full copy of the linked tree or
    file, and so did every link inside a moved tree. The fallback now recreates
    the links wherever the target's class can hold them, so the result no
    longer depends on whether the two paths share a filesystem. A target that
    cannot hold links (MemPath, an object store) still receives the content.
  • copy(recursive=True, preserve_metadata=True) keeps directory
    permissions.
    Files kept their mode but directories were created with the
    default one, so a 0o700 directory copied as 0o755 and the files that
    were private through their parent became reachable; a move of a tree across
    devices did the same. Each directory now takes the source's reported mode
    after its children are copied (so a read-only directory still receives
    them), as shutil.copytree and pathlib 3.14 do.
  • PathSyncer no longer deletes the target entry behind a source link that
    cannot be resolved.
    With the default follow_symlinks=True, a source
    child that is a symlink to nothing (an unmounted volume, a removed
    directory) was read as "vanished", and remove_missing=True removed the
    same-named target entry recursively: a target directory holding the only
    copy of its files was emptied and the run reported success. (A link in a
    loop failed the listing of the whole directory instead.) A name the source
    directory lists is now never missing.
    Such a link is an error for that entry, offered to ignore_error with
    event SyncEvent.SyncStart; the target entry stays as it is, with or
    without remove_missing, and the rest of the directory is still synced
    once the policy tolerates it. A sync that met such a link and finished
    silently now raises
    (FileNotFoundError for a dangling link, the stat's
    OSError for a loop) under the default policy: pass ignore_error=True
    (or a callable) to carry on past it, or follow_symlinks=False to mirror
    the link itself.
  • ignore_error receives PathAndStat entries when the root stat fails.
    A failing stat() of the root source or target offered the caller's raw
    path objects to the policy while the hook got PathAndStat, so a policy
    reading source.path or source.stat raised AttributeError and hid the
    real error. Both arguments are now always PathAndStat (with an unknown
    stat when the stat is what failed).
  • PathSyncer no longer lists, writes or deletes through a Windows
    junction inside the target.
    A symlink found below the root target was
    replaced, but a junction looks like an ordinary directory, so
    remove_missing=True removed the files of the directory it names and
    wrote the source's files into it. A junction below the root is now treated
    like a symlink: it is replaced by a real directory
    (SyncEvent.TypeMismatch; rmdir() removes the junction and nothing
    behind it). A mount point inside the target is still synced into, and the
    target passed to sync() is used as given, junction or not. A sync that
    relied on writing through a junction must name the junction's directory as
    its target.
  • A failed rename fallback in PathSyncer no longer deletes the only copy of
    a file.
    Onto a backend whose rename() refuses an existing target (plain
    SFTP without posix-rename), the old file is removed and the temporary file
    renamed in its place; when that second rename failed, the clean-up deleted
    the temporary file too and the directory held neither version. Once the old
    entry is gone the temporary file (and, replacing a link, the temporary link)
    is kept and the OSError names it; ignore_error receives that error. The
    fallback is now exercised by the tests.
  • A changed file whose name is near the length limit can be updated. The
    temporary sibling is .NAME.<12 hex digits>.pathlib-next-tmp, 31 characters
    longer than NAME, so a file with a 225-character name was created by the
    first sync and failed (ENAMETOOLONG, WinError 123) on every later one that
    found it changed. NAME is now cut so that the temporary name fits in 255
    UTF-8 bytes, keeping the random part and the suffix.
  • The temporary files of a killed PathSyncer run are no longer copied
    onward, and a stale one is removed.
    The leftover .NAME.<hex>.pathlib-next-tmp
    of an interrupted transfer stayed beside the intact target for good under
    the default remove_missing=False, and a sync from that directory copied it
    to the next destination. A name of exactly that form is now the library's
    own: it is never a sync source, and remove_missing=True no longer removes
    one as "missing" (a transfer running in another process writes one). It is
    removed from a target directory when it is stale -- not being written by
    this process, with a known modification time over 24 hours old -- when a
    changed file or link in that directory is next written, and by
    remove_missing=True; a younger one, or one whose store reports no
    modification time, stays, and so does anything that does not match the
    full pattern. A file you named .x.<12 hex digits>.pathlib-next-tmp by
    hand is skipped as a source; rename it to have it synced. The removal is a
    SyncEvent.RemovedMissing.
  • A PathSyncer dry run refuses a symlink the target cannot hold, as the
    real run does.
    With follow_symlinks=False, a symlink in the source and a
    target that cannot create links (MemPath, an object store), dry_run=True
    returned and reported SyncEvent.Symlink while the real run raised
    NotImplementedError, so uripath sync --dry-run printed would symlink
    for something the real run refuses. Both now take the decision before
    anything is touched, offer the same NotImplementedError to ignore_error
    and fire no Symlink event for it; the command's dry run exits 1 with the
    same message.
  • PathSyncer sees one tree under two spellings or classes as overlapping.
    The overlap check compared two paths of the same class lexically, so
    MemPath("r") and MemPath("/r") on one backend, one archive directory
    under zip: and archive: (or with a query), and a LocalPath and the
    file: URI of the same directory were not reported, so a sync could copy a
    tree onto or into itself. It now asks the question copy() and move() ask
    about "same file", so all of these raise ValueError through ignore_error
    before anything is touched; two paths of an unrelated class are still never
    reported.
  • A recursive copy() or rm() on the asyncssh backend has stopped when it
    raises.
    After the first error, a timeout or a KeyboardInterrupt in the
    calling thread, the tasks below the failing entry kept copying and deleting
    on the server in the background, so a caller that retried or cleaned up
    raced them, and a failed task's own error was logged as never retrieved.
    Every task of the walk is now cancelled and awaited before the call returns
    or raises, an exception delivered to the waiting thread cancels the walk
    like a timeout does, and a destination file whose copy did not complete (an
    error, a cancel, an interrupt) is removed instead of being left truncated.
    Code that relied on the work finishing after an error has to wait for the
    call itself.
  • The asyncssh copy(recursive=True, overwrite=True) no longer deletes a
    destination file before its source is known to be readable.
    It unlinked
    an existing destination and only then opened the source, so a source that
    could not be opened (permission denied, locked) left the destination gone,
    where the paramiko backend and Path.copy() keep it. The source is opened
    first.
  • A recursive sftp: copy between two paths with different supplied
    backends goes through the generic copy.
    Two paths of one authority that
    hold distinct backends (a different ssh_config HostName, a tunnel, other
    credentials) made the asyncssh fan-out create the target directory over the
    target's connection and write every file over the source's. The fan-out now
    runs only when _same_filesystem() holds, so each file reaches the target's
    server.
  • The asyncssh copy applies modes as Path.copy() does. It sent
    st_mode with its file-type bits (0o100644, 0o40755), created
    sub-directories without a mode and never set the mode of the directory
    copied into. It now sends the permission bits only, creates directories
    with 0o777 like mkdir(), settles every directory, the target included,
    after what is in it, and drops the setuid, setgid and sticky bits between
    two different classes, as the generic copy does.
  • On asyncssh, rm(recursive=True) treats missing_ok and ignore_error
    as the generic rm() does.
    missing_ok=True swallowed a
    FileNotFoundError from an entry that vanished below the path and
    returned with the tree still there; it now covers the path rm() was
    called on only. A callable ignore_error that returned False was handed
    the same error again at every enclosing directory (five calls for a file
    four levels down); each error is offered once.
  • The asyncssh backend honours an ssh_config Port for a host alias. It
    always passed a port to asyncssh (22 when the URI had none), which outranks
    the config, so sftp://box/ with Host box / HostName 127.0.0.1 /
    Port 2222 connected to port 22 and offered the URI's password to whatever
    answered there. A port is now passed only when the URI names one; the
    URI's port still outranks the config's.
  • An sftp: host that is not a plain host name or address is refused before
    any connection or config lookup.
    The host reached the config lookup and
    then a ProxyCommand command line unchanged, so sftp://u@host%20--flag/x
    with ProxyCommand prog %h %p ran prog host --flag 22, and a host
    starting with - arrived as an option. ValueError naming the host is
    now raised where a connection or a config lookup would start, on both
    backends; building, joining and comparing such a path still work. A host is
    accepted when it is an IP address (an IPv6 one may carry a zone) or a name
    of ASCII letters, digits, ., _ and - (not first) and the letters and
    digits of other scripts (an IDN); anything else is refused.
  • The paramiko backend expands ProxyCommand tokens with the connection's
    own values.
    %p and %r came from the config or its defaults, so
    sftp://alice@example.invalid:2222/x with ProxyCommand prog %h %p %r ran
    prog example.invalid 22 and the local user name: the tunnel went to port
    22 as the local user while the client believed it was talking to port 2222
    as alice. %h, %n, %p, %r and %% now expand, in one pass, with
    the host name, the host as written, the port and the user this connection
    uses. A user name with white space, a quote, a shell metacharacter or a
    leading - is refused (ValueError) when the command holds %r.
  • An Include inside a Host or Match block stays conditional on the
    paramiko backend.
    The included file's own blocks were spliced in as
    top-level blocks, so a Host * section in a file included from
    Host internal set the user and ProxyCommand of every host. Each of the
    included file's blocks now applies only where the enclosing block does, as
    in OpenSSH. An Include outside any block is unchanged.
  • Removing the last member of a zip directory no longer removes the
    directory, and rm(recursive=True) of a zip directory finishes.
    A
    directory that exists only through its members (what ZipFile.write()
    produces) vanished with its last member: unlink() left its parent
    missing, the next write into it raised FileNotFoundError, and
    rm(recursive=True) deleted every member and then failed on the rmdir()
    of a directory that was gone (the archive root failed the same way).
    unlink(), rename(), rmdir() and rm() now write the directory's own
    entry (name/) in the rewrite that empties it, rmdir() of an emptied
    directory or of an empty archive root succeeds, and rm(recursive=True) of
    a directory is one rewrite of the archive instead of one per member. A tree
    that holds a member under a file is refused with OSError(ENOTEMPTY) and
    nothing is removed. A zip edited this way may now hold empty directory
    entries.
  • ZipUri.rename() checks where it renames to. A destination below a
    file or below a missing directory was accepted and made a member nothing
    could list, and a directory could be renamed into its own subtree. It now
    raises NotADirectoryError, FileNotFoundError and OSError(EINVAL)
    respectively, as pathlib does; create the directory first.
  • A zip member that the archive spells twice is removed and renamed as one.
    In an archive holding norm and ./norm, unlink() and rename() acted
    on the later entry only: the earlier one came back with its older content,
    and rm(recursive=True) of the enclosing directory failed with "Directory
    not empty". unlink(), rename(), rmdir() and rm() now act on every
    entry that names the member, and renaming over a name that has two
    spellings replaces them all.
  • A member below a file does not exist. An archive holding a file y
    and a member y/z listed only y, yet y/z could still be statted, read
    and removed, so copy(recursive=True) and walk() skipped data that
    read_bytes() returned. exists(), stat(), open(), unlink() and
    rename() of y/z now report it missing (FileNotFoundError; pathlib
    says NotADirectoryError there), on zip: and tar: alike, and writing or
    mkdir() below y raises NotADirectoryError. Remove or rename y to
    reach y/z.
  • The root of an archive that is missing, or is not an archive, no longer
    exists.
    exists(), is_dir() and stat() of zip:file:///missing.zip!/
    (and of tar:, archive: and an HTTP outer answering 404) said it was a
    directory while iterdir() raised. They now open the archive: exists()
    is False for a missing one and stat() raises FileNotFoundError, and a
    file that is not an archive raises zipfile.BadZipFile or
    tarfile.ReadError, as a listing and a member's exists() do. mkdir() of
    the root of a missing archive raises FileNotFoundError; an archive is
    still created by writing its first member.
  • move() of an object-store prefix no longer deletes keys it did not copy,
    and a recursive copy no longer leaves them behind.
    On s3:, gs: and
    az: a move copies by walking the prefix one level at a time and then
    removes everything under it with a flat listing. Keys the walk cannot reach
    were deleted without being copied: a key with an empty, . or .. segment
    (d//y.txt), the subtree under a key that is also an object (d/logs and
    d/logs/2026.txt), and a name/ key that holds data. copy(recursive=True)
    skipped the same keys without a word. copy(recursive=True), move() and
    rm(recursive=True) of a prefix that holds such keys now raise
    OSError(EINVAL) naming them and change nothing (a copy writes nothing, a
    move leaves the source whole). rm(recursive=True, ignore_error=...) is
    offered the error and, if it is ignored, removes only the keys a walk
    reaches. Remove the named keys first, or unlink() the object that hides a
    subtree, and repeat. A prefix of ordinary keys is unchanged, down to the one
    batched delete. iterdir() and walk() are unchanged.
  • On az: a name that is both a blob and a prefix lists as the blob. The
    SDK returns every prefix of a page before its blobs, so the prefix won, the
    blob's content was neither listed nor copied, and stat() disagreed with the
    listing. iterdir(), walk() and copy(recursive=True) now show the blob,
    as s3: and gs: do.
  • Writing or renaming a file onto an object-store prefix directory is
    refused.
    write_bytes() on d while d/x.txt existed stored an object
    d that every listing then showed instead of the directory, and
    rename() of a file onto dst did the same, leaving the keys below it
    unreachable by iterdir(), walk(), copy() and sync. Both now raise
    IsADirectoryError on s3:, gs: and az:, at the cost of one list
    request per open("w") and rename(); credentials that may write but not
    list are not stopped. Remove the directory (rm(recursive=True)) first.
  • mkdir() no longer writes a marker when its existence probe fails. The
    object stores asked exists(), which reads any error as "missing", so a 403,
    a 503 or a timeout on the probe wrote existing.txt/ beside the object
    existing.txt and returned. The probe is now stat(), only a not-found
    answer means absent, and any other error is raised with nothing written.
  • rmdir() of a gs: bucket root or an az: container root is refused.
    It returned success and removed nothing; it now raises PermissionError, as
    S3Path.rmdir() does for a bucket, and so does a non-recursive rm() of the
    root.
  • A failed ftp: listing no longer leaves the cached connection out of step.
    ftplib reads a directory listing while the listing is iterated, outside the
    guard that drops a connection on a failed transfer. A name the client could
    not decode (UnicodeDecodeError) or a stalled data connection left the
    server's final reply queued, and every later stat(), exists(),
    iterdir() and walk() on that thread failed with ftplib.error_reply
    until an unrelated read or write happened to reconnect. A listing is now
    read whole inside the guard: a failure mid-transfer drops the connection and
    the next request reconnects, a name that is not valid UTF-8 raises
    OSError(EILSEQ) instead of UnicodeDecodeError, and a 4xx reply to
    MLSD (425 Can't open data connection) raises OSError(EAGAIN) instead of
    a raw ftplib.error_temp, as it does for a read or a write. Code that
    caught UnicodeDecodeError or ftplib.error_temp around a listing must
    catch OSError.
  • ftp://host with no path lists the root. A path of "" sent MLSD,
    NLST, RETR, STOR, DELE, MKD, RMD and SITE CHMOD with an empty
    argument, which a server reads as its working directory: the login
    directory when it is not /, and whatever directory an earlier is_dir()
    had changed into (CWD) when it is. ftp://host/ always listed the root.
    An empty path now sends / in every command that takes the path, so both
    spellings name the same directory.

Full Changelog: v0.9.11...v0.9.12