Skip to content

Releases: jose-pr/pathapi

v0.1.2

Choose a tag to compare

@github-actions github-actions released this 17 Oct 05:30

Added

  • max_workers for recursive copy(), rm() and PathSyncer. copy() (and
    copy_into()), rm() and PathSyncer(...) take max_workers=None. None, the
    default, is one thread, exactly as before. An int of 2 or more hands the files
    and links of each directory to a pool of at most that many threads, which
    helps a backend that pays a round trip per entry. It applies to a class that sets
    the new class attribute _threadsafe = True (LocalPath and FTPPath do); any other
    class, including every class of another distribution until it sets the attribute,
    runs serially whatever was asked. Callbacks (progress, on_error, ignore_error,
    the sync hook, follow policies) run one at a time but on pool threads, in no
    fixed order, and the error that ends the operation is the first to happen. A value
    below 1 is ValueError, one that is not an int is TypeError. PathContract
    has a supports_concurrent_use switch for the matching test.
  • AsyncPath and AsyncFile: a path for use from an event loop. AsyncPath(path)
    wraps any Path; the pure members (name, parent, /, with_*(), match(), ...)
    are plain and give AsyncPath, every method that does I/O is a coroutine of the same
    name and arguments, iterdir(), glob(), rglob() and walk() return async iterators,
    and open() returns an object that is awaited or used with async with and gives an
    AsyncFile (read, readline, write, seek, ..., aclose()). They are exported
    from pathapi and pathapi.path. Each call runs the wrapped path's sync method on a
    worker thread (asyncio.to_thread()), so every backend can be used from a loop and the
    loop is not blocked; cancelling an await abandons the wait and the worker finishes the
    call. Recursive copy(), rm() and move() are one such call, so max_workers= is the
    sync fan-out. supports() and capabilities() of an AsyncPath answer for the wrapped
    path. A subclass gets its own class for every path derived from it, built by the
    classmethod AsyncPath._adopt(path) (cls(path)), which a subclass with another
    constructor overrides.
  • AsyncHTTPClient: the HTTP client for an event loop. pathapi.http.AsyncHTTPClient
    is the sibling of HTTPClient: the same constructor arguments, redirect and
    Authorization rules, timeout, deadline, reply limit and errors, with request()
    and redirect() as coroutines, aclose() and async with. It returns an
    AsyncHTTPResponse (await read(), iter_bytes() as an async iterator, await aclose()). It sends through an async transport, an object with async send() and
    async aclose(). The default one, ThreadedTransport, runs the synchronous transport
    PATHAPI_HTTP_TRANSPORT names on worker threads, so every synchronous transport works
    from a loop; a native one is declared in the entry-point group
    pathapi.http_async_transports and chosen by PATHAPI_HTTP_ASYNC_TRANSPORT (same
    rules and messages as the synchronous registry; async_transport_names(),
    get_async_transport() and default_async_transport()).
  • The async half of the transport suite. python -m pathapi.http.testing --http-async-transports NAME holds an async transport to the promises of the
    synchronous suite (test_async_transport.py, test_async_client.py). Without the
    option the async tests run over builtin when --http-transports is left out too,
    and are not collected when it names a transport: a distribution that runs
    --http-transports NAME gets the tests it got before.
  • pathapi.testing.AsyncPathContract. The pytest contract of AsyncPath over a
    backend: it takes the root fixture of PathContract and checks each coroutine
    against the sync path's result, with asyncio.run() and no extra test dependency. Its
    class attribute async_path_class (default AsyncPath) names the class under test, so a
    subclass of AsyncPath is tested with the paths it derives.

Changed

  • An unknown HTTP transport name is told where to look, not which package to
    install.
    get_transport() and PATHAPI_HTTP_TRANSPORT raise ValueError for a
    name no installed distribution declares in the group pathapi.http_transports,
    naming the group and suggesting pathapi-NAME as a name to look for. The error is
    a ValueError that is also an ImportError, so code that caught either still
    does. requests is no longer special: transport_names() lists it only when an
    installed distribution declares it. The refusal of a proxy that is not http://
    points at a transport of that group that supports it. Install the distribution
    that provides the transport.

