diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..e37a08a --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,48 @@ +name: Deploy Docs + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + python-version: "3.13" + + - name: Install dependencies + run: uv sync --group docs + + - name: Build docs (strict) + run: uv run mkdocs build --strict + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: site + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 926b93a..5f32c09 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,8 +10,9 @@ jobs: test: runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - python-version: ["3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v4 @@ -26,3 +27,17 @@ jobs: - name: Run tests run: uv run pytest tests/ -v + + docs-build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v6 + + - name: Install dependencies + run: uv sync --group docs + + - name: Build docs (strict) + run: uv run mkdocs build --strict diff --git a/.gitignore b/.gitignore index 73968a1..33ca54b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,29 @@ +# Byte-compiled / caches __pycache__/ *.py[cod] -*.egg-info/ -dist/ -build/ -.venv/ .pytest_cache/ +.ruff_cache/ +.mypy_cache/ + +# Coverage .coverage +.coverage.* htmlcov/ +coverage.xml + +# Build artifacts +dist/ +build/ +*.egg-info/ + +# Environments +.venv/ +venv/ + +# Docs build +site/ + +# OS / editor +.DS_Store +.idea/ +.vscode/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..409b588 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,70 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.1.0] - 2026-07-03 + +### Added + +- Bilingual (English / 日本語) documentation built with MkDocs Material + + mkdocs-static-i18n + mkdocstrings, deployed to GitHub Pages: + guides for waiting, iterating, deriving/composing, tasks/cancellation, + and events/streams, plus a full API reference. +- Runnable examples under `examples/` (state machine, debouncing, + composing values). +- `py.typed` marker so type checkers pick up the annotations. +- Docs build job in CI and a GitHub Pages deployment workflow. +- Regression test suites for `held_for`, raising predicates, task + helpers, and stream helpers. + +### Fixed + +- `AsyncValue.wait_value(..., held_for=N)` raised `asyncio.TimeoutError` + when the condition *successfully* held for the duration; it now + returns the value. +- `AsyncValue.eventual_values(..., held_for=N)` had the hold logic + inverted (yielded on flicker, raised `TimeoutError` on a stable hold). +- A raising predicate corrupted delivery: the exception surfaced in the + assigning task and later waiters were never notified. Predicate + exceptions are now delivered to their own waiter; transform/compose + errors are re-raised only after all notifications went out. +- `compose_values()` silently swallowed exceptions raised by + `_transform_`, freezing the composite output at its last good value. +- `move_on_when()` leaked `CancelledError` to the caller (and never ran + the body) when the trigger completed without awaiting, e.g. + `move_on_when(already_set_event.wait)`. +- `move_on_when()` swallowed exceptions raised by the trigger; they now + cancel the body and propagate. +- `run_and_cancelling()` / `start_and_cancelling()` masked the body's + exception when the background task had also failed. +- `wait_any()` / `wait_all()` / `wait_any_map()` leaked already-started + tasks when creating a later task raised synchronously. +- `MulticastQueue` treated a broadcast `None` as end-of-stream, + terminating listeners early; a private sentinel is used instead. +- `periodic()` drifted: scheduling is now anchored to the absolute + `start + k * period` grid and re-syncs after an overrun. + +### Changed + +- Requires Python 3.10+ (was 3.8+); CI covers 3.10–3.14. +- Project metadata: description, keywords, classifiers, documentation + URL pointing to GitHub Pages. + +## [0.0.1] - 2024-05-15 + +### Added + +- Initial port of trio-util APIs to asyncio, created for a + [PyCon US 2024 talk](https://us.pycon.org/2024/schedule/presentation/142/): + `AsyncValue`, `AsyncBool`, `compose_values`, `open_held_for`, + `open_hysteresis`, `wait_any`/`wait_all`/`wait_any_map`, + `move_on_when`/`CancelScope`, `run_and_cancelling`/ + `start_and_cancelling`, `MulticastQueue`, `RepeatedEvent`, + `periodic`, `azip`/`azip_longest`, + `iter_move_on_after`/`iter_fail_after`. + +[0.1.0]: https://github.com/jrfk/asyncio-util/releases/tag/v0.1.0 +[0.0.1]: https://github.com/jrfk/asyncio-util/commit/acf53ed diff --git a/README.ja.md b/README.ja.md new file mode 100644 index 0000000..c290587 --- /dev/null +++ b/README.ja.md @@ -0,0 +1,122 @@ +# asyncio-util + +[![Test](https://github.com/jrfk/asyncio-util/actions/workflows/test.yml/badge.svg)](https://github.com/jrfk/asyncio-util/actions/workflows/test.yml) +[![Docs](https://github.com/jrfk/asyncio-util/actions/workflows/docs.yml/badge.svg)](https://jrfk.github.io/asyncio-util/ja/) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://spdx.org/licenses/MIT.html) + +**[trio-util](https://github.com/groove-x/trio-util) から asyncio へ移植した +ユーティリティ集 — await できる値、キャンセルスコープ、繰り返しイベント、 +その他いろいろ。** + +[ドキュメント](https://jrfk.github.io/asyncio-util/ja/) | [English README](README.md) + +> [!TIP] +> このプロジェクトは、trio-util を asyncio に移植する PyCon US 2024 の +> [トーク](https://us.pycon.org/2024/schedule/presentation/142/)のサンプル +> コードとして始まり、独立したライブラリに成長しました。 + +----- + +中心となるのは `AsyncValue` — 任意の数のタスクが状態や遷移を *await* できる +値です。ポーリングループも、`Event` の手動管理も、取りこぼしの心配も +いりません。 + +```python +import asyncio +from asyncio_util import AsyncValue + +connection_state = AsyncValue("disconnected") + +async def watcher(): + # 値が条件を満たすまで待つ… + await connection_state.wait_value("connected") + print("接続しました!") + + # …あるいは値の変化に逐次反応する。 + async for state in connection_state.eventual_values(): + print(f"現在の状態: {state}") + +async def main(): + task = asyncio.ensure_future(watcher()) + await asyncio.sleep(0) + connection_state.value = "connecting" + connection_state.value = "connected" + await asyncio.sleep(0.1) + task.cancel() + +asyncio.run(main()) +``` + +マッチ判定は**代入時に同期的に**行われるため、値が直後に元へ戻っても +マッチする変化を取りこぼしません。 + +```python +av = AsyncValue(10) + +# 別の場所で: av.value = 20; av.value = 10 (高速な往復) +value = await av.wait_value(20) # それでも 20 が返る +``` + +## 機能 + +**await できる値** + +- `AsyncValue` / `AsyncBool` — `wait_value()`・`wait_transition()`・ + `eventual_values()`・`transitions()`。述語、`timeout=`、`held_for=` + デバウンスに対応 +- `open_transform(f)` — `f(value)` を追跡する派生 `AsyncValue` +- `compose_values(x=..., y=...)` — 複数の値にまたがる条件を await +- `open_held_for()` / `open_hysteresis()` — バタつく信号のための時間ベース + フィルタ + +**タスクとキャンセル** + +- `wait_any()` / `wait_all()` / `wait_any_map()` — 構造化されたレースと + ジョイン(開始したタスクは戻る前に必ずキャンセル・待機される) +- `move_on_when(trigger)` — イベント発火でブロックをキャンセル +- `run_and_cancelling(fn)` / `start_and_cancelling(fn)` — ブロックに + スコープされたバックグラウンドタスク + +**イベント・ストリーム・タイミング** + +- `RepeatedEvent` — 何度も発火でき、複数リスナーを持てるイベント +- `MulticastQueue` — 各アイテムを全リスナーへブロードキャスト +- `periodic(period)` — ドリフトしない周期イテレーション +- `azip()` / `azip_longest()` — 非同期イテレータの async `zip` +- `iter_move_on_after()` / `iter_fail_after()` — アイテム単位のタイムアウト + +依存ゼロ、型付き(`py.typed`)、Python 3.10+(CPython & PyPy)。 + +## インストール + +> [!WARNING] +> PyPI にはまだ公開されていません。 + +```console +pip install git+https://github.com/jrfk/asyncio-util +``` + +## ドキュメント + +ガイド(日本語 / English)と API リファレンス: +**https://jrfk.github.io/asyncio-util/ja/** + +実行できるサンプルは [`examples/`](examples/) にあります。 + +## 開発 + +```console +uv sync --group dev +uv run pytest # テスト +uv run mkdocs serve # ドキュメントのプレビュー (uv sync --group docs) +``` + +## 謝辞 + +API は GROOVE X の [trio-util](https://github.com/groove-x/trio-util) を +モデルに、asyncio のプリミティブの上にゼロから再実装したものです。 + +## ライセンス + +`asyncio-util` は [MIT](https://spdx.org/licenses/MIT.html) ライセンスの下で +配布されています。 diff --git a/README.md b/README.md index 9ca4490..fdcab80 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,90 @@ -> [!NOTE] -> While [trio-util](https://github.com/groove-x/trio-util) supports anyio -> +# asyncio-util + +[![Test](https://github.com/jrfk/asyncio-util/actions/workflows/test.yml/badge.svg)](https://github.com/jrfk/asyncio-util/actions/workflows/test.yml) +[![Docs](https://github.com/jrfk/asyncio-util/actions/workflows/docs.yml/badge.svg)](https://jrfk.github.io/asyncio-util/) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://spdx.org/licenses/MIT.html) + +**Utilities for asyncio, ported from [trio-util](https://github.com/groove-x/trio-util) — +awaitable values, cancel scopes, repeated events, and more.** + +[Documentation](https://jrfk.github.io/asyncio-util/) | [日本語 README](README.ja.md) | [日本語ドキュメント](https://jrfk.github.io/asyncio-util/ja/) > [!TIP] -> This repository is a sample project for the PyCon US 2024 [talk](https://us.pycon.org/2024/schedule/presentation/142/). -> This project was created with [Hatch](https://github.com/pypa/hatch) 🥚. +> This project started as the sample code for a PyCon US 2024 +> [talk](https://us.pycon.org/2024/schedule/presentation/142/) on porting +> trio-util to asyncio, and grew into a standalone library. -> [!WARNING] -> Some tests are failing. -> If you want to use it officially, let's add anyio support to trio-util. +----- +The centerpiece is `AsyncValue`: a value whose states and transitions any +number of tasks can *await* — no polling loops, no hand-rolled `Event` +juggling, no missed wakeups: +```python +import asyncio +from asyncio_util import AsyncValue +connection_state = AsyncValue("disconnected") -# Asyncio Util +async def watcher(): + # Wait until the value satisfies a condition... + await connection_state.wait_value("connected") + print("connected!") -[![PyPI - Version](https://img.shields.io/pypi/v/asyncio-util.svg)](https://pypi.org/project/asyncio-util) -[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/asyncio-util.svg)](https://pypi.org/project/asyncio-util) + # ...or react to every change as it happens. + async for state in connection_state.eventual_values(): + print(f"state is now: {state}") ------ +async def main(): + task = asyncio.ensure_future(watcher()) + await asyncio.sleep(0) + connection_state.value = "connecting" + connection_state.value = "connected" + await asyncio.sleep(0.1) + task.cancel() + +asyncio.run(main()) +``` + +Matches are evaluated **synchronously at assignment time**, so a matching +change is never missed even if the value flips back immediately: + +```python +av = AsyncValue(10) + +# elsewhere: av.value = 20; av.value = 10 (rapid flip) +value = await av.wait_value(20) # still returns 20 +``` + +## Features + +**Awaitable values** -## Table of Contents +- `AsyncValue` / `AsyncBool` — `wait_value()`, `wait_transition()`, + `eventual_values()`, `transitions()`, with predicates, `timeout=`, and + `held_for=` debouncing +- `open_transform(f)` — derive an `AsyncValue` tracking `f(value)` +- `compose_values(x=..., y=...)` — await conditions spanning several values +- `open_held_for()` / `open_hysteresis()` — time-based filters for flappy + signals -- [Installation](#installation) -- [License](#license) +**Tasks and cancellation** + +- `wait_any()` / `wait_all()` / `wait_any_map()` — structured racing and + joining (all started tasks are cancelled and awaited before returning) +- `move_on_when(trigger)` — cancel a block when an event fires +- `run_and_cancelling(fn)` / `start_and_cancelling(fn)` — background tasks + scoped to a block + +**Events, streams and timing** + +- `RepeatedEvent` — an event that fires many times, with multiple listeners +- `MulticastQueue` — broadcast each item to every active listener +- `periodic(period)` — drift-free periodic iteration +- `azip()` / `azip_longest()` — async `zip` over async iterators +- `iter_move_on_after()` / `iter_fail_after()` — per-item timeouts + +Zero dependencies, typed (`py.typed`), Python 3.10+ (CPython & PyPy). ## Installation @@ -31,9 +92,30 @@ > This project is not yet available on PyPI. ```console -pip install asyncio-util +pip install git+https://github.com/jrfk/asyncio-util ``` +## Documentation + +Full guides (English / 日本語) and API reference: +**https://jrfk.github.io/asyncio-util/** + +Runnable examples live in [`examples/`](examples/). + +## Development + +```console +uv sync --group dev +uv run pytest # tests +uv run mkdocs serve # docs preview (uv sync --group docs) +``` + +## Acknowledgements + +The API is modeled on [trio-util](https://github.com/groove-x/trio-util) by +GROOVE X, reimplemented from scratch on top of asyncio primitives. + ## License -`asyncio-util` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license. +`asyncio-util` is distributed under the terms of the +[MIT](https://spdx.org/licenses/MIT.html) license. diff --git a/docs/api.ja.md b/docs/api.ja.md new file mode 100644 index 0000000..017b2c6 --- /dev/null +++ b/docs/api.ja.md @@ -0,0 +1,59 @@ +# API リファレンス + +公開シンボルはすべてトップレベルの `asyncio_util` パッケージからインポート +できます。 + +```python +from asyncio_util import AsyncValue, compose_values, wait_any, ... +``` + +trio-util 互換のため、`BoolEvent` と `ValueEvent` が `AsyncBool` と +`AsyncValue` のエイリアスとして提供されています。 + +!!! note + docstring(以下の各説明文)は英語です。各機能の日本語の解説は + [ガイド](guide/waiting.md)を参照してください。 + +## await できる値 + +::: asyncio_util.AsyncValue + +::: asyncio_util.AsyncBool + +::: asyncio_util.compose_values + +::: asyncio_util.open_held_for + +::: asyncio_util.open_hysteresis + +## タスクとキャンセル + +::: asyncio_util.wait_any + +::: asyncio_util.wait_all + +::: asyncio_util.wait_any_map + +::: asyncio_util.move_on_when + +::: asyncio_util.CancelScope + +::: asyncio_util.run_and_cancelling + +::: asyncio_util.start_and_cancelling + +## イベント・ストリーム・タイミング + +::: asyncio_util.RepeatedEvent + +::: asyncio_util.MulticastQueue + +::: asyncio_util.periodic + +::: asyncio_util.azip + +::: asyncio_util.azip_longest + +::: asyncio_util.iter_move_on_after + +::: asyncio_util.iter_fail_after diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..84c8482 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,54 @@ +# API Reference + +All public symbols are importable from the top-level `asyncio_util` package: + +```python +from asyncio_util import AsyncValue, compose_values, wait_any, ... +``` + +For trio-util compatibility, `BoolEvent` and `ValueEvent` are provided as +aliases of `AsyncBool` and `AsyncValue`. + +## Awaitable values + +::: asyncio_util.AsyncValue + +::: asyncio_util.AsyncBool + +::: asyncio_util.compose_values + +::: asyncio_util.open_held_for + +::: asyncio_util.open_hysteresis + +## Tasks and cancellation + +::: asyncio_util.wait_any + +::: asyncio_util.wait_all + +::: asyncio_util.wait_any_map + +::: asyncio_util.move_on_when + +::: asyncio_util.CancelScope + +::: asyncio_util.run_and_cancelling + +::: asyncio_util.start_and_cancelling + +## Events, streams and timing + +::: asyncio_util.RepeatedEvent + +::: asyncio_util.MulticastQueue + +::: asyncio_util.periodic + +::: asyncio_util.azip + +::: asyncio_util.azip_longest + +::: asyncio_util.iter_move_on_after + +::: asyncio_util.iter_fail_after diff --git a/docs/getting-started.ja.md b/docs/getting-started.ja.md new file mode 100644 index 0000000..2e5a875 --- /dev/null +++ b/docs/getting-started.ja.md @@ -0,0 +1,131 @@ +# はじめに + +## 動作要件 + +- Python 3.10 以降(CPython または PyPy) +- サードパーティ依存なし + +## インストール + +!!! warning + PyPI にはまだ公開されていません。GitHub からインストールしてください。 + +```console +pip install git+https://github.com/jrfk/asyncio-util +``` + +[uv](https://docs.astral.sh/uv/) を使う場合: + +```console +uv add git+https://github.com/jrfk/asyncio-util +``` + +## はじめての AsyncValue + +[`AsyncValue`][asyncio_util.AsyncValue] は初期値をラップします。読み書きは +`value` プロパティを通して、ふつうの属性と同じように行います。 + +```python +from asyncio_util import AsyncValue + +score = AsyncValue(0) +print(score.value) # 0 +score.value = 10 +print(score.value) # 10 +``` + +ふつうの属性との違いは、他のタスクが **await できる**ことです。 + +```python +import asyncio +from asyncio_util import AsyncValue + +score = AsyncValue(0) + +async def announcer(): + value = await score.wait_value(lambda v: v >= 100) + print(f"{value} に到達!") + +async def game(): + for points in (30, 40, 50): + await asyncio.sleep(0.1) + score.value += points # プロパティは累算代入にも対応 + +async def main(): + await asyncio.gather(announcer(), game()) + +asyncio.run(main()) +``` + +出力: + +```text +120 に到達! +``` + +ここにポーリングはありません。`announcer()` は `score.value += points` の代入で +述語が真になるまで眠っていて、条件を満たしたまさにその値を受け取ります。 + +## 値と述語 + +待機系の API はすべて、**ふつうの値**(`==` で比較)と**述語関数**の +どちらでも受け付けます。 + +```python +await score.wait_value(100) # value == 100 +await score.wait_value(lambda v: v >= 100) # predicate(value) が True +``` + +!!! tip "None を待つ" + `None` も他の値と同じように扱われます。 + `await av.wait_value(None)` は値が `None` になるまで待ちます。 + +## タイムアウト + +`wait_value()` と `wait_transition()` は `timeout` 引数を受け取り、期限切れで +`asyncio.TimeoutError` を送出します。 + +```python +try: + await score.wait_value(100, timeout=1.0) +except asyncio.TimeoutError: + print("タイムアウト!") +``` + +標準ライブラリのイディオムとの組み合わせも同様に機能します。コードベースの +流儀に合わせてください。 + +```python +await asyncio.wait_for(score.wait_value(100), timeout=1.0) # 3.10+ + +async with asyncio.timeout(1.0): # 3.11+ + await score.wait_value(100) +``` + +タイムアウトなどで待機がキャンセルされた場合、内部のウェイターは即座に +片付けられます。リークはありません。 + +## 等値性について + +現在の値と等しい(`==`)値の代入は**何もしません**。ウェイターは起きず、 +遷移としても記録されません。この重複排除があるおかげで、派生値 +([派生と合成](guide/deriving.md)を参照)を低コストに保てます。 + +## スレッド安全性 + +`AsyncValue` は**スレッドセーフではありません**。`value` への代入は +イベントループが動いているスレッドからのみ行ってください。別スレッドからは、 +まずループに乗せます。 + +```python +loop.call_soon_threadsafe(setattr, av, "value", 42) +``` + +## 次のステップ + +- [値を待つ](guide/waiting.md) — `wait_value`・`wait_transition`・`held_for` の + 詳細。 +- [変化をイテレートする](guide/iterating.md) — 値をストリームとして消費する。 +- [派生と合成](guide/deriving.md) — 値から値を組み立てる。 +- [タスクとキャンセル](guide/tasks.md) — `AsyncValue` 以外の構造化ヘルパー。 +- [イベント・ストリーム・タイミング](guide/streams.md) — 残りのツール群。 diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..dc52370 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,132 @@ +# Getting Started + +## Requirements + +- Python 3.10 or later (CPython or PyPy) +- No third-party dependencies + +## Installation + +!!! warning + Not yet published to PyPI. Install from GitHub: + +```console +pip install git+https://github.com/jrfk/asyncio-util +``` + +or with [uv](https://docs.astral.sh/uv/): + +```console +uv add git+https://github.com/jrfk/asyncio-util +``` + +## Your first AsyncValue + +An [`AsyncValue`][asyncio_util.AsyncValue] wraps an initial value. Read and +write it through the `value` property like a normal attribute: + +```python +from asyncio_util import AsyncValue + +score = AsyncValue(0) +print(score.value) # 0 +score.value = 10 +print(score.value) # 10 +``` + +The difference from a plain attribute: other tasks can **await** it. + +```python +import asyncio +from asyncio_util import AsyncValue + +score = AsyncValue(0) + +async def announcer(): + value = await score.wait_value(lambda v: v >= 100) + print(f"reached {value}!") + +async def game(): + for points in (30, 40, 50): + await asyncio.sleep(0.1) + score.value += points # property supports augmented assignment + +async def main(): + await asyncio.gather(announcer(), game()) + +asyncio.run(main()) +``` + +Output: + +```text +reached 120! +``` + +There is no polling here: `announcer()` sleeps until the assignment +`score.value += points` makes the predicate true, and it receives the exact +value that satisfied it. + +## Values vs. predicates + +Every waiting API accepts either a **plain value** (compared with `==`) or a +**predicate function**: + +```python +await score.wait_value(100) # value == 100 +await score.wait_value(lambda v: v >= 100) # predicate(value) is True +``` + +!!! tip "Waiting for `None`" + A plain `None` is treated as a value like any other: + `await av.wait_value(None)` waits until the value is `None`. + +## Timeouts + +`wait_value()` and `wait_transition()` accept a `timeout` argument and raise +`asyncio.TimeoutError` when it expires: + +```python +try: + await score.wait_value(100, timeout=1.0) +except asyncio.TimeoutError: + print("timeout!") +``` + +The standard library idioms compose just as well — use whichever your +codebase prefers: + +```python +await asyncio.wait_for(score.wait_value(100), timeout=1.0) # 3.10+ + +async with asyncio.timeout(1.0): # 3.11+ + await score.wait_value(100) +``` + +When a wait is cancelled — by a timeout or otherwise — its internal waiter is +cleaned up immediately; nothing leaks. + +## A note on equality + +Assigning a value that compares equal (`==`) to the current one is a **no-op**: +no waiter wakes up and no transition is recorded. This deduplication is what +makes derived values (see [Deriving and composing](guide/deriving.md)) cheap. + +## Thread safety + +`AsyncValue` is **not thread-safe**. Assign `value` only from the thread +running the event loop. From another thread, hop onto the loop first: + +```python +loop.call_soon_threadsafe(setattr, av, "value", 42) +``` + +## Next steps + +- [Waiting for values](guide/waiting.md) — the full story on `wait_value`, + `wait_transition` and `held_for`. +- [Iterating over changes](guide/iterating.md) — consuming values as streams. +- [Deriving and composing](guide/deriving.md) — building values out of values. +- [Tasks and cancellation](guide/tasks.md) — structured helpers beyond + `AsyncValue`. +- [Events, streams and timing](guide/streams.md) — the rest of the toolbox. diff --git a/docs/guide/deriving.ja.md b/docs/guide/deriving.ja.md new file mode 100644 index 0000000..3ee3aa6 --- /dev/null +++ b/docs/guide/deriving.ja.md @@ -0,0 +1,156 @@ +# 派生と合成 + +現実のシステムが見張るのは、生の値 1 つではありません。値の*関数* +(「華氏温度が 100 を超えたか?」)や、値の*組み合わせ*(「ロボットはドックに +いて、**かつ**充電済みか?」)です。`asyncio-util` はその両方に加えて、 +バタつく信号のための時間ベースのフィルタを 2 つ提供します。 + +## open_transform — 値から値を派生させる + +[`open_transform()`][asyncio_util.AsyncValue.open_transform] は、常に +`f(source.value)` と等しい新しい `AsyncValue` を返します。 + +```python +celsius = AsyncValue(25.0) + +async with celsius.open_transform(lambda c: c * 9 / 5 + 32) as fahrenheit: + assert fahrenheit.value == 77.0 + + # 派生値は完全な AsyncValue — await もイテレートもできる: + await fahrenheit.wait_value(lambda f: f > 100) +``` + +重要な性質: + +- 更新は**同期的**です。`celsius.value = x` が返った時点で + `fahrenheit.value` はすでに `f(x)` になっています。2 つの値が食い違う + 瞬間はありません。 +- 更新は派生値自身のセッターを通るため、**重複排除**されます。`f` が + 2 つの入力を同じ出力に写すなら、派生値に遷移は発生しません。 + + ```python + parity = AsyncValue(1) + async with parity.open_transform(lambda x: x % 2) as is_odd: + parity.value = 3 # 1 -> 3。しかし 3 % 2 == 1 なので is_odd は変化しない + ``` + +- **同じ関数オブジェクト**での `open_transform` は 1 つの派生値を共有します + (内部で参照カウント管理)。 +- `async with` ブロックを抜けると派生値は切り離されます。最後の値は保持 + しますが、追跡元にはもう追随しません。 + +!!! note "変換関数について" + 述語と同じく、変換関数は追跡元のセッターの中で実行されます。速く、純粋に、 + 例外を投げないように保ってください。もし例外を投げた場合、他のすべての + ウェイターへの通知が済んだ後で、`value` を代入したコードに伝播します。 + +## compose_values — 複数の値を合成する + +[`compose_values()`][asyncio_util.compose_values] は、任意の数の +`AsyncValue` を 1 つにまとめます。合成後の値は**名前付きタプル**で、 +どの入力が変化しても更新されます。 + +```python +from asyncio_util import AsyncValue, compose_values + +docked = AsyncValue(False) +battery_percent = AsyncValue(15) + +with compose_values(docked=docked, battery=battery_percent) as status: + # status.value は namedtuple: (docked=False, battery=15) + print(status.value.docked, status.value.battery) + + # 両方にまたがる条件を await する: + await status.wait_value(lambda s: s.docked and s.battery >= 90) +``` + +これが「A **かつ** B になるまで待つ」への明快な答えです。イベントの手動 +振り付けも、再チェックのループも不要 — 1 つの合成値に対する 1 つの述語だけです。 + +`_transform_` を渡すと、合成タプルを一段で別の値へ写像できます。 + +```python +with compose_values(_transform_=lambda s: s.x + s.y, x=x, y=y) as total: + await total.wait_value(lambda t: t > 100) +``` + +合成値は `AsyncValue` *そのもの*なので、前の章までの内容がすべて適用できます。 + +```python +# どちらかの入力が変わるたびに反応する: +async for snapshot in status.eventual_values(): + update_dashboard(snapshot) + +# 組み合わせに対するエッジ検出: +await status.wait_transition( + lambda new, old: new.docked and not old.docked +) +``` + +## AsyncBool — ブール値のショートハンド + +[`AsyncBool`][asyncio_util.AsyncBool] は、デフォルトが `False` の +`AsyncValue[bool]` です。フラグに便利です。 + +```python +from asyncio_util import AsyncBool + +ready = AsyncBool() # False で開始 +await ready.wait_value(True) +``` + +(trio-util 互換のため、`BoolEvent` と `ValueEvent` が `AsyncBool` と +`AsyncValue` のエイリアスとして提供されています。) + +## open_held_for — 「安定したか?」 + +[`open_held_for()`][asyncio_util.open_held_for] は*任意の* `AsyncValue` を +監視し、ソースが `duration` 秒間変化していない間 True になる `AsyncBool` を +返します。 + +```python +from asyncio_util import AsyncValue, open_held_for + +position = AsyncValue((0.0, 0.0)) + +async with open_held_for(position, duration=2.0) as settled: + await settled.wait_value(True) + print("位置が 2 秒間安定しました") +``` + +ソースが変化するたびに出力は False に戻り、タイマーは振り出しに戻ります。 + +## open_hysteresis — ブール値のバタつき除去 + +[`open_hysteresis()`][asyncio_util.open_hysteresis] はブールの `AsyncValue` を +フィルタし、短いブリップが伝播しないようにします。出力は、入力が +`rising_duration` 秒間 True であり続けたときにのみ立ち上がり、 +`falling_duration` 秒間 False であり続けたときにのみ立ち下がります。 + +```python +from asyncio_util import AsyncBool, open_hysteresis + +link_up = AsyncBool() + +async with open_hysteresis(link_up, rising_duration=1.0, + falling_duration=5.0) as stable_link: + async for is_up, _ in stable_link.transitions(): + print("リンク", "回復" if is_up else "喪失") +``` + +200 ms のネットワークブリップは `stable_link` には届きません。 + +!!! note "バックグラウンドタスク" + `open_held_for` と `open_hysteresis` は、`async with` ブロックが開いて + いる間だけ小さなバックグラウンドタスクを走らせます。ブロックを抜けると + キャンセルされ、終了を待ってから戻ります。 + +## 使い分け + +| やりたいこと | 使うもの | +| --- | --- | +| 1 つのソースの `f(value)` を見張る | `open_transform` | +| 複数のソースにまたがる条件を await する | `compose_values` | +| 両方 — 例: `f(a, b)` | `compose_values` + `_transform_` | +| 値が落ち着いたことを知る | `open_held_for` | +| フラグの短いブリップを抑制する | `open_hysteresis` | diff --git a/docs/guide/deriving.md b/docs/guide/deriving.md new file mode 100644 index 0000000..5f8ce4a --- /dev/null +++ b/docs/guide/deriving.md @@ -0,0 +1,158 @@ +# Deriving and composing + +Real systems rarely watch one raw value. They watch *functions* of values +("is the temperature in °F above 100?") and *combinations* of values ("is the +robot docked **and** charged?"). `asyncio-util` covers both, plus two +time-based filters for flappy signals. + +## open_transform — derive one value from another + +[`open_transform()`][asyncio_util.AsyncValue.open_transform] gives you a new +`AsyncValue` that always equals `f(source.value)`: + +```python +celsius = AsyncValue(25.0) + +async with celsius.open_transform(lambda c: c * 9 / 5 + 32) as fahrenheit: + assert fahrenheit.value == 77.0 + + # The derived value is a full AsyncValue — await it, iterate it: + await fahrenheit.wait_value(lambda f: f > 100) +``` + +Key properties: + +- Updates are **synchronous**: by the time `celsius.value = x` returns, + `fahrenheit.value` is already `f(x)`. There is no window where the two + disagree. +- Updates are **deduplicated** through the derived value's own setter. If + `f` maps two inputs to the same output, the derived value does not + produce a transition: + + ```python + parity = AsyncValue(1) + async with parity.open_transform(lambda x: x % 2) as is_odd: + parity.value = 3 # 1 -> 3, but 3 % 2 == 1: no change on is_odd + ``` + +- Two `open_transform` calls with the **same function object** share one + derived value (reference-counted internally). +- Leaving the `async with` block disconnects the derived value. It keeps its + last value but no longer follows the source. + +!!! note "Transform functions" + Like predicates, transform functions run inside the source's setter: + keep them fast, pure, and non-raising. If one does raise, the exception + propagates to the code assigning `value` — after all other waiters have + been notified. + +## compose_values — combine several values + +[`compose_values()`][asyncio_util.compose_values] merges any number of +`AsyncValue`s into one whose value is a **named tuple**, updated whenever any +input changes: + +```python +from asyncio_util import AsyncValue, compose_values + +docked = AsyncValue(False) +battery_percent = AsyncValue(15) + +with compose_values(docked=docked, battery=battery_percent) as status: + # status.value is a namedtuple: (docked=False, battery=15) + print(status.value.docked, status.value.battery) + + # Await a condition that spans both: + await status.wait_value(lambda s: s.docked and s.battery >= 90) +``` + +This is the clean answer to "wait until A **and** B": no manual event +choreography, no re-check loops — one predicate over one composite value. + +Pass `_transform_` to map the composite tuple to something else in one step: + +```python +with compose_values(_transform_=lambda s: s.x + s.y, x=x, y=y) as total: + await total.wait_value(lambda t: t > 100) +``` + +Everything from the previous chapters applies to the composite, because it +*is* an `AsyncValue`: + +```python +# React to any change of either input: +async for snapshot in status.eventual_values(): + update_dashboard(snapshot) + +# Edge-detect on the combination: +await status.wait_transition( + lambda new, old: new.docked and not old.docked +) +``` + +## AsyncBool — boolean shorthand + +[`AsyncBool`][asyncio_util.AsyncBool] is simply `AsyncValue[bool]` with a +`False` default — handy for flags: + +```python +from asyncio_util import AsyncBool + +ready = AsyncBool() # starts False +await ready.wait_value(True) +``` + +(For trio-util compatibility, `BoolEvent` and `ValueEvent` are provided as +aliases of `AsyncBool` and `AsyncValue`.) + +## open_held_for — "has it been stable?" + +[`open_held_for()`][asyncio_util.open_held_for] watches *any* `AsyncValue` +and yields an `AsyncBool` that is True while the source has not changed for +`duration` seconds: + +```python +from asyncio_util import AsyncValue, open_held_for + +position = AsyncValue((0.0, 0.0)) + +async with open_held_for(position, duration=2.0) as settled: + await settled.wait_value(True) + print("position has been stable for 2 seconds") +``` + +Any change of the source resets the output to False and restarts the timer. + +## open_hysteresis — de-flap a boolean + +[`open_hysteresis()`][asyncio_util.open_hysteresis] filters a boolean +`AsyncValue` so that short blips don't propagate: the output only rises after +the input has been True for `rising_duration`, and only falls after it has +been False for `falling_duration`: + +```python +from asyncio_util import AsyncBool, open_hysteresis + +link_up = AsyncBool() + +async with open_hysteresis(link_up, rising_duration=1.0, + falling_duration=5.0) as stable_link: + async for is_up, _ in stable_link.transitions(): + print("link", "recovered" if is_up else "LOST") +``` + +A 200 ms network blip never reaches `stable_link`. + +!!! note "Background tasks" + `open_held_for` and `open_hysteresis` run a small background task while + the `async with` block is open; it is cancelled and awaited on exit. + +## Choosing between them + +| You want to… | Reach for | +| --- | --- | +| watch `f(value)` of one source | `open_transform` | +| await a condition across several sources | `compose_values` | +| both — e.g. `f(a, b)` | `compose_values` with `_transform_` | +| know when a value has settled | `open_held_for` | +| suppress short blips of a flag | `open_hysteresis` | diff --git a/docs/guide/iterating.ja.md b/docs/guide/iterating.ja.md new file mode 100644 index 0000000..6f55e25 --- /dev/null +++ b/docs/guide/iterating.ja.md @@ -0,0 +1,113 @@ +# 変化をイテレートする + +一度だけ待つなら `await`、反応し続けるなら `async for`。`AsyncValue` は、 +意図的にセマンティクスを変えた 2 つの非同期イテレータを提供します。 + +| | [`eventual_values()`][asyncio_util.AsyncValue.eventual_values] | [`transitions()`][asyncio_util.AsyncValue.transitions] | +| --- | --- | --- | +| セマンティクス | レベル — 「値は何か」 | エッジ — 「何が変わったか」 | +| 生成するもの | マッチした値 | マッチした `(new, old)` ペア | +| 現在の値 | (マッチすれば)最初に生成 | 決して生成しない | +| 消費が遅いとき | **最新**の値までスキップ | 途中の変化を**取りこぼす** | +| 典型的な用途 | 状態の描画、目標値への同期 | イベントのログ、エッジのカウント | + +## eventual_values — 状態を追いかける + +`eventual_values()` は、(マッチしていれば)現在の値をまず生成し、以後は +変化のたびにマッチした値を生成します。**最新**の状態だけが重要な場面 — +UI の描画、リコンシリエーションループ、ウォッチドッグ — に最適です。 + +```python +async for state in connection_state.eventual_values(): + redraw_status_icon(state) +``` + +述語や値を渡せば、マッチする状態だけが流れてきます。 + +```python +async for _ in connection_state.eventual_values("disconnected"): + schedule_reconnect() +``` + +### 正確なセマンティクス + +- 同じ値が 2 回連続で生成されることはありません。 +- ループ本体の実行中に値が変化した場合、途中の値はスキップされることが + ありますが、イテレータは**必ず最新のマッチする値に収束します**。古い状態を + 描画したまま止まってしまうことはありません。 +- イテレータが(処理中ではなく)*待機中*にマッチする値が設定された場合、 + その値は捕捉され、直後に値がまた変わっても生成されます。これは + `wait_value()` の「取りこぼさない保証」と一貫しています。 + +```python +av = AsyncValue(0) + +# 消費側が忙しい間に av.value が 1, 2, 3, 4, 5 と変化… +# …次のイテレーションで生成されるのは 5。1〜4 のスキップは仕様です。 +``` + +### ストリームのデバウンス: held_for + +`wait_value()` と同じく、`eventual_values()` も `held_for` を受け取ります。 +値は、指定した秒数だけ連続してマッチし続けたときにのみ生成されます。 +保持期間内に述語を出入りするような値はスキップされます。 + +```python +# しきい値超えが 2 秒間続いた読み値だけを受け取る。 +async for reading in sensor.eventual_values(lambda v: v > 100, held_for=2.0): + trigger_alarm(reading) +``` + +## transitions — 変化を追いかける + +`transitions()` はマッチした変化ごとに `(value, old_value)` を生成します。 +結果の状態ではなく*イベント*そのものが重要なときに使います。 + +```python +async for new, old in av.transitions(lambda new, old: new < old): + logger.warning("値が %s から %s に低下", old, new) +``` + +### 取りこぼされる遷移 + +`transitions()` がエッジを報告するのは**実際に待機している間だけ**です。 +ループ本体が前の変化を処理している間に起きた変化は失われます。エッジは +キューイングされません。 + +```python +async for new, old in av.transitions(): + await slow_handler(new) # この await の間の変化は失われる +``` + +!!! warning "すべてのエッジが必要ならキューを使う" + これは「現在の状態」モデルに固有の性質です。`AsyncValue` が保持するのは + 1 つの値であって、履歴ではありません。*すべての*変化を処理する必要が + あるなら、プロデューサ側で `asyncio.Queue` に積んでください。 + + ```python + queue: asyncio.Queue[int] = asyncio.Queue() + + def set_value(v: int) -> None: + av.value = v + queue.put_nowait(v) # 明示的な履歴 + ``` + +## イテレーションの終了 + +どちらのイテレータも無限です。通常の非同期イテレーションと同じ方法で +終了してください。 + +- ループから `break` する +- 消費しているタスクをキャンセルする +- ループをタイムアウトスコープで包む + +いずれの場合も後片付けは自動です。イテレータが中断・クローズ・キャンセル +された瞬間にウェイターは除去されます。 + +```python +async def watch(av: AsyncValue[str]) -> None: + async for state in av.eventual_values(): + if state == "shutdown": + break + await apply(state) +``` diff --git a/docs/guide/iterating.md b/docs/guide/iterating.md new file mode 100644 index 0000000..27492bc --- /dev/null +++ b/docs/guide/iterating.md @@ -0,0 +1,112 @@ +# Iterating over changes + +Waiting once is `await`; reacting continuously is `async for`. `AsyncValue` +offers two async iterators with deliberately different semantics: + +| | [`eventual_values()`][asyncio_util.AsyncValue.eventual_values] | [`transitions()`][asyncio_util.AsyncValue.transitions] | +| --- | --- | --- | +| Semantics | level — "what is the value" | edge — "what changed" | +| Yields | matching values | matching `(new, old)` pairs | +| Current value | yielded first (if matching) | never yielded | +| Slow consumer | skips ahead to the **latest** value | **misses** intermediate changes | +| Typical use | render state, sync to a target | log events, count edges | + +## eventual_values — follow the state + +`eventual_values()` yields the current value (if it matches) and then each +matching value as it changes. It is ideal when only the **latest** state +matters — UI rendering, reconciliation loops, watchdogs: + +```python +async for state in connection_state.eventual_values(): + redraw_status_icon(state) +``` + +With a predicate or value, only matching states come through: + +```python +async for _ in connection_state.eventual_values("disconnected"): + schedule_reconnect() +``` + +### Exact semantics + +- The same value is never yielded twice in a row. +- If the value changes while the loop body is running, intermediate values + may be skipped, but the iterator **always converges to the latest matching + value**. You cannot end up rendering a stale state forever. +- If a matching value is set while the iterator is *waiting* (not busy), it + is captured and will be yielded even if the value immediately changes + again — consistent with `wait_value()`'s no-miss guarantee. + +```python +av = AsyncValue(0) + +# Consumer busy while av.value goes 1, 2, 3, 4, 5... +# ...the next iteration yields 5. The 1-4 are skipped by design. +``` + +### Debouncing the stream: held_for + +Like `wait_value()`, `eventual_values()` accepts `held_for`: a value is only +yielded once it has matched continuously for that many seconds. Values that +flicker in and out of the predicate within the hold period are skipped: + +```python +# Only see readings that stayed over the threshold for 2 seconds. +async for reading in sensor.eventual_values(lambda v: v > 100, held_for=2.0): + trigger_alarm(reading) +``` + +## transitions — follow the changes + +`transitions()` yields `(value, old_value)` for each matching change. Use it +when the *event* matters, not just the resulting state: + +```python +async for new, old in av.transitions(lambda new, old: new < old): + logger.warning("value dropped from %s to %s", old, new) +``` + +### Missed transitions + +`transitions()` reports edges **only while it is actually waiting**. A change +that happens while your loop body is still processing the previous one is +gone — edges are not queued: + +```python +async for new, old in av.transitions(): + await slow_handler(new) # a change during this await is missed +``` + +!!! warning "If every edge matters, use a queue" + This is inherent to the "current state" model — an `AsyncValue` stores + one value, not a history. When you must process *every* change, push + them into an `asyncio.Queue` at the producer side: + + ```python + queue: asyncio.Queue[int] = asyncio.Queue() + + def set_value(v: int) -> None: + av.value = v + queue.put_nowait(v) # explicit history + ``` + +## Ending iteration + +Both iterators are infinite. End them the way you end any async iteration: + +- `break` out of the loop, +- cancel the consuming task, or +- wrap the loop with a timeout scope. + +Cleanup is automatic in all cases — waiters are removed the moment the +iterator is suspended, closed, or its task is cancelled. + +```python +async def watch(av: AsyncValue[str]) -> None: + async for state in av.eventual_values(): + if state == "shutdown": + break + await apply(state) +``` diff --git a/docs/guide/streams.ja.md b/docs/guide/streams.ja.md new file mode 100644 index 0000000..11c8aeb --- /dev/null +++ b/docs/guide/streams.ja.md @@ -0,0 +1,137 @@ +# イベント・ストリーム・タイミング + +await できる値のほかにも、`asyncio-util` は繰り返しイベント、ブロードキャスト、 +周期実行、複数の非同期ソースのイテレーションのための小さなツール群を +提供します。 + +## RepeatedEvent — 何度も発火するイベント + +`asyncio.Event` はワンショットです。一度 set されると、誰かが `clear()` を +忘れずに呼ぶまですべての `wait()` が即座に返ります — 典型的なレースの温床 +です。[`RepeatedEvent`][asyncio_util.RepeatedEvent] は「(また)何かが起きた」 +パターンを直接モデル化します。 + +```python +from asyncio_util import RepeatedEvent + +changed = RepeatedEvent() + +# プロデューサ側: +changed.set() # 発火。何度でも呼べる + +# コンシューマ側 — この時点以降の次の発火を待つ: +await changed.wait() +``` + +イテレーションは 2 スタイルあり、[`eventual_values` と `transitions`](iterating.md) +の関係に対応します。 + +```python +# 「少なくとも 1 回発火した」ことを見逃さない(結果整合性): +async for _ in changed.events(): + await rebuild_index() + +# 実際に待っている間だけ反応。処理中の発火は破棄される: +async for _ in changed.unqueued_events(): + await redraw() +``` + +`events(repeat_last=True)` は、開始時に現在の状態に対しても 1 回 yield +します。起動時に現状を処理したいコンシューマに便利です。 + +## MulticastQueue — 全リスナーへブロードキャスト + +`asyncio.Queue` は各アイテムを *1 つ*のコンシューマに届けます。 +[`MulticastQueue`][asyncio_util.MulticastQueue] は各アイテムを +**すべてのアクティブなリスナー**に届けます。 + +```python +from asyncio_util import MulticastQueue + +mq: MulticastQueue[str] = MulticastQueue() + +async def subscriber(name: str): + async with mq.listen() as items: + async for item in items: + print(name, "が受信:", item) + +# 別の場所で: +await mq.broadcast("hello") +``` + +- リスナーが受け取るのは、**購読している間**(`listen()` の中)に + ブロードキャストされたアイテムだけです。 +- リスナーが購読をやめると、イテレーションはきれいに終了します。 + +!!! warning "損失は仕様です" + 各リスナーは有限のバッファ(`queue_size`、デフォルト 10)を持ちます。 + リスナーが遅れてバッファが埋まると、*そのリスナー向けの*新しいアイテムは + 静かに破棄されます。最も遅いコンシューマに合わせてバッファを + サイジングするか、より速く消費してください。 + +## periodic — ドリフトしない周期実行 + +ループ内で `period` 秒 sleep する方式はドリフトします。毎回、本体の実行時間が +スケジュールに加算されていくからです。[`periodic()`][asyncio_util.periodic] +は代わりに時計上の固定点を狙い、実際のタイミングも教えてくれます。 + +```python +from asyncio_util import periodic + +async for elapsed, delta in periodic(1.0): + print(f"開始から {elapsed:.1f} 秒、前回から {delta} 秒") + await poll_sensors() +``` + +- `elapsed` — ループ開始からの秒数。`delta` — 前回のイテレーションからの + 秒数(初回は `None`)。 +- 本体が period を超過した場合、次のイテレーションはほぼ即座に、次の空き + グリッドスロットで始まります。取り逃したスロットは「積み残し」に + なりません。 + +## azip / azip_longest — 非同期イテレータの zip + +[`azip()`][asyncio_util.azip] は複数の非同期イテレータを**並行に** +(順番にではなく)進め、結果のタプルを yield します。 + +```python +from asyncio_util import azip, azip_longest + +async for left, right in azip(sensor_a.eventual_values(), + sensor_b.eventual_values()): + compare(left, right) +``` + +`azip` は最短のソースで停止します。 +[`azip_longest()`][asyncio_util.azip_longest] はすべてのソースが尽きるまで +続行し、終了したソースには `fillvalue`(デフォルト `None`)を補います。 + +## iter_move_on_after / iter_fail_after — アイテム単位のタイムアウト + +任意の非同期イテレータに対して、**アイテム間**の待ち時間を制限します。 + +```python +from asyncio_util import iter_move_on_after, iter_fail_after + +# 1 秒以内にアイテムが来なければ静かにイテレーションを終える: +async for msg in iter_move_on_after(1.0, message_stream): + handle(msg) + +# あるいはストールをエラーにする: +async for msg in iter_fail_after(1.0, message_stream): # asyncio.TimeoutError + handle(msg) +``` + +タイムアウトは各 `__anext__` 呼び出しに適用されます。イテレーション全体 +ではありません。 + +## 使い分け + +| やりたいこと | 使うもの | +| --- | --- | +| タスク間で「(また)起きた」を通知する | `RepeatedEvent` | +| 1 つのストリームを複数コンシューマに配る | `MulticastQueue` | +| N 秒ごとにドリフトなしで実行する | `periodic` | +| 複数の非同期ソースを足並みを揃えて消費する | `azip` / `azip_longest` | +| ストールしたストリームを打ち切る(または失敗させる) | `iter_move_on_after` / `iter_fail_after` | +| イベントだけでなく*状態*を運ぶ | [`AsyncValue`](waiting.md) | diff --git a/docs/guide/streams.md b/docs/guide/streams.md new file mode 100644 index 0000000..dff6583 --- /dev/null +++ b/docs/guide/streams.md @@ -0,0 +1,134 @@ +# Events, streams and timing + +Beyond awaitable values, `asyncio-util` ships a small toolbox for recurring +events, broadcasting, periodic work, and iterating multiple async sources. + +## RepeatedEvent — an event that fires many times + +`asyncio.Event` is one-shot: once set, every `wait()` returns immediately +until someone remembers to `clear()` it — a classic source of races. +[`RepeatedEvent`][asyncio_util.RepeatedEvent] models the "something happened +(again)" pattern directly: + +```python +from asyncio_util import RepeatedEvent + +changed = RepeatedEvent() + +# Producer: +changed.set() # fire; call as often as you like + +# Consumer — wait for the next firing after this point: +await changed.wait() +``` + +Two iteration styles, mirroring [`eventual_values` vs `transitions`](iterating.md): + +```python +# Never miss "there was at least one firing" (eventual consistency): +async for _ in changed.events(): + await rebuild_index() + +# Only react while actually waiting; firings during the body are dropped: +async for _ in changed.unqueued_events(): + await redraw() +``` + +`events(repeat_last=True)` additionally yields once immediately — useful when +the consumer wants to process the current state on startup. + +## MulticastQueue — broadcast to every listener + +An `asyncio.Queue` delivers each item to *one* consumer. +[`MulticastQueue`][asyncio_util.MulticastQueue] delivers each item to +**every active listener**: + +```python +from asyncio_util import MulticastQueue + +mq: MulticastQueue[str] = MulticastQueue() + +async def subscriber(name: str): + async with mq.listen() as items: + async for item in items: + print(name, "got", item) + +# elsewhere: +await mq.broadcast("hello") +``` + +- Listeners only receive items broadcast **while they are subscribed** + (inside `listen()`). +- Iteration ends cleanly when the listener unsubscribes. + +!!! warning "Lossy by design" + Each listener has a bounded buffer (`queue_size`, default 10). If a + listener falls behind and its buffer fills up, new items for *that + listener* are silently dropped. Size the buffer for your slowest + consumer, or drain faster. + +## periodic — drift-free intervals + +Sleeping `period` seconds in a loop drifts: each iteration adds the body's +runtime to the schedule. [`periodic()`][asyncio_util.periodic] targets fixed +points on the clock instead and tells you the actual timing: + +```python +from asyncio_util import periodic + +async for elapsed, delta in periodic(1.0): + print(f"{elapsed:.1f}s since start, {delta}s since last") + await poll_sensors() +``` + +- `elapsed` — seconds since the loop started; `delta` — seconds since the + previous iteration (`None` on the first). +- If the body overruns the period, the next iteration starts immediately — + overruns are not "queued up". + +## azip / azip_longest — zip for async iterators + +[`azip()`][asyncio_util.azip] advances several async iterators +**concurrently** (not one after the other) and yields result tuples: + +```python +from asyncio_util import azip, azip_longest + +async for left, right in azip(sensor_a.eventual_values(), + sensor_b.eventual_values()): + compare(left, right) +``` + +`azip` stops at the shortest source; +[`azip_longest()`][asyncio_util.azip_longest] continues until all are +exhausted, substituting `fillvalue` (default `None`) for finished ones. + +## iter_move_on_after / iter_fail_after — per-item timeouts + +Bound how long you are willing to wait **between items** of any async +iterator: + +```python +from asyncio_util import iter_move_on_after, iter_fail_after + +# Stop iterating silently if no item arrives within 1s: +async for msg in iter_move_on_after(1.0, message_stream): + handle(msg) + +# Or make a stall an error: +async for msg in iter_fail_after(1.0, message_stream): # asyncio.TimeoutError + handle(msg) +``` + +The timeout applies to each `__anext__` call, not to the whole iteration. + +## Choosing between them + +| You want to… | Reach for | +| --- | --- | +| signal "it happened (again)" between tasks | `RepeatedEvent` | +| fan one stream out to many consumers | `MulticastQueue` | +| run something every N seconds without drift | `periodic` | +| consume several async sources in lockstep | `azip` / `azip_longest` | +| give up (or fail) on a stalled stream | `iter_move_on_after` / `iter_fail_after` | +| carry *state*, not just events | [`AsyncValue`](waiting.md) | diff --git a/docs/guide/tasks.ja.md b/docs/guide/tasks.ja.md new file mode 100644 index 0000000..4c4e606 --- /dev/null +++ b/docs/guide/tasks.ja.md @@ -0,0 +1,144 @@ +# タスクとキャンセル + +asyncio には `create_task`・`gather`・`wait` があります。強力ですが、タスクを +リークさせやすい道具でもあります。ここで紹介するヘルパーは、よくあるパターンを +*構造化*された形に包みます。ヘルパーが開始したタスクは、制御が戻る前に必ず +キャンセルされ、終了まで待たれます。 + +いずれも**引数なしの async callable**(コルーチンオブジェクトではなく)を +受け取ります。引数が必要なら `functools.partial` か `lambda` を使って +ください。 + +```python +from functools import partial + +await wait_any(partial(fetch, url1), partial(fetch, url2)) +``` + +## wait_any — 最初の 1 つが勝つ + +[`wait_any()`][asyncio_util.wait_any] は callable を並行実行し、**最初**の +完了で戻ります。まだ走っているものはすべてキャンセルされ、終了を待たれます。 + +```python +from asyncio_util import wait_any + +await wait_any( + lambda: user_pressed_cancel.wait_value(True), + lambda: job.wait_value("done"), +) +``` + +勝者が例外を投げていた場合、その例外が伝播します。 + +## wait_all — 全員が完了する + +[`wait_all()`][asyncio_util.wait_all] は**すべて**の callable が完了したときに +戻ります。どれかが例外を投げると、残りはキャンセルされ、例外が伝播します。 + +```python +from asyncio_util import wait_all + +await wait_all(sync_database, sync_cache, sync_search_index) +``` + +## wait_any_map — どれが、何を返して勝ったのか + +[`wait_any_map()`][asyncio_util.wait_any_map] は、*どの*ブランチが完了し、 +何を返したのかを知りたいときの `wait_any` です。キーワード引数でブランチに +名前を付けると、結果は namedtuple で返り、完了したブランチだけが +非 `None` になります。 + +```python +from asyncio_util import wait_any_map + +result = await wait_any_map( + timeout=lambda: asyncio.sleep(5), + value=lambda: av.wait_value(lambda v: v > 10), +) +if result.value is not None: + process(result.value) +else: + print("タイムアウトしました") +``` + +## move_on_when — シグナルでブロックをキャンセル + +[`move_on_when()`][asyncio_util.move_on_when] は、*トリガー*コルーチンが +完了するまでブロックを実行し、完了したらブロックをキャンセルします。 +`asyncio.timeout()` がブロックを*時間*で区切るのに対し、`move_on_when` は +**イベント**で区切ります。 + +```python +from asyncio_util import move_on_when + +async with move_on_when(shutdown_requested.wait_value, True) as scope: + await serve_forever() + +if scope.cancelled_caught: + print("シャットダウン要求により中断されました") +``` + +- yield される [`CancelScope`][asyncio_util.CancelScope] の + `cancelled_caught` で、本体が自力で完了したのかトリガーに中断されたのかを + 判別できます。 +- トリガーは、本体が最初の `await` に到達した時点で走り始めます。 +- トリガー自体が例外を投げた場合、本体はキャンセルされ、トリガーの例外が + ブロックから再送出されます。 + +!!! warning "asyncio の制約" + トリガーの発火と、外部からのタスクキャンセルが同じ瞬間に起きた場合、 + 外部キャンセルがトリガー起因と区別できず、握りつぶされる可能性が + あります。これは asyncio の単一種別キャンセルに固有の制約です + (trio はランタイムレベルのキャンセルスコープで解決しています)。 + `move_on_when` の本体は小さく保ってください。 + +## run_and_cancelling — ブロックにスコープされたバックグラウンドタスク + +[`run_and_cancelling()`][asyncio_util.run_and_cancelling] は、ブロックの +実行中だけバックグラウンドタスクを走らせます。`move_on_when` の逆です。 + +```python +from asyncio_util import run_and_cancelling + +async with run_and_cancelling(heartbeat, interval=1.0): + await do_migration() +# ここで heartbeat はキャンセルされ、終了を待たれる +``` + +本体の実行中にバックグラウンドタスクが失敗しても、本体は中断されません。 +失敗はブロックを抜けるときに再送出されます(本体自身の例外があれば +そちらが優先されます)。 + +## start_and_cancelling — タスクの準備完了を待つ + +[`start_and_cancelling()`][asyncio_util.start_and_cancelling] は、起動 +フェーズが必要なバックグラウンドタスクのための `run_and_cancelling` です。 +callable は `task_status` キーワードを受け取り、準備ができたら +`task_status.set()` を呼びます。本体はそれまで開始されません。 + +```python +from asyncio_util import start_and_cancelling + +async def server(*, task_status): + listener = await create_listener() + task_status.set() # 準備完了 — 本体が進んでよい + await serve(listener) + +async with start_and_cancelling(server): + await run_client_tests() # サーバーは確実に listen 中 +``` + +準備完了のシグナル前にタスクが失敗した場合は、ブロックに入らず、その場で +例外が送出されます。 + +## 使い分け + +| やりたいこと | 使うもの | +| --- | --- | +| 複数の操作を競争させる | `wait_any` / `wait_any_map` | +| 複数の操作を完走させる | `wait_all` | +| イベント発火で作業を止める | `move_on_when` | +| 一定時間で作業を止める | `asyncio.timeout()`(標準ライブラリ) | +| ブロックの間だけ補助タスクを生かす | `run_and_cancelling` | +| …さらに起動完了を待ってから | `start_and_cancelling` | diff --git a/docs/guide/tasks.md b/docs/guide/tasks.md new file mode 100644 index 0000000..87bfea7 --- /dev/null +++ b/docs/guide/tasks.md @@ -0,0 +1,144 @@ +# Tasks and cancellation + +asyncio gives you `create_task`, `gather` and `wait` — powerful, but easy to +leak tasks with. These helpers wrap the common patterns in *structured* form: +every task they start is cancelled and awaited before control returns to you. + +All of them take **zero-argument async callables** (not coroutine objects), +so pass `functools.partial` or a `lambda` when you need arguments: + +```python +from functools import partial + +await wait_any(partial(fetch, url1), partial(fetch, url2)) +``` + +## wait_any — first one wins + +[`wait_any()`][asyncio_util.wait_any] runs the callables concurrently and +returns when the **first** completes. Everything still running is cancelled +and awaited: + +```python +from asyncio_util import wait_any + +await wait_any( + lambda: user_pressed_cancel.wait_value(True), + lambda: job.wait_value("done"), +) +``` + +If the winner raised, that exception propagates. + +## wait_all — everyone finishes + +[`wait_all()`][asyncio_util.wait_all] returns when **all** callables have +completed. If any raises, the rest are cancelled and the exception +propagates: + +```python +from asyncio_util import wait_all + +await wait_all(sync_database, sync_cache, sync_search_index) +``` + +## wait_any_map — which one won, and with what? + +[`wait_any_map()`][asyncio_util.wait_any_map] is `wait_any` for when you need +to know *which* branch finished and what it returned. Keyword arguments name +the branches; the result is a namedtuple where only the finished branch is +non-`None`: + +```python +from asyncio_util import wait_any_map + +result = await wait_any_map( + timeout=lambda: asyncio.sleep(5), + value=lambda: av.wait_value(lambda v: v > 10), +) +if result.value is not None: + process(result.value) +else: + print("timed out") +``` + +## move_on_when — cancel a block on a signal + +[`move_on_when()`][asyncio_util.move_on_when] runs a block until a *trigger* +coroutine completes, then cancels the block. Where `asyncio.timeout()` +bounds a block by time, `move_on_when` bounds it by an **event**: + +```python +from asyncio_util import move_on_when + +async with move_on_when(shutdown_requested.wait_value, True) as scope: + await serve_forever() + +if scope.cancelled_caught: + print("interrupted by shutdown request") +``` + +- The yielded [`CancelScope`][asyncio_util.CancelScope] records whether the + body was interrupted by the trigger (`cancelled_caught`) as opposed to + finishing on its own. +- The trigger starts running once the body reaches its first `await`. +- If the trigger itself raises, the body is cancelled and the trigger's + exception is re-raised from the block. + +!!! warning "asyncio limitation" + If the trigger fires at the same moment the surrounding task is + cancelled from outside, the external cancellation can be + indistinguishable from the trigger's and may be swallowed. This is + inherent to asyncio's single-flavor cancellation (trio solves it with + runtime-level cancel scopes). Keep `move_on_when` bodies small. + +## run_and_cancelling — a background task scoped to a block + +[`run_and_cancelling()`][asyncio_util.run_and_cancelling] runs a background +task for exactly as long as the block is executing — the inverse of +`move_on_when`: + +```python +from asyncio_util import run_and_cancelling + +async with run_and_cancelling(heartbeat, interval=1.0): + await do_migration() +# heartbeat is cancelled and awaited here +``` + +If the background task fails while the body runs, the body is not +interrupted; the failure is re-raised when the block exits (the body's own +exception, if any, takes precedence). + +## start_and_cancelling — wait until the task is ready + +[`start_and_cancelling()`][asyncio_util.start_and_cancelling] is +`run_and_cancelling` for background tasks that need a startup phase. The +callable receives a `task_status` keyword and calls `task_status.set()` when +it is ready; the block body does not start until then: + +```python +from asyncio_util import start_and_cancelling + +async def server(*, task_status): + listener = await create_listener() + task_status.set() # ready — the body may proceed + await serve(listener) + +async with start_and_cancelling(server): + await run_client_tests() # the server is guaranteed to be listening +``` + +If the task fails before signaling readiness, the exception is raised +immediately instead of entering the block. + +## Choosing between them + +| You want to… | Reach for | +| --- | --- | +| race several operations | `wait_any` / `wait_any_map` | +| run several operations to completion | `wait_all` | +| stop work when an event fires | `move_on_when` | +| stop work after a duration | `asyncio.timeout()` (stdlib) | +| keep a helper task alive during a block | `run_and_cancelling` | +| …and wait for it to boot first | `start_and_cancelling` | diff --git a/docs/guide/waiting.ja.md b/docs/guide/waiting.ja.md new file mode 100644 index 0000000..305a615 --- /dev/null +++ b/docs/guide/waiting.ja.md @@ -0,0 +1,155 @@ +# 値を待つ + +`AsyncValue` の待機には、信号処理から借りた 2 つの区別があります。 + +- **レベル(Level)** — 「値が X *である*」。条件がすでに成り立っていれば + 即座に完了します。これが + [`wait_value()`][asyncio_util.AsyncValue.wait_value] です。 +- **エッジ(Edge)** — 「値が X *になる*」。実際の変化だけがカウントされ、 + 現在の値はマッチしません。これが + [`wait_transition()`][asyncio_util.AsyncValue.wait_transition] です。 + +## wait_value — レベル待機 + +```python +av = AsyncValue("disconnected") + +# 特定の値を待つ: +await av.wait_value("connected") + +# 述語でも: +await av.wait_value(lambda state: state in ("connected", "degraded")) +``` + +`wait_value()` は**マッチした値**を返します。現在の値がすでにマッチしていれば、 +中断せずに即座に返ります。 + +### 取りこぼさない保証 + +述語は **`value` セッターの中で同期的に**評価されます。マッチした値はその瞬間に +捕捉されてウェイターに届けられます。待っているタスクがスケジュールされる前に +値が再び変化しても、です。 + +```python +av = AsyncValue(10) + +async def waiter(): + return await av.wait_value(20) + +task = asyncio.ensure_future(waiter()) +await asyncio.sleep(0) # ウェイターの登録を待つ + +av.value = 20 # ここでマッチが捕捉される +av.value = 10 # 直後に元へ戻す + +assert await task == 20 # それでもウェイターは 20 を受け取る +assert av.value == 10 # 現在の値は既に先へ進んでいる +``` + +!!! warning "返り値は「今の値」とは限らない" + この捕捉セマンティクスのため、`wait_value()` が返すのは*マッチした*値で + あって、*今の*値とは限りません。現在の状態が必要なら、あらためて + `av.value` を読んでください。 + +## wait_transition — エッジ待機 + +`wait_transition()` は現在の状態を完全に無視して、次のマッチする**変化**を +待ちます。返り値は `(value, old_value)` のペアです。 + +```python +av = AsyncValue("disconnected") + +# あらゆる変化: +value, old = await av.wait_transition() + +# 特定の値への変化: +value, old = await av.wait_transition("connected") + +# (new, old) を見る述語 — 例: 立ち上がりエッジのみ: +value, old = await av.wait_transition(lambda new, old: new > old) +``` + +エッジ述語は遷移の両側を見られるので、「接続が*切れた*ときだけ発火する」の +ようなパターンも 1 行で書けます。 + +```python +await av.wait_transition( + lambda new, old: new == "disconnected" and old == "connected" +) +``` + +!!! note "等しい値の代入は遷移ではない" + `x == av.value` となる `av.value = x` は何もしません。ウェイターは起きず、 + 遷移にもなりません。 + +## held_for — デバウンス + +条件が*安定して*成立してから動きたいことがあります。センサーの読み値、 +ヘルスチェック、接続状態など。`wait_value()` に `held_for` を渡すと、 +マッチが連続して保持されることを要求できます。 + +```python +# 接続が 5 秒間連続で維持されて初めて先へ進む。 +await av.wait_value("connected", held_for=5.0) +``` + +保持期間の途中で条件が崩れるとタイマーは振り出しに戻り、待機が続きます。 +述語が少なくとも `held_for` 秒間成立し続けたときにのみ返ります。 + +## タイムアウト + +`wait_value()` と `wait_transition()` はオプションの `timeout`(秒)を受け取り、 +マッチする前に期限が切れると `asyncio.TimeoutError` を送出します。 + +```python +try: + value = await av.wait_value(20, timeout=1.0) +except asyncio.TimeoutError: + ... +``` + +標準ライブラリのタイムアウトイディオムも使えます。イテレータ系 API を +時間制限したいときはこちらを使ってください。 + +=== "asyncio.wait_for (3.10+)" + + ```python + value = await asyncio.wait_for(av.wait_value(20), timeout=1.0) + ``` + +=== "asyncio.timeout (3.11+)" + + ```python + async with asyncio.timeout(1.0): + value = await av.wait_value(20) + ``` + +キャンセル(タイムアウト、`task.cancel()`、タスクグループの中断)は常に +安全です。内部のウェイターは `finally` ブロックで除去されるため、待機が +どんな形で終わってもリークしません。 + +## 複数のタスクから待つ + +同じ `AsyncValue` を、同じ条件でも異なる条件でも、任意の数のタスクが同時に +待てます。各ウェイターは独立していて、1 回の代入が条件を満たすすべての +ウェイターを起こします。 + +```python +results = await asyncio.gather( + av.wait_value(7), + av.wait_value(lambda v: v > 5), + av.wait_transition(), +) +``` + +## 述語のルール + +- 述語は `value` セッターの中で同期的に実行されます。**速く**、 + **副作用なし**に保ってください。 +- 述語が例外を投げた場合、その例外は**その述語のウェイター**に届き、 + `await` から送出されます。他のウェイターや代入したタスクには影響しません。 + とはいえ、例外は投げないでください。 +- 述語の中で await はできません(コルーチンではなく、ふつうの関数です)。 +- callable な引数はすべて述語として扱われます。値そのものが callable な場合は、 + 明示的な等値述語で待ってください: + `av.wait_value(lambda v: v == target_fn)`。 diff --git a/docs/guide/waiting.md b/docs/guide/waiting.md new file mode 100644 index 0000000..75e0eb9 --- /dev/null +++ b/docs/guide/waiting.md @@ -0,0 +1,156 @@ +# Waiting for values + +`AsyncValue` distinguishes two kinds of waiting, borrowed from signal +processing: + +- **Level** — "the value *is* X". If the condition already holds, you are + done immediately. This is [`wait_value()`][asyncio_util.AsyncValue.wait_value]. +- **Edge** — "the value *becomes* X". Only an actual change counts; the + current value never matches. This is + [`wait_transition()`][asyncio_util.AsyncValue.wait_transition]. + +## wait_value — level waiting + +```python +av = AsyncValue("disconnected") + +# Wait for a specific value: +await av.wait_value("connected") + +# Or a predicate: +await av.wait_value(lambda state: state in ("connected", "degraded")) +``` + +`wait_value()` returns the **matched value**. If the current value already +matches, it returns immediately without suspending. + +### The no-miss guarantee + +Predicates are evaluated **synchronously inside the `value` setter**. The +matched value is captured at that instant and delivered to the waiter, even +if the value changes again before the waiting task gets scheduled: + +```python +av = AsyncValue(10) + +async def waiter(): + return await av.wait_value(20) + +task = asyncio.ensure_future(waiter()) +await asyncio.sleep(0) # let the waiter register + +av.value = 20 # match captured here +av.value = 10 # immediately flipped back + +assert await task == 20 # the waiter still saw 20 +assert av.value == 10 # even though the current value moved on +``` + +!!! warning "The returned value can be stale" + Because of this capture semantics, the value returned by `wait_value()` + is the value *that matched*, which is not necessarily the value *now*. + Read `av.value` again if you need the current state. + +## wait_transition — edge waiting + +`wait_transition()` ignores the current state entirely and waits for the next +matching **change**. It returns a `(value, old_value)` pair: + +```python +av = AsyncValue("disconnected") + +# Any change at all: +value, old = await av.wait_transition() + +# A change to a specific value: +value, old = await av.wait_transition("connected") + +# A predicate over (new, old) — e.g. only rising edges: +value, old = await av.wait_transition(lambda new, old: new > old) +``` + +Edge predicates see both sides of the transition, which makes patterns like +"fire only when we *lose* the connection" one-liners: + +```python +await av.wait_transition( + lambda new, old: new == "disconnected" and old == "connected" +) +``` + +!!! note "Equal assignments are not transitions" + `av.value = x` where `x == av.value` is a no-op. It wakes no waiter and + produces no transition. + +## held_for — debouncing + +Sometimes a condition must be *stable* before you act on it: a sensor +reading, a health check, a connection state. Pass `held_for` to +`wait_value()` to require the match to hold continuously: + +```python +# Only proceed once the connection has been up for 5 seconds straight. +await av.wait_value("connected", held_for=5.0) +``` + +If the condition is lost during the hold period, the timer restarts and the +wait continues. The call returns only after the predicate has held for at +least `held_for` seconds. + +## Timeouts + +`wait_value()` and `wait_transition()` take an optional `timeout` (seconds) +and raise `asyncio.TimeoutError` when it expires before a match: + +```python +try: + value = await av.wait_value(20, timeout=1.0) +except asyncio.TimeoutError: + ... +``` + +The standard library timeout idioms work too, and are the way to bound the +iterator APIs: + +=== "asyncio.wait_for (3.10+)" + + ```python + value = await asyncio.wait_for(av.wait_value(20), timeout=1.0) + ``` + +=== "asyncio.timeout (3.11+)" + + ```python + async with asyncio.timeout(1.0): + value = await av.wait_value(20) + ``` + +Cancellation (from a timeout, `task.cancel()`, or a task group aborting) is +always safe: the internal waiter is removed in a `finally` block, so nothing +leaks no matter how the wait ends. + +## Waiting from many tasks + +Any number of tasks can wait on the same `AsyncValue` concurrently, with the +same or different conditions. Each waiter is independent; a single +assignment wakes every waiter whose condition it satisfies. + +```python +results = await asyncio.gather( + av.wait_value(7), + av.wait_value(lambda v: v > 5), + av.wait_transition(), +) +``` + +## Rules for predicates + +- Predicates run inside the `value` setter, synchronously. Keep them + **fast** and **side-effect free**. +- A predicate that raises delivers the exception to **its own waiter**: the + `await` raises it. Other waiters and the assigning task are unaffected. + Still: don't raise. +- Don't await inside predicates (they are plain functions, not coroutines). +- Any callable argument is treated as a predicate. If your values are + themselves callables, wait with an explicit equality predicate: + `av.wait_value(lambda v: v == target_fn)`. diff --git a/docs/index.ja.md b/docs/index.ja.md new file mode 100644 index 0000000..ab441d1 --- /dev/null +++ b/docs/index.ja.md @@ -0,0 +1,128 @@ +# asyncio-util + +**trio-util から asyncio へ移植したユーティリティ集 — await できる値、 +キャンセルスコープ、繰り返しイベント、その他いろいろ。** + +`asyncio-util` は、GROOVE X 製 +[trio-util](https://github.com/groove-x/trio-util) の使い心地を素の asyncio に +持ち込みます。中心となるのは [`AsyncValue`][asyncio_util.AsyncValue] — +任意の数のタスクが状態や遷移を *await* できる値です。ポーリングループも、 +`Event` の手動管理も、取りこぼしの心配もいりません。 + +!!! info "この プロジェクトについて" + このプロジェクトは、trio-util を asyncio に移植する + [PyCon US 2024 トーク](https://us.pycon.org/2024/schedule/presentation/142/) + のサンプルコードとして始まり、独立したライブラリに成長しました。 + +```python +import asyncio +from asyncio_util import AsyncValue + +connection_state = AsyncValue("disconnected") + +async def watcher(): + await connection_state.wait_value("connected") + print("接続しました!") + +async def main(): + task = asyncio.ensure_future(watcher()) + await asyncio.sleep(0) + connection_state.value = "connected" # watcher が起きる + await task + +asyncio.run(main()) +``` + +## なぜ AsyncValue なのか + +タスク間の通知には「共有状態 + `asyncio.Event`」を使うのが定石ですが、 +これは思いのほか間違えやすい組み合わせです。 + +- `Event` は値を運びません。状態は別に持ち、両者の同期を自分で保つ必要が + あります。 +- 「X が 3 を超えるまで待つ」には *チェック → 待機 → クリア* のループが必要で、 + ウェイクアップの取りこぼしを避けるには慎重に書かなければなりません。 +- 待っているタスクが動き出す前に値が 2 回変化すると、途中の値は静かに失われ + ます。`20` を待つタスクは、値が `20` だった瞬間を見逃します。 + +`AsyncValue` はこの 3 つをまとめて解決します。値と通知は 1 つのオブジェクトに +まとまり、待機は `await` 1 回で書け、マッチ判定は**代入時に同期的に**行われる +ため、値が直後に元へ戻ってもマッチする変化を取りこぼしません。 + +```python +av = AsyncValue(10) + +# 別の場所で: av.value = 20; av.value = 10 (高速な往復) +value = await av.wait_value(20) # それでも 20 が返る +``` + +## 機能一覧 + +### await できる値 + +| API | 役割 | +| --- | --- | +| [`AsyncValue`][asyncio_util.AsyncValue] | 状態と遷移を await できる値 | +| [`AsyncBool`][asyncio_util.AsyncBool] | デフォルト `False` の `AsyncValue[bool]` | +| [`wait_value()`][asyncio_util.AsyncValue.wait_value] | 値または述語にマッチするまで待つ | +| [`wait_transition()`][asyncio_util.AsyncValue.wait_transition] | 次のマッチする*変化*(エッジ)を待つ | +| [`eventual_values()`][asyncio_util.AsyncValue.eventual_values] | 常に最新値へ収束する値のイテレータ | +| [`transitions()`][asyncio_util.AsyncValue.transitions] | `(new, old)` の変化ペアのイテレータ | +| [`open_transform()`][asyncio_util.AsyncValue.open_transform] | `f(value)` を追跡する派生 `AsyncValue` | +| [`compose_values()`][asyncio_util.compose_values] | 複数の `AsyncValue` を合成し、横断的な条件を await | +| [`open_held_for()`][asyncio_util.open_held_for] | 値が安定したら True になる `AsyncBool` | +| [`open_hysteresis()`][asyncio_util.open_hysteresis] | ブール値のヒステリシスフィルタ | + +### タスクとキャンセル + +| API | 役割 | +| --- | --- | +| [`wait_any()`][asyncio_util.wait_any] | 並行実行して最初の完了で戻る | +| [`wait_all()`][asyncio_util.wait_all] | 並行実行して全完了で戻る | +| [`wait_any_map()`][asyncio_util.wait_any_map] | *どれが*完了したかと結果も返す `wait_any` | +| [`move_on_when()`][asyncio_util.move_on_when] | トリガー完了時にブロックをキャンセル | +| [`run_and_cancelling()`][asyncio_util.run_and_cancelling] | ブロックにスコープされたバックグラウンドタスク | +| [`start_and_cancelling()`][asyncio_util.start_and_cancelling] | 同上 + 準備完了シグナルを待つ | + +### イベント・ストリーム・タイミング + +| API | 役割 | +| --- | --- | +| [`RepeatedEvent`][asyncio_util.RepeatedEvent] | 何度も発火でき、複数リスナーを持てるイベント | +| [`MulticastQueue`][asyncio_util.MulticastQueue] | 各アイテムを全リスナーへブロードキャスト | +| [`periodic()`][asyncio_util.periodic] | ドリフトしない周期イテレーション | +| [`azip()` / `azip_longest()`][asyncio_util.azip] | 非同期イテレータの async `zip` | +| [`iter_move_on_after()` / `iter_fail_after()`][asyncio_util.iter_move_on_after] | 非同期イテレータのアイテム単位タイムアウト | + +- **依存ゼロ** — 標準ライブラリのみで動作します。 +- **型付き** — `py.typed` マーカーを同梱。`AsyncValue` はジェネリクス + (`AsyncValue[int]`、`AsyncValue[State]` など)に対応します。 +- **Python 3.10+**、CPython と PyPy をサポート。 + +## インストール + +!!! warning + PyPI にはまだ公開されていません。GitHub からインストールしてください。 + +```console +pip install git+https://github.com/jrfk/asyncio-util +``` + +## 次に読むページ + +- [はじめに](getting-started.md) — インストールと最初の一歩。 +- [値を待つ](guide/waiting.md) — `wait_value`・`wait_transition`・タイムアウト・ + `held_for`。 +- [変化をイテレートする](guide/iterating.md) — `eventual_values` と `transitions`、 + その正確なセマンティクス。 +- [派生と合成](guide/deriving.md) — `open_transform`・`compose_values`・ + `open_held_for`・`open_hysteresis`。 +- [タスクとキャンセル](guide/tasks.md) — `wait_any`・`move_on_when` など。 +- [イベント・ストリーム・タイミング](guide/streams.md) — `RepeatedEvent`・ + `MulticastQueue`・`periodic`・非同期イテレーションヘルパー。 +- [API リファレンス](api.md) — すべての公開シンボルとソースコード。 + +## 謝辞 + +API は GROOVE X の [trio-util](https://github.com/groove-x/trio-util) を +モデルに、asyncio のプリミティブの上に再実装したものです。 diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..1823f8b --- /dev/null +++ b/docs/index.md @@ -0,0 +1,131 @@ +# asyncio-util + +**Utilities for asyncio, ported from trio-util — awaitable values, cancel +scopes, repeated events, and more.** + +`asyncio-util` brings the ergonomics of +[trio-util](https://github.com/groove-x/trio-util) (by GROOVE X) to plain +asyncio. Its centerpiece is [`AsyncValue`][asyncio_util.AsyncValue]: a value +whose states and transitions any number of tasks can *await* — no polling +loops, no hand-rolled `Event` juggling, no missed wakeups. + +!!! info "Origin" + This project started as the sample code for a + [PyCon US 2024 talk](https://us.pycon.org/2024/schedule/presentation/142/) + on porting trio-util to asyncio, and grew into a standalone library. + +```python +import asyncio +from asyncio_util import AsyncValue + +connection_state = AsyncValue("disconnected") + +async def watcher(): + await connection_state.wait_value("connected") + print("connected!") + +async def main(): + task = asyncio.ensure_future(watcher()) + await asyncio.sleep(0) + connection_state.value = "connected" # wakes the watcher + await task + +asyncio.run(main()) +``` + +## Why AsyncValue? + +Shared state plus `asyncio.Event` is the usual way to signal between tasks — +and it is surprisingly easy to get wrong: + +- An `Event` carries no value; you must keep the state next to it and keep + the two in sync yourself. +- "Wait until X > 3" requires a loop of *check → wait → clear* that has to be + written carefully to avoid lost wakeups. +- If the value changes twice before the waiting task runs, the intermediate + value is silently lost. A task waiting for `20` misses the moment when the + value was `20` and has already moved on. + +`AsyncValue` solves all three: the value and its signaling live in one +object, waiting is one `await`, and matches are evaluated **synchronously at +assignment time** — so a matching change is never missed, even if the value +flips back immediately: + +```python +av = AsyncValue(10) + +# elsewhere: av.value = 20; av.value = 10 (rapid flip) +value = await av.wait_value(20) # still returns 20 +``` + +## Feature overview + +### Awaitable values + +| API | What it does | +| --- | --- | +| [`AsyncValue`][asyncio_util.AsyncValue] | A value whose states and transitions can be awaited | +| [`AsyncBool`][asyncio_util.AsyncBool] | `AsyncValue[bool]` with a `False` default | +| [`wait_value()`][asyncio_util.AsyncValue.wait_value] | Wait until the value matches a value or predicate | +| [`wait_transition()`][asyncio_util.AsyncValue.wait_transition] | Wait for the next matching *change* (edge) | +| [`eventual_values()`][asyncio_util.AsyncValue.eventual_values] | Iterate over values, always converging to the latest | +| [`transitions()`][asyncio_util.AsyncValue.transitions] | Iterate over `(new, old)` change pairs | +| [`open_transform()`][asyncio_util.AsyncValue.open_transform] | Derive an `AsyncValue` tracking `f(value)` | +| [`compose_values()`][asyncio_util.compose_values] | Combine several `AsyncValue`s and await cross-value conditions | +| [`open_held_for()`][asyncio_util.open_held_for] | An `AsyncBool` that turns True when a value has been stable | +| [`open_hysteresis()`][asyncio_util.open_hysteresis] | Hysteresis filtering for a boolean value | + +### Tasks and cancellation + +| API | What it does | +| --- | --- | +| [`wait_any()`][asyncio_util.wait_any] | Run coroutines concurrently, return on the first to finish | +| [`wait_all()`][asyncio_util.wait_all] | Run coroutines concurrently, return when all finish | +| [`wait_any_map()`][asyncio_util.wait_any_map] | `wait_any` that tells you *which* finished, with its result | +| [`move_on_when()`][asyncio_util.move_on_when] | Cancel a block when a trigger completes | +| [`run_and_cancelling()`][asyncio_util.run_and_cancelling] | Run a background task scoped to a block | +| [`start_and_cancelling()`][asyncio_util.start_and_cancelling] | Same, but waits for the task to signal readiness | + +### Events, streams and timing + +| API | What it does | +| --- | --- | +| [`RepeatedEvent`][asyncio_util.RepeatedEvent] | An event that can fire many times, with multiple listeners | +| [`MulticastQueue`][asyncio_util.MulticastQueue] | Broadcast each item to every active listener | +| [`periodic()`][asyncio_util.periodic] | Drift-free periodic iteration | +| [`azip()` / `azip_longest()`][asyncio_util.azip] | Async `zip` over async iterators | +| [`iter_move_on_after()` / `iter_fail_after()`][asyncio_util.iter_move_on_after] | Per-item timeouts for async iterators | + +- **Zero dependencies** — only the standard library. +- **Typed** — ships a `py.typed` marker; `AsyncValue` is generic + (`AsyncValue[int]`, `AsyncValue[State]`, …). +- **Python 3.10+**, CPython and PyPy. + +## Installation + +!!! warning + Not yet published to PyPI. Install from GitHub: + +```console +pip install git+https://github.com/jrfk/asyncio-util +``` + +## Where to go next + +- [Getting Started](getting-started.md) — install and first steps. +- [Waiting for values](guide/waiting.md) — `wait_value`, `wait_transition`, + timeouts and `held_for`. +- [Iterating over changes](guide/iterating.md) — `eventual_values` and + `transitions`, and their exact semantics. +- [Deriving and composing](guide/deriving.md) — `open_transform`, + `compose_values`, `open_held_for`, `open_hysteresis`. +- [Tasks and cancellation](guide/tasks.md) — `wait_any`, `move_on_when` and + friends. +- [Events, streams and timing](guide/streams.md) — `RepeatedEvent`, + `MulticastQueue`, `periodic`, async iteration helpers. +- [API Reference](api.md) — every public symbol, with source. + +## Acknowledgements + +The API is modeled on [trio-util](https://github.com/groove-x/trio-util) by +GROOVE X, reimplemented on top of asyncio primitives. diff --git a/examples/compose_dashboard.py b/examples/compose_dashboard.py new file mode 100644 index 0000000..874a538 --- /dev/null +++ b/examples/compose_dashboard.py @@ -0,0 +1,45 @@ +"""Combine several AsyncValues and await a cross-value condition. + +A robot may start a job only when it is docked AND sufficiently +charged. compose_values() turns that into a single predicate. + +Run: python examples/compose_dashboard.py +""" + +import asyncio + +from asyncio_util import AsyncValue, compose_values + + +async def charger(battery: AsyncValue[int]) -> None: + while battery.value < 100: + await asyncio.sleep(0.1) + battery.value = min(100, battery.value + 25) + print(f"[charger] battery = {battery.value}%") + + +async def driver(docked: AsyncValue[bool]) -> None: + await asyncio.sleep(0.25) + docked.value = True + print("[driver] docked") + + +async def wait_ready(status) -> None: + snapshot = await status.wait_value(lambda s: s.docked and s.battery >= 75) + print(f"[main] ready to start job: {snapshot}") + + +async def main() -> None: + docked = AsyncValue(False) + battery = AsyncValue(10) + + with compose_values(docked=docked, battery=battery) as status: + await asyncio.gather( + charger(battery), + driver(docked), + wait_ready(status), + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/connection_state.py b/examples/connection_state.py new file mode 100644 index 0000000..cb8ccb5 --- /dev/null +++ b/examples/connection_state.py @@ -0,0 +1,41 @@ +"""Wait for states and observe transitions of a connection state machine. + +Run: python examples/connection_state.py +""" + +import asyncio + +from asyncio_util import AsyncValue + + +async def connect(state: AsyncValue[str]) -> None: + for next_state in ("connecting", "connected", "degraded", "connected"): + await asyncio.sleep(0.2) + print(f"[producer] -> {next_state}") + state.value = next_state + + +async def wait_until_connected(state: AsyncValue[str]) -> None: + value = await state.wait_value("connected") + print(f"[wait_value] connected (value={value!r})") + + +async def log_every_transition(state: AsyncValue[str]) -> None: + async for new, old in state.transitions(): + print(f"[transitions] {old!r} -> {new!r}") + if new == "connected" and old == "degraded": + print("[transitions] recovered, stopping log") + return + + +async def main() -> None: + state = AsyncValue("disconnected") + await asyncio.gather( + wait_until_connected(state), + log_every_transition(state), + connect(state), + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/debounce.py b/examples/debounce.py new file mode 100644 index 0000000..195f2af --- /dev/null +++ b/examples/debounce.py @@ -0,0 +1,32 @@ +"""Debounce a flapping sensor with wait_value(..., held_for=N). + +The reading briefly spikes over the threshold, drops back, then stays +high. The waiter only fires for the stable period. + +Run: python examples/debounce.py +""" + +import asyncio + +from asyncio_util import AsyncValue + + +async def sensor(reading: AsyncValue[float]) -> None: + for value, delay in ((105.0, 0.1), (95.0, 0.1), (110.0, 0.5)): + await asyncio.sleep(delay) + print(f"[sensor] reading = {value}") + reading.value = value + + +async def alarm(reading: AsyncValue[float]) -> None: + value = await reading.wait_value(lambda v: v > 100.0, held_for=0.3) + print(f"[alarm] over 100 for 0.3s straight (value={value})") + + +async def main() -> None: + reading = AsyncValue(90.0) + await asyncio.gather(alarm(reading), sensor(reading)) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..cbb7517 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,98 @@ +site_name: asyncio-util +site_description: "Utilities for asyncio, ported from trio-util: awaitable values, cancel scopes, repeated events, and more" +site_url: https://jrfk.github.io/asyncio-util/ +repo_url: https://github.com/jrfk/asyncio-util +repo_name: jrfk/asyncio-util +edit_uri: edit/main/docs/ + +theme: + name: material + features: + - navigation.sections + - navigation.top + - navigation.footer + - content.code.copy + - content.action.edit + - toc.follow + palette: + - media: "(prefers-color-scheme: light)" + scheme: default + primary: indigo + accent: indigo + toggle: + icon: material/weather-night + name: Switch to dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: indigo + accent: indigo + toggle: + icon: material/weather-sunny + name: Switch to light mode + +nav: + - Home: index.md + - Getting Started: getting-started.md + - Guide: + - Waiting for values: guide/waiting.md + - Iterating over changes: guide/iterating.md + - Deriving and composing: guide/deriving.md + - Tasks and cancellation: guide/tasks.md + - Events, streams and timing: guide/streams.md + - API Reference: api.md + +markdown_extensions: + - admonition + - pymdownx.details + - pymdownx.highlight: + anchor_linenums: true + - pymdownx.inlinehilite + - pymdownx.superfences + - pymdownx.tabbed: + alternate_style: true + - toc: + permalink: true + +plugins: + - search + - autorefs: + resolve_closest: true + - i18n: + docs_structure: suffix + languages: + - locale: en + name: English + build: true + default: true + - locale: ja + name: 日本語 + build: true + site_description: "trio-util から asyncio へ移植したユーティリティ集 — await できる値、キャンセルスコープ、繰り返しイベント" + nav_translations: + Home: ホーム + Getting Started: はじめに + Guide: ガイド + Waiting for values: 値を待つ + Iterating over changes: 変化をイテレートする + Deriving and composing: 派生と合成 + Tasks and cancellation: タスクとキャンセル + Events, streams and timing: イベント・ストリーム・タイミング + API Reference: API リファレンス + - mkdocstrings: + handlers: + python: + paths: [src] + options: + docstring_style: google + show_source: true + show_root_heading: true + separate_signature: true + show_signature_annotations: true + members_order: source + +extra: + social: + - icon: fontawesome/brands/github + link: https://github.com/jrfk/asyncio-util + - icon: fontawesome/brands/python + link: https://pypi.org/project/asyncio-util/ diff --git a/pyproject.toml b/pyproject.toml index cfd1a9b..8164ce4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,29 +5,41 @@ build-backend = "hatchling.build" [project] name = "asyncio-util" dynamic = ["version"] -description = '' +description = "Utilities for asyncio, ported from trio-util: awaitable values, cancel scopes, repeated events, and more" readme = "README.md" -requires-python = ">=3.8" +requires-python = ">=3.10" license = "MIT" -keywords = [] +keywords = [ + "asyncio", + "async", + "await", + "event", + "state", + "synchronization", + "concurrency", + "trio-util", +] authors = [ { name = "Junya Fukuda", email = "junya.fukuda.e@gmail.com" }, ] classifiers = [ "Development Status :: 4 - Beta", + "Framework :: AsyncIO", + "Intended Audience :: Developers", "Programming Language :: Python", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", + "Typing :: Typed", ] dependencies = [] [project.urls] -Documentation = "https://github.com/jrfk/asyncio-util#readme" +Documentation = "https://jrfk.github.io/asyncio-util/" Issues = "https://github.com/jrfk/asyncio-util/issues" Source = "https://github.com/jrfk/asyncio-util" @@ -35,6 +47,13 @@ Source = "https://github.com/jrfk/asyncio-util" dev = [ "pytest>=7.0", "pytest-asyncio>=0.21", + "coverage[toml]>=7.0", +] +docs = [ + "mkdocs>=1.6", + "mkdocs-material>=9.5", + "mkdocs-static-i18n>=1.2", + "mkdocstrings[python]>=0.27", ] [tool.hatch.version] @@ -47,6 +66,21 @@ extra-dependencies = [ [tool.hatch.envs.types.scripts] check = "mypy --install-types --non-interactive {args:src/asyncio_util tests}" +[tool.hatch.envs.docs] +dependencies = [ + "mkdocs>=1.6", + "mkdocs-material>=9.5", + "mkdocs-static-i18n>=1.2", + "mkdocstrings[python]>=0.27", +] +[tool.hatch.envs.docs.scripts] +build = "mkdocs build --strict {args}" +serve = "mkdocs serve {args}" + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" + [tool.coverage.run] source_pkgs = ["asyncio_util", "tests"] branch = true @@ -73,4 +107,4 @@ extra-dependencies = [ ] [[tool.hatch.envs.hatch-test.matrix]] -python = ["3.8", "3.9", "3.10", "3.11", "3.12"] +python = ["3.10", "3.11", "3.12", "3.13", "3.14"] diff --git a/src/asyncio_util/__about__.py b/src/asyncio_util/__about__.py index fa5f080..3a438bb 100644 --- a/src/asyncio_util/__about__.py +++ b/src/asyncio_util/__about__.py @@ -1,4 +1,4 @@ # SPDX-FileCopyrightText: 2024-present Junya Fukuda # # SPDX-License-Identifier: MIT -__version__ = "0.0.1" +__version__ = "0.1.0" diff --git a/src/asyncio_util/_async_value.py b/src/asyncio_util/_async_value.py index c62ba78..65144c3 100644 --- a/src/asyncio_util/_async_value.py +++ b/src/asyncio_util/_async_value.py @@ -1,12 +1,13 @@ from __future__ import annotations import asyncio -from contextlib import asynccontextmanager +from contextlib import asynccontextmanager, contextmanager from typing import ( AsyncContextManager, AsyncIterator, Callable, Generic, + Iterator, TypeVar, overload, ) @@ -31,11 +32,14 @@ async def park(self): finally: self._futures.discard(future) - def unpark_all(self): + def unpark_all(self, error: BaseException | None = None): futures, self._futures = self._futures, set() for future in futures: if not future.done(): - future.set_result(None) + if error is not None: + future.set_exception(error) + else: + future.set_result(None) def _any_transition(value, old_value): # noqa: ARG001 @@ -77,17 +81,62 @@ def __call__(self, x, *args): # noqa: ARG002 class AsyncValue(Generic[T]): + """A mutable value whose changes can be awaited. + + ``AsyncValue`` wraps a value and lets any number of tasks wait for + states ("the value is 20") or transitions ("the value just changed + to 20") without polling and without managing events by hand. + + Matching is evaluated synchronously at assignment time, so + :meth:`wait_value` and :meth:`wait_transition` never miss a match + even if the value changes again immediately afterwards. + + Assigning a value equal to the current one is a no-op and wakes + nobody. + + Predicates must be fast, side-effect free, and must not raise: they + run synchronously inside the ``value`` setter. Because any callable + is treated as a predicate, a value that is itself callable cannot be + waited on by equality — wrap it: ``lambda v: v == target``. If a + predicate does raise, the exception is delivered to that waiter + (raised from its ``await``); other waiters are unaffected. + + Note: + ``AsyncValue`` is not thread-safe. Assign ``value`` only from + the thread running the event loop; from other threads use + ``loop.call_soon_threadsafe``. + + Example: + ```python + av = AsyncValue(0) + + async def watcher(): + value = await av.wait_value(20) # suspends until 20 + print(value) + + # elsewhere, later: + av.value = 20 # wakes the watcher + ``` + """ + def __init__(self, value: T): self._value = value self._level_results = _RefCountedDefaultDict(_Result) self._edge_results = _RefCountedDefaultDict(_Result) self._transforms = _RefCountedDefaultDict(lambda: AsyncValue(None)) + self._listeners: list[Callable[[T, T], None]] = [] def __repr__(self): return f"{self.__class__.__name__}({self.value})" @property def value(self) -> T: + """The current value. + + Assigning a new value synchronously notifies all matching + waiters. Assigning a value equal to the current one does + nothing. + """ return self._value @value.setter @@ -95,27 +144,92 @@ def value(self, x: T): if self._value != x: old = self._value new = self._value = x + # A raising predicate or transform must not prevent the + # remaining waiters from being notified: predicate errors are + # delivered to their own waiters, transform errors are + # re-raised only after every notification went out. + transform_error: Exception | None = None for f, result in self._level_results.items(): - if f(new): + try: + matched = f(new) + except Exception as exc: + result.event.unpark_all(exc) + continue + if matched: result.value = new result.event.unpark_all() for f, result in self._edge_results.items(): - if f(new, old): + try: + matched = f(new, old) + except Exception as exc: + result.event.unpark_all(exc) + continue + if matched: result.value = (new, old) result.event.unpark_all() for f, output in self._transforms.items(): - output.value = f(new) + try: + output.value = f(new) + except Exception as exc: + transform_error = transform_error or exc + for listener in list(self._listeners): + try: + listener(new, old) + except Exception as exc: + transform_error = transform_error or exc + if transform_error is not None: + raise transform_error async def _wait_predicate(self, result_map, predicate): with result_map.open_ref(predicate) as result: await result.event.park() return result.value + @contextmanager + def _subscribe(self, listener: Callable[[T, T], None]) -> Iterator[None]: + """Invoke ``listener(new_value, old_value)`` on every change. + + Listener exceptions are re-raised to the code assigning + ``value`` after all other notifications went out. + """ + self._listeners.append(listener) + try: + yield + finally: + self._listeners.remove(listener) + @overload async def wait_value(self, value: T, *, held_for=0.0, timeout=None) -> T: ... @overload async def wait_value(self, predicate: P, *, held_for=0.0, timeout=None) -> T: ... - async def wait_value(self, value_or_predicate, *, held_for=0.0, timeout=None): + async def wait_value( + self, + value_or_predicate: T | P, + *, + held_for: float = 0.0, + timeout: float | None = None, + ) -> T: + """Wait until the value matches, and return the matched value. + + Returns immediately if the current value already matches. + Otherwise the match is captured at assignment time, so a + matching value is returned even if the value changes again + before this coroutine resumes (the current value may differ by + then). + + Args: + value_or_predicate: A plain value to compare with ``==``, or + a callable ``predicate(value) -> bool``. Any callable + is treated as a predicate. + held_for: If greater than 0, only return once the match has + held for at least that many seconds. Each time the + match is lost, the hold timer restarts. + timeout: If not None, raise :class:`asyncio.TimeoutError` + when no match occurred within *timeout* seconds. + + Returns: + The value that satisfied the match. + """ predicate = _ValueWrapper(value_or_predicate) while True: try: @@ -126,16 +240,24 @@ async def wait_value(self, value_or_predicate, *, held_for=0.0, timeout=None): else: value = self._value await asyncio.sleep(0) - if held_for > 0: + except asyncio.TimeoutError: + timeout_message = "Operation timed out" + raise asyncio.TimeoutError(timeout_message) from None + if held_for > 0: + try: await asyncio.wait_for( self.wait_value(lambda v: not predicate(v)), held_for ) + # The match was lost before the hold elapsed; wait again. continue - break - except asyncio.TimeoutError: - timeout_message = "Operation timed out" - raise asyncio.TimeoutError(timeout_message) from None - return value + except asyncio.TimeoutError: + # The match was held for the full duration. Re-check in + # case the value changed while the timeout was being + # processed. + if not predicate(self._value): + continue + value = self._value + return value @overload async def eventual_values(self, value: T, held_for=0.0) -> AsyncIterator[T]: @@ -147,7 +269,32 @@ async def eventual_values( ) -> AsyncIterator[T]: yield self._value - async def eventual_values(self, value_or_predicate=_any_value, held_for=0.0): + async def eventual_values( + self, + value_or_predicate: T | P = _any_value, + held_for: float = 0.0, + ) -> AsyncIterator[T]: + """Iterate over matching values, always converging to the latest. + + Yields the current value first if it matches, then each + matching value as it is assigned. A slow consumer may miss + intermediate values, but is always caught up with the latest + matching value — hence "eventual". Consecutive duplicates are + suppressed. + + Args: + value_or_predicate: A plain value to compare with ``==``, or + a callable ``predicate(value) -> bool``. Omit it to + match every value. + held_for: If greater than 0, only yield a value once it has + matched continuously for that many seconds. + + Example: + ```python + async for state in av.eventual_values(): + redraw(state) + ``` + """ predicate = _ValueWrapper(value_or_predicate) last_value = self._value with self._level_results.open_ref( @@ -164,7 +311,15 @@ async def eventual_values(self, value_or_predicate=_any_value, held_for=0.0): await result.event.park() last_value = result.value if held_for > 0: - await asyncio.wait_for(not_predicate.event.park(), held_for) + try: + await asyncio.wait_for(not_predicate.event.park(), held_for) + # The match was lost before the hold elapsed; start over. + continue + except asyncio.TimeoutError: + # The match was held for the full duration. + if not predicate(self._value): + continue + last_value = self._value yield last_value if self._value == last_value: await not_last_value.event.park() @@ -174,8 +329,28 @@ async def wait_transition(self, value: T) -> tuple[T, T]: ... @overload async def wait_transition(self, predicate: P2 = _any_transition) -> tuple[T, T]: ... async def wait_transition( - self, value_or_predicate=_any_transition, *, timeout=None - ): + self, + value_or_predicate: T | P2 = _any_transition, + *, + timeout: float | None = None, + ) -> tuple[T, T]: + """Wait for the next matching change, and return ``(value, old_value)``. + + Unlike :meth:`wait_value`, the current value is never a match: + this waits for an *edge*, an actual assignment that changes the + value. + + Args: + value_or_predicate: A plain value the *new* value must equal, + or a callable ``predicate(value, old_value) -> bool``. + Omit it to match any change. + timeout: If not None, raise :class:`asyncio.TimeoutError` + when no matching change occurred within *timeout* + seconds. + + Returns: + The ``(value, old_value)`` pair captured at assignment time. + """ try: return await asyncio.wait_for( self._wait_predicate(self._edge_results, _ValueWrapper(value_or_predicate)), @@ -194,7 +369,27 @@ async def transitions( ) -> AsyncIterator[tuple[T, T]]: yield (self._value, self._value) - async def transitions(self, value_or_predicate=_any_transition): + async def transitions( + self, + value_or_predicate: T | P2 = _any_transition, + ) -> AsyncIterator[tuple[T, T]]: + """Iterate over matching changes as ``(value, old_value)`` pairs. + + Like :meth:`wait_transition` in a loop: only edges are reported, + never the current value. + + Warning: + Transitions are not queued. A change that happens while the + loop body is still processing the previous one is missed. + If every edge matters, have the producer push into an + ``asyncio.Queue`` (or a :class:`~asyncio_util.MulticastQueue`) + instead. + + Args: + value_or_predicate: A plain value the *new* value must equal, + or a callable ``predicate(value, old_value) -> bool``. + Omit it to match any change. + """ predicate = _ValueWrapper(value_or_predicate) with self._edge_results.open_ref(predicate) as result: while True: @@ -205,6 +400,27 @@ async def transitions(self, value_or_predicate=_any_transition): async def open_transform( self, function: Callable[[T], T_OUT] ) -> AsyncContextManager[AsyncValue[T_OUT]]: + """Derive a new :class:`AsyncValue` that tracks ``function(value)``. + + Within the ``async with`` block, the derived value is updated + synchronously on every assignment to this value. Updates that + produce an equal output are deduplicated by the derived value's + own setter. Opening the same function object twice shares one + derived value (reference-counted). + + Args: + function: A pure, non-raising function applied to each value. + It runs inside the source's setter; if it raises, the + exception propagates to the code assigning ``value`` + (after all other waiters have been notified). + + Example: + ```python + av = AsyncValue(3) + async with av.open_transform(lambda x: x * 10) as derived: + assert derived.value == 30 + ``` + """ with self._transforms.open_ref(function) as output: if output.value is None: output.value = function(self.value) diff --git a/src/asyncio_util/_awaitables.py b/src/asyncio_util/_awaitables.py index d668cd7..8dc0175 100644 --- a/src/asyncio_util/_awaitables.py +++ b/src/asyncio_util/_awaitables.py @@ -18,8 +18,10 @@ async def wait_any(*args: Callable[[], Awaitable[Any]]) -> None: if not args: return - tasks = [asyncio.create_task(f()) for f in args] + tasks: list[asyncio.Task[Any]] = [] try: + for f in args: + tasks.append(asyncio.create_task(f())) done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) await cancel_tasks(pending) errors = [] @@ -47,8 +49,10 @@ async def wait_all(*args: Callable[[], Awaitable[Any]]) -> None: if not args: return - tasks = [asyncio.create_task(f()) for f in args] + tasks: list[asyncio.Task[Any]] = [] try: + for f in args: + tasks.append(asyncio.create_task(f())) await asyncio.gather(*tasks) except BaseException: await cancel_tasks(tasks) @@ -91,17 +95,17 @@ async def wait_any_map( all_tasks: list[asyncio.Task[Any]] = [] task_to_name: dict[asyncio.Task[Any], str | None] = {} - for f in fns: - task = asyncio.create_task(f()) - all_tasks.append(task) - task_to_name[task] = None + try: + for f in fns: + task = asyncio.create_task(f()) + all_tasks.append(task) + task_to_name[task] = None - for name, f in fn_map.items(): - task = asyncio.create_task(f()) - all_tasks.append(task) - task_to_name[task] = name + for name, f in fn_map.items(): + task = asyncio.create_task(f()) + all_tasks.append(task) + task_to_name[task] = name - try: done, pending = await asyncio.wait( all_tasks, return_when=asyncio.FIRST_COMPLETED ) diff --git a/src/asyncio_util/_compose_values.py b/src/asyncio_util/_compose_values.py index 201ee93..bb84e8f 100644 --- a/src/asyncio_util/_compose_values.py +++ b/src/asyncio_util/_compose_values.py @@ -68,21 +68,20 @@ def __enter__(self) -> AsyncValue[Any]: for name, src in self._value_map.items(): - def _make_updater(field: str) -> Callable[[Any], bool]: - def _update(new_val: Any) -> bool: + def _make_updater(field: str) -> Callable[[Any, Any], None]: + def _update(new_val: Any, _old_val: Any) -> None: nonlocal composite composite = composite._replace(**{field: new_val}) if transform is not None: output.value = transform(composite) else: output.value = composite - return False # Never "matches" - just a side effect return _update - updater = _make_updater(name) - self._stack.enter_context( - src._level_results.open_ref(updater) - ) + # Registered as a listener: a raising transform propagates + # to the code assigning the source value (after all other + # waiters have been notified) instead of being silently lost. + self._stack.enter_context(src._subscribe(_make_updater(name))) self._output = output return output diff --git a/src/asyncio_util/_move_on_when.py b/src/asyncio_util/_move_on_when.py index 0178938..983313c 100644 --- a/src/asyncio_util/_move_on_when.py +++ b/src/asyncio_util/_move_on_when.py @@ -21,6 +21,12 @@ def __init__(self): class _MoveOnWhen: """Async context manager that cancels the body when a trigger completes. + The trigger task starts running once the body reaches its first + ``await``. If the trigger raises, the body is cancelled and the + trigger's exception is re-raised from the ``async with`` block + (unless the body raised its own, non-cancellation exception, which + takes precedence). + Note: If the trigger fires at the same moment that the task is cancelled externally, the external cancellation may be @@ -40,8 +46,15 @@ def __init__( self._kwargs = kwargs self.scope = CancelScope() self._trigger_task: asyncio.Task[None] | None = None + self._trigger_error: Exception | None = None async def __aenter__(self) -> CancelScope: + # The trigger task is created but deliberately not started here: + # if it could run (and complete) before the body is entered, its + # cancel() would land on this __aenter__ and the CancelledError + # would leak to the caller without the body ever running. By + # returning without awaiting, the trigger first runs once the + # body reaches an await point. current_task = asyncio.current_task() scope = self.scope @@ -50,12 +63,13 @@ async def _run_trigger() -> None: await self._fn(*self._args, **self._kwargs) except asyncio.CancelledError: return + except Exception as exc: + self._trigger_error = exc scope._should_cancel = True if current_task is not None and not current_task.done(): current_task.cancel() self._trigger_task = asyncio.create_task(_run_trigger()) - await asyncio.sleep(0) # Let the trigger task start running return self.scope async def __aexit__(self, exc_type, exc_val, exc_tb) -> bool: @@ -80,8 +94,15 @@ async def __aexit__(self, exc_type, exc_val, exc_tb) -> bool: if exc_type is asyncio.CancelledError and self.scope._should_cancel: self.scope.cancelled_caught = True + if self._trigger_error is not None: + # The body was cancelled because the trigger itself + # failed; surface that failure instead of swallowing it. + raise self._trigger_error return True # Suppress the CancelledError + if exc_type is None and self._trigger_error is not None: + raise self._trigger_error + return False @@ -126,23 +147,35 @@ async def run_and_cancelling( await do_main_work() # background_worker is cancelled here + If the background task fails while the body is running, the body is + *not* interrupted; the background exception is re-raised when the + block exits, unless the body raised its own exception (which takes + precedence). + Args: fn: Async callable to run in the background. *args: Positional arguments forwarded to *fn*. **kwargs: Keyword arguments forwarded to *fn*. """ task = asyncio.create_task(fn(*args, **kwargs)) + body_raised = False try: yield + except BaseException: + body_raised = True + raise finally: if task.done(): - # Propagate non-cancellation exceptions from the background task + # Propagate non-cancellation exceptions from the background + # task — but never mask an exception already propagating + # from the body. try: task.result() except asyncio.CancelledError: pass except Exception: - raise + if not body_raised: + raise else: task.cancel() try: @@ -190,6 +223,7 @@ def set(self) -> None: # If the task fails before calling task_status.set(), unblock started.wait() task.add_done_callback(lambda _: started.set()) + body_raised = False try: await started.wait() @@ -197,6 +231,9 @@ def set(self) -> None: task.result() # Propagate exception if task failed before started yield + except BaseException: + body_raised = True + raise finally: if task.done(): try: @@ -204,7 +241,8 @@ def set(self) -> None: except asyncio.CancelledError: pass except Exception: - raise + if not body_raised: + raise else: task.cancel() try: diff --git a/src/asyncio_util/_multicast_queue.py b/src/asyncio_util/_multicast_queue.py index 0bf3704..a4377e6 100644 --- a/src/asyncio_util/_multicast_queue.py +++ b/src/asyncio_util/_multicast_queue.py @@ -2,10 +2,12 @@ import asyncio from contextlib import asynccontextmanager -from typing import AsyncIterator, Generic, TypeVar +from typing import Any, AsyncIterator, Generic, TypeVar T = TypeVar("T") +_CLOSED = object() # end-of-stream sentinel (distinct from any user value) + class MulticastQueue(Generic[T]): """A broadcast queue that delivers each item to every active listener. @@ -29,7 +31,7 @@ class MulticastQueue(Generic[T]): def __init__(self, queue_size: int = 10) -> None: self._queue_size = queue_size - self._listeners: list[asyncio.Queue[T | None]] = [] + self._listeners: list[asyncio.Queue[Any]] = [] async def broadcast(self, value: T) -> None: """Send *value* to all active listeners.""" @@ -42,7 +44,7 @@ async def broadcast(self, value: T) -> None: @asynccontextmanager async def listen(self) -> AsyncIterator[_Listener[T]]: """Register a listener and yield an async iterable of broadcast values.""" - q: asyncio.Queue[T | None] = asyncio.Queue(maxsize=self._queue_size) + q: asyncio.Queue[Any] = asyncio.Queue(maxsize=self._queue_size) self._listeners.append(q) try: yield _Listener(q) @@ -52,7 +54,7 @@ async def listen(self) -> AsyncIterator[_Listener[T]]: # even if the queue is full by dropping an item to make room. while True: try: - q.put_nowait(None) + q.put_nowait(_CLOSED) break except asyncio.QueueFull: try: @@ -64,7 +66,7 @@ async def listen(self) -> AsyncIterator[_Listener[T]]: class _Listener(Generic[T]): """Async iterable wrapper around a listener queue.""" - def __init__(self, queue: asyncio.Queue[T | None]) -> None: + def __init__(self, queue: asyncio.Queue[Any]) -> None: self._queue = queue self._closed = False @@ -75,11 +77,16 @@ async def __anext__(self) -> T: if self._closed: raise StopAsyncIteration value = await self._queue.get() - if value is None: + if value is _CLOSED: self._closed = True raise StopAsyncIteration return value async def get(self) -> T: - """Get the next broadcast value.""" + """Get the next broadcast value. + + Raises: + StopAsyncIteration: If the listener has been closed (its + ``listen()`` context has exited). + """ return await self.__anext__() diff --git a/src/asyncio_util/_periodic.py b/src/asyncio_util/_periodic.py index 56653e4..3068ed5 100644 --- a/src/asyncio_util/_periodic.py +++ b/src/asyncio_util/_periodic.py @@ -14,8 +14,11 @@ async def periodic(period: float) -> AsyncIterator[tuple[float, float | None]]: A tuple of (elapsed_since_start, delta_since_last). delta is None on the first iteration. - If the loop body takes longer than *period*, the next iteration - starts immediately without sleeping. + Iterations are scheduled on the absolute ``start + k * period`` + grid, so per-iteration latency does not accumulate as drift. If + the loop body takes longer than *period*, the next iteration starts + (almost) immediately at the next free grid slot; missed slots are + skipped, not queued. """ if period <= 0: raise ValueError(f"period must be positive, got {period}") @@ -23,18 +26,19 @@ async def periodic(period: float) -> AsyncIterator[tuple[float, float | None]]: t0 = loop.time() t_last: float | None = None t_start = t0 + tick = 1 while True: delta = t_start - t_last if t_last is not None else None yield (t_start - t0, delta) - target = t_start + period now = loop.time() - delay = target - now - if delay > 0: - await asyncio.sleep(delay) - else: - await asyncio.sleep(0) + target = t0 + tick * period + while target <= now: # overran one or more slots: skip them + tick += 1 + target = t0 + tick * period + await asyncio.sleep(target - now) + tick += 1 t_last = t_start t_start = loop.time() diff --git a/src/asyncio_util/py.typed b/src/asyncio_util/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_async_value_held_for.py b/tests/test_async_value_held_for.py new file mode 100644 index 0000000..59838aa --- /dev/null +++ b/tests/test_async_value_held_for.py @@ -0,0 +1,98 @@ +"""Regression tests for wait_value(held_for=) and eventual_values(held_for=). + +wait_value(..., held_for=N) previously raised TimeoutError when the value +*successfully* held for N seconds, and eventual_values(..., held_for=N) had +the hold logic inverted. +""" + +import asyncio + +import pytest + +from asyncio_util import AsyncValue + + +@pytest.mark.asyncio +async def test_wait_value_held_for_returns_after_stable_hold(): + value = AsyncValue(0) + + async def set_value_later(): + await asyncio.sleep(0.05) + value.value = 10 + + asyncio.create_task(set_value_later()) + result = await asyncio.wait_for( + value.wait_value(lambda v: v > 5, held_for=0.1), timeout=1 + ) + assert result == 10 + + +@pytest.mark.asyncio +async def test_wait_value_held_for_immediately_matching_value(): + value = AsyncValue(10) + result = await asyncio.wait_for( + value.wait_value(lambda v: v > 5, held_for=0.05), timeout=1 + ) + assert result == 10 + + +@pytest.mark.asyncio +async def test_wait_value_held_for_restarts_on_flicker(): + value = AsyncValue(0) + loop = asyncio.get_running_loop() + start = loop.time() + + async def flicker(): + value.value = 10 # match + await asyncio.sleep(0.05) + value.value = 0 # lost before 0.1s elapsed + await asyncio.sleep(0.01) + value.value = 10 # match again; hold restarts + + task = asyncio.create_task(flicker()) + result = await asyncio.wait_for( + value.wait_value(lambda v: v > 5, held_for=0.1), timeout=1 + ) + assert result == 10 + assert loop.time() - start >= 0.15 + await task + + +@pytest.mark.asyncio +async def test_wait_value_held_for_with_timeout_still_times_out(): + value = AsyncValue(0) + with pytest.raises(asyncio.TimeoutError): + await value.wait_value(lambda v: v > 5, held_for=0.05, timeout=0.05) + + +@pytest.mark.asyncio +async def test_eventual_values_held_for_yields_only_stable_values(): + value = AsyncValue(0) + seen = [] + + async def consume(): + async for v in value.eventual_values(lambda v: v > 5, held_for=0.08): + seen.append(v) + if v == 30: + break + + async def produce(): + value.value = 10 # flickers away quickly: must not be yielded + await asyncio.sleep(0.02) + value.value = 0 + await asyncio.sleep(0.02) + value.value = 30 # stays: must be yielded + consumer = asyncio.create_task(consume()) + await asyncio.sleep(0) + producer = asyncio.create_task(produce()) + await asyncio.wait_for(asyncio.gather(consumer, producer), timeout=1) + assert seen == [30] + + +@pytest.mark.asyncio +async def test_eventual_values_held_for_initial_stable_value(): + value = AsyncValue(10) + iterator = value.eventual_values(lambda v: v > 5, held_for=0.05) + first = await asyncio.wait_for(anext(iterator), timeout=1) + assert first == 10 + await iterator.aclose() diff --git a/tests/test_async_value_robustness.py b/tests/test_async_value_robustness.py new file mode 100644 index 0000000..482b7e6 --- /dev/null +++ b/tests/test_async_value_robustness.py @@ -0,0 +1,82 @@ +"""Robustness tests: raising predicates must not corrupt delivery. + +A predicate that raises is an application bug, but it must be delivered +to its own waiter — not crash the assigning task, and not prevent other +waiters from being notified for the same change. +""" + +import asyncio + +import pytest + +from asyncio_util import AsyncValue + + +async def spin(count: int = 3) -> None: + for _ in range(count): + await asyncio.sleep(0) + + +@pytest.mark.asyncio +async def test_predicate_exception_is_delivered_to_the_waiter(): + value = AsyncValue(0) + + def bad_predicate(v): + if v == 1: + raise RuntimeError("boom") + return False + + task = asyncio.ensure_future(value.wait_value(bad_predicate)) + await spin() + value.value = 1 # must not raise in the assigning task + with pytest.raises(RuntimeError, match="boom"): + await asyncio.wait_for(task, timeout=1) + + +@pytest.mark.asyncio +async def test_predicate_exception_does_not_starve_other_waiters(): + value = AsyncValue(0) + + def bad_predicate(v): + raise RuntimeError("boom") + + bad_task = asyncio.ensure_future(value.wait_value(bad_predicate)) + good_task = asyncio.ensure_future(value.wait_value(1)) + edge_task = asyncio.ensure_future(value.wait_transition()) + await spin() + value.value = 1 + with pytest.raises(RuntimeError, match="boom"): + await asyncio.wait_for(bad_task, timeout=1) + assert await asyncio.wait_for(good_task, timeout=1) == 1 + assert await asyncio.wait_for(edge_task, timeout=1) == (1, 0) + + +@pytest.mark.asyncio +async def test_transform_exception_reaches_setter_after_notifications(): + value = AsyncValue(0) + + def bad_transform(v): + if v == 1: + raise RuntimeError("boom") + return v + + async with value.open_transform(bad_transform): + waiter = asyncio.ensure_future(value.wait_value(1)) + await spin() + with pytest.raises(RuntimeError, match="boom"): + value.value = 1 + # The waiter was still notified despite the transform error. + assert await asyncio.wait_for(waiter, timeout=1) == 1 + + +@pytest.mark.asyncio +async def test_rapid_synchronous_changes_are_not_missed(): + # The matching value is captured at assignment time, even if the + # value changes again before the waiter gets to run. + value = AsyncValue(10) + task = asyncio.ensure_future(value.wait_value(20)) + await spin() + value.value = 20 + value.value = 10 + assert await asyncio.wait_for(task, timeout=1) == 20 + assert value.value == 10 diff --git a/tests/test_move_on_when.py b/tests/test_move_on_when.py index a7bdc21..fd3d812 100644 --- a/tests/test_move_on_when.py +++ b/tests/test_move_on_when.py @@ -71,6 +71,9 @@ async def trigger(): with pytest.raises(ValueError, match="body error"): async with move_on_when(trigger) as scope: + # Reach an await point so the trigger task starts running + # before the body raises. + await asyncio.sleep(0) raise ValueError("body error") await asyncio.sleep(0.01) diff --git a/tests/test_stream_helpers_robustness.py b/tests/test_stream_helpers_robustness.py new file mode 100644 index 0000000..c02ed7d --- /dev/null +++ b/tests/test_stream_helpers_robustness.py @@ -0,0 +1,88 @@ +"""Regression tests for compose_values, MulticastQueue and periodic. + +Covers: +- compose_values: a raising _transform_ was silently swallowed, leaving + the composite output frozen at its last good value. +- MulticastQueue: broadcasting a legitimate None terminated listeners + (None doubled as the end-of-stream sentinel). +- periodic: scheduling drifted because targets were computed relative + to the previous wake-up instead of the absolute start time. +""" + +import asyncio + +import pytest + +from asyncio_util import AsyncValue, MulticastQueue, compose_values, periodic + + +class TestComposeValuesTransformErrors: + async def test_transform_error_propagates_to_setter(self): + x = AsyncValue(1) + y = AsyncValue(0) + + with compose_values(_transform_=lambda s: s.y // s.x, x=x, y=y) as out: + assert out.value == 0 + with pytest.raises(ZeroDivisionError): + x.value = 0 + + async def test_output_recovers_after_transform_error(self): + x = AsyncValue(1) + y = AsyncValue(8) + + with compose_values(_transform_=lambda s: s.y // s.x, x=x, y=y) as out: + with pytest.raises(ZeroDivisionError): + x.value = 0 + x.value = 2 + assert out.value == 4 + + async def test_other_waiters_still_notified_on_transform_error(self): + x = AsyncValue(1) + + with compose_values(_transform_=lambda s: 1 // s.x, x=x): + waiter = asyncio.ensure_future(x.wait_value(0)) + await asyncio.sleep(0) + with pytest.raises(ZeroDivisionError): + x.value = 0 + assert await asyncio.wait_for(waiter, timeout=1) == 0 + + +class TestMulticastQueueNoneValues: + async def test_broadcasting_none_does_not_end_the_stream(self): + mq: MulticastQueue[str | None] = MulticastQueue() + received = [] + + async def subscriber(): + async with mq.listen() as items: + async for item in items: + received.append(item) + if len(received) == 3: + break + + task = asyncio.ensure_future(subscriber()) + await asyncio.sleep(0) + await mq.broadcast("a") + await mq.broadcast(None) + await mq.broadcast("b") + await asyncio.wait_for(task, timeout=1) + assert received == ["a", None, "b"] + + +class TestPeriodicGrid: + async def test_schedule_does_not_drift_after_overrun(self): + loop = asyncio.get_running_loop() + period = 0.05 + start = loop.time() + wakes = [] + + async for _elapsed, _delta in periodic(period): + wakes.append(loop.time() - start) + if len(wakes) == 2: + await asyncio.sleep(period * 1.5) # overrun one slot + if len(wakes) == 5: + break + + # After the overrun, later iterations must snap back to the + # k * period grid instead of shifting permanently. + final_offset = wakes[-1] % period + assert min(final_offset, period - final_offset) < period * 0.4 diff --git a/tests/test_task_helpers_robustness.py b/tests/test_task_helpers_robustness.py new file mode 100644 index 0000000..725aae9 --- /dev/null +++ b/tests/test_task_helpers_robustness.py @@ -0,0 +1,119 @@ +"""Regression tests for move_on_when / run_and_cancelling / wait_any. + +Covers: +- move_on_when with a trigger that completes immediately (previously + leaked CancelledError out of __aenter__; the body never ran). +- move_on_when with a raising trigger (previously swallowed the + exception and let the body run to completion). +- run_and_cancelling with a failed background task while the body is + raising (previously masked the body's exception). +- wait_any/wait_all task leak when creating a task raises synchronously. +""" + +import asyncio + +import pytest + +from asyncio_util import ( + move_on_when, + run_and_cancelling, + wait_all, + wait_any, +) + + +class TestMoveOnWhenImmediateTrigger: + async def test_already_set_event_does_not_leak_cancellation(self): + event = asyncio.Event() + event.set() + ran_body = False + + async with move_on_when(event.wait) as scope: + ran_body = True + await asyncio.sleep(10) + + assert ran_body + assert scope.cancelled_caught + + async def test_trigger_completing_without_awaiting(self): + async def instant(): + return None + + async with move_on_when(instant) as scope: + await asyncio.sleep(10) + + assert scope.cancelled_caught + + +class TestMoveOnWhenRaisingTrigger: + async def test_trigger_exception_propagates_and_cancels_body(self): + body_completed = False + + async def bad_trigger(): + await asyncio.sleep(0.01) + raise ValueError("trigger boom") + + with pytest.raises(ValueError, match="trigger boom"): + async with move_on_when(bad_trigger): + await asyncio.sleep(10) + body_completed = True + + assert not body_completed + + async def test_body_exception_wins_over_trigger_exception(self): + async def bad_trigger(): + raise ValueError("trigger boom") + + with pytest.raises(RuntimeError, match="body boom"): + async with move_on_when(bad_trigger): + raise RuntimeError("body boom") + + +class TestRunAndCancellingExceptionMasking: + async def test_body_exception_is_not_masked_by_background_failure(self): + async def failing_background(): + raise ValueError("bg boom") + + with pytest.raises(RuntimeError, match="body boom"): + async with run_and_cancelling(failing_background): + await asyncio.sleep(0.05) # let the background task fail + raise RuntimeError("body boom") + + async def test_background_failure_propagates_when_body_is_clean(self): + async def failing_background(): + raise ValueError("bg boom") + + with pytest.raises(ValueError, match="bg boom"): + async with run_and_cancelling(failing_background): + await asyncio.sleep(0.05) + + +def _pending_stray_tasks() -> list[asyncio.Task]: + return [t for t in asyncio.all_tasks() if t is not asyncio.current_task()] + + +class TestWaitHelpersTaskLeak: + async def test_wait_any_cancels_started_tasks_on_sync_creation_error(self): + async def waiter(): + await asyncio.sleep(10) + + def broken(): + raise RuntimeError("sync failure") + + with pytest.raises(RuntimeError, match="sync failure"): + await wait_any(waiter, broken) + # The already-created waiter task must not be left pending. + await asyncio.sleep(0) + assert _pending_stray_tasks() == [] + + async def test_wait_all_cancels_started_tasks_on_sync_creation_error(self): + async def waiter(): + await asyncio.sleep(10) + + def broken(): + raise RuntimeError("sync failure") + + with pytest.raises(RuntimeError, match="sync failure"): + await wait_all(waiter, broken) + await asyncio.sleep(0) + assert _pending_stray_tasks() == [] diff --git a/uv.lock b/uv.lock index 53e3f72..7bd8067 100644 --- a/uv.lock +++ b/uv.lock @@ -1,11 +1,6 @@ version = 1 revision = 3 -requires-python = ">=3.8" -resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version == '3.9.*'", - "python_full_version < '3.9'", -] +requires-python = ">=3.10" [[package]] name = "asyncio-util" @@ -13,307 +8,910 @@ source = { editable = "." } [package.dev-dependencies] dev = [ - { name = "pytest", version = "8.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "pytest-asyncio", version = "0.24.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "pytest-asyncio", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "pytest-asyncio", version = "1.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "coverage", extra = ["toml"] }, + { name = "pytest" }, + { name = "pytest-asyncio" }, +] +docs = [ + { name = "mkdocs" }, + { name = "mkdocs-material" }, + { name = "mkdocs-static-i18n" }, + { name = "mkdocstrings", extra = ["python"] }, ] [package.metadata] [package.metadata.requires-dev] dev = [ + { name = "coverage", extras = ["toml"], specifier = ">=7.0" }, { name = "pytest", specifier = ">=7.0" }, { name = "pytest-asyncio", specifier = ">=0.21" }, ] +docs = [ + { name = "mkdocs", specifier = ">=1.6" }, + { name = "mkdocs-material", specifier = ">=9.5" }, + { name = "mkdocs-static-i18n", specifier = ">=1.2" }, + { name = "mkdocstrings", extras = ["python"], specifier = ">=0.27" }, +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.flatt.tech/simple/" } +sdist = { url = "https://pypi.flatt.tech/files/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://pypi.flatt.tech/files/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] [[package]] name = "backports-asyncio-runner" version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +source = { registry = "https://pypi.flatt.tech/simple/" } +sdist = { url = "https://pypi.flatt.tech/files/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://pypi.flatt.tech/files/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + +[[package]] +name = "backrefs" +version = "7.0" +source = { registry = "https://pypi.flatt.tech/simple/" } +sdist = { url = "https://pypi.flatt.tech/files/packages/5e/a7/a7dd63622beef68cc0d3c3c36d472e143dd95443d5ebf14cd1a5b4dfbf11/backrefs-7.0.tar.gz", hash = "sha256:4989bb9e1e99eb23647c7160ed51fb21d0b41b5d200f2d3017da41e023097e82", size = 7012453, upload-time = "2026-04-28T16:28:04.215Z" } +wheels = [ + { url = "https://pypi.flatt.tech/files/packages/d4/39/39a31d7eae729ea14ed10c3ccef79371197177b9355a86cb3525709e8502/backrefs-7.0-py310-none-any.whl", hash = "sha256:b57cd227ea556b0aed3dc9b8da4628db4eabc0402c6d7fcfc69283a93955f7e9", size = 380824, upload-time = "2026-04-28T16:27:55.647Z" }, + { url = "https://pypi.flatt.tech/files/packages/c9/b5/9302644225ba7dfa934a2ff2b9c7bb85701313a90dddb3dfaf693fa5bae2/backrefs-7.0-py311-none-any.whl", hash = "sha256:a0fa7360c63509e9e077e174ef4e6d3c21c8db94189b9d957289ae6d794b9475", size = 392626, upload-time = "2026-04-28T16:27:57.42Z" }, + { url = "https://pypi.flatt.tech/files/packages/36/da/87912ddec6e06feffbaa3d7aa18fc6352bee2e8f1fee185d7d1690f8f4e8/backrefs-7.0-py312-none-any.whl", hash = "sha256:ca42ce6a49ace3d75684dfa9937f3373902a63284ecb385ce36d15e5dcb41c12", size = 398537, upload-time = "2026-04-28T16:27:58.913Z" }, + { url = "https://pypi.flatt.tech/files/packages/00/bb/90ba423612b6aa0adccc6b1874bcd4a9b44b660c0c16f346611e00f64ac3/backrefs-7.0-py313-none-any.whl", hash = "sha256:f2c52955d631b9e1ac4cd56209f0a3a946d592b98e7790e77699339ae01c102a", size = 400491, upload-time = "2026-04-28T16:28:00.928Z" }, + { url = "https://pypi.flatt.tech/files/packages/3e/5c/fb93d3092640a24dfb7bd7727a24016d7c01774ca013e60efd3f683c8002/backrefs-7.0-py314-none-any.whl", hash = "sha256:a6448b28180e3ca01134c9cf09dcebafad8531072e09903c5451748a05f24bc9", size = 412349, upload-time = "2026-04-28T16:28:02.412Z" }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.flatt.tech/simple/" } +sdist = { url = "https://pypi.flatt.tech/files/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://pypi.flatt.tech/files/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.flatt.tech/simple/" } +sdist = { url = "https://pypi.flatt.tech/files/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, + { url = "https://pypi.flatt.tech/files/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182, upload-time = "2026-04-02T09:25:40.673Z" }, + { url = "https://pypi.flatt.tech/files/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329, upload-time = "2026-04-02T09:25:42.354Z" }, + { url = "https://pypi.flatt.tech/files/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230, upload-time = "2026-04-02T09:25:44.281Z" }, + { url = "https://pypi.flatt.tech/files/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890, upload-time = "2026-04-02T09:25:45.475Z" }, + { url = "https://pypi.flatt.tech/files/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930, upload-time = "2026-04-02T09:25:46.58Z" }, + { url = "https://pypi.flatt.tech/files/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109, upload-time = "2026-04-02T09:25:48.031Z" }, + { url = "https://pypi.flatt.tech/files/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684, upload-time = "2026-04-02T09:25:49.245Z" }, + { url = "https://pypi.flatt.tech/files/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785, upload-time = "2026-04-02T09:25:50.671Z" }, + { url = "https://pypi.flatt.tech/files/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055, upload-time = "2026-04-02T09:25:51.802Z" }, + { url = "https://pypi.flatt.tech/files/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502, upload-time = "2026-04-02T09:25:53.388Z" }, + { url = "https://pypi.flatt.tech/files/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295, upload-time = "2026-04-02T09:25:54.765Z" }, + { url = "https://pypi.flatt.tech/files/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145, upload-time = "2026-04-02T09:25:55.904Z" }, + { url = "https://pypi.flatt.tech/files/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884, upload-time = "2026-04-02T09:25:57.074Z" }, + { url = "https://pypi.flatt.tech/files/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343, upload-time = "2026-04-02T09:25:58.199Z" }, + { url = "https://pypi.flatt.tech/files/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174, upload-time = "2026-04-02T09:25:59.322Z" }, + { url = "https://pypi.flatt.tech/files/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805, upload-time = "2026-04-02T09:26:00.756Z" }, + { url = "https://pypi.flatt.tech/files/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://pypi.flatt.tech/files/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://pypi.flatt.tech/files/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://pypi.flatt.tech/files/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://pypi.flatt.tech/files/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://pypi.flatt.tech/files/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://pypi.flatt.tech/files/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://pypi.flatt.tech/files/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://pypi.flatt.tech/files/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://pypi.flatt.tech/files/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://pypi.flatt.tech/files/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://pypi.flatt.tech/files/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://pypi.flatt.tech/files/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://pypi.flatt.tech/files/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://pypi.flatt.tech/files/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://pypi.flatt.tech/files/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://pypi.flatt.tech/files/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://pypi.flatt.tech/files/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://pypi.flatt.tech/files/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://pypi.flatt.tech/files/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://pypi.flatt.tech/files/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://pypi.flatt.tech/files/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://pypi.flatt.tech/files/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://pypi.flatt.tech/files/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://pypi.flatt.tech/files/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://pypi.flatt.tech/files/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://pypi.flatt.tech/files/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://pypi.flatt.tech/files/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://pypi.flatt.tech/files/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://pypi.flatt.tech/files/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://pypi.flatt.tech/files/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://pypi.flatt.tech/files/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://pypi.flatt.tech/files/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://pypi.flatt.tech/files/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://pypi.flatt.tech/files/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://pypi.flatt.tech/files/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://pypi.flatt.tech/files/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://pypi.flatt.tech/files/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://pypi.flatt.tech/files/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://pypi.flatt.tech/files/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://pypi.flatt.tech/files/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://pypi.flatt.tech/files/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://pypi.flatt.tech/files/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://pypi.flatt.tech/files/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://pypi.flatt.tech/files/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://pypi.flatt.tech/files/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://pypi.flatt.tech/files/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://pypi.flatt.tech/files/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://pypi.flatt.tech/files/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://pypi.flatt.tech/files/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://pypi.flatt.tech/files/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://pypi.flatt.tech/files/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://pypi.flatt.tech/files/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://pypi.flatt.tech/files/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://pypi.flatt.tech/files/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://pypi.flatt.tech/files/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://pypi.flatt.tech/files/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://pypi.flatt.tech/files/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://pypi.flatt.tech/files/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://pypi.flatt.tech/files/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://pypi.flatt.tech/files/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://pypi.flatt.tech/files/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://pypi.flatt.tech/files/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://pypi.flatt.tech/files/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://pypi.flatt.tech/files/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://pypi.flatt.tech/files/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://pypi.flatt.tech/files/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://pypi.flatt.tech/files/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://pypi.flatt.tech/files/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://pypi.flatt.tech/files/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://pypi.flatt.tech/files/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://pypi.flatt.tech/files/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://pypi.flatt.tech/files/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://pypi.flatt.tech/files/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://pypi.flatt.tech/files/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://pypi.flatt.tech/files/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://pypi.flatt.tech/files/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://pypi.flatt.tech/files/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://pypi.flatt.tech/files/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://pypi.flatt.tech/files/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://pypi.flatt.tech/files/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.flatt.tech/simple/" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://pypi.flatt.tech/files/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://pypi.flatt.tech/files/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, ] [[package]] name = "colorama" version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +source = { registry = "https://pypi.flatt.tech/simple/" } +sdist = { url = "https://pypi.flatt.tech/files/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, + { url = "https://pypi.flatt.tech/files/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.14.3" +source = { registry = "https://pypi.flatt.tech/simple/" } +sdist = { url = "https://pypi.flatt.tech/files/packages/b4/91/0a7c28934e50d8ac9a7b117712d176f2953c3170bccced5eaacfa3e96175/coverage-7.14.3.tar.gz", hash = "sha256:1a7563a443f3d53fdeb040ec8c9f7466aed7ca3dc5891aa09d3ca3625fa4387f", size = 924398, upload-time = "2026-06-22T23:10:25.584Z" } +wheels = [ + { url = "https://pypi.flatt.tech/files/packages/ec/bd/b01188f0de73ee8b6597cf20c63fccd898ad31405772f15165cb61a62c00/coverage-7.14.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:360bec1f58e7243e3405d3bdf7a1a8115aa9b448d54dc7cd6f7b7e0e9406b62e", size = 220378, upload-time = "2026-06-22T23:07:38.925Z" }, + { url = "https://pypi.flatt.tech/files/packages/33/eb/f7aa3cb46500b709070c8d12335446971ec8b8c2ea155fea05d2000b4b1f/coverage-7.14.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ed68faa5e85de2f3e400bc3f122e5c82735a58c8bb24b9f63a2215954ba17b2d", size = 220895, upload-time = "2026-06-22T23:07:41.536Z" }, + { url = "https://pypi.flatt.tech/files/packages/c3/c0/b41b8499fc9060ca40ad2a197d301155be1ead398f0f0bfdb27b2b4a660f/coverage-7.14.3-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:830c1fca669c572dec37ce9c838224ee45aac5be0f6961edf871e82e49d6537c", size = 247631, upload-time = "2026-06-22T23:07:43.244Z" }, + { url = "https://pypi.flatt.tech/files/packages/da/bb/e9ecea1307c6a549c223842cccbd5d55193cc27b82f26338782d4355047c/coverage-7.14.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a64caee2193563601dbaaa55fe2dcf597debef04a2f8f1fa8a07aa4bb7ac7a1e", size = 249460, upload-time = "2026-06-22T23:07:45.147Z" }, + { url = "https://pypi.flatt.tech/files/packages/59/cb/3821542809b7b726296fd364ed1c23d10a5770f1469957010c3b4bc5d408/coverage-7.14.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0096fd7559178f0cc9cf088f2dbd2a02ef85bacaa69732c633517286b4494610", size = 251324, upload-time = "2026-06-22T23:07:46.875Z" }, + { url = "https://pypi.flatt.tech/files/packages/76/27/f34f66f0ff152189ccc7b3f0582cf7909e239cb3b8c214362ed2149719b8/coverage-7.14.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6197e5a00183c11a8ce7c6abd18be1a9189fd8399084ffc95196f4f0db4f2137", size = 253237, upload-time = "2026-06-22T23:07:48.352Z" }, + { url = "https://pypi.flatt.tech/files/packages/22/81/aa363fa95d14fc892bd5de80edadc8d7cce584a0f6376f6336e492618e67/coverage-7.14.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7dfe427045520d6abca33687dfef767b4f635015893a1816c5decb12eb72ce18", size = 248344, upload-time = "2026-06-22T23:07:49.896Z" }, + { url = "https://pypi.flatt.tech/files/packages/66/fe/dc8a149441a3fea611cbbaf46bb12099adbe08f69903df1794581b0504b8/coverage-7.14.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9a3f142070eb7b82fc4085a55d887396f9c4e21250bccebe2ba22502c45b9647", size = 249365, upload-time = "2026-06-22T23:07:51.464Z" }, + { url = "https://pypi.flatt.tech/files/packages/f8/a2/0004127deee122e020be24a4d86ce72fa14ae28198811b945aabf91293b5/coverage-7.14.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:64b2055bb6e0dc945af35cdeceb3633e6ed9273475ef3af85592410fd6803803", size = 247369, upload-time = "2026-06-22T23:07:53.064Z" }, + { url = "https://pypi.flatt.tech/files/packages/1e/72/3654c004f4df4f0c5a9643d9abaed5b26e5d3c1d0ecabe788786cb425efa/coverage-7.14.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:1551b4caac3e3ec9f2bfcec6bf3776e01c0edbdd2e240431a50ca1a1aac72c27", size = 251182, upload-time = "2026-06-22T23:07:54.789Z" }, + { url = "https://pypi.flatt.tech/files/packages/a5/2f/7bdcdf1e7c4d0632648852768063c25582a0a747bb5f8036a04e211e7eb7/coverage-7.14.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:583d50d59142f8549470bd6390471d0fe8b8c8d69d6a0f28ac71e05380cef640", size = 247639, upload-time = "2026-06-22T23:07:56.254Z" }, + { url = "https://pypi.flatt.tech/files/packages/03/dc/0e01b071f69021d262a51ce39345dd6bc194465db0acfc7b34fd89e6b787/coverage-7.14.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0bb8a6bc7015efdf8a928753b25da1b9ca2d6f24ef04d2ee0688e486f32aae7", size = 248242, upload-time = "2026-06-22T23:07:57.692Z" }, + { url = "https://pypi.flatt.tech/files/packages/1c/51/08279e6ebe3479bf705db5fdc1a968e44ba1567e4cbc567f76b45f5e646e/coverage-7.14.3-cp310-cp310-win32.whl", hash = "sha256:d48400185564042287dc487c1f016a3397f18ab4f4c5d5ec36edc218f7ffa35b", size = 222431, upload-time = "2026-06-22T23:07:59.094Z" }, + { url = "https://pypi.flatt.tech/files/packages/40/2f/5c56670781fee5722ef0c415a74750c9a033bfacdb9d07b1493a0308108d/coverage-7.14.3-cp310-cp310-win_amd64.whl", hash = "sha256:eadea7aba74e40adee867a8c0eec17b820b061d308a4b014f7a0e118c2b0aa61", size = 223059, upload-time = "2026-06-22T23:08:00.662Z" }, + { url = "https://pypi.flatt.tech/files/packages/f1/24/efb17eb94018dd3415d0e8a76a4786a866e8964aa9c50f033399d23939c2/coverage-7.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e574801e1d643561594aa021206c46d80b257e9853087090ba97bed8b0a509d3", size = 220501, upload-time = "2026-06-22T23:08:02.182Z" }, + { url = "https://pypi.flatt.tech/files/packages/76/93/32f1bfca6cdd34259c8af42820a034b7a28dfb44969a13ed38c17e0ba5b0/coverage-7.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f82b6bb7d75a2613e85d07cefa3a8c973d0544a8993337f6e2728e4a1e94c305", size = 221008, upload-time = "2026-06-22T23:08:03.701Z" }, + { url = "https://pypi.flatt.tech/files/packages/eb/88/0d0f974855ff905d15a64f7873d00bdc4182e2736267486c6634f4af293c/coverage-7.14.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a2335ea5fed26af2e831094964fa3f8fae60b45f7e37fcc2d3b615b2add3ad87", size = 251420, upload-time = "2026-06-22T23:08:05.211Z" }, + { url = "https://pypi.flatt.tech/files/packages/39/7f/117dd2ec65e4140576f8ef991d88220f9b806769f7a8c20e0550c0f924e2/coverage-7.14.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fbb8c3a98e779013786ae01d229662aeacbc77100efbd3f2f245219ace5af700", size = 253331, upload-time = "2026-06-22T23:08:06.672Z" }, + { url = "https://pypi.flatt.tech/files/packages/87/55/f0bd6d6538e3f16829fb8a44b6c0d2fe9da638bbfdd6a20f8b5da8f4fa81/coverage-7.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac082660de8f429ba0ea363595abb838998570b9a7546777c60f413ab902bbde", size = 255441, upload-time = "2026-06-22T23:08:08.208Z" }, + { url = "https://pypi.flatt.tech/files/packages/1e/98/aa71f7879019c846a8a9662579ea4484b0202cf1e252ffeed647075e7eca/coverage-7.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac012839ff7e396030f1e94e10553a431d14e4de2ab65cb3acb72bbd5628ca2", size = 257398, upload-time = "2026-06-22T23:08:09.749Z" }, + { url = "https://pypi.flatt.tech/files/packages/f3/4f/5fd367e59844190f5965015d7bee899e67a89d13eb2760118479bf836f2f/coverage-7.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5952f8c1bda2a5347154450379316e6dfa4d934d62ca35f6784451e6f55074fb", size = 251558, upload-time = "2026-06-22T23:08:11.37Z" }, + { url = "https://pypi.flatt.tech/files/packages/8f/de/5383a6ee5a6376701fe07d980fa8e4a66c0c377fead16712720340d701a3/coverage-7.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8cf0f2509acb4619e2471a1951089054dd58ebea7a912066d2ea56dd4c24ca4a", size = 253134, upload-time = "2026-06-22T23:08:13.04Z" }, + { url = "https://pypi.flatt.tech/files/packages/01/99/09542b1a99f788e3daec7f0fadc288821e71aca9ea298d51bfa1ba79fed5/coverage-7.14.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:2e41fd3aab806770008279a93879b0924b16247e09ab537c043d08bbca53b4ab", size = 251195, upload-time = "2026-06-22T23:08:14.606Z" }, + { url = "https://pypi.flatt.tech/files/packages/02/9d/722fe8c13f0fbb064491b9e8656e56a606286792e5068c47ca1042e773e8/coverage-7.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f0a47095963cfe054e0df178daca95aec21e680d6076da807c3add28dfe920f7", size = 254959, upload-time = "2026-06-22T23:08:16.431Z" }, + { url = "https://pypi.flatt.tech/files/packages/fb/58/943627179ff1d82da9e54d0a5b0bb907bb19cf19515599ccd921de50b469/coverage-7.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a090cbf9521e78ffdb2fcf448b72902afe9f5923ff6a12d5c0d0120200348af9", size = 250914, upload-time = "2026-06-22T23:08:18.03Z" }, + { url = "https://pypi.flatt.tech/files/packages/a5/d4/803efcbf9ae5567454a0c71e983589529448e2704ee0da2dc0163d482f18/coverage-7.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4d310baf69a4fbe8a098ce727e4808a34866ac718a6f759ae659cbd3221358bc", size = 251824, upload-time = "2026-06-22T23:08:19.704Z" }, + { url = "https://pypi.flatt.tech/files/packages/32/79/3f78ea9563132746eed5cecb75d2e576f9d8fec45a47242b5ae0950b82a3/coverage-7.14.3-cp311-cp311-win32.whl", hash = "sha256:74fdd718d88fe144f4579b8747873a07ec3f04cb837d5faec5a25d9e22fa31a8", size = 222594, upload-time = "2026-06-22T23:08:21.311Z" }, + { url = "https://pypi.flatt.tech/files/packages/85/22/9ebbc5a2ab42ac5d0eea1f48648629e1de9bbe41ec243ed6b93d55a5a53f/coverage-7.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:cc96aa922e21d4bc5d5ed3c915cef27dfcbc13686f47d5e378d647fbfba655a2", size = 223073, upload-time = "2026-06-22T23:08:23.318Z" }, + { url = "https://pypi.flatt.tech/files/packages/71/af/69d5fcc16cb555153f99cec5467922f226be0369f7335a9506856d2a7bd0/coverage-7.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:c66f9f9d4f1e9712eb9b1de5310f881d4e2188cfcba5065e1a8490f38687f2c4", size = 222617, upload-time = "2026-06-22T23:08:25.054Z" }, + { url = "https://pypi.flatt.tech/files/packages/bd/b0/8a911f6ffe6974dac4df95b468ab9a2899d0e59f0f99a489afeec39f00bc/coverage-7.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3d74ff26299c4879ce3a4d826f9d3d4d556fd285fde7bbce3c0ef5a8ab1cec24", size = 220672, upload-time = "2026-06-22T23:08:26.621Z" }, + { url = "https://pypi.flatt.tech/files/packages/36/16/0fc0cb52538783dbbae0934b834f5a58fd5354380ee6cad4a07b15dc845d/coverage-7.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:96150a9cf3468ea20f0bc5d0e21b3df8972c31480ef90fa7614b773cc6429665", size = 221035, upload-time = "2026-06-22T23:08:28.372Z" }, + { url = "https://pypi.flatt.tech/files/packages/77/e2/421ccfbb48335ac49e93301478cf5d623b0c2bf1c0cadd8e2b2fc6c0c710/coverage-7.14.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:27d07a46500ba23515b838dbcf52512026af04090755cf6cc64166d88c9b9a1a", size = 252540, upload-time = "2026-06-22T23:08:30.226Z" }, + { url = "https://pypi.flatt.tech/files/packages/06/c2/05b8c890097c61a7f4406b35396b997a635200ded0339eda83dfbe526c5f/coverage-7.14.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:621e13c6108234d7960aaf5762ab5c3c00f33c30c15af06dcbff0c73bf112727", size = 255274, upload-time = "2026-06-22T23:08:31.876Z" }, + { url = "https://pypi.flatt.tech/files/packages/dc/be/b6d9efe447f8ba3c3c854195f326bd64c54b907d936cd2fdebf8767ec72e/coverage-7.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b60ca6d8af70473491a15a343cbabab2e8f9ea66a4376e81c7aa24876a6f977", size = 256389, upload-time = "2026-06-22T23:08:33.843Z" }, + { url = "https://pypi.flatt.tech/files/packages/d4/3c/f26e50acc429e608bc534ac06f0a3c169019c798178ec5e9de3dbc0df9c9/coverage-7.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c90a7cdd5e380e1ce02f19792e2ac2fbfbf177e35a27e69fd3e873b30d895c0c", size = 258648, upload-time = "2026-06-22T23:08:35.481Z" }, + { url = "https://pypi.flatt.tech/files/packages/9e/a2/01c1fabf816c8e1dae197e258edf878a3d3ddc86fbda34b76e5794277d8f/coverage-7.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5d788e5fd55347eef06ca0732c77d04a264de67e8ff24631270cdff3767a60cf", size = 252949, upload-time = "2026-06-22T23:08:37.562Z" }, + { url = "https://pypi.flatt.tech/files/packages/89/c6/941166dd79c31fd44a13063780ae8d552eee0089a0a0930b9bdb7df554ed/coverage-7.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62c7f79db2851c95ef020e5d28b97afde3daf9f7febcd35b53e05638f729063f", size = 254310, upload-time = "2026-06-22T23:08:39.174Z" }, + { url = "https://pypi.flatt.tech/files/packages/10/31/80b1fd028201a961033ce95be3cd1e39e521b3762e6b4a1ac1616cb291e7/coverage-7.14.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:90f7608aeb5d9b60b523b9fb2a4ee1973867cc4865a3f26fe6c7577073b70205", size = 252453, upload-time = "2026-06-22T23:08:40.84Z" }, + { url = "https://pypi.flatt.tech/files/packages/5f/85/c3d9addd94c4b524f3f4af0232075f5fe7170ce99a1386edff803e5934db/coverage-7.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1e3b91f9c4740aeb571ecf82e5e8d8e4ab62d34fcb5a5d4e5baa38c6f7d2857c", size = 256522, upload-time = "2026-06-22T23:08:42.494Z" }, + { url = "https://pypi.flatt.tech/files/packages/91/14/e5a0575f73795af3a7a9ae13dadf812e17d32422896839987dc3f86947e1/coverage-7.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c946099774a7699de03cbd0ff0a64e21aed4525eed9d959adde4afe6d15758ef", size = 252023, upload-time = "2026-06-22T23:08:44.243Z" }, + { url = "https://pypi.flatt.tech/files/packages/38/9b/9652ee531937ce3b8a63a8896885b2b4a2d56adc30e53c9540c666286d88/coverage-7.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:16b206e521feb8b7133a45754643dead0538489cf8b783b90cf5f4e3299625fd", size = 253893, upload-time = "2026-06-22T23:08:46.113Z" }, + { url = "https://pypi.flatt.tech/files/packages/b1/05/42678841c8c38e4b08bdfc48269f5a16dfbf5806000fe6a89b4cece3c691/coverage-7.14.3-cp312-cp312-win32.whl", hash = "sha256:ea3169c7116eb6cdf7608c6c7da9ecfcb3da40688e3a510fac2d1d2bafd6dc35", size = 222734, upload-time = "2026-06-22T23:08:47.858Z" }, + { url = "https://pypi.flatt.tech/files/packages/df/87/07a4fcee55177a25f1b52331a8e92cf4f2c53b1a9c75ce2981fd59c684ad/coverage-7.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:7ea52fc08f007bcc494d4bb3df3851e95843d881860ba38fe2c64dc100db5e7d", size = 223266, upload-time = "2026-06-22T23:08:49.494Z" }, + { url = "https://pypi.flatt.tech/files/packages/aa/34/2b8b66a989282ea7b370beb49f50bab29470dc30bb0b03935b6b802782f7/coverage-7.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:8cec0ad652ec57790970d817490105bd917d783c2f7b38d6b58a0ca312e1a336", size = 222655, upload-time = "2026-06-22T23:08:51.766Z" }, + { url = "https://pypi.flatt.tech/files/packages/a9/83/7fefbf5df23ed2b7f489907564a7b34b9b07098128e12e0fdfa92626e456/coverage-7.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47968988b367990ae4ab17523790c38cd125e02c6bfd379b6022be2d40bdc38c", size = 220699, upload-time = "2026-06-22T23:08:53.522Z" }, + { url = "https://pypi.flatt.tech/files/packages/31/e6/38c3653ff6d56d704b29241362387ca824e38e15b76fdcb7096538195790/coverage-7.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0ee68f5c34812780f3a7063382c0a9fcbb99985b7ddcdcaa626e4f3fb2e0783a", size = 221068, upload-time = "2026-06-22T23:08:55.571Z" }, + { url = "https://pypi.flatt.tech/files/packages/20/86/4f5c45d51c5cd10a128933f0fd235393c9146abbfd2ce2dfa68b3267ead3/coverage-7.14.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fa9e5c6857a7e80fa22ace5cf3550ae392bbfc322f1d8dd2d2d5a8be38cec027", size = 252060, upload-time = "2026-06-22T23:08:57.464Z" }, + { url = "https://pypi.flatt.tech/files/packages/82/50/dfce42eff2cecabcd5a9bbad5489449c87db3415f408d23ffee417ce01f6/coverage-7.14.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:98a0859b0e98e43e1178a9402e19c8127766b14f7109a374d976e5a62c0e5c73", size = 254657, upload-time = "2026-06-22T23:08:59.453Z" }, + { url = "https://pypi.flatt.tech/files/packages/ba/d2/639ceb1bc8038fd0d66768278d5dc22df3391918b8278c2a21aa2602a531/coverage-7.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69918344541ed9c8368566c2adc03c0e33d4550d7faa87d1b35e49b6a3286ea9", size = 255892, upload-time = "2026-06-22T23:09:01.291Z" }, + { url = "https://pypi.flatt.tech/files/packages/8b/96/002094a10e113512500dc1e10430a449417e17b0f90f7d496bcb820208b7/coverage-7.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b7f300ac92cd4b570724c8ffbbd0c130fee298d2447f41d5a3abf58976fae1de", size = 258026, upload-time = "2026-06-22T23:09:03.017Z" }, + { url = "https://pypi.flatt.tech/files/packages/0b/ec/286a5d2fad9c4bee59bd724feeb7d5bf8303c6c9200b51d1dd945a9c72b0/coverage-7.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11a7ec9f97ab950f4c5af62229befc7faf208fdbc0116d3902d7e306cf2c5abd", size = 252285, upload-time = "2026-06-22T23:09:04.773Z" }, + { url = "https://pypi.flatt.tech/files/packages/d9/7d/a17753a0b12dd48d0d50f5fab079ad99d3be1eac790494d89f3a417ca0b9/coverage-7.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a571bd889cd36c5922ce8e42e059f9d37d02301531d11374afa4c87a578625d5", size = 254023, upload-time = "2026-06-22T23:09:06.513Z" }, + { url = "https://pypi.flatt.tech/files/packages/86/ef/a76c6ceba6a2c313f905310abf2701d534cada22d372db11731831e9e209/coverage-7.14.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:de76caefc8deabb0dd1678b6a980be97d14c8d87e213ac194dbf8b09e96d63fb", size = 251989, upload-time = "2026-06-22T23:09:08.382Z" }, + { url = "https://pypi.flatt.tech/files/packages/d9/39/353013a75fec0fb49f7553519f9d52b4441e902e5178c93f38eb6c07cedb/coverage-7.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d20a15c622194234161535459affa8f7905830391c9ccfa060d495dbfe3a1c7f", size = 256144, upload-time = "2026-06-22T23:09:10.369Z" }, + { url = "https://pypi.flatt.tech/files/packages/29/0e/613878555d734def11c5b20a2701a15cb3781b9e9ea749da27c5f436e928/coverage-7.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b488bd4b23397db62e7a9459129d01ff06a846582a732efd24834b24a6ada498", size = 251808, upload-time = "2026-06-22T23:09:12.057Z" }, + { url = "https://pypi.flatt.tech/files/packages/af/76/359c058c9cfdcf1e8b107663881225b03b364a320017eda24a2a66e55102/coverage-7.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6a3693b4153394d265f44fb855fdc80e72403024d4d6f91c4871b334d028e4e0", size = 253579, upload-time = "2026-06-22T23:09:13.858Z" }, + { url = "https://pypi.flatt.tech/files/packages/1d/d9/4ba2f060933a30ebe363cef9f67a365b0a317e580c0d5d9169d56a73ef1c/coverage-7.14.3-cp313-cp313-win32.whl", hash = "sha256:338b19131ab1a6b767b462bfcbaa692e7ae22f24463e39d49b02a83410ff6b37", size = 222741, upload-time = "2026-06-22T23:09:15.636Z" }, + { url = "https://pypi.flatt.tech/files/packages/76/e8/196ebc25d8f34c06d43a6e9c8513c9266ef8dbf3b5672beb1a00cf5e29fa/coverage-7.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:b3d77f7f196abdef7e01415de1bce09f216189e83e58159cfeef2b92d0464994", size = 223283, upload-time = "2026-06-22T23:09:17.478Z" }, + { url = "https://pypi.flatt.tech/files/packages/7c/af/51d2aac6417523a286f10fb25f09eb9518a84df9f1151e93ff6871f34849/coverage-7.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:e6230e688c7c3e65cedd41a774eb4ec221adc6bfee13768231015b702d5e4150", size = 222678, upload-time = "2026-06-22T23:09:19.7Z" }, + { url = "https://pypi.flatt.tech/files/packages/61/56/14e3b97facbfa1304dd19e676e26599ad359f04714bed32f7f1c5a88efdc/coverage-7.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:605ab2b566a22bd94834529d66d295c364aba84afd3e5498285c7a524017b1fc", size = 220741, upload-time = "2026-06-22T23:09:21.616Z" }, + { url = "https://pypi.flatt.tech/files/packages/12/1d/db378b5cca433b90b893f26dab728b280ddd89f272a1fdfed4aeaa05c686/coverage-7.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a3c2134809e80fac091bfed18a6991b5a5eb5df5ae32b17ac4f4f99864b73dd7", size = 221068, upload-time = "2026-06-22T23:09:23.452Z" }, + { url = "https://pypi.flatt.tech/files/packages/47/f0/3f8421b20d9c4fcd39be9a8ca3c3fda8bc204b44efbd09fede153afd3e2f/coverage-7.14.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c02efd507227bde9969cab0db8f48890eb3b5dcad6afac57a4792df4133543ce", size = 252117, upload-time = "2026-06-22T23:09:25.458Z" }, + { url = "https://pypi.flatt.tech/files/packages/27/ca/59ea35fb99743549ec8b37eff141ece4431fea590c89e536ed8032ef45cf/coverage-7.14.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1bb93c2aa61d2a5b38f1526546d95cf4132cb681e541a337bf8dfd092be816e5", size = 254622, upload-time = "2026-06-22T23:09:27.523Z" }, + { url = "https://pypi.flatt.tech/files/packages/c8/25/ec6de51ae7493b92a1cf74d1b763121c29636759167e2a593ba4db5881e4/coverage-7.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f502e948e03e866538048bba081c075caaa62e5bda6ea5b7432e45f587eb462a", size = 255968, upload-time = "2026-06-22T23:09:29.43Z" }, + { url = "https://pypi.flatt.tech/files/packages/5d/05/c8bfc77823f42b4664fb25842f13b567022f6f84a4c83c8ecbb16734b7cb/coverage-7.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9973ef2463f8e6cfb61a6324126bb3e17d67a85f22f58d856e583ea2e3ca6501", size = 258284, upload-time = "2026-06-22T23:09:31.397Z" }, + { url = "https://pypi.flatt.tech/files/packages/f6/15/1d1b242027124a32b26ef01f82018b8c4ef34ef174aa6aeba7b1eeef48e8/coverage-7.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9be4e7d4c5ca0427889f8f9d614bd630c2be741b1de7699bca3b2b6c0e41003e", size = 252143, upload-time = "2026-06-22T23:09:33.256Z" }, + { url = "https://pypi.flatt.tech/files/packages/74/b6/d2a9842fd2a5d7d27f1ac851c043a734a494ad75402c5331db3da79ed691/coverage-7.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a574912f3bde4b0619f6e97d01aa590b70998859244793769eb3a6df78ee56d3", size = 253976, upload-time = "2026-06-22T23:09:35.351Z" }, + { url = "https://pypi.flatt.tech/files/packages/fd/30/e1600ddf7e226db5558bb5323d2186fff00f505c4b764643ec89ce5d8175/coverage-7.14.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e343fb086c9cd780b38622fea7c369acd64c1a0724312149b5d769c387a2b1f5", size = 251942, upload-time = "2026-06-22T23:09:37.313Z" }, + { url = "https://pypi.flatt.tech/files/packages/d9/2c/9159de64f9dd648e324328d588a44cfab1e331eb5259ce1141afe2a92dfb/coverage-7.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3c68df8e61f1e09633fefc7538297145623957a048534368c9d212782aa5e845", size = 256220, upload-time = "2026-06-22T23:09:39.165Z" }, + { url = "https://pypi.flatt.tech/files/packages/91/67/b7f536cc2c124f48e91b22fbb741d2261f4e3d310faf6f76007f47566e5d/coverage-7.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3e5b550a128419373c2f6cec28a244207013ef15f5cbcff6a5ca09d1dfaaf027", size = 251756, upload-time = "2026-06-22T23:09:41.056Z" }, + { url = "https://pypi.flatt.tech/files/packages/dd/ec/f3718038e2d4860c715a55428377ca7f6c75872caf98cabd982e1d76967d/coverage-7.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2bfc4dd0a912329eccc7484a7d0b2a38032b38c40663b1e1ac595f10c457954b", size = 253413, upload-time = "2026-06-22T23:09:43.306Z" }, + { url = "https://pypi.flatt.tech/files/packages/b8/a5/91f11efeef89b3cc9b30461128db15b0511ef813ab889a7b7ab636b3a497/coverage-7.14.3-cp314-cp314-win32.whl", hash = "sha256:0423d64c013057a06e70f070f073cec4b0cbc7d2b27f3c7007292f2ff1d52965", size = 222946, upload-time = "2026-06-22T23:09:45.261Z" }, + { url = "https://pypi.flatt.tech/files/packages/58/fd/98ac9f524d9ec378de831c034dbdeb544ca7ef7d2d9c9996daf232a037fd/coverage-7.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:92c22e19ce64ca3f2ad751f16f14df1468b4c231bd6af97185063a9c292a0cb3", size = 223436, upload-time = "2026-06-22T23:09:47.177Z" }, + { url = "https://pypi.flatt.tech/files/packages/b4/a0/7cd612d650a772a0ae80144443406bf61981c896c3d57c9e6e79fb2cdbd1/coverage-7.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:41de778bd41780586e2b04912079c73089ab5d839624e28db3bdb26de638da92", size = 222861, upload-time = "2026-06-22T23:09:49.384Z" }, + { url = "https://pypi.flatt.tech/files/packages/55/57/017353fab573779c0d00448e47d102edd36c792f7b6f233a4d89a7a08384/coverage-7.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8427f370ca67db4c975d2a26acfc0e5783ca0b52444dbc50278ace0f35445949", size = 221474, upload-time = "2026-06-22T23:09:51.417Z" }, + { url = "https://pypi.flatt.tech/files/packages/69/92/90cf1f1a5c468a9c1b7ba2716e0e205293ad9b02f5f573a6de4318b15ba1/coverage-7.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d8e88f335544a47e22ae2e45b344772925ec65166555c958720d5ed971880891", size = 221738, upload-time = "2026-06-22T23:09:53.487Z" }, + { url = "https://pypi.flatt.tech/files/packages/a4/c0/4df964fa539f8399fd7679c09c472d73744de334686fd3f01e3a2465ce4e/coverage-7.14.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:beaab199b9e5ceaf5a225e16a9d4df136f2a1eae0a5c20de1e277c8a5225f388", size = 263101, upload-time = "2026-06-22T23:09:55.895Z" }, + { url = "https://pypi.flatt.tech/files/packages/06/76/e5d33b2576ae3bf2be2058cd1cae57774b61e400f2c3c58f3783dc2ffb4a/coverage-7.14.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3ff255799f5a1676c71c1c32ec01fd043aa09d57b3d95764b24992757184784", size = 265225, upload-time = "2026-06-22T23:09:57.904Z" }, + { url = "https://pypi.flatt.tech/files/packages/61/d2/e52419afe391a39ba27fdefaf0737d8e34bf03faef6ab3b3006545bbd0d0/coverage-7.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:878832eaac515b62decfa76965aed558775f86bf1fc8cca76993c0c84ae31aed", size = 267643, upload-time = "2026-06-22T23:09:59.938Z" }, + { url = "https://pypi.flatt.tech/files/packages/58/7a/f2625d8d5006b6b20fba5afaef00b24a763fe96476ea798a3076cbc1f84e/coverage-7.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:611e62cb9386096d81b63e0a05330750268617231e7bd598e1fe77482a2c58a5", size = 268762, upload-time = "2026-06-22T23:10:01.943Z" }, + { url = "https://pypi.flatt.tech/files/packages/7d/bf/908024006bba57127354d74e938954b9c3cd765cc2e0412dc9c37b415cda/coverage-7.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:02c41de2a88011b893050fc9830267d927a50a215f7ad5ec17349db7090ccf26", size = 262208, upload-time = "2026-06-22T23:10:03.954Z" }, + { url = "https://pypi.flatt.tech/files/packages/34/a0/d4f9296441b909817442fdb26bd77a698f08272ec683a7394b00eb2e47a0/coverage-7.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:526ce9721116af23b1065089f0b75046fe521e7772ab94b641cd66b7a0421889", size = 265096, upload-time = "2026-06-22T23:10:05.936Z" }, + { url = "https://pypi.flatt.tech/files/packages/e8/da/4ae4f3f4e477b56a4ce1e5c48a35eff38a94b50130ce5bdc897024741cfc/coverage-7.14.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e4ed44705ca4bead6fc977a8b741f2145608289b33c8a9b42a95d0f15aedbf4d", size = 262699, upload-time = "2026-06-22T23:10:07.973Z" }, + { url = "https://pypi.flatt.tech/files/packages/d8/7a/6927148073ff32856d78baa77b4ddc07a9be7e90020f9db0661c4ca523a1/coverage-7.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2415902f385a23dcc4ccd26e0ba803249a169af6a930c003a4c715eeb9a5444e", size = 266433, upload-time = "2026-06-22T23:10:10.145Z" }, + { url = "https://pypi.flatt.tech/files/packages/f7/a7/774f658dbe9c4c3f5daa86a87e0459ac3832e4e3cc67affe078547f727b9/coverage-7.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b75ee850fc2d7c831e883220c445b035f2224de2ba6103f1e56dbd237ab913f7", size = 261547, upload-time = "2026-06-22T23:10:12.191Z" }, + { url = "https://pypi.flatt.tech/files/packages/3d/14/a0c18c0376c43cbf973f43ef6ca20019c950597180e6396232f7b6a27102/coverage-7.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dc9b4e35e7c3920e925ba7f14886fd5fbe481232754624e832ddba66c7535635", size = 263859, upload-time = "2026-06-22T23:10:14.492Z" }, + { url = "https://pypi.flatt.tech/files/packages/10/ac/43a3d0f460af524b131a6191805bc5d18b806ab4e828fbf82e8c8c3af446/coverage-7.14.3-cp314-cp314t-win32.whl", hash = "sha256:7b27c822a8161afbe48e99f1adfb098d270ae7e0f7d7b0555ce110529bdb69cc", size = 223250, upload-time = "2026-06-22T23:10:16.758Z" }, + { url = "https://pypi.flatt.tech/files/packages/3f/5f/d5e5c56b0712e96ce8f69fe7dbf229ff938b437bc50862743c8a0d2cea84/coverage-7.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:39e1dbbb6ff2c338e0196a482558a792a1de3aa64261196f5cdb3da016ad9cda", size = 224082, upload-time = "2026-06-22T23:10:19.23Z" }, + { url = "https://pypi.flatt.tech/files/packages/62/35/947cbd5be1d3bcbbdc43d6791de8a56c6501903311d42915ae06a82815f0/coverage-7.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:68520c90babfa2d560eca6d497921ed3a4f469623bd709733124491b2aa8ef3f", size = 223400, upload-time = "2026-06-22T23:10:21.24Z" }, + { url = "https://pypi.flatt.tech/files/packages/eb/e3/a0aa32bfa3a081951f60a23bc0e7b512891ef0eecda1153cf1d8ba36c6b1/coverage-7.14.3-py3-none-any.whl", hash = "sha256:fb7e18afb6e903c1a92401a2f0501ac277dca527bb9ca6fe1f691a8a0026a0e8", size = 212469, upload-time = "2026-06-22T23:10:23.405Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, ] [[package]] name = "exceptiongroup" version = "1.3.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://pypi.flatt.tech/simple/" } dependencies = [ - { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +sdist = { url = "https://pypi.flatt.tech/files/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, + { url = "https://pypi.flatt.tech/files/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] [[package]] -name = "iniconfig" +name = "ghp-import" version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version == '3.9.*'", - "python_full_version < '3.9'", +source = { registry = "https://pypi.flatt.tech/simple/" } +dependencies = [ + { name = "python-dateutil" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +sdist = { url = "https://pypi.flatt.tech/files/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, + { url = "https://pypi.flatt.tech/files/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, +] + +[[package]] +name = "griffelib" +version = "2.1.0" +source = { registry = "https://pypi.flatt.tech/simple/" } +sdist = { url = "https://pypi.flatt.tech/files/packages/33/e4/8d187ea29c2e30b3a09505c567513077d6117861bde1fbd997a167f262ec/griffelib-2.1.0.tar.gz", hash = "sha256:762a186d2c6fd6794d4ea20d428d597ffb857cb56b66421651cbba15bdd5e813", size = 216234, upload-time = "2026-06-19T12:05:42.278Z" } +wheels = [ + { url = "https://pypi.flatt.tech/files/packages/e4/d3/5268aeabf2ad82658c4e2ff3a060648d0f02f3926cb53247c0e4d0dab49e/griffelib-2.1.0-py3-none-any.whl", hash = "sha256:cc7b3d2d2865ad0b909fcc38086e3f554b5ea7acbaa7bbb7ecaa3f5dfb7d9f00", size = 142560, upload-time = "2026-06-19T12:05:38.742Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.flatt.tech/simple/" } +sdist = { url = "https://pypi.flatt.tech/files/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://pypi.flatt.tech/files/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] name = "iniconfig" version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", +source = { registry = "https://pypi.flatt.tech/simple/" } +sdist = { url = "https://pypi.flatt.tech/files/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://pypi.flatt.tech/files/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.flatt.tech/simple/" } +dependencies = [ + { name = "markupsafe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +sdist = { url = "https://pypi.flatt.tech/files/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, + { url = "https://pypi.flatt.tech/files/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] [[package]] -name = "packaging" -version = "26.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +name = "markdown" +version = "3.10.2" +source = { registry = "https://pypi.flatt.tech/simple/" } +sdist = { url = "https://pypi.flatt.tech/files/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, + { url = "https://pypi.flatt.tech/files/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, ] [[package]] -name = "pluggy" -version = "1.5.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.flatt.tech/simple/" } +sdist = { url = "https://pypi.flatt.tech/files/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://pypi.flatt.tech/files/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://pypi.flatt.tech/files/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://pypi.flatt.tech/files/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://pypi.flatt.tech/files/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://pypi.flatt.tech/files/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://pypi.flatt.tech/files/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://pypi.flatt.tech/files/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://pypi.flatt.tech/files/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://pypi.flatt.tech/files/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://pypi.flatt.tech/files/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://pypi.flatt.tech/files/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://pypi.flatt.tech/files/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://pypi.flatt.tech/files/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://pypi.flatt.tech/files/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://pypi.flatt.tech/files/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://pypi.flatt.tech/files/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://pypi.flatt.tech/files/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://pypi.flatt.tech/files/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://pypi.flatt.tech/files/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://pypi.flatt.tech/files/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://pypi.flatt.tech/files/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://pypi.flatt.tech/files/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://pypi.flatt.tech/files/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://pypi.flatt.tech/files/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://pypi.flatt.tech/files/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://pypi.flatt.tech/files/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://pypi.flatt.tech/files/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://pypi.flatt.tech/files/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://pypi.flatt.tech/files/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://pypi.flatt.tech/files/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://pypi.flatt.tech/files/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://pypi.flatt.tech/files/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://pypi.flatt.tech/files/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://pypi.flatt.tech/files/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://pypi.flatt.tech/files/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://pypi.flatt.tech/files/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://pypi.flatt.tech/files/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://pypi.flatt.tech/files/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://pypi.flatt.tech/files/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://pypi.flatt.tech/files/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://pypi.flatt.tech/files/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://pypi.flatt.tech/files/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://pypi.flatt.tech/files/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://pypi.flatt.tech/files/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://pypi.flatt.tech/files/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://pypi.flatt.tech/files/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://pypi.flatt.tech/files/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://pypi.flatt.tech/files/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://pypi.flatt.tech/files/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://pypi.flatt.tech/files/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://pypi.flatt.tech/files/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://pypi.flatt.tech/files/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://pypi.flatt.tech/files/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://pypi.flatt.tech/files/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://pypi.flatt.tech/files/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://pypi.flatt.tech/files/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://pypi.flatt.tech/files/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://pypi.flatt.tech/files/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://pypi.flatt.tech/files/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://pypi.flatt.tech/files/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://pypi.flatt.tech/files/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://pypi.flatt.tech/files/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://pypi.flatt.tech/files/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://pypi.flatt.tech/files/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://pypi.flatt.tech/files/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://pypi.flatt.tech/files/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://pypi.flatt.tech/files/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://pypi.flatt.tech/files/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://pypi.flatt.tech/files/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://pypi.flatt.tech/files/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://pypi.flatt.tech/files/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://pypi.flatt.tech/files/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://pypi.flatt.tech/files/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://pypi.flatt.tech/files/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://pypi.flatt.tech/files/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://pypi.flatt.tech/files/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://pypi.flatt.tech/files/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955, upload-time = "2024-04-20T21:34:42.531Z" } + +[[package]] +name = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.flatt.tech/simple/" } +sdist = { url = "https://pypi.flatt.tech/files/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556, upload-time = "2024-04-20T21:34:40.434Z" }, + { url = "https://pypi.flatt.tech/files/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, ] [[package]] -name = "pluggy" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version == '3.9.*'", +name = "mkdocs" +version = "1.6.1" +source = { registry = "https://pypi.flatt.tech/simple/" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ghp-import" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mergedeep" }, + { name = "mkdocs-get-deps" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, +] +sdist = { url = "https://pypi.flatt.tech/files/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } +wheels = [ + { url = "https://pypi.flatt.tech/files/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, +] + +[[package]] +name = "mkdocs-autorefs" +version = "1.4.4" +source = { registry = "https://pypi.flatt.tech/simple/" } +dependencies = [ + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +sdist = { url = "https://pypi.flatt.tech/files/packages/52/c0/f641843de3f612a6b48253f39244165acff36657a91cc903633d456ae1ac/mkdocs_autorefs-1.4.4.tar.gz", hash = "sha256:d54a284f27a7346b9c38f1f852177940c222da508e66edc816a0fa55fc6da197", size = 56588, upload-time = "2026-02-10T15:23:55.105Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, + { url = "https://pypi.flatt.tech/files/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl", hash = "sha256:834ef5408d827071ad1bc69e0f39704fa34c7fc05bc8e1c72b227dfdc5c76089", size = 25530, upload-time = "2026-02-10T15:23:53.817Z" }, ] [[package]] -name = "pygments" -version = "2.20.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +name = "mkdocs-get-deps" +version = "0.2.2" +source = { registry = "https://pypi.flatt.tech/simple/" } +dependencies = [ + { name = "mergedeep" }, + { name = "platformdirs" }, + { name = "pyyaml" }, +] +sdist = { url = "https://pypi.flatt.tech/files/packages/ce/25/b3cccb187655b9393572bde9b09261d267c3bf2f2cdabe347673be5976a6/mkdocs_get_deps-0.2.2.tar.gz", hash = "sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1", size = 11047, upload-time = "2026-03-10T02:46:33.632Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, + { url = "https://pypi.flatt.tech/files/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl", hash = "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650", size = 9555, upload-time = "2026-03-10T02:46:32.256Z" }, ] [[package]] -name = "pytest" -version = "8.3.5" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", +name = "mkdocs-material" +version = "9.7.6" +source = { registry = "https://pypi.flatt.tech/simple/" } +dependencies = [ + { name = "babel" }, + { name = "backrefs" }, + { name = "colorama" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "mkdocs" }, + { name = "mkdocs-material-extensions" }, + { name = "paginate" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "requests" }, +] +sdist = { url = "https://pypi.flatt.tech/files/packages/45/29/6d2bcf41ae40802c4beda2432396fff97b8456fb496371d1bc7aad6512ec/mkdocs_material-9.7.6.tar.gz", hash = "sha256:00bdde50574f776d328b1862fe65daeaf581ec309bd150f7bff345a098c64a69", size = 4097959, upload-time = "2026-03-19T15:41:58.161Z" } +wheels = [ + { url = "https://pypi.flatt.tech/files/packages/2c/01/bc663630c510822c95c47a66af9fa7a443c295b47d5f041e5e6ae62ef659/mkdocs_material-9.7.6-py3-none-any.whl", hash = "sha256:71b84353921b8ea1ba84fe11c50912cc512da8fe0881038fcc9a0761c0e635ba", size = 9305470, upload-time = "2026-03-19T15:41:55.217Z" }, ] + +[[package]] +name = "mkdocs-material-extensions" +version = "1.3.1" +source = { registry = "https://pypi.flatt.tech/simple/" } +sdist = { url = "https://pypi.flatt.tech/files/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } +wheels = [ + { url = "https://pypi.flatt.tech/files/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, +] + +[[package]] +name = "mkdocs-static-i18n" +version = "1.3.1" +source = { registry = "https://pypi.flatt.tech/simple/" } dependencies = [ - { name = "colorama", marker = "python_full_version < '3.9' and sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.9'" }, - { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "packaging", marker = "python_full_version < '3.9'" }, - { name = "pluggy", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "tomli", marker = "python_full_version < '3.9'" }, + { name = "mkdocs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891, upload-time = "2025-03-02T12:54:54.503Z" } +sdist = { url = "https://pypi.flatt.tech/files/packages/ce/f9/51e2ffda9c7210bc35a24f3717b08c052cd4b728dfa87f901c00d8005259/mkdocs_static_i18n-1.3.1.tar.gz", hash = "sha256:a6125ea7db6cc1a900d76a967f262535af09831160a93c56d7f0d522a79b5faf", size = 1371325, upload-time = "2026-02-20T10:42:41.835Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634, upload-time = "2025-03-02T12:54:52.069Z" }, + { url = "https://pypi.flatt.tech/files/packages/6a/0b/43ff4afb6b438d47718b1959a22075ed95d8460d8c47381878b37a40de63/mkdocs_static_i18n-1.3.1-py3-none-any.whl", hash = "sha256:4036e24795a150c9c4d4b001ed24a43aec01335f76188dbe5a5d8fb4a27eba65", size = 21853, upload-time = "2026-02-20T10:42:40.551Z" }, ] [[package]] -name = "pytest" -version = "8.4.2" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version == '3.9.*'", +name = "mkdocstrings" +version = "1.0.4" +source = { registry = "https://pypi.flatt.tech/simple/" } +dependencies = [ + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, + { name = "mkdocs-autorefs" }, + { name = "pymdown-extensions" }, +] +sdist = { url = "https://pypi.flatt.tech/files/packages/1d/5d/f888d4d3eb31359b327bc9b17a212d6ef03fe0b0682fbb3fc2cb849fb12b/mkdocstrings-1.0.4.tar.gz", hash = "sha256:3969a6515b77db65fd097b53c1b7aa4ae840bd71a2ee62a6a3e89503446d7172", size = 100088, upload-time = "2026-04-15T09:16:53.376Z" } +wheels = [ + { url = "https://pypi.flatt.tech/files/packages/6e/94/be70f8ee9c45f2f62b39a1f0e9303bc20e138a8f3b8e50ffd89498e177e1/mkdocstrings-1.0.4-py3-none-any.whl", hash = "sha256:63464b4b29053514f32a1dbbf604e52876d5e638111b0c295ab7ed3cac73ca9b", size = 35560, upload-time = "2026-04-15T09:16:51.436Z" }, +] + +[package.optional-dependencies] +python = [ + { name = "mkdocstrings-python" }, ] + +[[package]] +name = "mkdocstrings-python" +version = "2.0.5" +source = { registry = "https://pypi.flatt.tech/simple/" } dependencies = [ - { name = "colorama", marker = "python_full_version == '3.9.*' and sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version == '3.9.*'" }, - { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "packaging", marker = "python_full_version == '3.9.*'" }, - { name = "pluggy", version = "1.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "pygments", marker = "python_full_version == '3.9.*'" }, - { name = "tomli", marker = "python_full_version == '3.9.*'" }, + { name = "griffelib" }, + { name = "mkdocs-autorefs" }, + { name = "mkdocstrings" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +sdist = { url = "https://pypi.flatt.tech/files/packages/b1/b6/e858701499d57eee8b3fd8e78168083956c6683ddbe727b46758b19e1119/mkdocstrings_python-2.0.5.tar.gz", hash = "sha256:3a4d92556ad39637e88af94a5374213af9a8e3040c3824ceaed04b486c017594", size = 199578, upload-time = "2026-06-19T10:41:08.868Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, + { url = "https://pypi.flatt.tech/files/packages/d1/fc/10ab7e80650a9c9e8f4f1105f8c8e73567f88ed0c06ada589ab81d38687c/mkdocstrings_python-2.0.5-py3-none-any.whl", hash = "sha256:30c837bbff016549f659fcba6539ac351303f0fd7e713c89a040611072236e9d", size = 104951, upload-time = "2026-06-19T10:41:07.378Z" }, ] [[package]] -name = "pytest" -version = "9.0.2" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.flatt.tech/simple/" } +sdist = { url = "https://pypi.flatt.tech/files/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://pypi.flatt.tech/files/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] -dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, - { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "packaging", marker = "python_full_version >= '3.10'" }, - { name = "pluggy", version = "1.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "pygments", marker = "python_full_version >= '3.10'" }, - { name = "tomli", marker = "python_full_version == '3.10.*'" }, + +[[package]] +name = "paginate" +version = "0.5.7" +source = { registry = "https://pypi.flatt.tech/simple/" } +sdist = { url = "https://pypi.flatt.tech/files/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } +wheels = [ + { url = "https://pypi.flatt.tech/files/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.flatt.tech/simple/" } +sdist = { url = "https://pypi.flatt.tech/files/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, + { url = "https://pypi.flatt.tech/files/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] [[package]] -name = "pytest-asyncio" -version = "0.24.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", +name = "platformdirs" +version = "4.10.0" +source = { registry = "https://pypi.flatt.tech/simple/" } +sdist = { url = "https://pypi.flatt.tech/files/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://pypi.flatt.tech/files/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.flatt.tech/simple/" } +sdist = { url = "https://pypi.flatt.tech/files/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://pypi.flatt.tech/files/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.flatt.tech/simple/" } +sdist = { url = "https://pypi.flatt.tech/files/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://pypi.flatt.tech/files/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pymdown-extensions" +version = "11.0" +source = { registry = "https://pypi.flatt.tech/simple/" } dependencies = [ - { name = "pytest", version = "8.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "markdown" }, + { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/52/6d/c6cf50ce320cf8611df7a1254d86233b3df7cc07f9b5f5cbcb82e08aa534/pytest_asyncio-0.24.0.tar.gz", hash = "sha256:d081d828e576d85f875399194281e92bf8a68d60d72d1a2faf2feddb6c46b276", size = 49855, upload-time = "2024-08-22T08:03:18.145Z" } +sdist = { url = "https://pypi.flatt.tech/files/packages/47/67/f1e79672a5f91985577c7984c9709ca110e4fd37fe7fd167b60422e6ccc2/pymdown_extensions-11.0.tar.gz", hash = "sha256:8269cef0247f9e2d0a62fcea10860aba05c1cbab5470fd4b63230b96434dc589", size = 857049, upload-time = "2026-06-23T02:27:45.146Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/31/6607dab48616902f76885dfcf62c08d929796fc3b2d2318faf9fd54dbed9/pytest_asyncio-0.24.0-py3-none-any.whl", hash = "sha256:a811296ed596b69bf0b6f3dc40f83bcaf341b155a269052d82efa2b25ac7037b", size = 18024, upload-time = "2024-08-22T08:03:15.536Z" }, + { url = "https://pypi.flatt.tech/files/packages/af/b6/1ae53367e28b9cffa3be7574e13fbe4589694272fd47710fbdbafd3d63c6/pymdown_extensions-11.0-py3-none-any.whl", hash = "sha256:fbc4acb641814fa9d17521bbd21a5240ef739a662f11c06330c4b78c93e954d6", size = 269415, upload-time = "2026-06-23T02:27:43.826Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.flatt.tech/simple/" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://pypi.flatt.tech/files/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://pypi.flatt.tech/files/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] [[package]] name = "pytest-asyncio" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version == '3.9.*'", +version = "1.3.0" +source = { registry = "https://pypi.flatt.tech/simple/" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] +sdist = { url = "https://pypi.flatt.tech/files/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://pypi.flatt.tech/files/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.flatt.tech/simple/" } dependencies = [ - { name = "backports-asyncio-runner", marker = "python_full_version == '3.9.*'" }, - { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "six" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" } +sdist = { url = "https://pypi.flatt.tech/files/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" }, + { url = "https://pypi.flatt.tech/files/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] [[package]] -name = "pytest-asyncio" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.flatt.tech/simple/" } +sdist = { url = "https://pypi.flatt.tech/files/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://pypi.flatt.tech/files/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://pypi.flatt.tech/files/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://pypi.flatt.tech/files/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://pypi.flatt.tech/files/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://pypi.flatt.tech/files/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://pypi.flatt.tech/files/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://pypi.flatt.tech/files/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://pypi.flatt.tech/files/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://pypi.flatt.tech/files/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://pypi.flatt.tech/files/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://pypi.flatt.tech/files/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://pypi.flatt.tech/files/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://pypi.flatt.tech/files/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://pypi.flatt.tech/files/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://pypi.flatt.tech/files/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://pypi.flatt.tech/files/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://pypi.flatt.tech/files/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://pypi.flatt.tech/files/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://pypi.flatt.tech/files/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://pypi.flatt.tech/files/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://pypi.flatt.tech/files/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://pypi.flatt.tech/files/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://pypi.flatt.tech/files/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://pypi.flatt.tech/files/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://pypi.flatt.tech/files/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://pypi.flatt.tech/files/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://pypi.flatt.tech/files/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://pypi.flatt.tech/files/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://pypi.flatt.tech/files/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://pypi.flatt.tech/files/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://pypi.flatt.tech/files/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://pypi.flatt.tech/files/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://pypi.flatt.tech/files/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://pypi.flatt.tech/files/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://pypi.flatt.tech/files/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://pypi.flatt.tech/files/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://pypi.flatt.tech/files/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://pypi.flatt.tech/files/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://pypi.flatt.tech/files/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://pypi.flatt.tech/files/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://pypi.flatt.tech/files/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://pypi.flatt.tech/files/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://pypi.flatt.tech/files/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://pypi.flatt.tech/files/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://pypi.flatt.tech/files/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://pypi.flatt.tech/files/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://pypi.flatt.tech/files/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://pypi.flatt.tech/files/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://pypi.flatt.tech/files/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://pypi.flatt.tech/files/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://pypi.flatt.tech/files/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://pypi.flatt.tech/files/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://pypi.flatt.tech/files/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://pypi.flatt.tech/files/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://pypi.flatt.tech/files/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://pypi.flatt.tech/files/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] + +[[package]] +name = "pyyaml-env-tag" +version = "1.1" +source = { registry = "https://pypi.flatt.tech/simple/" } dependencies = [ - { name = "backports-asyncio-runner", marker = "python_full_version == '3.10.*'" }, - { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and python_full_version < '3.13'" }, + { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +sdist = { url = "https://pypi.flatt.tech/files/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, + { url = "https://pypi.flatt.tech/files/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, ] [[package]] -name = "tomli" -version = "2.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, - { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, - { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, - { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, - { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, - { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, - { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, - { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, - { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, - { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, - { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, - { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, - { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, - { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, - { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, - { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, - { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, - { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, - { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, - { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, - { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, - { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, - { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, - { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, - { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, - { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, - { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, - { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, - { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, - { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, - { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, - { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, - { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, - { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, - { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, - { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, - { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, - { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, - { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, - { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, - { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, - { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, - { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, - { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, - { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, - { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.flatt.tech/simple/" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://pypi.flatt.tech/files/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://pypi.flatt.tech/files/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] [[package]] -name = "typing-extensions" -version = "4.13.2" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.flatt.tech/simple/" } +sdist = { url = "https://pypi.flatt.tech/files/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://pypi.flatt.tech/files/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967, upload-time = "2025-04-10T14:19:05.416Z" } + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.flatt.tech/simple/" } +sdist = { url = "https://pypi.flatt.tech/files/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806, upload-time = "2025-04-10T14:19:03.967Z" }, + { url = "https://pypi.flatt.tech/files/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://pypi.flatt.tech/files/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://pypi.flatt.tech/files/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://pypi.flatt.tech/files/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://pypi.flatt.tech/files/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://pypi.flatt.tech/files/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://pypi.flatt.tech/files/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://pypi.flatt.tech/files/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://pypi.flatt.tech/files/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://pypi.flatt.tech/files/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://pypi.flatt.tech/files/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://pypi.flatt.tech/files/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://pypi.flatt.tech/files/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://pypi.flatt.tech/files/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://pypi.flatt.tech/files/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://pypi.flatt.tech/files/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://pypi.flatt.tech/files/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://pypi.flatt.tech/files/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://pypi.flatt.tech/files/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://pypi.flatt.tech/files/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://pypi.flatt.tech/files/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://pypi.flatt.tech/files/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://pypi.flatt.tech/files/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://pypi.flatt.tech/files/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://pypi.flatt.tech/files/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://pypi.flatt.tech/files/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://pypi.flatt.tech/files/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://pypi.flatt.tech/files/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://pypi.flatt.tech/files/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://pypi.flatt.tech/files/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://pypi.flatt.tech/files/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://pypi.flatt.tech/files/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://pypi.flatt.tech/files/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://pypi.flatt.tech/files/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://pypi.flatt.tech/files/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://pypi.flatt.tech/files/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://pypi.flatt.tech/files/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://pypi.flatt.tech/files/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://pypi.flatt.tech/files/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://pypi.flatt.tech/files/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://pypi.flatt.tech/files/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://pypi.flatt.tech/files/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://pypi.flatt.tech/files/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://pypi.flatt.tech/files/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://pypi.flatt.tech/files/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://pypi.flatt.tech/files/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] [[package]] name = "typing-extensions" version = "4.15.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version == '3.9.*'", +source = { registry = "https://pypi.flatt.tech/simple/" } +sdist = { url = "https://pypi.flatt.tech/files/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://pypi.flatt.tech/files/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.flatt.tech/simple/" } +sdist = { url = "https://pypi.flatt.tech/files/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://pypi.flatt.tech/files/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.flatt.tech/simple/" } +sdist = { url = "https://pypi.flatt.tech/files/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, + { url = "https://pypi.flatt.tech/files/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload-time = "2024-11-01T14:06:24.793Z" }, + { url = "https://pypi.flatt.tech/files/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload-time = "2024-11-01T14:06:27.112Z" }, + { url = "https://pypi.flatt.tech/files/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload-time = "2024-11-01T14:06:29.876Z" }, + { url = "https://pypi.flatt.tech/files/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, + { url = "https://pypi.flatt.tech/files/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, + { url = "https://pypi.flatt.tech/files/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, + { url = "https://pypi.flatt.tech/files/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://pypi.flatt.tech/files/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://pypi.flatt.tech/files/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://pypi.flatt.tech/files/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://pypi.flatt.tech/files/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://pypi.flatt.tech/files/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://pypi.flatt.tech/files/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" }, + { url = "https://pypi.flatt.tech/files/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" }, + { url = "https://pypi.flatt.tech/files/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://pypi.flatt.tech/files/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://pypi.flatt.tech/files/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://pypi.flatt.tech/files/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://pypi.flatt.tech/files/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://pypi.flatt.tech/files/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://pypi.flatt.tech/files/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://pypi.flatt.tech/files/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://pypi.flatt.tech/files/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://pypi.flatt.tech/files/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, ]