Skip to content

v0.9.4

Choose a tag to compare

@github-actions github-actions released this 16 Sep 03:06

Fixed

  • Path.copy() destroyed or created the target when the source could not
    be read.
    It unlinked an existing target (with overwrite=True) and
    opened the target for writing before opening the source, so a missing
    file, a directory without recursive=True, or a source HTTP 404 left the
    target empty, or left a new 0-byte file that made a retry fail with
    FileExistsError. The source is now opened first, and a copy that fails
    mid-stream removes its partial target.

  • Copying or moving a file onto itself deleted it. f.copy(f, overwrite=True) (or onto a case-insensitive alias such as F.TXT on
    Windows/macOS) emptied the file; a case-only rename with
    move(overwrite=True) deleted it. copy() now raises
    OSError(EINVAL, "Source and target are the same file"); move() renames
    in place.

  • move(overwrite=True) removed the target before checking the source.
    A missing source, or a file moved onto a directory, deleted the target
    (including a whole tree) and only then raised. It now raises
    FileNotFoundError / IsADirectoryError first and leaves the target
    alone. A local file target is replaced atomically with os.replace(), so a
    locked source on Windows no longer costs the target.

  • rm(recursive=True) deleted files outside the tree through Windows
    junctions and file: directory symlinks.
    A junction reads as a
    directory to a non-following stat, and UriPath's default _scandir()
    used a following stat, so both were descended into and their targets'
    contents deleted. Both are now removed as links. FileUri listings also
    reuse LocalPath's scandir metadata (one call per directory).

  • PathSyncer.sync() could delete or write outside its target.

    • A root source that does not exist now raises FileNotFoundError.
      Before, with remove_missing=True it deleted the entire target (a typo,
      an unmounted share, a 404); without it, it reported success. Callers
      that relied on syncing an absent source as a no-op must now catch the
      error or pass ignore_error.
    • Overlapping source and target (one inside the other, same
      implementation and backend) now raise ValueError. Before, the source
      could be deleted, or copies nested until RecursionError.
    • A child name that would leave the target (.., a name the parent-name
      fallback turned into .., or \/: on a Windows target) now raises
      ValueError through ignore_error. Before, such entries from an S3,
      SFTP or archive listing were written, or removed, outside the target.
    • Entries listed with an unknown stat (GitLab blobs, FTP without MLSD) are
      now re-stat'd. Before, with follow_symlinks=False nothing was copied
      and remove_missing=True deleted the existing mirror.
    • A symlink inside the target is replaced by the real file or directory.
      Before, sync listed, wrote and deleted through it, into whatever it
      pointed at.
  • http:/dav: listings yielded . and .. as children. wsgidav's
    parent row (<a href="..">) became a file named .., and unlinking it
    deleted the parent collection; a ./ entry made walk() loop forever; a
    PROPFIND href %2E%2E/ let a recursive copy write outside its destination.
    Such names are no longer listed.

  • DavPath.unlink() and HttpPath.unlink() deleted whole collections.
    They sent a bare DELETE, which WebDAV applies recursively; unlink() on
    a directory, and symlink_to(force=True) over one, removed the tree. Both
    now raise IsADirectoryError for a directory (HttpPath relies on its
    HEAD-based directory check). rm(recursive=True) still deletes trees.

  • DavPath read an HTTP error page as file content. open("rb") /
    read_bytes() / copy() on a missing or forbidden file returned the
    server's 404/401/500 body. They now raise FileNotFoundError /
    PermissionError / OSError.

  • Reading a local zip: archive opened it for writing. A read-only zip
    was unreadable (exists() returned False), exists() on a missing
    archive created it, and probing a file that is not a zip appended 22 bytes
    to it. Reads now open the archive read-only; the first write into a
    missing archive creates it. Probing a non-zip file now raises
    zipfile.BadZipFile.

  • Zip unlink()/rename()/overwrite reset every other member. The
    rewrite gave all members the current time, DEFLATE compression and mode
    0600, and dropped the archive comment and any leading bytes (a zipapp
    shebang); it also replaced a symlinked archive with a regular file. Member
    metadata, the comment, the prefix bytes and the archive's file mode are
    now kept, and a symlinked archive stays a symlink.

  • Zip rename() onto an existing member created a duplicate name, and a
    later rewrite kept the old content. It now replaces the target (POSIX
    semantics; see docs/divergences.md).

  • Archive listings exposed traversal member names. Members named with
    .., an absolute path, \-separated traversal or a drive prefix are no
    longer listed, so iterdir()/walk()/copy(recursive=True) cannot write
    outside a destination through them.

  • utils.unpack_archive() let crafted members escape dest on Windows
    (D:evil.txt, and the same-drive C:../C:../x). Such members, and any
    member with a .. part (previously extracted with the .. dropped), are
    now skipped.

  • rename()/move() renamed onto the wrong host, bucket or archive.
    Every scheme renamed through its own connection or bucket with only the
    target's path: SftpPath/FtpPath moves to another server renamed on the
    source server, S3Path/GsPath moves to another bucket landed in the
    source bucket (overwriting an existing object of that name there), a zip
    member moved to a local path was renamed inside the archive, and
    LocalPath.move() onto a remote path renamed a local file. rename() now
    raises NotImplementedError for a target on another endpoint, archive or
    Azure container, and move() copies and deletes instead. move() also
    falls back to copy + delete on a cross-device rename (EXDEV), and
    SftpPath.copy(recursive=True) uses its concurrent fan-out only for a
    target on the same host.

  • Session credentials and tokens followed a join to another host.
    base / "http://other/x", UriPath(base, url) and base.with_source(...)
    reused base's backend, so an HttpPath.with_session(auth=...) session or
    a github://TOKEN@... token was sent to the other host. A backend is now
    reused only for the same scheme, userinfo, host and port.

  • AzPath.rename() with a str target always raised TypeError, and a
    pending copy crashed with KeyError after starting it. GsPath/AzPath/
    S3Path.rename() onto the same key deleted the object.
    Both fixed.

  • FileUri.rename("b.txt") resolved against the process cwd and returned
    a LocalPath. It now renames within the same directory and returns a
    FileUri.

  • MemPath.copy("/b.txt")/move("/c.txt") wrote into a new, empty
    in-memory filesystem
    , and move() then deleted the source. A str
    destination now stays on the source's backend.

  • SftpPath.copy() raised ModuleNotFoundError without asyncssh
    (paramiko-only sftp extra), including every single-file download and
    PathSyncer with an SFTP source.

  • SFTP connections leaked or went stale. A first-call asyncssh
    rm(recursive=True) hung for 60 s and deleted nothing; dropped paramiko
    connections and closed asyncssh SFTP channels were never replaced, so every
    later call failed and exists() returned False; evicted connections,
    failed logins and failed SFTP starts leaked sockets and threads; concurrent
    first calls opened duplicate connections; a recursive copy held two remote
    handles open per file in the tree; SftpPath(url, ssh_config=...) ignored
    ssh_config; the paramiko backend ignored ssh_config Include.

  • UriPath("ftps://...") returned a stub UriPath in a fresh process
    instead of FtpPath.

  • URL credentials leaked into HTTP errors and redirects. They are now sent
    as Basic auth= instead of inside the request URL, WebDAV MOVE
    Destination no longer carries them, and translated errors no longer chain
    the requests exception (__cause__ is None; the message carries the
    HTTP status and reason). URL credentials now take priority over a matching
    ~/.netrc entry.

  • github:/gitlab: tokens leaked through str()/repr()/errors, and
    user:TOKEN@host authenticated with the username. The token is now read
    from the password slot when present, and these schemes redact the whole
    userinfo.

  • glob()/rglob() crashed, hung or returned wrong results.
    glob("**") and glob("dir/**") raised NotADirectoryError on any tree
    containing a file; ** followed directory symlinks, so a symlink loop
    produced duplicates effectively forever; globbing under a missing directory
    or a file raised instead of yielding nothing; repeated ** returned
    duplicates; a trailing / matched files on MemPath and URI paths; ?
    never matched on a UriPath (read as a query); glob.full_match() slowed
    down exponentially with repeated **. A trailing ** now follows the
    running Python (files too on 3.13+).

  • match() on MemPath, Uri and every UriPath did not follow
    pathlib.
    It anchored at the start and let * cross /, and a URI's
    host: prefix defeated absolute patterns. It is now pathlib's
    right-anchored per-segment match; an empty pattern raises ValueError.
    LocalPath accepts match(case_sensitive=) on 3.9-3.11, and its
    full_match() handles rooted, drive and backslash patterns before 3.13.

  • parents/parent of an absolute MemPath or Uri lost the root.
    MemPath('/a/b').parents is now ['/a', '/']; Uri('http://h/a').parent is
    http://h/; a top-level FileUri's parent is / (or the drive root
    C:/ on Windows) instead of resolving to the current directory.
    relative_to()/is_relative_to() treat s3://bucket/http://h as the
    root, so relative_to(p.parent) and walk_up=True work.

  • with_name()/with_stem()/with_suffix() accepted '', . and
    separators
    on MemPath and Uri, splicing x/y or ../../etc into a
    path. They now raise ValueError like pathlib.

  • MemPath did not normalize like PurePosixPath. MemPath('/') / 'a'
    was //a and unequal to MemPath('/a'); a trailing / changed equality;
    an absolute join did not reset. All now match PurePosixPath.

  • Path.touch() made new files world-writable and could truncate existing
    ones.
    It chmod'ed every new file to 0o666 ignoring the umask (file:,
    SFTP, FTP) and treated any stat() error as "missing". mode now defaults
    to None (chmod only when passed), an existing file is never truncated, and
    FileUri.touch() uses pathlib's touch().

  • copy() made local copies read-only. preserve_metadata=True applied
    the placeholder 0o444/0o555 mode that MemPath, HTTP, WebDAV, object stores
    and archives report, so a copied file could not be overwritten or re-synced.
    Placeholder modes are no longer applied (FileStat.mode_known).

  • utils.parsedate() read GMT dates as local time, so every HTTP/WebDAV
    st_mtime was off by the host's UTC offset, and it raised OverflowError
    on Windows east of UTC. It now returns UTC epoch seconds and passes numbers
    through.

  • s3://b/dir/ (trailing slash) was treated as the marker object: not a
    directory, and rm(recursive=True) removed only the marker. S3, GCS and
    Azure keys now drop one trailing /. Azure keys keep interior empty
    segments (a//b) as written.

  • GsBackend rewrote the process-wide STORAGE_EMULATOR_HOST and dropped
    other client_options. Keyword arguments now go to storage.Client
    unchanged; for an emulator also pass use_auth_w_custom_endpoint=False.

  • URI queries were corrupted on the wire. They were percent-decoded at
    parse time and re-encoded with &, = and + treated as safe, so a signed
    URL's sig=ab%2Bcd%3D%3D reached the server as ab+cd== and an escaped &
    split a value; with_query(dict) double-encoded. Uri.query is now kept as
    received and sent unchanged (see Changed).

  • A remote path joined with a relative pathlib.Path became a local
    file.
    UriPath("sftp://h/srv/") / pathlib.Path("etc/x") produced
    file:/srv/etc/x, so reads and writes hit the local disk. A relative path
    now joins like a PurePath and stays on the remote; only an absolute local
    path becomes file:. UriPath.joinpath() picks the class from the scheme.

  • URIs with non-UTF-8 percent-escapes (caf%E9.html) raised
    UnicodeDecodeError; they now construct and round-trip. data: payloads
    are no longer dot-normalized or decoded twice, and binary payloads work.

  • Windows file: URIs: file://localhost/C:/... could not be printed,
    hashed or compared; a file://<host>/share whose host is this machine
    mapped to the current drive instead of a UNC path.

  • Scheme registry: a UriPath subclass defined after the first dispatch
    was never found, and every unknown scheme rescanned entry points.

  • open() modes: rt/wt failed on every non-local backend and invalid
    modes raised NotImplementedError; modes are now validated like the
    built-in open() (ValueError). open("r+") on buffered backends (FTP,
    S3, GCS, Azure, local zip members) returned a writable buffer whose writes
    were discarded; writes are now uploaded on close (or NotImplementedError
    where impossible).

  • copy(): copy(recursive=True) into its own subtree recursed without
    limit; copy(follow_symlinks=False) copied the link target's content with
    the link's 0o777 mode (it now recreates the link, as pathlib 3.14 does);
    overwrite=False was decided by exists(), which reads a transient 503 as
    "missing". Downstream classes mixing a concrete stdlib path with Path now
    also get pathlib_next's stat/chmod/glob/walk/_scandir.

  • MemPath: iterdir() on a missing path raised NotADirectoryError;
    files always reported st_mtime=0, so PathSyncer skipped same-size edits.

  • Archives (zip:/tar:/archive:): children of the archive root were
    named /name and matched no member, so iterdir/glob/recursive copy
    from the root failed; tarballs with ./ members (tar -C dir .,
    shutil.make_archive) were unreadable; adding a zip member rewrote the
    central directory in place (a crash corrupted the archive); a cached handle
    ignored changes by other writers and kept the file locked on Windows;
    archive URIs did not round-trip names with #, ? or %; concurrent tar
    reads returned wrong bytes; stored member modes were ignored; iterdir() on
    a file returned []. utils.make_archive() now accepts any Path source,
    writes the target only when complete and supports zip64;
    utils.unpack_archive() accepts non-seekable streams and extracts tar links.

  • HTTP/WebDAV: gzip-encoded responses were returned compressed (and
    rewrite-mode append re-uploaded them); a redirect made stat() report a
    directory; listing hints fabricated sizes used as the patch-append offset;
    mid-body read failures raised urllib3 exceptions; DavPath listed a
    directory with a space in its name as its own child, treated a 207
    Multi-Status with failed members as success, re-sent a failed PUT at garbage
    collection and raised raw requests.HTTPErrors; with_session(headers=...)
    was replaced by internal headers.

  • GitHub/GitLab: GitLab listings stopped after 100 entries (and stat()
    called later subdirectories missing); GitHub listings stopped at 1,000;
    self-hosted API roots dropped the port; GitLab root stat() answered from
    the URI shape without asking the server.

  • uripath CLI: sync compared sizes only, so same-size edits were never
    copied; --dry-run printed nothing; read/cp - buffered whole objects.

  • FTP: every separately built path opened its own connection; an error
    mid-transfer desynchronized the cached connection for good; a write whose
    connection timed out while idle lost its data; without MLSD, directories
    stat'ed as missing; MLSD mtimes were read as local time; permission errors
    surfaced as FileNotFoundError and raw ftplib errors escaped.

  • S3/GCS/Azure: botocore ClientError escaped exists()/walk() and a
    403 read as "missing"; GCS/Azure turned every exception (including a missing
    SDK) into "does not exist", so copy(overwrite=False) could overwrite;
    iterdir() on a missing path returned [] and rmdir()/unlink() accepted
    wrong-type targets; a failed upload was retried at garbage collection over
    newer data; prefix-directory move() failed; S3 objects above 5 GiB could
    not be written or renamed; open("x") was a check-then-put race;
    AzPath without backend= ignored the URI's account; Azure recursive rm()
    stopped at the first failing blob in a batch.

  • PathSyncer: an interrupted copy lost the previous version (it now
    writes a temporary sibling and renames); preserve mode deleted the target
    before discovering symlinks were unsupported; dry runs crashed on new
    subdirectories; RemovedMissing events carried the parent directory;
    ignore_error was called once per ancestor with the wrong paths; two
    unknown (0) mtimes counted as "in sync"; FIFOs and devices replaced the
    target with an empty directory.

  • SFTP: rename() onto an existing file failed with a bare
    OSError("Failure"); unlink(missing_ok=True) skipped dangling symlinks;
    a relative readlink() result could not be printed; asyncssh file handles
    made one round trip per byte in readline() and an unclosed handle hung
    interpreter exit for 60 s; the asyncssh recursive copy called a bool
    ignore_error and ignored the own-subtree and symlink rules; paramiko's
    native checksum probed an extension OpenSSH does not implement, paying extra
    round trips per file.

  • rename() returned None on dav:, s3:, gs:, az:, ftp: and
    sftp:; it now returns the new path, as pathlib does.

  • Wrong exception types on sftp:, dav:, gitlab: and MemPath.
    Listing or rmdir() of a file raises NotADirectoryError, rmdir() of a
    non-empty directory OSError(ENOTEMPTY) (MemPath raised
    FileExistsError), opening or unlinking a directory IsADirectoryError;
    dav: reading a collection raised nothing and returned its HTML index, and
    rmdir() of a missing path raised NotADirectoryError. paramiko
    open("x") returned a file that could not be written.

  • Path.rm(recursive=True, ignore_error=callable) offered a declined
    error to the callable again from every enclosing directory.

  • uripath crashed at import without the uri extra, even for local
    files. Local paths and - now work; a URI argument reports the extra to
    install. Without the extra it also read every colon name as a URI
    (uripath read notes:draft asked for pathlib-next[uri] instead of
    reading the file); the schemes this package registers are now read from
    its entry points, so a colon name behaves the same in either install.

  • match() disagreed with pathlib on Python 3.12 at the root. 3.12
    matches the whole path as one string with its separators swapped for
    newlines, so "/" is a single newline that "**" matches from either
    side ("/".match("**"), match("/**") and match("**/**") are all
    True there) while "*" never does, and a bracket expression such as
    "[!a]" consumes the separator itself. MemPath/Uri answered False
    throughout; 3.12 now runs a port of that algorithm instead of the
    part-by-part comparison every other version uses.

  • import pathlib_next imported netimps (a host-name query at import,
    slow on Windows), and the first file:/data: path imported requests
    and botocore; both now load on first use. GsPath/AzPath are exported
    from pathlib_next.uri.schemes.

  • *.local.* files shipped in the sdist and wheel.

  • The SFTP/FTP/WebDAV examples built URIs from unencoded credentials (a
    password containing /, #, ? or @ changed the host) and printed the
    password.

  • benchmarks/bench.py crashed at import on Python 3.9.

  • Docs: the CI benchmark table had its Ubuntu, Windows and macOS columns
    rotated; the paramiko single-file write/copy slowdown was on Ubuntu.

  • Core, MemPath and URI edge cases. open() leaked the backend handle
    when the text wrapper failed; synthesized errors from rm()/touch() and
    MemPath lacked errno/filename; copy(progress=) never reported a
    zero-byte file; samefile(str) lost the backend or host; MemPath handles
    appended at the seek position, hid unflushed writes and accepted writes on
    read handles, and exclusive create/mkdir could both succeed under
    concurrency; FileStat.from_stat() kept None fields; checksums failed on
    FIPS hosts (usedforsecurity=False); is_dir()/is_file() rejected
    follow_symlinks=; "prefix" / path was unsupported; suffix/stem ignored
    3.14's rules; lazy URI parsing could expose unset components to another
    thread; Uri.__eq__ raised against a relative local path; a // path with
    no authority could not be rendered; non-ASCII hosts were sent
    percent-encoded; Uri("/a").is_absolute() was False; data: accepted
    r+ and discarded writes.

  • Scheme and CLI edge cases. FTP listed MLSD cdir/pdir entries named
    like children; archives nested in archives could not be addressed;
    GitLab iterdir() on a file yielded nothing; GitHub/GitLab 429 and
    secondary rate limits were not recognised; git://<ip> raised
    AttributeError; a custom BaseRepoBackend without a cache crashed on
    GitLab; git-hosting open("r+") returned a writable buffer; uripath cp -
    overwrote an existing target without --overwrite, a local name with a
    colon was treated as a URI, and a closed pipe or Ctrl-C printed errors;
    HttpPath.iterdir() on a file downloaded it; HTTP 409 on PUT and 410 were
    mis-mapped; WebDAV read only the first <propstat>; HTTP listings behind a
    prefix were scoped by the page title; S3/GCS listings disagreed with
    stat() when a key was both an object and a prefix; GCS/Azure roots always
    reported existing; asyncssh SFTP errors had no errno/filename.

  • PathSyncer edge cases. Tolerated errors left no trace; a directory
    that became a file or symlink in the source deleted the target directory
    even with remove_missing=False; SyncStart passed raw paths to the hook
    and dry runs reported dry_run=False for traversal events; preserved
    directory symlinks were created as file links on Windows; an identical
    symlink was recreated on every run; PathAndStat(path) described a symlink
    itself instead of following it.

Changed

  • PathSyncer keeps non-empty target directories on a type change unless
    remove_missing=True
    (IsADirectoryError through ignore_error,
    SyncEvent.TypeMismatch). Every tolerated error is now logged at WARNING
    on pathlib_next.sync and reported to the hook as SyncEvent.Error.
    PathAndStat follows symlinks by default. PathSyncer.hook() gains a
    keyword-only always_run.
  • URI comparisons and rendering: Uri == <non-URI Pathname> (e.g. a
    LocalPath) is now False (a Uri still equals another Uri or a URI
    string); non-ASCII hosts are rendered in IDNA form; an explicit
    schemesmap= is authoritative (an unknown scheme gives a plain UriPath);
    with_source() with a scheme-less source returns a plain UriPath; / no
    longer hides a TypeError raised inside a scheme class.
  • data: URIs reject r+, decode base64 only with ;base64, and imply
    text/plain for a parameters-only header.
  • uripath exits 141 on a closed stdout and 130 on Ctrl-C.
  • Package metadata uses PEP 639 (License-Expression: MIT) instead of
    the License :: classifier, and adds Typing :: Typed,
    Development Status :: 4 - Beta and Python 3.9-3.14 classifiers. Building
    from source needs hatchling>=1.27.
  • The az extra installs azure-identity, which an AzPath without
    backend= needs for its default credential.
  • pathlib_next.testing contracts are stricter: 48 PathContract tests
    (was 20) covering pathlib error types, glob, walk, rename, open modes and
    recursive copy. A backend that cannot meet a rule sets a capability
    attribute to False (supports_listing, supports_empty_directories,
    distinguishes_file_types, supports_rename, supports_append,
    supports_exclusive_create, enforces_directory_hierarchy). The contract
    root must be a fresh, function-scoped directory populated with
    populate_fixture_tree(). test_iterdir_lists_children no longer passes
    when iterdir() is unimplemented.
  • Uri.query is the percent-encoded query as received and is sent
    unchanged; Query(...).decode()/to_dict() decode each name and value
    once. A str passed to with_query() is taken as already encoded; a
    mapping's keys now escape =. Code that read .query expecting decoded
    text must decode it.
  • Archive paths raise pathlib's POSIX exception types (iterdir() on a
    file, reading or unlink()ing a directory, rmdir() on a file), and
    mkdir()/new zip members need an existing parent. Zip rename() returns the
    new path. Archive as_uri() percent-encodes the member path.
  • uripath sync compares file content by default; --size-only restores
    the old comparison. --dry-run prints planned changes and -v/--verbose
    prints changes made.
  • gitlab: reads a - segment at position 3 or later as GitLab's /-/
    separator
    (gitlab://host/group/sub/project/-/path).
  • SftpPath.rename() replaces an existing target where the server
    supports posix-rename@openssh.com, else raises FileExistsError.
  • DavPath maps request errors to pathlib exceptions (PUT/MOVE into a
    missing parent: FileNotFoundError; 423: PermissionError).
  • Path.glob()/rglob()/LocalPath.glob() include hidden files and
    directories by default
    , as pathlib does. Pass include_hidden=False for
    the old results. glob("") now raises ValueError and an absolute pattern
    raises glob.NonRelativePatternError; before, "" yielded the base and an
    absolute pattern listed outside it.
  • SFTP host keys are verified by default on both backends. Before, any
    server key was accepted (asyncssh even overrode ssh_config pinning), so a
    man-in-the-middle received the URI password. Now an unknown or changed key
    fails before credentials are sent. paramiko uses ~/.ssh/known_hosts,
    ssh_config UserKnownHostsFile and RejectPolicy. To keep the old
    behaviour
    add the host to known_hosts, or opt out explicitly:
    SftpBackend(connect_opts, paramiko.AutoAddPolicy(), known_hosts=None) /
    AsyncsshSftpBackend(connect_opts={"known_hosts": None}).
  • ftps: verifies the server certificate and host name by default.
    Before, any certificate was accepted and the password sent to it. For a
    private CA pass FtpBackend(ssl_context=ssl.create_default_context(cafile=...));
    FtpBackend(verify=False) disables verification. Data connections now reuse
    the TLS session (vsftpd, FileZilla Server).
  • Network operations have default timeouts. HTTP/WebDAV/github/gitlab:
    (10, 60) s connect/read (with_session(..., timeout=...),
    RepoBackend(timeout=...)); FTP: 30 s (FtpBackend(timeout=...)); paramiko
    connect/banner/auth/channel-open: 30 s (SftpBackend(..., timeout=...)).
    timeout=None restores the unbounded wait. Before, a stalled server hung
    the caller forever.
  • asyncssh timeouts apply to single requests only
    (AsyncsshSftpBackend(timeout=...), default 60 s). Recursive
    copy()/rm() and read_bytes()/write_bytes() no longer raise after
    60 s while the work continued in the background. A timed-out request is
    cancelled and raises the builtin TimeoutError on every Python version (on
    3.9/3.10 it was concurrent.futures.TimeoutError).
  • paramiko backend: ssh_config ProxyJump raises NotImplementedError.
    Before, it was ignored and the connection went direct. Use asyncssh, a
    ProxyCommand, or connect_opts["sock"].
  • asyncssh rm()/copy() error callbacks run in a worker thread, so they
    may call ordinary path methods; a sync SFTP call made on the bridge-loop
    thread raises RuntimeError instead of hanging.

Added

  • SyncEvent.Error and SyncEvent.TypeMismatch reporting; str / path
    (__rtruediv__) on Pathname and Uri.
  • pathlib_next.testing.populate_fixture_tree(root) and FIXTURE_TREE.
  • benchmarks/bench.py --save (min/median/max ms per call as JSON under
    benchmarks/results/) and --samples.
  • SyncEvent.Compare and SyncEvent.Skipped; uripath sync --size-only and
    -v/--verbose.
  • glob.parse_pattern(), glob.select(), glob.NonRelativePatternError;
    recurse_symlinks=False on glob()/rglob(); FileStat.mode_known.
  • close() on SftpBackend/AsyncsshSftpBackend; FtpBackend(timeout=, ssl_context=, verify=); RepoBackend(timeout=); utils.LRU(on_evict=...)
    and LRU.discard().
  • utils.is_safe_child_name(name, *, windows=False) and
    utils.is_windows_flavoured(path): check that an untrusted name stays a
    single component inside its parent before joining it onto a destination.

Full Changelog: v0.9.3...v0.9.4