Fixed

  • A nan or infinite HTTP timeout is refused with ValueError. timeout=float("nan")
    passed the check, and the built-in transport then failed in socket.settimeout() after
    it had opened the connection, which leaked the socket. Pass a positive finite number of
    seconds, or None.
  • move(overwrite=True) no longer destroys the old target when the move fails.
    An existing target was removed before the source was renamed or copied over it, so
    a failing rename or copy lost the target. The target now stays until the source
    has taken its place: a local file is replaced atomically, rename() is tried
    over a file elsewhere, a backend that refuses has the target renamed aside and put
    back on failure, and a copy and delete finishes the new file or tree beside the
    target first. A directory target is swapped in by rename where its class has one.
    If you list the directory while a move runs, you may see a hidden
    .NAME.<hex>.pathapi-tmp sibling.
  • copy(follow_symlinks=False, overwrite=True) of a link no longer destroys the old
    target when the new link cannot be made.
    The existing target was unlinked before
    symlink_to(), so a refused link lost it. Where the target's class can rename, the
    link is now made in a hidden sibling and renamed over the target; a class that
    cannot rename still unlinks first. A target class that cannot make links now raises
    NotImplementedError before anything is removed.

Full Changelog: v0.1.1...v0.1.2

v0.1.1

Choose a tag to compare

@github-actions github-actions released this 15 Oct 19:24

Added

  • deadline for a whole HTTP request. HTTPClient(deadline=...) and the
    deadline= keyword of request() and redirect() take the seconds a request may
    take, redirects and the reading of the body included (None, the default, is no
    limit). Each wait's timeout is held to the time that remains and TimeoutError is
    raised once it has passed; timeout still bounds each wait. A read already blocked
    can overrun the deadline by at most its timeout. A value that is not a number is
    TypeError, one that is not positive ValueError. It works over every transport.

Fixed

  • copy(overwrite=True) no longer destroys the old target when the copy fails.
    An existing file was removed before the new content was written, so a source that
    failed half way left no file, or a partial one. The old file is kept until the
    new content is complete: it is written to a hidden .NAME.<hex>.pathapi-tmp
    sibling and renamed over it, and a class whose write stream commits on close is
    written to directly. A class that cannot rename writes the sibling and then writes
    it over the target. If you list the directory while a copy runs, you may see that
    sibling.
  • pathlib.Path(src).copy(target) (3.14) onto a Path of this library refuses
    unsafe child names.
    _copy_from() joined every listed name onto the target; a
    name that is not one component (.., a separator, on a Windows target a drive or
    a trailing dot) now raises ValueError before anything is written for it, as
    copy(recursive=True) does. A source that lists such names needs a copy that
    skips them.
  • The shipped transport suite names the file of its tests wherever it is run
    from.
    python -m pathapi.http.testing (and run()) roots pytest at the suite's
    own directory unless --rootdir is given, so node ids keep their file when the
    checkout and the installed package are on different drives.

Full Changelog: v0.1.0...v0.1.1

v0.1.0

Choose a tag to compare

@github-actions github-actions released this 15 Oct 02:27

Published as pathlib-next up to 0.10.0; from 0.1.0 as pathapi. The released
sections below keep the name they were published under.

