Skip to content

v0.9.13

Choose a tag to compare

@github-actions github-actions released this 08 Oct 18:24

Added

  • copy_into(), move_into() and replace() on every Path. pathlib 3.14
    has them and the generic classes did not (MemPath, UriPath, DavPath,
    SftpPath, ... raised AttributeError; LocalPath had copy_into() and
    move_into() on 3.14 only). copy_into(target_dir, ...) copies under the
    same name with copy()'s keywords and defaults, move_into(target_dir, *, overwrite=False) moves with move()'s, and both return the new path.
    replace(target) is move(target, overwrite=True) and returns target; a
    directory is replaced only when empty (OSError(ENOTEMPTY) otherwise, and
    nothing is removed). A str directory or target stays on the same backend.
    info is not added.
  • Pathname.is_absolute() has a default: the path has a root. It raised
    NotImplementedError for MemPath and every Pathname subclass that did not
    override it.
  • Generic paths can be ordered. sorted(), <, <=, > and >= raised
    TypeError for MemPath, Uri, UriPath and every custom Path.
    Pathname now orders two paths of the same exact type by the key its ==
    uses (the segments; the URI text for Uri), and returns NotImplemented
    for anything else, as pathlib does across flavours. A subclass with its
    own __eq__ overrides _order_key() to match it. LocalPath keeps
    pathlib's ordering.
  • pathlib_next.__version__ and __all__ on the core modules. The root has
    __version__, the installed distribution's version ("0+unknown" for a
    source tree that is not installed). The root, path, fspath, protocols
    (with fs, io and checksum), utils, utils.stat and utils.glob
    declare __all__ with the names src/pathlib_next/AGENTS.md documents for
    them. Nothing documented moved.
  • InsecureTransportWarning. A RepoBackend (github:, gitlab:, git:)
    that is about to send an Authorization header (its token, or one passed as
    headers=) to a plain http:// API root on a host other than loopback now
    warns with this UserWarning subclass (importable from
    pathlib_next.uri.schemes._gitrepo, .github and .gitlab); the request is
    still sent. Use an https:// api_base, or filter the warning for a
    trusted network.
  • SftpAuthenticationError and SftpHostKeyError. Importable from
    pathlib_next.uri.schemes.sftp. A refused login is the first (a
    PermissionError, EACCES) and a host key that is unknown, changed or
    refused the second (a ConnectionError, ECONNABORTED), on both SFTP
    backends. Catch OSError, or these two to tell a login from a host key.
  • ArchiveUri.refresh(). A remote archive (zip:http://...!/m) is read once
    and kept while any path to it lives, so a change at its source was never seen
    by a program that held a path. path.refresh() forgets what is held of the
    archive, for every path to it, and the next use reads it again. A local
    archive is checked against its file on every use and needs none.
  • GsBackend(timeout=, retry=). Both go to every call a GsPath makes
    (timeout in seconds or a (connect, read) pair, retry a
    google.api_core.retry.Retry or None for none). Without them the SDK's
    defaults apply, and an unreachable endpoint kept every call waiting for about
    two minutes. A custom BaseGsBackend adds options through call_options().
  • schemes.az.COPY_POLL_TIMEOUT. How long AzPath.rename() waits for the
    server-side copy (300 seconds); set it before the call to change it.
  • pathlib_next.testing: five capability switches and eight tests. For a
    contract class that subclasses PathContract, the new tests are:
    a payload of about 290 KiB holding every byte value and runs of CR/LF, written
    whole and in pieces and read back whole and in parts; names holding a space,
    #, %, +, &, = and mixed case, stored, listed, renamed and removed as
    written; stat() of a longer file and of a
    directory, and a numeric st_mtime; _scandir() agreeing with stat() for every child; nothing
    existing below a file; partial read(n); and rename() onto a non-empty
    directory raising. The switches, each documented in the module and in the
    guide: supports_mkdir, supports_delete and supports_move (default
    True: a backend with no directories, delete or move, such as TFTP, sets them
    False instead of marking tests as expected failures), supports_unusual_names
    (default True), and supports_question_mark_names (default False: a
    Windows file system cannot store a name holding ?). A backend that kept
    passing the old suite and now fails a new test was altering bytes, sizes or
    names: fix it, or set the switch that names the gap. With
    supports_delete = False, copy(overwrite=True) is no longer exercised;
    enforces_directory_hierarchy = False also skips the rename-onto-a-directory
    test.

Changed

  • from pathlib_next import * publishes only the documented names. It also
    brought P, PN and the __future__ name annotations, and
    from pathlib_next.protocols import * and from pathlib_next.utils import *
    brought the modules they import. P, PN, K, V and the rest stay
    importable from the module that defines them; a script that relied on
    from pathlib_next import * for one of them imports it by name.
  • The constructors of Path, LocalPath, MemPath and FileStat pass mypy
    and pyright at their defaults.
    Both read Path("x"), LocalPath("x"),
    MemPath("/a") and FileStat(...) as constructing an abstract class (the
    protocol stubs had empty bodies, FileStat's fields were not declared, and
    Pathname's abstract members counted against the bare Path, which builds a
    LocalPath). The stubs now raise NotImplementedError through a call,
    FileStat declares its fields, Path.__new__ is annotated, and a checker
    treats Pathname's abstract members as ordinary methods. Nothing changes at
    run time: ABC enforcement still refuses a subclass that omits one. A consumer
    can drop a # type: ignore[abstract] on those four constructions.
    FileStatLike.st_mtime is annotated float, which is what every backend
    reports.
  • On Python 3.14 LocalPath.copy_into() and move_into() are this
    library's, not stdlib's.
    Stdlib's overwrote an existing file, copied
    directories without being asked and LocalPath answered differently from
    every other backend. They now behave as MemPath's do: an existing target
    raises FileExistsError unless overwrite=True, a directory needs
    recursive=True, and the new path is returned. Pass overwrite=True, recursive=True for the stdlib behaviour. LocalPath.replace() stays
    stdlib's os.replace() on every version.
  • with_stem("") raises on 3.13 and later when the name has a suffix, as
    pathlib does there (MemPath("/a/c.txt").with_stem("") was /a/.txt).
    Before 3.13 it is unchanged.
  • walk() lists a Windows junction in filenames and does not enter it
    unless follow_symlinks=True, as it already did for a symlink to a directory
    and as pathlib.Path.walk() does on 3.14. It listed the junction in
    dirnames and descended: a junction to its own parent was walked 64 levels
    deep, until the path was too long. A caller that wants the target's files
    passes follow_symlinks=True. The header and docs/divergences.md no longer
    say that LocalPath protects a following walk from a loop; neither does
    pathlib.
  • utils.as_mode() and utils.as_owner() refuse what cannot be a mode or an
    owner.
    as_mode(-1) returned -1, as_mode("07777777777777") a number
    wider than any mode, and as_owner(True, False) was (1, 0) on 3.14 and
    (True, False) on 3.9. A bool is now TypeError, a mode outside
    0..0o177777 (a st_mode with its type bits still passes, as os.chmod
    accepts it; stat.S_IMODE() drops them) and an id outside 0..2**32-1
    (-1 stays "unchanged") ValueError.
  • UriPath(url) type-checks under pyright. UriPath.__new__ annotated
    schemesmap as dict with a default of None, which pyright rejects for
    every construction (UriPath("sftp://h/x")); it is now an optional mapping of
    scheme to class.
  • ReadPathContract has a pickle test and a supports_pickle switch. A
    contract subclass checks that pickle.loads(pickle.dumps(path)) is equal to
    the path, hashes alike, and for a URI path is placed on the same filesystem.
    An implementation whose paths cannot pickle sets supports_pickle = False
    on its test class and the test skips.
  • Uri(0), Uri(5) and other falsy non-text arguments raise TypeError.
    Only None and empty text are the empty URI; Uri(0) or Uri([]) used to
    be silently the empty URI, and Uri(5) said it got NoneType. The message
    now names the type that was passed. Pass None or "" for an empty part.
  • An http: directory index larger than 8 MiB raises. iterdir(),
    walk() and glob() read the whole index and parse it at about a second per
    megabyte with no bound, so a server decided how long a listing took. A body
    over pathlib_next.uri.schemes.http.MAX_LISTING_BYTES (8 MiB after decoding)
    is now OSError (EFBIG), checked against Content-Length before the body
    is read. Assign a larger number to that module constant to list a bigger
    index.
  • Listing an http: subdirectory and stat() of a redirecting URL send fewer
    requests.
    A directory without a trailing slash is now asked for with it
    first (one GET instead of a GET and the redirect it earned, per
    directory of a walk()), falling back to the path as given on a 404, and
    stat() follows the Location of the first answer instead of repeating the
    request that was redirected.
  • The header and the schemes guide say that only Authorization is dropped
    from a redirected GET/HEAD.
    with_session(headers={"X-Api-Key": ...})
    has always been sent to the new host when requests follows a redirect to
    another one; nothing changed in the code. Keep a secret in Authorization
    or auth=, or point the path at the final URL.
  • stat() of a gitlab: file asks with HEAD. It was a GET of the files
    endpoint, whose reply carries the file base64-encoded, so every stat(),
    exists() and is_file() of a large file (and every file met by walk(),
    glob() or copy(), which get no size from the tree listing) downloaded
    it. The size now comes from the X-Gitlab-Size header of a HEAD; a reply
    without it, or a server that refuses HEAD, is followed by the GET of the
    metadata as before. github: still reads the contents object.
  • Paths derived from one another on one endpoint share the backend the first
    of them builds.
    root / name on a fresh root, or p.parent beside p,
    built a backend (an http: session, a RepoBackend with its own
    default-branch cache, an SFTP connection) per path on first use: 20 children
    of one root opened 20 connections, one TLS handshake each, where they open
    one after root has done I/O itself, and gitlab: looked the default branch
    up once per child. They now share one slot for the backend they derive for
    themselves; deriving a path still builds nothing and imports no I/O library,
    a thread race builds one, and a supplied backend (backend=,
    with_backend(), with_session()) is carried as before and never replaced.
    Paths constructed separately, on another endpoint, or with no source (the
    result of relative_to()) do not share; two UriPath(url) calls still make
    two connections, so build the root once and derive from it.
  • uripath.main() returns the exit status of a wrong invocation and of
    --help.
    It raised SystemExit(2) and SystemExit(0), although it is
    documented to return an int. The console script's exit status is the same;
    a program that called main() and caught SystemExit reads the returned
    status instead.
  • The SSH library's message for a failed SFTP handshake is logged at DEBUG.
    A handshake or session failure that is neither a refused login nor a host key
    is a ConnectionAbortedError whose text names only the library's exception
    class (SFTP connection failed (KeyExchangeFailed)), so the reason was lost.
    The library's message is now logged on the logger pathlib_next.sftp at
    DEBUG, on both backends; the exception is unchanged. The record can hold what
    the library put in its message, including a credential, so enable that logger
    for troubleshooting only.

Fixed

  • copy() makes two stat() calls per file, not three to five. The source's
    type, mode and size come from one stat() and the target's existence and
    type from another, and two stats that carry st_dev/st_ino are compared
    rather than asking samefile() for stats that a FileStat backend cannot
    answer. On a remote scheme each is a request. Everything copy() refused it
    still refuses: the same file under two spellings, an existing target without
    overwrite=True, a directory in the way. A move by copy and delete saves one
    more.
  • LRU(maxsize=0) stores nothing. It stored the new value, evicted it at
    once and handed it to on_evict (a connection cache closed the connection it
    was about to return). maxsize=None was a TypeError; it is now unbounded,
    as for functools.lru_cache.
  • FileStat(st_mode=0o644) is a file, and FileStat(st_mode=0o755, is_dir=True) a directory. is_dir was consulted only when st_mode was
    empty, so a bare permission mode was neither. The type bits are now ORed in; a
    mode that carries a type keeps it. setmode() read a block device or a
    socket as a directory.
  • full_match() on a generic path treats the root as a component of its own.
    MemPath("/a/b.txt").full_match("*/*/*.txt") was True (the first *
    matched the empty root), MemPath("/").full_match("/**") was False,
    MemPath("a/b").full_match("a/./b") was False and MemPath("").full_match("")
    was False; pathlib 3.13 answers False, True, True, True. A rooted
    path now needs a rooted pattern or a leading ** that reaches past the root,
    a lone * does not match it, and . and empty pattern components are
    ignored. Uri shares the fix. A bracket expression still never matches a
    separator (see docs/divergences.md). A path or os.PathLike is accepted as
    the pattern.
  • The 3.12 match() pass swaps newlines and separators in both directions.
    A newline inside a name was read as a separator, so on Python 3.12
    MemPath("a\nb").match("a/b") was True. Other interpreters are unchanged.
  • LocalPath.symlink_to("./t/") stores ./t/ on POSIX, as pathlib does; it
    stored t (and t//x as t/x) because the text was parsed as a path first.
    Windows still rewrites a relative target into its own separators, without
    which the link would not resolve. A generic Path builds a str target with
    with_segments(), so a subclass with per-instance state (a MemPath
    backend) no longer receives a target bound to a fresh one.
  • utils.parsedate() says what it accepts. bytes, a datetime and a
    tuple of fewer than six items raise, and "31 Feb" rolls over to March; the
    docstring and the header now say so. Nothing else changed.
  • LocalPath.copy() and move() are Path.copy() and Path.move()
    (LocalPath.copy is Path.copy): the two forwarding methods are gone, and with
    them one call frame. A subclass that overrode the private
    Path._is_junction_link() overrides is_dir_binding() instead; the alias is
    gone.
  • The header no longer says LocalPath.is_*() return False for any
    OSError on every Python version.
    exists() does; is_dir(), is_file(),
    is_fifo(), is_socket(), is_block_device() and is_char_device() are
    stdlib's and, before 3.13, raise for an error such as PermissionError. The
    behaviour is unchanged.
  • Joining a MemPath onto another keeps the left operand's tree.
    root / MemPath("f.txt"), root.joinpath(MemPath("f.txt")) and
    MemPath(root, MemPath("f.txt")) addressed the last argument's own empty
    backend, so .exists() was False for a file root held, and
    utils.glob.glob(MemPath("*.py"), root_dir=src) found nothing. The first
    MemPath argument now names the backend; an explicit backend= still wins.
    Pass backend= to place a join on a different tree.
  • MemPath applies .. to the tree. /missing/../a.txt existed,
    /a.txt/../d was a directory and /missing/../new was created as /new,
    because .. was removed from the text first. As on a POSIX filesystem, the
    directory a .. leaves must exist (FileNotFoundError) and be a directory
    (NotADirectoryError); .. above the root stays at the root. A spelling
    through a directory that exists names the same node as before.
  • Two MemPath append handles both land, and a write never shows a reader
    an empty file.
    Each handle opened with "ab" published its own snapshot
    of the whole file on close, so the second close overwrote the first's bytes
    (b"02" for appends of 1 and 2 to 0), and every publish emptied the
    file before refilling it. A handle now adds only what it wrote, in one step
    (b"012"), and a write replaces the content in one step.
  • MemPath.rmdir() of the root raises OSError(EBUSY), not
    FileNotFoundError.
    The root exists, and FileNotFoundError read as
    "already gone" to a caller that catches it; root.rm(recursive=True) emptied
    the tree and then failed with it. The tree is still emptied; catch OSError
    if you call it on a root.
  • MemPath.segments is a tuple and an unknown keyword is a TypeError.
    The list was the path's own: p.segments.append("c") changed str(p) and
    its hash. MemPath("/", bakend=b) silently built a fresh backend. Code that
    mutated segments or passed a stray keyword was already wrong; read a copy
    with list(p.segments).
  • On Python 3.9 a copied or pickled MemPath file keeps its st_mtime.
    copy, deepcopy and pickle of a MemFile (and of a MemPathBackend)
    gave every file the time of the copy on 3.9; 3.14 already kept it. A sync
    quick check that compares (size, mtime) across a copied tree now sees the
    same answer on every version.
  • A sync that changes nothing stats each root once and lists each directory
    once.
    PathSyncer stat'd every source entry (with the default
    follow_symlinks=True, although the listing already carried the stat) and
    every target entry one by one, and with remove_missing=True listed each
    target directory and stat'd its entries a second time: 205 entries in 6
    directories cost 206 source and 206 to 411 target stat() calls. The
    listing's stat now answers for every entry that is not a link; a link, an
    entry whose listing stat is None (unknown) and a target name the listing
    did not report are stat'd, one call each. On a remote scheme each of those
    calls was a request. A backend that does not override _scandir() (the
    default stats every entry itself) now stats the entries of each target
    directory it syncs into once, instead of each source entry. With
    follow_symlinks=True and remove_missing=True the target link that is
    removed is reported to hook and ignore_error by its own stat, not by the
    stat of what it points at, as it already was with follow_symlinks=False.
  • The default PathSyncer checksum asks for a native digest only when both
    sides can give one.
    For a source with a server-side digest and a target
    without (LocalPath, MemPath), every compared file computed one digest
    that was thrown away before both sides were streamed. Whether a side can
    answer is now checked first, and the second side is asked only when the first
    answered.
  • A carried pattern under an extended-length anchor is expanded, and checked
    when it is carried.
    LocalPath(r"\\?\C:\data\*.py").glob(None) selected
    nothing (a wildcard-free \\?\C:\data\a.py too): the ? of the prefix was
    read as a wildcard. The anchor is no longer searched for one. A carried
    pattern that is invalid (a** before 3.13) now raises when glob(None) is
    called, as a pattern argument does, and not when the result is first
    iterated.
  • An explicit case_sensitive= changes the case rule and nothing else. On
    Windows case_sensitive=True made .. select nothing (glob("sub/../*"),
    rglob("..")), and case_sensitive=False joined a literal without looking
    for it (glob("SUB") yielded SUB, glob("nul") and glob(" ") yielded a
    path). . and .. stay literal, and a literal is looked for in the listing
    whenever case_sensitive is passed, yielding the name as stored, as pathlib
    does.
  • glob() checks a literal component as the running pathlib does. Before
    3.13 one that is not the last must be a directory: a.txt/.., missing/..
    and **/deep/.. selected the base or every directory on 3.9. From 3.12 the
    last literal is tested without following a link, so glob("dangling")
    selects a dangling symlink, as pathlib does there; glob("dang*") already
    did. A MemPath follows the tree for .., which makes the same patterns
    select nothing there on every version.
  • rglob(".") selects every entry below. It raised ValueError; pathlib
    globs **. glob(".") is still a ValueError.
  • glob(on_error=) is told about a missing or non-directory base whatever
    the first component is.
    glob("*") reported it and glob("**") and
    glob("**/*") did not. Each failed listing is reported once: a recursive glob
    called the hook twice for one unreadable directory.
  • A recursive glob lists each directory once. glob("**/*.txt") and
    rglob("*") listed every directory twice, once to find the directories below
    it and once to match names in it; on a remote scheme each listing is a
    request. The first listing now serves both.
  • bound_loops=True keeps every directory where st_ino is 0 or None.
    A filesystem that reports a device but no file index gave every directory the
    same identity, so each looked like its own ancestor and glob("**/*.conf", bound_loops=True) returned only the top level. Such a path is walked
    unbounded, as the documentation says.
  • LocalPath.glob() and rglob() raise the audit events pathlib raises.
    pathlib.Path.glob and pathlib.Path.rglob were never raised for a
    LocalPath (os.scandir still audited each directory). An audit hook
    written against pathlib now sees the same events with the same arguments
    on the running version. glob(None) has no pathlib counterpart and raises
    none.
  • make_archive(format="tar") sizes a member from the bytes it read. The
    member size was stat().st_size, so a file whose backend does not know its
    size (an HTTP file served without Content-Length reports 0) became an empty
    member with no error, and a size that was too large failed with unexpected end of data. The content is read once into a buffer, counted, and then
    written, so the member holds the file. A tar of a large file is read and
    buffered once more than before.
  • make_archive keeps directories, times and permissions. A source with
    empty/ and sub/run.sh archived one member, so unpack_archive rebuilt no
    empty/; every directory is now a member of its own, before what is in it.
    Members were dated 1980-01-01 (zip) or the epoch (tar) whatever the source
    said; a member now carries the modification time the source reports, and a
    source that reports none keeps those two defaults. A tar member from a source
    that reports no permission bits (MemPath, an HTTP file) was read-only
    (0o444); it is 0o644 for a file and 0o755 for a directory. Code that
    unpacks these archives needs no change.
  • unpack_archive skips a tar link that names itself. A symlink a -> a,
    a -> b with b -> a, or a hard link to itself ended the extraction with
    RecursionError and no warning. Links are resolved by a loop that follows a
    chain once, so such a link is skipped with the UserWarning every other
    unresolvable link gets, the other members are extracted, and a chain of any
    length ending at a file is followed.
  • A host made only of digits is a host. UriPath("s3://20240101/key")
    had host "" and rendered s3:/key, so a bucket, container or account named
    with digits addressed nothing (gs://123456/k, az://1234567/c/k,
    http://12345/x and sftp://u@007/x likewise). The host is now 20240101
    and the URI renders as written. http://12345:80/x was already right.
  • The first unescaped : of a userinfo separates user from password.
    sftp://us%3Aer:S3CRET@h/p was read as user us with password er:S3CRET
    and rendered sftp://us:er:S3CRET@h/p, a different credential from the one
    written. The user is us:er and the URI renders us%3Aer:S3CRET.
    Source.userinfo is still the decoded user:password text (a str);
    parsed_userinfo() and the composer read the split it recorded, and two
    endpoints that differ only in that split no longer share a backend.
  • A port is 0-65535, and a : must be followed by digits. http://h:99999/
    parsed, http://h:0/x parsed and rendered without :0 (and counted as the
    same endpoint as no port), a 5000-digit port was an error on 3.14 and a
    5000-digit integer on 3.9, and http://h:abc/ or http://h:-1/ made the
    whole h:abc the host name. A port above 65535 and a : followed by
    anything but digits (after a name that is not a bracketed address) are now a
    ValueError that does not echo the input; an empty port (http://h:/) is
    still no port, and port 0 is kept. A Source built by hand with a scheme
    that is not an RFC 3986 scheme or a port that is not 0-65535 raises
    ValueError when it is rendered, where Source("http", None, "h", "80/evil")
    used to add a path to the URI.
  • An IPv6 zone follows %25. http://[fe80::1%25eth0]/x had zone
    25eth0 and an unescaped http://[fe80::1%eth0]/x was written back with a
    bare %. The zone is eth0, and the URI is written [fe80::1%25eth0]
    whichever spelling it was read from. A zone that was spelled %25 followed
    by nothing is now an error.
  • Source.as_str() and str(Source) compose like Uri.as_uri(). They went
    through a second composer: a non-ASCII host came out percent-encoded
    (http://b%C3%BCcher.example, now xn--bcher-kva), an empty host gave
    file:// and x:// (now file: and x:), and a scheme or port that was
    not valid raised from one composer and not from the other.
  • Source.from_str() does not echo its input. A path, query or fragment
    after the authority raised ValueError with the whole string, password
    included; the message now names the component (found a path). A port out of
    range does the same. repr() and str() of a Source also hide the whole
    userinfo of a github:, gitlab: or git: source, where the token is the
    user name and was printed.
  • A file name holding a lone surrogate can be printed, hashed and sorted. An
    NTFS directory may hold a\ud800b.txt; iterdir() yielded the child, but
    str(), repr(), hash(), == and sorted() of it raised
    UnicodeEncodeError. Such a name renders as its UTF-8 bytes
    (%ED%A0%80); an escaped non-UTF-8 byte renders as that byte, as before.
  • A path of a scheme works without its client library, and the error names the
    extra.
    UriPath("http://h/x"), https:, dav:, github:, gitlab: and
    git: raised ModuleNotFoundError: requests at construction, s3: did the
    same for botocore, so such a URL could not even be parsed or joined; gs:
    and az: constructed and failed on the first request with No module named 'google'. Every scheme class now imports and constructs, joins, compares and
    renders without its library, and the first operation that needs it raises
    ImportError saying so and naming the extra (pip install "pathlib-next[http]"; s3, gs and az likewise). import pathlib_next.uri without uritools raises a ModuleNotFoundError that names
    the uri extra, pathlib_next.UriPath an AttributeError that does, and
    hasattr(pathlib_next, "UriPath") stays False. A program that caught
    ModuleNotFoundError around the construction of a path to find out whether a
    client is installed now gets the ImportError at the first operation, and
    from pathlib_next.uri.schemes import HttpPath no longer fails without
    requests.
  • schemesmap= is an allow-list for every class a path chooses. It was read
    once, for the first dispatch: zip: opened its outer archive through the
    global registry (so file: was reachable although the map omitted it),
    copy("file:///x") wrote a local file, and joining a UriPath argument,
    with_source() and a str destination chose from every registered class.
    The path now keeps the map, and each of those chooses from it (a scheme the
    map omits gives a plain UriPath, whose I/O raises NotImplementedError);
    an archive path does not reuse a handle another path opened through
    file:. The map restricts dispatch only: it does not stop code that holds a
    built path or class from using it.
  • A pickled UriPath holds its URI and nothing else. The pickle of a path
    that had a backend carried it: the URI password, the session headers of
    with_session(), S3 client_kwargs and an SFTP connect_opts password were
    all in the bytes, a zip: path did not pickle at all, and an s3: or gs:
    path stopped pickling once its client existed. A path now pickles as its URI
    text (a URI with userinfo pickles with it, since that is the URI) and its
    schemesmap; the stat hint is not part of it, a backend the path built for
    itself is built again when needed, and a backend you supplied is dropped
    unless its class sets picklable = True. A program that
    sent a path with a supplied backend to another process and relied on the
    backend arriving must set picklable = True on the backend class or call
    with_backend() on the unpickled path. copy.copy() and copy.deepcopy()
    share the backend object (supplied or built by the path) instead of copying a
    connection, so a deep copy (dataclasses.asdict() makes one) no longer fails
    on a zip: path.
  • Source.is_local() answers for the host alone and forgets. It cached per
    whole Source, password included, so three paths differing only in password
    cost three lookups and the cache kept the Source objects (and passwords)
    alive for the life of the process; an answer never changed even if an
    interface came or went. The answer is now kept per host for 60 seconds, for at
    most 256 hosts.
  • A query built from a mapping or pairs escapes + and =. Query({"sig": "ab+cd=="}) was sig=ab+cd==, which a form-style reader on the server takes
    as ab cd==; a base64 signature or an ISO-8601 offset (+00:00) reached it
    altered. It is now sig=ab%2Bcd%3D%3D, and decode() returns exactly what
    the encoder was given for any text. A str is still kept byte for byte, and
    decode() still follows RFC 3986: a + in received text stays a plus
    (urllib.parse.parse_qsl reads form-encoded text as spaces). A program that
    compared the encoded text of a query holding + or = in a value must expect
    the escapes.
  • A data: payload may hold an unescaped ?. UriPath("data:,a?b") read
    a, and an SVG payload that starts <?xml read <, with the rest filed as
    the URI's query; stat().st_size was short too. RFC 2397 has no query, so
    the query is part of the payload (decoded like the path) and the full text is
    read. A # still starts the fragment.
  • with_path() and with_segments() build a URI that prints. On a URI with
    an authority with_path("y") and with_segments("y") returned a path
    without its leading /, and repr(), hash() and == of the result raised
    ValueError; the slash is now added as the constructor adds it. with_segments()
    and with_path() accept bytes, PurePath and os.PathLike too, so
    samefile(pathlib.Path(...)) no longer raises TypeError. A str given to
    samefile() is still a path, not URI text.
  • Uri.query is always a Query and fragment always a str.
    Uri("http://h/x").query was None (so .query.to_dict() raised
    AttributeError) while the same URI joined with a name had ""; both are
    now an empty Query and "". repr() of a URI whose parts were never read
    showed <pathlib_next.uri.Uri object at 0x...>; it shows the URI. An
    explicit second initialization of a URI with other parts (with_source()
    does one) left as_uri() and normalized_path of the old parts in their
    caches, and one with the same parts reset the caches another thread had
    filled; the first now clears every cache and the second none.
  • Source slices and Source.from_str("file:") equals what a parse gives.
    source[0:2] raised TypeError; it is the tuple slice. from_str("file:")
    had host None where Uri("file:").source has "", so the two compared
    unequal.
  • A str join keeps an empty segment inside its key. Uri("http://h/d") / "x//y" was http://h/d/x/y, while Uri("http://h/d") / Uri("x//y"),
    Uri("http://h/d", "x//y") and Uri("http://h/d/x//y") were all
    http://h/d/x//y, so on an object store a key holding // could not be
    addressed with / or joinpath(). The four spellings now give the same
    URI (http://h/d/x//y), and so does a str destination of rename(),
    copy() and move(). normalized_path still collapses, and a name a
    listing yields as empty is still skipped. Code that relied on p / "a//b"
    collapsing, as pathlib does, strips the empty segment itself.
  • The ancestors of a UNC FileUri stop at the share root. On Windows
    UriPath("file://server/share/a/b").parents ended file://server/share and
    file://server/, which names no file system, and parent of the share root
    climbed out of the share. The share is the anchor, as it is for
    PureWindowsPath: the ancestors are file://server/share/a and
    file://server/share/, and the share root is its own parent. Off Windows
    the root of the host still ends the chain.
  • A FileUri argument holding a drive restarts a join on Windows.
    UriPath("file:///C:/d/") / UriPath("file:///D:/f") was file:/C:/d/D:/f
    (the same join with a plain Uri, or with the str "D:/f", was already
    file:/D:/f). The argument now restarts the join whichever class carries it.
  • An error raised while a join is built is reported as that error.
    Uri("x") / other answered "unsupported operand type(s) for /" for any
    TypeError or NotImplementedError raised anywhere in the construction of the
    result, a bug in a scheme's __init__ included; UriPath already told the two
    apart. Only an argument the constructor cannot read is unsupported now, for
    both classes, and /, joinpath() and "prefix" / uri are defined once.
  • A cut-short, stalled or undecodable http: directory index raises instead
    of listing part of it.
    iterdir(), walk() and glob() read the index
    with requests' own .text: on urllib3 1.26 (what a Python 3.9 install with
    the s3 extra gets) a connection that closed early gave the entries that had
    arrived, which PathSyncer(remove_missing=True) read as the whole directory
    and deleted the rest; a stalled body raised requests' ConnectionError
    rather than TimeoutError, and a bad Content-Encoding leaked requests'
    ContentDecodingError. The index is now read the way open() reads a file:
    a short body is OSError (EIO) and a stall is TimeoutError, both naming
    the directory.
  • A DAV listing keeps only the members of the collection it asked about.
    A PROPFIND reply that held members deeper than Depth: 1, hrefs for
    another path, another host or the collection spelled differently (a proxy
    that strips a prefix) listed every one of them as a child by the last
    segment of its href, so the directory could list itself; an href of "" or
    / made the whole listing raise NotADirectoryError; and any well-formed
    XML that was not a multistatus (a login page answered with 200) listed as
    an empty directory and stat()ed as missing. A member is now listed only
    when its href, resolved against the collection, names the same host and a
    direct child; a reply that is not a multistatus, or names neither the
    collection nor any member of it, is OSError (EIO).
  • A size a server states that is not a plain number is unknown.
    Content-Length: abc made HttpPath.stat() raise ValueError, one member
    with <getcontentlength>abc</...> made DavPath.iterdir() of the whole
    collection raise it, and a negative number became a negative st_size. Each
    is now an unknown size (st_size 0, and patch-mode append still refuses a
    HEAD without a usable Content-Length).
  • A listed http:/dav: name that is not UTF-8 can be opened. caf%E9.txt
    listed with U+FFFD in place of the byte and the request for it named
    caf%EF%BF%BD.txt.
    The name keeps its bytes as Uri("http://h/caf%E9.txt") does and the request
    names the file the server listed.
  • An http: directory URL with a query or fragment is a directory.
    http://h/d/?C=M and http://h/d/#top read as files, so unlink() sent the
    DELETE it refuses for http://h/d/. The path of the final URL decides.
  • A PROPFIND behind a redirect keeps its method and body. requests
    followed a 302 or 303 as a GET (so stat(), exists() and listing of a
    dav: URL behind one failed or read as missing) and a 301 without the body
    (the server saw an allprop request). A PROPFIND is now sent again, with
    its headers and body, after a 301, 302, 303, 307 or 308 to the same scheme,
    host and port, and refused with OSError (EIO) naming the status and the
    Location for another origin, a second redirect or any other 3xx. A dav:
    URL that redirects to another origin (http: to https:, say) must be
    spelled with its final scheme and host (davs:).
  • URL passwords outside Latin-1 are sent as the octets the URL carried.
    http://user:caf%C3%A9@host/ went out as the single byte E9 (requests
    encodes a text credential as Latin-1) and %FF raised UnicodeEncodeError
    from stat() before anything was sent, so exists() said False. The
    percent-escapes are now decoded to bytes and requests sends those bytes;
    an ASCII credential is passed as text as before.
  • as_posix() of a github:, gitlab: or git: path no longer shows the
    userinfo.
    str(), repr() and as_uri(sanitize=True) dropped it but
    as_posix() rendered TOKEN@github.com:/o/r/f. It is github.com:/o/r/f
    now; as_uri() still round-trips the userinfo.
  • A github: path cannot add to its API URL. Owner and repository were put
    into the URL raw (github://github.com/o/r%3Fx=1/a.txt asked for
    /repos/o/r?x=1/contents/a.txt) and a .. segment made with with_name("..")
    or with_path() was collapsed by the client into another repository's
    address, with the token attached. Owner, repository and every path segment
    are percent-encoded now, and a . or .. segment in the path or the ref
    raises ValueError (exists() answers False); the same check covers
    gitlab:'s file path.
  • open("r+"), open("r+b") on http: and dav: raise
    NotImplementedError.
    They returned a read-only stream whose first write
    failed. Open with "rb" to read. The github: and gitlab: paths already
    refused them.
  • dav: uploads with the write_method given to with_session(). It was
    accepted and ignored (PUT always went out). DavPath.stat() also accepts
    walk_up_last_modified= like HttpPath.stat(); the PROPFIND reply already
    carries the modification time, so it changes nothing.
  • A github: or gitlab: API reply that is not the documented shape is
    OSError (EIO) naming the path.
    A 200 that was a proxy's HTML page,
    null, a number, a truncated body, an array where an object belongs or an
    entry without a name and a type raised AttributeError, TypeError,
    KeyError or requests' JSONDecodeError from stat(), iterdir() and
    read_bytes(), and exists() raised too (it only swallows OSError); a
    tree reply without a tree listed as an empty directory, and a size that
    was text broke stat(). Each reply is decoded and checked in one place per
    provider now, exists() answers False and stat() raises OSError. A
    raw file body has no shape to check and is returned as sent.
  • DavPath.rename() no longer puts the source's query in Destination. A
    source such as dav://h/d/f.txt?sig=... sent Destination: .../d/g.txt?sig=...,
    handing a signature meant for the source to the destination URL. The header
    carries the target's own query (none for a str target) and no fragment, and
    rename() returns the target path with that query. Pass a Uri target that
    carries the query a server needs on the destination.
  • A lost connection, a refused login and an untrusted host key are OSError,
    on both SFTP backends.
    asyncssh.ConnectionLost, HostKeyNotVerifiable and
    PermissionDenied, paramiko.AuthenticationException, BadHostKeyException
    and the SSHException of a rejected key crossed the API, and a translated
    server status was chained to the library's exception. The dropped connection
    is now ConnectionResetError, the login SftpAuthenticationError, the key
    SftpHostKeyError, any other failure of the handshake ConnectionAbortedError
    and a refused connection ConnectionRefusedError on both; nothing is chained
    (the library's text can carry credentials). A caller that caught
    paramiko.AuthenticationException or asyncssh.PermissionDenied catches
    SftpAuthenticationError or PermissionError; one that caught
    paramiko.SSHException for a refused key catches SftpHostKeyError. After a
    lost connection no second request is made to find out whether the entry
    exists (mkdir() of a directory that was created could answer
    FileExistsError).
  • A directory holding a name that is not UTF-8 lists on both SFTP backends.
    With paramiko one such name raised UnicodeDecodeError out of iterdir(),
    walk(), glob() and copy(); with asyncssh it raised OSError, which
    walk() and glob() swallowed into an empty result, and rm(recursive=True)
    could not remove the tree. The name is now the lone-surrogate string of its
    bytes, beside its siblings, and reads, stats, unlinks and copies by the same
    bytes.
  • paramiko's SFTP errors agree with asyncssh's. An "operation unsupported"
    status was an OSError without an errno (so copy() to a server that
    refuses SETSTAT raised after the data had been copied and a recursive copy
    stopped at the first file); it is NotImplementedError, which copy()
    tolerates. Errors now carry the remote path in filename (and filename2 for
    rename()), and the two-path error of asyncssh no longer prints
    [WinError None] on Windows.
  • chown(None, gid) and chown(uid, None) on sftp: never send 0 for the id
    they leave alone.
    An SFTP version 4 server (asyncssh asks for 4 by default)
    names the owner and the group instead of numbering them, so every file read
    as owned by 0 and chown(None, 4242) asked the server to give the file to
    root. An owner name that is a number is that id; any other name makes the
    partial chown() raise NotImplementedError naming the field, with nothing
    sent. chown(uid, gid) is unchanged, and so is a version 3 server's (OpenSSH).
  • A pickled or deep-copied sftp: backend works. The default ssh_config
    and, for paramiko, the default known_hosts were bare objects compared by
    identity, so a copy was read as "an iterable of paths" and the first request
    raised TypeError. They are the same object after a pickle or a copy. A
    path pickled with a backend= still carries only its URI and ssh_config.
  • SftpPath.with_source() carries the path's ssh_config, and a join onto
    another host does not.
    with_source() handed the default back (a path told
    to read no configuration read ~/.ssh/config on the new host), and
    SftpPath(a, "sftp://other/y") took a's ssh_config onto other. The
    configuration now follows a derivation on the same endpoint and is dropped,
    with the backend, on another host. Two paths of one endpoint whose
    ssh_config differs no longer share the backend they build for themselves.
  • checksum() on sftp: asks per algorithm. A server that refused one
    algorithm with "operation unsupported" (a FIPS build and md5), a bare failure
    for one file, or a probe by supported_checksums() (md5 only) switched native
    checksums off for the whole connection, and every later digest was streamed.
    Only the algorithm refused is remembered now, a failure for one file refuses
    nothing, and supported_checksums() is never empty for a connection that
    produced a digest. An algorithm other than md5, sha1, sha224,
    sha256, sha384, sha512 and crc32 is NotImplementedError without a
    request, and a reply is a digest only of the size its algorithm has (an empty
    reply was returned as '' for crc32).
  • File objects of the two SFTP backends agree. write_bytes() and
    open("xb").write() returned None on paramiko; they return the count, as on
    asyncssh. truncate() raised io.UnsupportedOperation on asyncssh and
    required a size on paramiko; both truncate, and truncate() without a size
    cuts at the current position. fileno() raises io.UnsupportedOperation on
    both.
  • A file left open at exit is written. path.open("w").write(...) without
    close() at module scope left an empty file on asyncssh (and, for text, on
    paramiko) with a RuntimeError about shutting down. Files opened for writing
    are flushed and closed when the interpreter exits, before its threads stop,
    waiting at most five seconds for a connection that has hung.
  • A URI user cannot add arguments to an asyncssh ProxyCommand. A user such
    as a -oProxyCommand=calc was expanded into %r and split afterwards, so
    the part after the space became an argument of the proxy program (paramiko
    already refused it). The same users are refused with ValueError before
    asyncssh is given the user, when the configuration for the host would pass it
    on; with no %r in the ProxyCommand (or no configuration) a user with a
    space still connects.
  • Testing the children of a listed sftp: directory costs no request per
    child.
    [c for c in d.iterdir() if c.is_dir()] made one listing and one
    stat per entry, because the listing's attributes were used only for
    lstat(). A listed entry that is not a link now answers stat(),
    is_dir(), is_file() and exists() from the listing (once, as before), so
    21 entries cost one request; a link is still followed by a request of its
    own. The asyncssh copy(recursive=True) and rm(recursive=True) also decide
    file or directory from the listing: removing 128 files in 8 directories
    made 301 requests and now makes 165, the number the generic rm() makes.
  • copy() and rm() on the paramiko backend do not import asyncssh. Both
    imported it before looking at the backend, so forcing
    PATHLIB_NEXT_SFTP_BACKEND=paramiko still loaded it on the first copy()
    or rm(), and without it installed the failing import was retried on every
    call. A backend now says whether it has native tree operations
    (BaseSftpBackend.supports_tree, with tree_copy() and tree_rm(); False
    unless a backend sets it); a custom backend needs no change.
  • A paramiko connection of a thread that has ended is closed. Entries were
    closed only when the 128-entry cache evicted them, so a program that used
    SFTP from many short-lived threads held a transport thread and a socket for
    each until then. The next new connection closes the ones whose thread has
    ended.
  • An ssh_config that was edited is read again by the paramiko backend. The
    parsed file was cached by its path for the life of the process, so a changed
    Host block was ignored until restart. The cache now also keys on the file's
    modification time and size (a change to an Included file alone is not
    noticed).
  • An archive: path reads nothing until it is used. UriPath("archive:http://h/noext!/m")
    sent a request while it was being built (an outer whose name does not say
    zip or tar is sniffed), a second one to read the member, and a sniff that
    failed made that path object a tar for good, so a good zip then failed with
    tarfile.ReadError. The format is now settled on first use: building,
    printing, joining, copying and pickling a path send nothing, a non-local
    outer read to decide is not fetched again, and a failure to read the outer
    propagates and decides nothing, so the next call tries again. A write
    through an archive: path whose outer is missing and has no extension is now
    FileNotFoundError (it was NotImplementedError).
  • A tar member whose name is not UTF-8 can be printed, hashed and compared.
    A tar written in a Latin-1 locale lists caf\udce9.txt, and str(),
    repr(), hash(), == and as_uri() of that path raised
    UnicodeEncodeError. The name now keeps its bytes as %XX in the URI
    (.../caf%E9.txt), as every other scheme composes it, and the URI parses
    back to the same member.
  • A damaged archive raises one exception family per format. Besides
    zipfile.BadZipFile and tarfile.ReadError, exists(), stat(),
    iterdir() and read_bytes() leaked zlib.error, EOFError,
    NotImplementedError("zip file version ...") and gzip.BadGzipFile (an
    OSError, so exists() answered False for a corrupt .tar.gz). They are
    now BadZipFile for a zip and ReadError for a tar, with the decoder's error
    as __cause__. Code that caught zlib.error or EOFError around a read of
    an archive member catches those two instead. An encrypted member is still
    RuntimeError and an unsupported compression method
    NotImplementedError.
  • exists() and is_file() of a zip member no longer raise KeyError
    while another thread rewrites the zip.
    The name was looked up in one
    snapshot and its stat read from a handle that had been reopened in between;
    both now happen under one hold of the handle's lock, and a member that is
    gone by then is FileNotFoundError.
  • New members written through zip: are deflated. A member added with
    write_bytes() or open("w") was stored uncompressed, so 100 000 bytes of
    one letter took 100 000 bytes. An overwritten member keeps the compression
    method it had (a stored mimetype stays stored).
  • A read-only zip is not replaced on POSIX. write_bytes(), unlink() and
    rename() on a zip whose mode forbids writing succeeded, because the archive
    is replaced by a new file; they raise PermissionError before anything is
    written, as on Windows. Replacing the archive still gives it a new inode:
    another hard link to it keeps the old content.
  • stat() on ftp: is one reply where the server offers MLST, and a
    connection is probed with NOOP only after it has been idle.
    A stat sent
    NOOP, TYPE, PASV and MLSD of the parent directory over a new data
    connection (the whole listing, so N stats in a directory of N entries were
    quadratic), and exists() of a missing file sent 10 commands. A server whose
    FEAT lists MLST (RFC 3659) is now asked MLST of the entry itself, a
    single reply on the control connection; exists() of a missing file is
    MLST, TYPE, SIZE, CWD. A connection that carried a command within
    schemes.ftp.IDLE_PROBE_SECONDS (1 second) is used without a NOOP; one that
    has been idle longer, or has not been used yet, is probed and replaced if the
    server dropped it, as before. A server without MLST is read as before: the
    parent's MLSD, then SIZE/CWD.
  • stat(), exists() and is_file() of an ftp: file work when its
    directory holds a name the client cannot decode.
    The parent's listing
    failed with OSError(EILSEQ) for every call and each call reconnected. With
    MLST no listing is read; on a server with MLSD and no MLST the error
    makes SIZE/CWD answer (the listing still drops the connection, so each
    such stat reconnects). iterdir() of that directory still raises
    OSError(EILSEQ).
  • A malformed size fact no longer makes a directory unlistable. size=abc
    or size=1.5 raised ValueError from iterdir(), a negative size was
    reported as is, and a size of 400 digits became an int. A size that is not
    a plain non-negative decimal below 2**63 is unknown (st_size is 0), as a
    missing one is.
  • rename() on ftp: onto an existing target is FileExistsError. A server
    that does not replace it (a Windows one answers 550 File exists) raised
    PermissionError with both files intact. The refusal is FileExistsError
    when the target is there, and PermissionError still when the reply names a
    permission problem or the target is not there. A POSIX server replaces the
    target, as before.
  • The connections of a thread that has ended are closed. Twenty short-lived
    threads that each used one ftp: path left twenty logged-in control
    connections open until 128 newer ones pushed them out, which a server's
    per-client connection limit can refuse. A thread's connections are now closed
    when the thread ends, and one that another thread's cache overflow drops while
    a command is running on it is closed when that command ends.
  • A chmod() the FTP server refuses says why. A refused SITE CHMOD
    ("550 Not enough privileges") raised NotImplementedError("SITE CHMOD not supported by this server") without the reply. It is still
    NotImplementedError (so copy() skips it), now with the server's reply in
    the message and as __cause__.
  • Walking, listing and stat() of an archive no longer scan every name for
    each directory.
    iterdir() of a directory compared every member name of the
    archive against the directory's prefix and then stat()-ed each child with a
    second validation of the file, so a walk() grew with directories times
    members. The members, their directories (those that exist only through their members
    too) and each entry's stat data are derived once per open handle; a listing
    costs one validation of the file and work in the size of the directory, and
    the children iterdir() returns answer their first stat() from it.
  • Reading a member of an archive holds a bounded amount of memory. A local
    tar: was read whole into memory and kept for the life of the handle, and a
    member read built the whole member in memory at about twice its size (a zip
    member that declares 512 MiB peaked at over 1 GiB). A local tar is now read
    from its file as members are reached, and a member larger than
    schemes.archive._base.MEMBER_SPOOL_BYTES (16 MiB) is copied to an unnamed
    temporary file instead of memory, so reading it costs disk in the size the
    archive declares. A non-local archive is still read whole into memory. Code
    that read a member through open("rb") is unchanged: the stream is still
    seekable and independent of the archive.
  • A zip change copies the entries it does not change as they lie in the
    archive.
    unlink(), rmdir(), rename(), rm(recursive=True) and an
    overwrite decompressed and compressed every member of the archive again, so
    their cost was the archive's uncompressed size, and one encrypted member (or
    one stored with a compression method zipfile lacks) made every such change
    to the archive fail with RuntimeError or NotImplementedError. Untouched
    entries are copied without being decompressed, a renamed entry gets a new
    local header and its data is copied, and a member with a non-ASCII name that
    the archive stores without the UTF-8 flag gets the flag, as before.
  • stat() of a tar hard link reports the size of its target however the
    target is spelled.
    A link to d//real.txt or d/./real.txt reported
    size 0 while reading it returned the data; tar and zip now use the one name
    rule.
  • archive+zip: and archive+tar: are second names of zip: and tar:.
    Both schemes, both class names (ArchiveZipUri, ArchiveTarUri, now aliases
    of ZipUri and TarUri) and the printed and pickled form of a path (it keeps
    the scheme it was written with) are unchanged. type(path) of an
    archive+zip: path is ZipUri, so repr() shows that name, and
    isinstance(path, ArchiveZipUri) is also true for a zip: path.
  • A failed request on s3:, gs: and az: is an OSError of the type the
    header promises, and the SDK's exception is not chained.
    A timeout was
    OSError(EIO) on s3:; an unreachable endpoint reached exists() and
    walk(on_error=) as the SDK's own exception on gs: and az: (after up to
    two minutes with the SDKs' default retries), so neither saw it. A request that
    runs out of time is now TimeoutError, an endpoint that cannot be reached
    ConnectionError, a connection that breaks while an answer or a body arrives
    ConnectionResetError, each with the path as filename; the store's answers
    stay FileNotFoundError, PermissionError and OSError(EIO). The message
    names the SDK exception's type and not its text, which holds the request URL
    and with it any query-string credential, and __cause__ is None. Catch
    OSError or these subclasses; code that read the SDK exception from
    __cause__ must catch it from the SDK call itself.
  • An s3: read cut short or stalled in the body is an OSError. Only the
    call that opens the object was translated: a server that closed the
    connection mid-body raised botocore's ResponseStreamingError from
    read_bytes() and copy(), and a stalled body botocore's
    ReadTimeoutError without the path. They are ConnectionResetError and
    TimeoutError naming the path.
  • Reading or writing a bucket or container root, and a path with no bucket or
    account.
    read_bytes(), write_bytes() or open("r+") of s3://bucket/
    raised an SDK error (botocore's ParamValidationError, a ValueError or an
    HTTP 400 reply), and S3Path("s3:x").exists() raised the first too. A root is a directory
    (IsADirectoryError, nothing is sent), and a path with no bucket, or no
    account and no backend=, does not exist (FileNotFoundError, exists() is
    False).
  • az://account can be listed. is_dir() answered True for it while
    iterdir() and walk() raised ValueError; its children are the containers.
  • rm(recursive=True, missing_ok=True) under a missing bucket or container
    returns.
    It raised FileNotFoundError (a missing key in an existing
    bucket was already fine). rename() of a missing path onto its own name returned the path; it
    is FileNotFoundError, and renaming an existing key or prefix onto itself
    still changes nothing.
  • A key a batch delete refuses is offered to ignore_error with its own
    path.
    On s3: the refused keys of a 1000-key batch reached ignore_error
    as one OSError without an errno, with the directory being removed as the
    path; each is now a PermissionError (AccessDenied) or OSError(EIO) for
    its key, offered once with that key's path, and without a handler the first is
    raised. gs: and az: offered a failed delete with the directory's path too.
  • An S3Backend, GsBackend or AzBackend builds one client however many
    threads ask first, and can be pickled or copied after it built one.
    Threads
    racing for the first request each built a client (and, on Azure, a
    credential). A backend that had made its client could not be pickled or
    deep-copied (PicklingError for boto3's and Google's clients, a
    RecursionError in copy.deepcopy). A pickled or copied backend keeps its
    options and builds its own client on first use. A pickled S3Path, GsPath
    or AzPath still holds only its URI: the receiving process uses the default
    backend unless the program supplies one, and no client_kwargs, credential or
    client is in the bytes.
  • Existence probes on az: ask the service for one item. stat() of a
    prefix, exists() of a missing path, mkdir(), open("x"), writes and
    rmdir() listed with the service's default page (up to 5000 entries) to learn
    whether one blob exists. rename() of a prefix directory on all three stores
    asked for the key a second time before listing the prefix.
  • AzPath.rename() no longer waits for ever on a pending copy. It waits at
    most COPY_POLL_TIMEOUT, aborts the copy and raises TimeoutError, the source
    untouched.
  • uripath exits 141 when the reader of stdout went away, on Windows too.
    Windows reports a closed pipe as OSError(EINVAL), so read, write and
    sync printed uripath: OSError: [Errno 22] Invalid argument and exited 1 or
    120 unless the reader left in the middle of a large write. They now exit 141
    without a message, as the documentation says.
  • Without the uri extra, uripath no longer echoes a URI argument. The
    error line quoted the whole argument, password included. It now reads the 'http' scheme needs the 'uri' extra: pip install 'pathlib-next[uri]'.
  • uripath reports a wrong invocation with exit status 2 and the usage.
    cp -r with - and an unknown --encoding exited 1 as if the operation had
    failed. Every flag and positional now has a help line.
  • uripath.main(stderr=) accepts a binary stream. The error line was written
    as text, so a BytesIO raised TypeError instead of returning 1.

Full Changelog: v0.9.12...v0.9.13