Added

  • supports() and capabilities(): which protocols a path class implements.
    pathapi.protocols.supports(obj_or_class, *protocols) is true when every
    primitive of every protocol named is something other than its default stub, and
    capabilities(obj_or_class) lists the protocols that hold. Ask once instead of
    calling and catching NotImplementedError. The answer is advisory: a primitive may
    still refuse an argument. A class whose answer depends on its connection defines
    _supports(protocol) -> bool | None.
  • The operations of Path are protocols you can mix in. Create (mkdir,
    touch), Listing (iterdir, glob, rglob, walk), Remove (unlink, rmdir,
    rm), Readlink (readlink), Symlink (symlink_to), Identity (samefile), Copy, Move
    and the pure-path description Named are in pathapi.protocols, and
    Path is built from them. A read-only backend can be a class built from Pathname,
    Listing, Stat and BinaryOpen with no Path: glob, checksum.md5
    and PathSyncer take it as a source.
  • Chmod is split, and so is every pair of a reader and a writer. Chown
    (chown()), Owned (owner(), group()), Utime (_utime()), Readlink and
    Symlink are protocols of their own, so supports(path, Chmod) asks for chmod()
    alone and an archive supports Readlink and not Symlink. Chmod still inherits
    Chown and Utime, so chown, _chown and _utime stay reachable from it.
  • owner(), group(), readlink(), hardlink_to(), absolute() and resolve()
    on every Path.
    A backend supplies _user_name() and _group_name(),
    readlink(), _hardlink_to() and _cwd(); resolve() follows links through
    readlink(), at most 40, and is lexical where a class has none. LocalPath keeps
    pathlib's own.
  • The contract suites' switches supports_listing, supports_mkdir,
    supports_delete, supports_utime and supports_rename default to None, which
    asks supports() about Listing, Create, Remove, Utime and Move, so a class
    without a protocol skips those tests with no switch set and the skip names the
    protocol. Set one to True or False on the test class to override the answer.
  • providers.primitive marks a default of a primitive whose body raises an error of its
    own, so supports() reads it as absent.
  • providers.derived_from(*primitives) marks a method that only wraps other
    primitives, so a base class can define iterdir() over _listdir() and
    supports() still says a subclass without _listdir() cannot list.
  • providers.notimplemented takes a reason: @notimplemented("why") marks a stub
    that supports() reads as absent and whose NotImplementedError carries the
    reason; the bare form is unchanged.

Changed

  • The distribution, the import package and the repository are pathapi. Version
    numbering restarts at 0.1.0. pathlib-next 0.10.1 is a final release that depends on
    pathapi and re-exports it under the old import name with a DeprecationWarning.
    What to do: install pathapi and change the names in this table.

    Before After
    pip install pathlib-next pip install pathapi
    import pathlib_next, pathlib_next.<module> import pathapi, pathapi.<module>
    entry-point group pathlib_next.http_transports pathapi.http_transports
    pathlib-next-requests (pathlib_next_requests) pathapi-requests (pathapi_requests)
    PATHLIB_NEXT_HTTP_TRANSPORT PATHAPI_HTTP_TRANSPORT
    the pathlib-next/<version> user agent, the .pathlib-next-tmp suffix of a PathSyncer temporary sibling pathapi/<version>, .pathapi-tmp
    the pathlib_next.sync and pathlib_next.http loggers pathapi.sync, pathapi.http
    benchmark result schema pathlib_next.bench/1 pathapi.bench/1
  • copy() over an existing file does not call unlink() on a target whose class has no
    Remove (it replaces the content through open()), and move() raises
    NotImplementedError before copying when the source cannot be removed and has no
    rename() to use. Before, the first raised from the unlink() and the second left
    the file in two places. What to do: nothing, unless you relied on either.

  • Every Path now has a readlink(), which raises NotImplementedError where the class
    cannot read links, so hasattr(path, "readlink") no longer tells the two apart. What
    to do: ask supports(path, Readlink).

  • A write stream that commits on close (CommitOnClose, and any stream built on
    DiscardOnError), or the text file over one, that is garbage collected without being
    closed is now discarded with a ResourceWarning instead of committed, as a with
    block that raised already was. Before, the collector closed it and so uploaded or
    rewrote what the program never finished. What to do: close the file (use with);
    a stream of your own on DiscardOnError can override _holds_uncommitted() to stay
    silent when it has nothing to commit.

  • _supports(protocol) may make at most one request per backend, cached for the
    backend's lifetime, and never raises (it answers None when the request fails).

Full Changelog: v0.10.0...v0.1.0

pathlib-next-v0.10.1

Choose a tag to compare

The last release under the name pathlib-next: the project is now pathapi. This
release contains no implementation of its own.

Changed

  • pathlib-next depends on pathapi>=0.1.0,<0.2, and import pathlib_next re-exports
    it after a DeprecationWarning: every public name, and every submodule
    (pathlib_next.protocols, pathlib_next.http, ...) as the same module object as its
    pathapi counterpart, so isinstance agrees across the two names. The package
    ships py.typed; PATHLIB_NEXT_HTTP_TRANSPORT is copied to PATHAPI_HTTP_TRANSPORT
    when only the old one is set. What to do: install pathapi and import pathapi;
    README.md has the table of names. pathapi 0.1.0 is 0.10.0 plus the protocols of
    pathapi and the changes listed in its changelog.

v0.10.0

Choose a tag to compare

@github-actions github-actions released this 14 Oct 12:58

pathlib-next 0.10.0 is the core alone: the path classes, pathlib_next.http,
FTPPath and the archive paths, with no dependency, no extra and no console
script. The URI layer, every scheme that needs a library, and the uripath
command are distributions of their own that depend on it. Under Changed, the
migration comes in this order: the table of names, then every change of
behaviour with what to do about it.

Added

  • pathlib_next.http: HTTP requests on the standard library, with a
    transport you can replace, and no URI.
    import pathlib_next.http needs no
    extra and no third-party library (it loads http.client and ssl;
    import pathlib_next still loads neither). HTTPClient(transport=None, *, headers=None, timeout=(10, 60), user_agent=None) sends request(method, url, *, data=None, headers=None, stream=False, allow_redirects=None, timeout, auth=None) through a transport and follows redirects: GET,
    HEAD and OPTIONS up to 30, with Authorization dropped for good by a hop
    to another scheme, host or port; every other method is sent again once after
    a 307 or 308 to the same origin and is otherwise an OSError(EIO) naming the
    status. A transport is any object with send(method, url, *, headers, body=None, timeout, stream=False) that returns an HTTPResponse, and a
    close(); HTTPTransport(*, ssl_context=None, verify=True, proxies=None) is
    the built-in one on http.client (TLS verified by default, proxies= as an
    object indexed with the request URL, one idle connection per thread and
    server). The package also holds the helpers a path built on it needs:
    raise_for_status, translate_errors (a failed request as the OSError
    type pathlib raises), read_limited, json_body and drop_body (a reply
    read whole is held to MAX_REPLY_BYTES, 8 MiB after decoding, and the
    setting is assignable), peer_text, split_userinfo, server_size and
    is_redirect. PATHLIB_NEXT_HTTP_TRANSPORT names the transport a client
    builds when it is given none: builtin, or the name of one an installed
    distribution declares in the entry-point group pathlib_next.http_transports
    (requests, from the pathlib-next-requests distribution; transport_names()
    lists them without importing any and get_transport(name) loads one).
    prepare_request() makes the checks of the transport contract for a
    transport of your own.
  • pathlib_next.http.testing: the suite that holds a transport to the
    promises of the built-in one.
    With the distribution that provides the
    transport and pytest installed, python -m pathlib_next.http.testing --http-transports requests -q runs the tests that depend on the transport
    against it, over loopback servers scripted at the byte level. The test
    files ship in the wheel. It is testing support and not part of the stable
    API: its contents, including test names, fixtures and helper signatures, may
    change in any release, and pathlib_next.http never imports it.
  • pathlib_next.ftp: FTPPath and FTPBackend, an FTP tree that needs no
    URI and no extra.
    FTPPath("/pub/x", host="h", user="u", tls=True) builds
    the path's own FTPBackend; FTPPath("/pub/x", backend=backend) shares one
    connection cache between paths built separately (or plugs in a
    BaseFTPBackend of your own), and giving both is a TypeError. Nothing
    connects until the first operation. FTPBackend(host, port=21, *, user, password, tls, timeout, ssl_context, verify) keeps a control connection per
    thread, is a context manager, and close() ends every connection it holds.
    str() is the remote path, repr() names the endpoint and never shows a
    password, and two paths are one tree when they share a backend or name the
    same host, port, account and security. pathlib_next.FTPPath is imported on
    first use, so import pathlib_next still loads neither ftplib nor ssl.
  • pathlib_next.archive: ZipPath, TarPath and ArchivePath, the members
    of an archive file that is any Path, with no URI and no extra.

    ZipPath("d/x", archive="a.zip") takes the archive as a Path, or a str
    or os.PathLike (a LocalPath); the segments are the member path, read
    from the archive root. ZipPath and TarPath fix the format, ArchivePath
    reads it from the extension or the first bytes on first use, and building a
    path reads nothing. One handle serves each archive file and format, so paths
    built separately see each other's writes. A zip that is a local file is
    written in place (write_bytes, mkdir, unlink, rmdir, rename,
    rm(recursive=True), batch()); every other archive is read-only, in
    memory or on any other Path. str() is the member path, repr() names the
    archive, and as_uri() is zip:<archive uri>!/<member>.
    ArchivePath.backend_for() gives the handle to pass as archive=.
    pathlib_next.ZipPath, TarPath and ArchivePath are imported on first use.
  • pathlib_next.providers.TreePath, the base FTPPath and the archive
    paths are built on, for a path whose names are POSIX segments below the
    root of a tree a backend serves.
  • pathlib_next.providers.name_stem(), name_suffix() and
    name_suffixes().
    One path component split into stem and suffix as
    pathlib of the running interpreter splits it, for a class that computes
    those itself and must agree with Pathname.
  • pathlib_next.exceptions: PathError, the base of the exceptions the
    package defines.
    NonRelativePatternError (an absolute glob pattern) is
    now a PathError as well as a NotImplementedError and a ValueError, so
    every except clause that caught it still does. I/O failures stay the
    OSError types pathlib raises. PathError, NonRelativePatternError and
    MemPath are exported from pathlib_next.
  • pathlib_next.mempath, pathlib_next.sync and pathlib_next.testing
    declare __all__
    , so from pathlib_next.sync import * publishes
    PathSyncer, SyncEvent and PathAndStat and nothing else, and a type
    checker reads those names as the module's exports.
  • pathlib_next.providers.DiscardOnError, a mixin for a write stream that
    commits when it is closed: its __exit__ calls discard() when the with
    block raised, then closes. CommitOnClose inherits it and has a
    discard(); see the entry under Changed.

Changed

  • Where every name went. Change each import and each install as the table
    says. There is no alias for an old name: the old import raises
    ModuleNotFoundError or ImportError, and a name that is not in the table
    was never public. The URI layer, the schemes and the command line are the
    distributions uripath, sftppath, objpath, repopath and
    pathlib-next-requests, which each document their own changes of behaviour;
    the core's own moves come first.

    Was Is now
    pathlib_next.utils.sync (PathSyncer, SyncEvent, PathAndStat) pathlib_next.sync
    pathlib_next.utils.glob (glob, select, full_match, parse_pattern, ...) pathlib_next.glob
    pathlib_next.utils.glob.NonRelativePatternError pathlib_next.NonRelativePatternError (defined in pathlib_next.exceptions)
    pathlib_next.utils.checksum (md5, sha256, stream, native) pathlib_next.checksum
    pathlib_next.utils.md5, pathlib_next.utils.sha256 pathlib_next.checksum.md5, pathlib_next.checksum.sha256
    pathlib_next.utils.archive, pathlib_next.utils.make_archive, pathlib_next.utils.unpack_archive pathlib_next.archive.make_archive, pathlib_next.archive.unpack_archive
    pathlib_next.utils.archive.detect_format (also _detect_format) pathlib_next.archive.detect_format
    pathlib_next.utils.stat.FileStat pathlib_next.FileStat
    pathlib_next.utils.as_mode pathlib_next.providers.parse_mode
    pathlib_next.utils.as_owner pathlib_next.providers.parse_owner
    pathlib_next.utils.UNCHANGED pathlib_next.providers.UNCHANGED
    pathlib_next.utils.as_error_handler, as_error_predicate, OfferedOnce pathlib_next.providers.as_error_handler, as_error_predicate, OfferedOnce
    pathlib_next.utils.is_safe_child_name, is_windows_flavoured pathlib_next.providers.is_safe_child_name, is_windows_flavoured
    pathlib_next.utils.notimplemented pathlib_next.providers.notimplemented
    pathlib_next.utils.LRU pathlib_next.providers.LRU
    pathlib_next.utils._commit.CommitOnClose (also _CommitOnClose) pathlib_next.providers.CommitOnClose
    pathlib_next.path.check_follow, contains, copy_error_predicate (also _check_follow, _contains) pathlib_next.providers.check_follow, contains, copy_error_predicate
    pathlib_next.utils.sizeof_fmt, pathlib_next.utils.parsedate removed: no replacement is public
    the package pathlib_next.uri the distribution uripath, import name uripath
    pathlib_next.Uri, pathlib_next.UriPath (and pathlib_next.uri.Uri, UriPath, UriLike) uripath.URI, uripath.URIPath, uripath.URILike
    Path, Pathname, FileStat imported from pathlib_next.uri the same names in pathlib_next
    pathlib_next.uri.source.Source uripath.Source (also uripath.source.Source)
    pathlib_next.uri.source.compose_uri, compose_host, percent_encode, is_drive, SAFE_PATH, ERRORS the same names in uripath.source
    pathlib_next.uri.same_authority uripath.source.same_authority
    pathlib_next.uri.DerivedBackend uripath.schemes.DerivedBackend
    pathlib_next.uri.query.Query; Query(text), Query.decode() uripath.Query, no longer a str; Query.parse(text), Query.to_pairs()
    Source.from_str(text, strict); Source.as_str(sanitize=True), as_str(sanitize=False) Source.parse(text, *, strict=True); str(source), source.reveal()
    `pathlib_next.uri.s...
Read more

v0.9.14

Choose a tag to compare

@github-actions github-actions released this 12 Oct 09:24

Added

  • MemPath.relative_to(other, *, walk_up=False). It raised
    NotImplementedError; it now answers as PurePosixPath.relative_to does,
    on the path's own names (no .. applied): other is a MemPath of the same
    backend or a str, and the result is a relative MemPath on that backend.
    ValueError when the path is not below other, when only one of them is
    rooted, or when the backends differ. walk_up=True gives the ..-prefixed
    form on every Python version, not only 3.12 and later. Code that caught
    NotImplementedError around it can call it directly.
  • copy(on_error=) and copy_into(on_error=) take rm()'s error
    predicate.
    on_error(error, path) -> bool (or a bool) is asked once for
    each child of a recursive copy that fails, with path the source entry:
    True skips it and the copy goes on, False raises the error. The same
    function used to mean opposite things to the two methods (lambda *a: False
    made rm() raise and copy() suppress every error), and a predicate
    written for one raised TypeError in the other. ignore_error= keeps its
    notify-only callable; use on_error= for a predicate. Passing both raises
    TypeError before anything is copied. sftp: (the asyncssh concurrent walk
    included), s3:, gs: and az: accept it, and PathContract gains a test
    that gives one predicate to rm() and copy().
  • Path.info, and Python 3.14's pathlib.Path.copy() onto any Path.
    Every Path (MemPath, UriPath, SftpPath, ...) has info, on every
    Python version: an object with exists(), is_dir(), is_file() (each with
    follow_symlinks=True) and is_symlink(), the shape of 3.14's
    pathlib.types.PathInfo. It calls stat() at most once following links and
    once not, and remembers the answers on that object only, so a new path.info
    asks again. LocalPath keeps pathlib's own on 3.14. A new hook,
    Path._copy_from(source, follow_symlinks=True, preserve_metadata=False),
    lets pathlib.Path(f).copy(MemPath("/x")) work on 3.14, where it raised
    AttributeError: ... has no attribute '_copy_from': it overwrites a file,
    raises on an existing directory, and with preserve_metadata=True copies the
    permission bits and the modification time where the class can set them.
    Path.copy() of this library is unchanged and does not call it.
  • FileStat.size_known, and listings that know a type but not a size.
    A listing can now say "this is a file" without inventing its size: a stat
    with size_known=False answers is_dir(), is_file(), exists(),
    walk(), glob() and rm(recursive=True), while stat() of the listed
    child asks the server for the real size and time, and PathSyncer, the
    progress total of copy() and quick_check never read the placeholder.
    gitlab: blobs, an Apache index with sizes like 1.2K and the stdlib
    bulleted-list index (http:) used to give no hint at all, so walking a tree
    cost a request per file; it now costs one per directory. An exact size from a
    listing that has one stays a full hint. A scheme of your own may yield
    FileStat(is_dir=False, size_known=False) from _scandir(); code that reads
    a listing's st_size or st_mtime should check size_known.
  • PathSyncer(follow_mounts=False) and uripath sync --no-follow-mounts.
    A mount point found below the target root (a mounted volume, a bind mount)
    is still synced into by default, as rsync does. With follow_mounts=False
    it is an error for that entry instead: OSError(EXDEV) naming it, offered to
    ignore_error with event TypeMismatch, and nothing behind it is listed,
    written or removed, in a dry run too. A bind mount aliases another directory
    as a junction does, and rm(recursive=True) already stops at one; this lets
    a sync with remove_missing=True refuse it. A junction is replaced under
    either value, and the root target is used as given.
  • touch() moves an existing file's time, and a sync leaves its copies with
    their source's time.
    The generic touch() of an existing file did nothing;
    it now sets the access and modification times to now through a new _utime()
    hook (_utime(*, atime=None, mtime=None), seconds since the epoch, None
    leaves that time alone), implemented by LocalPath, FileUri, MemPath,
    SftpPath on both client libraries (whole seconds) and FtpPath on a server
    whose FEAT lists MFMT (modification time only). A class that does not
    implement it raises NotImplementedError from _utime() and touch()
    leaves the file as it was, as before. PathContract gains
    supports_utime (default True; set it False on a test class whose
    backend cannot set a time) and a test behind it.
  • PathSyncer(preserve_mtime=True). A file the sync copies is given the
    modification time its source reported, where the target's class implements
    _utime(); a source that reports no time, a target that cannot set one or
    refuses it, keep the time the copy gave the file, and the sync reports
    nothing. Directories and links are left alone. quick_check (default on for
    a pair with a non-local side) could never succeed after this library's own
    copy, so a remote tree was checksummed, and so downloaded, again by every
    later run; the second run of an unchanged tree now reads no file.
  • The names the built-in schemes take from the core are documented, and the
    helpers among them are public.
    The shipped API header gains "For path
    implementers" and "For scheme implementers" sections, each hook with its
    signature and one line of contract (_init, _from_parsed_parts,
    _format_parsed_parts, _make_child_relpath, _rename_target,
    _pop_stat_hint, _same_location, the backend-state hooks, _anchor_segments
    and the rest). Twelve helpers that were underscore names now have public ones:
    pathlib_next.path.check_follow and contains,
    pathlib_next.utils.archive.detect_format,
    pathlib_next.utils._commit.CommitOnClose, pathlib_next.uri.same_authority
    and DerivedBackend, and in pathlib_next.uri.source compose_uri,
    compose_host, percent_encode, is_drive, SAFE_PATH and ERRORS. The
    underscore spellings (_check_follow, _contains, _encode, ...) still
    import and are the same objects, so nothing has to change; a scheme written
    outside this package can use the documented names instead. path.__all__
    now lists check_follow and contains.
  • glob(recurse_symlinks=True) and rglob(recurse_symlinks=True) work.
    They raised NotImplementedError on every class. ** now descends through
    symlinks to directories, as pathlib 3.13 does, and the keyword is accepted
    on every Python version. A loop ends where pathlib 3.13 would follow it
    until the system refuses: a link to a directory the descent is already
    inside (st_dev/st_ino) is listed as an entry and not entered, and a
    backend whose stat has no identity follows at most 40 links in one descent.
    utils.glob.glob() and utils.glob.select() take the keyword too. The
    default stays False; a caller that caught the NotImplementedError to
    fall back to its own walk can drop the fallback.
  • checksum() and supported_checksums() on s3:, gs: and az:. An
    object-store path now answers NativeChecksum from the digest the store
    keeps, with one request and no byte of the content. S3Path gives the plain
    MD5 that the ETag is for an object one request wrote and that KMS or a
    customer key does not encrypt (checksum("md5")), and the stored full-object
    crc32, crc32c, crc64nvme, sha1 or sha256; GsPath gives md5Hash
    and crc32c; AzPath gives the Content-MD5 property. A multipart ETag
    (...-N), a composite checksum, a GCS object with a content encoding or no
    md5Hash, a blob without Content-MD5 and any other algorithm raise
    NotImplementedError, never a guess; a missing path is FileNotFoundError
    and a prefix directory IsADirectoryError. PathSyncer uses them without
    being told: the second sync() of an unchanged tree of 100 s3: objects
    to another prefix sent 200 GetObject before and sends none. Azure's
    Content-MD5 is a property a client can set and the service does not verify
    against the content in every case.
  • copy() of a file within one object store is one request of the store.
    s3:/gs: (inside a bucket, or between buckets that one client reaches: the
    same supplied backend, or both paths' default) and az: (inside a
    container) issue CopyObject, copy_blob or the blob copy instead of
    reading the file and writing it back; copy(recursive=True) and move() of
    a prefix do so for each file. A file copy on s3: took 6 requests (a
    GetObject and a PutObject among them) and takes 4, of which one is the
    copy. The checks copy() always made run first and in the same order (the
    same file, a target that is a directory or holds keys, an existing target
    without overwrite=True); an existing target is replaced by the copy and no
    longer deleted first, so it is intact when the copy fails; progress is
    called once with the total; a file named as the root of a
    recursive=True copy is still read and written; a store that answers "not
    implemented" gets the streamed copy. The copy keeps the object's content type
    and metadata, which the streamed copy did not.
  • gs: and az: read and write an object in pieces. Reading asks for
    ranges of 4 MiB (gs:) or 512 KiB (az:, schemes.az.READ_CHUNK: the SDK
    holds about nine times a response, assign a larger number to trade memory
    for requests) as the stream is read, instead of downloading the whole
    object first; the stream stays seekable and seek() asks nothing until the
    next read. Writing spools in memory up to 4 MiB and then in a temp file, as
    s3: does, and uploads on close: a resumable upload in 2 MiB requests on
    gs:, blocks on az:. A read ...
Read more

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...
Read more

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") / ".."...
Read more

v0.9.11

Choose a tag to compare

@github-actions github-actions released this 21 Sep 15:19

Added

  • Native checksums on the asyncssh SFTP backend. SftpPath.checksum()
    and supported_checksums() now send the check-file-handle extension on
    AsyncsshSftpBackend (the backend chosen when asyncssh is installed), as
    they already did on the paramiko backend. Against a server that implements
    it (ProFTPD's mod_sftp, for example), PathSyncer compares server-side
    digests instead of reading both files. OpenSSH does not implement it: the
    refusal costs one request per connection, and files are compared by
    streaming as before. A native checksum() is not bounded by the backend's
    timeout, because the server hashes the whole file before it answers.

Changed

  • The uri extra now requires netimps>=0.3.1 (was >=0.2.0). An
    environment that pins an older netimps must raise that pin.

Full Changelog: v0.9.10...v0.9.11

v0.9.10

Choose a tag to compare

@github-actions github-actions released this 20 Sep 13:30

Added

  • Path._same_filesystem(other), an override hook answering whether two
    paths of one type resolve their segments in the same place. Path equality
    ignores that, and copy(), move() and PathSyncer now ask it before
    calling two paths the same file, nested, or overlapping. A path type whose
    instances can front different hosts or stores should override it; the
    default keeps the previous behaviour. pathlib_next.testing.PathContract
    gains two tests: a file copied onto a separately built spelling of itself
    is refused with its content intact, and paths under one root share a
    filesystem.

Changed

  • The built-in Base*Backend classes (s3, gs, az, ftp, sftp,
    github/gitlab) are weakly referenceable, which is how a UriPath
    tells a backend it derived for itself from one it was given. A custom
    backend class using __slots__ without "__weakref__" still works; it is
    simply always read as supplied.

Fixed

  • A path type that fronts several hosts can transfer between them again.
    Since 0.9.4, copy()/move() raised OSError(EINVAL) "Source and target
    are the same file" and PathSyncer raised "source and target overlap" for
    /app.conf on one host onto /app.conf on another, whenever the type kept
    its connection under any attribute other than the private _backend the
    guards looked for. Such a type now overrides _same_filesystem().
  • Two separately built URIs to the same URL are recognised as the same
    file.
    Each derived its own backend on first use, which the guards read as
    two hosts: UriPath(url).copy(UriPath(url), overwrite=True) was not
    refused on any scheme without st_dev/st_ino (S3 and WebDAV among
    them), and neither was a sync of a URL onto itself. Only two backends the
    caller supplied now tell equal URIs apart. One consequence: a path given a
    backend and a bare path to the same URL are now the same file for
    copy()/move(), as they already were for PathSyncer.
  • copy(recursive=True)'s "into itself" check now honours two supplied
    backends, like the other two guards; it refused a copy between two hosts
    whose URIs nested.

Full Changelog: v0.9.9...v0.9.10