diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..9e49e49 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,5 @@ +github: "david-lev" +patreon: "davidlev" +buy_me_a_coffee: "davidlev" +ko_fi: "davidlev" +custom: ["https://paypal.me/davidlev"] diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..c1b34ba --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,42 @@ +name: Publish Python Package to PyPI + +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + build-and-publish: + runs-on: ubuntu-latest + + environment: + name: pypi + url: https://pypi.org/p/apkpull + + permissions: + id-token: write + + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + + - name: Install Python 3.10 + run: uv python install 3.10 + + - name: Build + run: uv build + + - name: Smoke test (wheel) + run: uv run --isolated --no-project --with dist/*.whl python -c "import apkpull; print(apkpull.__version__)" + + - name: Smoke test (source distribution) + run: uv run --isolated --no-project --with dist/*.tar.gz python -c "import apkpull; print(apkpull.__version__)" + + - name: Publish + run: uv publish diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..1eca314 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,69 @@ +name: CI + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + tests: + name: pytest + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + # Every supported Python version, on the cheapest runner. Integration tests that need a + # real adb device/emulator skip themselves automatically when none is present (see + # tests/integration/test_live_emulator.py) — no device is set up in CI. + - { os: ubuntu-latest, python-version: '3.10' } + - { os: ubuntu-latest, python-version: '3.11' } + - { os: ubuntu-latest, python-version: '3.12' } + - { os: ubuntu-latest, python-version: '3.13' } + - { os: ubuntu-latest, python-version: '3.14' } + # Min/max Python only on macOS/Windows, to catch OS-specific issues (path handling, + # subprocess quoting, apkpull.notify's platform branches, ...). + - { os: macos-latest, python-version: '3.10' } + - { os: macos-latest, python-version: '3.14' } + - { os: windows-latest, python-version: '3.10' } + - { os: windows-latest, python-version: '3.14' } + + steps: + - uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: uv sync + + - name: Run tests + run: uv run pytest + + lint: + name: lint + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + python-version: '3.10' + + - name: Install dependencies + run: uv sync + + - name: Run ruff check + run: uv run ruff check . + + - name: Run ruff format + run: uv run ruff format --check . + + - name: Run ty + run: uv run ty check diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..206f550 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +__pycache__/ +*.pyc +.idea +.vscode + +# build artifacts +build/ +dist/ +*.egg-info/ + +# tooling +.venv/ +venv/ +.ruff_cache/ +.pytest_cache/ +.ty_cache/ +.mypy_cache/ +.coverage +coverage.xml +htmlcov/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..e2f9ce2 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,36 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks + +ci: + autoupdate_schedule: quarterly + skip: [ty] + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-toml + - id: check-added-large-files + - id: check-executables-have-shebangs + - id: check-illegal-windows-names + - id: check-json + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.16.2 + hooks: + - id: ruff-check + args: [ --fix ] + - id: ruff-format + + - repo: https://github.com/astral-sh/ty-pre-commit + rev: v0.0.70 + hooks: + - id: ty + + - repo: https://github.com/astral-sh/uv-pre-commit + rev: 0.12.3 + hooks: + - id: uv-lock diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..42a452e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 David Lev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 1124678..9567a26 100644 --- a/README.md +++ b/README.md @@ -1,61 +1,419 @@ -![Alt Text](https://user-images.githubusercontent.com/42866208/151712735-6cf6ef8a-c5e0-46e8-9b54-53b213aa3ed8.gif) ---- -# 📱 APKpull -[![CodeFactor](https://www.codefactor.io/repository/github/david-lev/apkpull/badge)](https://www.codefactor.io/repository/github/david-lev/apkpull) +

+ apkpull logo + apkpull • Download Android apps from Google Play as one installable bundle +

-**APKpull provides reliability in downloading apps from Google Play from real Android devices or emulators** +

+ PyPI Version + Downloads + Python Versions + Tests + pre-commit.ci status + License + Code Quality +

+ +**apkpull drives Google Play like a person would** — installs or updates an app on real, adb-connected devices or +emulators, then pulls the base APK and every split into one verified, installable `.apks` bundle, powered by +[apkfile](https://github.com/david-lev/apkfile). Point it at several devices at once and it merges their splits +into a single artifact anyone can install, whatever their phone's CPU, screen, or language. -## How to use? -### Run APKpull with one command: ```bash -curl -sL bit.ly/apkpull | bash -s -- com.whatsapp +apkpull com.whatsapp # pull one app from every connected device +apkpull com.whatsapp,com.spotify.music -d ~/Apps # pull several apps, into a custom folder +``` + +## Install +```bash +pip install -U apkpull +# or +uv tool install apkpull # installs the `apkpull` command standalone +uv add apkpull # ...or as a dependency, to use it as a library (see "As a library" below) ``` -> - APKpull get's [PackageName](https://support.google.com/admob/answer/9972781) as the first (required) argument. -> - You can also pull the files into spesific path insted of `~/Downloads/apkpull_dl` by adding `-d /path/to/directory` flag -> - Uninstall the app after the pulling is complete by adding `--uninstall` flag. -> - `--help` to get instructions. -> - For example: + +Requires Python 3.10+ and [ADB](https://developer.android.com/studio/command-line/adb) on your `PATH`. That's it — +`apkpull com.whatsapp` is ready to run. See [Development](#development) below for setting up a local clone instead. + +## Why run more than one device? + +Google Play doesn't hand out one universal APK — every install is built from a **base APK** plus a handful of +**splits**, chosen to match that specific device: its CPU architecture (ABI), screen density, and every language +it has configured. Pull from a single phone and you only get *that phone's* splits — perfectly installable on that +phone, but potentially missing pieces someone else's phone needs: a different ABI, a higher-density screen, a +language you don't happen to have installed. + +apkpull's answer is to run against several devices at once and merge the *union* of everything they each pulled +into one bundle (see [Cross-device merging](#cross-device-merging) below) — so instead of "the splits my phone +happened to get," you end up with "every split any of these devices got," installable by a much wider set of real +phones. Three cheap emulators (or devices) get you almost all the way there: + +- **Two ABIs** — one `armeabi-v7a` (32-bit) and one `arm64-v8a` (64-bit). Between them they cover virtually every + Android phone in the wild; skip `x86`/`x86_64` unless you specifically care about Chromebooks or x86 tablets, + which few apps bother splitting for anyway. +- **Two screen densities** — Android buckets every real screen into one of a handful of density tiers + (`ldpi`/`mdpi`/`hdpi`/`xhdpi`/`xxhdpi`/`xxxhdpi`), and Play only ever serves the one closest to a device's actual + dpi. **xxhdpi (~480dpi)** and **xxxhdpi (~640dpi)** between them cover most phones sold in the last several + years, from mid-range through current flagships — set one emulator's AVD screen density to each. +- **As many languages as possible, all on one device** — Play fetches one split per *installed* language, so + running a separate device per language is wasted effort. Add every language you care about to a single device + (Settings → System → Languages) instead, and one pull covers all of them — apkpull already unions a device's + *full* configured language list into the bundle, not just its primary locale. + +This is also exactly what apkpull's own duplicate-device detection is built around: two devices that agree on ABI, +density bucket, SDK, and configured languages will just pull the same files twice, so apkpull warns about it (but +still processes both); two devices that agree on everything *except* language get flagged too, with a suggestion +to fold them into one multi-language device instead — precisely the setup recommended above. See +[Duplicate/redundant devices](#reliability) below for the exact mechanics. + +## Setting up a device or emulator + +apkpull needs at least one adb-reachable device or emulator with **Google Play installed and signed in**. + +### Real device + +1. Enable Developer Options (tap Settings → About phone → Build number 7 times), then enable USB debugging + inside Developer Options. +2. Plug it in over USB (or set up [wireless debugging](https://developer.android.com/tools/adb#wireless)) and + accept the "Allow USB debugging?" prompt on the device. +3. `adb devices` should list it as `device` (not `unauthorized` — re-check the on-device prompt if so). + +### Emulator (AVD) + +Any AVD works as long as its system image includes **Play Store**, not just Play Services — look for images +tagged `Google Play` in Android Studio's AVD wizard, or `google_apis_playstore` on the command line (a plain +`google_apis` image has no Play Store app at all, and apkpull can't do anything without one). + +**Android Studio** (easiest): Tools → Device Manager → Create Device → pick a phone profile → pick a system +image with a **Play Store** icon next to it → Finish, then press ▶ to boot it. + +**Command line**, if you'd rather not open Android Studio (`sdkmanager`/`avdmanager`/`emulator` live under your +SDK root, e.g. `$ANDROID_HOME/cmdline-tools/latest/bin` and `$ANDROID_HOME/emulator` — add those to `PATH`, or +run them with their full path): + ```bash -curl -sL bit.ly/apkpull | bash -s -- com.tranzmate -d ~/Documents/my_apks_dir/ --uninstall +# list installable system images, install one with a Play Store (adjust api level/abi as needed) +sdkmanager --list | grep google_apis_playstore +sdkmanager "system-images;android-34;google_apis_playstore;arm64-v8a" # arm64-v8a on Apple Silicon, x86_64 on Intel/most Linux + +avdmanager create avd -n apkpull -k "system-images;android-34;google_apis_playstore;arm64-v8a" -d pixel_6 +emulator -avd apkpull ``` -### Or, if you want to make changes to the script: -- Clone APKpull: + +Either way, confirm it shows up before running apkpull: + ```bash -git clone https://github.com/david-lev/apkpull.git +adb devices # should list it as `device` +apkpull com.whatsapp -v ``` -- Go to script directory: + +### Hardware acceleration + +The emulator is only fast with real hardware virtualization behind it — apkpull's UI automation does several +`uiautomator dump`/screenshot round-trips per screen, so this matters a lot in practice: + +- **macOS**: uses Apple's Hypervisor.framework (HVF) automatically, no setup needed. Check with + `sysctl kern.hv_support` (`1` = supported). +- **Linux**: needs KVM — `sudo apt install qemu-kvm` (Debian/Ubuntu) or equivalent, and your user in the `kvm` + group. Check with `ls -l /dev/kvm`. +- **Windows**: uses WHPX (Windows Hypervisor Platform) or Hyper-V — enable via "Turn Windows features on or + off" → Windows Hypervisor Platform. + +Without one of these, the emulator falls back to full software emulation — it'll still work, just painfully +slowly for anything UI-automation-heavy. + +### Docker? + +Short answer: great on Linux, not currently useful on macOS. + +On a **Linux host**, running the emulator inside Docker with hardware acceleration is a well-trodden path — +pass `--device /dev/kvm` into the container and it gets near-native speed. This is exactly how CI systems do it +(e.g. [`reactivecircus/android-emulator-runner`](https://github.com/ReactiveCircus/android-emulator-runner) for +GitHub Actions, or [`budtmo/docker-android`](https://github.com/budtmo/docker-android) as a standalone image); +apkpull just needs `adb connect`/`adb devices` to see whatever container(s) you bring up, same as any device. + +On **macOS**, there's no equivalent: Docker Desktop for Mac runs containers inside its own lightweight Linux VM, +and that inner VM doesn't expose nested virtualization (no `/dev/kvm`) or HVF passthrough to containers — a +containerized emulator would fall back to software emulation, i.e. *slower* than just running it natively. On +Mac, running the emulator directly on the host (as above) already gets you HVF acceleration for free; Docker +wouldn't add anything here. If you need many parallel emulators for scale rather than raw speed, a Linux +box/CI runner (or a cloud device farm) is the more productive direction than fighting macOS's Docker sandboxing. + +## Usage + ```bash -cd apkpull && chmod +x apkpull.sh +apkpull com.whatsapp +apkpull com.whatsapp,com.spotify.music -d ~/Documents/my_apks --uninstall-after -v +apkpull com.whatsapp --devices emulator-5554,emulator-5556 --json +``` + +``` +usage: apkpull [-h] [-d DIR] [--uninstall-after] [--devices ID1,ID2,...] + [--max-workers MAX_WORKERS] [--max-poll-rounds MAX_POLL_ROUNDS] + [--unlock-timeout SECONDS] [--download-timeout SECONDS] + [--download-retries N] [--format {apks,zip,folder}] + [--notify] [--no-keep-screen-on] [--no-verify] [--strict-verify] + [--full-manifest] + [--skip-existence-check] [--skip-duplicate-check] [--skip-update-check] + [--no-live] [--adb-path ADB_PATH] [--json] [-v] [--version] + packages + + packages Package name, e.g. com.whatsapp. Comma-separated for more than + one, e.g. com.whatsapp,com.spotify.music — apps on the same + device download concurrently, same as tapping Install on each + from Google Play. + -d, --dest DIR Directory to pull files into (default: ~/Downloads/APKpull) + --uninstall-after Uninstall the app after pulling it. + --devices ID1,ID2,... Comma-separated device ids to target (default: every device adb sees). + --max-workers N Max devices processed concurrently. + --max-poll-rounds N UI-polling rounds before giving up on a device. + --unlock-timeout SEC Seconds to wait for a locked device to unlock (0 = forever). Default: 300. + --download-timeout SEC + Seconds to wait for a download/update to finish before retrying + or giving up on it (0 = forever). Default: 300. + --download-retries N Times to restart a timed-out download/update before reporting + it as failed. Default: 1. + --format {apks,zip,folder} + Output format (default: apks). See "Output formats" below. + --notify Send native desktop notifications (macOS/Linux). + --no-keep-screen-on Don't force the screen to stay awake while plugged in during the + pull (default: on; the device may lock and interrupt automation). + --no-verify Skip apkfile verification of pulled apks. + --strict-verify Treat a verification mismatch as a device failure. + --full-manifest Write the full apkfile manifest (every permission's AOSP detail, + exported/deep-link components, size breakdown, dex info, full + certificate fields) to manifest.json instead of the trimmed + default. No effect with --no-verify. + --skip-existence-check + Don't do the advisory pre-check of whether the package exists on + Google Play (see below) before touching devices. + --skip-duplicate-check + Don't warn about devices that look identical or share hardware + but differ only in configured languages (see below). + --skip-update-check For an already-installed package, pull whatever's currently on + the device instead of checking Google Play for an update first + (see below) — faster, but may pull a stale version. + --no-live Don't show the live per-device/per-package status table — just + plain, unbounded log lines. Automatic when stderr isn't a real + terminal. + --adb-path PATH Path to the adb executable (default: search PATH). + --json Print the run summary as JSON instead of text. + -v, -vv Verbose logging: -v for info, -vv for debug. ``` -- Make your changes and run the script with package name: + +Exit code is `0` when every targeted device succeeds, otherwise the number of failed devices (capped at `9`); `50` +if no devices were found at all. + +## As a library + +```python +from apkpull import OutputFormat, run + +summary = run( + ["com.whatsapp", "com.spotify.music"], # a single package name also works + "~/Documents/my_apks", + uninstall=True, + output_format=OutputFormat.FOLDER, +) +for outcome in summary.outcomes: + print(outcome.device.model, outcome.package, outcome.status, outcome.destination) +``` + +`run()` returns a `RunSummary` dataclass (`.as_dict()` for JSON) — see `apkpull/models.py` for the full shape. + +## How it works + +- **Why UI automation, and why text instead of resource-ids or coordinates**: apkpull needs *something* stable to + find a button on. Raw screen coordinates break the moment the layout shifts — a different screen size, or Play + Store itself pushing an update that reflows a screen; resource-ids would be the normal fix for that, but Google + Play's own UI exposes none on its buttons at all — confirmed by dumping the live UI tree, every text node's + `resource-id` comes back empty. What *is* stable is a button's visible, localized text, so that's what apkpull + matches on: dump the screen with `uiautomator`, find a node by its text for the device's language, tap its + bounds — the same way a person would. English, Hebrew, Spanish, French and Russian are built in; add a language + by extending the table in `apkpull/locales.py` — `scripts/extract_play_strings.sh` helps bootstrap most of a new + locale's strings straight from Google Play's own APK (see its header comment for how, and its limits). +- Each connected device runs on its own thread, so multiple devices — potentially with different + architectures/densities/languages — download and pull in parallel. apkpull waits for every targeted device to + finish a given package, then merges the union of all their distinct splits into one bundle (see + ["Why run more than one device?"](#why-run-more-than-one-device) above and + [Cross-device merging](#cross-device-merging) below) instead of one incomplete bundle per device. +- Multiple packages on the *same* device also download concurrently rather than one at a time: apkpull briefly + visits each package's Play Store page just long enough to tap Install/Update, then tracks every in-flight + package purely over adb (`pm path`/`dumpsys package`) and pulls each as it finishes — no UI polling involved, + since Google Play's own download manager genuinely overlaps downloads once they're started (confirmed by + watching two real installs queue and finish out of tap order on a live device). One tradeoff: because apkpull + isn't camped on any single package's page anymore, it can no longer auto-retry a download a human cancelled + by hand mid-run — only a device going offline is still recovered from mid-flight. +- For a package already installed on the device, apkpull's default is to check Google Play for an update first and + pull whatever that leaves installed (the existing version if already up to date, the new one if an update was + just applied) — `--skip-update-check` skips that check entirely for such a package: no Play Store launch, no UI + polling, straight to pulling whatever's on the device right now. Faster and more reliable (a whole class of UI + automation just doesn't happen for that package), at the cost of possibly pulling a stale version — reported as + `skipped_update_check` rather than `already_up_to_date`, since apkpull never actually checked. +- Every pulled app is packaged into a single `-` artifact — base apk + every split, always + zipped and verified with `apkfile` first regardless of format (see below), only *materialized* differently at + the end. That's what makes it immediately useful on its own: install it straight from `apkfile` + (`ApksFile(path).install()`), hand it to [SAI](https://github.com/Aefyr/SAI) on another Android device, or just + unzip it — instead of a directory tree of loose files only that one pull could make sense of. +- The zip is re-opened locally with `apkfile` and cross-checked against what adb reported on-device (package + name, version code) — catching a truncated pull, or a bug in the zipping step, that `adb pull`'s own exit code + and a successful zip write would both miss. A `manifest.json` is written with the aggregated metadata: + permissions, features, ABIs, languages, signing certificates and more, unioned across the base apk and every + split — trimmed by default to keep it digestible (dropping per-permission AOSP detail, exported/deep-link + component lists, size breakdown, dex info, and most certificate fields); pass `--full-manifest` for all of it. + +### Output formats (`--format`) + +| Format (default **apks**) | Layout | +|---|---| +| `apks` | `-.apks` — a [bundletool](https://developer.android.com/tools/bundletool)/SAI-format zip (`meta.sai_v2.json` + base + splits) | +| `zip` | `-.zip` — byte-identical contents, just a familiar extension for tools that expect a plain zip | +| `folder` | `-/` — extracted: `base.apk`, each split, and `manifest.json` as loose files | + +All output lands flat in one destination directory — one artifact + one `manifest.json` per (package, version) +(inside the folder for `folder`, a `.manifest.json` sidecar otherwise), plus a loose OBB file on the rare app +that ships one (inside the folder too, for `folder`): +``` +APKpull/ +├── com.whatsapp-243119027.apks +├── com.whatsapp-243119027.manifest.json +├── tfilon.tfilon-41.zip +├── tfilon.tfilon-41.manifest.json +└── com.some.game-9/ + ├── base.apk + ├── config.arm64_v8a.apk + ├── manifest.json + └── main.9.com.some.game.obb +``` +An artifact that already exists locally is never rebuilt — a rerun of the same package/version skips straight +past every device without touching any of them. + +### Cross-device merging + +apkpull waits for every targeted device to finish a given package (success or failure) before packaging it, then +merges the union of all their distinct splits — and any OBB — into one bundle. Devices reporting different +`version_code`s for the same package can't be merged (Play Store can stage rollouts), so those get one bundle +per version instead. If some devices fail while others succeed, apkpull still builds the bundle from whoever +succeeded and logs a warning naming which device(s)/architecture are missing from it — a device's own outcome can +end up `error` purely because a sibling's contribution (or the shared bundle's strict-verify check) broke the +merge, even though that device's own install/update succeeded; the error message says so explicitly. Raw pulls +are staged under a `.apkpull-staging/` directory inside the destination while a group is still in flight, cleaned +up once its bundle is built (or the merge fails) — and best-effort swept at the start of every run in case a +previous one crashed before cleaning up its own. + +### Reliability + +- **Ctrl+C**: confirmed hands-on that the naive `ThreadPoolExecutor` + `as_completed()` pattern silently swallows + SIGINT — an untimed wait on thread-pool futures just doesn't get interrupted, so Ctrl+C used to do nothing until + every device finished or timed out on its own (which, with default timeouts, could be minutes). Fixed by + polling with a 1-second timeout instead of blocking indefinitely, so the interrupt is noticed promptly. On + Ctrl+C, apkpull stops launching any more work (cancels devices that haven't started yet), prints a clear + message, and exits with `130` (the standard shell convention) instead of a raw traceback — but a device already + mid-adb-call can't be safely force-killed, so apkpull waits for it to reach its own natural stopping point + (bounded by its existing `--download-timeout`/`--unlock-timeout`/etc.) rather than abandoning it uncleanly. +- **Foreground detection**: apkpull needs to know whether the device is still showing Google Play (vs. having + navigated away) to decide whether to relaunch it. This used to read `dumpsys activity activities`' + `mResumedActivity` field — which turns out to not exist at all on Android 14 (API 34) and up, replaced by + `topResumedActivity`/`ResumedActivity:`. With that field silently missing, apkpull would conclude it was + *never* on Google Play and relaunch the details page on every single poll, even mid-download — resetting the + page and derailing the install. Fixed by reading `dumpsys window`'s `mCurrentFocus` instead, which has been a + stable single-line answer across the versions checked. +- **Package existence**: before touching any device, apkpull does an advisory-only check of whether the package + has a listing on the Play Store *website* (`--skip-existence-check` turns it off). It only ever warns, never + aborts the run — a confirmed 404 there doesn't reliably mean the package can't be installed on the actual + target device, since that request runs from apkpull's host machine's own network/region, not the device's Play + Store account's region. A region-locked app (most banking apps, for instance — verified hands-on against a real + one) can easily 404 the web listing from one country while installing fine on a device signed in with the right + region. The on-device automation is the real source of truth, and it now recognizes two more screens instead of + falling through to a slow, unhelpful `AutomationTimeoutError`: a dedicated "item not found" page (confirmed + hands-on), and — more generally — Play's red warning-triangle icon, which reliably pairs with a plain-text error + message on the details page (confirmed hands-on for the region-restricted screen). Any banner using that icon + that apkpull doesn't have a specific rule for still gets caught immediately, with the actual on-screen text + surfaced in the error instead of a generic timeout. +- **Insufficient device storage**: confirmed hands-on against a genuinely low-storage emulator, Play Store's "Not + enough storage" dialog only ever appears *after* tapping Install/Update — never on the details page beforehand + — so apkpull now takes one extra look right after that tap specifically to catch it (and, by the same change, + any other post-tap dialog), instead of assuming a download silently started in the background and polling adb + forever for an install that will never happen. +- **Paid apps**: detected by a price tag (e.g. `$4.99`) anywhere on the details page, which is driven by the + signed-in Google account's *billing region*, not the display language — confirmed hands-on: a Russian-language + page for a paid app still showed its price in Shekels, matching the test account's actual billing country. So + this needs no per-locale strings, just currency-symbol coverage (`₪ $ € £ ₽ ¥ ₹`). +- **Offline, two different ways**: Play Store shows *two* distinct screens for no network, confirmed hands-on — + a themed "You're offline" browsing page (usually hit navigating straight to a package's details page) and a + separate plainer "Something went wrong" / "No internet connection..." page with a "Try again" button (seen via + other navigation paths, e.g. from the store's home feed). Both are caught and raise the same error, since it's + the same underlying condition either way. +- **Locked devices**: apkpull waits for a locked device to be unlocked before starting automation, polling + `--unlock-timeout` seconds (default 300, `0` = forever) before giving up on that device — without this, a + single locked device would hang the entire run forever, since it waits for every targeted device before + printing a summary. +- **Disconnected devices**: every polling loop (waiting for a button, waiting for install/update to finish, + waiting for unlock) re-checks the connection each iteration and fails that device cleanly the moment it drops, + rather than hanging or crashing the whole run — a device going offline mid-pull is reported as a normal + per-device failure, and every other device keeps running. +- **Stalled downloads**: the adb-only poll that waits for a download/update to finish (see above) is bounded by + `--download-timeout` seconds (default 300, `0` = forever); on timeout apkpull restarts that package's kickoff up + to `--download-retries` times (default 1) before reporting it as failed, so one stuck download can't hang a + device — or, with `0`/no other in-flight packages, the whole run — forever. +- **Duplicate/redundant devices**: when targeting more than one device, apkpull warns (but still processes every + device) about two kinds of overlap, based on the exact properties Google Play actually uses to pick which + apk/splits to serve — ABI, screen density *bucket*, SDK version, and the *full set* of languages configured on + the device (not just the primary one — Play fetches a language split per installed language, so a device with + three languages configured contributes three language splits, not one): + - Devices matching on **all four** will download byte-identical files — a straightforward waste, since running + one of them would produce the exact same contribution to the merged bundle as running both. + - Devices matching on ABI/density/SDK but with **different configured languages** will each pull different + language splits, but redundantly re-pull the shared base/ABI/density splits to get there — apkpull suggests + configuring every language on a single device instead (see + ["Why run more than one device?"](#why-run-more-than-one-device) above), so one pull covers all of them + without repeating the shared splits per language. + + Density is compared by *bucket* (`ldpi`/`mdpi`/`tvdpi`/`hdpi`/`xhdpi`/`xxhdpi`/`xxxhdpi` — Play only ever serves + one of these, never a device's exact raw dpi), not the raw `ro.sf.lcd_density` value, using `apkfile`'s + `DensityBucket` and nearest-match-by-distance: two devices reporting different raw density (e.g. 420 vs 439) can + round to the same bucket and get an identical dpi split, so comparing raw density would both miss real + duplicates and flag devices whose difference doesn't actually matter. + + `--skip-duplicate-check` turns this off entirely (no warning, and no extra per-device `getprop` round-trip to + check for one) for setups where the overlap is intentional — e.g. deliberately running two identical devices + just to compare timing, or a fleet where every device really is meant to be a like-for-like clone. +- Run with `-vv` to see every adb call and UI-polling decision. If automation gets stuck (an unrecognized screen + after `--max-poll-rounds` polls), a screenshot + UI dump are saved for a bug report (path logged at the time). + +## Development + +Uses [uv](https://docs.astral.sh/uv/) for dependency management: + ```bash -./apkpull.sh com.whatsapp -``` ---- -- Because each device may have different splits, several devices can be connected at the same time. Only splits that do not already exist locally will be downloaded again. -- The script's exit codes are: `0` if all success, `1`-`9` for every faild device, `10` if pkg not provided, `20` if adb not found, `30` for INT signal (Ctrl+C), `40` if app not in play store, and `50` if no devices connected -- I would recommend following the device screen for the first few times to see how the automation works. Then you can already run it with your eyes closed... -- APKpull uses [ADB](https://developer.android.com/studio/command-line/adb) to connect to devices, jump into the app page in Google Play, make the necessary clicks based on the existing data on the screen, download the app and finally pull it to local as an APK with Splits and even OBB's (if any). -- While running the script, many tests are performed: checking updates, locked screen, device compatibility or country restrictions, paid apps, network, sign in check and more. -- If there is an untreated end case, after several attempts (Can be increased by raising the number of [`max_rounds`](/apkpull.sh#L31)) a log will be created that will contain a screenshot and dump ui from the device, which can be attached to a new issue on github. -- Due to the way Google Play app is built, most of the tests and clicks are based on text, so English and Hebrew are currently supported. If you want to support other languages as well, fork this repo, create a list of `buttons_lang` at the beginning of the script, fill in the values in the existing format in the other languages and open PR. -- The files are saved by default to the `~/Downloads/apkpull_dl` folder in the next example format: +git clone https://github.com/david-lev/apkpull.git +cd apkpull +uv sync +uv run pre-commit install +``` + +Run the CLI from a clone with `uv run apkpull ...` (no need to `pip install -e .` first). + ```bash -. -├── com.flambestudios.picplaypost -│   └── 451 -│   ├── com.flambestudios.picplaypost_451 -│   │   ├── config.arm64_v8a.apk -│   │   ├── config.armeabi_v7a.apk -│   │   ├── config.en.apk -│   │   └── config.xhdpi.apk -│   └── com.flambestudios.picplaypost-451_base.apk -├── com.microsoft.office.officehubrow -│   └── 42787678 -│   ├── com.microsoft.office.officehubrow-42787678.apk -│   └── main.42787678.com.microsoft.office.officehubrow.obb -└── tfilon.tfilon - └── 38 - └── tfilon.tfilon-38.apk +uv run pytest # full test suite (unit + integration) +uv run pytest -m "not integration" # unit tests only — no device required +uv run pytest -m integration # only the tests that need a real adb device/emulator + +uv run ruff check . # lint +uv run ruff format . # format +uv run ty check # type check +uv run pre-commit run --all-files # everything pre-commit enforces ``` + +The integration tests only ever target `com.android.vending` (the Play Store app, always already installed), so +they never install/update/uninstall anything on your device — they exercise device discovery, the "already up to +date" automation path, real pulling, and `apkfile` verification against genuine adb output. They skip themselves +automatically when no device is connected, so plain `uv run pytest` is always safe to run. + +CI (`.github/workflows/tests.yml`) runs the unit test suite across Python 3.10–3.14 on Linux, plus 3.10/3.14 +(min/max) on macOS and Windows, and a separate `lint` job runs `ruff check`, `ruff format --check`, and `ty check`. + +## Legacy bash version + +The original single-file [`apkpull.sh`](./apkpull.sh) still works and needs nothing but `bash` + `adb`, but is no +longer maintained — the Python CLI above is the supported tool going forward. diff --git a/apkpull/__init__.py b/apkpull/__init__.py new file mode 100644 index 0000000..7a517b2 --- /dev/null +++ b/apkpull/__init__.py @@ -0,0 +1,30 @@ +"""apkpull: automate pulling Android apps from Google Play across one or more devices.""" + +from .exceptions import ( + ApkPullError, + AppNotFoundError, + DeviceError, + DeviceLockedTimeoutError, + InvalidPackageNameError, + NoDevicesFoundError, +) +from .models import DeviceOutcome, OutputFormat, PulledFile, RunSummary, Status +from .orchestrator import run + +__version__ = "2.0.0" + +__all__ = [ + "ApkPullError", + "AppNotFoundError", + "DeviceError", + "DeviceLockedTimeoutError", + "DeviceOutcome", + "InvalidPackageNameError", + "NoDevicesFoundError", + "OutputFormat", + "PulledFile", + "RunSummary", + "Status", + "__version__", + "run", +] diff --git a/apkpull/__main__.py b/apkpull/__main__.py new file mode 100644 index 0000000..2fa9e58 --- /dev/null +++ b/apkpull/__main__.py @@ -0,0 +1,6 @@ +from sys import exit + +from .cli import main + +if __name__ == "__main__": + exit(main()) diff --git a/apkpull/adb.py b/apkpull/adb.py new file mode 100644 index 0000000..84cddb0 --- /dev/null +++ b/apkpull/adb.py @@ -0,0 +1,151 @@ +"""Thin, typed wrapper around the ``adb`` command-line tool. + +Nothing in this module knows anything about Google Play or apk pulling — +it is a general purpose adb client so it can be unit tested by mocking +``subprocess.run`` alone, and reused outside apkpull if needed. +""" + +from __future__ import annotations + +import logging +import shutil +import subprocess +from dataclasses import dataclass +from pathlib import Path + +from .exceptions import AdbNotFoundError, DeviceDisconnectedError + +logger = logging.getLogger("apkpull.adb") + +DEFAULT_TIMEOUT = 30.0 + + +@dataclass(frozen=True, slots=True) +class AdbResult: + args: list[str] + returncode: int + stdout: str + stderr: str + + @property + def ok(self) -> bool: + return self.returncode == 0 + + +class AdbClient: + """Runs ``adb`` subprocesses. One instance is shared across all devices.""" + + def __init__(self, adb_path: str | None = None) -> None: + resolved = adb_path or shutil.which("adb") + if not resolved: + raise AdbNotFoundError( + "Unable to find `adb`. Install Android platform-tools or add it to PATH." + ) + self.adb_path = resolved + logger.debug("Using adb at %s", self.adb_path) + + # -- low level ----------------------------------------------------- + + def _run( + self, args: list[str], *, timeout: float = DEFAULT_TIMEOUT, check: bool = False + ) -> AdbResult: + cmd = [self.adb_path, *args] + logger.debug("$ %s", " ".join(cmd)) + try: + proc = subprocess.run( + cmd, capture_output=True, text=True, timeout=timeout, check=False + ) + except subprocess.TimeoutExpired as exc: + raise DeviceDisconnectedError( + f"adb command timed out: {' '.join(cmd)}" + ) from exc + result = AdbResult( + args=cmd, returncode=proc.returncode, stdout=proc.stdout, stderr=proc.stderr + ) + if not result.ok: + logger.debug("adb exited %s: %s", result.returncode, result.stderr.strip()) + if check and not result.ok: + raise DeviceDisconnectedError( + result.stderr.strip() or f"adb command failed: {' '.join(cmd)}" + ) + return result + + # -- device discovery ------------------------------------------------ + + def list_devices(self) -> list[str]: + """Return device ids currently in the ``device`` (ready) state.""" + result = self._run(["devices"]) + devices = [] + for line in result.stdout.splitlines()[1:]: + line = line.strip() + if not line or "\t" not in line: + continue + device_id, state = line.split("\t", 1) + if state.strip() == "device": + devices.append(device_id) + return devices + + def get_state(self, device_id: str) -> str: + result = self._run(["-s", device_id, "get-state"], timeout=5) + if not result.ok: + return ( + (result.stderr or "unknown") + .strip() + .replace("error: device ", "") + .rstrip(".") + ) + return result.stdout.strip() + + def is_connected(self, device_id: str) -> bool: + return self.get_state(device_id) == "device" + + # -- shell ----------------------------------------------------------- + + def shell( + self, + device_id: str, + command: str, + *, + timeout: float = DEFAULT_TIMEOUT, + check: bool = True, + ) -> str: + result = self._run( + ["-s", device_id, "shell", command], timeout=timeout, check=check + ) + return result.stdout + + # -- file transfer ----------------------------------------------------- + + def pull( + self, + device_id: str, + remote_path: str, + local_path: Path, + *, + timeout: float = 300.0, + ) -> None: + local_path.parent.mkdir(parents=True, exist_ok=True) + result = self._run( + ["-s", device_id, "pull", remote_path, str(local_path)], timeout=timeout + ) + if not result.ok: + raise DeviceDisconnectedError( + f"Failed to pull {remote_path}: {result.stderr.strip()}" + ) + + def exec_out_to_file( + self, device_id: str, command: str, local_path: Path, *, timeout: float = 30.0 + ) -> None: + local_path.parent.mkdir(parents=True, exist_ok=True) + cmd = [self.adb_path, "-s", device_id, "exec-out", *command.split()] + try: + with open(local_path, "wb") as fh: + proc = subprocess.run( + cmd, stdout=fh, stderr=subprocess.PIPE, timeout=timeout, check=False + ) + except subprocess.TimeoutExpired as exc: + raise DeviceDisconnectedError(f"exec-out timed out: {command}") from exc + if proc.returncode != 0: + raise DeviceDisconnectedError( + f"exec-out failed: {proc.stderr.decode(errors='replace').strip()}" + ) diff --git a/apkpull/automation.py b/apkpull/automation.py new file mode 100644 index 0000000..b39779d --- /dev/null +++ b/apkpull/automation.py @@ -0,0 +1,296 @@ +"""Drives the Google Play Store app's UI to install or update a package. + +Play Store exposes no stable resource-ids on its buttons (confirmed by +dumping the live UI tree on a real emulator — every text node's +``resource-id`` attribute is empty), so — like the original bash script — +this automates by finding buttons via their (localized) text and tapping +the resulting screen coordinates. See :mod:`apkpull.locales` for the string +tables and :mod:`apkpull.uidump` for the dump-parsing primitives. +""" + +from __future__ import annotations + +import logging +import tempfile +import time +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path + +from . import uidump +from .device import Device +from .exceptions import ( + AppNotFoundError, + AutomationTimeoutError, + DeviceDisconnectedError, + DeviceLockedTimeoutError, + DeviceOfflineError, + IncompatibleDeviceError, + InsufficientStorageError, + NotSignedInError, + PaidAppError, + RegionRestrictedError, + UnrecognizedPlayStoreError, +) +from .locales import PAID_APP_CURRENCY_SYMBOLS, ButtonSet +from .models import Status +from .progress import DeviceReporter, Stage + + +@dataclass(frozen=True, slots=True) +class KickoffResult: + """Result of starting (not waiting out) an install/update. + + ``needs_tracking`` is ``False`` when there's nothing left to do — either + the app was already up to date, or it finished installing/updating + before we even finished tapping (rare, but real on a fast connection). + Otherwise the caller should poll ``device.is_installed``/``version_code`` + (for installs/updates respectively) until it resolves. + """ + + needs_tracking: bool + status: Status | None = None + baseline_version_code: int | None = None + + +logger = logging.getLogger("apkpull.automation") + +GOOGLE_PLAY_PACKAGE = "com.android.vending" + + +@dataclass(slots=True) +class AutomationConfig: + max_rounds: int = 5 + """Consecutive polls with no recognizable UI state before giving up.""" + poll_interval: float = 1.0 + unlock_poll_interval: float = 1.0 + unlock_timeout: float = 300.0 + """Give up waiting for a locked device after this many seconds. ``0`` waits forever.""" + logs_dir: Path = Path(tempfile.gettempdir()) / "apkpull_logs" + + +class PlayStoreAutomator: + """Stateful helper bound to one :class:`~apkpull.device.Device`.""" + + def __init__( + self, device: Device, buttons: ButtonSet, config: AutomationConfig | None = None + ) -> None: + self.device = device + self.buttons = buttons + self.config = config or AutomationConfig() + + # -- public flows --------------------------------------------------------- + + def launch(self, package: str) -> None: + url = f"https://play.google.com/store/apps/details?id={package}" + self.device.launch_url(url, GOOGLE_PLAY_PACKAGE) + + def wait_for_unlock(self, report: DeviceReporter | None = None) -> None: + if self.device.is_unlocked(): + return + logger.warning( + "[%s] Device is locked. Waiting for it to be unlocked...", self.device.label + ) + if report: + report(Stage.LOCKED, "") + start = time.monotonic() + while not self.device.is_unlocked(): + self.device.ensure_connected() + elapsed = time.monotonic() - start + if self.config.unlock_timeout and elapsed >= self.config.unlock_timeout: + raise DeviceLockedTimeoutError( + f"Device stayed locked for over {self.config.unlock_timeout:.0f}s; giving up." + ) + time.sleep(self.config.unlock_poll_interval) + logger.info("[%s] Device unlocked.", self.device.label) + + def start_install(self, package: str) -> KickoffResult: + """Kick off the "not installed yet" flow — does not wait for it to finish.""" + b = self.buttons + logger.info( + "[%s] Launching Google Play to %s's page.", self.device.label, package + ) + self.launch(package) + + install_coords, _nodes = self._wait_for_button( + package, + button_text=b.install, + also_break_on=(b.cancel,), + ) + if install_coords is None: + # `_wait_for_button`'s only other exit condition is `also_break_on`, so + # `install_coords is None` here always means the "Cancel" button matched. + logger.info("[%s] %s is already downloading.", self.device.label, package) + return KickoffResult(needs_tracking=True) + + self._tap_until_download_starts(package, install_coords) + if self.device.is_installed(package): + # Fast connection: it can finish installing before we ever see "Cancel". + logger.info("[%s] %s installed successfully!", self.device.label, package) + return KickoffResult(needs_tracking=False, status=Status.INSTALLED) + + logger.info("[%s] %s: download started.", self.device.label, package) + return KickoffResult(needs_tracking=True) + + def start_update(self, package: str) -> KickoffResult: + """Kick off the "already installed, check for update" flow.""" + b = self.buttons + self.launch(package) + + try: + update_coords, nodes = self._wait_for_button( + package, + button_text=b.update, + also_break_on=(b.cancel, b.open, b.play, b.uninstall, b.deactivate), + ) + except AutomationTimeoutError: + logger.warning( + "[%s] Could not determine update status for %s.", + self.device.label, + package, + ) + return KickoffResult(needs_tracking=False, status=Status.ALREADY_UP_TO_DATE) + + # `update_coords is None` is ambiguous by itself: `also_break_on` matches + # either "already up to date" (Open/Play/Uninstall visible, no Update + # button) or "already updating" (Cancel visible). Only the latter should + # fall through to tracking the update. + if update_coords is None and not uidump.contains_text(nodes, b.cancel): + logger.info("[%s] %s is already up to date.", self.device.label, package) + return KickoffResult(needs_tracking=False, status=Status.ALREADY_UP_TO_DATE) + + before = self.device.version_code(package) + if before is None: + raise AutomationTimeoutError( + f"Could not read {package}'s current version code from the device." + ) + if update_coords is None: + logger.info("[%s] %s is already updating.", self.device.label, package) + return KickoffResult(needs_tracking=True, baseline_version_code=before) + + self._tap_until_download_starts(package, update_coords) + if self.device.version_code(package) != before: + logger.info("[%s] %s updated successfully!", self.device.label, package) + return KickoffResult(needs_tracking=False, status=Status.UPDATED) + + logger.info("[%s] %s: update started.", self.device.label, package) + return KickoffResult(needs_tracking=True, baseline_version_code=before) + + # -- internals -------------------------------------------------------------- + + def _check_error_screens(self, nodes: list[uidump.UiNode]) -> None: + """``nodes`` is one dump parsed once by the caller (``_wait_for_button``/ + ``_tap_until_download_starts``) — every check below queries that same + parsed list rather than re-scanning the raw XML itself, since a single + poll tick used to trigger up to a dozen-plus independent re-parses of + the same dump here.""" + b = self.buttons + if b.not_found and uidump.contains_text(nodes, b.not_found): + raise AppNotFoundError(b.not_found) + if b.insufficient_storage and uidump.contains_text( + nodes, b.insufficient_storage + ): + raise InsufficientStorageError(b.insufficient_storage) + if uidump.contains_text(nodes, b.hardware_incompatible): + raise IncompatibleDeviceError(b.hardware_incompatible) + if uidump.contains_text(nodes, b.country_restricted): + raise RegionRestrictedError(b.country_restricted) + if uidump.contains_text(nodes, b.offline): + raise DeviceOfflineError(f"{b.offline}.") + if b.no_internet_dialog and uidump.contains_text(nodes, b.no_internet_dialog): + raise DeviceOfflineError(b.no_internet_dialog) + if uidump.contains_any_currency_amount(nodes, PAID_APP_CURRENCY_SYMBOLS): + raise PaidAppError("This app is paid.") + if b.sign_in and uidump.contains_text(nodes, b.sign_in): + raise NotSignedInError("You must be logged in to a Google account.") + if b.warning_icon: + message = uidump.find_text_near_content_desc(nodes, b.warning_icon) + if message: + raise UnrecognizedPlayStoreError(f"Google Play showed: {message!r}") + + def _wait_for_button( + self, package: str, *, button_text: str, also_break_on: tuple[str, ...] + ) -> tuple[tuple[int, int] | None, list[uidump.UiNode]]: + """Poll until ``button_text`` (or any text in ``also_break_on``) appears. + + Returns ``(coords, nodes)`` — ``coords`` is ``None`` when the match was + one of ``also_break_on`` rather than the target button itself; ``nodes`` + is the winning poll's dump, already parsed once, for the caller to + query further without re-parsing. + """ + rounds = 0 + while True: + self.device.ensure_connected() + dump = self.device.dump_ui() + nodes = uidump.parse(dump) + self._check_error_screens(nodes) + + coords = uidump.find_button(nodes, button_text) + if coords is not None: + return coords, nodes + if any(uidump.contains_text(nodes, t) for t in also_break_on): + return None, nodes + + if not self.device.is_foreground_package(GOOGLE_PLAY_PACKAGE): + logger.info("[%s] Left Google Play, relaunching...", self.device.label) + self.launch(package) + + rounds += 1 + if rounds >= self.config.max_rounds: + screenshot, dump_path = self._capture_error(dump) + raise AutomationTimeoutError( + f"An unknown error occurred waiting for '{button_text}'.", + screenshot=str(screenshot), + dump=str(dump_path), + ) + time.sleep(self.config.poll_interval) + + def _tap_until_download_starts(self, package: str, coords: tuple[int, int]) -> None: + """Tap ``coords`` (the install/update button), then take one more look to + catch an immediate post-tap failure (confirmed hands-on: Play Store's + "Not enough storage" dialog appears only *after* this tap, never on the + details page itself — without this check the caller would just assume a + download silently started in the background and poll adb forever for an + install that will never happen) or see whether the "Cancel" button + appeared (download started) or the device wandered off Google Play + mid-tap. Either way, our job here is just to *start* the download; the + caller tracks completion over adb. + """ + self.device.ensure_connected() + self.device.tap(*coords) + self._check_error_screens(uidump.parse(self.device.dump_ui())) + if not self.device.is_foreground_package(GOOGLE_PLAY_PACKAGE): + logger.info("[%s] Left Google Play, relaunching...", self.device.label) + self.launch(package) + + def _capture_error(self, dump: str) -> tuple[Path, Path]: + self.config.logs_dir.mkdir(parents=True, exist_ok=True) + stamp = datetime.now().astimezone().strftime("%Y%m%d_%H%M%S") + info = self.device.info() + # device_id in the filename (not just model/lang) since two devices + # erroring in the same second with the same model+lang -- exactly the + # "duplicate-looking devices" scenario apkpull itself warns about -- + # would otherwise silently overwrite each other's diagnostic files. + base = self.config.logs_dir / ( + f"{stamp}_{info.model}_{info.lang}_{self.device.device_id}".replace( + " ", "_" + ).replace(":", "_") + ) + dump_path = base.with_suffix(".xml") + screenshot_path = base.with_suffix(".png") + dump_path.write_text(dump, encoding="utf-8", errors="replace") + try: + self.device.adb.exec_out_to_file( + self.device.device_id, "screencap -p", screenshot_path + ) + except DeviceDisconnectedError: + logger.debug( + "Could not capture screenshot; device disconnected mid-capture." + ) + logger.warning( + "[%s] LOG: saved screenshot/dump to %s / %s", + self.device.label, + screenshot_path, + dump_path, + ) + return screenshot_path, dump_path diff --git a/apkpull/bundle.py b/apkpull/bundle.py new file mode 100644 index 0000000..c0d57e8 --- /dev/null +++ b/apkpull/bundle.py @@ -0,0 +1,111 @@ +"""Build and verify SAI/bundletool-compatible ``.apks`` bundles. + +A ``.apks`` file (as produced by e.g. `SAI `_) is +just a zip of a base apk + splits + a ``meta.sai_v2.json`` manifest — +``apkfile.ApksFile`` already knows how to read (and install) one. Producing +this instead of a loose directory of apk files turns a device pull into one +shareable, immediately-installable artifact per (package, version), openable +by apkfile itself, by SAI on another Android device, or by any zip tool. +""" + +from __future__ import annotations + +import enum +import logging +from pathlib import Path + +from apkfile import ApksFile, InvalidApkError, InvalidBundleError + +from .exceptions import VerificationError + +logger = logging.getLogger("apkpull.bundle") + +META_NAME = "meta.sai_v2.json" +BASE_NAME = "base.apk" + + +def build_apks_bundle( + *, + base_path: Path, + split_paths: list[Path], + dest_path: Path, +) -> None: + """Build a SAI-format ``.apks`` file at ``dest_path`` from ``base_path`` + ``split_paths``. + + Delegates to ``apkfile.ApksFile.create``, which re-parses ``base_path`` to derive the + ``meta.sai_v2.json`` manifest (package, label, version_code, version_name, min/target sdk). + + Written to a ``.tmp`` sibling first and atomically renamed into place, so + a crash or interrupt mid-write can never leave a half-written file at + ``dest_path`` for a later run's dedup check to mistake for a complete pull. + """ + dest_path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = dest_path.with_name(dest_path.name + ".tmp") + try: + built = ApksFile.create([base_path, *split_paths], tmp_path) + built._zip.close() # release the handle before the rename below (Windows can't rename an open file) + tmp_path.replace(dest_path) + finally: + tmp_path.unlink(missing_ok=True) + + +def json_default(value): + """``json.dumps(..., default=...)`` hook for the `Path`/`Enum` values ``ApksFile.as_dict()`` leaves as-is.""" + if isinstance(value, enum.Enum): + return value.value + return str(value) + + +def verify_bundle( + package: str, + expected_version_code: int, + bundle_path: Path, + *, + strict: bool = False, + full: bool = False, +) -> tuple[bool, dict]: + """Re-open ``bundle_path`` with apkfile and confirm it matches ``package``/``expected_version_code``. + + This is what catches a truncated/corrupt pull, or a bundle-writing bug, + that ``adb pull``'s own exit code and the zip step's own success missed. + + ``full`` is forwarded to ``ApksFile.as_dict()`` — trimmed (the default) + leaves out several verbose/duplicative sections (full per-permission AOSP + detail, exported/deep-link component lists, size breakdown, dex info, + most certificate fields) to keep the written manifest digestible; ``full`` + includes all of it. + + Returns ``(verified, manifest_dict)``. Raises :class:`VerificationError` if + ``strict`` and verification fails — including the bundle failing to parse + at all. + """ + try: + apks = ApksFile(bundle_path) + except (InvalidBundleError, InvalidApkError) as exc: + logger.warning("Verification failed for %s: %s", bundle_path.name, exc) + if strict: + raise VerificationError( + f"{bundle_path.name} failed to parse: {exc}" + ) from exc + return False, {"error": str(exc)} + + verified = ( + apks.package_name == package and apks.version_code == expected_version_code + ) + if not verified: + logger.warning( + "Verification mismatch for %s: package=%s (expected %s) version=%s (expected %s)", + bundle_path.name, + apks.package_name, + package, + apks.version_code, + expected_version_code, + ) + + manifest = apks.as_dict(full=full) + manifest["splits"] = [s.split_name for s in apks.splits] + manifest["verified"] = verified + + if strict and not verified: + raise VerificationError(f"Verification failed for {bundle_path.name}") + return verified, manifest diff --git a/apkpull/cli.py b/apkpull/cli.py new file mode 100644 index 0000000..1afa33b --- /dev/null +++ b/apkpull/cli.py @@ -0,0 +1,271 @@ +"""Command-line entry point. + +apkpull com.whatsapp +apkpull com.whatsapp,com.spotify.music -d ~/Documents/my_apks --uninstall-after -vv +apkpull com.whatsapp --devices emulator-5554,emulator-5556 --json +""" + +from __future__ import annotations + +import argparse +import json +import logging +import sys +from contextlib import nullcontext +from pathlib import Path + +from . import __version__ +from .automation import AutomationConfig +from .exceptions import ApkPullError +from .locales import supported_languages +from .logging_setup import setup_logging +from .models import FileKind, OutputFormat +from .orchestrator import run as run_pull +from .tui import LiveDisplay + +logger = logging.getLogger("apkpull.cli") + +DEFAULT_DEST = Path.home() / "Downloads" / "APKpull" + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="apkpull", + description="Pull an Android app's apk/obb files from Google Play via one or more adb-connected devices.", + ) + parser.add_argument( + "packages", + help="Package name, e.g. com.whatsapp. Comma-separated for more than " + "one, e.g. com.whatsapp,com.spotify.music — apps on the same device " + "download concurrently, same as tapping Install on each from Google Play.", + ) + parser.add_argument( + "-d", + "--dest", + type=Path, + default=DEFAULT_DEST, + metavar="DIR", + help=f"Directory to pull files into (default: {DEFAULT_DEST})", + ) + parser.add_argument( + "--uninstall-after", + action="store_true", + help="Uninstall the app after pulling it.", + ) + parser.add_argument( + "--devices", + metavar="ID1,ID2,...", + help="Comma-separated device ids to target (default: every device adb sees).", + ) + parser.add_argument( + "--max-workers", + type=int, + default=None, + help="Max devices processed concurrently.", + ) + parser.add_argument( + "--max-poll-rounds", + type=int, + default=5, + help="UI-polling rounds before giving up on a device.", + ) + parser.add_argument( + "--unlock-timeout", + type=float, + default=300.0, + help="Seconds to wait for a locked device to unlock before giving up on it " + "(0 = wait forever). Default: 300.", + ) + parser.add_argument( + "--download-timeout", + type=float, + default=300.0, + help="Seconds to wait for a package's download/update to finish before " + "retrying or giving up on it (0 = wait forever). Default: 300.", + ) + parser.add_argument( + "--download-retries", + type=int, + default=1, + help="Times to restart a package's download/update after it times out, " + "before reporting it as failed. Default: 1.", + ) + parser.add_argument( + "--format", + choices=[f.value for f in OutputFormat], + default=OutputFormat.APKS.value, + help="Output format: 'apks' (SAI/bundletool zip, default), 'zip' (same contents, " + ".zip extension), or 'folder' (extracted loose files).", + ) + parser.add_argument( + "--notify", action="store_true", help="Send native desktop notifications." + ) + parser.add_argument( + "--no-keep-screen-on", + dest="keep_screen_on", + action="store_false", + help="Don't force the screen to stay awake while plugged in during the pull " + "(the device may lock and interrupt automation).", + ) + parser.add_argument( + "--no-verify", + action="store_true", + help="Skip apkfile verification of pulled apks.", + ) + parser.add_argument( + "--strict-verify", + action="store_true", + help="Treat a verification mismatch as a device failure (implies verification is on).", + ) + parser.add_argument( + "--full-manifest", + action="store_true", + help="Write the full apkfile manifest (every permission's AOSP detail, exported/" + "deep-link components, size breakdown, dex info, full certificate fields) to " + "manifest.json instead of the trimmed default. No effect with --no-verify.", + ) + parser.add_argument( + "--skip-existence-check", + action="store_true", + help="Don't pre-check that the package exists on Google Play before touching devices.", + ) + parser.add_argument( + "--skip-duplicate-check", + action="store_true", + help="Don't warn about devices that look identical or share hardware but " + "differ only in configured languages.", + ) + parser.add_argument( + "--skip-update-check", + action="store_true", + help="For a package already installed on a device, pull whatever's " + "currently installed instead of checking Google Play for an update " + "first — faster, and skips Play Store UI automation entirely for that " + "package, but may pull a stale version.", + ) + parser.add_argument( + "--force-locale", + choices=supported_languages(), + default=None, + help="Force Google Play to this language for the run, on every targeted " + "device, reverting it back to whatever it was afterward — useful when a " + "device's own language isn't one apkpull supports. Requires Android 13+ " + "(API 33+) per-app locale overrides; on older devices this is skipped " + "with a warning and that device falls back to its own language as usual.", + ) + parser.add_argument( + "--no-live", + action="store_true", + help="Don't show the live per-device/per-package status table — just plain, " + "unbounded log lines. Useful for a run with enough devices/packages that the " + "table would no longer fit the terminal (see the module docstring in tui.py " + "for why that matters). Live display is also automatically skipped when " + "stderr isn't a real terminal.", + ) + parser.add_argument( + "--adb-path", + default=None, + help="Path to the adb executable (default: search PATH).", + ) + parser.add_argument( + "--json", + action="store_true", + help="Print the run summary as JSON instead of text.", + ) + parser.add_argument( + "-v", + "--verbose", + action="count", + default=0, + help="-v for info logs, -vv for debug logs.", + ) + parser.add_argument("--version", action="version", version=f"apkpull {__version__}") + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + device_ids = [d.strip() for d in args.devices.split(",")] if args.devices else None + packages = [p.strip() for p in args.packages.split(",")] + automation_config = AutomationConfig( + max_rounds=args.max_poll_rounds, unlock_timeout=args.unlock_timeout + ) + + # -v/-vv's logs print through the same console and scroll in above the + # live region same as any other log line (see tui.py's module + # docstring) -- no need to force --no-live for them. Live display is + # still auto-skipped for a non-terminal stderr (piped/redirected/CI): + # there's nothing to redraw in place there, and rich would just print + # one frame per line. + use_live = not args.no_live and sys.stderr.isatty() + display = LiveDisplay() if use_live else None + # Route logging through the live display's own console when active, so + # warnings/errors print correctly above its live region instead of + # corrupting it (a plain direct stderr write would). + setup_logging(args.verbose, console=display.console if display else None) + + try: + with display if display is not None else nullcontext(): + summary = run_pull( + packages, + args.dest, + device_ids=device_ids, + uninstall=args.uninstall_after, + max_workers=args.max_workers, + notify_enabled=args.notify, + verify=not args.no_verify, + strict_verify=args.strict_verify, + full_manifest=args.full_manifest, + output_format=OutputFormat(args.format), + adb_path=args.adb_path, + automation_config=automation_config, + skip_existence_check=args.skip_existence_check, + keep_screen_on=args.keep_screen_on, + download_timeout=args.download_timeout, + download_retries=args.download_retries, + skip_duplicate_check=args.skip_duplicate_check, + skip_update_check=args.skip_update_check, + force_locale=args.force_locale, + on_progress=display.update if display else None, + ) + except ApkPullError as exc: + logger.error(str(exc)) + if args.json: + print(json.dumps({"error": str(exc)})) + return 1 + except KeyboardInterrupt: + logger.warning("Interrupted by user.") + if args.json: + print(json.dumps({"error": "Interrupted by user (Ctrl+C)."})) + return 130 # 128 + SIGINT — the standard shell convention + + if args.json: + print(json.dumps(summary.as_dict(), indent=2)) + else: + _print_summary(summary) + + return summary.exit_code + + +def _print_summary(summary) -> None: + for outcome in summary.outcomes: + icon = "OK " if outcome.ok else "FAIL" + print( + f"[{icon}] {outcome.device.label} / {outcome.package}: {outcome.status.value}" + + (f" - {outcome.error}" if outcome.error else "") + ) + for pulled in outcome.pulled_files: + if pulled.kind == FileKind.OBB: + size_mb = pulled.size_bytes / 1024 / 1024 + print(f" OBB: {pulled.name} ({size_mb:.1f} MB)") + print(f"\n{summary.successful}/{summary.total} successful operations!") + + +def run() -> None: # console_script entry point (pyproject: apkpull = apkpull.cli:run) + sys.exit(main()) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/apkpull/device.py b/apkpull/device.py new file mode 100644 index 0000000..35a1450 --- /dev/null +++ b/apkpull/device.py @@ -0,0 +1,292 @@ +"""High level, per-device operations built on top of :class:`apkpull.adb.AdbClient`. + +Everything here still deals only in adb/shell primitives — no Play Store / +UI-automation knowledge lives in this module (that's :mod:`apkpull.automation`). +""" + +from __future__ import annotations + +import logging +import re + +from apkfile import DensityBucket + +from .adb import AdbClient +from .exceptions import DeviceDisconnectedError +from .models import DeviceInfo + +logger = logging.getLogger("apkpull.device") + +_GETPROP_RE = re.compile(r"\[([\w.\-]+)\]:\s*\[(.*?)\]") +_VERSION_CODE_RE = re.compile(r"versionCode=(\d+)") +_VERSION_NAME_RE = re.compile(r"versionName=([^\s]+)") +_APP_LOCALES_RE = re.compile(r"are \[(.*?)\]") + +MIN_SDK_FOR_APP_LOCALES = 33 +"""Per-app locale overrides (``cmd locale ...``) landed in Android 13.""" + +# The real, physical-density buckets Android resource resolution (and Google +# Play's dpi-split selection) picks among — excludes DensityBucket's +# resource-qualifier-only sentinels (DEFAULT/ANY/NONE), which no device's +# actual screen density ever reports as. +_REAL_DENSITY_BUCKETS = ( + DensityBucket.LDPI, + DensityBucket.MDPI, + DensityBucket.TVDPI, + DensityBucket.HDPI, + DensityBucket.XHDPI, + DensityBucket.XXHDPI, + DensityBucket.XXXHDPI, +) + + +def _nearest_density_bucket(dpi: int) -> str: + """Google Play always serves one of a fixed set of density buckets, never a + device's exact raw dpi — this finds the nearest one by absolute distance. + Approximate: Android's real resource-matching algorithm has some + asymmetric preference for scaling a higher-density asset down over a + lower-density one up, which a plain nearest-neighbor doesn't model — but + it's right for the vast majority of real device densities, which don't + sit exactly on a bucket boundary. + """ + return min(_REAL_DENSITY_BUCKETS, key=lambda b: abs(b.dpi - dpi)).value + + +_MIN_SDK_RE = re.compile(r"minSdk=(\d+)") + + +class Device: + """A single adb-connected device/emulator, identified by ``device_id``.""" + + def __init__(self, adb: AdbClient, device_id: str) -> None: + self.adb = adb + self.device_id = device_id + self._info: DeviceInfo | None = None + + def __repr__(self) -> str: # pragma: no cover - cosmetic + return f"Device({self.device_id!r})" + + # -- connection state ------------------------------------------------- + + def is_connected(self) -> bool: + return self.adb.is_connected(self.device_id) + + def ensure_connected(self) -> None: + if not self.is_connected(): + state = self.adb.get_state(self.device_id) + raise DeviceDisconnectedError(f"Device {self.device_id} is {state}!") + + # -- identity ----------------------------------------------------------- + + def seed_info(self, info: DeviceInfo) -> None: + """Pre-populate the cached :meth:`info` result from an already-resolved + lookup done elsewhere (e.g. the duplicate-device check in + ``orchestrator.py``), so a later :meth:`info` call on this instance + returns it directly instead of redundantly re-issuing the same + ``getprop``/locale round trip for the same device within one run.""" + self._info = info + + def info(self, *, refresh: bool = False) -> DeviceInfo: + if self._info is not None and not refresh: + return self._info + raw = self.adb.shell(self.device_id, "getprop") + props = dict(_GETPROP_RE.findall(raw)) + lang, langs = self._resolve_locales(props) + density = self._to_int( + props.get("ro.sf.lcd_density") or props.get("qemu.sf.lcd_density") + ) + self._info = DeviceInfo( + device_id=self.device_id, + model=props.get("ro.product.model", "unknown"), + abi=props.get("ro.product.cpu.abi", "unknown"), + lang=lang, + langs=langs, + sdk=self._to_int(props.get("ro.build.version.sdk")), + density=density, + density_bucket=_nearest_density_bucket(density) + if density is not None + else None, + ) + return self._info + + @staticmethod + def _to_int(value: str | None) -> int | None: + return int(value) if value and value.isdigit() else None + + def _resolve_locales(self, props: dict[str, str]) -> tuple[str, frozenset[str]]: + """Same fallback chain as apkfile's ``install._device_lang_codes``: a fresh/headless + emulator that never went through Setup Wizard can leave ``persist.sys.locale(s)`` + completely unset (confirmed hands-on by apkfile), even though a real, user-configured + device always has one of these populated. Falls through, in order: the full priority + locale list, the single primary locale, the live settings-provider value, and finally + the read-only build-time default — which is always present. + + Returns ``(lang, langs)``: ``lang`` is the primary locale in its full + ``persist.sys.locale``-style form (e.g. ``en-US``, used to pick which Play Store + button-text table to drive); ``langs`` is the base-subtag set across *every* configured + locale (e.g. ``{"en", "he"}`` for ``en-US,he-IL``) — Play Store fetches a language split + per *installed* language, not just the primary one, so this is what actually predicts + which splits a pull will contain. + """ + for key in ("persist.sys.locales", "persist.sys.locale"): + raw = props.get(key, "").strip() + if raw and raw != "null": + return self._split_locales(raw) + raw = self.adb.shell( + self.device_id, "settings get system system_locales", check=False + ).strip() + if raw and raw != "null": + return self._split_locales(raw) + raw = props.get("ro.product.locale", "").strip() + return self._split_locales(raw) if raw else ("unknown", frozenset()) + + @staticmethod + def _split_locales(raw: str) -> tuple[str, frozenset[str]]: + tags = [tag for tag in raw.split(",") if tag] + primary = tags[0] if tags else "unknown" + return primary, frozenset(tag.split("-")[0] for tag in tags) + + @property + def label(self) -> str: + return self.info().label + + # -- package queries ----------------------------------------------------- + + def is_installed(self, package: str) -> bool: + result = self.adb.shell(self.device_id, f"pm path {package}", check=False) + return bool(result.strip()) + + def is_disabled(self, package: str) -> bool: + result = self.adb.shell(self.device_id, "pm list packages -d", check=False) + return any(line.strip() == f"package:{package}" for line in result.splitlines()) + + def apk_paths(self, package: str) -> list[str]: + result = self.adb.shell(self.device_id, f"pm path {package}", check=False) + return [ + line.removeprefix("package:").strip() + for line in result.splitlines() + if line.strip() + ] + + def dumpsys_package(self, package: str) -> str: + return self.adb.shell(self.device_id, f"dumpsys package {package}", check=False) + + def version_code(self, package: str) -> int | None: + match = _VERSION_CODE_RE.search(self.dumpsys_package(package)) + return int(match.group(1)) if match else None + + def version_name(self, package: str) -> str | None: + match = _VERSION_NAME_RE.search(self.dumpsys_package(package)) + return match.group(1) if match else None + + def min_sdk(self, package: str) -> int | None: + match = _MIN_SDK_RE.search(self.dumpsys_package(package)) + return int(match.group(1)) if match else None + + def file_exists(self, remote_path: str) -> bool: + result = self.adb.shell( + self.device_id, f"test -f '{remote_path}' && echo yes", check=False + ) + return result.strip() == "yes" + + def remote_size_human(self, remote_path: str) -> str: + result = self.adb.shell(self.device_id, f"du -sh '{remote_path}'", check=False) + return result.split()[0].strip() if result.strip() else "?" + + # -- screen / input ----------------------------------------------------- + + def is_unlocked(self) -> bool: + result = self.adb.shell(self.device_id, "dumpsys window", check=False) + return "mShowingDream=false mDreamingLockscreen=false" in result + + def focused_window(self) -> str: + """The currently focused window, e.g. ``mCurrentFocus=Window{... com.android.vending/...}``. + + Deliberately reads ``dumpsys window``'s ``mCurrentFocus`` rather than + ``dumpsys activity activities``' resumed-activity field: the latter's + field name isn't stable across Android versions — confirmed hands-on + that ``mResumedActivity`` is simply absent from that dump on Android + 14 (API 34), replaced by ``topResumedActivity``/``ResumedActivity:``. + Parsing for whichever field name happened to exist previously made + this silently and permanently return "not on Google Play", causing + an unconditional relaunch on every single poll — see the automation + log spam this produced (``Left Google Play, relaunching...`` every + iteration) even while genuinely sitting on the right page. + ``mCurrentFocus`` has been a stable, single-line answer across + versions in comparison. + """ + result = self.adb.shell(self.device_id, "dumpsys window", check=False) + for line in result.splitlines(): + if "mCurrentFocus" in line: + return line.strip() + return "" + + def is_foreground_package(self, package: str) -> bool: + return package in self.focused_window() + + def tap(self, x: int, y: int) -> None: + self.adb.shell(self.device_id, f"input tap {x} {y}", check=False) + + def launch_url(self, url: str, package: str) -> None: + self.adb.shell( + self.device_id, + f"am start -a android.intent.action.VIEW -d '{url}' -p {package}", + check=False, + ) + + def dump_ui(self) -> str: + # One combined shell round-trip instead of three separate adb subprocess + # calls (rm, dump, cat) -- this is the single most-executed adb sequence + # in the whole tool (runs on every automation poll, for every device), + # so cutting adb subprocess overhead here matters a lot in aggregate. + # `uiautomator dump`'s own stdout ("UI hierarchy dumped to: ...") is + # redirected away so only `cat`'s output comes back. + return self.adb.shell( + self.device_id, + "rm -f /sdcard/window_dump.xml; uiautomator dump >/dev/null 2>&1; " + "cat /sdcard/window_dump.xml", + check=False, + timeout=15, + ) + + # -- app-scoped locale (Android 13+ / API 33+) --------------------------- + + def get_app_locale(self, package: str) -> str: + """``package``'s current per-app locale override, e.g. ``"fr"`` — empty + string means it follows the system locale. Confirmed hands-on: ``cmd + locale get-app-locales`` replies ``"Locales for for user 0 are + []"`` (``[]`` when unset), on every Android version that has the + command at all -- callers still need their own SDK gate before relying + on this being meaningful (see ``MIN_SDK_FOR_APP_LOCALES``).""" + result = self.adb.shell( + self.device_id, + f"cmd locale get-app-locales {package} --user 0", + check=False, + ) + match = _APP_LOCALES_RE.search(result) + return match.group(1) if match else "" + + def set_app_locale(self, package: str, locale: str) -> None: + """Override ``package``'s locale, or clear the override back to the + system default when ``locale`` is ``""``. Only takes effect once the + app's process restarts -- ``force_stop`` it afterward.""" + self.adb.shell( + self.device_id, + f"cmd locale set-app-locales {package} --user 0 --locales {locale}", + check=False, + ) + + def force_stop(self, package: str) -> None: + self.adb.shell(self.device_id, f"am force-stop {package}", check=False) + + # -- settings ------------------------------------------------------------- + + def get_setting(self, namespace: str, key: str) -> str: + return self.adb.shell( + self.device_id, f"settings get {namespace} {key}", check=False + ).strip() + + def put_setting(self, namespace: str, key: str, value: str) -> None: + self.adb.shell( + self.device_id, f"settings put {namespace} {key} {value}", check=False + ) diff --git a/apkpull/exceptions.py b/apkpull/exceptions.py new file mode 100644 index 0000000..196cc2c --- /dev/null +++ b/apkpull/exceptions.py @@ -0,0 +1,134 @@ +"""Exception hierarchy for apkpull. + +Every error that can be attributed to a *specific device* subclasses +:class:`DeviceError` so the orchestrator can catch a single base class, +record the failure against that device and keep processing the rest. +""" + +from __future__ import annotations + + +class ApkPullError(Exception): + """Base class for all apkpull errors.""" + + +class AdbNotFoundError(ApkPullError): + """The ``adb`` executable could not be located on ``PATH``.""" + + +class NoDevicesFoundError(ApkPullError): + """No devices/emulators are visible to ``adb``.""" + + +class InvalidPackageNameError(ApkPullError): + """The supplied package name is not syntactically valid.""" + + +class DeviceError(ApkPullError): + """Base class for errors scoped to a single device. + + Raising one of these inside :func:`apkpull.orchestrator.run_for_device` + aborts that device's run without affecting the others. + """ + + def __init__(self, message: str, *, retryable: bool = False) -> None: + super().__init__(message) + self.retryable = retryable + + +class DeviceDisconnectedError(DeviceError): + """The device went offline / unauthorized / was unplugged mid-run.""" + + +class DeviceLockedTimeoutError(DeviceError): + """The device stayed locked past ``AutomationConfig.unlock_timeout``. + + Without a bound here, a single locked device would hang the *entire* + run forever: :func:`apkpull.orchestrator.run` waits on every device's + future via ``as_completed()``, so one stuck device blocks the whole + CLI from ever printing a summary, even though every other device may + have long since finished. + """ + + +class GooglePlayUnavailableError(DeviceError): + """The Play Store app is missing or disabled on the device.""" + + +class UnsupportedLocaleError(DeviceError): + """The device's system language has no known Play Store button strings.""" + + +class PaidAppError(DeviceError): + """The app requires payment and cannot be pulled automatically.""" + + +class RegionRestrictedError(DeviceError): + """The app is not available in the device's Play Store region.""" + + +class IncompatibleDeviceError(DeviceError): + """Play Store reports the device is not compatible with this app.""" + + +class NotSignedInError(DeviceError): + """No Google account is signed in on the device.""" + + +class DeviceOfflineError(DeviceError): + """The device itself reports it has no network connection.""" + + +class InsufficientStorageError(DeviceError): + """Play Store's "Not enough storage" dialog reports the device doesn't have + room to install/update this package.""" + + +class AppNotFoundError(DeviceError): + """Play Store's on-device UI reports the package doesn't exist. + + Deliberately *not* raised from an unauthenticated web request anymore — + that check runs from apkpull's host machine, whose network/region isn't + necessarily the device's Play Store account's region, so a 404 there + doesn't reliably mean "this device can't install it either" (a region- + locked app is the common case). The on-device UI is the actual source of + truth, and scoping this to one device/package means a single bad package + name no longer aborts every other device's run. + """ + + +class UnrecognizedPlayStoreError(DeviceError): + """Play Store showed a warning-triangle error banner whose text doesn't match + any of the specifically-handled screens above (paid app, region-restricted, + offline, incompatible device, not signed in) — raised with the banner's + actual on-screen text so an unfamiliar error is still diagnosable, instead + of silently polling until :class:`AutomationTimeoutError` gives up with no + real explanation.""" + + +class AutomationTimeoutError(DeviceError): + """UI automation could not find an expected button/state in time.""" + + def __init__( + self, message: str, *, screenshot: str | None = None, dump: str | None = None + ) -> None: + super().__init__(message, retryable=True) + self.screenshot = screenshot + self.dump = dump + + +class DownloadTimeoutError(DeviceError): + """A package's download/update did not finish within ``download_timeout``, + even after ``download_retries`` restart attempts. + + Without a bound here, a stalled download would hang the completion-pass + poll loop in :func:`apkpull.orchestrator.run_for_device` forever. + """ + + +class PullError(DeviceError): + """Pulling apk/obb files off the device failed.""" + + +class VerificationError(DeviceError): + """A pulled file failed post-pull verification against device metadata.""" diff --git a/apkpull/locales.py b/apkpull/locales.py new file mode 100644 index 0000000..42c54c6 --- /dev/null +++ b/apkpull/locales.py @@ -0,0 +1,164 @@ +"""Google Play Store button/text strings, keyed by 2-letter language code. + +Play Store's UI exposes no stable resource-ids on its buttons (verified by +dumping the live UI tree with uiautomator — every text node's ``resource-id`` +is empty), so text matching is the only reliable way to drive it. This table +is the single place that knowledge lives; adding a language is a data change +here, not a code change anywhere else. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class ButtonSet: + open: str + play: str + install: str + uninstall: str + deactivate: str + update: str + cancel: str + hardware_incompatible: str + country_restricted: str + offline: str + sign_in: str | None = None + """Play Store's "Sign in" prompt on the details page when no Google account + is signed in. Unlike the tap-target fields above, this is only ever used + for error *detection* (never tapped, never an ``also_break_on`` match), so + — like ``not_found``/``warning_icon``/``insufficient_storage`` below — a + locale can be fully functional without it confirmed yet. ``None`` for + locales where this hasn't been confirmed hands-on.""" + not_found: str | None = None + """The dedicated "Item not found." page shown for a package with no Play Store + listing at all — a distinct full-page state, not a warning banner on the + details page. ``None`` for locales where this hasn't been confirmed hands-on.""" + warning_icon: str | None = None + """``content-desc`` of the red warning-triangle icon Play Store shows next to a + banner-style error message on the details page — confirmed hands-on for the + region-restricted screen; not verified for every other banner. Lets + :func:`apkpull.automation.PlayStoreAutomator._check_error_screens` catch and + surface banner errors it doesn't have a specific rule for, instead of silently + polling until :class:`~apkpull.exceptions.AutomationTimeoutError` gives up with + no real explanation. ``None`` for locales where this hasn't been confirmed + hands-on.""" + insufficient_storage: str | None = None + """Title of the "Not enough storage" dialog Play Store shows when there isn't + room on the device to install/update — confirmed hands-on (reproduced against + a genuinely low-storage emulator) and, unlike ``not_found``, a real static + resource: extracted for every locale here directly from Play Store's own + resources.arsc. A plain ``AlertDialog``, not paired with ``warning_icon``, so + it needs its own field to be caught at all. ``None`` for locales where this + hasn't been confirmed hands-on.""" + no_internet_dialog: str | None = None + """Body text of the full-page "Something went wrong" / "No internet + connection..." state — confirmed hands-on, and a *different* screen from + ``offline`` (that one's the themed "You're offline" browsing page; this one + is a plainer error page with a "Try again" button, seen after tapping + something while offline rather than just landing on an offline page). + Matched on the body text rather than the "Something went wrong" title, + which is reused by unrelated errors elsewhere in Play Store (multiple + resource ids). Raises the same :class:`~apkpull.exceptions.DeviceOfflineError` + as ``offline`` — same underlying condition, just a different rendering. + ``None`` for locales where this hasn't been confirmed hands-on.""" + + +LOCALES: dict[str, ButtonSet] = { + "en": ButtonSet( + open="Open", + play="Play", + install="Install", + uninstall="Uninstall", + deactivate="Deactivate", + update="Update", + cancel="Cancel", + hardware_incompatible="Your device isn't compatible with this version.", + country_restricted="This item isn't available in your country.", + offline="You're offline", + sign_in="Sign in", + not_found="Item not found.", + warning_icon="Warning", + insufficient_storage="Not enough storage", + no_internet_dialog="No internet connection. Make sure Wi‑Fi or cellular data is turned on, then try again.", + ), + "he": ButtonSet( + open="פתח", + play="שחק", + install="התקנה", + uninstall="הסר התקנה", + deactivate="ביטול הפעלה", + update="עדכון", + cancel="ביטול", + hardware_incompatible="המכשיר שלך אינו תואם לגירסה זו.", + country_restricted="פריט זה אינו זמין בארצך.", + offline="אין חיבור לאינטרנט", + sign_in="כניסה", + warning_icon="אזהרה", + insufficient_storage="אין מספיק נפח אחסון פנוי", + no_internet_dialog="אין חיבור לאינטרנט. יש לוודא שה-Wi-Fi או חבילת הגלישה מופעלים ולנסות שוב.", + ), + "es": ButtonSet( + open="Abrir", + play="Jugar", + install="Instalar", + uninstall="Desinstalar", + deactivate="Desactivar", + update="Actualizar", + cancel="Cancelar", + hardware_incompatible="Tu dispositivo no es compatible con esta versión.", + country_restricted="Este elemento no está disponible en tu país.", + offline="No tienes conexión", + sign_in="Iniciar sesión", + not_found="Elemento no encontrado", + warning_icon="Advertencia", + insufficient_storage="No hay suficiente almacenamiento", + no_internet_dialog="Sin conexión a Internet. Comprueba que el Wi‑Fi o los datos están activados y vuelve a intentarlo.", + ), + "fr": ButtonSet( + open="Ouvrir", + play="Jouer", + install="Installer", + uninstall="Désinstaller", + deactivate="Désactiver", + update="Mettre à jour", + cancel="Annuler", + hardware_incompatible="Votre appareil n'est pas compatible avec cette version.", + country_restricted="Cet article n'est pas disponible dans votre pays.", + offline="Vous êtes hors connexion", + sign_in="Connexion", + not_found="Élément introuvable.", + warning_icon="Avertissement", + insufficient_storage="Espace de stockage insuffisant", + no_internet_dialog="Aucune connexion Internet. Assurez-vous que le Wi-Fi/les données mobiles sont activés, et réessayez.", + ), + "ru": ButtonSet( + open="Открыть", + play="Играть", + install="Установить", + uninstall="Удалить", + deactivate="Отказаться", + update="Обновить", + cancel="Отмена", + hardware_incompatible="Не поддерживается на вашем устройстве.", + country_restricted="Недоступно в вашей стране.", + offline="Вы не в Сети", + sign_in="Войти", + not_found="Файл не найден.", + warning_icon="Предупреждение", + insufficient_storage="Недостаточно места", + no_internet_dialog="Нет доступа к Интернету. Проверьте подключение к Wi-Fi или сотовой сети и повторите попытку.", + ), +} + +PAID_APP_CURRENCY_SYMBOLS = ("₪", "$", "€", "£", "₽", "¥", "₹") + + +def get_locale(lang: str) -> ButtonSet | None: + """Look up a :class:`ButtonSet` by a ``persist.sys.locale``-style tag (e.g. ``en-US``).""" + return LOCALES.get(lang[:2].lower()) + + +def supported_languages() -> list[str]: + return sorted(LOCALES) diff --git a/apkpull/logging_setup.py b/apkpull/logging_setup.py new file mode 100644 index 0000000..fcb93ff --- /dev/null +++ b/apkpull/logging_setup.py @@ -0,0 +1,102 @@ +"""Verbose, human-friendly logging setup. + +``-v`` / ``-vv`` on the CLI map to INFO / DEBUG. Output is colorized when +attached to a TTY. Every log call site includes the device label in the +message itself (``"[Pixel 7] ..."``) since multiple devices log concurrently +from a thread pool and a single shared stream is simplest to reason about. +""" + +from __future__ import annotations + +import logging +import sys +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from rich.console import Console + +_LEVEL_COLORS = { + logging.DEBUG: "\x1b[36m", # cyan + logging.INFO: "\x1b[32m", # green + logging.WARNING: "\x1b[33m", # yellow + logging.ERROR: "\x1b[31m", # red + logging.CRITICAL: "\x1b[41m", # red bg +} +_RESET = "\x1b[0m" + +_RICH_LEVEL_STYLES = { + logging.DEBUG: "cyan", + logging.INFO: "green", + logging.WARNING: "yellow", + logging.ERROR: "red", + logging.CRITICAL: "white on red", +} + + +class _ColorFormatter(logging.Formatter): + def __init__(self, *, use_color: bool, debug: bool) -> None: + fmt = ( + "%(asctime)s %(levelname)-8s %(name)s: %(message)s" + if debug + else "%(levelname)-8s %(message)s" + ) + super().__init__(fmt, datefmt="%H:%M:%S") + self.use_color = use_color + + def format(self, record: logging.LogRecord) -> str: + message = super().format(record) + if not self.use_color: + return message + color = _LEVEL_COLORS.get(record.levelno, "") + return f"{color}{message}{_RESET}" if color else message + + +class _RichConsoleHandler(logging.Handler): + """Prints through a rich ``Console`` instead of writing straight to a + stream — required when a live display is active on that same console: + plain direct writes would corrupt its region, but anything printed via + the console itself is automatically coordinated to appear above it.""" + + def __init__(self, console: Console, *, formatter: logging.Formatter) -> None: + super().__init__() + self.console = console + self.setFormatter(formatter) + + def emit(self, record: logging.LogRecord) -> None: + try: + message = self.format(record) + self.console.print( + message, style=_RICH_LEVEL_STYLES.get(record.levelno), markup=False + ) + except Exception: # noqa: BLE001 - logging handlers must never raise + self.handleError(record) + + +def setup_logging( + verbosity: int, *, stream=None, console: Console | None = None +) -> None: + """Configure the ``apkpull`` logger tree. Call once, from the CLI entry point. + + ``console`` routes output through a shared rich ``Console`` instead of + writing directly to ``stream`` — pass the same console a + :class:`apkpull.tui.LiveDisplay` is using so log records print correctly + above its live region instead of corrupting it. + """ + level = {0: logging.WARNING, 1: logging.INFO}.get(verbosity, logging.DEBUG) + + if console is not None: + handler: logging.Handler = _RichConsoleHandler( + console, formatter=_ColorFormatter(use_color=False, debug=verbosity >= 2) + ) + else: + stream = stream or sys.stderr + handler = logging.StreamHandler(stream) + handler.setFormatter( + _ColorFormatter(use_color=stream.isatty(), debug=verbosity >= 2) + ) + + logger = logging.getLogger("apkpull") + logger.setLevel(level) + logger.handlers.clear() + logger.addHandler(handler) + logger.propagate = False diff --git a/apkpull/models.py b/apkpull/models.py new file mode 100644 index 0000000..fa9e125 --- /dev/null +++ b/apkpull/models.py @@ -0,0 +1,183 @@ +"""Plain data types shared across apkpull. + +Kept dependency-free (stdlib only) so they're cheap to import from tests, +and trivial to serialize with :func:`dataclasses.asdict` for ``--json`` output. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path + + +class FileKind(str, Enum): + BUNDLE = "bundle" + """The base apk + splits, as one artifact — a zip (.apks/.zip) or an extracted folder.""" + OBB = "obb" + + +class OutputFormat(str, Enum): + APKS = "apks" + """SAI/bundletool ``.apks``: base apk + splits + ``meta.sai_v2.json``, zipped.""" + ZIP = "zip" + """Same contents as APKS, just named ``.zip`` for tools that expect a plain zip.""" + FOLDER = "folder" + """Extracted: base apk + splits as loose files in a ``-/`` directory.""" + + +class Status(str, Enum): + INSTALLED = "installed" + UPDATED = "updated" + ALREADY_UP_TO_DATE = "already_up_to_date" + SKIPPED_UPDATE_CHECK = "skipped_update_check" + """Package was already installed and ``skip_update_check`` was set, so apkpull + pulled whatever's currently on the device without ever asking Play Store + whether a newer version exists — unlike ALREADY_UP_TO_DATE, this is not a + claim that the installed version actually is current.""" + UNSUPPORTED_LOCALE = "unsupported_locale" + ERROR = "error" + + +@dataclass(frozen=True, slots=True) +class PulledFile: + """A single apk/obb file that was (or would have been) copied to the host.""" + + kind: FileKind + name: str + local_path: Path + size_bytes: int + already_existed: bool = False + verified: bool | None = None + """``None`` = not checked, ``True``/``False`` = apkfile verification result.""" + + def as_dict(self) -> dict: + d = { + "kind": self.kind.value, + "name": self.name, + "local_path": str(self.local_path), + "size_bytes": self.size_bytes, + "already_existed": self.already_existed, + "verified": self.verified, + } + return d + + +@dataclass(slots=True) +class DeviceInfo: + """Static-ish facts about a device, cached for the lifetime of a run.""" + + device_id: str + model: str = "unknown" + abi: str = "unknown" + lang: str = "unknown" + """Primary locale (e.g. ``en-US``) — which button-text table drives automation.""" + langs: frozenset[str] = frozenset() + """Base subtag of every locale configured on the device (e.g. ``{"en", "he"}``) — Play + fetches a language split per *installed* language, not just the primary.""" + sdk: int | None = None + density: int | None = None + """Raw screen density (dpi) as reported by the device — not what decides which + dpi split Play serves; see ``density_bucket``.""" + density_bucket: str | None = None + """Nearest standard Android density bucket for ``density`` (e.g. ``"xxhdpi"``) + — this, not the raw value, is what actually determines which dpi split Google + Play serves: two devices with different ``density`` but the same bucket get + identical dpi splits. A plain ``str`` (not apkfile's ``DensityBucket`` enum) so + this module stays dependency-free — see module docstring.""" + + @property + def fingerprint(self) -> tuple[str, str | None, frozenset[str], int | None]: + """The subset of properties Google Play uses to pick which apk/splits + to serve. Two devices sharing a fingerprint would get identical + downloads.""" + return (self.abi, self.density_bucket, self.langs, self.sdk) + + @property + def label(self) -> str: + return self.model if self.model != "unknown" else self.device_id + + +@dataclass(slots=True) +class DeviceOutcome: + """Result of running apkpull for one package on one device.""" + + device: DeviceInfo + package: str + status: Status + pulled_files: list[PulledFile] = field(default_factory=list) + version_code: int | None = None + version_name: str | None = None + destination: Path | None = None + error: str | None = None + duration_s: float = 0.0 + uninstalled: bool = False + """Note ``uninstalled=True`` together with ``status=Status.ERROR`` is a + valid combination: uninstalling happens right after this device's own + raw pull finishes, before its splits are merged with any other device's + into a shared bundle — a later failure in that merge (or a sibling + device's contribution) can still flip ``status`` to ``ERROR`` even + though this device's own install/update and uninstall already + succeeded.""" + + @property + def ok(self) -> bool: + return self.status in ( + Status.INSTALLED, + Status.UPDATED, + Status.ALREADY_UP_TO_DATE, + Status.SKIPPED_UPDATE_CHECK, + ) + + def as_dict(self) -> dict: + return { + "device_id": self.device.device_id, + "model": self.device.model, + "abi": self.device.abi, + "lang": self.device.lang, + "langs": sorted(self.device.langs), + "sdk": self.device.sdk, + "density": self.device.density, + "density_bucket": self.device.density_bucket, + "package": self.package, + "status": self.status.value, + "ok": self.ok, + "version_code": self.version_code, + "version_name": self.version_name, + "destination": str(self.destination) if self.destination else None, + "pulled_files": [f.as_dict() for f in self.pulled_files], + "error": self.error, + "duration_s": round(self.duration_s, 2), + "uninstalled": self.uninstalled, + } + + +@dataclass(slots=True) +class RunSummary: + """Aggregate result across every device targeted by a single run.""" + + outcomes: list[DeviceOutcome] = field(default_factory=list) + + @property + def total(self) -> int: + return len(self.outcomes) + + @property + def successful(self) -> int: + return sum(1 for o in self.outcomes if o.ok) + + @property + def exit_code(self) -> int: + """0 if every device succeeded, otherwise the number of failures (capped at 9).""" + failures = self.total - self.successful + if self.total == 0: + return 50 + return min(failures, 9) + + def as_dict(self) -> dict: + return { + "total": self.total, + "successful": self.successful, + "exit_code": self.exit_code, + "devices": [o.as_dict() for o in self.outcomes], + } diff --git a/apkpull/notify.py b/apkpull/notify.py new file mode 100644 index 0000000..a17c489 --- /dev/null +++ b/apkpull/notify.py @@ -0,0 +1,43 @@ +"""Best-effort native desktop notifications. + +macOS uses ``osascript`` (always present); Linux uses ``notify-send`` when +available. Any failure here is swallowed — a missing notifier must never +fail a pull. +""" + +from __future__ import annotations + +import logging +import platform +import shutil +import subprocess + +logger = logging.getLogger("apkpull.notify") + + +def notify(title: str, message: str) -> None: + system = platform.system() + try: + if system == "Darwin": + script = f"display notification {_osascript_quote(message)} with title {_osascript_quote(title)}" + subprocess.run( + ["osascript", "-e", script], capture_output=True, timeout=5, check=False + ) + elif system == "Linux" and shutil.which("notify-send"): + subprocess.run( + ["notify-send", title, message], + capture_output=True, + timeout=5, + check=False, + ) + else: + logger.debug( + "No native notifier available on %s; skipping notification.", system + ) + except Exception: # pragma: no cover - notifications are best-effort + logger.debug("Failed to send notification.", exc_info=True) + + +def _osascript_quote(value: str) -> str: + escaped = value.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' diff --git a/apkpull/orchestrator.py b/apkpull/orchestrator.py new file mode 100644 index 0000000..d616246 --- /dev/null +++ b/apkpull/orchestrator.py @@ -0,0 +1,1017 @@ +"""Top-level entry points: validate packages, then run them across N devices concurrently. + +This is the only module that knows how to fan work out across devices — adb, +device, automation and puller are all single-device/synchronous and get +composed here with a :class:`~concurrent.futures.ThreadPoolExecutor` (adb +round-trips are I/O bound, so threads are enough; no device touches another +device's state). + +Within one device, multiple packages are handled in two phases rather than +threaded against each other, since a device has exactly one screen: a +"kickoff" pass visits each package's Play Store page just long enough to +start its install/update, then a completion pass polls every in-flight +package purely over adb (``pm path``/``dumpsys package``, no UI involved) and +pulls each as it resolves — which is what lets Google Play's own overlapping +downloads actually pay off, instead of apkpull serializing what Play Store +itself doesn't. +""" + +from __future__ import annotations + +import logging +import re +import shutil +import time +import urllib.error +import urllib.request +from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait +from dataclasses import dataclass +from pathlib import Path + +from apkfile.install import uninstall_apks + +from .adb import AdbClient +from .automation import AutomationConfig, PlayStoreAutomator +from .device import MIN_SDK_FOR_APP_LOCALES, Device +from .exceptions import ( + DeviceError, + DownloadTimeoutError, + GooglePlayUnavailableError, + InvalidPackageNameError, + NoDevicesFoundError, + UnsupportedLocaleError, +) +from .locales import get_locale, supported_languages +from .models import ( + DeviceInfo, + DeviceOutcome, + FileKind, + OutputFormat, + PulledFile, + RunSummary, + Status, +) +from .notify import notify +from .progress import PackageReporter, ProgressCallback, ProgressEvent, Stage +from .puller import ( + Puller, + RawPull, + build_merged_bundle, + group_staging_dir, + size_of, + staging_root, +) + +logger = logging.getLogger("apkpull.orchestrator") + +_PACKAGE_NAME_RE = re.compile(r"^([A-Za-z][A-Za-z0-9_]*\.)+[A-Za-z][A-Za-z0-9_]*$") +_PLAY_STORE_APP_PACKAGE = "com.android.vending" + + +@dataclass(slots=True) +class _Tracked: + """A package whose install/update has been kicked off and is being polled.""" + + kind: str + """``"install"`` or ``"update"``.""" + baseline_version_code: int | None = None + """For updates: the version code before the update, so a change means done.""" + started_at: float = 0.0 + """``time.monotonic()`` when this attempt was kicked off, for ``download_timeout``.""" + retries_left: int = 0 + + +@dataclass(slots=True) +class _PendingContribution: + """One device's successful raw pull, waiting for every other device + targeting the same package to resolve (success or failure) before + :func:`_merge_pending_contributions` builds the shared bundle once and + patches ``outcome`` in place with the real ``pulled_files``/``status``. + + ``outcome`` is the *exact* :class:`~apkpull.models.DeviceOutcome` + instance already appended to this device's outcome list — mutating it + here is what lets the merge phase finish an outcome that + ``_pull_and_finish`` had to leave incomplete, without constructing a + second, competing one. + """ + + raw: RawPull + outcome: DeviceOutcome + + +def validate_package_name(package: str) -> None: + if not _PACKAGE_NAME_RE.match(package): + raise InvalidPackageNameError(f"Invalid syntax for package name: {package!r}") + + +def check_package_exists(package: str, *, timeout: float = 3.0) -> None: + """Best-effort, advisory-only check that ``package`` exists on the Play Store. + + Never raises — only logs. A 404 here means "the unauthenticated Play Store + *website*, as seen from wherever apkpull's host happens to be, has no + listing for this package at this address" — which is not the same question + as "can the *device*'s Play Store account install it". A region-locked app + (most banking apps, for instance) can easily 404 the web listing from one + network/country while still installing fine on a device signed in with the + right region/account, so a confirmed 404 here used to hard-abort the whole + run before touching any device — this is the exact false-negative that + forced that call to be removed. The automation flow's on-device checks are + the actual source of truth for a package that truly doesn't exist. + """ + url = f"https://play.google.com/store/apps/details?id={package}" + request = urllib.request.Request(url, method="HEAD") + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + if response.status == 404: + logger.warning( + "%s returned 404 from the Play Store website; it may not " + "exist, or may just be unavailable from this network/region. " + "Continuing anyway — device-side automation will surface a " + "clearer error if it truly can't be found.", + package, + ) + except urllib.error.HTTPError as exc: + if exc.code == 404: + logger.warning( + "%s returned 404 from the Play Store website; it may not " + "exist, or may just be unavailable from this network/region. " + "Continuing anyway — device-side automation will surface a " + "clearer error if it truly can't be found.", + package, + ) + except urllib.error.URLError: + logger.debug( + "Could not reach Google Play to verify %s exists; continuing anyway.", + package, + ) + + +def _log_and_notify_finish( + label: str, + package: str, + destination: Path, + pulled_files: list[PulledFile], + *, + status: Status, + notify_enabled: bool, +) -> None: + pulled_count = sum(1 for f in pulled_files if not f.already_existed) + if pulled_count: + logger.info( + "[%s] %s: %d file(s) pulled to %s", + label, + package, + pulled_count, + destination, + ) + else: + logger.info( + "[%s] %s: files already existed at %s, nothing new pulled.", + label, + package, + destination, + ) + if notify_enabled: + notify(label, f"{package}: {status.value} ({pulled_count} file(s) pulled)") + + +def _pull_and_finish( + adb: AdbClient, + device: Device, + info: DeviceInfo, + package: str, + status: Status, + dest_root: Path, + *, + uninstall: bool, + notify_enabled: bool, + output_format: OutputFormat, + start: float, + report: PackageReporter | None = None, +) -> tuple[DeviceOutcome, _PendingContribution | None]: + puller = Puller(device, dest_root) + raw = puller.pull_raw(package, output_format=output_format, report=report) + + uninstalled = False + if uninstall: + # Safe immediately after the raw pull regardless of whether this + # device's bundle needs merging with others -- its files are + # already off the device by this point, merging is purely local + # file work from here on. + logger.info("[%s] Uninstalling %s...", info.label, package) + if report: + report(package, Stage.UNINSTALLING, "") + uninstall_apks(package, device_id=device.device_id, adb_path=adb.adb_path) + uninstalled = True + + if raw.already_built: + pulled_files = [ + PulledFile( + kind=FileKind.BUNDLE, + name=raw.target.name, + local_path=raw.target, + size_bytes=size_of(raw.target), + already_existed=True, + ), + *raw.obb_pulled_files, + ] + outcome = DeviceOutcome( + device=info, + package=package, + status=status, + pulled_files=pulled_files, + version_code=raw.version_code, + version_name=raw.version_name, + destination=raw.target, + duration_s=time.monotonic() - start, + uninstalled=uninstalled, + ) + if report: + report(package, Stage.DONE, status.value, path=str(raw.target)) + _log_and_notify_finish( + info.label, + package, + raw.target, + pulled_files, + status=status, + notify_enabled=notify_enabled, + ) + return outcome, None + + # This device's splits might not be the whole picture -- other devices + # targeting this same package could still be mid-flight. `destination` + # is knowable now (target_path is a pure function of package/version/ + # format), but `pulled_files` genuinely isn't until every device in this + # (package, version_code) group has resolved and _merge_pending_contributions + # builds the shared bundle once -- see that function for what happens next. + if report: + report(package, Stage.MERGING, "") + outcome = DeviceOutcome( + device=info, + package=package, + status=status, + pulled_files=[], + version_code=raw.version_code, + version_name=raw.version_name, + destination=raw.target, + duration_s=time.monotonic() - start, + uninstalled=uninstalled, + ) + return outcome, _PendingContribution(raw=raw, outcome=outcome) + + +def run_for_device( + adb: AdbClient, + device_id: str, + packages: list[str], + dest_root: Path, + *, + uninstall: bool = False, + notify_enabled: bool = False, + output_format: OutputFormat = OutputFormat.APKS, + automation_config: AutomationConfig | None = None, + keep_screen_on: bool = True, + download_timeout: float = 300.0, + download_retries: int = 1, + skip_update_check: bool = False, + force_locale: str | None = None, + on_progress: ProgressCallback | None = None, + device_info: DeviceInfo | None = None, +) -> tuple[list[DeviceOutcome], list[_PendingContribution]]: + device = Device(adb, device_id) + if device_info is not None: + # Already resolved once by _warn_about_duplicate_devices, right before + # the thread pool was created -- reuse it instead of re-issuing the + # same getprop/locale round trip a second time for this device. + device.seed_info(device_info) + start = time.monotonic() + stay_on_previous: str | None = None + locale_override_active = False + previous_play_locale = "" + outcomes: list[DeviceOutcome] = [] + pending: list[_PendingContribution] = [] + + def broadcast(label: str, stage: Stage, detail: str = "") -> None: + """For device-scoped states (connecting, locked) that block every + package on this device at once, not just one — fires the same event + for each of them.""" + if on_progress: + for pkg in packages: + on_progress(ProgressEvent(device_id, label, pkg, stage, detail)) + + def error_outcome( + pkg: str, exc: Exception, device_info: DeviceInfo + ) -> DeviceOutcome: + logger.error("[%s] %s: %s", device_info.label, pkg, exc) + if notify_enabled: + notify(device_info.label, f"{pkg}: {exc}") + if on_progress: + on_progress( + ProgressEvent(device_id, device_info.label, pkg, Stage.ERROR, str(exc)) + ) + return DeviceOutcome( + device=device_info, + package=pkg, + status=Status.ERROR, + error=str(exc), + duration_s=time.monotonic() - start, + ) + + broadcast(device_id, Stage.CONNECTING) + + try: + device.ensure_connected() + info = device.info() + logger.info( + "[%s] Connected (%s, %s processor).", + info.label, + info.model, + info.abi, + ) + + if not device.is_installed(_PLAY_STORE_APP_PACKAGE) or device.is_disabled( + _PLAY_STORE_APP_PACKAGE + ): + raise GooglePlayUnavailableError( + "Google Play is disabled or not installed." + ) + + buttons = get_locale(info.lang) + if force_locale: + if info.sdk is not None and info.sdk >= MIN_SDK_FOR_APP_LOCALES: + previous_play_locale = device.get_app_locale(_PLAY_STORE_APP_PACKAGE) + device.set_app_locale(_PLAY_STORE_APP_PACKAGE, force_locale) + device.force_stop(_PLAY_STORE_APP_PACKAGE) + locale_override_active = True + buttons = get_locale(force_locale) + logger.info( + "[%s] Forced Google Play to '%s' for this run (was %s).", + info.label, + force_locale, + previous_play_locale or "system default", + ) + else: + logger.warning( + "[%s] Can't force Google Play's locale: needs Android %d+ " + "(this device is API %s). Falling back to its own language.", + info.label, + MIN_SDK_FOR_APP_LOCALES, + info.sdk, + ) + if buttons is None: + raise UnsupportedLocaleError( + f"Device language ({info.lang}) is not supported by apkpull; install/update manually." + ) + + automator = PlayStoreAutomator(device, buttons, automation_config) + automator.wait_for_unlock( + report=lambda stage, detail="": broadcast(info.label, stage, detail) + ) + + if keep_screen_on: + stay_on_previous = device.get_setting("global", "stay_on_while_plugged_in") + device.put_setting("global", "stay_on_while_plugged_in", "7") + + def report( + package: str, stage: Stage, detail: str = "", path: str = "" + ) -> None: + if on_progress: + on_progress( + ProgressEvent( + device_id, + info.label, + package, + stage, + detail, + f"{info.abi}, {info.lang}", + path, + ) + ) + + def pull_and_finish( + package: str, status: Status + ) -> tuple[DeviceOutcome, _PendingContribution | None]: + return _pull_and_finish( + adb, + device, + info, + package, + status, + dest_root, + uninstall=uninstall, + notify_enabled=notify_enabled, + output_format=output_format, + report=report, + start=start, + ) + + def record(package: str, status: Status) -> None: + outcome, contribution = pull_and_finish(package, status) + outcomes.append(outcome) + if contribution is not None: + pending.append(contribution) + + # -- kickoff pass: visit each package's page just long enough to start + # its install/update; everything after this is adb-only, no UI needed. + tracked: dict[str, _Tracked] = {} + for idx, package in enumerate(packages): + try: + device.ensure_connected() + already_installed = device.is_installed(package) + if already_installed and skip_update_check: + record(package, Status.SKIPPED_UPDATE_CHECK) + continue + kind = "update" if already_installed else "install" + report(package, Stage.OPENING_PLAY_STORE, kind) + kickoff = ( + automator.start_update(package) + if already_installed + else automator.start_install(package) + ) + if kickoff.needs_tracking: + report(package, Stage.DOWNLOADING, kind) + tracked[package] = _Tracked( + kind=kind, + baseline_version_code=kickoff.baseline_version_code, + started_at=time.monotonic(), + retries_left=download_retries, + ) + else: + assert kickoff.status is not None + record(package, kickoff.status) + except DeviceError as exc: + outcomes.append(error_outcome(package, exc, info)) + if not device.is_connected(): + outcomes.extend( + error_outcome(p, exc, info) for p in packages[idx + 1 :] + ) + tracked.clear() + break + + # -- completion pass: poll everything still in flight, pulling each as + # it resolves so downloading and pulling overlap. + while tracked: + try: + device.ensure_connected() + except DeviceError as exc: + outcomes.extend(error_outcome(p, exc, info) for p in tracked) + tracked.clear() + break + + resolved: list[str] = [] + for package, t in tracked.items(): + try: + if t.kind == "install": + done, status = device.is_installed(package), Status.INSTALLED + else: + current = device.version_code(package) + done = ( + current is not None and current != t.baseline_version_code + ) + status = Status.UPDATED + except DeviceError as exc: + outcomes.append(error_outcome(package, exc, info)) + resolved.append(package) + continue + + if not done: + if ( + download_timeout + and time.monotonic() - t.started_at >= download_timeout + ): + if t.retries_left > 0: + t.retries_left -= 1 + logger.warning( + "[%s] %s: still not finished after %.0fs, " + "restarting (%d retry/retries left)...", + info.label, + package, + download_timeout, + t.retries_left, + ) + try: + report( + package, Stage.OPENING_PLAY_STORE, f"{t.kind}-retry" + ) + kickoff = ( + automator.start_update(package) + if t.kind == "update" + else automator.start_install(package) + ) + if kickoff.needs_tracking: + t.baseline_version_code = ( + kickoff.baseline_version_code + ) + t.started_at = time.monotonic() + report( + package, Stage.DOWNLOADING, f"{t.kind}-retry" + ) + else: + resolved.append(package) + assert kickoff.status is not None + record(package, kickoff.status) + except DeviceError as exc: + resolved.append(package) + outcomes.append(error_outcome(package, exc, info)) + else: + resolved.append(package) + outcomes.append( + error_outcome( + package, + DownloadTimeoutError( + f"{package} did not finish downloading/" + f"updating within {download_timeout:.0f}s." + ), + info, + ) + ) + continue + resolved.append(package) + try: + record(package, status) + except DeviceError as exc: + outcomes.append(error_outcome(package, exc, info)) + + for package in resolved: + del tracked[package] + if tracked: + time.sleep(automator.config.poll_interval) + + except DeviceError as exc: + device_info = ( + device.info() if device.is_connected() else DeviceInfo(device_id=device_id) + ) + return [error_outcome(pkg, exc, device_info) for pkg in packages], [] + finally: + if stay_on_previous is not None: + try: + device.put_setting( + "global", "stay_on_while_plugged_in", stay_on_previous or "0" + ) + except DeviceError: + pass + if locale_override_active: + try: + device.set_app_locale(_PLAY_STORE_APP_PACKAGE, previous_play_locale) + device.force_stop(_PLAY_STORE_APP_PACKAGE) + except DeviceError: + pass + + return outcomes, pending + + +def _warn_about_duplicate_devices( + adb: AdbClient, targets: list[str] +) -> dict[str, DeviceInfo]: + """Group targeted devices by the properties that decide which apk/splits + Google Play serves and log a warning when that grouping suggests redundant + work — either the same download twice, or one download needlessly split + across devices. + + Returns whatever :class:`DeviceInfo` this managed to resolve, keyed by + ``device_id`` — ``run()`` hands each one to its device's own + ``run_for_device`` call so that thread doesn't redundantly re-fetch the + same ``getprop``/locale round trip a second time for the same device. + """ + infos: list[DeviceInfo] = [] + for device_id in targets: + device = Device(adb, device_id) + try: + device.ensure_connected() + infos.append(device.info()) + except Exception: # noqa: BLE001 - best-effort check, must never break the real run + logger.debug( + "Could not read device info for %s while checking for duplicate " + "devices; skipping it for this check.", + device_id, + ) + + # Same fingerprint (abi, density bucket, langs, sdk) end-to-end: these + # devices will pull byte-identical files, so running more than one of them + # is redundant. Density is compared by *bucket* (e.g. "xxhdpi"), not raw + # dpi — two devices reporting different raw density can still round to the + # same bucket and get an identical dpi split, so raw dpi would both miss + # real duplicates and matter less than it looks like it should. + by_fingerprint: dict[tuple, list[DeviceInfo]] = {} + for info in infos: + by_fingerprint.setdefault(info.fingerprint, []).append(info) + for (abi, density_bucket, langs, sdk), group in by_fingerprint.items(): + if len(group) > 1: + logger.warning( + "%d devices look identical (abi=%s, density_bucket=%s, langs=%s, " + "sdk=%s) and will likely download the same files: %s", + len(group), + abi, + density_bucket or "unknown", + ",".join(sorted(langs)) or "unknown", + sdk, + ", ".join(i.label for i in group), + ) + + # Same hardware (abi, density bucket, sdk) but different configured + # languages: these devices pull different language splits, but + # redundantly re-pull the shared base/abi/density splits to get there — + # configuring every language on one device instead would get the same + # full set of splits in a single pull. + by_hardware: dict[tuple, list[DeviceInfo]] = {} + for info in infos: + by_hardware.setdefault((info.abi, info.density_bucket, info.sdk), []).append( + info + ) + for (abi, density_bucket, sdk), group in by_hardware.items(): + distinct_langs = {info.langs for info in group} + if len(distinct_langs) > 1: + union = sorted(set().union(*distinct_langs)) + breakdown = ", ".join( + f"{info.label} ({','.join(sorted(info.langs)) or 'unknown'})" + for info in group + ) + logger.warning( + "%d devices share the same hardware (abi=%s, density_bucket=%s, " + "sdk=%s) but have different configured languages, so they'll " + "redundantly re-pull the shared splits to get different language " + "splits: %s. Consider configuring every language (%s) on a single " + "device instead — one pull would then cover all of them.", + len(group), + abi, + density_bucket or "unknown", + sdk, + breakdown, + ",".join(union) or "unknown", + ) + + return {info.device_id: info for info in infos} + + +def _merge_pending_contributions( + outcomes: list[DeviceOutcome], + contributions: list[_PendingContribution], + dest_root: Path, + *, + verify: bool, + strict_verify: bool, + full_manifest: bool, + output_format: OutputFormat, + notify_enabled: bool, + on_progress: ProgressCallback | None, +) -> None: + """Build one merged bundle per (package, version_code) group, once every + targeted device has resolved (this is only ever called after run()'s + thread-pool drain loop has fully emptied — never concurrently with a + run_for_device thread, so no locking is needed here). Mutates each + contribution's `outcome` (already appended to `outcomes`) in place with + the real, final `pulled_files`, and on failure `status`/`error`. + """ + if not contributions: + return + + def broadcast_to( + group: list[_PendingContribution], stage: Stage, detail: str = "" + ) -> None: + if not on_progress: + return + for c in group: + info = c.outcome.device + on_progress( + ProgressEvent( + c.raw.device_id, + info.label, + c.raw.package, + stage, + detail, + f"{info.abi}, {info.lang}", + ) + ) + + by_package: dict[str, list[_PendingContribution]] = {} + for contribution in contributions: + by_package.setdefault(contribution.raw.package, []).append(contribution) + + for package, pkg_contributions in by_package.items(): + by_version: dict[int, list[_PendingContribution]] = {} + for contribution in pkg_contributions: + by_version.setdefault(contribution.raw.version_code, []).append( + contribution + ) + + if len(by_version) > 1: + logger.warning( + "%s: devices reported %d different version codes (%s) -- built a " + "separate merged bundle for each; splits won't be combined across " + "different versions.", + package, + len(by_version), + ", ".join(str(v) for v in sorted(by_version)), + ) + + failed = [ + o for o in outcomes if o.package == package and o.status == Status.ERROR + ] + if failed: + total = sum(1 for o in outcomes if o.package == package) + missing = ", ".join( + f"{o.device.label} ({o.device.abi}): {o.error}" for o in failed + ) + logger.warning( + "%s: merged bundle includes splits from %d/%d device(s); missing " + "from: %s", + package, + total - len(failed), + total, + missing, + ) + + for version_code, group in by_version.items(): + # Sorted for a deterministic pick among devices whose split + # filenames collide (see build_merged_bundle) and for + # deterministic log-message wording run to run. + group.sort(key=lambda c: c.raw.device_id) + labels = [c.outcome.device.label for c in group] + group_dir = group_staging_dir(dest_root, package, version_code) + broadcast_to(group, Stage.PACKAGING) + try: + if verify: + broadcast_to(group, Stage.VERIFYING) + target, pulled_files = build_merged_bundle( + package, + version_code, + [c.raw for c in group], + dest_root, + output_format=output_format, + verify=verify, + strict=strict_verify, + full=full_manifest, + ) + except DeviceError as exc: + # Matches run_for_device's own philosophy: only DeviceError + # (PullError/VerificationError) is "expected" here; anything + # else propagates and crashes the run, same as an unexpected + # exception from run_for_device already does today. + logger.error( + "%s (version %d): merge failed for %d device(s) (%s): %s", + package, + version_code, + len(group), + ", ".join(labels), + exc, + ) + for c in group: + # This device's own install/update already succeeded -- + # only the shared packaging step failed. Called out + # explicitly so ERROR-in-the-summary doesn't read as + # "this device's pull failed" when it didn't. + c.outcome.status = Status.ERROR + c.outcome.error = ( + f"Merged bundle for {package} failed: {exc} (this device's " + f"own install/update succeeded; only local packaging failed)" + ) + c.outcome.pulled_files = [] + broadcast_to(group, Stage.ERROR, detail=str(exc)) + if notify_enabled: + for c in group: + notify( + c.outcome.device.label, f"{package}: merge failed - {exc}" + ) + else: + for c in group: + c.outcome.pulled_files = pulled_files + pulled_count = sum(1 for f in pulled_files if not f.already_existed) + logger.info( + "%s: merged bundle from %d device(s) (%s) -> %d file(s) pulled to %s", + package, + len(group), + ", ".join(labels), + pulled_count, + target, + ) + if on_progress: + for c in group: + info = c.outcome.device + on_progress( + ProgressEvent( + c.raw.device_id, + info.label, + package, + Stage.DONE, + c.outcome.status.value, + f"{info.abi}, {info.lang}", + str(target), + ) + ) + if notify_enabled: + for c in group: + notify( + c.outcome.device.label, + f"{package}: {c.outcome.status.value} " + f"({pulled_count} file(s) pulled)", + ) + finally: + shutil.rmtree(group_dir, ignore_errors=True) + + +def _drain_batch( + done: set, + outcomes: list[DeviceOutcome], + all_contributions: list[_PendingContribution], +) -> list[Exception]: + """Collect every finished future in ``done`` into ``outcomes``/ + ``all_contributions``, in place. + + ``done`` (from ``concurrent.futures.wait``) is an *unordered* set, so an + unexpected exception from one device's future must not stop a sibling + future's already-computed result -- also sitting right there in the same + batch -- from being collected: any exception raised by ``future.result()`` + (a genuine bug in ``run_for_device``, not a ``DeviceError`` it already + turned into a normal error outcome -- those never raise) is collected and + returned instead of raised immediately, so every future in the batch is + always drained first. A future ``run()`` itself already cancelled (see + the ``KeyboardInterrupt`` handling below) is skipped rather than treated + as an error. + """ + exceptions: list[Exception] = [] + for future in done: + if future.cancelled(): + continue + try: + device_outcomes, contributions = future.result() + except Exception as exc: # noqa: BLE001 - returned to the caller, not swallowed + exceptions.append(exc) + continue + outcomes.extend(device_outcomes) + all_contributions.extend(contributions) + return exceptions + + +def run( + packages: str | list[str], + dest_root: Path, + *, + device_ids: list[str] | None = None, + uninstall: bool = False, + max_workers: int | None = None, + notify_enabled: bool = False, + verify: bool = True, + strict_verify: bool = False, + full_manifest: bool = False, + output_format: OutputFormat = OutputFormat.APKS, + adb_path: str | None = None, + automation_config: AutomationConfig | None = None, + skip_existence_check: bool = False, + keep_screen_on: bool = True, + download_timeout: float = 300.0, + download_retries: int = 1, + skip_duplicate_check: bool = False, + skip_update_check: bool = False, + force_locale: str | None = None, + on_progress: ProgressCallback | None = None, +) -> RunSummary: + """Pull every package in ``packages`` from every targeted device concurrently.""" + package_list = [packages] if isinstance(packages, str) else list(packages) + for package in package_list: + validate_package_name(package) + if not skip_existence_check: + check_package_exists(package) + if force_locale is not None and get_locale(force_locale) is None: + raise UnsupportedLocaleError( + f"--force-locale {force_locale!r} isn't a locale apkpull supports " + f"(supported: {', '.join(supported_languages())})." + ) + + adb = AdbClient(adb_path) + targets = device_ids or adb.list_devices() + if not targets: + raise NoDevicesFoundError( + "No devices found! At least one device must be connected." + ) + logger.info( + "%d device(s) targeted, %d package(s) each.", len(targets), len(package_list) + ) + device_infos: dict[str, DeviceInfo] = {} + if len(targets) > 1 and not skip_duplicate_check: + device_infos = _warn_about_duplicate_devices(adb, targets) + + if on_progress: + # Device label isn't known yet (that needs a live adb round-trip, + # done per-device once its own thread starts) — device_id doubles as + # the initial label so the renderer has a stable row to show + # immediately, updated to the friendly label once resolved. + for device_id in targets: + for package in package_list: + on_progress(ProgressEvent(device_id, device_id, package, Stage.QUEUED)) + + # Best-effort: clean up anything a previous, crashed run left staged but + # never merged. Safe to do before any device thread starts -- nothing + # below can have written into this run's own staging dirs yet. Some + # callers (mainly tests that mock run_for_device and never touch the + # filesystem) pass dest_root=None despite the type hint -- guard rather + # than break that existing convention. + if dest_root is not None: + shutil.rmtree(staging_root(dest_root), ignore_errors=True) + + outcomes: list[DeviceOutcome] = [] + all_contributions: list[_PendingContribution] = [] + # Not a `with` block: ThreadPoolExecutor.__exit__ calls shutdown(wait=True) + # unconditionally, which would block on already-running devices even after + # we've deliberately stopped waiting for them below on Ctrl+C. + pool = ThreadPoolExecutor( + max_workers=max_workers or len(targets), thread_name_prefix="apkpull" + ) + futures = { + pool.submit( + run_for_device, + adb, + device_id, + package_list, + dest_root, + uninstall=uninstall, + notify_enabled=notify_enabled, + output_format=output_format, + automation_config=automation_config, + keep_screen_on=keep_screen_on, + download_timeout=download_timeout, + download_retries=download_retries, + skip_update_check=skip_update_check, + force_locale=force_locale, + on_progress=on_progress, + device_info=device_infos.get(device_id), + ): device_id + for device_id in targets + } + pending = set(futures) + try: + while pending: + # Poll with a short timeout instead of blocking indefinitely: an + # untimed wait on thread-pool futures doesn't get interrupted by + # SIGINT at all (confirmed hands-on against a real run) — Ctrl+C + # would otherwise do nothing until every device finished or timed + # out on its own, which could be minutes away. + done, pending = wait(pending, timeout=1.0, return_when=FIRST_COMPLETED) + exceptions = _drain_batch(done, outcomes, all_contributions) + if exceptions: + # A genuine bug in run_for_device, not a per-device DeviceError + # (those never reach here as an exception). Shut down the same + # way Ctrl+C does below -- cancel whatever hasn't started, + # wait for whatever's already running (can't be force-killed, + # but is bounded by its own timeouts), close the pool -- so + # this doesn't abandon still-running device threads or lose + # any sibling device's already-collected result, then surface + # the failure instead of silently dropping it. + for future in pending: + future.cancel() + while pending: + done, pending = wait( + pending, timeout=1.0, return_when=FIRST_COMPLETED + ) + exceptions.extend(_drain_batch(done, outcomes, all_contributions)) + pool.shutdown(wait=False) + raise exceptions[0] + pool.shutdown(wait=False) + except KeyboardInterrupt: + for future in pending: + future.cancel() # only takes effect for devices not yet started + logger.warning( + "Interrupted — not starting any more device(s). Waiting for %d " + "already in flight to finish (they can't be stopped mid-call, " + "but are already bounded by their own timeouts).", + len(pending), + ) + while pending: + done, pending = wait(pending, timeout=1.0, return_when=FIRST_COMPLETED) + for exc in _drain_batch(done, outcomes, all_contributions): + # Same "don't lose the rest of the batch" reasoning as the + # normal path above -- but here the original KeyboardInterrupt + # is what must keep propagating (the `raise` at the end of + # this block), so an unexpected exception from a straggling + # future is logged rather than re-raised in its place. + logger.error( + "Unexpected error from a device thread during shutdown: %s", exc + ) + pool.shutdown(wait=False) + # Still merge whatever raw pulls did finish before the interrupt -- + # strictly better than leaving them staged and unused, and it's pure + # local-file work so it doesn't meaningfully delay the interrupt. + _merge_pending_contributions( + outcomes, + all_contributions, + dest_root, + verify=verify, + strict_verify=strict_verify, + full_manifest=full_manifest, + output_format=output_format, + notify_enabled=notify_enabled, + on_progress=on_progress, + ) + raise + + _merge_pending_contributions( + outcomes, + all_contributions, + dest_root, + verify=verify, + strict_verify=strict_verify, + full_manifest=full_manifest, + output_format=output_format, + notify_enabled=notify_enabled, + on_progress=on_progress, + ) + return RunSummary(outcomes=outcomes) diff --git a/apkpull/progress.py b/apkpull/progress.py new file mode 100644 index 0000000..315eebf --- /dev/null +++ b/apkpull/progress.py @@ -0,0 +1,96 @@ +"""Optional progress-reporting hook threaded through the pull pipeline. + +Kept as a single narrow callback type so orchestrator.py/puller.py don't need +to know anything about *how* (or whether) progress gets rendered — +:mod:`apkpull.tui` is the only module that turns these events into an actual +live terminal display. Every call site guards with ``if report:`` so the +hook costs nothing when unused (the default library/CLI path). +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from enum import Enum +from typing import Protocol + + +class Stage(str, Enum): + """One (device, package) pair's position in the pull pipeline. Ordered + roughly as they occur, though DOWNLOADING can be skipped entirely (already + up to date / ``skip_update_check``) and PACKAGING/VERIFYING/UNINSTALLING + are each skippable too depending on flags and whether a bundle already + exists locally. + + CONNECTING and LOCKED are device-scoped, not package-scoped — the same + event fires for every package on that device at once (a locked or + still-connecting device blocks all of them together), via + ``run_for_device``'s ``broadcast`` closure rather than the per-package + ``report`` one. + + MERGING sits between PULLING and PACKAGING: apkpull waits for every + targeted device to finish a package before merging their splits into one + bundle (see ``orchestrator._merge_pending_contributions``), so a device + fires MERGING the moment its own raw pull finishes -- otherwise it would + look stalled while waiting on slower sibling devices in its + (package, version_code) group. PACKAGING/VERIFYING and the final + DONE/ERROR for such a device only fire once its whole group resolves, + which can be a real wall-clock gap after MERGING -- that's expected, not + a stall. + """ + + CONNECTING = "connecting" + LOCKED = "locked" + QUEUED = "queued" + OPENING_PLAY_STORE = "opening_play_store" + DOWNLOADING = "downloading" + """``detail`` distinguishes what's actually happening: ``"install"`` or + ``"update"``, with a ``"-retry"`` suffix after a download-timeout retry + (e.g. ``"update-retry"``) -- see PackageReporter call sites.""" + PULLING = "pulling" + MERGING = "merging" + PACKAGING = "packaging" + VERIFYING = "verifying" + UNINSTALLING = "uninstalling" + DONE = "done" + ERROR = "error" + + +@dataclass(frozen=True, slots=True) +class ProgressEvent: + device_id: str + device_label: str + package: str + stage: Stage + detail: str = "" + device_meta: str = "" + """E.g. ``"arm64-v8a, en-US"`` — empty until the device's info() resolves + (the very first QUEUED events, fired before any device is even connected + to, don't have it yet).""" + path: str = "" + """Set only on DONE — where the pulled file(s)/folder ended up locally.""" + + +ProgressCallback = Callable[[ProgressEvent], None] +"""Signature for the top-level hook passed to :func:`apkpull.orchestrator.run`.""" + + +class PackageReporter(Protocol): + """Signature for the per-device closure (``package, stage, detail``, + optionally ``path``) threaded into :func:`apkpull.orchestrator._pull_and_finish` + and :meth:`apkpull.puller.Puller.pull_raw` — device identity is already + bound by the closure, so call sites only need to name the package. + ``path`` is only ever passed (as a keyword) on the DONE call site — a + :class:`typing.Protocol` rather than a plain ``Callable[...]`` alias + specifically so that optional/keyword parameter is actually represented + in the type, not just documented here.""" + + def __call__( + self, package: str, stage: Stage, detail: str = "", path: str = "" + ) -> None: ... + + +DeviceReporter = Callable[[Stage, str], None] +"""Signature for a device-scoped closure (``stage, detail``, no package) — +threaded into :meth:`apkpull.automation.PlayStoreAutomator.wait_for_unlock`, +which blocks the whole device, not any one package.""" diff --git a/apkpull/puller.py b/apkpull/puller.py new file mode 100644 index 0000000..b38dad8 --- /dev/null +++ b/apkpull/puller.py @@ -0,0 +1,477 @@ +"""Pulls a package's apks off a device, and merges every targeted device's +contribution into one bundle. + +Output lands flat in one destination directory, one artifact per (package, +version), named by :class:`~apkpull.models.OutputFormat`: + + /-.apks # OutputFormat.APKS (default) + /-.zip # OutputFormat.ZIP + /-/ # OutputFormat.FOLDER (extracted) + /-.manifest.json # apks/zip: sidecar; folder: inside it + /main...obb # only if the app ships one (rare) + +Every format is built the same way under the hood: base + splits are always +zipped into a bundle first (see :mod:`apkpull.bundle`) and verified with +apkfile *before* the requested format is materialized — that's what lets +FOLDER get the exact same verification/manifest coverage as APKS/ZIP despite +not being a zip on disk itself. + +Two-phase pipeline, split across this module's two entry points: + +- :meth:`Puller.pull_raw` — per-device I/O. Pulls base.apk + splits (+ any + OBB) off ONE device into a persistent staging directory and returns a + :class:`RawPull` describing what landed there, without building anything. + Runs inside that device's own thread in :mod:`apkpull.orchestrator`. +- :func:`build_merged_bundle` — run-wide, single-threaded, no adb. Takes + every device's :class:`RawPull` for one ``(package, version_code)`` once + they've *all* finished (success or failure), unions their distinct splits + (and OBBs) by filename, and builds ONE bundle from the result — so a + package pulled from three differently-configured devices ends up as a + single universal artifact, not three redundant/incomplete ones. Called + from :func:`apkpull.orchestrator._merge_pending_contributions` once every + targeted device has resolved. + +An artifact that already exists locally (from an earlier, separate apkpull +invocation — never from a sibling device in the *same* run, since merging +only ever happens after every device has already finished) is never +rebuilt, letting a rerun skip straight past devices whose work is already +done without touching them at all. +""" + +from __future__ import annotations + +import json +import logging +import shutil +import tempfile +import zipfile +from dataclasses import dataclass, field +from pathlib import Path + +from apkfile import ApkFile, InvalidApkError + +from .bundle import BASE_NAME, build_apks_bundle, json_default, verify_bundle +from .device import Device +from .exceptions import PullError +from .models import FileKind, OutputFormat, PulledFile +from .progress import PackageReporter, Stage + +logger = logging.getLogger("apkpull.puller") + +_STAGING_DIRNAME = ".apkpull-staging" + + +def size_of(path: Path) -> int: + if path.is_dir(): + return sum(f.stat().st_size for f in path.rglob("*") if f.is_file()) + return path.stat().st_size + + +def target_path( + dest_root: Path, package: str, version_code: int, output_format: OutputFormat +) -> Path: + """Where a (package, version_code) bundle lands — a pure function of its + inputs, no device or filesystem I/O, so it's knowable before anything is + pulled.""" + stem = f"{package}-{version_code}" + suffix = { + OutputFormat.APKS: ".apks", + OutputFormat.ZIP: ".zip", + OutputFormat.FOLDER: "", + }[output_format] + return dest_root / f"{stem}{suffix}" + + +def materialize( + zip_path: Path, target: Path, output_format: OutputFormat, manifest: dict | None +) -> None: + """Move/extract a just-built bundle zip into its final ``target``.""" + target.parent.mkdir(parents=True, exist_ok=True) + if output_format == OutputFormat.FOLDER: + # Extract to a sibling temp dir first, then atomically rename into place — same + # crash-safety reasoning as build_apks_bundle's own .tmp-then-replace, so a partial + # extraction (e.g. interrupted mid-run) can never look "already pulled" to a later + # dedup check. + tmp_target = target.with_name(target.name + ".tmp") + shutil.rmtree(tmp_target, ignore_errors=True) + with zipfile.ZipFile(zip_path) as zf: + zf.extractall(tmp_target) + if manifest is not None: + (tmp_target / "manifest.json").write_text( + json.dumps(manifest, indent=2, default=json_default), + encoding="utf-8", + ) + tmp_target.replace(target) + else: + shutil.move(str(zip_path), str(target)) + if manifest is not None: + target.with_suffix(".manifest.json").write_text( + json.dumps(manifest, indent=2, default=json_default), + encoding="utf-8", + ) + + +def obb_names(package: str, version_code: int) -> list[str]: + return [ + f"main.{version_code}.{package}.obb", + f"patch.{version_code}.{package}.obb", + ] + + +def staging_root(dest_root: Path) -> Path: + """Where every in-progress (package, version_code) group's staged raw + pulls live until they're merged. Wiped best-effort at the start of every + :func:`apkpull.orchestrator.run` call, in case a previous run crashed + before cleaning up its own groups.""" + return dest_root / _STAGING_DIRNAME + + +def group_staging_dir(dest_root: Path, package: str, version_code: int) -> Path: + return staging_root(dest_root) / f"{package}-{version_code}" + + +def _device_staging_dir( + dest_root: Path, package: str, version_code: int, device_id: str +) -> Path: + # Wireless-adb device ids look like "192.168.1.5:5555" — sanitize rather + # than assume every adb-reported id is filesystem-safe as-is. + safe_id = device_id.replace(":", "_").replace("/", "_") + return group_staging_dir(dest_root, package, version_code) / safe_id + + +@dataclass(slots=True) +class RawPull: + """One device's raw contribution toward a ``(package, version_code)`` + bundle — pulled off one device and staged locally; not yet merged with + any other device's contribution or assembled into a bundle.""" + + device_id: str + package: str + version_code: int + """``dumpsys``-reported version code — the grouping/target-path key.""" + version_name: str | None + target: Path + """:func:`target_path` result — known even before anything is built.""" + already_built: bool + """``True``: ``target`` already existed before this pull (from an + earlier, separate apkpull invocation); nothing below is populated + except ``obb_pulled_files``.""" + base_path: Path | None = None + split_paths: list[Path] = field(default_factory=list) + obb_paths: list[Path] = field(default_factory=list) + """Staged, not yet in their final location — only populated when + ``already_built`` is ``False``.""" + obb_pulled_files: list[PulledFile] = field(default_factory=list) + """Already in their final location — only populated when + ``already_built`` is ``True``.""" + staging_dir: Path | None = None + + +class Puller: + def __init__(self, device: Device, dest_root: Path) -> None: + self.device = device + self.dest_root = dest_root + + def pull_raw( + self, + package: str, + *, + output_format: OutputFormat = OutputFormat.APKS, + report: PackageReporter | None = None, + ) -> RawPull: + """Pull this device's base.apk + splits (+ any OBB) off it and stage + them locally — but don't build a bundle. That happens once, later, + across every targeted device's ``RawPull`` for this + (package, version_code) — see :func:`build_merged_bundle`. + """ + self.device.ensure_connected() + + version_code = self.device.version_code(package) + version_name = self.device.version_name(package) + if version_code is None: + raise PullError(f"Could not determine the installed version of {package}.") + + self.dest_root.mkdir(parents=True, exist_ok=True) + target = target_path(self.dest_root, package, version_code, output_format) + + if target.exists(): + # Fast path: a *separate, earlier* apkpull invocation already + # built this bundle. This can never be a sibling device from + # this same run — merging only ever happens once, after every + # targeted device's thread has already finished, so nothing in + # this run could have written `target` yet. Still worth + # re-checking OBBs even though the bundle itself is done — e.g. + # a previous run with a different --format wouldn't have had + # anywhere to put one for FOLDER output. + logger.info( + "[%s] %s already exists, skipping pull.", self.device.label, target.name + ) + obb_dir = target if output_format == OutputFormat.FOLDER else self.dest_root + obb_pulled: list[PulledFile] = [] + for obb_name in obb_names(package, version_code): + remote_obb = f"/sdcard/Android/obb/{package}/{obb_name}" + if self.device.file_exists(remote_obb): + obb_pulled.append( + self._pull_if_missing( + remote_obb, obb_dir / obb_name, FileKind.OBB, obb_name + ) + ) + return RawPull( + device_id=self.device.device_id, + package=package, + version_code=version_code, + version_name=version_name, + target=target, + already_built=True, + obb_pulled_files=obb_pulled, + ) + + remote_paths = self.device.apk_paths(package) + if not remote_paths: + raise PullError(f"Unable to get apk paths for {package}.") + + staging_dir = _device_staging_dir( + self.dest_root, package, version_code, self.device.device_id + ) + staging_dir.mkdir(parents=True, exist_ok=True) + base_local, split_locals = self._pull_apks( + package, remote_paths, staging_dir, report + ) + + try: + ApkFile(base_local) + except InvalidApkError as exc: + raise PullError(f"Pulled base apk for {package} is invalid: {exc}") from exc + + obb_paths: list[Path] = [] + for obb_name in obb_names(package, version_code): + remote_obb = f"/sdcard/Android/obb/{package}/{obb_name}" + if self.device.file_exists(remote_obb): + local_obb = staging_dir / obb_name + size_human = self.device.remote_size_human(remote_obb) + logger.info( + "[%s] Pulling %s (%s)...", self.device.label, obb_name, size_human + ) + if report: + report(package, Stage.PULLING, f"{obb_name} ({size_human})") + self.device.adb.pull(self.device.device_id, remote_obb, local_obb) + obb_paths.append(local_obb) + + return RawPull( + device_id=self.device.device_id, + package=package, + version_code=version_code, + version_name=version_name, + target=target, + already_built=False, + base_path=base_local, + split_paths=split_locals, + obb_paths=obb_paths, + staging_dir=staging_dir, + ) + + def _pull_apks( + self, + package: str, + remote_paths: list[str], + dest_dir: Path, + report: PackageReporter | None, + ) -> tuple[Path, list[Path]]: + base_local: Path | None = None + split_locals: list[Path] = [] + for remote_path in remote_paths: + # Android always names the base apk "base.apk" on-device; splits are named + # "split_.apk" — the "split_" prefix is redundant once zipped up + # alongside base.apk, so it's dropped for a cleaner in-bundle filename. + remote_name = Path(remote_path).name + local_name = ( + remote_name.removeprefix("split_") + if remote_name != BASE_NAME + else remote_name + ) + local_path = dest_dir / local_name + size_human = self.device.remote_size_human(remote_path) + logger.info( + "[%s] Pulling %s (%s)...", + self.device.label, + local_path.name, + size_human, + ) + if report: + report(package, Stage.PULLING, f"{local_path.name} ({size_human})") + self.device.adb.pull(self.device.device_id, remote_path, local_path) + if local_path.name == BASE_NAME: + base_local = local_path + else: + split_locals.append(local_path) + + if base_local is None: + raise PullError(f"{package} has no {BASE_NAME} among its installed paths.") + return base_local, split_locals + + def _pull_if_missing( + self, remote_path: str, local_path: Path, kind: FileKind, display_name: str + ) -> PulledFile: + if local_path.exists(): + logger.info( + "[%s] %s already exists, skipping.", self.device.label, display_name + ) + return PulledFile( + kind=kind, + name=display_name, + local_path=local_path, + size_bytes=local_path.stat().st_size, + already_existed=True, + ) + + size_human = self.device.remote_size_human(remote_path) + logger.info( + "[%s] Pulling %s (%s)...", self.device.label, display_name, size_human + ) + self.device.adb.pull(self.device.device_id, remote_path, local_path) + return PulledFile( + kind=kind, + name=display_name, + local_path=local_path, + size_bytes=local_path.stat().st_size, + ) + + +def _place_obbs( + contributions: list[RawPull], + dest_root: Path, + target: Path, + output_format: OutputFormat, +) -> list[PulledFile]: + """Union every contribution's staged OBBs by filename and copy each into + its final location (disk→disk — these were already pulled off a device + by :meth:`Puller.pull_raw`, no adb involved here).""" + obb_dir = target if output_format == OutputFormat.FOLDER else dest_root + obbs: dict[str, Path] = {} + for contribution in contributions: + for obb_path in contribution.obb_paths: + obbs.setdefault(obb_path.name, obb_path) + + placed: list[PulledFile] = [] + for name in sorted(obbs): + source = obbs[name] + local_path = obb_dir / name + if local_path.exists(): + placed.append( + PulledFile( + kind=FileKind.OBB, + name=name, + local_path=local_path, + size_bytes=local_path.stat().st_size, + already_existed=True, + ) + ) + else: + local_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, local_path) + placed.append( + PulledFile( + kind=FileKind.OBB, + name=name, + local_path=local_path, + size_bytes=local_path.stat().st_size, + ) + ) + return placed + + +def build_merged_bundle( + package: str, + version_code: int, + contributions: list[RawPull], + dest_root: Path, + *, + output_format: OutputFormat = OutputFormat.APKS, + verify: bool = True, + strict: bool = False, + full: bool = False, +) -> tuple[Path, list[PulledFile]]: + """Merge every contribution's base+splits (and OBBs) into one bundle. + + ``contributions`` must all be non-``already_built`` :class:`RawPull`\\ s + for the same ``(package, version_code)``, and must already be sorted by + ``device_id`` by the caller — that's what makes the pick deterministic + on a split-filename collision between two devices (first sorted + device_id wins; see step 2 below). + + ``full`` is forwarded to :func:`~apkpull.bundle.verify_bundle` (only + meaningful when ``verify`` is also true, same as apkfile's own + ``as_dict(full=...)`` it wraps) and controls how much detail the written + ``manifest.json``/``.manifest.json`` sidecar carries. + + Pure aside from the filesystem: no adb, no threading — safe to call + from anywhere once every contributing device's raw pull has finished. + """ + target = target_path(dest_root, package, version_code, output_format) + if target.exists(): + # Belt-and-suspenders: a *different, concurrently running* apkpull + # process (not a sibling device thread from this run — see + # pull_raw's docstring) could have built this since our + # contributors were staged. Pre-existing race class, not new; just + # don't clobber it. + pulled = [ + PulledFile( + kind=FileKind.BUNDLE, + name=target.name, + local_path=target, + size_bytes=size_of(target), + already_existed=True, + ) + ] + pulled.extend(_place_obbs(contributions, dest_root, target, output_format)) + return target, pulled + + primary = contributions[0] + assert ( + primary.base_path is not None + ) # only non-already_built contributions reach here + + # First occurrence (i.e. earliest sorted device_id) wins on a filename + # collision — same split filename for the same version_code should mean + # identical content across devices, since Play Store's split naming is + # deterministic per abi/density/lang/version, so this is a tiebreaker + # for a case that shouldn't meaningfully arise, not a real conflict + # resolution. + splits: dict[str, Path] = {} + for contribution in contributions: + for split_path in contribution.split_paths: + splits.setdefault(split_path.name, split_path) + split_paths = [splits[name] for name in sorted(splits)] + + with tempfile.TemporaryDirectory(prefix="apkpull-merge-") as tmp: + zip_path = Path(tmp) / "bundle.apks" + logger.info("Packaging %s from %d device(s)...", package, len(contributions)) + # Base.apk isn't device-split — Play Store serves one canonical build per + # version_code — so no cross-device reconciliation is needed here; the manifest + # fields (package, version_code, ...) are derived from `primary.base_path` alone. + build_apks_bundle( + base_path=primary.base_path, + split_paths=split_paths, + dest_path=zip_path, + ) + + verified = None + manifest = None + if verify: + verified, manifest = verify_bundle( + package, version_code, zip_path, strict=strict, full=full + ) + + materialize(zip_path, target, output_format, manifest) + + pulled = [ + PulledFile( + kind=FileKind.BUNDLE, + name=target.name, + local_path=target, + size_bytes=size_of(target), + verified=verified, + ) + ] + pulled.extend(_place_obbs(contributions, dest_root, target, output_format)) + return target, pulled diff --git a/apkpull/tui.py b/apkpull/tui.py new file mode 100644 index 0000000..e1ad8c7 --- /dev/null +++ b/apkpull/tui.py @@ -0,0 +1,239 @@ +"""Live terminal display: one table per package, devices as rows, updating in +place as :class:`~apkpull.progress.ProgressEvent`\\ s arrive. + +Every device/package pair keeps its row for the whole run, even once it +hits ``DONE``/``ERROR`` -- the table is meant to be a stable, complete +record of every device's status, not something rows disappear from as work +finishes. ``logging`` warnings and (optionally, see ``apkpull.cli``) verbose +logs print through the same ``rich`` console and appear scrolled in above +the live region, which is where rich's ``Live`` places anything printed +while it's active -- as long as the live region itself stays within the +terminal's height. Once it doesn't, every refresh tick re-emits more lines +than fit, so the excess forces a real scroll each time, continuously +carrying earlier output (including those warnings) further up and +eventually out of scrollback -- a genuinely large run (many packages and/or +devices) can hit this. ``--no-live`` is the escape valve for that case: it +skips this table entirely in favor of plain, unbounded log lines, which +can't hit this problem since nothing is redrawn in place. + +Deliberately kept separate from orchestrator.py/puller.py/automation.py — +they only know how to *emit* :class:`~apkpull.progress.ProgressEvent`\\ s via +an optional callback; this module is the only place that turns those into a +rendered display. :func:`apkpull.cli.main` decides whether to use it at all. +""" + +from __future__ import annotations + +import threading +import time +from dataclasses import dataclass + +from rich.cells import cell_len +from rich.console import Console, Group, RenderableType +from rich.live import Live +from rich.table import Table +from rich.text import Text + +from .models import Status +from .progress import ProgressEvent, Stage + +# Stages with one fixed icon/label, no detail-dependent wording. DOWNLOADING +# and OPENING_PLAY_STORE are handled separately in _RowState.status_text — +# their wording depends on install-vs-update and retry status. +_STAGE_LABELS: dict[Stage, tuple[str, str]] = { + Stage.CONNECTING: ("🔌", "Connecting…"), + Stage.QUEUED: ("⏳", "Queued"), + Stage.MERGING: ("🧩", "Waiting for other devices…"), + Stage.PACKAGING: ("📦", "Packaging…"), + Stage.VERIFYING: ("🔍", "Verifying…"), + Stage.UNINSTALLING: ("🗑", "Uninstalling…"), +} + +_DONE_LABELS: dict[str, str] = { + # INSTALLED/UPDATED are real device-state changes that happened *this + # run* -- worth calling out specifically. ALREADY_UP_TO_DATE and + # SKIPPED_UPDATE_CHECK didn't change anything on the device; apkpull's + # actual job there was just retrieving the files, so "Pulled" is the + # accurate word, not "Installed" (nothing was (re)installed). + Status.INSTALLED.value: "Installed", + Status.UPDATED.value: "Updated", + Status.ALREADY_UP_TO_DATE.value: "Pulled", + Status.SKIPPED_UPDATE_CHECK.value: "Pulled", +} + + +def _format_elapsed(seconds: float) -> str: + minutes, secs = divmod(int(seconds), 60) + return f"{minutes}:{secs:02d}" + + +@dataclass(slots=True) +class _RowState: + device_label: str + device_meta: str = "" + stage: Stage = Stage.QUEUED + detail: str = "" + path: str = "" + """Set once DONE -- the local destination the files were pulled to.""" + started_at: float | None = None + """``None`` while still queued -- elapsed shows "—" until real work starts.""" + finished_at: float | None = None + """Set once DONE/ERROR, so elapsed freezes instead of ticking past completion.""" + + def device_text(self) -> str: + text = f"📱 {self.device_label}" + if self.device_meta: + text += f" ({self.device_meta})" + return text + + def status_text(self) -> Text: + # overflow/no_wrap are set on the Status column itself (see render()), + # not here -- Rich ignores a cell's own Text settings in favor of its + # column's, so setting them on these Text objects would do nothing. + if self.stage == Stage.ERROR: + return Text(f"❌ {self.detail or 'Error'}", style="red") + if self.stage == Stage.DONE: + label = _DONE_LABELS.get(self.detail, self.detail or "Done") + if self.path: + return Text(f"✅ {label} → {self.path}", style="green") + return Text(f"✅ {label}", style="green") + if self.stage == Stage.LOCKED: + return Text("🔒 Locked, please unlock…", style="yellow") + if self.stage == Stage.PULLING: + suffix = f" {self.detail}" if self.detail else "…" + return Text(f"📥 Pulling{suffix}") + if self.stage == Stage.DOWNLOADING: + kind = self.detail.removesuffix("-retry") + is_retry = self.detail.endswith("-retry") + icon, verb = ( + ("🔄", "Updating") if kind == "update" else ("⬇", "Downloading") + ) + suffix = " (retry)" if is_retry else "" + return Text(f"{icon} {verb}…{suffix}") + if self.stage == Stage.OPENING_PLAY_STORE: + suffix = " (retry)" if self.detail.endswith("-retry") else "" + return Text(f"📲 Opening Play Store…{suffix}") + icon, label = _STAGE_LABELS.get(self.stage, ("", self.stage.value)) + return Text(f"{icon} {label}") + + def elapsed_text(self) -> str: + if self.started_at is None: + return "—" + end = self.finished_at if self.finished_at is not None else time.monotonic() + return _format_elapsed(end - self.started_at) + + @property + def is_terminal(self) -> bool: + return self.stage in (Stage.DONE, Stage.ERROR) + + +class LiveDisplay: + """Context manager: ``with LiveDisplay() as display: run(..., on_progress=display.update)``. + + ``update`` is safe to call from any thread — apkpull fans work out across + devices on a thread pool, and every device's worker thread reports its + own progress concurrently. rich.live.Live's own refresh loop (not this + class) is what actually redraws the terminal, on a timer, so elapsed + times tick smoothly even between events rather than only on new ones. + """ + + def __init__(self, console: Console | None = None) -> None: + self.console = console or Console(stderr=True) + self._rows: dict[tuple[str, str], _RowState] = {} + self._package_order: list[str] = [] + self._lock = threading.Lock() + self._live = Live( + _Renderable(self), + console=self.console, + refresh_per_second=4, + transient=False, + ) + + def __enter__(self) -> LiveDisplay: # noqa: PYI034 - Self needs Python 3.11+ + self._live.start() + return self + + def __exit__(self, *exc_info: object) -> None: + self._live.stop() + + def update(self, event: ProgressEvent) -> None: + key = (event.device_id, event.package) + with self._lock: + row = self._rows.get(key) + if row is None: + row = _RowState(device_label=event.device_label) + self._rows[key] = row + if event.package not in self._package_order: + self._package_order.append(event.package) + row.device_label = event.device_label + if event.device_meta: + row.device_meta = event.device_meta + if event.path: + row.path = event.path + if row.started_at is None and event.stage != Stage.QUEUED: + row.started_at = time.monotonic() + if event.stage in (Stage.DONE, Stage.ERROR): + row.finished_at = time.monotonic() + row.stage = event.stage + row.detail = event.detail + + def render(self) -> RenderableType: + with self._lock: + packages = list(self._package_order) + rows_by_package: dict[str, list[tuple[str, _RowState]]] = {} + for (device_id, package), row in self._rows.items(): + rows_by_package.setdefault(package, []).append((device_id, row)) + total = len(self._rows) + done = sum(1 for row in self._rows.values() if row.is_terminal) + failed = sum(1 for row in self._rows.values() if row.stage == Stage.ERROR) + + # Fixed, shared widths for every table this frame -- otherwise each + # table auto-sizes its Device/Elapsed columns from only its own + # rows, so column boundaries drift table to table (and frame to + # frame, as elapsed digits/labels change length) instead of lining + # up into a steady grid. + device_col_width = max( + (cell_len(row.device_text()) for row in self._rows.values()), + default=0, + ) + device_col_width = max(device_col_width, cell_len("Device")) + elapsed_col_width = max( + (len(row.elapsed_text()) for row in self._rows.values()), + default=0, + ) + elapsed_col_width = max(elapsed_col_width, len("Elapsed")) + + tables: list[RenderableType] = [] + for package in packages: + device_rows = rows_by_package.get(package, []) + table = Table(title=package, title_justify="left", expand=True) + table.add_column("Device", width=device_col_width, no_wrap=True) + # no_wrap/overflow must be set on the *column*, not on the Text + # renderables in each cell -- Table._render always uses the + # column's settings for every cell regardless of what a Text + # object itself asks for. Without this, a long unbroken value + # (e.g. a filesystem path with no spaces to wrap on) forces the + # column wider than its ratio share to fit it, which is what + # was throwing the whole table's column boundaries out of line. + table.add_column("Status", ratio=1, no_wrap=True, overflow="ellipsis") + table.add_column( + "Elapsed", justify="right", width=elapsed_col_width, no_wrap=True + ) + for _device_id, row in device_rows: + table.add_row(row.device_text(), row.status_text(), row.elapsed_text()) + tables.append(table) + + summary = f"{done}/{total} complete" + (f" · {failed} failed" if failed else "") + return Group(*tables, Text(summary, style="bold")) + + +class _Renderable: + """Thin adapter so rich re-renders fresh (recomputed elapsed times, latest + state) on every refresh tick, not just when :meth:`LiveDisplay.update` is + called -- ``Live`` re-invokes ``__rich_console__`` on its own timer.""" + + def __init__(self, display: LiveDisplay) -> None: + self._display = display + + def __rich_console__(self, console, options): + yield self._display.render() diff --git a/apkpull/uidump.py b/apkpull/uidump.py new file mode 100644 index 0000000..c65add4 --- /dev/null +++ b/apkpull/uidump.py @@ -0,0 +1,124 @@ +"""Pure parsing of ``uiautomator dump`` XML. + +Kept free of any adb/subprocess dependency so button-finding logic can be +unit tested against static XML fixtures instead of a real device. + +:func:`parse` does the one and only regex pass over a dump's raw XML; +every lookup below (:func:`find_button`, :func:`contains_text`, ...) takes +its ``list[UiNode]`` result rather than the XML string itself, so a single +poll tick's dump only ever gets scanned once no matter how many checks run +against it -- see ``automation.py``'s ``_check_error_screens``/ +``_wait_for_button``, which parse once per tick and reuse the result across +what would otherwise be up to a dozen-plus independent re-scans. +""" + +from __future__ import annotations + +import re +from collections.abc import Sequence +from dataclasses import dataclass +from functools import cache + +_NODE_RE = re.compile(r"]*?/?>") +_ATTR_RE = re.compile(r'([\w-]+)="((?:[^"\\]|\\.)*)"') +_BOUNDS_RE = re.compile(r"\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]") + + +@dataclass(frozen=True, slots=True) +class UiNode: + text: str + content_desc: str + bounds: tuple[int, int, int, int] + + @property + def center(self) -> tuple[int, int]: + x1, y1, x2, y2 = self.bounds + return (x1 + x2) // 2, (y1 + y2) // 2 + + +def _unescape(value: str) -> str: + return ( + value.replace(""", '"') + .replace("'", "'") + .replace("<", "<") + .replace(">", ">") + .replace("&", "&") + ) + + +def parse(dump_xml: str) -> list[UiNode]: + """Parse every ```` with valid bounds out of a dump, once. + + Nodes with no ``text`` (but a real ``content-desc``, e.g. Play Store's + warning-triangle icon) are still included -- only a missing/invalid + ``bounds`` excludes a node, since that's what makes it unusable either + way (can't tap it, can't measure distance to it). + """ + nodes: list[UiNode] = [] + for match in _NODE_RE.finditer(dump_xml): + attrs = dict(_ATTR_RE.findall(match.group(0))) + bounds_match = _BOUNDS_RE.match(attrs.get("bounds", "")) + if not bounds_match: + continue + x1, y1, x2, y2 = (int(g) for g in bounds_match.groups()) + nodes.append( + UiNode( + text=_unescape(attrs.get("text", "")), + content_desc=_unescape(attrs.get("content-desc", "")), + bounds=(x1, y1, x2, y2), + ) + ) + return nodes + + +def find_button(nodes: Sequence[UiNode], text: str) -> tuple[int, int] | None: + """Return the tap coordinates of the first node whose text exactly matches ``text``.""" + for node in nodes: + if node.text == text: + return node.center + return None + + +def contains_text(nodes: Sequence[UiNode], text: str) -> bool: + return any(node.text == text for node in nodes) + + +def find_text_near_content_desc( + nodes: Sequence[UiNode], content_desc: str +) -> str | None: + """Find a node whose ``content-desc`` exactly matches ``content_desc`` — e.g. + Play Store's warning-triangle icon, which carries no text of its own — and + return the text of the nearest text-bearing node (by vertical distance + between bounds centers), on the assumption that an icon and its + accompanying message are one visual row. ``None`` if no such icon is + present, or no text node exists to pair it with. + """ + icon = next((n for n in nodes if n.content_desc == content_desc), None) + texts = [n for n in nodes if n.text] + if icon is None or not texts: + return None + _, icon_y = icon.center + nearest = min(texts, key=lambda n: abs(n.center[1] - icon_y)) + return nearest.text + + +@cache +def _currency_patterns( + symbols: tuple[str, ...], +) -> tuple[re.Pattern[str], re.Pattern[str]]: + escaped = "|".join(re.escape(s) for s in symbols) + return ( + re.compile(rf"\d+[.,]\d+\s*(?:{escaped})"), + re.compile(rf"(?:{escaped})\s*\d+[.,]\d+"), + ) + + +def contains_any_currency_amount( + nodes: Sequence[UiNode], symbols: tuple[str, ...] +) -> bool: + """Detect a price tag like ``$4.99`` or ``₪17.90`` anywhere in the dump.""" + pattern, alt_pattern = _currency_patterns(symbols) + for node in nodes: + if node.text and (pattern.search(node.text) or alt_pattern.search(node.text)): + return True + return False diff --git a/assets/logo.svg b/assets/logo.svg new file mode 100644 index 0000000..ac7cb53 --- /dev/null +++ b/assets/logo.svg @@ -0,0 +1,13 @@ + + apkpull + + + + + + + + + + + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e278662 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,92 @@ +[build-system] +requires = ["uv_build>=0.12.3,<0.13"] +build-backend = "uv_build" + +[tool.uv.build-backend] +module-name = "apkpull" +module-root = "" + +[project] +name = "apkpull" +version = "1.0.0" +description = "Automate downloading (pulling) Android apps from Google Play, across multiple real/emulated devices at once." +readme = "README.md" +license = "MIT" +license-files = ["LICENSE"] +requires-python = ">=3.10" +authors = [{ name = "David Lev", email = "david@davidlev.dev" }] +keywords = ["android", "adb", "apk", "google-play", "automation", "uiautomator"] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "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", + "Topic :: Utilities", +] +dependencies = [ + "apkfile>=1.4.1", + "rich>=13.0", +] + +[dependency-groups] +dev = [ + "pytest~=9.0", + "pytest-cov~=7.0", + "pytest-timeout~=2.3", + "ruff~=0.16", + "ty>=0.0.65", + "pre-commit~=4.5", +] + +[project.scripts] +apkpull = "apkpull.cli:run" + +[project.urls] +Homepage = "https://github.com/david-lev/apkpull" +"Issue Tracker" = "https://github.com/david-lev/apkpull/issues" +"Source Code" = "https://github.com/david-lev/apkpull" + +[tool.ruff.format] +docstring-code-format = true + +[tool.ruff.lint] +extend-select = [ + "T20", # flake8-print: forbid stray `print()`/`pprint()` calls in library code (use `logging` instead) + "E711", # forbid `== None` / `!= None`; use `is None` / `is not None` instead + "E712", # forbid `== True` / `== False`; use `is`/`is not` or plain truthiness instead + "E741", # forbid ambiguous single-character names (`l`, `O`, `I`) +] + +[tool.ruff.lint.per-file-ignores] +"apkpull/cli.py" = ["T201"] +"tests/**" = ["T201"] + +[tool.ty.src] +include = ["apkpull"] + +[tool.pytest.ini_options] +markers = [ + "integration: requires a real/emulated device reachable via adb", +] +testpaths = ["tests"] +timeout = 10 + +[tool.coverage.run] +include = ["apkpull/*"] + +[tool.coverage.report] +show_missing = true +exclude_lines = [ + "pragma: no cover", + "if TYPE_CHECKING:", + "raise NotImplementedError", + "def __repr__", + "\\.\\.\\.", +] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..badd8d9 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from contextlib import contextmanager +from pathlib import Path +from unittest.mock import patch + +import pytest +from apkfile._apk import ApkFile as RealApkFile + +DATA_DIR = Path(__file__).parent / "data" / "apk" +POLITEDROID_PATH = DATA_DIR / "politedroid.apk" +TEST_DEBUG_PATH = DATA_DIR / "test-debug.apk" + + +@pytest.fixture +def politedroid_bytes() -> bytes: + """A small real apk: package com.politedroid, version_code 4.""" + return POLITEDROID_PATH.read_bytes() + + +@pytest.fixture +def test_debug_bytes() -> bytes: + """A minimal real apk: package org.t0t0.androguard.test, version_code 1.""" + return TEST_DEBUG_PATH.read_bytes() + + +@pytest.fixture +def force_splits(): + """Context manager factory patching ``apkfile._bundle.ApkFile`` so + ``ApksFile.create()`` treats given real (non-split) fixture apks as + splits -- neither politedroid.apk nor test-debug.apk carries an actual + `android:split=` manifest attribute, so this is how tests get more than + one of them into a bundle together without ``create()`` rejecting the + extras as competing base apks. + + Pass explicit paths to force, or ``where=`` to force every + path matching it (e.g. every path not named ``base.apk``). + """ + + @contextmanager + def _force(*paths: Path, where=None): + forced = {Path(p) for p in paths} + + def _load(path): + apk = RealApkFile(path) + if Path(path) in forced or (where and where(Path(path))): + apk.__dict__["is_split"] = True + apk.__dict__["split_name"] = Path(path).stem + return apk + + with patch("apkfile._bundle.ApkFile", side_effect=_load): + yield + + return _force diff --git a/tests/data/apk/politedroid.apk b/tests/data/apk/politedroid.apk new file mode 100644 index 0000000..7ef4659 Binary files /dev/null and b/tests/data/apk/politedroid.apk differ diff --git a/tests/data/apk/test-debug.apk b/tests/data/apk/test-debug.apk new file mode 100644 index 0000000..4d27954 Binary files /dev/null and b/tests/data/apk/test-debug.apk differ diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..9b3bb05 --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,144 @@ +"""Shared test helpers: build fake uiautomator dumps and a fake adb backend.""" + +from __future__ import annotations + +import zipfile +from pathlib import Path + +from apkpull.bundle import BASE_NAME + +_DATA_DIR = Path(__file__).parent / "data" / "apk" +POLITEDROID_BYTES = (_DATA_DIR / "politedroid.apk").read_bytes() +TEST_DEBUG_BYTES = (_DATA_DIR / "test-debug.apk").read_bytes() + + +def make_dump(*texts_and_bounds: tuple[str, tuple[int, int, int, int]]) -> str: + """Build a minimal uiautomator XML dump containing the given text nodes. + + >>> make_dump(("Install", (0, 0, 100, 50))) + """ + nodes = "".join( + f'' + for text, (x1, y1, x2, y2) in texts_and_bounds + ) + return f'{nodes}' + + +def make_icon_dump( + content_desc: str, + text: str, + *, + icon_bounds: tuple[int, int, int, int] = (36, 1156, 84, 1204), + text_bounds: tuple[int, int, int, int] = (132, 1150, 881, 1210), +) -> str: + """Build a dump pairing a text-less icon node (identified only by + ``content-desc``) with a text node on the same row — mirrors Play Store's + real warning-triangle-plus-message layout (confirmed hands-on: an + ``ImageView`` with ``content-desc="Warning"`` immediately followed by a + ``TextView`` with the error message, same row of bounds). + """ + x1, y1, x2, y2 = icon_bounds + tx1, ty1, tx2, ty2 = text_bounds + return ( + '' + f'' + f'' + "" + ) + + +BUTTON_BOUNDS = (100, 200, 300, 260) + + +class FakeAdb: + """Duck-types :class:`apkpull.adb.AdbClient` without touching a real subprocess. + + ``shell_responses`` maps an exact shell command string to either: + * a plain string (returned every time), + * a list of strings (popped in order, last element repeats once exhausted), + * a zero-arg callable (invoked for its return value). + """ + + def __init__(self) -> None: + self.adb_path = "fake-adb" + self.shell_responses: dict[str, object] = {} + self.shell_log: list[str] = [] + self.pulled: list[tuple[str, Path]] = [] + self.connected = True + self.state = "device" + # ApksFile.create() (invoked by build_apks_bundle()) re-parses every pulled + # apk for real, so base/split pulls need to land real, valid apk bytes on + # disk -- OBB pulls don't, since nothing ever parses those. + self.base_apk_bytes = POLITEDROID_BYTES + self.split_apk_bytes = TEST_DEBUG_BYTES + self.obb_bytes = b"FAKE-OBB-CONTENT" + + # -- discovery -------------------------------------------------------- + + def list_devices(self) -> list[str]: + return ["fake-1"] if self.connected else [] + + def get_state(self, device_id: str) -> str: + return self.state if self.connected else "offline" + + def is_connected(self, device_id: str) -> bool: + return self.connected and self.state == "device" + + # -- shell -------------------------------------------------------------- + + def shell( + self, device_id: str, command: str, *, timeout: float = 30.0, check: bool = True + ) -> str: + self.shell_log.append(command) + response = self.shell_responses.get(command, "") + if isinstance(response, list): + value = response.pop(0) if len(response) > 1 else response[0] + elif callable(response): + value = response() + else: + value = response + return value + + # -- file transfer ----------------------------------------------------- + + def pull( + self, + device_id: str, + remote_path: str, + local_path: Path, + *, + timeout: float = 300.0, + ) -> None: + local_path.parent.mkdir(parents=True, exist_ok=True) + if local_path.suffix == ".obb": + local_path.write_bytes(self.obb_bytes) + elif local_path.name == BASE_NAME: + local_path.write_bytes(self.base_apk_bytes) + else: + local_path.write_bytes(self.split_apk_bytes) + self.pulled.append((remote_path, local_path)) + + def exec_out_to_file( + self, device_id: str, command: str, local_path: Path, *, timeout: float = 30.0 + ) -> None: + local_path.parent.mkdir(parents=True, exist_ok=True) + local_path.write_bytes(b"FAKE-PNG") + + +def configure_apks_create(apks_ctor) -> None: + """For tests that fully mock ``apkpull.bundle.ApksFile`` (both the + ``ApksFile(path)`` constructor ``verify_bundle`` uses and the + ``ApksFile.create()`` classmethod ``build_apks_bundle`` uses): give + ``.create()`` a side effect that actually writes a (trivially valid, + empty) zip to the requested output path, mirroring ``apks_ctor``'s + return value. Without this, ``build_apks_bundle``'s tmp-file rename + into place raises ``FileNotFoundError`` since a bare ``MagicMock`` + ``.create()`` call has no on-disk effect at all. + """ + + def _create(apks, output_path, **kwargs): + with zipfile.ZipFile(output_path, "w"): + pass + return apks_ctor.return_value + + apks_ctor.create.side_effect = _create diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/test_live_emulator.py b/tests/integration/test_live_emulator.py new file mode 100644 index 0000000..a201a66 --- /dev/null +++ b/tests/integration/test_live_emulator.py @@ -0,0 +1,99 @@ +"""Integration tests against a real adb-connected device/emulator. + +Deliberately safe: everything here targets ``com.android.vending`` (the +Play Store app itself), which is always already installed on any device +that can run apkpull, so these tests never install/update/uninstall +anything real — they only exercise device discovery, the automation +"already up to date" branch, pulling, and apkfile-based verification +against genuine adb/apk data. + +Skipped automatically when no adb binary or no connected device is found, +so the rest of the suite stays runnable without hardware/an emulator. +""" + +from __future__ import annotations + +import shutil + +import pytest + +from apkpull.adb import AdbClient +from apkpull.device import Device +from apkpull.orchestrator import run + +GOOGLE_PLAY_PACKAGE = "com.android.vending" + + +def _connected_device_id() -> str | None: + if not shutil.which("adb"): + return None + devices = AdbClient().list_devices() + return devices[0] if devices else None + + +DEVICE_ID = _connected_device_id() + +pytestmark = [ + pytest.mark.integration, + pytest.mark.skipif( + DEVICE_ID is None, reason="no adb-connected device/emulator available" + ), +] + + +@pytest.fixture(scope="module") +def adb() -> AdbClient: + return AdbClient() + + +@pytest.fixture(scope="module") +def device(adb: AdbClient) -> Device: + return Device(adb, DEVICE_ID) + + +def test_list_devices_sees_the_target_device(adb: AdbClient): + assert DEVICE_ID in adb.list_devices() + + +def test_device_info_reports_real_properties(device: Device): + info = device.info(refresh=True) + assert info.model != "unknown" + assert info.abi != "unknown" + assert info.lang != "unknown" + + +def test_google_play_is_installed_and_enabled(device: Device): + assert device.is_installed(GOOGLE_PLAY_PACKAGE) + assert not device.is_disabled(GOOGLE_PLAY_PACKAGE) + + +def test_dump_ui_returns_non_empty_xml(device: Device): + dump = device.dump_ui() + assert " 0 + assert bundle.local_path.suffix == ".apks" + assert bundle.verified is True + + manifest_path = bundle.local_path.with_suffix(".manifest.json") + assert manifest_path.is_file() diff --git a/tests/test_adb.py b/tests/test_adb.py new file mode 100644 index 0000000..3a37ba2 --- /dev/null +++ b/tests/test_adb.py @@ -0,0 +1,99 @@ +import subprocess +from unittest.mock import patch + +import pytest + +from apkpull.adb import AdbClient +from apkpull.exceptions import AdbNotFoundError, DeviceDisconnectedError + + +def _completed(stdout="", stderr="", returncode=0): + return subprocess.CompletedProcess( + args=[], returncode=returncode, stdout=stdout, stderr=stderr + ) + + +@pytest.fixture +def adb(): + with patch("shutil.which", return_value="/usr/local/bin/adb"): + return AdbClient() + + +def test_raises_when_adb_not_on_path(): + with patch("shutil.which", return_value=None), pytest.raises(AdbNotFoundError): + AdbClient() + + +def test_explicit_adb_path_skips_which(): + client = AdbClient(adb_path="/custom/adb") + assert client.adb_path == "/custom/adb" + + +def test_list_devices_filters_to_ready_state(adb): + stdout = "List of devices attached\nemulator-5554\tdevice\n192.168.1.5:5555\tunauthorized\n\n" + with patch("subprocess.run", return_value=_completed(stdout=stdout)) as run: + devices = adb.list_devices() + assert devices == ["emulator-5554"] + assert run.call_args.args[0][0] == "/usr/local/bin/adb" + + +def test_get_state_returns_stripped_stdout(adb): + with patch("subprocess.run", return_value=_completed(stdout="device\n")): + assert adb.get_state("emulator-5554") == "device" + + +def test_get_state_cleans_up_error_message(adb): + with patch( + "subprocess.run", + return_value=_completed(stderr="error: device offline\n", returncode=1), + ): + assert adb.get_state("emulator-5554") == "offline" + + +def test_is_connected_true_only_for_device_state(adb): + with patch("subprocess.run", return_value=_completed(stdout="device\n")): + assert adb.is_connected("emulator-5554") is True + with patch("subprocess.run", return_value=_completed(stdout="offline\n")): + assert adb.is_connected("emulator-5554") is False + + +def test_shell_returns_stdout(adb): + with patch("subprocess.run", return_value=_completed(stdout="hello\n")): + assert adb.shell("emulator-5554", "echo hello") == "hello\n" + + +def test_shell_check_raises_on_failure(adb): + with ( + patch("subprocess.run", return_value=_completed(stderr="boom", returncode=1)), + pytest.raises(DeviceDisconnectedError), + ): + adb.shell("emulator-5554", "false") + + +def test_shell_check_false_swallows_failure(adb): + with patch("subprocess.run", return_value=_completed(stderr="boom", returncode=1)): + assert adb.shell("emulator-5554", "false", check=False) == "" + + +def test_shell_timeout_raises_disconnected(adb): + with ( + patch( + "subprocess.run", + side_effect=subprocess.TimeoutExpired(cmd="adb", timeout=1), + ), + pytest.raises(DeviceDisconnectedError), + ): + adb.shell("emulator-5554", "sleep 999") + + +def test_pull_creates_parent_dir_and_raises_on_failure(adb, tmp_path): + dest = tmp_path / "nested" / "out.apk" + with ( + patch( + "subprocess.run", + return_value=_completed(returncode=1, stderr="no such file"), + ), + pytest.raises(DeviceDisconnectedError), + ): + adb.pull("emulator-5554", "/sdcard/x.apk", dest) + assert dest.parent.is_dir() diff --git a/tests/test_automation.py b/tests/test_automation.py new file mode 100644 index 0000000..dc821e5 --- /dev/null +++ b/tests/test_automation.py @@ -0,0 +1,355 @@ +import dataclasses + +import pytest + +from apkpull.automation import AutomationConfig, KickoffResult, PlayStoreAutomator +from apkpull.device import Device +from apkpull.exceptions import ( + AppNotFoundError, + AutomationTimeoutError, + DeviceLockedTimeoutError, + DeviceOfflineError, + IncompatibleDeviceError, + InsufficientStorageError, + NotSignedInError, + PaidAppError, + RegionRestrictedError, + UnrecognizedPlayStoreError, +) +from apkpull.locales import LOCALES +from apkpull.models import Status +from apkpull.progress import Stage + +from .helpers import FakeAdb, make_dump, make_icon_dump + +PLAY_FOCUSED = "mCurrentFocus=Window{d2b u0 com.android.vending/.MainActivity}" +INSTALL = make_dump(("Install", (0, 0, 100, 50))) +CANCEL = make_dump(("Cancel", (0, 0, 100, 50))) +OPEN = make_dump(("Open", (0, 0, 100, 50))) +UPDATE = make_dump(("Update", (0, 0, 100, 50))) + + +def build( + package="com.app", + *, + pm_path=None, + focused_window=PLAY_FOCUSED, + dumps=None, + max_rounds=3, +): + adb = FakeAdb() + adb.shell_responses["dumpsys window"] = focused_window + if pm_path is not None: + adb.shell_responses[f"pm path {package}"] = pm_path + if dumps is not None: + adb.shell_responses[ + "rm -f /sdcard/window_dump.xml; uiautomator dump >/dev/null 2>&1; " + "cat /sdcard/window_dump.xml" + ] = dumps + device = Device(adb, "fake-1") + automator = PlayStoreAutomator( + device, LOCALES["en"], AutomationConfig(poll_interval=0, max_rounds=max_rounds) + ) + return automator, device, adb + + +# -- start_install -------------------------------------------------------- + + +def test_start_install_taps_and_reports_tracking_needed(): + """Typical happy path: tap Install, the download is now running in the + background — start_install's job is done, completion is tracked elsewhere. + """ + automator, _device, adb = build(pm_path="", dumps=[INSTALL]) + assert automator.start_install("com.app") == KickoffResult(needs_tracking=True) + assert "input tap 50 25" in adb.shell_log # center of the Install button + + +def test_start_install_skips_tap_when_already_downloading(): + automator, _device, adb = build(dumps=[CANCEL]) + assert automator.start_install("com.app") == KickoffResult(needs_tracking=True) + assert not any(cmd.startswith("input tap") for cmd in adb.shell_log) + + +def test_start_install_reports_done_when_install_finishes_instantly(): + """Regression test: on a fast connection the app can finish installing + right after the tap, without ever showing "Cancel" — start_install must + notice via pm path and report needs_tracking=False instead of assuming a + download is still running. + """ + automator, _device, _adb = build( + pm_path="package:/data/app/base.apk\n", dumps=[INSTALL] + ) + assert automator.start_install("com.app") == KickoffResult( + needs_tracking=False, status=Status.INSTALLED + ) + + +@pytest.mark.parametrize( + "button_text, exc_type", + [ + (LOCALES["en"].hardware_incompatible, IncompatibleDeviceError), + (LOCALES["en"].country_restricted, RegionRestrictedError), + (LOCALES["en"].offline, DeviceOfflineError), + (LOCALES["en"].sign_in, NotSignedInError), + (LOCALES["en"].not_found, AppNotFoundError), + ("$4.99", PaidAppError), + ], +) +def test_start_install_raises_on_error_screens(button_text, exc_type): + dump = make_dump((button_text, (0, 0, 100, 50))) + automator, _device, _adb = build(dumps=[dump]) + with pytest.raises(exc_type): + automator.start_install("com.app") + + +def test_start_install_ignores_sign_in_text_when_locale_lacks_it(): + """sign_in is optional (unlike the tap-target fields above it) -- a + ButtonSet without it confirmed yet must not crash, or misfire, on a dump + that happens to contain literally "Sign in": the check has to be skipped + entirely, not just fail to match.""" + buttons_without_sign_in = dataclasses.replace(LOCALES["en"], sign_in=None) + dump = make_dump(("Sign in", (0, 0, 100, 50))) + adb = FakeAdb() + adb.shell_responses["dumpsys window"] = PLAY_FOCUSED + adb.shell_responses[ + "rm -f /sdcard/window_dump.xml; uiautomator dump >/dev/null 2>&1; " + "cat /sdcard/window_dump.xml" + ] = [dump] + automator = PlayStoreAutomator( + Device(adb, "fake-1"), + buttons_without_sign_in, + AutomationConfig(poll_interval=0, max_rounds=1), + ) + with pytest.raises(AutomationTimeoutError): + automator.start_install("com.app") + + +def test_start_install_raises_generic_error_for_unrecognized_warning_banner(): + """A red-warning-triangle banner apkpull has no specific rule for must still + surface its actual on-screen text immediately, instead of silently polling + until AutomationTimeoutError gives up with no real explanation.""" + dump = make_icon_dump("Warning", "Some new Play Store error we've never seen.") + automator, _device, _adb = build(dumps=[dump]) + with pytest.raises(UnrecognizedPlayStoreError, match="Some new Play Store error"): + automator.start_install("com.app") + + +def test_start_install_prefers_specific_error_over_generic_warning_banner(): + """A screen matching one of the specifically-handled banners (e.g. region + restriction) must raise its specific type, not fall through to the generic + UnrecognizedPlayStoreError catch-all — even though Play Store pairs that + screen with the same warning-triangle icon too.""" + dump = make_icon_dump("Warning", LOCALES["en"].country_restricted) + automator, _device, _adb = build(dumps=[dump]) + with pytest.raises(RegionRestrictedError): + automator.start_install("com.app") + + +NOT_ENOUGH_STORAGE = make_dump(("Not enough storage", (0, 0, 100, 50))) + + +def test_start_install_raises_when_post_tap_dialog_shows_insufficient_storage(): + """Confirmed hands-on: Play Store's "Not enough storage" dialog only + appears *after* tapping Install, never on the details page itself -- it's + the post-tap dump in _tap_until_download_starts that actually catches it. + Without that dump, is_installed() would just report False forever and the + caller would assume a download silently started in the background.""" + automator, _device, adb = build(dumps=[INSTALL, NOT_ENOUGH_STORAGE]) + with pytest.raises(InsufficientStorageError): + automator.start_install("com.app") + assert "input tap 50 25" in adb.shell_log + + +def test_start_install_raises_device_offline_for_no_internet_dialog(): + """Confirmed hands-on: this full-page "Something went wrong" / "No + internet connection..." state is a *different* screen from the themed + "You're offline" page `offline` already catches — both must map to the + same DeviceOfflineError, since it's the same underlying condition.""" + dump = make_dump((LOCALES["en"].no_internet_dialog, (0, 0, 100, 50))) + automator, _device, _adb = build(dumps=[dump]) + with pytest.raises(DeviceOfflineError): + automator.start_install("com.app") + + +def test_start_install_times_out_and_captures_error(tmp_path): + automator, _device, _adb = build( + dumps=[make_dump(("nothing useful", (0, 0, 1, 1)))], max_rounds=2 + ) + automator.config.logs_dir = tmp_path / "logs" + with pytest.raises(AutomationTimeoutError) as excinfo: + automator.start_install("com.app") + assert excinfo.value.screenshot and excinfo.value.dump + assert (tmp_path / "logs").is_dir() + assert any((tmp_path / "logs").iterdir()) + + +def test_start_install_relaunches_when_navigated_away_from_play_store(): + automator, _device, adb = build( + focused_window="mCurrentFocus=Window{d2b u0 com.android.settings/.Main}", + pm_path="package:/data/app/base.apk\n", + dumps=[make_dump(("nothing", (0, 0, 1, 1))), CANCEL], + max_rounds=5, + ) + automator.start_install("com.app") + launch_cmds = [c for c in adb.shell_log if c.startswith("am start")] + assert len(launch_cmds) >= 2 # initial launch + at least one relaunch + + +# -- start_update ----------------------------------------------------------- + + +def test_start_update_already_up_to_date(): + automator, _device, adb = build(dumps=[OPEN]) + assert automator.start_update("com.app") == KickoffResult( + needs_tracking=False, status=Status.ALREADY_UP_TO_DATE + ) + assert not any(cmd.startswith("input tap") for cmd in adb.shell_log) + + +def test_start_update_taps_and_reports_tracking_needed(): + versions = iter([100, 100]) + adb = FakeAdb() + adb.shell_responses["dumpsys window"] = PLAY_FOCUSED + adb.shell_responses["dumpsys package com.app"] = lambda: ( + f"versionCode={next(versions, 100)} versionName=1.0" + ) + adb.shell_responses[ + "rm -f /sdcard/window_dump.xml; uiautomator dump >/dev/null 2>&1; " + "cat /sdcard/window_dump.xml" + ] = [UPDATE] + device = Device(adb, "fake-1") + automator = PlayStoreAutomator( + device, LOCALES["en"], AutomationConfig(poll_interval=0, max_rounds=5) + ) + assert automator.start_update("com.app") == KickoffResult( + needs_tracking=True, baseline_version_code=100 + ) + + +def test_start_update_reports_done_when_update_finishes_instantly(): + versions = iter([100, 101]) + adb = FakeAdb() + adb.shell_responses["dumpsys window"] = PLAY_FOCUSED + adb.shell_responses["dumpsys package com.app"] = lambda: ( + f"versionCode={next(versions, 101)} versionName=1.0" + ) + adb.shell_responses[ + "rm -f /sdcard/window_dump.xml; uiautomator dump >/dev/null 2>&1; " + "cat /sdcard/window_dump.xml" + ] = [UPDATE] + device = Device(adb, "fake-1") + automator = PlayStoreAutomator( + device, LOCALES["en"], AutomationConfig(poll_interval=0, max_rounds=5) + ) + assert automator.start_update("com.app") == KickoffResult( + needs_tracking=False, status=Status.UPDATED + ) + + +def test_start_update_already_updating_skips_tap(): + versions = iter([100, 100, 101]) + automator, _device, adb = build(dumps=[CANCEL]) + adb.shell_responses["dumpsys package com.app"] = lambda: ( + f"versionCode={next(versions, 101)} versionName=1.0" + ) + assert automator.start_update("com.app") == KickoffResult( + needs_tracking=True, baseline_version_code=100 + ) + assert not any(cmd.startswith("input tap") for cmd in adb.shell_log) + + +# -- wait_for_unlock ---------------------------------------------------------- + + +def test_wait_for_unlock_returns_immediately_when_unlocked(): + adb = FakeAdb() + adb.shell_responses["dumpsys window"] = ( + "mShowingDream=false mDreamingLockscreen=false" + ) + device = Device(adb, "fake-1") + automator = PlayStoreAutomator( + device, LOCALES["en"], AutomationConfig(poll_interval=0) + ) + automator.wait_for_unlock() # must not raise / hang + + +def test_wait_for_unlock_polls_until_unlocked(): + states = iter( + [ + "mShowingDream=true", + "mShowingDream=true", + "mShowingDream=false mDreamingLockscreen=false", + ] + ) + adb = FakeAdb() + adb.shell_responses["dumpsys window"] = lambda: next(states) + device = Device(adb, "fake-1") + automator = PlayStoreAutomator( + device, LOCALES["en"], AutomationConfig(poll_interval=0, unlock_poll_interval=0) + ) + automator.wait_for_unlock() + + +def test_wait_for_unlock_raises_after_timeout(): + adb = FakeAdb() + adb.shell_responses["dumpsys window"] = "mShowingDream=true" # never unlocks + device = Device(adb, "fake-1") + automator = PlayStoreAutomator( + device, + LOCALES["en"], + AutomationConfig(poll_interval=0, unlock_poll_interval=0, unlock_timeout=0.05), + ) + with pytest.raises(DeviceLockedTimeoutError): + automator.wait_for_unlock() + + +def test_wait_for_unlock_zero_timeout_waits_indefinitely(): + states = iter( + ["mShowingDream=true"] * 5 + ["mShowingDream=false mDreamingLockscreen=false"] + ) + adb = FakeAdb() + adb.shell_responses["dumpsys window"] = lambda: next(states) + device = Device(adb, "fake-1") + automator = PlayStoreAutomator( + device, + LOCALES["en"], + AutomationConfig(poll_interval=0, unlock_poll_interval=0, unlock_timeout=0), + ) + automator.wait_for_unlock() # must not raise despite outlasting a would-be short timeout + + +def test_wait_for_unlock_reports_locked_when_actually_locked(): + adb = FakeAdb() + adb.shell_responses["dumpsys window"] = "mShowingDream=true" # never unlocks + device = Device(adb, "fake-1") + automator = PlayStoreAutomator( + device, + LOCALES["en"], + AutomationConfig(poll_interval=0, unlock_poll_interval=0, unlock_timeout=0.05), + ) + reported: list[tuple[Stage, str]] = [] + with pytest.raises(DeviceLockedTimeoutError): + automator.wait_for_unlock( + report=lambda stage, detail: reported.append((stage, detail)) + ) + assert reported == [(Stage.LOCKED, "")] + + +def test_wait_for_unlock_does_not_report_when_already_unlocked(): + """report must only fire for a genuinely locked device -- not on every + call, or every device with a report callback would show as briefly + "locked" even when it never was.""" + adb = FakeAdb() + adb.shell_responses["dumpsys window"] = ( + "mShowingDream=false mDreamingLockscreen=false" + ) + device = Device(adb, "fake-1") + automator = PlayStoreAutomator( + device, LOCALES["en"], AutomationConfig(poll_interval=0) + ) + reported: list[tuple[Stage, str]] = [] + automator.wait_for_unlock( + report=lambda stage, detail: reported.append((stage, detail)) + ) + assert reported == [] diff --git a/tests/test_bundle.py b/tests/test_bundle.py new file mode 100644 index 0000000..dbafcf9 --- /dev/null +++ b/tests/test_bundle.py @@ -0,0 +1,184 @@ +import enum +import json +import zipfile +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +import pytest +from apkfile import InvalidApkError, InvalidBundleError + +from apkpull.bundle import ( + BASE_NAME, + META_NAME, + build_apks_bundle, + json_default, + verify_bundle, +) +from apkpull.exceptions import VerificationError + + +def test_build_apks_bundle_writes_base_and_meta(tmp_path, politedroid_bytes): + base = tmp_path / "base.apk" + base.write_bytes(politedroid_bytes) + dest = tmp_path / "out.apks" + + build_apks_bundle(base_path=base, split_paths=[], dest_path=dest) + + assert dest.is_file() + assert not dest.with_name(dest.name + ".tmp").exists() + with zipfile.ZipFile(dest) as zf: + assert set(zf.namelist()) == {BASE_NAME, META_NAME} + assert zf.read(BASE_NAME) == politedroid_bytes + meta = json.loads(zf.read(META_NAME)) + assert meta == { + "package": "com.politedroid", + "label": "Polite Droid", + "version_code": 4, + "meta_version": 2, + "version_name": "1.3", + "min_sdk": 3, + } + + +def test_build_apks_bundle_writes_splits_under_their_original_filenames( + tmp_path, politedroid_bytes, test_debug_bytes, force_splits +): + base = tmp_path / "base.apk" + base.write_bytes(politedroid_bytes) + split = tmp_path / "config.en.apk" + split.write_bytes(test_debug_bytes) + dest = tmp_path / "out.apks" + + with force_splits(split): + build_apks_bundle(base_path=base, split_paths=[split], dest_path=dest) + + with zipfile.ZipFile(dest) as zf: + assert set(zf.namelist()) == {BASE_NAME, "config.en.apk", META_NAME} + assert zf.read("config.en.apk") == test_debug_bytes + + +def test_build_apks_bundle_omits_none_optional_fields(tmp_path, test_debug_bytes): + base = tmp_path / "base.apk" + base.write_bytes(test_debug_bytes) + dest = tmp_path / "out.apks" + + build_apks_bundle(base_path=base, split_paths=[], dest_path=dest) + + with zipfile.ZipFile(dest) as zf: + meta = json.loads(zf.read(META_NAME)) + # test-debug.apk has no min_sdk/target_sdk in its manifest + assert meta.keys() == { + "package", + "label", + "version_code", + "meta_version", + "version_name", + } + + +def test_build_apks_bundle_replaces_existing_file_atomically( + tmp_path, politedroid_bytes +): + base = tmp_path / "base.apk" + base.write_bytes(politedroid_bytes) + dest = tmp_path / "out.apks" + dest.write_bytes(b"stale-content") + + build_apks_bundle(base_path=base, split_paths=[], dest_path=dest) + + with zipfile.ZipFile(dest) as zf: + assert zf.read(BASE_NAME) == politedroid_bytes + + +def _apks_ctor(*, package_name="com.app", version_code=5, splits=()): + mock = SimpleNamespace( + package_name=package_name, + version_code=version_code, + as_dict=lambda *, full=False: { + "package_name": package_name, + "version_code": version_code, + "full": full, + }, + splits=splits, + ) + return mock + + +def test_verify_bundle_true_when_matching(): + with patch( + "apkpull.bundle.ApksFile", + return_value=_apks_ctor(splits=[SimpleNamespace(split_name="config.en")]), + ): + verified, manifest = verify_bundle("com.app", 5, Path("x.apks")) + assert verified is True + assert manifest["splits"] == ["config.en"] + assert manifest["verified"] is True + + +def test_verify_bundle_full_defaults_to_false(): + with patch("apkpull.bundle.ApksFile", return_value=_apks_ctor()): + _, manifest = verify_bundle("com.app", 5, Path("x.apks")) + assert manifest["full"] is False + + +def test_verify_bundle_forwards_full_to_as_dict(): + with patch("apkpull.bundle.ApksFile", return_value=_apks_ctor()): + _, manifest = verify_bundle("com.app", 5, Path("x.apks"), full=True) + assert manifest["full"] is True + + +def test_verify_bundle_false_on_package_mismatch(): + with patch( + "apkpull.bundle.ApksFile", return_value=_apks_ctor(package_name="com.other") + ): + verified, _ = verify_bundle("com.app", 5, Path("x.apks")) + assert verified is False + + +def test_verify_bundle_false_on_version_mismatch(): + with patch("apkpull.bundle.ApksFile", return_value=_apks_ctor(version_code=99)): + verified, _ = verify_bundle("com.app", 5, Path("x.apks")) + assert verified is False + + +def test_verify_bundle_strict_raises_on_mismatch(): + with ( + patch( + "apkpull.bundle.ApksFile", return_value=_apks_ctor(package_name="com.other") + ), + pytest.raises(VerificationError), + ): + verify_bundle("com.app", 5, Path("x.apks"), strict=True) + + +def test_verify_bundle_handles_unparseable_bundle_without_raising(): + with patch("apkpull.bundle.ApksFile", side_effect=InvalidBundleError("bad zip")): + verified, manifest = verify_bundle("com.app", 5, Path("x.apks")) + assert verified is False + assert "error" in manifest + + +def test_verify_bundle_handles_invalid_apk_inside_bundle(): + with patch("apkpull.bundle.ApksFile", side_effect=InvalidApkError("bad apk")): + verified, _manifest = verify_bundle("com.app", 5, Path("x.apks")) + assert verified is False + + +def test_verify_bundle_strict_raises_when_unparseable(): + with ( + patch("apkpull.bundle.ApksFile", side_effect=InvalidBundleError("bad zip")), + pytest.raises(VerificationError), + ): + verify_bundle("com.app", 5, Path("x.apks"), strict=True) + + +def test_json_default_uses_enum_value(): + class Color(enum.Enum): + RED = "red" + + assert json_default(Color.RED) == "red" + + +def test_json_default_stringifies_other_types(): + assert json_default(Path("a/b")) == str(Path("a/b")) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..0c5b6c9 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,253 @@ +import json +from pathlib import Path +from unittest.mock import patch + +from apkpull.cli import build_parser, main +from apkpull.exceptions import NoDevicesFoundError +from apkpull.models import DeviceInfo, DeviceOutcome, OutputFormat, RunSummary, Status + + +def make_summary(*statuses: Status) -> RunSummary: + return RunSummary( + outcomes=[ + DeviceOutcome( + device=DeviceInfo(device_id=f"d{i}"), package="com.app", status=s + ) + for i, s in enumerate(statuses) + ] + ) + + +def test_parser_requires_packages(): + parser = build_parser() + args = parser.parse_args(["com.whatsapp"]) + assert args.packages == "com.whatsapp" + assert args.uninstall_after is False + assert args.verbose == 0 + assert args.format == OutputFormat.APKS.value + assert args.unlock_timeout == 300.0 + assert args.keep_screen_on is True + assert args.download_timeout == 300.0 + assert args.download_retries == 1 + assert args.skip_duplicate_check is False + assert args.skip_update_check is False + assert args.no_live is False + assert args.full_manifest is False + + +def test_parser_accepts_valid_format_choices(): + parser = build_parser() + for fmt in ("apks", "zip", "folder"): + assert parser.parse_args(["com.app", "--format", fmt]).format == fmt + + +def test_parser_rejects_invalid_format(capsys): + parser = build_parser() + try: + parser.parse_args(["com.app", "--format", "rar"]) + raised = False + except SystemExit: + raised = True + assert raised + + +def test_parser_parses_all_flags(): + parser = build_parser() + args = parser.parse_args( + [ + "com.whatsapp", + "-d", + "/tmp/out", + "--uninstall-after", + "--devices", + "a,b", + "--max-workers", + "2", + "--max-poll-rounds", + "9", + "--unlock-timeout", + "60", + "--download-timeout", + "120", + "--download-retries", + "3", + "--format", + "zip", + "--notify", + "--no-keep-screen-on", + "--skip-duplicate-check", + "--skip-update-check", + "--no-live", + "--no-verify", + "--strict-verify", + "--full-manifest", + "--json", + "-vv", + ] + ) + assert args.dest == Path("/tmp/out") + assert args.uninstall_after is True + assert args.devices == "a,b" + assert args.max_workers == 2 + assert args.max_poll_rounds == 9 + assert args.unlock_timeout == 60.0 + assert args.download_timeout == 120.0 + assert args.download_retries == 3 + assert args.format == "zip" + assert args.notify is True + assert args.keep_screen_on is False + assert args.skip_duplicate_check is True + assert args.skip_update_check is True + assert args.no_live is True + assert args.no_verify is True + assert args.strict_verify is True + assert args.full_manifest is True + assert args.json is True + assert args.verbose == 2 + + +def test_main_returns_summary_exit_code(): + with patch("apkpull.cli.run_pull", return_value=make_summary(Status.INSTALLED)): + assert main(["com.app"]) == 0 + + +def test_main_returns_nonzero_on_partial_failure(): + with patch( + "apkpull.cli.run_pull", + return_value=make_summary(Status.INSTALLED, Status.ERROR), + ): + assert main(["com.app"]) == 1 + + +def test_main_prints_json_summary(capsys): + with patch("apkpull.cli.run_pull", return_value=make_summary(Status.INSTALLED)): + main(["com.app", "--json"]) + payload = json.loads(capsys.readouterr().out) + assert payload["total"] == 1 + assert payload["successful"] == 1 + + +def test_main_handles_apkpull_error_gracefully(capsys): + with patch("apkpull.cli.run_pull", side_effect=NoDevicesFoundError("no devices")): + code = main(["com.app"]) + assert code == 1 + + +def test_main_json_error_output(capsys): + with patch("apkpull.cli.run_pull", side_effect=NoDevicesFoundError("no devices")): + main(["com.app", "--json"]) + payload = json.loads(capsys.readouterr().out) + assert "no devices" in payload["error"] + + +def test_main_returns_130_on_keyboard_interrupt(capsys): + """130 (128 + SIGINT) is the standard shell convention for a Ctrl+C exit, + and main() must catch it explicitly -- otherwise the user gets a raw + traceback instead of a clean message.""" + with patch("apkpull.cli.run_pull", side_effect=KeyboardInterrupt): + code = main(["com.app"]) + assert code == 130 + + +def test_main_json_output_on_keyboard_interrupt(capsys): + with patch("apkpull.cli.run_pull", side_effect=KeyboardInterrupt): + main(["com.app", "--json"]) + payload = json.loads(capsys.readouterr().out) + assert "Interrupted" in payload["error"] + + +def test_main_passes_parsed_args_through_to_run_pull(): + with patch( + "apkpull.cli.run_pull", return_value=make_summary(Status.INSTALLED) + ) as run_mock: + main( + [ + "com.app", + "--devices", + "a,b", + "--uninstall-after", + "--strict-verify", + "--full-manifest", + ] + ) + _, kwargs = run_mock.call_args + assert run_mock.call_args.args[0] == ["com.app"] + assert kwargs["device_ids"] == ["a", "b"] + assert kwargs["uninstall"] is True + assert kwargs["strict_verify"] is True + assert kwargs["full_manifest"] is True + assert kwargs["output_format"] == OutputFormat.APKS + + +def test_main_uses_live_display_on_a_real_terminal_by_default(): + with ( + patch( + "apkpull.cli.run_pull", return_value=make_summary(Status.INSTALLED) + ) as run_mock, + patch("apkpull.cli.LiveDisplay") as display_ctor, + patch("sys.stderr.isatty", return_value=True), + ): + main(["com.app"]) + display_ctor.assert_called_once() + assert run_mock.call_args.kwargs["on_progress"] == display_ctor.return_value.update + + +def test_main_skips_live_display_with_no_live_flag(): + with ( + patch( + "apkpull.cli.run_pull", return_value=make_summary(Status.INSTALLED) + ) as run_mock, + patch("apkpull.cli.LiveDisplay") as display_ctor, + patch("sys.stderr.isatty", return_value=True), + ): + main(["com.app", "--no-live"]) + display_ctor.assert_not_called() + assert run_mock.call_args.kwargs["on_progress"] is None + + +def test_main_still_uses_live_display_when_verbose(): + """-v/-vv logs print through the same console as any other log line and + scroll in above the live table (see tui.py's module docstring) -- no + need to force plain logs just because verbose logging is on; --no-live + is the explicit way to skip the table.""" + with ( + patch( + "apkpull.cli.run_pull", return_value=make_summary(Status.INSTALLED) + ) as run_mock, + patch("apkpull.cli.LiveDisplay") as display_ctor, + patch("sys.stderr.isatty", return_value=True), + ): + main(["com.app", "-v"]) + display_ctor.assert_called_once() + assert run_mock.call_args.kwargs["on_progress"] is not None + + +def test_main_skips_live_display_on_a_non_terminal_stderr(): + with ( + patch( + "apkpull.cli.run_pull", return_value=make_summary(Status.INSTALLED) + ) as run_mock, + patch("apkpull.cli.LiveDisplay") as display_ctor, + patch("sys.stderr.isatty", return_value=False), + ): + main(["com.app"]) + display_ctor.assert_not_called() + assert run_mock.call_args.kwargs["on_progress"] is None + + +def test_main_splits_comma_separated_packages(): + with patch( + "apkpull.cli.run_pull", return_value=make_summary(Status.INSTALLED) + ) as run_mock: + main(["com.whatsapp, com.spotify.music"]) + assert run_mock.call_args.args[0] == ["com.whatsapp", "com.spotify.music"] + + +def test_main_passes_format_and_unlock_timeout_through_to_run_pull(): + with patch( + "apkpull.cli.run_pull", return_value=make_summary(Status.INSTALLED) + ) as run_mock: + main(["com.app", "--format", "folder", "--unlock-timeout", "45"]) + _, kwargs = run_mock.call_args + assert kwargs["output_format"] == OutputFormat.FOLDER + assert kwargs["automation_config"].unlock_timeout == 45.0 diff --git a/tests/test_device.py b/tests/test_device.py new file mode 100644 index 0000000..d194bbb --- /dev/null +++ b/tests/test_device.py @@ -0,0 +1,269 @@ +from apkpull.device import Device +from apkpull.models import DeviceInfo + +from .helpers import FakeAdb + + +def make_device(**shell_responses) -> tuple[Device, FakeAdb]: + adb = FakeAdb() + adb.shell_responses.update(shell_responses) + return Device(adb, "fake-1"), adb + + +def test_info_parses_getprop_output(): + device, _ = make_device( + getprop="[ro.product.model]: [Pixel 7]\n[ro.product.cpu.abi]: [arm64-v8a]\n" + "[persist.sys.locale]: [en-US]\n[ro.build.version.sdk]: [34]\n" + "[ro.sf.lcd_density]: [420]\n" + ) + info = device.info() + assert info.model == "Pixel 7" + assert info.abi == "arm64-v8a" + assert info.lang == "en-US" + assert info.langs == {"en"} + assert info.label == "Pixel 7" + assert info.sdk == 34 + assert info.density == 420 + assert info.density_bucket == "xxhdpi" + + +def test_seed_info_avoids_a_getprop_round_trip(): + """seed_info() lets a caller that already resolved a device's info + elsewhere (see orchestrator.py's duplicate-device check) hand it to a + fresh Device instance instead of paying for the same getprop/locale + round trip a second time.""" + device, adb = make_device(getprop="[ro.product.model]: [Should Not Be Read]\n") + seeded = DeviceInfo(device_id="fake-1", model="Seeded") + device.seed_info(seeded) + assert device.info() is seeded + assert "getprop" not in adb.shell_log + + +def test_info_falls_back_to_qemu_density_prop(): + device, _ = make_device(getprop="[qemu.sf.lcd_density]: [440]\n") + info = device.info() + assert info.density == 440 + assert info.density_bucket == "xxhdpi" + + +def test_info_defaults_to_unknown_when_missing(): + device, _ = make_device(getprop="") + info = device.info() + assert info.model == info.abi == info.lang == "unknown" + assert info.langs == frozenset() + assert info.label == "fake-1" + assert info.sdk is None + assert info.density is None + assert info.density_bucket is None + + +def test_info_maps_raw_density_to_nearest_bucket(): + """Real devices rarely report a density that lands exactly on a standard + bucket -- these are values seen in the wild that must round to their + nearest bucket, not just match an exact one.""" + cases = { + 120: "ldpi", + 160: "mdpi", + 213: "tvdpi", + 240: "hdpi", + 320: "xhdpi", + 360: "xhdpi", # common real device value, between xhdpi(320) and xxhdpi(480) + 480: "xxhdpi", + 560: "xxhdpi", # common real device value, between xxhdpi(480) and xxxhdpi(640) + 640: "xxxhdpi", + } + for raw_dpi, expected_bucket in cases.items(): + device, _ = make_device(getprop=f"[ro.sf.lcd_density]: [{raw_dpi}]\n") + assert device.info(refresh=True).density_bucket == expected_bucket, raw_dpi + + +def test_info_is_cached_until_refresh(): + device, adb = make_device(getprop="[ro.product.model]: [A]\n") + device.info() + adb.shell_responses["getprop"] = "[ro.product.model]: [B]\n" + assert device.info().model == "A" + assert device.info(refresh=True).model == "B" + + +def test_lang_prefers_persist_sys_locales_over_singular(): + device, _ = make_device( + getprop="[persist.sys.locales]: [fr-FR,en-US]\n[persist.sys.locale]: [en-US]\n" + ) + assert device.info().lang == "fr-FR" + + +def test_langs_collects_every_configured_locale_not_just_the_primary(): + """Play Store fetches a language split per *installed* language, not just the + primary one — a device with fr-FR,en-US,he-IL configured needs all three + tracked, even though `lang` (automation) only cares about the first.""" + device, _ = make_device(getprop="[persist.sys.locales]: [fr-FR,en-US,he-IL]\n") + info = device.info() + assert info.lang == "fr-FR" + assert info.langs == {"fr", "en", "he"} + + +def test_lang_falls_back_to_settings_when_persist_props_unset(): + """Regression: apkfile's install.py confirmed hands-on that a fresh/headless emulator can + leave persist.sys.locale(s) completely empty even though it's fully booted.""" + device, _adb = make_device( + getprop="[ro.product.model]: [Emulator]\n", + **{"settings get system system_locales": "de-DE\n"}, + ) + assert device.info().lang == "de-DE" + + +def test_lang_falls_back_to_ro_product_locale_as_last_resort(): + device, _ = make_device( + getprop="[ro.product.locale]: [ja-JP]\n", + **{"settings get system system_locales": "null\n"}, + ) + assert device.info().lang == "ja-JP" + + +def test_lang_unknown_when_every_source_is_empty(): + device, _ = make_device(getprop="") + assert device.info().lang == "unknown" + + +def test_is_installed_true_when_pm_path_has_output(): + device, _ = make_device( + **{"pm path com.whatsapp": "package:/data/app/com.whatsapp/base.apk\n"} + ) + assert device.is_installed("com.whatsapp") is True + + +def test_is_installed_false_when_pm_path_empty(): + device, _ = make_device(**{"pm path com.whatsapp": ""}) + assert device.is_installed("com.whatsapp") is False + + +def test_is_disabled_matches_exact_package(): + device, _ = make_device( + **{"pm list packages -d": "package:com.other\npackage:com.whatsapp\n"} + ) + assert device.is_disabled("com.whatsapp") is True + assert device.is_disabled("com.wha") is False + + +def test_apk_paths_strips_package_prefix(): + device, _ = make_device( + **{ + "pm path com.whatsapp": "package:/data/app/base.apk\npackage:/data/app/split_config.en.apk\n" + } + ) + assert device.apk_paths("com.whatsapp") == [ + "/data/app/base.apk", + "/data/app/split_config.en.apk", + ] + + +def test_version_code_name_min_sdk_parsed_from_dumpsys(): + dumpsys = " versionCode=451 minSdk=21\n versionName=1.2.3\n" + device, _ = make_device(**{"dumpsys package com.app": dumpsys}) + assert device.version_code("com.app") == 451 + assert device.version_name("com.app") == "1.2.3" + assert device.min_sdk("com.app") == 21 + + +def test_version_code_none_when_not_found(): + device, _ = make_device(**{"dumpsys package com.app": "no matching output"}) + assert device.version_code("com.app") is None + + +def test_file_exists_true_false(): + device, _ = make_device(**{"test -f '/sdcard/x.obb' && echo yes": "yes\n"}) + assert device.file_exists("/sdcard/x.obb") is True + + device2, _ = make_device(**{"test -f '/sdcard/y.obb' && echo yes": ""}) + assert device2.file_exists("/sdcard/y.obb") is False + + +def test_is_unlocked_checks_dream_state(): + device, _ = make_device( + **{"dumpsys window": "mShowingDream=false mDreamingLockscreen=false\n"} + ) + assert device.is_unlocked() is True + + device2, _ = make_device( + **{"dumpsys window": "mShowingDream=true mDreamingLockscreen=true\n"} + ) + assert device2.is_unlocked() is False + + +def test_is_foreground_package(): + device, _ = make_device( + **{ + "dumpsys window": " mCurrentFocus=Window{d2b Ru0 com.android.vending/.Foo}\n" + } + ) + assert device.is_foreground_package("com.android.vending") is True + assert device.is_foreground_package("com.whatsapp") is False + + +def test_focused_window_empty_when_field_absent(): + device, _ = make_device(**{"dumpsys window": "some other unrelated output\n"}) + assert device.focused_window() == "" + + +def test_dump_ui_clears_dumps_and_cats_in_one_combined_shell_call(): + """Clear/dump/cat are joined into a single ``adb shell`` round-trip (see + ``Device.dump_ui``) instead of three separate ones -- this runs on every + automation poll, for every device, so the subprocess overhead adds up.""" + combined = ( + "rm -f /sdcard/window_dump.xml; uiautomator dump >/dev/null 2>&1; " + "cat /sdcard/window_dump.xml" + ) + device, adb = make_device(**{combined: ""}) + result = device.dump_ui() + assert result == "" + assert adb.shell_log == [combined] + + +def test_tap_sends_input_tap_with_coords(): + device, adb = make_device() + device.tap(123, 456) + assert "input tap 123 456" in adb.shell_log + + +def test_get_and_put_setting(): + device, adb = make_device(**{"settings get global stay_on_while_plugged_in": "0\n"}) + assert device.get_setting("global", "stay_on_while_plugged_in") == "0" + device.put_setting("global", "stay_on_while_plugged_in", "7") + assert "settings put global stay_on_while_plugged_in 7" in adb.shell_log + + +def test_get_app_locale_parses_a_set_override(): + device, _adb = make_device( + **{ + "cmd locale get-app-locales com.android.vending --user 0": ( + "Locales for com.android.vending for user 0 are [fr]" + ) + } + ) + assert device.get_app_locale("com.android.vending") == "fr" + + +def test_get_app_locale_empty_when_unset(): + device, _adb = make_device( + **{ + "cmd locale get-app-locales com.android.vending --user 0": ( + "Locales for com.android.vending for user 0 are []" + ) + } + ) + assert device.get_app_locale("com.android.vending") == "" + + +def test_set_app_locale_sends_the_locale(): + device, adb = make_device() + device.set_app_locale("com.android.vending", "en") + assert ( + "cmd locale set-app-locales com.android.vending --user 0 --locales en" + in adb.shell_log + ) + + +def test_force_stop_sends_am_force_stop(): + device, adb = make_device() + device.force_stop("com.android.vending") + assert "am force-stop com.android.vending" in adb.shell_log diff --git a/tests/test_locales.py b/tests/test_locales.py new file mode 100644 index 0000000..ba5b4c6 --- /dev/null +++ b/tests/test_locales.py @@ -0,0 +1,72 @@ +import dataclasses + +from apkpull.locales import LOCALES, get_locale, supported_languages + + +def test_get_locale_exact_code(): + assert get_locale("en") is LOCALES["en"] + + +def test_get_locale_normalizes_region_and_case(): + assert get_locale("EN-US") is LOCALES["en"] + assert get_locale("he-IL") is LOCALES["he"] + + +def test_get_locale_unsupported_returns_none(): + assert get_locale("de") is None + + +def test_supported_languages_sorted(): + assert supported_languages() == sorted(LOCALES) + + +def test_en_has_confirmed_not_found_and_warning_icon_strings(): + assert LOCALES["en"].not_found == "Item not found." + assert LOCALES["en"].warning_icon == "Warning" + + +def test_he_has_confirmed_warning_icon_but_not_not_found(): + """Extracted from Google Play's own split_config.iw.apk resources.arsc + (resource id shared with the English "Warning" entry) and cross-checked: + it matches the independently hands-on-confirmed country_restricted/ + hardware_incompatible/offline strings already in this ButtonSet. + not_found isn't a static resource at all (confirmed hands-on: absent from + base.apk's string table for every locale), so it stays unset here too — + not a permanent state, just not yet captured live for Hebrew.""" + assert LOCALES["he"].warning_icon == "אזהרה" + assert LOCALES["he"].not_found is None + + +def test_es_fr_ru_are_supported_with_all_fields_confirmed_hands_on(): + """es/fr/ru were added via aapt2 resource-id extraction from Google Play's + own split APKs, cross-checked against the live UI wherever a candidate + string was ambiguous (multiple resource ids sharing the same English + text) — resolving two genuine disagreements: French "warning_icon" (candidates + split "Avertissement"/"Mise en garde" — live screen confirmed + "Avertissement") and French "sign_in" (candidates split "Se connecter"/ + "Connexion" — live screen confirmed "Connexion").""" + assert get_locale("es") is LOCALES["es"] + assert get_locale("fr") is LOCALES["fr"] + assert get_locale("ru") is LOCALES["ru"] + assert LOCALES["fr"].warning_icon == "Avertissement" + assert LOCALES["fr"].sign_in == "Connexion" + + +def test_no_internet_dialog_confirmed_for_every_locale(): + """A distinct screen from `offline` (confirmed hands-on: "Something went + wrong" / "No internet connection..." with a "Try again" button, vs. + `offline`'s themed "You're offline" browsing page) -- both map to the + same DeviceOfflineError, so every locale needs this filled in too.""" + for lang, buttons in LOCALES.items(): + assert buttons.no_internet_dialog, f"{lang}.no_internet_dialog is unset" + + +def test_every_locale_defines_all_required_fields_non_empty(): + """Optional fields (a default of ``None``) are for strings only confirmed + hands-on for some locales so far — those may legitimately be unset.""" + for lang, buttons in LOCALES.items(): + for field in dataclasses.fields(buttons): + if field.default is None: + continue + value = getattr(buttons, field.name) + assert value, f"{lang}.{field.name} must not be empty" diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..ae55bda --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,148 @@ +import json + +from apkpull.models import ( + DeviceInfo, + DeviceOutcome, + FileKind, + PulledFile, + RunSummary, + Status, +) + + +def make_outcome(status: Status) -> DeviceOutcome: + return DeviceOutcome( + device=DeviceInfo(device_id="d1", model="Pixel"), + package="com.app", + status=status, + ) + + +def test_fingerprint_matches_for_devices_with_same_play_relevant_properties(): + a = DeviceInfo( + device_id="d1", + model="Pixel", + abi="arm64-v8a", + langs=frozenset({"en"}), + sdk=34, + density_bucket="xxhdpi", + ) + b = DeviceInfo( + device_id="d2", + model="Pixel Clone", + abi="arm64-v8a", + langs=frozenset({"en"}), + sdk=34, + density_bucket="xxhdpi", + ) + assert a.fingerprint == b.fingerprint + + +def test_fingerprint_differs_when_any_relevant_property_differs(): + a = DeviceInfo( + device_id="d1", + abi="arm64-v8a", + langs=frozenset({"en"}), + sdk=34, + density_bucket="xxhdpi", + ) + b = DeviceInfo( + device_id="d2", + abi="arm64-v8a", + langs=frozenset({"en"}), + sdk=33, + density_bucket="xxhdpi", + ) + assert a.fingerprint != b.fingerprint + + +def test_fingerprint_uses_density_bucket_not_raw_density(): + """Two devices reporting different raw dpi can still round to the same + Android density bucket and get an identical dpi split from Play — so the + fingerprint must compare buckets, not raw density, in both directions: + same bucket despite different raw dpi is a match; different bucket is not, + even if that's counterintuitive from the raw numbers alone.""" + a = DeviceInfo( + device_id="d1", abi="arm64-v8a", density=420, density_bucket="xxhdpi" + ) + b = DeviceInfo( + device_id="d2", abi="arm64-v8a", density=440, density_bucket="xxhdpi" + ) + assert a.fingerprint == b.fingerprint + + c = DeviceInfo(device_id="d3", abi="arm64-v8a", density=320, density_bucket="xhdpi") + assert a.fingerprint != c.fingerprint + + +def test_fingerprint_uses_full_langs_set_not_just_primary_lang(): + """Two devices with the same primary `lang` but different secondary installed + languages would get different Play Store language splits, so they must not + fingerprint as duplicates — and conversely, `lang` itself (which only + reflects automation's primary locale) must play no part in the comparison.""" + a = DeviceInfo(device_id="d1", lang="en-US", langs=frozenset({"en"})) + b = DeviceInfo(device_id="d2", lang="en-US", langs=frozenset({"en", "fr"})) + assert a.fingerprint != b.fingerprint + + c = DeviceInfo(device_id="d3", lang="fr-FR", langs=frozenset({"en"})) + assert a.fingerprint == c.fingerprint + + +def test_pulled_file_as_dict_roundtrips_through_json(tmp_path): + pf = PulledFile( + kind=FileKind.BUNDLE, + name="a.apks", + local_path=tmp_path / "a.apks", + size_bytes=10, + ) + assert json.loads(json.dumps(pf.as_dict()))["kind"] == "bundle" + + +def test_device_outcome_ok_true_for_success_statuses(): + for status in ( + Status.INSTALLED, + Status.UPDATED, + Status.ALREADY_UP_TO_DATE, + Status.SKIPPED_UPDATE_CHECK, + ): + assert make_outcome(status).ok is True + + +def test_device_outcome_ok_false_for_failure_statuses(): + for status in (Status.ERROR, Status.UNSUPPORTED_LOCALE): + assert make_outcome(status).ok is False + + +def test_device_outcome_as_dict_is_json_serializable(): + outcome = make_outcome(Status.INSTALLED) + json.dumps(outcome.as_dict()) # must not raise + + +def test_run_summary_exit_code_zero_when_all_succeed(): + summary = RunSummary( + outcomes=[make_outcome(Status.INSTALLED), make_outcome(Status.UPDATED)] + ) + assert summary.exit_code == 0 + assert summary.successful == 2 + assert summary.total == 2 + + +def test_run_summary_exit_code_counts_failures(): + summary = RunSummary( + outcomes=[make_outcome(Status.INSTALLED), make_outcome(Status.ERROR)] + ) + assert summary.exit_code == 1 + + +def test_run_summary_exit_code_caps_at_nine(): + summary = RunSummary(outcomes=[make_outcome(Status.ERROR) for _ in range(20)]) + assert summary.exit_code == 9 + + +def test_run_summary_exit_code_fifty_when_no_devices(): + assert RunSummary(outcomes=[]).exit_code == 50 + + +def test_run_summary_as_dict_is_json_serializable(): + summary = RunSummary(outcomes=[make_outcome(Status.INSTALLED)]) + payload = json.dumps(summary.as_dict()) + assert json.loads(payload)["total"] == 1 diff --git a/tests/test_orchestrator.py b/tests/test_orchestrator.py new file mode 100644 index 0000000..428aa5b --- /dev/null +++ b/tests/test_orchestrator.py @@ -0,0 +1,1413 @@ +import urllib.error +import zipfile +from concurrent.futures import Future +from unittest.mock import MagicMock, patch + +import pytest + +from apkpull.automation import AutomationConfig +from apkpull.exceptions import ( + InvalidPackageNameError, + NoDevicesFoundError, + PullError, + VerificationError, +) +from apkpull.models import DeviceInfo, DeviceOutcome, OutputFormat, Status +from apkpull.orchestrator import ( + _drain_batch, + _merge_pending_contributions, + _PendingContribution, + _warn_about_duplicate_devices, + check_package_exists, + run, + run_for_device, + validate_package_name, +) +from apkpull.progress import ProgressEvent, Stage +from apkpull.puller import RawPull, target_path + +from .helpers import ( + POLITEDROID_BYTES, + TEST_DEBUG_BYTES, + FakeAdb, + configure_apks_create, + make_dump, +) + +OPEN = make_dump(("Open", (0, 0, 100, 50))) + + +def _run_for_device_and_merge(adb, device_id, packages, dest_root, **kwargs): + """run_for_device() alone no longer finishes building bundles for + devices that need one -- real merging now happens in run()'s post-drain + merge phase (see _merge_pending_contributions). This chains the two + exactly as run() does, without a second device or any threading, for + tests that want the old all-in-one result. + + verify/strict_verify/full_manifest only matter to the merge step now + (run_for_device no longer accepts them at all -- building/verifying + moved entirely to _merge_pending_contributions), so they're popped out + before forwarding the rest of kwargs to run_for_device. + """ + verify = kwargs.pop("verify", True) + strict_verify = kwargs.pop("strict_verify", False) + full_manifest = kwargs.pop("full_manifest", False) + outcomes, pending = run_for_device(adb, device_id, packages, dest_root, **kwargs) + _merge_pending_contributions( + outcomes, + pending, + dest_root, + verify=verify, + strict_verify=strict_verify, + full_manifest=full_manifest, + output_format=kwargs.get("output_format", OutputFormat.APKS), + notify_enabled=kwargs.get("notify_enabled", False), + on_progress=kwargs.get("on_progress"), + ) + return outcomes + + +def _make_contribution(tmp_path, device_id, package, version_code, *, splits): + """Build a (_PendingContribution) directly, bypassing FakeAdb/ + Puller.pull_raw -- these are for run()-level tests exercising the + merge-phase *wiring* (grouping, warnings, staging cleanup), not the + device-pull mechanics themselves (already covered in test_puller.py). + + base_path/split_paths get real fixture apk bytes (not literal placeholder + text) since the merge phase feeds them into a real ApksFile.create() call + -- callers wrap the eventual run()/build_merged_bundle() call in + force_splits(where=...) so these split filenames are actually treated as + splits rather than competing base apks.""" + staging = tmp_path / ".apkpull-staging" / f"{package}-{version_code}" / device_id + staging.mkdir(parents=True) + base_path = staging / "base.apk" + base_path.write_bytes(POLITEDROID_BYTES) + split_paths = [] + for name in splits: + split_path = staging / name + split_path.write_bytes(TEST_DEBUG_BYTES) + split_paths.append(split_path) + target = target_path(tmp_path, package, version_code, OutputFormat.APKS) + raw = RawPull( + device_id=device_id, + package=package, + version_code=version_code, + version_name="1.0", + target=target, + already_built=False, + base_path=base_path, + split_paths=split_paths, + obb_paths=[], + staging_dir=staging, + ) + outcome = DeviceOutcome( + device=DeviceInfo(device_id=device_id, model=device_id), + package=package, + status=Status.INSTALLED, + pulled_files=[], + version_code=version_code, + version_name="1.0", + destination=target, + ) + return _PendingContribution(raw=raw, outcome=outcome) + + +# -- validate_package_name ------------------------------------------------ + + +@pytest.mark.parametrize("package", ["com.whatsapp", "com.a.b_c.d", "a.b"]) +def test_validate_package_name_accepts_valid(package): + validate_package_name(package) # must not raise + + +@pytest.mark.parametrize("package", ["", "com", "1com.app", "com..app", "com.app!"]) +def test_validate_package_name_rejects_invalid(package): + with pytest.raises(InvalidPackageNameError): + validate_package_name(package) + + +# -- check_package_exists -------------------------------------------------- + + +def test_check_package_exists_warns_but_does_not_raise_on_404(caplog): + """A 404 from Play Store's unauthenticated *website* doesn't reliably mean + the package can't be installed on the actual target device — a region- + locked app (e.g. a bank's) can 404 from one network/country while still + installing fine on a device with the right account/region. This must + never hard-abort the run; the on-device automation is what actually knows.""" + with ( + patch( + "urllib.request.urlopen", + side_effect=urllib.error.HTTPError("u", 404, "not found", {}, None), + ), + caplog.at_level("WARNING", logger="apkpull.orchestrator"), + ): + check_package_exists("com.nonexistent") # must not raise + + warnings = [r.message for r in caplog.records if r.levelname == "WARNING"] + assert len(warnings) == 1 + assert "com.nonexistent" in warnings[0] + + +def test_check_package_exists_ignores_network_errors(): + with patch("urllib.request.urlopen", side_effect=urllib.error.URLError("timeout")): + check_package_exists("com.whatsapp") # must not raise + + +def test_check_package_exists_ok_on_200(): + response = MagicMock() + response.status = 200 + response.__enter__.return_value = response + with patch("urllib.request.urlopen", return_value=response): + check_package_exists("com.whatsapp") # must not raise + + +# -- run_for_device -------------------------------------------------------- + + +def _adb_with_device(*, lang="en-US", sdk=None, play_installed=True, pkg_pm_path=""): + adb = FakeAdb() + sdk_prop = f"[ro.build.version.sdk]: [{sdk}]\n" if sdk is not None else "" + adb.shell_responses["getprop"] = ( + f"[ro.product.model]: [Pixel]\n[ro.product.cpu.abi]: [arm64-v8a]\n" + f"[persist.sys.locale]: [{lang}]\n{sdk_prop}" + ) + adb.shell_responses["pm path com.android.vending"] = ( + "package:/data/app/vending/base.apk\n" if play_installed else "" + ) + adb.shell_responses["pm list packages -d"] = "" + # Both is_unlocked() and focused_window() read `dumpsys window`, so one scripted + # response has to carry both facts. + adb.shell_responses["dumpsys window"] = ( + "mShowingDream=false mDreamingLockscreen=false\n" + "mCurrentFocus=Window{d2b u0 com.android.vending/.Main}" + ) + adb.shell_responses["settings get global stay_on_while_plugged_in"] = "0\n" + adb.shell_responses["pm path com.app"] = pkg_pm_path + adb.shell_responses["cmd locale get-app-locales com.android.vending --user 0"] = ( + "Locales for com.android.vending for user 0 are []" + ) + return adb + + +def test_run_for_device_reports_error_when_play_store_missing(): + adb = _adb_with_device(play_installed=False) + outcomes, _pending = run_for_device(adb, "fake-1", ["com.app"], None) + assert len(outcomes) == 1 + assert outcomes[0].status == Status.ERROR + assert "Google Play" in outcomes[0].error + + +def test_run_for_device_reports_error_stage_on_failure(): + """error_outcome() is the one path that can fire before info()/report() + exist at all (e.g. Google Play missing, checked before anything else) -- + it must still emit an ERROR progress event using device_id as the + fallback label, not crash for lack of a resolved DeviceInfo.""" + adb = _adb_with_device(play_installed=False) + events: list[ProgressEvent] = [] + run_for_device(adb, "fake-1", ["com.app"], None, on_progress=events.append) + assert [e.stage for e in events] == [Stage.CONNECTING, Stage.ERROR] + assert events[-1].device_id == "fake-1" + assert events[0].package == "com.app" + + +def test_run_for_device_reports_error_for_unsupported_locale(): + adb = _adb_with_device(lang="de-DE") + outcomes, _pending = run_for_device(adb, "fake-1", ["com.app"], None) + assert len(outcomes) == 1 + assert outcomes[0].status == Status.ERROR + assert "language" in outcomes[0].error + + +def test_force_locale_overrides_an_unsupported_device_language(): + """A device whose own language isn't supported would normally hard-fail + (see the test above) -- force_locale should let it through instead by + overriding just Google Play's locale, on a device new enough to support + per-app locale overrides (Android 13+ / API 33). No packages requested, + so this exercises only the device-setup phase (where the override lives) + without needing to fake a full Play Store automation flow too.""" + adb = _adb_with_device(lang="de-DE", sdk=34) + outcomes, _pending = run_for_device(adb, "fake-1", [], None, force_locale="en") + assert outcomes == [] # no UnsupportedLocaleError -- setup completed cleanly + assert ( + "cmd locale set-app-locales com.android.vending --user 0 --locales en" + in adb.shell_log + ) + + +def test_force_locale_reverts_google_plays_locale_afterward(): + adb = _adb_with_device(lang="de-DE", sdk=34) + run_for_device(adb, "fake-1", [], None, force_locale="en") + set_locale_calls = [ + c + for c in adb.shell_log + if c.startswith("cmd locale set-app-locales com.android.vending") + ] + assert set_locale_calls == [ + "cmd locale set-app-locales com.android.vending --user 0 --locales en", + "cmd locale set-app-locales com.android.vending --user 0 --locales ", + ] + assert adb.shell_log.count("am force-stop com.android.vending") == 2 + + +def test_force_locale_is_skipped_on_devices_too_old_to_support_it(): + """No API level reported (or below 33) -- force_locale must not be applied + at all, and the device falls back to its own (here unsupported) language, + exactly as if force_locale had never been passed.""" + adb = _adb_with_device(lang="de-DE", sdk=30) + outcomes, _pending = run_for_device( + adb, "fake-1", ["com.app"], None, force_locale="en" + ) + assert len(outcomes) == 1 + assert outcomes[0].status == Status.ERROR + assert "language" in outcomes[0].error + assert not any(c.startswith("cmd locale set-app-locales") for c in adb.shell_log) + + +def test_run_rejects_an_unsupported_force_locale_value(tmp_path): + """Validated up front, before any adb/device work starts -- a bad value + (typo, unsupported language) shouldn't cost a real adb round-trip to + discover.""" + with pytest.raises(Exception, match="force-locale"): + run(["com.app"], tmp_path, skip_existence_check=True, force_locale="xx") + + +def test_run_for_device_reports_error_per_package_when_setup_fails(): + """A device-level failure (no Play Store here) applies to every requested + package, not just the first — each still gets its own outcome.""" + adb = _adb_with_device(play_installed=False) + outcomes, _pending = run_for_device(adb, "fake-1", ["com.app", "com.other"], None) + assert {o.package for o in outcomes} == {"com.app", "com.other"} + assert all(o.status == Status.ERROR for o in outcomes) + + +def test_run_for_device_pulls_and_verifies_already_up_to_date_app(tmp_path): + adb = _adb_with_device(pkg_pm_path="package:/data/app/com.app-x/base.apk\n") + adb.shell_responses[ + "rm -f /sdcard/window_dump.xml; uiautomator dump >/dev/null 2>&1; " + "cat /sdcard/window_dump.xml" + ] = [OPEN] + adb.shell_responses["dumpsys package com.app"] = "versionCode=5 versionName=1.0" + + # Puller parses the pulled base apk with ApkFile before zipping it into a bundle, and + # then re-opens the finished bundle with ApksFile to verify it — FakeAdb.pull() writes + # placeholder bytes rather than a real apk, so both apkfile entry points are mocked here. + with ( + patch("apkpull.puller.ApkFile") as apk_ctor, + patch("apkpull.bundle.ApksFile") as apks_ctor, + ): + apk_ctor.return_value.labels = {"": "App"} + apk_ctor.return_value.version_code = 5 + apk_ctor.return_value.version_name = "1.0" + apk_ctor.return_value.min_sdk_version = 21 + apk_ctor.return_value.target_sdk_version = 34 + + apks_ctor.return_value.package_name = "com.app" + apks_ctor.return_value.version_code = 5 + apks_ctor.return_value.as_dict.return_value = { + "package_name": "com.app", + "version_code": 5, + } + apks_ctor.return_value.splits = [] + configure_apks_create(apks_ctor) + + outcomes = _run_for_device_and_merge(adb, "fake-1", ["com.app"], tmp_path) + + assert len(outcomes) == 1 + outcome = outcomes[0] + assert outcome.ok, outcome.error + assert outcome.status == Status.ALREADY_UP_TO_DATE + assert outcome.pulled_files[0].verified is True + assert outcome.pulled_files[0].name == "com.app-5.apks" + assert outcome.destination == tmp_path / "com.app-5.apks" + assert (tmp_path / "com.app-5.manifest.json").is_file() + + +def test_run_for_device_reports_progress_through_the_full_pipeline(tmp_path): + adb = _adb_with_device(pkg_pm_path="package:/data/app/com.app-x/base.apk\n") + adb.shell_responses[ + "rm -f /sdcard/window_dump.xml; uiautomator dump >/dev/null 2>&1; " + "cat /sdcard/window_dump.xml" + ] = [OPEN] + adb.shell_responses["dumpsys package com.app"] = "versionCode=5 versionName=1.0" + + events: list[ProgressEvent] = [] + + with ( + patch("apkpull.puller.ApkFile") as apk_ctor, + patch("apkpull.bundle.ApksFile") as apks_ctor, + ): + apk_ctor.return_value.labels = {"": "App"} + apk_ctor.return_value.version_code = 5 + apk_ctor.return_value.version_name = "1.0" + apk_ctor.return_value.min_sdk_version = 21 + apk_ctor.return_value.target_sdk_version = 34 + apks_ctor.return_value.package_name = "com.app" + apks_ctor.return_value.version_code = 5 + apks_ctor.return_value.as_dict.return_value = {} + apks_ctor.return_value.splits = [] + configure_apks_create(apks_ctor) + + _run_for_device_and_merge( + adb, "fake-1", ["com.app"], tmp_path, on_progress=events.append + ) + + stages = [e.stage for e in events] + # CONNECTING (device-level) -> OPENING_PLAY_STORE (kickoff) -> PULLING + # (per file, from Puller) -> MERGING (this device's raw pull is done, + # waiting on its (package, version_code) group -- trivially itself + # here, since it's the only device) -> PACKAGING -> VERIFYING + # (verify=True by default) -> DONE, the last two fired from the merge + # phase once the group resolves. Note no DOWNLOADING here: this package + # resolves immediately (already up to date), so it's never tracked as + # an in-flight download. + assert stages[0] == Stage.CONNECTING + assert stages[1] == Stage.OPENING_PLAY_STORE + assert Stage.PULLING in stages + assert Stage.MERGING in stages + assert Stage.PACKAGING in stages + assert stages[-2:] == [Stage.VERIFYING, Stage.DONE] + assert all(e.device_id == "fake-1" for e in events) + assert all(e.package == "com.app" for e in events) + assert events[-1].detail == Status.ALREADY_UP_TO_DATE.value + + +def test_run_for_device_skips_play_store_entirely_when_skip_update_check(tmp_path): + """skip_update_check must not just skip *tapping* Update -- it should skip + launching Play Store and dumping the UI for that package altogether, since + the whole point is avoiding Play Store UI automation for an already- + installed package, not just avoiding the tap.""" + adb = _adb_with_device(pkg_pm_path="package:/data/app/com.app-x/base.apk\n") + adb.shell_responses["dumpsys package com.app"] = "versionCode=5 versionName=1.0" + + with ( + patch("apkpull.puller.ApkFile") as apk_ctor, + patch("apkpull.bundle.ApksFile") as apks_ctor, + ): + apk_ctor.return_value.labels = {"": "App"} + apk_ctor.return_value.version_code = 5 + apk_ctor.return_value.version_name = "1.0" + apk_ctor.return_value.min_sdk_version = 21 + apk_ctor.return_value.target_sdk_version = 34 + apks_ctor.return_value.package_name = "com.app" + apks_ctor.return_value.version_code = 5 + apks_ctor.return_value.as_dict.return_value = {} + apks_ctor.return_value.splits = [] + configure_apks_create(apks_ctor) + + outcomes = _run_for_device_and_merge( + adb, "fake-1", ["com.app"], tmp_path, skip_update_check=True + ) + + assert len(outcomes) == 1 + outcome = outcomes[0] + assert outcome.ok, outcome.error + assert outcome.status == Status.SKIPPED_UPDATE_CHECK + assert not any(cmd.startswith("am start") for cmd in adb.shell_log) + assert "uiautomator dump" not in adb.shell_log + + +def test_run_for_device_forwards_output_format_to_puller(tmp_path): + adb = _adb_with_device(pkg_pm_path="package:/data/app/com.app-x/base.apk\n") + adb.shell_responses[ + "rm -f /sdcard/window_dump.xml; uiautomator dump >/dev/null 2>&1; " + "cat /sdcard/window_dump.xml" + ] = [OPEN] + adb.shell_responses["dumpsys package com.app"] = "versionCode=5 versionName=1.0" + + with ( + patch("apkpull.puller.ApkFile") as apk_ctor, + patch("apkpull.bundle.ApksFile") as apks_ctor, + ): + apk_ctor.return_value.labels = {"": "App"} + apk_ctor.return_value.version_code = 5 + apk_ctor.return_value.version_name = "1.0" + apk_ctor.return_value.min_sdk_version = 21 + apk_ctor.return_value.target_sdk_version = 34 + apks_ctor.return_value.package_name = "com.app" + apks_ctor.return_value.version_code = 5 + apks_ctor.return_value.as_dict.return_value = {} + apks_ctor.return_value.splits = [] + configure_apks_create(apks_ctor) + + outcomes = _run_for_device_and_merge( + adb, "fake-1", ["com.app"], tmp_path, output_format=OutputFormat.ZIP + ) + + assert outcomes[0].pulled_files[0].name == "com.app-5.zip" + + +def test_run_for_device_handles_multiple_packages_on_one_device(tmp_path): + """Two already-up-to-date packages on the same device: both get kicked off + and pulled, and both outcomes come back — the core multi-package flow.""" + adb = _adb_with_device(pkg_pm_path="package:/data/app/com.app-x/base.apk\n") + adb.shell_responses["pm path com.other"] = ( + "package:/data/app/com.other-x/base.apk\n" + ) + adb.shell_responses[ + "rm -f /sdcard/window_dump.xml; uiautomator dump >/dev/null 2>&1; " + "cat /sdcard/window_dump.xml" + ] = [OPEN] + adb.shell_responses["dumpsys package com.app"] = "versionCode=5 versionName=1.0" + adb.shell_responses["dumpsys package com.other"] = "versionCode=9 versionName=2.0" + + with ( + patch("apkpull.puller.ApkFile") as apk_ctor, + patch("apkpull.bundle.ApksFile") as apks_ctor, + ): + apk_ctor.return_value.labels = {"": "App"} + apk_ctor.return_value.version_name = "1.0" + apk_ctor.return_value.min_sdk_version = 21 + apk_ctor.return_value.target_sdk_version = 34 + apk_ctor.return_value.version_code = 5 + apks_ctor.return_value.package_name = "com.app" + apks_ctor.return_value.version_code = 5 + apks_ctor.return_value.as_dict.return_value = {} + apks_ctor.return_value.splits = [] + configure_apks_create(apks_ctor) + + outcomes = _run_for_device_and_merge( + adb, "fake-1", ["com.app", "com.other"], tmp_path + ) + + assert {o.package for o in outcomes} == {"com.app", "com.other"} + assert all(o.status == Status.ALREADY_UP_TO_DATE for o in outcomes) + assert all(o.ok for o in outcomes) + + +def test_run_for_device_reports_locked_stage_for_a_locked_device(): + """A locked device blocks the whole device, not one package -- LOCKED + must broadcast to every package on it, like CONNECTING does.""" + adb = _adb_with_device() + adb.shell_responses["dumpsys window"] = ( + "mShowingDream=true mDreamingLockscreen=true\n" + ) + events: list[ProgressEvent] = [] + + run_for_device( + adb, + "fake-1", + ["com.app", "com.other"], + None, + automation_config=AutomationConfig(unlock_timeout=0.01, unlock_poll_interval=0), + on_progress=events.append, + ) + + locked = [(e.device_id, e.package) for e in events if e.stage == Stage.LOCKED] + assert set(locked) == {("fake-1", "com.app"), ("fake-1", "com.other")} + + +def test_run_for_device_distinguishes_downloading_from_updating(): + """DOWNLOADING's detail must say which is actually happening -- an update + in progress looks nothing like a fresh install to someone watching.""" + adb = _adb_with_device(pkg_pm_path="package:/data/app/com.app-x/base.apk\n") + adb.shell_responses[ + "rm -f /sdcard/window_dump.xml; uiautomator dump >/dev/null 2>&1; " + "cat /sdcard/window_dump.xml" + ] = [make_dump(("Update", (0, 0, 100, 50)))] + adb.shell_responses["dumpsys package com.app"] = "versionCode=5 versionName=1.0" + events: list[ProgressEvent] = [] + + # The update never actually resolves (static FakeAdb responses) -- bound + # the completion-pass poll loop so the test doesn't wait out the full + # default 300s download_timeout; the DOWNLOADING event under test already + # fired during the kickoff pass, before that loop is ever reached. + run_for_device( + adb, + "fake-1", + ["com.app"], + None, + download_timeout=0.01, + download_retries=0, + on_progress=events.append, + ) + + downloading = [e for e in events if e.stage == Stage.DOWNLOADING] + assert len(downloading) == 1 + assert downloading[0].detail == "update" + + +def test_run_for_device_restores_stay_on_setting_even_when_automation_fails(): + """stay_on is bumped to 7 before automation starts and must be restored in + a `finally`, even when automation raises (e.g. a paid-app screen).""" + adb = _adb_with_device(pkg_pm_path="") # not installed -> takes the install path + adb.shell_responses["settings get global stay_on_while_plugged_in"] = "3\n" + adb.shell_responses[ + "rm -f /sdcard/window_dump.xml; uiautomator dump >/dev/null 2>&1; " + "cat /sdcard/window_dump.xml" + ] = [make_dump(("$4.99", (0, 0, 10, 10)))] + + outcomes, _pending = run_for_device(adb, "fake-1", ["com.app"], None) + + assert outcomes[0].status == Status.ERROR + assert adb.shell_log.count("settings put global stay_on_while_plugged_in 7") == 1 + assert adb.shell_log[-1] == "settings put global stay_on_while_plugged_in 3" + + +def test_run_for_device_skips_stay_on_setting_when_keep_screen_on_false(tmp_path): + adb = _adb_with_device(pkg_pm_path="package:/data/app/com.app-x/base.apk\n") + adb.shell_responses[ + "rm -f /sdcard/window_dump.xml; uiautomator dump >/dev/null 2>&1; " + "cat /sdcard/window_dump.xml" + ] = [OPEN] + adb.shell_responses["dumpsys package com.app"] = "versionCode=5 versionName=1.0" + + with ( + patch("apkpull.puller.ApkFile") as apk_ctor, + patch("apkpull.bundle.ApksFile") as apks_ctor, + ): + apk_ctor.return_value.labels = {"": "App"} + apk_ctor.return_value.version_code = 5 + apk_ctor.return_value.version_name = "1.0" + apk_ctor.return_value.min_sdk_version = 21 + apk_ctor.return_value.target_sdk_version = 34 + apks_ctor.return_value.package_name = "com.app" + apks_ctor.return_value.version_code = 5 + apks_ctor.return_value.as_dict.return_value = {} + apks_ctor.return_value.splits = [] + configure_apks_create(apks_ctor) + + outcomes = _run_for_device_and_merge( + adb, "fake-1", ["com.app"], tmp_path, keep_screen_on=False + ) + + assert all(o.ok for o in outcomes) + assert not any("stay_on_while_plugged_in" in cmd for cmd in adb.shell_log) + + +INSTALL = make_dump(("Install", (0, 0, 100, 50))) + + +def test_run_for_device_reports_timeout_when_download_never_finishes(): + """No retries left: a download that never completes must be reported as an + error instead of polling forever.""" + adb = _adb_with_device(pkg_pm_path="") # never becomes installed + adb.shell_responses[ + "rm -f /sdcard/window_dump.xml; uiautomator dump >/dev/null 2>&1; " + "cat /sdcard/window_dump.xml" + ] = [INSTALL] + + outcomes, _pending = run_for_device( + adb, + "fake-1", + ["com.app"], + None, + automation_config=AutomationConfig(poll_interval=0), + download_timeout=1e-9, + download_retries=0, + ) + + assert len(outcomes) == 1 + assert outcomes[0].status == Status.ERROR + assert "did not finish" in outcomes[0].error + + +def test_run_for_device_retries_download_after_timeout_then_succeeds(tmp_path): + """One retry left: a timed-out download restarts the kickoff flow and + succeeds once the retry's install actually finishes.""" + adb = _adb_with_device(pkg_pm_path="") + adb.shell_responses[ + "rm -f /sdcard/window_dump.xml; uiautomator dump >/dev/null 2>&1; " + "cat /sdcard/window_dump.xml" + ] = [INSTALL, INSTALL] + # is_installed() is polled 4 times before it finally succeeds: once during + # the initial kickoff, once right after the initial tap, once in the + # completion pass (times out), and once right after the retry's tap. + adb.shell_responses["pm path com.app"] = [ + "", + "", + "", + "", + "package:/data/app/com.app-x/base.apk\n", + ] + adb.shell_responses["dumpsys package com.app"] = "versionCode=5 versionName=1.0" + + with ( + patch("apkpull.puller.ApkFile") as apk_ctor, + patch("apkpull.bundle.ApksFile") as apks_ctor, + ): + apk_ctor.return_value.labels = {"": "App"} + apk_ctor.return_value.version_code = 5 + apk_ctor.return_value.version_name = "1.0" + apk_ctor.return_value.min_sdk_version = 21 + apk_ctor.return_value.target_sdk_version = 34 + apks_ctor.return_value.package_name = "com.app" + apks_ctor.return_value.version_code = 5 + apks_ctor.return_value.as_dict.return_value = {} + apks_ctor.return_value.splits = [] + configure_apks_create(apks_ctor) + + outcomes = _run_for_device_and_merge( + adb, + "fake-1", + ["com.app"], + tmp_path, + automation_config=AutomationConfig(poll_interval=0), + download_timeout=1e-9, + download_retries=1, + ) + + assert len(outcomes) == 1 + assert outcomes[0].ok, outcomes[0].error + assert outcomes[0].status == Status.INSTALLED + + +# -- run() (multi-device fan-out) ------------------------------------------ + + +def test_run_raises_when_no_devices(): + with patch("apkpull.orchestrator.AdbClient") as adb_ctor: + adb_ctor.return_value.list_devices.return_value = [] + with pytest.raises(NoDevicesFoundError): + run("com.app", None, skip_existence_check=True) + + +def test_run_warns_when_devices_share_a_play_relevant_fingerprint(caplog): + infos = { + "dev-1": DeviceInfo( + device_id="dev-1", + model="Pixel A", + abi="arm64-v8a", + lang="en-US", + langs=frozenset({"en"}), + sdk=34, + density=420, + density_bucket="xxhdpi", + ), + "dev-2": DeviceInfo( + device_id="dev-2", + model="Pixel B", + abi="arm64-v8a", + lang="en-US", + langs=frozenset({"en"}), + sdk=34, + density=420, + density_bucket="xxhdpi", + ), + "dev-3": DeviceInfo( + device_id="dev-3", + model="Different", + abi="x86_64", + lang="en-US", + langs=frozenset({"en"}), + sdk=34, + density=420, + density_bucket="xxhdpi", + ), + } + + class _FakeDevice: + def __init__(self, adb, device_id): + self.device_id = device_id + + def ensure_connected(self): + pass + + def info(self): + return infos[self.device_id] + + with ( + patch("apkpull.orchestrator.AdbClient") as adb_ctor, + patch("apkpull.orchestrator.run_for_device") as run_for_device_mock, + patch("apkpull.orchestrator.Device", _FakeDevice), + caplog.at_level("WARNING", logger="apkpull.orchestrator"), + ): + adb_ctor.return_value.list_devices.return_value = list(infos) + run_for_device_mock.return_value = ([], []) + run("com.app", None, skip_existence_check=True) + + warnings = [r.message for r in caplog.records if r.levelname == "WARNING"] + assert len(warnings) == 1 + assert "Pixel A" in warnings[0] + assert "Pixel B" in warnings[0] + assert "Different" not in warnings[0] + + +def test_run_warns_for_same_density_bucket_despite_different_raw_density(caplog): + """Two devices reporting different raw dpi (420 vs 439) that round to the + same Android density bucket (xxhdpi) will get an identical dpi split from + Play — so this must still warn as a duplicate, not be missed because the + raw numbers don't match.""" + infos = { + "dev-1": DeviceInfo( + device_id="dev-1", + model="Pixel A", + abi="arm64-v8a", + langs=frozenset({"en"}), + sdk=34, + density=420, + density_bucket="xxhdpi", + ), + "dev-2": DeviceInfo( + device_id="dev-2", + model="Pixel B", + abi="arm64-v8a", + langs=frozenset({"en"}), + sdk=34, + density=439, + density_bucket="xxhdpi", + ), + } + + class _FakeDevice: + def __init__(self, adb, device_id): + self.device_id = device_id + + def ensure_connected(self): + pass + + def info(self): + return infos[self.device_id] + + with ( + patch("apkpull.orchestrator.AdbClient") as adb_ctor, + patch("apkpull.orchestrator.run_for_device") as run_for_device_mock, + patch("apkpull.orchestrator.Device", _FakeDevice), + caplog.at_level("WARNING", logger="apkpull.orchestrator"), + ): + adb_ctor.return_value.list_devices.return_value = list(infos) + run_for_device_mock.return_value = ([], []) + run("com.app", None, skip_existence_check=True) + + warnings = [r.message for r in caplog.records if r.levelname == "WARNING"] + assert len(warnings) == 1 + assert "look identical" in warnings[0] + assert "xxhdpi" in warnings[0] + + +def test_run_skips_duplicate_check_entirely_when_requested(caplog): + """skip_duplicate_check must not just filter the warning out — it should skip + the check's device.info() calls altogether, since they cost a real adb + round-trip per device.""" + with ( + patch("apkpull.orchestrator.AdbClient") as adb_ctor, + patch("apkpull.orchestrator.run_for_device") as run_for_device_mock, + patch("apkpull.orchestrator.Device") as device_ctor, + caplog.at_level("WARNING", logger="apkpull.orchestrator"), + ): + adb_ctor.return_value.list_devices.return_value = ["dev-1", "dev-2"] + run_for_device_mock.return_value = ([], []) + run("com.app", None, skip_existence_check=True, skip_duplicate_check=True) + + device_ctor.assert_not_called() + assert not [r for r in caplog.records if r.levelname == "WARNING"] + + +def test_run_does_not_warn_for_a_single_device(caplog): + with ( + patch("apkpull.orchestrator.AdbClient") as adb_ctor, + patch("apkpull.orchestrator.run_for_device") as run_for_device_mock, + caplog.at_level("WARNING", logger="apkpull.orchestrator"), + ): + adb_ctor.return_value.list_devices.return_value = ["dev-1"] + run_for_device_mock.return_value = ([], []) + run("com.app", None, skip_existence_check=True) + + assert not [r for r in caplog.records if r.levelname == "WARNING"] + + +def test_run_warns_to_consolidate_languages_when_only_langs_differ(caplog): + """Same hardware (abi/density/sdk), different configured languages: not a + full duplicate (they'd pull different language splits), but still worth + flagging — the shared base/abi/density splits get redundantly re-pulled + when one device configured with every language would do.""" + infos = { + "dev-1": DeviceInfo( + device_id="dev-1", + model="Pixel A", + abi="arm64-v8a", + langs=frozenset({"en"}), + sdk=34, + density=420, + density_bucket="xxhdpi", + ), + "dev-2": DeviceInfo( + device_id="dev-2", + model="Pixel B", + abi="arm64-v8a", + langs=frozenset({"en", "fr"}), + sdk=34, + density=420, + density_bucket="xxhdpi", + ), + } + + class _FakeDevice: + def __init__(self, adb, device_id): + self.device_id = device_id + + def ensure_connected(self): + pass + + def info(self): + return infos[self.device_id] + + with ( + patch("apkpull.orchestrator.AdbClient") as adb_ctor, + patch("apkpull.orchestrator.run_for_device") as run_for_device_mock, + patch("apkpull.orchestrator.Device", _FakeDevice), + caplog.at_level("WARNING", logger="apkpull.orchestrator"), + ): + adb_ctor.return_value.list_devices.return_value = list(infos) + run_for_device_mock.return_value = ([], []) + run("com.app", None, skip_existence_check=True) + + warnings = [r.message for r in caplog.records if r.levelname == "WARNING"] + assert len(warnings) == 1 + assert "Pixel A (en)" in warnings[0] + assert "Pixel B (en,fr)" in warnings[0] + assert "en,fr" in warnings[0] # the recommended union to configure on one device + assert "look identical" not in warnings[0] + + +def test_run_accepts_a_single_package_string(): + """Library ergonomics: `run("com.app", ...)` still works, normalized to + a one-element list internally — same call as before this feature.""" + with ( + patch("apkpull.orchestrator.AdbClient") as adb_ctor, + patch("apkpull.orchestrator.run_for_device") as run_for_device_mock, + ): + adb_ctor.return_value.list_devices.return_value = ["dev-1"] + run_for_device_mock.return_value = ([], []) + run("com.app", None, skip_existence_check=True) + + _adb, _device_id, packages_arg, _dest_root = run_for_device_mock.call_args.args + assert packages_arg == ["com.app"] + + +def test_run_reraises_keyboard_interrupt_instead_of_swallowing_it(tmp_path): + """A library caller must be able to catch KeyboardInterrupt itself -- + run() shouldn't silently convert it into a partial RunSummary.""" + fake_pool = MagicMock() + fake_pool.submit.return_value = MagicMock() + + with ( + patch("apkpull.orchestrator.AdbClient") as adb_ctor, + patch("apkpull.orchestrator.ThreadPoolExecutor", return_value=fake_pool), + patch("apkpull.orchestrator.wait", side_effect=KeyboardInterrupt), + ): + adb_ctor.return_value.list_devices.return_value = ["dev-1"] + with pytest.raises(KeyboardInterrupt): + run("com.app", tmp_path, skip_existence_check=True) + + +def test_run_cancels_not_yet_started_devices_on_keyboard_interrupt(tmp_path, caplog): + """Confirmed hands-on that an untimed wait on thread-pool futures doesn't + get interrupted by SIGINT at all -- run() must poll with a timeout + instead, and on interrupt stop launching new device work (cancel() + only actually takes effect for futures that haven't started) and shut + the pool down without blocking on already-running ones.""" + futures_not_started = [MagicMock(), MagicMock()] + + fake_pool = MagicMock() + fake_pool.submit.side_effect = futures_not_started + + # First wait() call is "interrupted" (Ctrl+C); the second simulates the + # real, expected follow-up once cancel() has synchronously marked both + # not-yet-started futures CANCELLED — which wait() reports as done. + with ( + patch("apkpull.orchestrator.AdbClient") as adb_ctor, + patch("apkpull.orchestrator.ThreadPoolExecutor", return_value=fake_pool), + patch( + "apkpull.orchestrator.wait", + side_effect=[KeyboardInterrupt, (set(futures_not_started), set())], + ), + caplog.at_level("WARNING", logger="apkpull.orchestrator"), + ): + adb_ctor.return_value.list_devices.return_value = ["dev-1", "dev-2"] + with pytest.raises(KeyboardInterrupt): + run("com.app", tmp_path, skip_existence_check=True) + + for future in futures_not_started: + future.cancel.assert_called_once() + fake_pool.shutdown.assert_called_with(wait=False) + assert any("Interrupted" in r.message for r in caplog.records) + + +def test_run_reports_queued_for_every_device_package_pair_upfront(tmp_path): + """QUEUED must fire for the full (device, package) grid before any device + thread starts, so a renderer can show the complete worklist immediately + instead of rows appearing one at a time as threads happen to get + scheduled.""" + events: list[ProgressEvent] = [] + + with ( + patch("apkpull.orchestrator.AdbClient") as adb_ctor, + patch("apkpull.orchestrator.run_for_device") as run_for_device_mock, + ): + adb_ctor.return_value.list_devices.return_value = ["dev-1", "dev-2"] + run_for_device_mock.return_value = ([], []) + run( + ["com.app", "com.other"], + tmp_path, + skip_existence_check=True, + on_progress=events.append, + ) + + queued = [(e.device_id, e.package) for e in events if e.stage == Stage.QUEUED] + assert set(queued) == { + ("dev-1", "com.app"), + ("dev-1", "com.other"), + ("dev-2", "com.app"), + ("dev-2", "com.other"), + } + + +def test_run_fans_out_to_every_device_concurrently(tmp_path): + def make_fake_outcomes(device_id, *_a, **_k): + from apkpull.models import DeviceInfo, DeviceOutcome + + return [ + DeviceOutcome( + device=DeviceInfo(device_id=device_id), + package="com.app", + status=Status.INSTALLED, + ) + ] + + with ( + patch("apkpull.orchestrator.AdbClient") as adb_ctor, + patch("apkpull.orchestrator.run_for_device") as run_for_device_mock, + ): + adb_ctor.return_value.list_devices.return_value = ["dev-1", "dev-2", "dev-3"] + run_for_device_mock.side_effect = lambda adb, device_id, *a, **k: ( + make_fake_outcomes(device_id), + [], + ) + + summary = run("com.app", tmp_path, skip_existence_check=True) + + assert summary.total == 3 + assert summary.successful == 3 + assert summary.exit_code == 0 + assert {o.device.device_id for o in summary.outcomes} == {"dev-1", "dev-2", "dev-3"} + + +def test_run_fans_out_multiple_packages_to_every_device(tmp_path): + def make_fake_outcomes(device_id, packages, *_a, **_k): + from apkpull.models import DeviceInfo, DeviceOutcome + + return [ + DeviceOutcome( + device=DeviceInfo(device_id=device_id), + package=package, + status=Status.INSTALLED, + ) + for package in packages + ] + + with ( + patch("apkpull.orchestrator.AdbClient") as adb_ctor, + patch("apkpull.orchestrator.run_for_device") as run_for_device_mock, + ): + adb_ctor.return_value.list_devices.return_value = ["dev-1", "dev-2"] + run_for_device_mock.side_effect = lambda adb, device_id, packages, *a, **k: ( + make_fake_outcomes(device_id, packages), + [], + ) + + summary = run(["com.app", "com.other"], tmp_path, skip_existence_check=True) + + assert summary.total == 4 # 2 devices x 2 packages + assert {(o.device.device_id, o.package) for o in summary.outcomes} == { + ("dev-1", "com.app"), + ("dev-1", "com.other"), + ("dev-2", "com.app"), + ("dev-2", "com.other"), + } + + +def test_run_respects_explicit_device_ids(): + with ( + patch("apkpull.orchestrator.AdbClient") as adb_ctor, + patch("apkpull.orchestrator.run_for_device") as run_for_device_mock, + ): + from apkpull.models import DeviceInfo, DeviceOutcome + + run_for_device_mock.return_value = ( + [ + DeviceOutcome( + device=DeviceInfo(device_id="only-me"), + package="com.app", + status=Status.INSTALLED, + ) + ], + [], + ) + run("com.app", None, device_ids=["only-me"], skip_existence_check=True) + + adb_ctor.return_value.list_devices.assert_not_called() + + +# -- cross-device merge (run()'s post-drain _merge_pending_contributions) -------------------- + + +def test_run_merges_distinct_splits_from_two_devices_into_one_bundle( + tmp_path, force_splits +): + """The primary end-to-end regression test: two devices with genuinely + different splits (arm64/en vs x86_64/he) must both end up in the final + bundle, not just whichever device's thread finished first.""" + + # Built lazily, inside the mocked run_for_device -- run() wipes any + # pre-existing .apkpull-staging at startup (crash-recovery, see + # test_run_removes_stale_staging_directory_... below), which would + # otherwise delete these fixtures before the merge phase ever sees them. + def side_effect(adb, device_id, packages, dest_root, **kwargs): + if device_id == "dev-a": + c = _make_contribution( + dest_root, + "dev-a", + "com.app", + 100, + splits=["config.arm64_v8a.apk", "config.en.apk"], + ) + else: + c = _make_contribution( + dest_root, + "dev-b", + "com.app", + 100, + splits=["config.x86_64.apk", "config.he.apk"], + ) + return [c.outcome], [c] + + with ( + patch("apkpull.orchestrator.AdbClient") as adb_ctor, + patch("apkpull.orchestrator.run_for_device") as run_for_device_mock, + force_splits(where=lambda p: p.name != "base.apk"), + ): + adb_ctor.return_value.list_devices.return_value = ["dev-a", "dev-b"] + run_for_device_mock.side_effect = side_effect + summary = run( + "com.app", + tmp_path, + skip_existence_check=True, + skip_duplicate_check=True, + verify=False, + ) + + target = tmp_path / "com.app-100.apks" + assert target.exists() + with zipfile.ZipFile(target) as zf: + names = set(zf.namelist()) + assert { + "base.apk", + "config.arm64_v8a.apk", + "config.en.apk", + "config.x86_64.apk", + "config.he.apk", + } <= names + assert all(o.pulled_files for o in summary.outcomes) + assert all(o.destination == target for o in summary.outcomes) + + +def test_run_forwards_full_manifest_to_build_merged_bundle(tmp_path): + def side_effect(adb, device_id, packages, dest_root, **kwargs): + c = _make_contribution( + dest_root, "dev-a", "com.app", 100, splits=["config.en.apk"] + ) + return [c.outcome], [c] + + with ( + patch("apkpull.orchestrator.AdbClient") as adb_ctor, + patch("apkpull.orchestrator.run_for_device") as run_for_device_mock, + patch( + "apkpull.orchestrator.build_merged_bundle", + return_value=(tmp_path / "com.app-100.apks", []), + ) as build_mock, + ): + adb_ctor.return_value.list_devices.return_value = ["dev-a"] + run_for_device_mock.side_effect = side_effect + run("com.app", tmp_path, skip_existence_check=True, full_manifest=True) + + assert build_mock.call_args.kwargs["full"] is True + + +def test_run_builds_separate_bundles_for_devices_reporting_different_version_codes( + tmp_path, caplog, force_splits +): + def side_effect(adb, device_id, packages, dest_root, **kwargs): + if device_id == "dev-a": + c = _make_contribution( + dest_root, "dev-a", "com.app", 100, splits=["config.en.apk"] + ) + else: + c = _make_contribution( + dest_root, "dev-b", "com.app", 200, splits=["config.en.apk"] + ) + return [c.outcome], [c] + + with ( + patch("apkpull.orchestrator.AdbClient") as adb_ctor, + patch("apkpull.orchestrator.run_for_device") as run_for_device_mock, + caplog.at_level("WARNING", logger="apkpull.orchestrator"), + force_splits(where=lambda p: p.name != "base.apk"), + ): + adb_ctor.return_value.list_devices.return_value = ["dev-a", "dev-b"] + run_for_device_mock.side_effect = side_effect + run( + "com.app", + tmp_path, + skip_existence_check=True, + skip_duplicate_check=True, + verify=False, + ) + + assert (tmp_path / "com.app-100.apks").exists() + assert (tmp_path / "com.app-200.apks").exists() + warnings = [r.message for r in caplog.records if r.levelname == "WARNING"] + assert any("different version codes" in w for w in warnings) + + +def test_run_warns_and_still_merges_when_one_of_three_devices_fails( + tmp_path, caplog, force_splits +): + def side_effect(adb, device_id, packages, dest_root, **kwargs): + if device_id == "dev-a": + c = _make_contribution( + dest_root, "dev-a", "com.app", 100, splits=["config.en.apk"] + ) + return [c.outcome], [c] + if device_id == "dev-b": + c = _make_contribution( + dest_root, "dev-b", "com.app", 100, splits=["config.he.apk"] + ) + return [c.outcome], [c] + failed_outcome = DeviceOutcome( + device=DeviceInfo(device_id="dev-c", model="dev-c"), + package="com.app", + status=Status.ERROR, + error="You must be logged in to a Google account.", + ) + return [failed_outcome], [] + + with ( + patch("apkpull.orchestrator.AdbClient") as adb_ctor, + patch("apkpull.orchestrator.run_for_device") as run_for_device_mock, + caplog.at_level("WARNING", logger="apkpull.orchestrator"), + force_splits(where=lambda p: p.name != "base.apk"), + ): + adb_ctor.return_value.list_devices.return_value = ["dev-a", "dev-b", "dev-c"] + run_for_device_mock.side_effect = side_effect + summary = run( + "com.app", + tmp_path, + skip_existence_check=True, + skip_duplicate_check=True, + verify=False, + ) + + target = tmp_path / "com.app-100.apks" + assert target.exists() + with zipfile.ZipFile(target) as zf: + names = set(zf.namelist()) + assert {"config.en.apk", "config.he.apk"} <= names + warnings = [r.message for r in caplog.records if r.levelname == "WARNING"] + assert any("missing from" in w and "dev-c" in w for w in warnings) + assert summary.exit_code == 1 # only dev-c failed + + +def test_run_marks_all_contributing_devices_error_when_merge_itself_fails(tmp_path): + def side_effect(adb, device_id, packages, dest_root, **kwargs): + if device_id == "dev-a": + c = _make_contribution( + dest_root, "dev-a", "com.app", 100, splits=["config.en.apk"] + ) + else: + c = _make_contribution( + dest_root, "dev-b", "com.app", 100, splits=["config.he.apk"] + ) + return [c.outcome], [c] + + with ( + patch("apkpull.orchestrator.AdbClient") as adb_ctor, + patch("apkpull.orchestrator.run_for_device") as run_for_device_mock, + patch( + "apkpull.orchestrator.build_merged_bundle", + side_effect=VerificationError("mismatch"), + ), + ): + adb_ctor.return_value.list_devices.return_value = ["dev-a", "dev-b"] + run_for_device_mock.side_effect = side_effect + summary = run( + "com.app", + tmp_path, + skip_existence_check=True, + skip_duplicate_check=True, + strict_verify=True, + ) + + assert all(o.status == Status.ERROR for o in summary.outcomes) + assert all("own install/update succeeded" in o.error for o in summary.outcomes) + + +def test_run_cleans_up_group_staging_directory_after_a_successful_merge( + tmp_path, force_splits +): + def side_effect(adb, device_id, packages, dest_root, **kwargs): + c = _make_contribution( + dest_root, "dev-a", "com.app", 100, splits=["config.en.apk"] + ) + return [c.outcome], [c] + + with ( + patch("apkpull.orchestrator.AdbClient") as adb_ctor, + patch("apkpull.orchestrator.run_for_device") as run_for_device_mock, + force_splits(where=lambda p: p.name != "base.apk"), + ): + adb_ctor.return_value.list_devices.return_value = ["dev-a"] + run_for_device_mock.side_effect = side_effect + run("com.app", tmp_path, skip_existence_check=True, verify=False) + + assert not (tmp_path / ".apkpull-staging" / "com.app-100").exists() + + +def test_run_cleans_up_group_staging_directory_after_a_failed_merge(tmp_path): + def side_effect(adb, device_id, packages, dest_root, **kwargs): + c = _make_contribution( + dest_root, "dev-a", "com.app", 100, splits=["config.en.apk"] + ) + return [c.outcome], [c] + + with ( + patch("apkpull.orchestrator.AdbClient") as adb_ctor, + patch("apkpull.orchestrator.run_for_device") as run_for_device_mock, + patch( + "apkpull.orchestrator.build_merged_bundle", side_effect=PullError("boom") + ), + ): + adb_ctor.return_value.list_devices.return_value = ["dev-a"] + run_for_device_mock.side_effect = side_effect + run("com.app", tmp_path, skip_existence_check=True) + + assert not (tmp_path / ".apkpull-staging" / "com.app-100").exists() + + +def test_run_removes_stale_staging_directory_left_by_a_previous_crashed_run_at_startup( + tmp_path, +): + stale = tmp_path / ".apkpull-staging" / "com.old-1" / "dev-x" + stale.mkdir(parents=True) + (stale / "base.apk").write_bytes(b"leftover") + + with ( + patch("apkpull.orchestrator.AdbClient") as adb_ctor, + patch("apkpull.orchestrator.run_for_device") as run_for_device_mock, + ): + adb_ctor.return_value.list_devices.return_value = ["dev-1"] + run_for_device_mock.return_value = ([], []) + run("com.app", tmp_path, skip_existence_check=True) + + assert not (tmp_path / ".apkpull-staging").exists() + + +# -- device-info reuse / unexpected-exception handling ---------------------- + + +def test_warn_about_duplicate_devices_returns_resolved_infos_by_device_id(): + infos = { + "dev-1": DeviceInfo(device_id="dev-1", model="Pixel A"), + "dev-2": DeviceInfo(device_id="dev-2", model="Pixel B"), + } + + class _FakeDevice: + def __init__(self, adb, device_id): + self.device_id = device_id + + def ensure_connected(self): + pass + + def info(self): + return infos[self.device_id] + + with patch("apkpull.orchestrator.Device", _FakeDevice): + result = _warn_about_duplicate_devices(MagicMock(), ["dev-1", "dev-2"]) + + assert result == infos + + +def test_run_passes_resolved_device_info_to_run_for_device_avoiding_a_second_fetch(): + """The duplicate-device check (run before the thread pool starts) already + resolved each device's info -- run() should hand that straight to + run_for_device instead of letting it redundantly re-fetch the same + getprop/locale round trip a second time for the same device.""" + infos = { + "dev-1": DeviceInfo(device_id="dev-1", model="Pixel A", abi="arm64-v8a"), + "dev-2": DeviceInfo(device_id="dev-2", model="Pixel B", abi="x86_64"), + } + + class _FakeDevice: + def __init__(self, adb, device_id): + self.device_id = device_id + + def ensure_connected(self): + pass + + def info(self): + return infos[self.device_id] + + with ( + patch("apkpull.orchestrator.AdbClient") as adb_ctor, + patch("apkpull.orchestrator.run_for_device") as run_for_device_mock, + patch("apkpull.orchestrator.Device", _FakeDevice), + ): + adb_ctor.return_value.list_devices.return_value = list(infos) + run_for_device_mock.return_value = ([], []) + run("com.app", None, skip_existence_check=True) + + seen = { + call.args[1]: call.kwargs["device_info"] + for call in run_for_device_mock.call_args_list + } + assert seen == infos + + +def test_run_propagates_unexpected_exception_from_run_for_device(): + """A genuine bug in run_for_device (not a per-device DeviceError, which it + already turns into a normal error outcome rather than raising) must still + surface loudly instead of being silently swallowed.""" + with ( + patch("apkpull.orchestrator.AdbClient") as adb_ctor, + patch("apkpull.orchestrator.run_for_device") as run_for_device_mock, + ): + adb_ctor.return_value.list_devices.return_value = ["dev-1"] + run_for_device_mock.side_effect = RuntimeError("unexpected bug") + with pytest.raises(RuntimeError, match="unexpected bug"): + run("com.app", None, skip_existence_check=True, skip_duplicate_check=True) + + +def test_drain_batch_collects_all_successes_even_when_one_future_raises(): + """`done` from concurrent.futures.wait() is an unordered set -- an + unexpected exception from one device's future must not prevent a + sibling's already-computed result, sitting in the same batch, from being + collected.""" + outcome = DeviceOutcome( + device=DeviceInfo(device_id="dev-1"), + package="com.app", + status=Status.INSTALLED, + ) + good: Future = Future() + good.set_result(([outcome], [])) + bad: Future = Future() + bad.set_exception(RuntimeError("boom")) + + outcomes: list = [] + contributions: list = [] + exceptions = _drain_batch({good, bad}, outcomes, contributions) + + assert outcomes == [outcome] + assert len(exceptions) == 1 + assert isinstance(exceptions[0], RuntimeError) + + +def test_drain_batch_skips_cancelled_futures_without_reporting_them_as_errors(): + cancelled: Future = Future() + cancelled.cancel() + + outcomes: list = [] + contributions: list = [] + exceptions = _drain_batch({cancelled}, outcomes, contributions) + + assert outcomes == [] + assert exceptions == [] diff --git a/tests/test_puller.py b/tests/test_puller.py new file mode 100644 index 0000000..66c9a93 --- /dev/null +++ b/tests/test_puller.py @@ -0,0 +1,719 @@ +import json +import zipfile +from types import SimpleNamespace +from unittest.mock import patch + +import pytest +from apkfile import InvalidApkError + +from apkpull.device import Device +from apkpull.exceptions import DeviceDisconnectedError, PullError, VerificationError +from apkpull.models import FileKind, OutputFormat, PulledFile +from apkpull.puller import Puller, build_merged_bundle, size_of + +from .helpers import FakeAdb + + +def make_puller( + tmp_path, *, pm_path, dumpsys, obb_exists=(), connected=True, device_id="fake-1" +): + adb = FakeAdb() + adb.connected = connected + adb.shell_responses["dumpsys package com.app"] = dumpsys + adb.shell_responses["pm path com.app"] = pm_path + for remote in obb_exists: + adb.shell_responses[f"test -f '{remote}' && echo yes"] = "yes\n" + device = Device(adb, device_id) + return Puller(device, tmp_path), adb + + +def fake_apk( + *, version_code=41, version_name="1.0", min_sdk=21, target_sdk=34, labels=None +): + return SimpleNamespace( + labels=labels or {"": "App"}, + version_code=version_code, + version_name=version_name, + min_sdk_version=min_sdk, + target_sdk_version=target_sdk, + ) + + +def _patched_apk_file(**kwargs): + return patch("apkpull.puller.ApkFile", return_value=fake_apk(**kwargs)) + + +def pull_and_build(puller, package, **kwargs): + """Reconstructs ``pull_package()``'s old all-in-one return shape + (``dest, files, version_code, version_name``) from the new two-step + pull_raw()/build_merged_bundle() pipeline, for tests that don't care + about multi-device merging specifically -- a single contribution merges + trivially to the same result the old single-device path produced.""" + output_format = kwargs.get("output_format", OutputFormat.APKS) + verify = kwargs.get("verify", True) + strict = kwargs.get("strict", False) + full = kwargs.get("full", False) + report = kwargs.get("report") + raw = puller.pull_raw(package, output_format=output_format, report=report) + if raw.already_built: + files = [ + PulledFile( + kind=FileKind.BUNDLE, + name=raw.target.name, + local_path=raw.target, + size_bytes=size_of(raw.target), + already_existed=True, + ), + *raw.obb_pulled_files, + ] + return raw.target, files, raw.version_code, raw.version_name + target, files = build_merged_bundle( + package, + raw.version_code, + [raw], + puller.dest_root, + output_format=output_format, + verify=verify, + strict=strict, + full=full, + ) + return target, files, raw.version_code, raw.version_name + + +# -- basic pulling / bundling (verification disabled — that's covered separately below) ------ + + +def test_single_apk_pull_produces_a_named_bundle(tmp_path): + puller, _adb = make_puller( + tmp_path, + dumpsys="versionCode=41 versionName=3.1.11", + pm_path="package:/data/app/com.app-x/base.apk\n", + ) + with _patched_apk_file(version_code=41, version_name="3.1.11"): + dest, files, vcode, vname = pull_and_build(puller, "com.app", verify=False) + + assert vcode == 41 + assert vname == "3.1.11" + assert len(files) == 1 + bundle = files[0] + assert bundle.kind == FileKind.BUNDLE + assert bundle.name == "com.app-41.apks" + assert bundle.local_path == tmp_path / "com.app-41.apks" + assert dest == bundle.local_path + assert bundle.local_path.is_file() + assert bundle.verified is None # verify=False: never checked + + with zipfile.ZipFile(bundle.local_path) as zf: + assert set(zf.namelist()) == {"base.apk", "meta.sai_v2.json"} + meta = json.loads(zf.read("meta.sai_v2.json")) + # com.app/41 is dumpsys-reported and drives the target *filename* -- + # meta.sai_v2.json's package/version_code come from re-parsing the + # real base.apk bytes FakeAdb staged (politedroid.apk, by default). + assert meta["package"] == "com.politedroid" + assert meta["version_code"] == 4 + + +def test_split_apk_pull_bundles_base_and_splits_into_one_apks_file( + tmp_path, force_splits +): + pm_path = ( + "package:/data/app/com.app-x/base.apk\n" + "package:/data/app/com.app-x/split_config.arm64_v8a.apk\n" + "package:/data/app/com.app-x/split_config.en.apk\n" + ) + puller, _adb = make_puller( + tmp_path, dumpsys="versionCode=451 versionName=1.0", pm_path=pm_path + ) + with ( + _patched_apk_file(version_code=451), + force_splits(where=lambda p: p.name != "base.apk"), + ): + _, files, _, _ = pull_and_build(puller, "com.app", verify=False) + + bundle = files[0] + assert bundle.name == "com.app-451.apks" + with zipfile.ZipFile(bundle.local_path) as zf: + assert set(zf.namelist()) == { + "base.apk", + "config.arm64_v8a.apk", + "config.en.apk", + "meta.sai_v2.json", + } + + +def test_obb_files_pulled_when_present(tmp_path): + puller, _adb = make_puller( + tmp_path, + dumpsys="versionCode=7 versionName=1.0", + pm_path="package:/data/app/com.app-x/base.apk\n", + obb_exists=["/sdcard/Android/obb/com.app/main.7.com.app.obb"], + ) + with _patched_apk_file(version_code=7): + _, files, _, _ = pull_and_build(puller, "com.app", verify=False) + obb_files = [f for f in files if f.kind == FileKind.OBB] + assert len(obb_files) == 1 + assert obb_files[0].name == "main.7.com.app.obb" + assert obb_files[0].local_path == tmp_path / "main.7.com.app.obb" + + +def test_no_obb_files_when_absent(tmp_path): + puller, _adb = make_puller( + tmp_path, + dumpsys="versionCode=7 versionName=1.0", + pm_path="package:/data/app/com.app-x/base.apk\n", + ) + with _patched_apk_file(version_code=7): + _, files, _, _ = pull_and_build(puller, "com.app", verify=False) + assert not any(f.kind == FileKind.OBB for f in files) + + +def test_skips_pull_when_bundle_already_exists(tmp_path): + puller, adb = make_puller( + tmp_path, + dumpsys="versionCode=41 versionName=1.0", + pm_path="package:/data/app/com.app-x/base.apk\n", + ) + existing = tmp_path / "com.app-41.apks" + existing.write_bytes(b"already-here") + + _, files, _, _ = pull_and_build(puller, "com.app", verify=False) + assert files[0].already_existed is True + assert ( + files[0].local_path.read_bytes() == b"already-here" + ) # not overwritten/rebuilt + assert not adb.pulled # adb pull was never invoked + + +def test_raises_when_version_code_unavailable(tmp_path): + puller, _adb = make_puller( + tmp_path, dumpsys="no matching output", pm_path="package:/data/app/base.apk\n" + ) + with pytest.raises(PullError): + pull_and_build(puller, "com.app", verify=False) + + +def test_raises_when_no_apk_paths(tmp_path): + puller, _adb = make_puller( + tmp_path, dumpsys="versionCode=1 versionName=1.0", pm_path="" + ) + with pytest.raises(PullError): + pull_and_build(puller, "com.app", verify=False) + + +def test_raises_when_base_apk_fails_to_parse(tmp_path): + puller, _adb = make_puller( + tmp_path, + dumpsys="versionCode=1 versionName=1.0", + pm_path="package:/data/app/com.app-x/base.apk\n", + ) + with ( + patch("apkpull.puller.ApkFile", side_effect=InvalidApkError("corrupt")), + pytest.raises(PullError), + ): + pull_and_build(puller, "com.app", verify=False) + + +def test_raises_when_device_disconnected_before_pulling(tmp_path): + puller, _adb = make_puller( + tmp_path, + dumpsys="versionCode=1 versionName=1.0", + pm_path="package:/data/app/com.app-x/base.apk\n", + connected=False, + ) + with pytest.raises(DeviceDisconnectedError): + pull_and_build(puller, "com.app", verify=False) + + +# -- pull_raw() in isolation ----------------------------------------------------------------- + + +def test_pull_raw_stages_base_and_splits_without_building_a_bundle(tmp_path): + pm_path = ( + "package:/data/app/com.app-x/base.apk\n" + "package:/data/app/com.app-x/split_config.en.apk\n" + ) + puller, _adb = make_puller( + tmp_path, dumpsys="versionCode=41 versionName=1.0", pm_path=pm_path + ) + with _patched_apk_file(version_code=41): + raw = puller.pull_raw("com.app") + + assert raw.already_built is False + assert not raw.target.exists() # nothing built yet + assert ( + raw.base_path + == tmp_path / ".apkpull-staging" / "com.app-41" / "fake-1" / "base.apk" + ) + assert raw.base_path.is_file() + assert [p.name for p in raw.split_paths] == ["config.en.apk"] + + +def test_pull_raw_short_circuits_when_target_already_exists(tmp_path): + puller, adb = make_puller( + tmp_path, + dumpsys="versionCode=41 versionName=1.0", + pm_path="package:/data/app/com.app-x/base.apk\n", + ) + (tmp_path / "com.app-41.apks").write_bytes(b"already-here") + + raw = puller.pull_raw("com.app") + assert raw.already_built is True + assert not adb.pulled + + +def test_pull_raw_still_pulls_missing_obb_when_target_already_exists(tmp_path): + puller, _adb = make_puller( + tmp_path, + dumpsys="versionCode=7 versionName=1.0", + pm_path="package:/data/app/com.app-x/base.apk\n", + obb_exists=["/sdcard/Android/obb/com.app/main.7.com.app.obb"], + ) + (tmp_path / "com.app-7.apks").write_bytes(b"already-here") + + raw = puller.pull_raw("com.app") + assert raw.already_built is True + assert len(raw.obb_pulled_files) == 1 + assert raw.obb_pulled_files[0].local_path == tmp_path / "main.7.com.app.obb" + assert raw.obb_pulled_files[0].local_path.is_file() + + +def test_pull_raw_stages_obb_into_staging_when_bundle_not_yet_built(tmp_path): + puller, _adb = make_puller( + tmp_path, + dumpsys="versionCode=7 versionName=1.0", + pm_path="package:/data/app/com.app-x/base.apk\n", + obb_exists=["/sdcard/Android/obb/com.app/main.7.com.app.obb"], + ) + with _patched_apk_file(version_code=7): + raw = puller.pull_raw("com.app") + + assert raw.already_built is False + assert len(raw.obb_paths) == 1 + assert raw.obb_paths[0].parent == raw.staging_dir + assert not (tmp_path / "main.7.com.app.obb").exists() # not placed yet + + +# -- build_merged_bundle() -- the core cross-device merge surface --------------------------- + + +def test_build_merged_bundle_unions_distinct_splits_from_two_devices( + tmp_path, force_splits +): + """The direct regression test for the reported bug: two devices with + genuinely different splits (arm64/en vs x86_64/he) must both end up in + the final bundle, not just whichever pulled first.""" + puller_a, _adb_a = make_puller( + tmp_path, + device_id="dev-a", + dumpsys="versionCode=100 versionName=1.0", + pm_path=( + "package:/data/app/x/base.apk\n" + "package:/data/app/x/split_config.arm64_v8a.apk\n" + "package:/data/app/x/split_config.en.apk\n" + ), + ) + puller_b, _adb_b = make_puller( + tmp_path, + device_id="dev-b", + dumpsys="versionCode=100 versionName=1.0", + pm_path=( + "package:/data/app/x/base.apk\n" + "package:/data/app/x/split_config.x86_64.apk\n" + "package:/data/app/x/split_config.he.apk\n" + ), + ) + with _patched_apk_file(version_code=100): + raw_a = puller_a.pull_raw("com.app") + raw_b = puller_b.pull_raw("com.app") + + with force_splits(where=lambda p: p.name != "base.apk"): + target, files = build_merged_bundle( + "com.app", + 100, + sorted([raw_a, raw_b], key=lambda r: r.device_id), + tmp_path, + verify=False, + ) + + bundle = next(f for f in files if f.kind == FileKind.BUNDLE) + assert bundle.local_path == target + with zipfile.ZipFile(target) as zf: + names = set(zf.namelist()) + assert { + "base.apk", + "config.arm64_v8a.apk", + "config.en.apk", + "config.x86_64.apk", + "config.he.apk", + "meta.sai_v2.json", + } <= names + + +def test_build_merged_bundle_dedupes_identical_split_filenames_keeping_first_by_device_id( + tmp_path, force_splits, politedroid_bytes, test_debug_bytes +): + puller_a, adb_a = make_puller( + tmp_path, + device_id="dev-a", + dumpsys="versionCode=100 versionName=1.0", + pm_path=( + "package:/data/app/x/base.apk\npackage:/data/app/x/split_config.en.apk\n" + ), + ) + puller_b, adb_b = make_puller( + tmp_path, + device_id="dev-b", + dumpsys="versionCode=100 versionName=1.0", + pm_path=( + "package:/data/app/x/base.apk\npackage:/data/app/x/split_config.en.apk\n" + ), + ) + # Shouldn't happen for a real device pair (same split filename should mean + # identical content for the same version_code) -- using two different real + # fixture apks here is only to prove which contribution wins the tiebreak + # (the winner's actual split bytes end up in the bundle). + adb_a.split_apk_bytes = politedroid_bytes + adb_b.split_apk_bytes = test_debug_bytes + with _patched_apk_file(version_code=100): + raw_a = puller_a.pull_raw("com.app") + raw_b = puller_b.pull_raw("com.app") + + with force_splits(where=lambda p: p.name != "base.apk"): + target, _files = build_merged_bundle( + "com.app", + 100, + sorted([raw_a, raw_b], key=lambda r: r.device_id), + tmp_path, + verify=False, + ) + with zipfile.ZipFile(target) as zf: + assert zf.namelist().count("config.en.apk") == 1 + assert zf.read("config.en.apk") == politedroid_bytes # dev-a sorts first + + +def test_build_merged_bundle_uses_first_devices_base_apk_and_metadata( + tmp_path, politedroid_bytes, test_debug_bytes +): + """ApksFile.create() derives the whole manifest by re-parsing whichever + contribution's base_path is primary -- there's no separate metadata + channel to inject fake per-device values into anymore, so this proves + the real post-refactor behavior with two genuinely different base apks: + dev-a's actual base-apk content is what ends up in the bundle.""" + puller_a, adb_a = make_puller( + tmp_path, + device_id="dev-a", + dumpsys="versionCode=100 versionName=1.0", + pm_path="package:/data/app/x/base.apk\n", + ) + puller_b, adb_b = make_puller( + tmp_path, + device_id="dev-b", + dumpsys="versionCode=100 versionName=1.0", + pm_path="package:/data/app/x/base.apk\n", + ) + adb_a.base_apk_bytes = politedroid_bytes + adb_b.base_apk_bytes = test_debug_bytes + raw_a = puller_a.pull_raw("com.app") + raw_b = puller_b.pull_raw("com.app") + + target, _files = build_merged_bundle( + "com.app", + 100, + sorted([raw_a, raw_b], key=lambda r: r.device_id), + tmp_path, + verify=False, + ) + with zipfile.ZipFile(target) as zf: + meta = json.loads(zf.read("meta.sai_v2.json")) + # dev-a sorts first, so its base apk's real content wins + assert meta["label"] == "Polite Droid" + assert meta["package"] == "com.politedroid" + + +def test_build_merged_bundle_unions_obbs_from_multiple_devices_without_duplicate_copy( + tmp_path, +): + puller_a, _adb_a = make_puller( + tmp_path, + device_id="dev-a", + dumpsys="versionCode=7 versionName=1.0", + pm_path="package:/data/app/x/base.apk\n", + obb_exists=["/sdcard/Android/obb/com.app/main.7.com.app.obb"], + ) + puller_b, _adb_b = make_puller( + tmp_path, + device_id="dev-b", + dumpsys="versionCode=7 versionName=1.0", + pm_path="package:/data/app/x/base.apk\n", + obb_exists=["/sdcard/Android/obb/com.app/main.7.com.app.obb"], + ) + with _patched_apk_file(version_code=7): + raw_a = puller_a.pull_raw("com.app") + raw_b = puller_b.pull_raw("com.app") + + _target, files = build_merged_bundle( + "com.app", + 7, + sorted([raw_a, raw_b], key=lambda r: r.device_id), + tmp_path, + verify=False, + ) + obb_files = [f for f in files if f.kind == FileKind.OBB] + assert len(obb_files) == 1 + assert obb_files[0].local_path == tmp_path / "main.7.com.app.obb" + + +def test_build_merged_bundle_short_circuits_when_target_already_exists(tmp_path): + puller, _adb = make_puller( + tmp_path, + dumpsys="versionCode=41 versionName=1.0", + pm_path="package:/data/app/x/base.apk\n", + ) + with _patched_apk_file(version_code=41): + raw = puller.pull_raw("com.app") + # A *different, concurrently running* apkpull process finished first -- + # not a sibling device from this same run (that can't happen: merging + # only ever happens after every targeted device has already resolved). + (tmp_path / "com.app-41.apks").write_bytes(b"already-here") + + with patch("apkpull.puller.build_apks_bundle") as mock_build: + target, files = build_merged_bundle( + "com.app", 41, [raw], tmp_path, verify=False + ) + mock_build.assert_not_called() + assert files[0].already_existed is True + assert target.read_bytes() == b"already-here" + + +def test_build_merged_bundle_raises_verification_error_in_strict_mode_and_leaves_no_target( + tmp_path, +): + puller, _adb = make_puller( + tmp_path, + dumpsys="versionCode=41 versionName=1.0", + pm_path="package:/data/app/x/base.apk\n", + ) + with _patched_apk_file(version_code=41): + raw = puller.pull_raw("com.app") + + with ( + patch( + "apkpull.puller.verify_bundle", side_effect=VerificationError("mismatch") + ), + pytest.raises(VerificationError), + ): + build_merged_bundle("com.app", 41, [raw], tmp_path, verify=True, strict=True) + assert not (tmp_path / "com.app-41.apks").exists() + + +def test_build_merged_bundle_folder_format_places_unioned_obb_inside_extracted_folder( + tmp_path, +): + puller, _adb = make_puller( + tmp_path, + dumpsys="versionCode=7 versionName=1.0", + pm_path="package:/data/app/x/base.apk\n", + obb_exists=["/sdcard/Android/obb/com.app/main.7.com.app.obb"], + ) + with _patched_apk_file(version_code=7): + raw = puller.pull_raw("com.app") + + target, files = build_merged_bundle( + "com.app", 7, [raw], tmp_path, output_format=OutputFormat.FOLDER, verify=False + ) + obb = next(f for f in files if f.kind == FileKind.OBB) + assert obb.local_path == target / "main.7.com.app.obb" + assert obb.local_path.is_file() + + +def test_build_merged_bundle_zip_format_same_contents_as_apks(tmp_path): + puller, _adb = make_puller( + tmp_path, + dumpsys="versionCode=41 versionName=1.0", + pm_path="package:/data/app/x/base.apk\n", + ) + with _patched_apk_file(version_code=41): + raw = puller.pull_raw("com.app") + + target, _files = build_merged_bundle( + "com.app", 41, [raw], tmp_path, output_format=OutputFormat.ZIP, verify=False + ) + assert target.name == "com.app-41.zip" + with zipfile.ZipFile(target) as zf: + assert "meta.sai_v2.json" in zf.namelist() + + +# -- verification integration -------------------------------------------------------------- + + +def test_verify_true_invokes_verify_bundle_and_writes_sidecar_manifest(tmp_path): + puller, _adb = make_puller( + tmp_path, + dumpsys="versionCode=41 versionName=1.0", + pm_path="package:/data/app/com.app-x/base.apk\n", + ) + manifest = {"package_name": "com.app", "version_code": 41} + with ( + _patched_apk_file(version_code=41), + patch( + "apkpull.puller.verify_bundle", return_value=(True, manifest) + ) as mock_verify, + ): + _, files, _, _ = pull_and_build(puller, "com.app") + + mock_verify.assert_called_once() + assert files[0].verified is True + manifest_path = tmp_path / "com.app-41.manifest.json" + assert manifest_path.is_file() + assert json.loads(manifest_path.read_text()) == manifest + + +def test_verify_false_skips_verify_bundle_entirely(tmp_path): + puller, _adb = make_puller( + tmp_path, + dumpsys="versionCode=41 versionName=1.0", + pm_path="package:/data/app/com.app-x/base.apk\n", + ) + with ( + _patched_apk_file(version_code=41), + patch("apkpull.puller.verify_bundle") as mock_verify, + ): + _, files, _, _ = pull_and_build(puller, "com.app", verify=False) + + mock_verify.assert_not_called() + assert files[0].verified is None + assert not (tmp_path / "com.app-41.manifest.json").exists() + + +def test_strict_flag_forwarded_to_verify_bundle(tmp_path): + puller, _adb = make_puller( + tmp_path, + dumpsys="versionCode=41 versionName=1.0", + pm_path="package:/data/app/com.app-x/base.apk\n", + ) + with ( + _patched_apk_file(version_code=41), + patch("apkpull.puller.verify_bundle", return_value=(True, {})) as mock_verify, + ): + pull_and_build(puller, "com.app", strict=True) + _, kwargs = mock_verify.call_args + assert kwargs["strict"] is True + + +def test_full_flag_forwarded_to_verify_bundle(tmp_path): + puller, _adb = make_puller( + tmp_path, + dumpsys="versionCode=41 versionName=1.0", + pm_path="package:/data/app/com.app-x/base.apk\n", + ) + with ( + _patched_apk_file(version_code=41), + patch("apkpull.puller.verify_bundle", return_value=(True, {})) as mock_verify, + ): + pull_and_build(puller, "com.app", full=True) + _, kwargs = mock_verify.call_args + assert kwargs["full"] is True + + +def test_full_flag_defaults_to_false(tmp_path): + puller, _adb = make_puller( + tmp_path, + dumpsys="versionCode=41 versionName=1.0", + pm_path="package:/data/app/com.app-x/base.apk\n", + ) + with ( + _patched_apk_file(version_code=41), + patch("apkpull.puller.verify_bundle", return_value=(True, {})) as mock_verify, + ): + pull_and_build(puller, "com.app") + _, kwargs = mock_verify.call_args + assert kwargs["full"] is False + + +# -- output formats ------------------------------------------------------------------------- + + +def test_zip_format_uses_zip_extension_same_contents(tmp_path): + puller, _adb = make_puller( + tmp_path, + dumpsys="versionCode=41 versionName=1.0", + pm_path="package:/data/app/com.app-x/base.apk\n", + ) + with _patched_apk_file(version_code=41): + _, files, _, _ = pull_and_build( + puller, "com.app", output_format=OutputFormat.ZIP, verify=False + ) + + bundle = files[0] + assert bundle.name == "com.app-41.zip" + assert bundle.local_path == tmp_path / "com.app-41.zip" + with zipfile.ZipFile(bundle.local_path) as zf: + assert "meta.sai_v2.json" in zf.namelist() + + +def test_folder_format_extracts_loose_files(tmp_path, force_splits): + pm_path = ( + "package:/data/app/com.app-x/base.apk\n" + "package:/data/app/com.app-x/split_config.en.apk\n" + ) + puller, _adb = make_puller( + tmp_path, dumpsys="versionCode=41 versionName=1.0", pm_path=pm_path + ) + manifest = {"package_name": "com.app", "version_code": 41} + with ( + _patched_apk_file(version_code=41), + patch("apkpull.puller.verify_bundle", return_value=(True, manifest)), + force_splits(where=lambda p: p.name != "base.apk"), + ): + _, files, _, _ = pull_and_build( + puller, "com.app", output_format=OutputFormat.FOLDER + ) + + bundle = files[0] + assert bundle.name == "com.app-41" + folder = tmp_path / "com.app-41" + assert bundle.local_path == folder + assert folder.is_dir() + assert (folder / "base.apk").is_file() + assert (folder / "config.en.apk").is_file() + assert json.loads((folder / "manifest.json").read_text()) == manifest + assert not folder.with_name( + folder.name + ".tmp" + ).exists() # atomic rename left no residue + assert bundle.size_bytes == sum( + f.stat().st_size for f in folder.iterdir() if f.is_file() + ) + + +def test_folder_format_dedup_skips_existing_folder(tmp_path): + puller, adb = make_puller( + tmp_path, + dumpsys="versionCode=41 versionName=1.0", + pm_path="package:/data/app/com.app-x/base.apk\n", + ) + existing = tmp_path / "com.app-41" + existing.mkdir() + (existing / "base.apk").write_bytes(b"already-here") + + _, files, _, _ = pull_and_build( + puller, "com.app", output_format=OutputFormat.FOLDER, verify=False + ) + assert files[0].already_existed is True + assert not adb.pulled + + +def test_folder_format_places_obb_inside_folder(tmp_path): + puller, _adb = make_puller( + tmp_path, + dumpsys="versionCode=7 versionName=1.0", + pm_path="package:/data/app/com.app-x/base.apk\n", + obb_exists=["/sdcard/Android/obb/com.app/main.7.com.app.obb"], + ) + with _patched_apk_file(version_code=7): + _, files, _, _ = pull_and_build( + puller, "com.app", output_format=OutputFormat.FOLDER, verify=False + ) + + obb = next(f for f in files if f.kind == FileKind.OBB) + assert obb.local_path == tmp_path / "com.app-7" / "main.7.com.app.obb" + assert obb.local_path.is_file() diff --git a/tests/test_tui.py b/tests/test_tui.py new file mode 100644 index 0000000..d563a1a --- /dev/null +++ b/tests/test_tui.py @@ -0,0 +1,333 @@ +from unittest.mock import patch + +from rich.cells import cell_len +from rich.console import Console +from rich.table import Table + +from apkpull.models import Status +from apkpull.progress import ProgressEvent, Stage +from apkpull.tui import LiveDisplay + + +def make_display() -> tuple[LiveDisplay, Console]: + console = Console(record=True, force_terminal=True, width=100) + return LiveDisplay(console=console), console + + +def render_text(display: LiveDisplay) -> str: + display.console.print(display.render()) + return display.console.export_text() + + +def test_queued_row_shows_dash_for_elapsed(): + display, _console = make_display() + display.update(ProgressEvent("dev-1", "dev-1", "com.app", Stage.QUEUED)) + text = render_text(display) + assert "Queued" in text + assert "—" in text + + +def test_package_table_title_shows_package_name_and_rows_show_device(): + display, _console = make_display() + display.update(ProgressEvent("dev-1", "dev-1", "com.app", Stage.QUEUED)) + text = render_text(display) + assert "com.app" in text + assert "arm64-v8a" not in text # not known yet + + display.update( + ProgressEvent( + "dev-1", + "Pixel 7", + "com.app", + Stage.DOWNLOADING, + device_meta="arm64-v8a, en-US", + ) + ) + text = render_text(display) + assert "Pixel 7" in text + assert "arm64-v8a, en-US" in text + + +def test_devices_group_under_their_own_package_table(): + display, _console = make_display() + for device_id, label in (("dev-1", "Pixel 7"), ("dev-2", "sdk_gphone64_arm64")): + for package in ("com.whatsapp", "com.spotify.music"): + display.update(ProgressEvent(device_id, label, package, Stage.QUEUED)) + text = render_text(display) + assert text.count("Device") == 2 # one table header per package + assert "com.whatsapp" in text + assert "com.spotify.music" in text + assert "Pixel 7" in text + assert "sdk_gphone64_arm64" in text + + +def test_device_and_elapsed_columns_share_width_across_tables(): + """Each package gets its own table, but a short device label in one + table must not leave its Device column narrower than a long label's in + another -- otherwise the tables' column boundaries drift out of line + with each other.""" + display, _console = make_display() + display.update( + ProgressEvent("dev-1", "sdk_gphone64_arm64", "com.tranzmate", Stage.QUEUED) + ) + display.update(ProgressEvent("dev-2", "P", "com.other", Stage.QUEUED)) + group = display.render() + tables = [r for r in group.renderables if isinstance(r, Table)] + assert len(tables) == 2 + device_widths = {t.columns[0].width for t in tables} + elapsed_widths = {t.columns[2].width for t in tables} + assert len(device_widths) == 1 + assert len(elapsed_widths) == 1 + assert device_widths.pop() == max( + cell_len("📱 sdk_gphone64_arm64"), cell_len("Device") + ) + + +def test_status_column_is_configured_for_single_line_truncation(): + """Regression test: rich.table.Table._render always uses the *column's* + no_wrap/overflow settings for every cell, ignoring whatever a Text object + in that cell asks for -- so a long unbroken value (e.g. a filesystem path, + which has no spaces to wrap on) must be constrained at the column level, + or it forces the column wider than its ratio share and throws every + table's column boundaries out of alignment with each other.""" + display, _console = make_display() + display.update(ProgressEvent("dev-1", "Pixel 7", "com.app", Stage.QUEUED)) + group = display.render() + tables = [r for r in group.renderables if isinstance(r, Table)] + status_column = tables[0].columns[1] + assert status_column.no_wrap is True + assert status_column.overflow == "ellipsis" + + +def test_connecting_stage_is_shown(): + display, _console = make_display() + display.update(ProgressEvent("dev-1", "Pixel 7", "com.app", Stage.CONNECTING)) + text = render_text(display) + assert "Connecting" in text + + +def test_locked_stage_prompts_to_unlock(): + display, _console = make_display() + display.update(ProgressEvent("dev-1", "Pixel 7", "com.app", Stage.LOCKED)) + text = render_text(display) + assert "Locked" in text + assert "unlock" in text + + +def test_downloading_stage_distinguishes_install_from_update(): + display, _console = make_display() + display.update( + ProgressEvent( + "dev-1", "Pixel 7", "com.app", Stage.DOWNLOADING, detail="install" + ) + ) + text = render_text(display) + assert "Downloading" in text + assert "Updating" not in text + + display.update( + ProgressEvent( + "dev-1", "Pixel 7", "com.other", Stage.DOWNLOADING, detail="update" + ) + ) + text = render_text(display) + assert "Updating" in text + + +def test_downloading_retry_shows_retry_suffix(): + display, _console = make_display() + display.update( + ProgressEvent( + "dev-1", "Pixel 7", "com.app", Stage.DOWNLOADING, detail="update-retry" + ) + ) + text = render_text(display) + assert "Updating" in text + assert "retry" in text + + +def test_done_stage_shows_installed_or_updated_when_it_actually_happened(): + display, _console = make_display() + display.update( + ProgressEvent( + "dev-1", "Pixel 7", "com.app", Stage.DONE, detail=Status.INSTALLED.value + ) + ) + text = render_text(display) + assert "Installed" in text + assert "✅" in text + + display.update( + ProgressEvent( + "dev-1", "Pixel 7", "com.other", Stage.DONE, detail=Status.UPDATED.value + ) + ) + text = render_text(display) + assert "Updated" in text + + +def test_done_stage_shows_pulled_when_nothing_was_installed(): + display, _console = make_display() + display.update( + ProgressEvent( + "dev-1", + "Pixel 7", + "com.app", + Stage.DONE, + detail=Status.ALREADY_UP_TO_DATE.value, + ) + ) + text = render_text(display) + assert "Pulled" in text + assert "Installed" not in text + + display.update( + ProgressEvent( + "dev-1", + "Pixel 7", + "com.other", + Stage.DONE, + detail=Status.SKIPPED_UPDATE_CHECK.value, + ) + ) + text = render_text(display) + assert "Pulled" in text + + +def test_done_stage_always_shows_the_destination_path(): + """apkpull pulls files on every successful status, not just the ones + labeled "Pulled" -- INSTALLED/UPDATED also pulled files, they just *also* + changed something on the device. The destination should show regardless + of which of the two happened.""" + display, _console = make_display() + display.update( + ProgressEvent( + "dev-1", + "Pixel 7", + "com.app", + Stage.DONE, + detail=Status.ALREADY_UP_TO_DATE.value, + path="/tmp/out/com.app.apks", + ) + ) + text = render_text(display) + assert "Pulled" in text + assert "/tmp/out/com.app.apks" in text + + display.update( + ProgressEvent( + "dev-1", + "Pixel 7", + "com.other", + Stage.DONE, + detail=Status.INSTALLED.value, + path="/tmp/out/com.other.apks", + ) + ) + text = render_text(display) + assert "Installed" in text + assert "/tmp/out/com.other.apks" in text + + +def test_error_stage_shows_the_error_message(): + display, _console = make_display() + display.update( + ProgressEvent( + "dev-1", "Pixel 7", "com.app", Stage.ERROR, detail="Something broke" + ) + ) + text = render_text(display) + assert "Something broke" in text + assert "❌" in text + + +def test_pulling_stage_shows_the_file_detail(): + display, _console = make_display() + display.update( + ProgressEvent( + "dev-1", + "Pixel 7", + "com.app", + Stage.PULLING, + detail="config.xxhdpi.apk (8.0M)", + ) + ) + text = render_text(display) + assert "config.xxhdpi.apk (8.0M)" in text + + +def test_rows_stay_in_the_table_after_reaching_a_terminal_stage(): + """The table is meant to be a stable, complete record of every device's + status for the whole run -- DONE/ERROR rows must keep their spot instead + of disappearing, so the operator can still see what happened to every + device/package at a glance once things finish.""" + display, _console = make_display() + display.update( + ProgressEvent( + "dev-1", "Pixel 7", "com.app", Stage.DONE, detail=Status.INSTALLED.value + ) + ) + display.update( + ProgressEvent( + "dev-2", "Pixel 8", "com.app", Stage.ERROR, detail="Something broke" + ) + ) + assert ("dev-1", "com.app") in display._rows + assert ("dev-2", "com.app") in display._rows + text = render_text(display) + assert "Pixel 7" in text + assert "Pixel 8" in text + assert "Installed" in text + assert "Something broke" in text + + +def test_summary_counts_only_terminal_stages_as_complete(): + display, _console = make_display() + display.update(ProgressEvent("dev-1", "dev-1", "com.app", Stage.QUEUED)) + display.update(ProgressEvent("dev-1", "dev-1", "com.other", Stage.DOWNLOADING)) + display.update( + ProgressEvent( + "dev-1", "dev-1", "com.done", Stage.DONE, detail=Status.INSTALLED.value + ) + ) + display.update( + ProgressEvent("dev-1", "dev-1", "com.failed", Stage.ERROR, detail="boom") + ) + text = render_text(display) + assert "2/4 complete" in text + assert "1 failed" in text + + +def test_elapsed_freezes_once_a_row_reaches_a_terminal_stage(): + """elapsed_text() must not keep growing as real wall-clock time passes + once a row is DONE/ERROR -- it should use the frozen finished_at instead + of calling time.monotonic() again.""" + display, _console = make_display() + display.update(ProgressEvent("dev-1", "dev-1", "com.app", Stage.DOWNLOADING)) + row = display._rows[("dev-1", "com.app")] + row.started_at -= 5 # simulate 5s of real elapsed time before completion + display.update( + ProgressEvent( + "dev-1", "dev-1", "com.app", Stage.DONE, detail=Status.INSTALLED.value + ) + ) + assert row.finished_at is not None + + first_read = row.elapsed_text() + assert first_read == "0:05" + with patch("apkpull.tui.time.monotonic", return_value=row.finished_at + 100): + # Real wall-clock time has "passed" (time.monotonic() would now + # return a much later value), but the row is DONE, so this must not + # be reflected -- elapsed stays pinned to finished_at - started_at. + second_read = row.elapsed_text() + assert second_read == first_read + + +def test_live_display_is_a_working_context_manager(): + """Exercises the real rich.live.Live start/stop + background refresh + thread, not just the pure render() logic above.""" + display, _console = make_display() + with display: + display.update(ProgressEvent("dev-1", "dev-1", "com.app", Stage.DOWNLOADING)) + # No exception/hang on __exit__, and the final state is still queryable. + assert display._rows[("dev-1", "com.app")].stage == Stage.DOWNLOADING diff --git a/tests/test_uidump.py b/tests/test_uidump.py new file mode 100644 index 0000000..0b101b6 --- /dev/null +++ b/tests/test_uidump.py @@ -0,0 +1,90 @@ +from apkpull import uidump + +from .helpers import make_dump, make_icon_dump + + +def test_find_button_returns_center_of_bounds(): + dump = make_dump(("Install", (100, 200, 300, 260))) + assert uidump.find_button(uidump.parse(dump), "Install") == (200, 230) + + +def test_find_button_missing_returns_none(): + dump = make_dump(("Open", (0, 0, 10, 10))) + assert uidump.find_button(uidump.parse(dump), "Install") is None + + +def test_find_button_requires_exact_match(): + dump = make_dump(("Reinstall", (0, 0, 10, 10))) + assert uidump.find_button(uidump.parse(dump), "Install") is None + + +def test_contains_text(): + dump = make_dump(("You're offline", (0, 0, 10, 10))) + nodes = uidump.parse(dump) + assert uidump.contains_text(nodes, "You're offline") + assert not uidump.contains_text(nodes, "Install") + + +def test_unescapes_xml_entities(): + dump = '' + assert uidump.contains_text(uidump.parse(dump), "You're offline") + + +def test_contains_any_currency_amount_detects_price_suffix(): + dump = make_dump(("$4.99", (0, 0, 10, 10))) + assert uidump.contains_any_currency_amount(uidump.parse(dump), ("$", "₪")) + + +def test_contains_any_currency_amount_detects_price_prefix(): + dump = make_dump(("₪17.90", (0, 0, 10, 10))) + assert uidump.contains_any_currency_amount(uidump.parse(dump), ("$", "₪")) + + +def test_contains_any_currency_amount_ignores_plain_numbers(): + dump = make_dump(("v4.99.1", (0, 0, 10, 10))) + assert not uidump.contains_any_currency_amount(uidump.parse(dump), ("$", "₪")) + + +def test_parse_skips_nodes_without_bounds_but_keeps_textless_ones(): + """A node with no ``bounds`` is unusable (can't tap it, can't measure + distance to it) and is dropped. A node with bounds but no text is kept + -- that's the exact shape of an icon-only node (e.g. Play Store's + warning-triangle, matched by ``content-desc`` in + ``find_text_near_content_desc``), which needs to survive parsing.""" + dump = '' + nodes = uidump.parse(dump) + assert len(nodes) == 1 + assert nodes[0].text == "" + assert nodes[0].bounds == (0, 0, 10, 10) + + +def test_find_text_near_content_desc_pairs_icon_with_its_message(): + dump = make_icon_dump("Warning", "This item isn't available in your country.") + assert ( + uidump.find_text_near_content_desc(uidump.parse(dump), "Warning") + == "This item isn't available in your country." + ) + + +def test_find_text_near_content_desc_picks_nearest_when_multiple_texts_present(): + dump = ( + '' + '' + '' + '' + "" + ) + assert ( + uidump.find_text_near_content_desc(uidump.parse(dump), "Warning") + == "Right next to the icon" + ) + + +def test_find_text_near_content_desc_returns_none_without_matching_icon(): + dump = make_dump(("Some text", (0, 0, 10, 10))) + assert uidump.find_text_near_content_desc(uidump.parse(dump), "Warning") is None + + +def test_find_text_near_content_desc_returns_none_without_any_text(): + dump = '' + assert uidump.find_text_near_content_desc(uidump.parse(dump), "Warning") is None diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..46126ff --- /dev/null +++ b/uv.lock @@ -0,0 +1,1536 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version < '3.11'", +] + +[[package]] +name = "alembic" +version = "1.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/2b/e4153978368de59918115c9e01d3ebf58a558a7285efa7e960c383c4b59a/alembic-1.19.1.tar.gz", hash = "sha256:e0fca0518118c78acc493e31bcb5402f190057aaf6df8b5b95ce94c4789cf648", size = 2070816, upload-time = "2026-08-08T16:32:01.565Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/89/e62cc37b69ad357cc8ecd6e7367f5245f523d3cbb338a66197212bdf6749/alembic-1.19.1-py3-none-any.whl", hash = "sha256:b39018cb3d9413a19cbd54cf3c02ad33998641f0538eb77413a488a21c3e14be", size = 265946, upload-time = "2026-08-08T16:32:03.153Z" }, +] + +[[package]] +name = "androguard" +version = "4.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "apkinspector" }, + { name = "asn1crypto" }, + { name = "click" }, + { name = "colorama" }, + { name = "cryptography" }, + { name = "dataset" }, + { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "ipython", version = "9.16.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "loguru" }, + { name = "lxml" }, + { name = "mutf8" }, + { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pydot" }, + { name = "pygments" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/a4/c6a1bcc4f4b40098259202f7155214f2ec315eb3ac5923f093646cf352c6/androguard-4.1.4.tar.gz", hash = "sha256:1e117ee4574366a2d7376b8c858433ad724b0a29e4036d9f1a9fda4372180267", size = 956315, upload-time = "2026-06-01T08:58:44.095Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/fc/dc0df02bcfb4c5182e2c15a60b2cd823f11470ddce8abcf3f99a43394434/androguard-4.1.4-py3-none-any.whl", hash = "sha256:6265a6d4007401cf5a62a98fa99a1c2994644d4311da031a5398efe3bcaa63b0", size = 1026935, upload-time = "2026-06-01T08:58:42.638Z" }, +] + +[[package]] +name = "apkfile" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "androguard" }, + { name = "cryptography" }, + { name = "loguru" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/35/ec4bd205fafacf37acc6fa94b3c07498db319a887b8009dd6af0b6fedcf5/apkfile-1.4.1.tar.gz", hash = "sha256:9f025453c9c856f3e4fdd1bc5249d55312a01daa80cf6b64624a9fe6a17a8798", size = 52476, upload-time = "2026-08-25T17:26:19.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/5a/1fd5fa4aa249138619098ac360475d9ef57cf2bccccbda515679b7f78ecc/apkfile-1.4.1-py3-none-any.whl", hash = "sha256:b2c49ba8180c1465a63c843f0208c8746596c3178fd1cc93ebed8699678d5a73", size = 62171, upload-time = "2026-08-25T17:26:17.968Z" }, +] + +[[package]] +name = "apkinspector" +version = "1.3.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/c2/56a1cb01ca64549633fa39b799861b226cda600412b62e9c6e4e4acb2f71/apkinspector-1.3.6.tar.gz", hash = "sha256:d39bee400906d28a6b1b09e1a70e4a87d69a044fb5d1890faae9917677651a04", size = 28042, upload-time = "2025-10-26T14:13:23.691Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/c4/36e95a50fed012832b7ea012644f753391c43322a4d0d1e421459cd22e22/apkinspector-1.3.6-py3-none-any.whl", hash = "sha256:811540c7ca2ee517c0d7ab47355cae593cd02f0699400134547a60940ac07ce7", size = 30348, upload-time = "2025-10-26T14:13:22.753Z" }, +] + +[[package]] +name = "apkpull" +version = "1.0.0" +source = { editable = "." } +dependencies = [ + { name = "apkfile" }, + { name = "rich" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-timeout" }, + { name = "ruff" }, + { name = "ty" }, +] + +[package.metadata] +requires-dist = [ + { name = "apkfile", specifier = ">=1.4.1" }, + { name = "rich", specifier = ">=13.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pre-commit", specifier = "~=4.5" }, + { name = "pytest", specifier = "~=9.0" }, + { name = "pytest-cov", specifier = "~=7.0" }, + { name = "pytest-timeout", specifier = "~=2.3" }, + { name = "ruff", specifier = "~=0.16" }, + { name = "ty", specifier = ">=0.0.65" }, +] + +[[package]] +name = "asn1crypto" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/cf/d547feed25b5244fcb9392e288ff9fdc3280b10260362fc45d37a798a6ee/asn1crypto-1.5.1.tar.gz", hash = "sha256:13ae38502be632115abf8a24cbe5f4da52e3b5231990aff31123c805306ccb9c", size = 121080, upload-time = "2022-03-15T14:46:52.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/7f/09065fd9e27da0eda08b4d6897f1c13535066174cc023af248fc2a8d5e5a/asn1crypto-1.5.1-py2.py3-none-any.whl", hash = "sha256:db4e40728b728508912cbb3d44f19ce188f218e9eba635821bb4b68564f8fd67", size = 105045, upload-time = "2022-03-15T14:46:51.055Z" }, +] + +[[package]] +name = "asttokens" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/1e/faf0f247f6f881b98fc4d6d07e14085cb89d13665084e6d6ac1dc2c03d0b/asttokens-3.0.2.tar.gz", hash = "sha256:3ecdbd8f2cc195f53ccada3a613538bb5f9ef6f6869129f13e03c30a677b8fe2", size = 63136, upload-time = "2026-07-12T03:31:49.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/2b/04b8a15f3a1c77bc79ddf5c73875327f34b4fa75982df2b76e45e402d364/asttokens-3.0.2-py3-none-any.whl", hash = "sha256:9da13157f5b28becde0bd374fc677dcd3c290614264eff096f167c469cd9f933", size = 28702, upload-time = "2026-07-12T03:31:47.542Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/d2/2cde336b375f55c76ca670f0be3978cc048e31e24f3b4d7ce8473150a388/cffi-2.1.1-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be", size = 183779, upload-time = "2026-08-03T21:19:15.602Z" }, + { url = "https://files.pythonhosted.org/packages/94/1a/4b2f7c92293ba05cbd4a9a1b28faaf0326272d9488e6354657571c48a7aa/cffi-2.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b", size = 184178, upload-time = "2026-08-03T21:19:16.67Z" }, + { url = "https://files.pythonhosted.org/packages/17/0b/ba385d8ccedf926c3cd06e8e2f327027da5afe5f0eb30f1f7bc43ac55125/cffi-2.1.1-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004", size = 211037, upload-time = "2026-08-03T21:19:17.705Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b9/0f2e58b2cefa33255bff36935d42b13180fe559bba82596540eb404bde7d/cffi-2.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9", size = 218652, upload-time = "2026-08-03T21:19:18.735Z" }, + { url = "https://files.pythonhosted.org/packages/37/15/180e0dab27b9312c7479003d14c9e547634b7dcb934e2cc4650e1b131a7a/cffi-2.1.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98", size = 205422, upload-time = "2026-08-03T21:19:19.96Z" }, + { url = "https://files.pythonhosted.org/packages/18/d4/03026f0c850cbbaa9030750490225b4a7f4d524ea4df72c3cc740a90f4ef/cffi-2.1.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9", size = 205444, upload-time = "2026-08-03T21:19:21.246Z" }, + { url = "https://files.pythonhosted.org/packages/75/77/60bebf6f818bec84210ac5b6979ce4eeadce6fbbaabc9c7ab23e506d1ce5/cffi-2.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6", size = 218742, upload-time = "2026-08-03T21:19:22.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ae/679bf47e73fd77b352171727f07de559a003f14de5d02b904a6ec1fa73ca/cffi-2.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf", size = 221054, upload-time = "2026-08-03T21:19:23.694Z" }, + { url = "https://files.pythonhosted.org/packages/09/b8/eefc0e06913b70aa153bf74c946094a18f58fd4aff11b7f372bfdfdca050/cffi-2.1.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659", size = 213489, upload-time = "2026-08-03T21:19:24.922Z" }, + { url = "https://files.pythonhosted.org/packages/6f/13/4e56852824a03cdf68523a35686f1c28eacd4bd30a7b0a78e682e6e6e1d3/cffi-2.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9", size = 220241, upload-time = "2026-08-03T21:19:26.214Z" }, + { url = "https://files.pythonhosted.org/packages/99/7f/040f9e163e4acac3ee3d85b02d00b2576e7ca980d8785f0a3a5f1a9bf7f5/cffi-2.1.1-cp310-cp310-win32.whl", hash = "sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41", size = 174578, upload-time = "2026-08-03T21:19:27.338Z" }, + { url = "https://files.pythonhosted.org/packages/ba/0b/644a2ec1a4eaba49c2939410bb1eb1d25b09d6d0582f5d2f95c537043725/cffi-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1", size = 185082, upload-time = "2026-08-03T21:19:28.409Z" }, + { url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" }, + { url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" }, + { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" }, + { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" }, + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/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://files.pythonhosted.org/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" } +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" }, +] + +[[package]] +name = "coverage" +version = "7.15.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/c3/4f2195f512fb172aa425a8803a874b2baa9ba7f80ff7b6080998761fc701/coverage-7.15.4.tar.gz", hash = "sha256:0548198fff07ccf4faf469520bce1c2eceb1ce3e62891921138dec10907f9d00", size = 936952, upload-time = "2026-08-06T13:50:24.442Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/70/b052a519a584663a7bd052841a2debe11c8309ec49a7786340003f9c0a02/coverage-7.15.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d0be6daac4cce6b8c8dc65886bae1b082ddbca4da8e5cbb5e15166acf253e264", size = 222245, upload-time = "2026-08-06T13:46:55.253Z" }, + { url = "https://files.pythonhosted.org/packages/67/39/892fa511aba3d1c3c8f49509a0ff5c71eab9f9f88d08e1a38da395821660/coverage-7.15.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b24e078eabcd6a9caa8b0713f9bc1eeb310bcc960a29d45a3b4fcd4b16d5b11d", size = 222762, upload-time = "2026-08-06T13:46:57.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/95/b2c724ce1e64bc23cb5b1d7eeffa9548dc3d811f7a6297b2d01607f4e062/coverage-7.15.4-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cfe20cc8cf8821d4fe54f89106cbf06aa27f37b5bbe3535568065a81539b4150", size = 249498, upload-time = "2026-08-06T13:46:59.012Z" }, + { url = "https://files.pythonhosted.org/packages/0b/4f/b1973f67a1382af65b572a31ed692f8e490a6ad707191eab59148376832a/coverage-7.15.4-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:83cf06cdd687677742caff1a9134833b7a8b75f111519d2cb0e0ba1b9a851e15", size = 251328, upload-time = "2026-08-06T13:47:00.764Z" }, + { url = "https://files.pythonhosted.org/packages/a2/09/03efa6722a132abcac91b32a60b64b240dd707c189c64eee697e48992c96/coverage-7.15.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8fa4de68e2a752468ff14b4e15db7def689a71be759e826a31ccecbef69c5fd0", size = 253194, upload-time = "2026-08-06T13:47:01.976Z" }, + { url = "https://files.pythonhosted.org/packages/45/63/8299201d9c80fb65551ce99c966cab83d706ec4066ac999bef08201346de/coverage-7.15.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4dff9daa47d83120c3ec38ce921214242944a832aa04e903e50b5b7ebac8972d", size = 255106, upload-time = "2026-08-06T13:47:03.281Z" }, + { url = "https://files.pythonhosted.org/packages/ee/16/26fd8a691eb8d9a230128685f6d23309d7402cb030aa553001788c8c50fc/coverage-7.15.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a093fd37229918976f602aa07aa59e0973cde82186f220c8e197f721f5be0ce4", size = 250177, upload-time = "2026-08-06T13:47:04.713Z" }, + { url = "https://files.pythonhosted.org/packages/ad/ef/3c7556f33783a0a566e01443ca62bd8eb2cdfe22d271efdc02e08beb5654/coverage-7.15.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:317db01a2cb02552fd67e2b1cca77a4b528a2a277176c5e0bf2cecbb639d3f54", size = 251234, upload-time = "2026-08-06T13:47:06.104Z" }, + { url = "https://files.pythonhosted.org/packages/29/49/640a34043edac950738f36a3567832db5731d4cb2ed84b59cdb89c6bccbf/coverage-7.15.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:8ee3838dcb656602c3b51e16aed9bfb0822f8d8d6d1c5966d32ec8c104be8e20", size = 249237, upload-time = "2026-08-06T13:47:07.467Z" }, + { url = "https://files.pythonhosted.org/packages/48/f5/e80f212669dd1be954ff844f883ef11a437ef4fd0089c6e0effc7b66b15d/coverage-7.15.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:425920379052ff1fe465268f3361d35804a241bbdd5a1b592c8cb60df4c52325", size = 253050, upload-time = "2026-08-06T13:47:08.748Z" }, + { url = "https://files.pythonhosted.org/packages/c7/e9/e5da0fe39f7fde1bca9edc09c60921bb5fdba4cec7db5bbad41ddfd8c230/coverage-7.15.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:69bb2400abef928e365ea7d4d9925169ada78ed2295546780002d4b65de3df88", size = 249508, upload-time = "2026-08-06T13:47:10.072Z" }, + { url = "https://files.pythonhosted.org/packages/7d/38/41bf25774a0c8bba6b467f917cb1c9a0a2605e02dc93aad489fc7050ed59/coverage-7.15.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:81661f82d302484e3119e7c80c519c02fa9bcc2a6b339baf67d67bc89c580f04", size = 250110, upload-time = "2026-08-06T13:47:11.35Z" }, + { url = "https://files.pythonhosted.org/packages/89/6e/26f2e54b79acc29d179ee4272922625aedb69198c4eb61f7ff4f098f3c78/coverage-7.15.4-cp310-cp310-win32.whl", hash = "sha256:cb476b2e828ecb71cb6b6a928d23fd20a7ddb501188022dae1c37499149cc338", size = 224294, upload-time = "2026-08-06T13:47:12.753Z" }, + { url = "https://files.pythonhosted.org/packages/7b/06/9a318fc3ae040d4d6cb2d86101c6aa963fab20899a5c58666adf52cde0ca/coverage-7.15.4-cp310-cp310-win_amd64.whl", hash = "sha256:3fc2130bf37df31852a8384f12601563a45a0024bccc6624f38355cba7a8d360", size = 224919, upload-time = "2026-08-06T13:47:14.17Z" }, + { url = "https://files.pythonhosted.org/packages/2a/66/edcec7d7a0b524aa8923e22925fde6fe50ce005a113dca13ae1581455c4c/coverage-7.15.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bbac5abad70df71019988f83f26ac7092ff2642975def4429e98dc7585ef3490", size = 222367, upload-time = "2026-08-06T13:47:15.578Z" }, + { url = "https://files.pythonhosted.org/packages/e6/c6/ab8de429e2e8548faf58ec7e1674a4ce00414b4113942d3fe87109cf0f68/coverage-7.15.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:357a173465c7ce028d07a95cc2b63b5bf59f50ecdd5ad75c5cbb78ada984048e", size = 222874, upload-time = "2026-08-06T13:47:16.961Z" }, + { url = "https://files.pythonhosted.org/packages/be/c4/3b7b49587e8a6b9af79b3eb468d443d6042b6d65b47aa26586846a0d6566/coverage-7.15.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:21b803935e2efc3acebe9697197a294fccf5dc4e5382bd6369542ff7a7d2a1d7", size = 253287, upload-time = "2026-08-06T13:47:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/fb/65/ec03b743a2a229c72cc1eff3e57be9d3564e9c6b4d5aba2d70744a3fc0d8/coverage-7.15.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7a2b580774a4786c1053157c0165e04476e03ff293993d7c148eee784a94bae6", size = 255199, upload-time = "2026-08-06T13:47:19.765Z" }, + { url = "https://files.pythonhosted.org/packages/41/4b/5163729e4b6582d61975cfd3ccab45b4ec53e21cf156d9941cb025188468/coverage-7.15.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a9464451c4efffe8d47ace5a540b10b0dc10e879066290f8600872b7f54a419d", size = 257308, upload-time = "2026-08-06T13:47:21.206Z" }, + { url = "https://files.pythonhosted.org/packages/86/08/2167a0f08fb87d702fa423a48578a32865464b7c9e1db3911ad7812ab414/coverage-7.15.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de602f34123c2f4af1c1869c6dbbbd60da6d5983bf01937367295d135cccbfce", size = 259268, upload-time = "2026-08-06T13:47:22.503Z" }, + { url = "https://files.pythonhosted.org/packages/1e/e5/68eebae3053dbd48508edea559c21b23fbdf3460784f91370c83a86a6acd/coverage-7.15.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6879ded16a27f3eeca19b900c147e81616e7054db451471a611b2755ee5249f7", size = 253392, upload-time = "2026-08-06T13:47:23.88Z" }, + { url = "https://files.pythonhosted.org/packages/1a/46/fd4ced40a2b691c774e515c9b69500bfa64c7960b67fcee4b2f6fad97fc3/coverage-7.15.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:986be58c3ab54aae8d3496a6225eea74f760fdbe739b38bd442c7e8d133aa53b", size = 255001, upload-time = "2026-08-06T13:47:25.469Z" }, + { url = "https://files.pythonhosted.org/packages/53/25/ae2e5fa710bb6957a9aadeb9e3598d3b3e4af6587ce857ad42e8639a3f30/coverage-7.15.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c6103639613fe6c1e989082948419bc77a2d26b6c825c99d7fad25f7d3d87afc", size = 253061, upload-time = "2026-08-06T13:47:26.845Z" }, + { url = "https://files.pythonhosted.org/packages/d7/31/67ddc0365db2c6e93ac8580bc4bbc50f65273262f973f63ebcdbc15c0495/coverage-7.15.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d3af93dddb5659276c63bc16ac6466ac2033a70ca816097bbc06345b8ccdf571", size = 256831, upload-time = "2026-08-06T13:47:28.217Z" }, + { url = "https://files.pythonhosted.org/packages/f6/78/82b8fd18f57fb13f12d98fe874995bb2c4f9f17be8aff762c426323fdb96/coverage-7.15.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b10075e5421d04265766a6d1dac809bbeb8a946fbb23c8f82c227409b2190719", size = 252781, upload-time = "2026-08-06T13:47:29.712Z" }, + { url = "https://files.pythonhosted.org/packages/0a/eb/6c74ef4dd12b252e573c49bdef9e2ac265bf3dbb79b8d7feb3266e084e9e/coverage-7.15.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a67a9f78b2942d87ba8ce3059c642164d2aedd65337377fb52fe9803656bc5c7", size = 253692, upload-time = "2026-08-06T13:47:31.192Z" }, + { url = "https://files.pythonhosted.org/packages/5a/66/eb9aed1c3fd2d36ee00eb173f434b14fa607fc056739c9a89ff4244010ea/coverage-7.15.4-cp311-cp311-win32.whl", hash = "sha256:69484d1aca26e322e1c3ce03f09341e84524ababad2d7202161738d83cc9f82e", size = 224461, upload-time = "2026-08-06T13:47:32.572Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6d/81fa4161dfb3ed9d74e40d58647eff83a56b7612e78352581280fce2f477/coverage-7.15.4-cp311-cp311-win_amd64.whl", hash = "sha256:63fd6fcd1dd6e158f7eb78606e72933b3f6d01e7b747f99c6c12d764307a0fdc", size = 224937, upload-time = "2026-08-06T13:47:34.205Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c1/d8dacf683c6cad3cf85ce68fd3774a6774ec402128822fdfaed920f11e6a/coverage-7.15.4-cp311-cp311-win_arm64.whl", hash = "sha256:ea82116c9893fa89e929b7f197ee5a1950a76e91cc5c85ba503fc02379d04890", size = 224479, upload-time = "2026-08-06T13:47:36.118Z" }, + { url = "https://files.pythonhosted.org/packages/1d/48/bc8d4ba7b37551a767bd863f15b3f80182b271c2f55975356f5f7dbe94c2/coverage-7.15.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4fedd1f7f428f9fe83b1ead5e7cc87a43427be31aadafbac3ac0636dc7abb22", size = 222543, upload-time = "2026-08-06T13:47:37.562Z" }, + { url = "https://files.pythonhosted.org/packages/20/dd/88d6f83f1fffc974a3691a34a97951c5b12df7512a6782c5963883cbc058/coverage-7.15.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:37e2f0cdf58e2e1fed4e4d5a8f8786ae2f7eb80b478016876667dc4a01d60a97", size = 222905, upload-time = "2026-08-06T13:47:38.927Z" }, + { url = "https://files.pythonhosted.org/packages/bd/5c/54ee0d4748585bb0acab9891cd8d92f2d3593165b4e59fc9de113bfb3140/coverage-7.15.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb55d0e70bb15f2e81477613627286581414693d74ac7963c93a790dd453ca9d", size = 254407, upload-time = "2026-08-06T13:47:40.488Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3f/f0642a372f494bd0d7dad3b497083b910194a5f1c88be2c94fef707c3b59/coverage-7.15.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:899b9da30f3c6c336566e3707495bb23e8302d39d862f01fa78c48b99b9437e2", size = 257145, upload-time = "2026-08-06T13:47:41.931Z" }, + { url = "https://files.pythonhosted.org/packages/71/17/8b46d0ed68251016002ec972c8fc0119961a765d0984cafb8bf317c43758/coverage-7.15.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d15715e8c46552827e5e4f30a35575a2dbcad14454cf3284c54483946bd16931", size = 258257, upload-time = "2026-08-06T13:47:43.527Z" }, + { url = "https://files.pythonhosted.org/packages/30/b8/8498a0e72d0adbe15477dd07463d2b3bb2c9f6a4815e8589e50939e2c3ae/coverage-7.15.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:002a438859f7b430bc99afeaf01a6d187dad1d0dc907b64cdeffc632a5db8fd8", size = 260517, upload-time = "2026-08-06T13:47:45.121Z" }, + { url = "https://files.pythonhosted.org/packages/41/e1/7dce19c3bdb1e3dd63e769508216500edad81bd5f69a26d724e32aceaf78/coverage-7.15.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e4193a04b518f7968f3099755f5509ee7cccc6dc2b92a6b14841934d22e222c9", size = 254785, upload-time = "2026-08-06T13:47:46.541Z" }, + { url = "https://files.pythonhosted.org/packages/dd/b1/e1494703c675a2561723cd9b89f45c9168782c31280c611b1f767851e57c/coverage-7.15.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e98dcc55d572b38e69d117da7e8e8efb8500f1f5eaf81ecd460a63220790b839", size = 256176, upload-time = "2026-08-06T13:47:48.155Z" }, + { url = "https://files.pythonhosted.org/packages/73/76/a5629d270fb638a43a4b10466f51e2f49d532c1aa4da2913cbbb150bbe0a/coverage-7.15.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:af6c538498ce66c10d3fd541c2a8d5b03da5850355add34e6cba564210cb9e72", size = 254321, upload-time = "2026-08-06T13:47:49.757Z" }, + { url = "https://files.pythonhosted.org/packages/ff/4f/9c44447218435d5766b911534f9d798144a5560f85e9a54ebe5f3f5d19f9/coverage-7.15.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1d10025d96ea89fc2f73714dbc4cbd433fe012c1ac9e23f895d7728b238b6e52", size = 258390, upload-time = "2026-08-06T13:47:51.248Z" }, + { url = "https://files.pythonhosted.org/packages/de/36/c1e127616fb3fa18a9ff71e76c417f2fd7424332a4870015ac224ef4c039/coverage-7.15.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d802e1947603162ded419bff83ac7489820355d2b856dfb09206574e3a37ac0c", size = 253894, upload-time = "2026-08-06T13:47:52.816Z" }, + { url = "https://files.pythonhosted.org/packages/e9/b9/fdb92c8ae7a8bb9b850cc253b7b3b9c8526f68130002048b5671cd510d09/coverage-7.15.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c2de40895718f91951b86712b4c5b694acaf9a0a49be13874896f599a1eed3f4", size = 255763, upload-time = "2026-08-06T13:47:54.296Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/a7d51b2587c7bdb76e71b0896d2565bf7d60436b5122fc83e511adb1f7cd/coverage-7.15.4-cp312-cp312-win32.whl", hash = "sha256:5c3431b2161279b7db5c2a1aa58ae02e5cb8c3c42d93a5094be3f5537bd5b11b", size = 224597, upload-time = "2026-08-06T13:47:56.074Z" }, + { url = "https://files.pythonhosted.org/packages/49/b9/5c5f80cc55f5acaaca6dee677626bfcec8c87204a7809b438b08e84f4571/coverage-7.15.4-cp312-cp312-win_amd64.whl", hash = "sha256:6befeab5fb2b51c958ca4ac6c5d141a1e8240f4f76e46350f1911963deda49cd", size = 225135, upload-time = "2026-08-06T13:47:57.52Z" }, + { url = "https://files.pythonhosted.org/packages/47/e4/2a4561f89ff6bf7c925c287d0f2cce8bdf139c3a33735c87e3203401cf94/coverage-7.15.4-cp312-cp312-win_arm64.whl", hash = "sha256:67bc345491ab55b837277d76f5775d057e8c7f1ac44d890d8c2c82adde258c6f", size = 224515, upload-time = "2026-08-06T13:47:58.977Z" }, + { url = "https://files.pythonhosted.org/packages/f1/84/651a9310859673aaa3b3203f1aa1641ca60fcf2494683e1c9474c7172780/coverage-7.15.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c705b28feb2775dc82a25f1d473a370bc37ff93f5177f4e29ce2425f560f6921", size = 222565, upload-time = "2026-08-06T13:48:00.796Z" }, + { url = "https://files.pythonhosted.org/packages/82/f9/4dcf700137e8af550670f4d74d1b63828ce93e1e2b05e5f10710eb2ea987/coverage-7.15.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3ff205ab5e3ecc670f6a4dd19d9cbf12ede53dd41cfc1e15716ec961ea6d314e", size = 222936, upload-time = "2026-08-06T13:48:02.391Z" }, + { url = "https://files.pythonhosted.org/packages/07/4a/612ff1e780b3fbfd637486f542f84adc5503873d8b5d279dec1ffeef9414/coverage-7.15.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5172326e861a38b48b48befca15e0f477a26b283337a33a739c8fed229934e36", size = 253926, upload-time = "2026-08-06T13:48:04.382Z" }, + { url = "https://files.pythonhosted.org/packages/b0/04/d1cff1c2ead4708a6a79c01d3736b6a25bd38a36678398f72a8dd33dfad9/coverage-7.15.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:12b59c90084e3234fb11184886bf4a40f4f16a8c8f867be2e087b81f8e8868d4", size = 256523, upload-time = "2026-08-06T13:48:05.996Z" }, + { url = "https://files.pythonhosted.org/packages/b9/80/d34e13fb4b293cbdb9665838cf5522077b8ad14ef947550631a4bced36a5/coverage-7.15.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349062d66f00b40fa2c1c222438bad25fabf755631b5d82937fe985c8008615c", size = 257759, upload-time = "2026-08-06T13:48:08.036Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e7/2c5fe7636fdb0732fe0f09f308a5b066864078b7fc61f6678e8478554f2e/coverage-7.15.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4256ced708e598e05209bc1a8ab4074e04a51dba4c62fb45926a229af675ace7", size = 259890, upload-time = "2026-08-06T13:48:09.834Z" }, + { url = "https://files.pythonhosted.org/packages/92/28/9689f0858dfff59c2ea688938ab9fa2925631235df67126a42b6c5c70ae1/coverage-7.15.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d80f974b20782d9612c8b4c9beeca867074c7cf4079d1419843fa25a26428b25", size = 254121, upload-time = "2026-08-06T13:48:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/785077c230c157243eb5aa9a26c3be260ecd02001bead54a3cada3df8e03/coverage-7.15.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e179f19bfe1d31f8eeeaa12990194d761c4f62f0759661000bca6cd8729f40b", size = 255891, upload-time = "2026-08-06T13:48:13.209Z" }, + { url = "https://files.pythonhosted.org/packages/d4/90/e20371b17b40f912f21305c2db2f30efa3de306f7320fc916804872c85a4/coverage-7.15.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8bc16bb47b7679670eceff71d78bfb7d6e5b143f6c2cd117487ec7c75e0d4b78", size = 253859, upload-time = "2026-08-06T13:48:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/05/49/25371987ee459a5f67c0427fb75c74f9358e65f2c71fe75bf41c1b6c5fcb/coverage-7.15.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd685005cd2c4200adfc14cf39a603b9320efab3f18a8f7f156d20c9cc3345f", size = 258011, upload-time = "2026-08-06T13:48:16.464Z" }, + { url = "https://files.pythonhosted.org/packages/30/6e/32e67467f6154bf4f1c4f63b05acc5097cba4237d45bbeeea446b52e8ac1/coverage-7.15.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:337399ad2c93b3acd2a937627dae8b3e86b66707cd3d3e856347999aadf1ef8d", size = 253676, upload-time = "2026-08-06T13:48:18.493Z" }, + { url = "https://files.pythonhosted.org/packages/03/c1/8b24192e89286399765155251f99ee9f070a9d637109018ac23d99b99f6f/coverage-7.15.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:96e257121228ec5cd2bb919276e94ac11074471bc37d68dbae0e8308cce15fff", size = 255453, upload-time = "2026-08-06T13:48:20.057Z" }, + { url = "https://files.pythonhosted.org/packages/16/6f/8b41ebdf67c87854e17c035336a90f1cfbad0c14c2a584301be6ff148718/coverage-7.15.4-cp313-cp313-win32.whl", hash = "sha256:c65a9e0dfc6143491879da4e13b5e30f8be192055de508d737fb14601edbd22c", size = 224605, upload-time = "2026-08-06T13:48:21.655Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e2/2946c7f0b42b152ecb21ff1bdad72e3d301e790c0c487e4a86e8c9f69347/coverage-7.15.4-cp313-cp313-win_amd64.whl", hash = "sha256:2ff8f5e9b8f7a94f0c11c45631eee103dbcb7d63274edd12c56efe1be690b3b4", size = 225148, upload-time = "2026-08-06T13:48:23.376Z" }, + { url = "https://files.pythonhosted.org/packages/9e/83/3f4a69957f48ae7a0aba76c34743f88963d607b19e03f3f8e66f91cae0f9/coverage-7.15.4-cp313-cp313-win_arm64.whl", hash = "sha256:6e0a8a5083b096487d6cfced94cdd514d8f5db6f113610fb36c0620edb1028cf", size = 224536, upload-time = "2026-08-06T13:48:25.117Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ac/748cf29eeb2d6be34a3176ce26a4f49e38085ee08e8935f05f6f26ed7e0f/coverage-7.15.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:770e9325ab5ea6d56f77e59b29ecfe0ac20b57a82a601876f90494a4dda0386f", size = 222608, upload-time = "2026-08-06T13:48:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/0b/02/1abbf5c984677b0aa439cdacaccbf38d248939d8ef8fe1cc7a50d73edb77/coverage-7.15.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d12b33a3a50a1676b7784dc8d00a0c6d66a9f2add4b85a041c19b6a7e53ef23c", size = 222940, upload-time = "2026-08-06T13:48:28.432Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e1/ff8f9f53d9fcf586125b55d0b1f04ec1c14955fee41e83d5814bee141bb5/coverage-7.15.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5669c8378ebde86f5def7a25d29586631b58acc27ffde04399f678f3dfc6e082", size = 253985, upload-time = "2026-08-06T13:48:29.995Z" }, + { url = "https://files.pythonhosted.org/packages/a1/26/595759762e514e81be1d7d01ed03444303bcd152226a6529998d253f9201/coverage-7.15.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff97a14362eef486483ed44042ca2027ea257df6ff768e62358ee0c9776925ac", size = 256492, upload-time = "2026-08-06T13:48:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/b79aabac54d482be23b5fcdd4f4662bff24a78edc4ee29201726929936d5/coverage-7.15.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a325e815318638aed1655d9c06e6d7c2d3d46c09231ce988070428a8762d734", size = 257837, upload-time = "2026-08-06T13:48:33.186Z" }, + { url = "https://files.pythonhosted.org/packages/09/0f/bf7f297885a5bf6fd71e5782404e0ff059ca09e8711ceb3a08544abde45a/coverage-7.15.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:474223409d88eb20d2d6a0d37ea60e8647a65a90cc008dc1f0410af5f64f1e0d", size = 260152, upload-time = "2026-08-06T13:48:34.75Z" }, + { url = "https://files.pythonhosted.org/packages/fd/f1/296744e854ff8368542343457414380465e9ceefb9192342feb9d3bc461d/coverage-7.15.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f2f62ae3cd189dd2e13aece758c57b3eecbd27be070dbd4cbd10936049e5dbf", size = 253978, upload-time = "2026-08-06T13:48:36.434Z" }, + { url = "https://files.pythonhosted.org/packages/55/b0/bbdb2e9057493e66220a2e149ca2d301ba0e3a58a83bd6b90de9826d16f3/coverage-7.15.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39ece820e29e0a2ba34b3ecb3be83c27e997eed8926f2ba6fe7ce7a0bda5843b", size = 255846, upload-time = "2026-08-06T13:48:38.317Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/38015b2b6d21258713bd17e76b59d033b191efb5703589cffd037dfbca20/coverage-7.15.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f21b56dcace11dfe013014201f577dcd592b2a9b72182d930361b47cf6f73f25", size = 253808, upload-time = "2026-08-06T13:48:39.993Z" }, + { url = "https://files.pythonhosted.org/packages/0b/64/0d515c1e60ee6fbfd1a0e79c07cd87d388a233b7adc37758735677203808/coverage-7.15.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:93a3a0b662abcc10c73a47cbc72cd60f63618d6989fb2d1286e50eacd974f303", size = 258081, upload-time = "2026-08-06T13:48:41.971Z" }, + { url = "https://files.pythonhosted.org/packages/91/71/04d9e7a3642146c6351338aef4ef85ab11dbbb54744c13245caba1aad1c0/coverage-7.15.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:141fae2cabf5569b782c10afc4c850ce10f618c13f8db54765cba99cc839da1f", size = 253624, upload-time = "2026-08-06T13:48:43.731Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a7/6c28b74c81ebff66987b0e2522ba5cffa3e90b0c33cb6a2eb264d4ee8cf1/coverage-7.15.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:81294c7e6ab30c5f74c0353b11b2fd6320e72d9bee6ac73b357caa8b916323a5", size = 255280, upload-time = "2026-08-06T13:48:45.58Z" }, + { url = "https://files.pythonhosted.org/packages/52/af/bc19996a7014b98d7bbb0f0939453c67074af65784a3aa16a789a07381fa/coverage-7.15.4-cp314-cp314-win32.whl", hash = "sha256:7bbd7d6418e0dab31a206af5203bd43ae36edb8e7fba1940b055d3e9249290d7", size = 224768, upload-time = "2026-08-06T13:48:47.525Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/219484e476d6e101ba0a444852579e05f5b75c37c611a42ed1190f73ef62/coverage-7.15.4-cp314-cp314-win_amd64.whl", hash = "sha256:f0204ed122758782970526057093f448051a39db9d810d4e344bb87a3546f425", size = 225259, upload-time = "2026-08-06T13:48:49.513Z" }, + { url = "https://files.pythonhosted.org/packages/b7/66/fa77daf4e383e5f776dac62c2409b6af81910ae6fe326bd5170dba74cc63/coverage-7.15.4-cp314-cp314-win_arm64.whl", hash = "sha256:9e71e7bc71c686a123347ae47a0de33a175e797a85bb57b791492adf4eec8ed8", size = 224684, upload-time = "2026-08-06T13:48:51.235Z" }, + { url = "https://files.pythonhosted.org/packages/58/5b/f03bf0ce362bbf3f785fa5219620d00778d4ac6fc9e407734828e9c672f6/coverage-7.15.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7c922735321eef3f87c280a3d39afff6b646723a2880b862cda4ac7a093b8aa8", size = 223338, upload-time = "2026-08-06T13:48:52.896Z" }, + { url = "https://files.pythonhosted.org/packages/0f/76/e77d0ae22501831cc9f92193e8a957a5caa1dd177f90a6d1d9b106242d92/coverage-7.15.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f41c17c4668a655ce96d090d8d5ffdc24ef64b5a02f9753884d08483e8a4a41a", size = 223609, upload-time = "2026-08-06T13:48:54.688Z" }, + { url = "https://files.pythonhosted.org/packages/82/1a/b1f089da8d38ac612fa2dd6dc7f4a1a7657d12f3e261d2996edd3a838d0b/coverage-7.15.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:46822e9b6ff1c6a72b518c162c44a8f45a61a1d609c51084bf5b16c023c5037b", size = 264970, upload-time = "2026-08-06T13:48:56.403Z" }, + { url = "https://files.pythonhosted.org/packages/bf/31/e66d98d6e9c7fcc88470f1e234eaf6b1950dc0dfbf797f7282c1c861da24/coverage-7.15.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d6f4955b73b5445271379a59e3792b0d978f42d4a01e0cf7a67d9c33a3bb0a5", size = 267088, upload-time = "2026-08-06T13:48:58.41Z" }, + { url = "https://files.pythonhosted.org/packages/59/a1/ae94eb2c541add426378408379f233591e069040b1e2cdb33df9498a0682/coverage-7.15.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3fc9e047706fb4a9abb54f719d3aa643e80e5bb3818182c40aee01ac0f0247ba", size = 269508, upload-time = "2026-08-06T13:49:00.42Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c7/88a10694a1c6a213569766aba9f25847b28155d4ac731b13226db216356d/coverage-7.15.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05e491d4f3165d62d4f5c8fd48dfeabf2ae8f42cbbd484319af33ea851b78982", size = 270629, upload-time = "2026-08-06T13:49:02.234Z" }, + { url = "https://files.pythonhosted.org/packages/b3/34/d8b8232e5e55169933b59aabcef2fedfa4b9d8897361bb80fcbda146505f/coverage-7.15.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:226c66e80ec0598d3b9b4874123df167ccca342aca8714f77cac6829688ee09c", size = 264043, upload-time = "2026-08-06T13:49:04.102Z" }, + { url = "https://files.pythonhosted.org/packages/7e/35/58b009dbf8c471c7224716478b9fed4a7e1af15320e1ed41660978504663/coverage-7.15.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac41cc14bebda0dbfb0628036b7f75706935c95bcc07fefe9a0f93614aa60a57", size = 266963, upload-time = "2026-08-06T13:49:05.821Z" }, + { url = "https://files.pythonhosted.org/packages/62/aa/57fbda1b42c892968273c56b6ee9dc0f1310850859230a507bc7873b1f65/coverage-7.15.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8af623e5cd92080acddd02b38f2f406a2c3a0893c38950b211890361448fbf26", size = 264569, upload-time = "2026-08-06T13:49:07.706Z" }, + { url = "https://files.pythonhosted.org/packages/98/8a/360e6e7f24d477b7e889703af0afa878d15b6d4d8d2a822b2835c169a879/coverage-7.15.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07545711d4f0f32852a18f18ad11f76f0109909d09e78b9008b4cfc67e829429", size = 268299, upload-time = "2026-08-06T13:49:09.587Z" }, + { url = "https://files.pythonhosted.org/packages/4e/89/6f701261aee21b6b5fa8f7872229406dc917e125069448292223bf213606/coverage-7.15.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a0865421cfdc53654b342d515e5a233187590882d20b95752150e53f65460017", size = 263413, upload-time = "2026-08-06T13:49:11.604Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0f/6f04036edc260ed425af83e834f627fad48941ce97b50bfe6edd8b6fa623/coverage-7.15.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:460115e32ee40566476db5048f9bec1e842c127ad8e6f8be745aad3ac9cbc839", size = 265725, upload-time = "2026-08-06T13:49:13.38Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ce/d19b5d4d5c49a7bfb925fd74310fee7d28bc99520ac3367ccbc54e662518/coverage-7.15.4-cp314-cp314t-win32.whl", hash = "sha256:cbde877ef9dd7baf272b9bfef2b8a25edd45d9170fc326951dd20eb480335e85", size = 225079, upload-time = "2026-08-06T13:49:15.265Z" }, + { url = "https://files.pythonhosted.org/packages/26/bb/7aa1b3b173faee0679037ca950bbbe1247273656697994d8d13f80f8d4b4/coverage-7.15.4-cp314-cp314t-win_amd64.whl", hash = "sha256:3da9e92d1c551fd7563833e9ade686efb0c4b7363ab7681a94283958c950bf5e", size = 225911, upload-time = "2026-08-06T13:49:17.279Z" }, + { url = "https://files.pythonhosted.org/packages/81/1c/4ea9e47426d80038d9222db3c4534cb6021a74b237d3ff97ffd33b6600dd/coverage-7.15.4-cp314-cp314t-win_arm64.whl", hash = "sha256:3a54f5a0d85050c73a38f6793090ee83974531e67fe5e57a1da9bee11398aa5e", size = 225219, upload-time = "2026-08-06T13:49:19.293Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c4/dc5d2ac8f9142e7ec7de66e7bf0591db29d78955a040bd915870d9c0e657/coverage-7.15.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2c9872e4d9dc5d3cf616bf4b382f5a00359305a5be666a3dd0b5cdb4e49597f9", size = 222604, upload-time = "2026-08-06T13:49:21.279Z" }, + { url = "https://files.pythonhosted.org/packages/70/39/33e63df81fe2ee100897451841c821467635923e58e37c6bd4b46dd8106c/coverage-7.15.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:e101dbb4b9b72f0cddd8cdc8c9c5b47f456766f5e0ac82dbfb75e5c55409b78a", size = 222944, upload-time = "2026-08-06T13:49:23.187Z" }, + { url = "https://files.pythonhosted.org/packages/99/1f/ef3ffb5557febc75a0d97aa459d0266d7d741110265121cc6d8539343d44/coverage-7.15.4-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7d1abebdb047729e852b9c77a00497dfbeb11eb3a117e037d7dbc3ac8e5f5c54", size = 254050, upload-time = "2026-08-06T13:49:25.008Z" }, + { url = "https://files.pythonhosted.org/packages/6f/f5/1f0f6f77698c3601ca0ae7431e34b24c62ca2f06fecb23b73ed1f651d2be/coverage-7.15.4-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d28a4a899354d0ea6214cc59b4fa19eefbce1b9ff1688ab579acf49e894bd3fb", size = 256967, upload-time = "2026-08-06T13:49:26.896Z" }, + { url = "https://files.pythonhosted.org/packages/03/7a/2ed9bed79925f4367c83c77f66a89e5ca7229c288d2d19ad5f36d1ca0070/coverage-7.15.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffb3c2aacea411cc7e1d27712490c11108e2de1d39019ae32915493a59a8b9ed", size = 258587, upload-time = "2026-08-06T13:49:28.692Z" }, + { url = "https://files.pythonhosted.org/packages/45/8c/fa34044f71b7cc4ecb6da9c2408770959b0591fa9b5fb6fb6bca38f94298/coverage-7.15.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9447978a92f405d301123cfd39ff49895490efb769a758fe2734c7f631bf8ce", size = 260785, upload-time = "2026-08-06T13:49:30.472Z" }, + { url = "https://files.pythonhosted.org/packages/4f/54/d5727ce36b4524a7394ab9f5f1df378e1f23affcdab01037dc8655185cc7/coverage-7.15.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:050467a7983b8e2fe7dd41a78bb30c3e7f8c0b8cafda14b1c46f8b5e3cf2dd3c", size = 254545, upload-time = "2026-08-06T13:49:32.271Z" }, + { url = "https://files.pythonhosted.org/packages/dc/e6/6e3783e576719590194bdffb6dd6d85490801785b7c331e35a245d8cb8b5/coverage-7.15.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d003b7a5708ddad5c206c79607a6b92abb6fc13c57d99d8a4468cc03a2941ced", size = 256682, upload-time = "2026-08-06T13:49:34.089Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f2/bacdbde18b69ed2de424fcf64d9fb0a4913753d4f0eca8bae9daad69f4bd/coverage-7.15.4-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c38efe30fd74e5c19e9433f11fb1f5dc9c6522770971b7c6145bbaa413dc8800", size = 254560, upload-time = "2026-08-06T13:49:36.052Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a3/1fb927196e3477c1b48831169ab58ba08f451ba87ae311ff1de68b26a616/coverage-7.15.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:1f4f826d70f772ab8b0c052329580d7fe8b8abd191e4ce0c8f81aec6614665d3", size = 258792, upload-time = "2026-08-06T13:49:38.01Z" }, + { url = "https://files.pythonhosted.org/packages/41/58/30d4c149c69053de0edfe325614c1d28d508f62b1783e0e4a234d2e49136/coverage-7.15.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4a4bf917c9953f57c957be31c1cd504e3bd2f34d4a352b9d391a3025336f6768", size = 253968, upload-time = "2026-08-06T13:49:39.934Z" }, + { url = "https://files.pythonhosted.org/packages/89/e4/77f639371b918aad30dda4051f95404b43578f7f2e2f87ba73e02ed1ff37/coverage-7.15.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1c9bf40ebef178a45192c75c4964760bb261b0e6ad725da5fc4c93f674f19753", size = 255893, upload-time = "2026-08-06T13:49:41.825Z" }, + { url = "https://files.pythonhosted.org/packages/5c/62/13be29b3ddab35f14c87967a4820a05106d2a3eccb4fa4ff550bf30b75e0/coverage-7.15.4-cp315-cp315-win32.whl", hash = "sha256:43619d04c3671792d2c4706ae8bf45e265dc87bbd4078189ef8b847ea1e74be2", size = 224768, upload-time = "2026-08-06T13:49:44.08Z" }, + { url = "https://files.pythonhosted.org/packages/a1/70/af0c6be0f964af6954f6b74bc109b0dbca02824696d2520fb17fe1ab06e3/coverage-7.15.4-cp315-cp315-win_amd64.whl", hash = "sha256:be619439dbcd31a2eab10b32de9fff62c26ed4bab69dc32b8363fdaaa0882809", size = 225242, upload-time = "2026-08-06T13:49:45.899Z" }, + { url = "https://files.pythonhosted.org/packages/4f/2d/f3bd3aab899fc9efc18b53133ee68f5f98574ef480649b23e12962226387/coverage-7.15.4-cp315-cp315-win_arm64.whl", hash = "sha256:def597967dafc2e8d97c9097ea453c464e0bb8ed38f193a43070f10dc623bb6d", size = 224674, upload-time = "2026-08-06T13:49:48.322Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ca/f69251cd63eabc6438321aea22148754cce758a26bde07dd490e3fe7cfc5/coverage-7.15.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c7dbc748ac8a1e3e59a2b28bea47675e6e778081dbbf081bde0d75def2fcbe1d", size = 223333, upload-time = "2026-08-06T13:49:50.293Z" }, + { url = "https://files.pythonhosted.org/packages/a7/a7/037b53b2885b0d8447064432491a4d5a1014cd9f97a594d53acd0c04541a/coverage-7.15.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:2413074a5ecbb61a01a7888fc72db0ca324d13588c5b38bc0dd8564cdcdfea26", size = 223630, upload-time = "2026-08-06T13:49:52.637Z" }, + { url = "https://files.pythonhosted.org/packages/80/4f/152b8a4779ae90da11bb24f7467df8a59f0be48a5c52acb856325ca48289/coverage-7.15.4-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4e6f6f632b7b2f714bf7a1346e8f97b650ee71f3c298aaad42a2ab60f0f07645", size = 264489, upload-time = "2026-08-06T13:49:54.52Z" }, + { url = "https://files.pythonhosted.org/packages/10/2d/84b4b9e0e1dd6528a51920ff7031f35b789382e467a28ec6a5a578cb8812/coverage-7.15.4-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8df457da2249d3c75ca2e5e835d59c725abfe92d27fdff6cd99eed85b51d5e9a", size = 267567, upload-time = "2026-08-06T13:49:56.721Z" }, + { url = "https://files.pythonhosted.org/packages/53/fc/ba01cc25299f9f8a2c8b02d3b28c53f3543d9fbfbe4e74fa2760b48f163e/coverage-7.15.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:050f66a08805acb5b8a23c6d4a517b1ecf82c08e81ed0e4bd727df065e5c6624", size = 270123, upload-time = "2026-08-06T13:49:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d0/db2647cbf40b14f8c308f94ff7bf89c06d564e59f396906edf50086ec788/coverage-7.15.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1587fb771d1ccceef708fdde1e5af8c7ed24b486b61d13a321acb7d8145390aa", size = 271107, upload-time = "2026-08-06T13:50:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/4d2d17924552c458bb4f77dd631f0e3bc92fbbdf2d2d916cd4b33bbfd5b1/coverage-7.15.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8b4f1c3a69ca580f3fbd6b2046915f536d7f586874f25c1bb23add2a3c88d50f", size = 264955, upload-time = "2026-08-06T13:50:03.023Z" }, + { url = "https://files.pythonhosted.org/packages/ee/de/dc010c7a3691f396d93bbc26bfcafa1c2a3a351cd520470f15faf5795bd5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:ffb58d7eff5b7f6ecc6fa21d6288ab7f968a212cb67d682c269c09b9eba3b66f", size = 267949, upload-time = "2026-08-06T13:50:05.557Z" }, + { url = "https://files.pythonhosted.org/packages/78/ea/dc96a11375e83c045c2f7c61fb6918277cfe9401db7c0f7b1d111a84b2e5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:d9df165544774574ee004b953023d1bebada1894a80b1052a43d798b0f676e67", size = 264421, upload-time = "2026-08-06T13:50:07.612Z" }, + { url = "https://files.pythonhosted.org/packages/c8/86/b77131a0f9503ce461cd577076147d7a9040f0c5dda772686f729e2cc9cb/coverage-7.15.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:f9de0a24a4079b53e523b5c5e2c5945ec251ab486652659955187cf255a259bc", size = 269121, upload-time = "2026-08-06T13:50:09.58Z" }, + { url = "https://files.pythonhosted.org/packages/24/24/944bc35007862955e7ebf05754e645419dcf5d7526c52735cfa2715e8ebf/coverage-7.15.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:150089274bdc9f940628552cb92844e0223c987f1902ab8efe9f45a2ec758d88", size = 264565, upload-time = "2026-08-06T13:50:11.722Z" }, + { url = "https://files.pythonhosted.org/packages/c7/cc/a3bb9f93e7e740659163e2ea584f8196ddcd2c456a5dbe15f6c50105fec1/coverage-7.15.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:a58a94fed5da6997d258e8f7668c1e195fbd04a691d781b7558f1e468f9e68bc", size = 266522, upload-time = "2026-08-06T13:50:13.786Z" }, + { url = "https://files.pythonhosted.org/packages/49/dd/e0e40f3560d878d888c580698ff5ad1179f5e1c3ac949684ef66b41a3817/coverage-7.15.4-cp315-cp315t-win32.whl", hash = "sha256:ebd5a6d8466ff30836572f3ba2cae8a5e8f85029b1c6d5e2ed338dc472a5166a", size = 225068, upload-time = "2026-08-06T13:50:15.825Z" }, + { url = "https://files.pythonhosted.org/packages/c6/7e/37732ea80eebc30e976e4cdab15c190bc42d96959a42e38ddf6f8c60468f/coverage-7.15.4-cp315-cp315t-win_amd64.whl", hash = "sha256:288bde2a2d7ab6b6c2d7252fcde8b524387f2d970bdba9658fc6f8bbcaef0f9b", size = 225895, upload-time = "2026-08-06T13:50:17.928Z" }, + { url = "https://files.pythonhosted.org/packages/c6/08/1e00f7923eaaba45fb3d51dd794125fc766304b1df264f3a9c6557bfb30e/coverage-7.15.4-cp315-cp315t-win_arm64.whl", hash = "sha256:68be5e1de60ff13c9095bbec0e5a7fa45b33b101752215b91345ea1f61c4a278", size = 225213, upload-time = "2026-08-06T13:50:19.981Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d9/e70c286c979378f061d8266e279b686ab0b0b688e1fe0af864684f23a77d/coverage-7.15.4-py3-none-any.whl", hash = "sha256:964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84", size = 214332, upload-time = "2026-08-06T13:50:22.192Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "cryptography" +version = "50.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" }, + { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" }, + { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" }, + { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" }, + { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" }, + { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" }, + { url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" }, + { url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" }, + { url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" }, + { url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" }, + { url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" }, + { url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" }, + { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" }, + { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" }, + { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" }, + { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" }, + { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" }, + { url = "https://files.pythonhosted.org/packages/9d/3e/e54cde8c01631a5a8226ccd617eab9e57fd5cfdad90f1a9e6bb570794631/cryptography-50.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c", size = 3963170, upload-time = "2026-07-31T14:24:51.968Z" }, + { url = "https://files.pythonhosted.org/packages/01/b6/0b9e125e90f3d2dcf599a218a899cda7326a3158cfa258723f0b398b08f6/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a", size = 4692441, upload-time = "2026-07-31T14:24:53.743Z" }, + { url = "https://files.pythonhosted.org/packages/53/c9/a5151588710785a96d7bc4de27d4cd62f263bbbcb203cfe29df537eb6505/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e", size = 4699810, upload-time = "2026-07-31T14:24:55.746Z" }, + { url = "https://files.pythonhosted.org/packages/c7/1a/15b92b25eb6ce3089cd49377ae990a0f3ad485a510f968aed1f19dbdcdf2/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d", size = 4691924, upload-time = "2026-07-31T14:24:58.082Z" }, + { url = "https://files.pythonhosted.org/packages/62/15/219075012ab13e8905f3cd572204f4acb4b111df787104346b9bc0cea789/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437", size = 4699593, upload-time = "2026-07-31T14:24:59.951Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b5/c2c5fce26f0ee40d21bafe7f191d29a34b35a65ac4fe8a1191d1983612e9/cryptography-50.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9", size = 3813796, upload-time = "2026-07-31T14:25:02.298Z" }, +] + +[[package]] +name = "dataset" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alembic" }, + { name = "sqlalchemy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/10/0113e396ac92652cfab96663e2a98eb8ddf33766c24c586d79d3ea87eaa3/dataset-2.0.0.tar.gz", hash = "sha256:0e13d309dfbaafb3e67b991605ce6767867262908864455505c415442c0bba45", size = 183331, upload-time = "2026-04-12T09:59:37.862Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/22/2a12be390bb1bc92e6930a9180f27a4cba002cb09fad9b68b03ca345e94f/dataset-2.0.0-py3-none-any.whl", hash = "sha256:c08354c068002c2beb7811b37b8abf434e1fc333898f8cfc2be32df3f14099b3", size = 21126, upload-time = "2026-04-12T09:59:36.741Z" }, +] + +[[package]] +name = "decorator" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82", size = 58084, upload-time = "2026-05-18T06:03:28.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +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" } +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" }, +] + +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, +] + +[[package]] +name = "filelock" +version = "3.32.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/64/a02e6765de08964ed371eca577870593245afc9dfac16d037de7c10d18e6/filelock-3.32.3.tar.gz", hash = "sha256:0ffa185a3540854c95caa7fa76b76cb219d907415e2c5dc9af25fd970563487f", size = 218135, upload-time = "2026-08-13T16:00:05.577Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/8e/50f46a9c0ce8d2861a394c1347caae037ea0431d2f67d7feb151cbc4649a/filelock-3.32.3-py3-none-any.whl", hash = "sha256:7f0ca4bcc0e181c60dbbd8aa9ab5b120ebb99e4e064e83636340056f833a1f09", size = 98901, upload-time = "2026-08-13T16:00:03.974Z" }, +] + +[[package]] +name = "greenlet" +version = "3.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/d8/7cc97c142388aef03f622e001c572c4f84e9252a439549d483f555771970/greenlet-3.5.5.tar.gz", hash = "sha256:adb4bae02e91a8e863e48b177e4014bdcac8a6b5e047ea1df687a61534b85e6c", size = 207585, upload-time = "2026-08-10T15:09:36.136Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/67/07ecd6d85c0f253363cbc4ac9e7dd048ca571a267fbccfea084d6009ac3b/greenlet-3.5.5-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:816230f469381ad0a43abc9fa8dda5a699e32fb78958dde32ded93213b70a667", size = 292971, upload-time = "2026-08-10T13:28:09.837Z" }, + { url = "https://files.pythonhosted.org/packages/24/35/426733bc24247ee17bb76df90f550c299ff5f8572b2bdd7cacb5c53fd994/greenlet-3.5.5-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5433cf291e0ef9114bd14d0d824db6e5e4a43033234bca48181a9597acca07b", size = 609290, upload-time = "2026-08-10T14:14:32.273Z" }, + { url = "https://files.pythonhosted.org/packages/9c/dc/17d3a5acceb2fd0bbc9682a228117b719cdfb68085e6dbb33fa325755f27/greenlet-3.5.5-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:19d59f068887d8c5907fc177f27683413ace3011b6ed646c0b309266e74a6502", size = 622650, upload-time = "2026-08-10T14:27:22.004Z" }, + { url = "https://files.pythonhosted.org/packages/51/91/00b3c0566316c6f383ea16ee05388ec4f57f6e0afa49e72ae471eda8c47f/greenlet-3.5.5-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ff00e12102358292087274dfb1669132387ff6e7920ebf9d85f4826ce0d3a56", size = 622819, upload-time = "2026-08-10T13:40:46.562Z" }, + { url = "https://files.pythonhosted.org/packages/3c/78/52de4f7ac9152ad1dbc8f437895c1e573d30ee1e1427e0f91a280e2417b6/greenlet-3.5.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:523bb8e27614d77101ea7a8cf59f8d91219b72d5c29f6a038c92b50828bfa8d0", size = 1582164, upload-time = "2026-08-10T14:15:02.645Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ff/c3855a00c2417e8f61c7b9f0bc4f8f599c6928f5c15681ad251e78a91e05/greenlet-3.5.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f1e2db190db51c17433eee424803818cf0670bf049d9cfe0dd07be111d1aa7c4", size = 1648807, upload-time = "2026-08-10T13:40:27.471Z" }, + { url = "https://files.pythonhosted.org/packages/70/21/d29ff6a79dbd1ec1fe74c155d25d4c5a85e221d6b9ffc2cc7a709e7c8c39/greenlet-3.5.5-cp310-cp310-win_amd64.whl", hash = "sha256:740e544169527b82695ce76af2f7ad6f030904658f2f3921a1d245771fb88cfc", size = 322832, upload-time = "2026-08-10T13:28:10.85Z" }, + { url = "https://files.pythonhosted.org/packages/4e/a3/07297917485ee2ca85bc3c8dc6ed85ad3fffcf424047fba62671dba68e97/greenlet-3.5.5-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:be63afcbbccfad3dd95a1ba12ada84dab2ef32031973d80b5b92df67fa763a61", size = 294165, upload-time = "2026-08-10T13:25:17.987Z" }, + { url = "https://files.pythonhosted.org/packages/db/51/6f732f9314cda54c5fd48a7620c7160f4f286967e8045ad94b9d66ce80b7/greenlet-3.5.5-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a268024ce2d7d2b04694bf1594058981a9fa663d1df4b762dee499211ed7c1c", size = 613610, upload-time = "2026-08-10T14:14:33.829Z" }, + { url = "https://files.pythonhosted.org/packages/d8/c0/b27589e25d220289edcd4d582b2b17b83058d1a56d53d971b6ea1a34f10d/greenlet-3.5.5-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35cbb8bf55ace57fbccb4fb8622c4521713acd8691e77f4696d416ea7ca527da", size = 625481, upload-time = "2026-08-10T14:27:23.647Z" }, + { url = "https://files.pythonhosted.org/packages/51/2d/f2c928218ac52f26d7a2c188c171d1b7e728b23782cb3347e7b4fce1493a/greenlet-3.5.5-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cc6df89ec5302337adc9cf096221cbed2510fd444b0e0f1586cf0470740864", size = 624562, upload-time = "2026-08-10T13:40:48.064Z" }, + { url = "https://files.pythonhosted.org/packages/3e/4a/92fc51d5d35912f4f06eec037ba347985defd0be47463a010a325634d9d2/greenlet-3.5.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d9b454c5fc48aeaa7c4337813dbf513a6870468e426438a04d922c6d0fe63db", size = 1584909, upload-time = "2026-08-10T14:15:04.343Z" }, + { url = "https://files.pythonhosted.org/packages/ac/58/ed98b80ac5738c149a5258544843c45601ade1fd70f61740cdaead6351b3/greenlet-3.5.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03551ed792cb1b4fc0277a0c60dfd8c343894a0ba06fe60dcd22f568b433da39", size = 1651184, upload-time = "2026-08-10T13:40:28.879Z" }, + { url = "https://files.pythonhosted.org/packages/d8/be/b582ceb80cefdf9d8da34078714e4b12b3d16f509dee0f65e40a5cc8fc7d/greenlet-3.5.5-cp311-cp311-win_amd64.whl", hash = "sha256:ab3df3dffb58bf70564e93a5cec7941e4d9faa5a36cc4234a10d3131afe04f53", size = 323280, upload-time = "2026-08-10T13:26:07.495Z" }, + { url = "https://files.pythonhosted.org/packages/4d/18/5313c4c58598c38b0373c013e4ff2b3e6d258aaaa338f373335ebecdaddd/greenlet-3.5.5-cp311-cp311-win_arm64.whl", hash = "sha256:2b70a766135540c472ac1393d57c2e1b4a2eb85bf526a1e41e6d096173a8cee5", size = 307785, upload-time = "2026-08-10T13:28:34.874Z" }, + { url = "https://files.pythonhosted.org/packages/2e/7e/9ecd0285e3153532ae07aeb88063c43c72b4221cf0d4d123b02f3682e3ff/greenlet-3.5.5-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:49520f0c95a48b42cf55414b8e8479beb274ea70431afc33e3f79903c71f4380", size = 295809, upload-time = "2026-08-10T13:25:34.023Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/60e4bbcc89252037b18087f2ec16405d5b2d5be42dde191bbf3667e96102/greenlet-3.5.5-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55272212cbc5f43d1d723725ab931f1939969b7e9523882ca58b55061769d053", size = 611910, upload-time = "2026-08-10T14:14:35.18Z" }, + { url = "https://files.pythonhosted.org/packages/a4/17/cd5134be659cd4a443e7a61ae670dabec165a814c51162916d637b6dd38e/greenlet-3.5.5-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655bca754a2ef4efcb0eb48a94d3f4593536d0f3d48f8ed44343c01d16a92f95", size = 624198, upload-time = "2026-08-10T14:27:25.229Z" }, + { url = "https://files.pythonhosted.org/packages/78/ac/5c5b959999b6f09c3026b5dfe171575bc3121c5236ce74f495096f25b203/greenlet-3.5.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:147b25a42e5ca5be3d42356e8f608b37af715a1c196e9bf9d1627f3341adfe1d", size = 621439, upload-time = "2026-08-10T13:40:49.391Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8b/6acf112ed8aee499f25b4d6949820fb02ac950ff9c1f3d793bd5be0599f2/greenlet-3.5.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:27493374cff1d1b7919dc8126547f2aea582737e3046147b434b1e12de56389b", size = 1581342, upload-time = "2026-08-10T14:15:05.653Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d7/734e5f198888876b42d7616ff6644c075baf6b8a2412deadd6b0e1b8b20c/greenlet-3.5.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:12e2ee66c2aba86133f10fd99d6a8856c6d351ffb7be0e4d52ef2cc5fbb705b2", size = 1645744, upload-time = "2026-08-10T13:40:30.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/30/1f42b88dc587b5899ee50616ad56ee40cafaf225df4fb829f10183c62a5c/greenlet-3.5.5-cp312-cp312-win_amd64.whl", hash = "sha256:49ddacd36af37735fab103846f4ee4d18a492dde72730d1699c0c8ebe30d9f18", size = 324171, upload-time = "2026-08-10T13:28:44.472Z" }, + { url = "https://files.pythonhosted.org/packages/76/e5/4dee4d8d2e603fe5fdd7b444e63219f7b9bd852c60c6214511c7157cbe88/greenlet-3.5.5-cp312-cp312-win_arm64.whl", hash = "sha256:5f1b1ff4828cdc1aba4266aff814085d04a1d07959287219af021b838b265d52", size = 308362, upload-time = "2026-08-10T13:26:46.839Z" }, + { url = "https://files.pythonhosted.org/packages/fb/3d/8cef5f724ec0d4add2af8961d504535ec60c3cca9e464f6d03bdba29d85b/greenlet-3.5.5-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:b79fd2a5bc099b5e744f34c4c9a58954a5f4cb7529fb4b6e8446057d61b6edaa", size = 294730, upload-time = "2026-08-10T13:27:51.206Z" }, + { url = "https://files.pythonhosted.org/packages/88/4b/8e7aa3f514273aecff30a16ab1bac09ff54cfc7e6860fdd8058c37ff2499/greenlet-3.5.5-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:634cf15a233a949136879dd388e25d3296e16f3f1e217d2456797b8579ebc6ed", size = 614536, upload-time = "2026-08-10T14:14:36.589Z" }, + { url = "https://files.pythonhosted.org/packages/85/48/4e95e9dd5a8a397dc6a6345dd7f1935113d0fca4f85e89d3976da9cd988d/greenlet-3.5.5-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:499adea519f748407fc6806d20eedabac2884fd73b9f38d81236e190ba20dfef", size = 626924, upload-time = "2026-08-10T14:27:27.048Z" }, + { url = "https://files.pythonhosted.org/packages/89/5d/398a1c71fa7a277deeb376c999979de6786f08fc2d5747a0b9d6e11738dd/greenlet-3.5.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2eabb980975cba5b93a95f6f69287d05fc05ac955bfd6a320a7c083eeb52c0b0", size = 623906, upload-time = "2026-08-10T13:40:50.501Z" }, + { url = "https://files.pythonhosted.org/packages/04/1b/745450fc5ea9e0cb17d840d248f284db3363de736d362c7d2d883e3eadba/greenlet-3.5.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:03115c2e0a371999bf8ae616aa8d653f96641d4705c457aebaa187276e9f7537", size = 1581430, upload-time = "2026-08-10T14:15:06.853Z" }, + { url = "https://files.pythonhosted.org/packages/d4/29/d51b296e3191bb15d3d81ec375af1909e4466c0f395d744ed475801798a9/greenlet-3.5.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4441153ffba21b90d3ca89fe3d31f5c093ae6c0bf0cfdfc98f54cde22f95b62e", size = 1645684, upload-time = "2026-08-10T13:40:32.133Z" }, + { url = "https://files.pythonhosted.org/packages/12/63/369f1a1625e64e9e31df3963c6044056e3fdfa3fa3fdba3c54ffefa6e987/greenlet-3.5.5-cp313-cp313-win_amd64.whl", hash = "sha256:95c5b1f4b3a193f8a0c2de4bfdcb48d119f7f1063941f1de1f2168051b3e52dd", size = 324075, upload-time = "2026-08-10T13:26:58.974Z" }, + { url = "https://files.pythonhosted.org/packages/45/78/649cb5c09d4d81f6dd1444e75474a7206784743283a21d24171562ac4899/greenlet-3.5.5-cp313-cp313-win_arm64.whl", hash = "sha256:1af90aa4bc129883b340cdd6957a3bc74f60528a4993bbd1f53aaebe1d9981cc", size = 308260, upload-time = "2026-08-10T13:27:50.795Z" }, + { url = "https://files.pythonhosted.org/packages/7f/8c/080e881fa2be95ff1ddbd6994b2bab3b1a78df3b3fcab39306011764fcc7/greenlet-3.5.5-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d4a389a852e392a6366058651a20fa5ba40d979865aa81bea2ccbdc44805070d", size = 295309, upload-time = "2026-08-10T13:26:03.032Z" }, + { url = "https://files.pythonhosted.org/packages/25/cc/0ac614e6586c0e42d4cc281a5819150f4f43685744a4c5ff77139286409d/greenlet-3.5.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70b157cd319873e8b544ddc2de158f55bbd0a9b0218c8ce9332039801518e328", size = 661185, upload-time = "2026-08-10T14:14:37.867Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b9/6808725354be8ad305dfe5172377664fc9642d4fc043be246b3314cf4482/greenlet-3.5.5-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8bdfd1424abcf26832961e766570cae79efdb9599d709088c9cb6ef82b194926", size = 673419, upload-time = "2026-08-10T14:27:28.652Z" }, + { url = "https://files.pythonhosted.org/packages/42/2e/40c509967da7f254680826a2fa0dd22138ec79946c70b97542d74cde8b43/greenlet-3.5.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:182de51c6b572a705f2fafaab2e783bcf7d2760940229dfe73086cbae037af3e", size = 670822, upload-time = "2026-08-10T13:40:51.833Z" }, + { url = "https://files.pythonhosted.org/packages/2d/22/c3c2eee4a8fe191d6d1d183086c56133d646024e3d70bfd414829f64560b/greenlet-3.5.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8fec3f165dfe332e490c3247c0f6c23b0bfc45f06496ad7f00ddb00e3d35e4dc", size = 1628469, upload-time = "2026-08-10T14:15:08.11Z" }, + { url = "https://files.pythonhosted.org/packages/f7/87/25babd09b94cb1f03e71db815fde463f0262e40cfbd953d58a8d77311351/greenlet-3.5.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c6ce25fee6cabc8bf22cb8b52e642cbb821be5b9aec8094d07ff03378141b8e9", size = 1691952, upload-time = "2026-08-10T13:40:33.502Z" }, + { url = "https://files.pythonhosted.org/packages/2e/3d/5cc9701117ea4dc0eb7bf1f4f9b7888a6e2e5277ddfae095805ace50f2b6/greenlet-3.5.5-cp314-cp314-win_amd64.whl", hash = "sha256:7dffc5c859fe6059974df1e37d7923d654a83e2ae18fdd616994270e001115e1", size = 327458, upload-time = "2026-08-10T13:27:02.868Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6b/594fa2de7fae7629168a404a4305d7d7e31a5742c50a801b1839543cb93d/greenlet-3.5.5-cp314-cp314-win_arm64.whl", hash = "sha256:5e2afcfc4d4305dd715809b03da5cbe437c8984f61d8917751eb5fe4aefa3e07", size = 311146, upload-time = "2026-08-10T13:27:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/24/e0/50cd600b469e5734c72709b6b1838b6bc63f307b573c772c3132d6ecfe92/greenlet-3.5.5-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:0e5a7de979d764aea1f5b6e95cf92b5b37741b9823702041f34b126e7f690277", size = 305471, upload-time = "2026-08-10T13:26:20.568Z" }, + { url = "https://files.pythonhosted.org/packages/75/a3/77acd66dfc6387b5219b2080806c0cabb73c10eb1bb44b413c40a62015ba/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fef01bd457f11fc158b130ca0027a3c365693280e8e231b65bdaf57999f39f5b", size = 672470, upload-time = "2026-08-10T14:14:39.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/71/0d178142dca3ec19f46fb2212ae73d30ad53b9d548dc64804086033a7089/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5173a72310725a74afc82c164f0e52cb8ad0de62f2bb623f24f6c0cc07d80272", size = 679973, upload-time = "2026-08-10T14:27:30.072Z" }, + { url = "https://files.pythonhosted.org/packages/6e/31/46eb8567302eaf787abf88d09df014e14ae3baf460af1b8b0efdbd3efcd5/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44f08341873200ba8a60a8bc14ace3d91f1754f7fa7bc66157714a8cd420a476", size = 676634, upload-time = "2026-08-10T13:40:53.004Z" }, + { url = "https://files.pythonhosted.org/packages/a3/e9/b88bbf5b29970cb84172dc2c32aa3e5e579ceb94c808e81c826454138850/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d246c0db9a2513cd45f019ba178ea4d4d4705bd210ee465e2c15d76a1ab13874", size = 1637320, upload-time = "2026-08-10T14:15:09.317Z" }, + { url = "https://files.pythonhosted.org/packages/6d/8c/7631ed29cc6f0392f11830076e172ce4885e70b0bc2c1bce1731176d4b4e/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:72507285b5caa1d17904a3f7c322ca780823a54170a0e04ec3f37bcc60d4db71", size = 1697412, upload-time = "2026-08-10T13:40:34.924Z" }, + { url = "https://files.pythonhosted.org/packages/da/0f/f7dd935f9c4cb1be49098770587f54d8a78518e55c89bce86c4fb4109057/greenlet-3.5.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7805655781fb8f28a55d05fe57ed61f5f10f1892fb587673e3bb5264f28041f0", size = 331514, upload-time = "2026-08-10T13:29:20.611Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e5/681b01f8fbc1b55232822f99e8f8afeb78a55a7c76a7bf9dbdc7ccb03a6d/greenlet-3.5.5-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:c0db80fcd5b8aece93f66c64f78a786bbb6b96c5fe63ef5a5a4581ecf8bab206", size = 295975, upload-time = "2026-08-10T13:28:45.985Z" }, + { url = "https://files.pythonhosted.org/packages/11/f2/69b488cd9e7267bf4b0fe8cdebf25d8d6df680d21bdf41150d23e23d6652/greenlet-3.5.5-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b241c32f912ada659808d68e308c568baf577eebf757d15471472de0c18cfad", size = 666823, upload-time = "2026-08-10T14:14:40.222Z" }, + { url = "https://files.pythonhosted.org/packages/84/d4/d5bc2fdebbdda0c94555925ba79948b8395d75a7f6a36cc85dce5bab9f11/greenlet-3.5.5-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ef6a08349401d8eaf3cb12688ac8557de95788556b8631ef17555a4a173022c0", size = 677613, upload-time = "2026-08-10T14:27:31.543Z" }, + { url = "https://files.pythonhosted.org/packages/bd/93/542d8a3a90f3b35c6ad8bf7e56a03010287f2cafa289a5b7985b5207db39/greenlet-3.5.5-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f2e3d061b8e13aec2f0441689b3c71b244a20e5d274a52cb0f7e31bd1d139552", size = 675930, upload-time = "2026-08-10T13:40:54.205Z" }, + { url = "https://files.pythonhosted.org/packages/52/b5/89c9f2e8460d71101037d47a1feed11928615a5edd42370be290e0657eeb/greenlet-3.5.5-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9ab5f5b93655e77fe0d6c2dfd22b5eac751bb1f876d8ec21761b7c1fb9266007", size = 1633878, upload-time = "2026-08-10T14:15:10.693Z" }, + { url = "https://files.pythonhosted.org/packages/b8/60/297de93f3b02ac78a5e04d32bb8bbe3080f4a73d8ed95016561463b70618/greenlet-3.5.5-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f0e5a21bd4452a88cf032fc43c4a5b307ab1380eacb63b5988f9c0317885e773", size = 1696597, upload-time = "2026-08-10T13:40:36.252Z" }, + { url = "https://files.pythonhosted.org/packages/18/25/54c6eaff4f337fb670215e89eb2d00d9499487b658e709d4b477be4a342e/greenlet-3.5.5-cp315-cp315-win_amd64.whl", hash = "sha256:469dbb0a78625642f4a626cfd0c6e8bccc0385b5e49189b6308bbe849ec88a8e", size = 327700, upload-time = "2026-08-10T13:28:06.752Z" }, + { url = "https://files.pythonhosted.org/packages/67/67/857e88a36301caa0e029870132c2478bd55d896630321432afab03a3115f/greenlet-3.5.5-cp315-cp315-win_arm64.whl", hash = "sha256:2d57406c3efd32d7a81e17a674314e8bd00792cdab49ea3228a49aa1bfb2e769", size = 311750, upload-time = "2026-08-10T13:34:08.815Z" }, + { url = "https://files.pythonhosted.org/packages/10/e2/3144c0a116067ac1e30457b0139a94d60d1d36a86e015de68e9ac87cb3bc/greenlet-3.5.5-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:68184dfcf50ccaa8e864770fe0633a7e27250ea9329f8192ef47ee9ecfd78e1c", size = 306387, upload-time = "2026-08-10T13:27:00.897Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a1/cb4223a7e9b9f43b8807e8eb212358bfe2dfaa174a9ea2889eb1714dcba2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ec0dc0e59dc9c61af5c47348365ccbbd7addfafe0a93b00336ff3da2907bdc6", size = 676472, upload-time = "2026-08-10T14:14:41.417Z" }, + { url = "https://files.pythonhosted.org/packages/9e/cd/a154b4498e5d8f12ada291cfb3b8d596eadde2177f5bf09a9be699d2a446/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e604f58e35833fc46ef20302bcb314dddbfd3fcf33a4f936216d51dd678d63ae", size = 684238, upload-time = "2026-08-10T14:27:32.946Z" }, + { url = "https://files.pythonhosted.org/packages/bf/bb/b0031d260c2968a3c87deebc51d80c64e499377f993aafe06ee3b7488cc2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40239b5384f96da3963585cc6d7eaa9b56f8ae67e8d92cc82dd9e202fc847de3", size = 681246, upload-time = "2026-08-10T13:40:55.402Z" }, + { url = "https://files.pythonhosted.org/packages/9a/07/da554b71ab88e649da146e1065d86a48a5c5d92e50ab74ef41b504aa7f56/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:a1eaccf5c3a1d3e46dead602c72e6836731e8e245c9de6a27764567b6b62d4c0", size = 1642735, upload-time = "2026-08-10T14:15:11.92Z" }, + { url = "https://files.pythonhosted.org/packages/78/76/26a3782a051677668af9d92beaa47cd87ba9dd5072f762961144a03dd4c6/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:19e4e026fe20691f333b8eb1a3bc9625eceba8c3f9d62ec5a6f8581afbc6b5a5", size = 1700925, upload-time = "2026-08-10T13:40:37.656Z" }, + { url = "https://files.pythonhosted.org/packages/28/d9/fe7baf4190c2ae71f267efb9de21b3172bb35bc0ed1ef53dd6027d658e33/greenlet-3.5.5-cp315-cp315t-win_amd64.whl", hash = "sha256:712aee154f648bde84634654bb38bb78c69ac640c37a45c9effed800735049d8", size = 331829, upload-time = "2026-08-10T13:26:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/df/af/419a4e383bd600858a9b67e9b280a60fdc383ee3f2fe5b6c0c1ef04e74d1/greenlet-3.5.5-cp315-cp315t-win_arm64.whl", hash = "sha256:7f049911ee81a16a03c33d5450d8d5867d27f596ca5fb201b86f4524e874468b", size = 315093, upload-time = "2026-08-10T13:29:34.949Z" }, +] + +[[package]] +name = "identify" +version = "2.6.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +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" } +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" }, +] + +[[package]] +name = "ipython" +version = "8.39.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "decorator" }, + { name = "exceptiongroup" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/18/f8598d287006885e7136451fdea0755af4ebcbfe342836f24deefaed1164/ipython-8.39.0.tar.gz", hash = "sha256:4110ae96012c379b8b6db898a07e186c40a2a1ef5d57a7fa83166047d9da7624", size = 5513971, upload-time = "2026-03-27T10:02:13.94Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/56/4cc7fc9e9e3f38fd324f24f8afe0ad8bb5fa41283f37f1aaf9de0612c968/ipython-8.39.0-py3-none-any.whl", hash = "sha256:bb3c51c4fa8148ab1dea07a79584d1c854e234ea44aa1283bcb37bc75054651f", size = 831849, upload-time = "2026-03-27T10:02:07.846Z" }, +] + +[[package]] +name = "ipython" +version = "9.16.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", +] +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ipython-pygments-lexers" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "psutil", marker = "sys_platform != 'cygwin' and sys_platform != 'emscripten'" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/96/b150fe7e25a5a29ae9ac1374e71488639605d39a1ea4abb74c9ce33af235/ipython-9.16.1.tar.gz", hash = "sha256:5a3d1f9a47ff216d6cf9cf863124f6a2c1a198d1354c546a4d24a370a283b64c", size = 4515302, upload-time = "2026-08-03T08:36:15.571Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/8e/1239df488393d61076653bfb29f759d0f60cab8e030abdf7c17c31539b51/ipython-9.16.1-py3-none-any.whl", hash = "sha256:4acae635506f6d352d94c4899a19d5f85f8bc4d230932342dca556fdab1c69b4", size = 625974, upload-time = "2026-08-03T08:36:13.654Z" }, +] + +[[package]] +name = "ipython-pygments-lexers" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, +] + +[[package]] +name = "jedi" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parso" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/b7/a3635f6a2d7cf5b5dd98064fc1d5fbbafcb25477bcea204a3a92145d158b/jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011", size = 3119416, upload-time = "2026-05-01T23:38:47.814Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67", size = 4884812, upload-time = "2026-05-01T23:38:43.919Z" }, +] + +[[package]] +name = "loguru" +version = "0.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "win32-setctime", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, +] + +[[package]] +name = "lxml" +version = "6.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/3b/aab6728cae887456f409b4d75e8a01856e4f04bd510de38052a47768b680/lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40", size = 4197430, upload-time = "2026-05-18T19:19:06.424Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/da/dbe4dfc01ac226fb0504fad035f4d69f3202f3502e20e68537631daddd96/lxml-6.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:09dd5b7075dc2f7709654a46543ba1ea3c2e217b2ed8fbd413a8a945a0f40f60", size = 8541124, upload-time = "2026-05-18T19:17:11.589Z" }, + { url = "https://files.pythonhosted.org/packages/78/20/f7095ed9fc2c025f9cfe71cc6ec9f1feb05624edc1812423b5f1aecf3d4b/lxml-6.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f6ac4ef4d82dff54670227a69c67782ae0b811b5cf6b17954f1e8f7502fc0d1d", size = 4602783, upload-time = "2026-05-18T19:17:20.888Z" }, + { url = "https://files.pythonhosted.org/packages/4a/a4/65c63ca98bd129f6cff7b8c2fa48953ab058cc6005b541354e7dd54d8000/lxml-6.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:556e94a63c9b04716f8e4de2abb65775061f846e89331b6c5be79183a24f98ea", size = 5002687, upload-time = "2026-05-18T19:17:01.738Z" }, + { url = "https://files.pythonhosted.org/packages/96/1d/ab7a5c4b5a394d98a94e2d0fc67bab8297597426770dd4978370fbdaf531/lxml-6.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6bf403fbb3b3e348a561a5f4f0b9961835657981c802a1df03653eef8a9074", size = 5155099, upload-time = "2026-05-18T19:17:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/d0/b1/07603bfeeb891a2596d5c2a68f7d2f70f7d11c841ebe391412c69c2857b0/lxml-6.1.1-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1dde6131244bba38a17c745836ba190bc753fd73c9291666287fd0a3fa3dcf30", size = 5057225, upload-time = "2026-05-18T19:17:08.117Z" }, + { url = "https://files.pythonhosted.org/packages/7a/16/cb391ee4b90186fa16d9ebcbe3ea96c71b8da3b0686386c8dcbcc3c67d44/lxml-6.1.1-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98fc784c2c1440667aeedf8465bdfe10208acf0ead656a2c68627299f546b315", size = 5287643, upload-time = "2026-05-18T19:17:11.507Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d6/b619717f918fd76747448fdbaee0e769edbc70e659b5b5d0112b7020b7a3/lxml-6.1.1-cp310-cp310-manylinux_2_28_i686.whl", hash = "sha256:add8cf6ddf9a65116119a28ece0f7886e30af27ba724a7594305f1d1b58a92a1", size = 5412445, upload-time = "2026-05-18T19:17:22.182Z" }, + { url = "https://files.pythonhosted.org/packages/c6/80/12bc5390ac0a3edeb579d9535e5049a5dda663438728e179d52fb319c33a/lxml-6.1.1-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:cf9d57306d848218f3601fee7601fab1a327c942d56e2e97610583cb4dd74206", size = 4770864, upload-time = "2026-05-18T19:17:26.851Z" }, + { url = "https://files.pythonhosted.org/packages/0b/59/6500c09da3137f54f020e908d81cfc5ee3e8888e908fd380207afad7c2e6/lxml-6.1.1-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88136950da4d13c318bde414ce10219931937851327f44328f2df4d2c4614067", size = 5359594, upload-time = "2026-05-18T19:17:32.527Z" }, + { url = "https://files.pythonhosted.org/packages/f2/9b/f64b4cc6b7ebcf75d95af3cde934d254b5f2f10d4163928d838d86b6eb48/lxml-6.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cecdd5dfdc87b1fd87dbf81d4b037a544f47f4c744200a67013771682d67686a", size = 5107713, upload-time = "2026-05-18T19:17:04.402Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/c7388ad5d3a72315d2832dc1458cbf4f2af7f2b990b606ff4876efd04511/lxml-6.1.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:cd312b9692e831d2ffcad61eab31d91d4b4655a962e61de8fb410472cbcd37aa", size = 4803973, upload-time = "2026-05-18T19:17:06.545Z" }, + { url = "https://files.pythonhosted.org/packages/3f/22/76197f0bbf165f0b9e75be59be4997e5259cde973f12f098c1b54c7f5d60/lxml-6.1.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:5b7328b46d49fc9477d91ae8f6d55340347d827b7734ba3ea33faae0efef1383", size = 5349925, upload-time = "2026-05-18T19:17:09.743Z" }, + { url = "https://files.pythonhosted.org/packages/24/52/d2a0cfeccb9bcdc47c7ee05cdae5d69b48c9acf20997790a6338bb0d0b3b/lxml-6.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:37a58976370f36d9329d118ad0b953c5aeb9119ac9c6a4e258942a225d0573a1", size = 5309825, upload-time = "2026-05-18T19:17:13.831Z" }, + { url = "https://files.pythonhosted.org/packages/19/4a/b30944266776c2f49749ef2445aa7e78898194134b80ad776386f61b56ae/lxml-6.1.1-cp310-cp310-win32.whl", hash = "sha256:cea3f4c1af79af13cdb2da0c028111d8f8522d4f22a000c82385535f24e5cf3a", size = 3598402, upload-time = "2026-05-18T19:17:08.21Z" }, + { url = "https://files.pythonhosted.org/packages/9e/97/33691c66a4d7ec1a5a98e7c909a5b83ee45c7f7ba4cf92b1c4cf26e98079/lxml-6.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:3abf332af33a74288675d936fe861fd4344da0dd6622193fbc4f2bfbb35536b5", size = 4021295, upload-time = "2026-05-18T19:17:28.638Z" }, + { url = "https://files.pythonhosted.org/packages/d0/5f/26a4dd0e12b9456ff7b12a21af5b491eb6629680d1edd73f4140fd386bcf/lxml-6.1.1-cp310-cp310-win_arm64.whl", hash = "sha256:8dadbe5b217ff35b6a8d16610dd710219b59b76d13f0e3f0d9f36786206e4485", size = 3667717, upload-time = "2026-05-19T19:22:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/62/b0/83f481780d1548750b8ce2ec824073deef2f452d9cd1a6faff8507e3d16d/lxml-6.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:53b7d2b7a10b1c35c0a5e21e9224accf60c1bbfba523990732e521b2b73adef2", size = 8526461, upload-time = "2026-05-18T19:17:25.862Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/30fa0f808002c7329397bfbb24e306789c0b29f04aa5842c07b174b4216f/lxml-6.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ff3f333630ab480244a1bff72043e511a91eb22e7595dead8653ee5612dd8f3d", size = 4595375, upload-time = "2026-05-18T19:17:34.555Z" }, + { url = "https://files.pythonhosted.org/packages/4f/d2/edb71cf0e561581a7c5eb2626244320eb04e9f8ce6d563184fd668b45073/lxml-6.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a4bbea04c97f6d78a48e3fbc1cb9116d2780b1b39e03a23f6eb9b603fd61f510", size = 4923654, upload-time = "2026-05-18T19:17:42.917Z" }, + { url = "https://files.pythonhosted.org/packages/4c/77/1bc7eeb0de4577d783fb625aa092cc9357883bba35845a3666bf1259f3dc/lxml-6.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db1d75f6617a49c1c01bc7023713e0ff59ab32c9579ae62a7674c0e34f3b0b0a", size = 5067921, upload-time = "2026-05-18T19:17:49.175Z" }, + { url = "https://files.pythonhosted.org/packages/1b/3c/c0690d74bd2bc17bc03b5b0d093569ead597dd0bfa088bf99eef8c24e19c/lxml-6.1.1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a12689be69a28ddaa0ab99a5a1137da2afd5f8f16df7b5680b66f616d3eda1d", size = 5002456, upload-time = "2026-05-18T19:17:59.715Z" }, + { url = "https://files.pythonhosted.org/packages/66/8d/d1b3271af0c0f1e27e8472a849e4d2c65bc7766884b9ad2da9e76e145c88/lxml-6.1.1-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b73c339ae29b90fd2d06e58ebd555a751bde9cd6bbd36cc0281b9a2c94e9d8", size = 5202776, upload-time = "2026-05-18T19:18:08.924Z" }, + { url = "https://files.pythonhosted.org/packages/7a/45/689824ffb237fd10125ad273f32b28ff04dc6203c2822c85ff65a93df65e/lxml-6.1.1-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:752d3bbfe874715ccd0aec7f88d7fc623c0f1fd7aa7b3238a084e017bad2a009", size = 5329945, upload-time = "2026-05-18T19:18:13.673Z" }, + { url = "https://files.pythonhosted.org/packages/5d/c0/ef73af53767e958fd87d437c170f272e2f6e6c0f854939f133a895f1e711/lxml-6.1.1-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:6b1761fbf9ec984e2e9d9c589ef5f5fd684b7c19f92aadd567a26c5224958db6", size = 4659237, upload-time = "2026-05-18T19:18:18.657Z" }, + { url = "https://files.pythonhosted.org/packages/a0/5e/e1158e40397585e91cb0472374a1f63d0926a1ddeaa92f13d1a1ffe306d5/lxml-6.1.1-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d680fbcb768404c601ecb43519ecd8461f6954cb11c06a78962f666832ccfca8", size = 5265904, upload-time = "2026-05-18T19:18:24.883Z" }, + { url = "https://files.pythonhosted.org/packages/a0/16/8687e5d1400ed1c0bc41dace232ebb7553952b618ea1f2e5fb6e2cfbbe23/lxml-6.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:162af1091cd785f2f27e62d3547ae9bc58ec5c86dd314d67021fd02463708d83", size = 5045225, upload-time = "2026-05-18T19:17:20.073Z" }, + { url = "https://files.pythonhosted.org/packages/ca/18/d877bd1ae2e5ffdfd4836565aba350db31feb2f2656d6ce70316ed66a05e/lxml-6.1.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e9308ff8241c532df3f3e570f9a5aeed6c853f888512ba4b75638d7c11c95ef6", size = 4712721, upload-time = "2026-05-18T19:17:40.512Z" }, + { url = "https://files.pythonhosted.org/packages/44/4d/1f44fd1d770b10dacbf6b5c6e520f4d6e0708744930f719dc04e67cab981/lxml-6.1.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5f6994074ebae6ffb04447268e37dc16edc304f9859cf91acb86e0af6c1b395c", size = 5252549, upload-time = "2026-05-18T19:17:51.236Z" }, + { url = "https://files.pythonhosted.org/packages/64/5d/1d66b84f850089254c230ef6ea6b267a5a54e2e179a5d960036a05d501d7/lxml-6.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80c2dfadb855da477cf73373ad29a333535dedb9b12bad02c9814c8e2b43bf08", size = 5226877, upload-time = "2026-05-18T19:18:00.875Z" }, + { url = "https://files.pythonhosted.org/packages/ad/00/84c4b5302d42a2d0184f38d538c8a197f33b52a50bd4f7bcfe990bce3036/lxml-6.1.1-cp311-cp311-win32.whl", hash = "sha256:30a89d3ac8faec007453fb541f3f46807eeec88edd5826f6e3fe001752a2c621", size = 3594072, upload-time = "2026-05-18T19:17:12.714Z" }, + { url = "https://files.pythonhosted.org/packages/61/9d/2e2f7d876349f45e0f3e29f72da311668853d59b58d473a2dea4f0160135/lxml-6.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:abbefa31eee84842140f67acef1c828e28bba8bbf0c3bc6e5492a9af88152c28", size = 4025469, upload-time = "2026-05-18T19:17:50.566Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d5/570e6390e4110331e6208b2ba83d1482cc9146808ee118b22824a34c1070/lxml-6.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:dcb292aa7fe485ceff7af4f92e46c5af397daec5dff64871a528f0fc47a3cc5b", size = 3667640, upload-time = "2026-05-19T19:22:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6e/c4add832b6fc1e887125b96f880d7b9b70aae5248718e046b1704bcac4b9/lxml-6.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:104c09bda8d2a562824c0e319d0768ce26a779b7601e0931d33b09b53c392ef7", size = 8570821, upload-time = "2026-05-18T19:17:42.068Z" }, + { url = "https://files.pythonhosted.org/packages/22/00/ff3009c88e65de8011630acf8ab5a09cb2becd2aaf47fba2f3449f6224e9/lxml-6.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:25c6997a9a534e016695a0ba06b2f07945de682731ff01065b6d5a4474179da1", size = 4624252, upload-time = "2026-05-18T19:17:47.897Z" }, + { url = "https://files.pythonhosted.org/packages/42/95/bb63f0fd62e554fe078e1fb3c8fe9083c14ddc7ad7fa178d10e57e071ac7/lxml-6.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c921ba5c51e4e9f63b8b00267d06566e1f63407408a0496da2d1d0bfc819c7fc", size = 4930746, upload-time = "2026-05-18T19:18:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/eb/99/0013e8d9b5960f4f041cf0b73e2f80c23eb5205b1f7bfb20203243651359/lxml-6.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:54a7f95e4de5fb94e2f9f4b9055c6ba33bf3d628fd77a1d647c5923caa2cdcdc", size = 5093723, upload-time = "2026-05-18T19:18:34.168Z" }, + { url = "https://files.pythonhosted.org/packages/29/91/317b332636bfc7bddcff828d41b3307f50043f4b237e40849c333d80fa1a/lxml-6.1.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f2ec43df44b1f76249ee0a615334f9b5b060e1c8bd90e706dad2d14d02f383", size = 5005557, upload-time = "2026-05-18T19:18:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/42/2f/cc9bf06afe70f9c9093ae60855d9759da9db601ec4080f7473319666ffd7/lxml-6.1.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:70ef8a7e102a1508f8121aae5b0867abd663f72c14f0a9c937e6554cb4587b7b", size = 5631036, upload-time = "2026-05-18T19:18:44.858Z" }, + { url = "https://files.pythonhosted.org/packages/08/f6/af32e23e563971ffb0fb86be52bc5be5c2c118858ffc119bf6a9039b173d/lxml-6.1.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ebe6af670449830d6d9b752c256a983291c766a1365ba5d5460048f9e33a7818", size = 5240367, upload-time = "2026-05-18T19:18:49.217Z" }, + { url = "https://files.pythonhosted.org/packages/78/83/8555d40948b09ce86f1bd0c68a7ac31d07b1929f92cc1b074006c97ef2d2/lxml-6.1.1-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:27acc820660aaffa4f7c087f29120e12980f7779d56d8492d263170111284740", size = 5350171, upload-time = "2026-05-18T19:18:52.779Z" }, + { url = "https://files.pythonhosted.org/packages/63/75/5d92da93729b7bad783689e6496049fa40927b45bec7bf183c981de3ca70/lxml-6.1.1-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:1db753c9115ec7100d073b744d17e25e88a8f90f5c39b2f5dd878149af59671f", size = 4694874, upload-time = "2026-05-18T19:18:55.139Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b5/3aad415a9a25b822e783f15deeb4dffccf5113030f1afa2222dd929313d9/lxml-6.1.1-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4f469aebd783bb741c2ecb2a681008fd26bfe5c16a9a72ed5467f834e810df2", size = 5244492, upload-time = "2026-05-18T19:19:01.28Z" }, + { url = "https://files.pythonhosted.org/packages/f1/a1/5fcf7eb9904b80086aa47dcf0027de07b1bb990afad2e6823144c368ae04/lxml-6.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:766b010012d59470072c1816b5b6c69f1d243e5db36ea5968e94accf430a4635", size = 5048232, upload-time = "2026-05-18T19:18:12.67Z" }, + { url = "https://files.pythonhosted.org/packages/77/74/1f601b63c7a69fcdf10fa9b148c81da8442204194f6c55509cc485c786b9/lxml-6.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b8d812c6011c08b8111a15e54dd990b8923692d80adf35488bee34026c35accf", size = 4777023, upload-time = "2026-05-18T19:18:15.928Z" }, + { url = "https://files.pythonhosted.org/packages/a2/b9/7a78f51aec95b1bf780d78e12705a9f6533284f8693dc5c0e6724fa53d3f/lxml-6.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:fe0306bd29505a9177aac19f1877174b0e7422c222a59f70b2cd41633448c3dc", size = 5645773, upload-time = "2026-05-18T19:18:23.223Z" }, + { url = "https://files.pythonhosted.org/packages/a5/6e/98a7b7ad54e4e74fa1f20fff776913980619d0ebe5558232d7da6580bdd8/lxml-6.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5ba186ad207446c65d3bb3d3e0412b032b1d9f595e59861e2354798c5703d955", size = 5233088, upload-time = "2026-05-18T19:18:31.433Z" }, + { url = "https://files.pythonhosted.org/packages/65/d1/bc0ed2427bf609f2ee10da303a6a226f9c8bce94f945dc29a32ce55de6e4/lxml-6.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aa366a1e55b8ebfe8ca8ddc3cfe75c8ebade181aeb0f661d0cb05986b647f72a", size = 5260995, upload-time = "2026-05-18T19:18:37.091Z" }, + { url = "https://files.pythonhosted.org/packages/69/8b/6772e1a4b513fc50a8d931f19edde0e13ae6918510a1e13ff67864f3e5ed/lxml-6.1.1-cp312-cp312-win32.whl", hash = "sha256:126c93f7f56f0eda92f6d8c619edc463a4f23d9252f1c9d0405a76f25fa9f11a", size = 3596382, upload-time = "2026-05-18T19:17:18.37Z" }, + { url = "https://files.pythonhosted.org/packages/1b/89/45198e9624762af2dfd2cb8782598477ceb29f6e59caab560388ae1f4ec1/lxml-6.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:26e6eda8d38c1fcab1090dd196ee87cbd13788e531937610e2589085de074e77", size = 3997255, upload-time = "2026-05-18T19:17:56.781Z" }, + { url = "https://files.pythonhosted.org/packages/90/a9/7a54b6834088d9ae528a7b780584ba6a39a9457b0ac330479f20ffbc9449/lxml-6.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:6540377fbd53fe1b629172288c464fb18db11ce1fa7dc15891da10aa9dcc3e7f", size = 3659610, upload-time = "2026-05-19T19:22:50.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/eb/7e6f37c5584ccbb2ff267f56fd0339016938c1c8684cfefab9b33ffc2f36/lxml-6.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:68a9198d0fc122d14bb76837de9aa80cf84caed990b5b237f532ed87d3706736", size = 8559780, upload-time = "2026-05-18T19:17:57.661Z" }, + { url = "https://files.pythonhosted.org/packages/a1/36/587c2521cf23a2cd6c9c22108aa7528f683a1f195ed7ccd23a4b1786ad36/lxml-6.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7d47866cb32fb503450b6edc9df355d10dc49836af2e89901bd6ac6b0896d9d9", size = 4618006, upload-time = "2026-05-18T19:18:04.452Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ca/ab7bfe2bf4c972af5e7878262845ead3a24a929a9b04bc11c7c1ece6c82a/lxml-6.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb7c9811bfaa8b1ed5ed319f5d370dfbcaa59d52ea64be2a5a85e18195930354", size = 4924139, upload-time = "2026-05-18T19:19:04.873Z" }, + { url = "https://files.pythonhosted.org/packages/6b/55/a0c72851dfee5ecc689f949723a73dea457758912542cb955b108eaf0d8f/lxml-6.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:762ff394d5bd56da0cf034a23dcce4e13923f15321a2adfa2ac00201dc6d3fca", size = 5082329, upload-time = "2026-05-18T19:19:09.728Z" }, + { url = "https://files.pythonhosted.org/packages/f0/b6/0608f7d61a3b96cc67e5648a3d906e31a5082093e10e7be65b3886289938/lxml-6.1.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a088f287f7d8275a33c07f2cac6c50b9319309a0200a39e7e75d80c707723099", size = 4993564, upload-time = "2026-05-18T19:19:13.608Z" }, + { url = "https://files.pythonhosted.org/packages/4c/66/ae227524b066d29d55bf0b453d93d2d793c40218657d643dcbbca13b8faf/lxml-6.1.1-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e902da4b04e6b52e5893900d4b8ab46068f75f3561f01bf1080957f9fd932ed6", size = 5613467, upload-time = "2026-05-18T19:19:16.228Z" }, + { url = "https://files.pythonhosted.org/packages/a6/76/dbe4a00b50385e40194231dcfe5a12c059de7cf90e89c83407d2b085b719/lxml-6.1.1-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d4962d4c66bf830a7e59ed6cfc17d148149898a3aefa8ec6e59763e6e3ed085", size = 5228304, upload-time = "2026-05-18T19:19:19.354Z" }, + { url = "https://files.pythonhosted.org/packages/1c/01/00b1b8442ed2041793336868ba0b9ea4b13d7da7c085c6404c207a63bf79/lxml-6.1.1-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:581d4c8ae690a6609e64862dd6b7c2489635c2d13907fc2b20f2bc200ff1d21e", size = 5341607, upload-time = "2026-05-18T19:19:22.297Z" }, + { url = "https://files.pythonhosted.org/packages/63/36/1ad29931e9a4638bb707869f01d423a6c815f82152138d1a40dfcfde2b95/lxml-6.1.1-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:876e1ff5930ed8bf295ec5ef9a8155e9b6b1876bbf1deed8b3a8069311875a8f", size = 4700168, upload-time = "2026-05-18T19:19:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d1/a9536cecf9be18a0dc72d32bead283a2332d1ffebd2dd3ac70ce444686e5/lxml-6.1.1-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9eb9b5a968f6e0f6d640092a567e14529ff8cea2e29d00da6f78a79fa49f013c", size = 5232487, upload-time = "2026-05-18T19:19:28.603Z" }, + { url = "https://files.pythonhosted.org/packages/0e/77/b4fb1e03bf5d130e879214d3100092e386418807fb74dd0adc4b0a48f351/lxml-6.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:aa49e06d94aba782c6a02eecb7e507969e7e7a41b267f1b359bb35585f295d5b", size = 5044231, upload-time = "2026-05-18T19:18:42.246Z" }, + { url = "https://files.pythonhosted.org/packages/26/4c/d00daeeb0a5530c4028a9232aa1b93db3ef4ed2158c116ea73c79a9765b3/lxml-6.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:70cdfd80589d59e43e18005dd7244e8895e93db8ab6a620b7e23df5445a4e3d2", size = 4769450, upload-time = "2026-05-18T19:18:48.013Z" }, + { url = "https://files.pythonhosted.org/packages/ed/6a/715a3a8d156ce42f29cf014706f5410c2ff3b02267774110fc23266409fe/lxml-6.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:aad9aa39483ed8ec44d6d2e59e5b98a0d80676ef0d92f44bfc374836111f62f5", size = 5635874, upload-time = "2026-05-18T19:18:51.914Z" }, + { url = "https://files.pythonhosted.org/packages/45/37/0544bc21dde2a88f3a17b504e6fc79c0e01d25a33c2f6079724e9e72b9c7/lxml-6.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d49514be2f28d895c38cf9d2b72d7b9a07d00314519f456c0b50b53cfcf4c785", size = 5223987, upload-time = "2026-05-18T19:18:59.715Z" }, + { url = "https://files.pythonhosted.org/packages/4d/f8/f6a5e8185bcb28c2befae3d31f8e3df3b811cb0f47746517a81279fcafe1/lxml-6.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:47402e62c52ff5988c1e8c6c63177f5708bccf48e366dea4e3dcf1e645e04947", size = 5250276, upload-time = "2026-05-18T19:19:03.834Z" }, + { url = "https://files.pythonhosted.org/packages/c7/f2/1a2b9f1b7a49d45495369be7ef9ad05b262930f2eab3e3145706fca8083f/lxml-6.1.1-cp313-cp313-win32.whl", hash = "sha256:3483644525531e1d5762b0c44a8e18b6efba321b6dcf8a8952de10b037618bca", size = 3596903, upload-time = "2026-05-18T19:17:29.863Z" }, + { url = "https://files.pythonhosted.org/packages/e6/99/f4ffb024f238eec2131aaa09f3278fb6129cf892741bf68e1fc1afb8c100/lxml-6.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:a10bd2fd62e8ce916ececb342f348f190724a098c1faa056fdfb2a22ad5e8660", size = 3995869, upload-time = "2026-05-18T19:18:02.596Z" }, + { url = "https://files.pythonhosted.org/packages/d1/53/70eb8c5c6037f27448f1e3c54ebede9545a801ae63f0a7254afca4fe8e45/lxml-6.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:424aa57aca0897eb922aef34395bd1289b3b6f04e6bae20ea123c0c7e333cffc", size = 3658490, upload-time = "2026-05-19T19:22:53.846Z" }, + { url = "https://files.pythonhosted.org/packages/13/e2/2e325795566de01d0d7c3bb57d3c370616b2d07b01214e84eec5d3b10963/lxml-6.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:19b7ab10b210b0b3ad7985d9ac4eb66ab09a90b20fe6e2f7ba55d01a234345d0", size = 8577146, upload-time = "2026-05-18T19:18:17.765Z" }, + { url = "https://files.pythonhosted.org/packages/93/cf/5630b5e4be7d2e6bee8efe83865c925221103cf0221303b104ce134b01e2/lxml-6.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c08e5c694306507275f2290073350c4f32e383db15213b2c69e7ff39c1193840", size = 4623866, upload-time = "2026-05-18T19:18:30.669Z" }, + { url = "https://files.pythonhosted.org/packages/d2/51/3904907c063451cf8d4a5c9fe0cad95fa1f4ec57f4e3884fa0731bd7a305/lxml-6.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:74a9717fd0d82effef5c2854f0d917231d5324b5a3eb7275c43ac9fa32f97a14", size = 4950022, upload-time = "2026-05-18T19:19:31.958Z" }, + { url = "https://files.pythonhosted.org/packages/94/cd/9c7611a51c37a2830928405817cc5d56a97f64fab83cc3f628748b135749/lxml-6.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efe0374196335f93b53269acd811b944f2e6bdc88e8894f214bd636455484909", size = 5086695, upload-time = "2026-05-18T19:19:34.764Z" }, + { url = "https://files.pythonhosted.org/packages/da/d6/24e3b5906abb0b674ff2ae195bc3ce59708df2bcd17cf17703b2d7dd643a/lxml-6.1.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac931cdc9442c1763b8a8f6cd62c0c938737eafc5be75eff88df55fc73bc0d00", size = 5031642, upload-time = "2026-05-18T19:19:37.771Z" }, + { url = "https://files.pythonhosted.org/packages/2d/db/6ec54f99019838bff54785c51da07f189eb4676861c5f2730962b0d8d665/lxml-6.1.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aee395f5d0927f947758b4ec119fd5fc8ec71f07a1c5c52077b30b04c0fa6955", size = 5647338, upload-time = "2026-05-18T19:19:40.553Z" }, + { url = "https://files.pythonhosted.org/packages/42/3d/ef4dcfffd22d27a61805d8ed9f7fb888495bc6aa88648fa07c1eaa5586b6/lxml-6.1.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9395002973c827b3ed67db77e6ec09f092919a587022174554096a269378fb13", size = 5239528, upload-time = "2026-05-18T19:19:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/62/bb/37fb3f0dff146bdcfa78eec47879273820b2a0bf350ec236ce14bd0b1c26/lxml-6.1.1-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:73bc2086f141224ebddb7fc5c6a36ca58b31b94b561e1dfe8e073e3270fad1e7", size = 5350730, upload-time = "2026-05-18T19:19:46.307Z" }, + { url = "https://files.pythonhosted.org/packages/90/42/43253f168388df4fae1f38c01df36ddb9bee39e2048167b54cdcbae85ea3/lxml-6.1.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3779def59032b81e44a5f70096ef6bf2082f8d901937dca354474ba09782e245", size = 4697530, upload-time = "2026-05-18T19:19:49.889Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a8/c5a8504f81bbdfc8e7094c2c850cdb4ed6777fc4d5ddd9e5ab819f3b0d54/lxml-6.1.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:86c89b9d55ebf820ad7c90bc533410f0d098054f293351f10603c0c46ff598f5", size = 5250670, upload-time = "2026-05-18T19:19:53.199Z" }, + { url = "https://files.pythonhosted.org/packages/77/b7/c7e76ab18744d75e21f320ebf9ff9d1ceae2b54dd431ea5a64caf26c9672/lxml-6.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19607c6bbff2a44cf3fe8250abccd20942d3462473e0a721d01d379ed017e462", size = 5084485, upload-time = "2026-05-18T19:19:08.422Z" }, + { url = "https://files.pythonhosted.org/packages/31/31/b35c53f8ef7b7c31cacd23d3638652fff7bcd1deb6eedb709ab43b685908/lxml-6.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c6ed5141a5c7507cf3ee76bd363b0d6f801e3321adc35b5d825a23115faa5465", size = 4737635, upload-time = "2026-05-18T19:19:12.321Z" }, + { url = "https://files.pythonhosted.org/packages/d9/06/31f23c813a7fe8e0cb1b175e915b08c9bf4e86d225b210feadbdbe519667/lxml-6.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:62aeb7e85b5d60320b9d77eef2e773994e2c0ce10121b277e0a19804e1654a5a", size = 5670681, upload-time = "2026-05-18T19:19:15.001Z" }, + { url = "https://files.pythonhosted.org/packages/1a/bc/ce619bccc89b1fd9ad8a8e1330ee3f3beff9f2ff95b712d7bbcdd6e22fc3/lxml-6.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b1b963fd8f5caa68e99dfae060d54de1fe9cba899b8718b44a00cdca53c3e590", size = 5238229, upload-time = "2026-05-18T19:19:18.131Z" }, + { url = "https://files.pythonhosted.org/packages/2f/5d/b329acbbedc0b619ebc2be6cf7ee9ed07e80892c88d4dfd612c33805789a/lxml-6.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63876be28efefa04a1df615b46770e82042cce445cfdce55160522f57b231ccb", size = 5264191, upload-time = "2026-05-18T19:19:21.118Z" }, + { url = "https://files.pythonhosted.org/packages/d6/85/be36fb1425b30db3c3f9df75fe86343ebffb79e6320bd7f588e25bfeac39/lxml-6.1.1-cp314-cp314-win32.whl", hash = "sha256:7f7a92e8583f06b1fd49d01158143b8461cfcd135dcb10ec807270a3051bd603", size = 3657202, upload-time = "2026-05-18T19:17:39.509Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ce/3cf9a827342269f54d405a6202397de63f07c69cbd6ce7d183a3f0cba1e9/lxml-6.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:b2d444f2e66624d68e9c6b211e28a76e22fff5fcabcfff4deac18b529b7d4137", size = 4064497, upload-time = "2026-05-18T19:18:14.662Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3e/1a957bde8f0760039e627f94699f82caa782c9d838d86c3d28245ee67212/lxml-6.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3fd9728a2735fda14f4e8235830c86b539e9661e849665bf926d3f867943b4bf", size = 3741991, upload-time = "2026-05-19T19:22:59.111Z" }, + { url = "https://files.pythonhosted.org/packages/78/b2/00ed55b3a2efa4658fb795c38d1090ec9b3e8a6c3683d4441fa517f09c3b/lxml-6.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:787b2496d0dbe8cd180984e8d29e3a6f76e7ea34db781cb3bd55e4ba1ef8b4ee", size = 8827545, upload-time = "2026-05-18T19:18:41.193Z" }, + { url = "https://files.pythonhosted.org/packages/c0/73/74573db19baa618d5f266f2407898b087ff6927115b00b71e5fc1b700847/lxml-6.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2c8daa471358dc2d6fcf02165e80ec68f77871a286df95bc5cc3816153b0fd2c", size = 4735736, upload-time = "2026-05-18T19:18:46.761Z" }, + { url = "https://files.pythonhosted.org/packages/16/02/6f7061f4f95f51e545d48e87647c54791d204a4e881be4156e7a26ba5338/lxml-6.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:acd7d70b64c0aae0c7922cca83d288a16f5f6da523637697872253415269baef", size = 4970291, upload-time = "2026-05-18T19:19:56.215Z" }, + { url = "https://files.pythonhosted.org/packages/b0/02/55fc057d8283427dea7d6edb102e7a840239c77a64a983d92f62a304c0e9/lxml-6.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4f0dd2f01f9f8a89f565d000e03abcf0a13d692a346c8d22f628d49af098777a", size = 5102822, upload-time = "2026-05-18T19:19:59.223Z" }, + { url = "https://files.pythonhosted.org/packages/e4/48/8e1cf78d89d66850121d9255a2a24414c98f775da93b90cf976956c24b14/lxml-6.1.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b7e8a14c8634bf6f7a568634cb395305a6d964aeb5b7ee32248094bed3a7e2c", size = 5027923, upload-time = "2026-05-18T19:20:01.549Z" }, + { url = "https://files.pythonhosted.org/packages/ed/00/0632a0647612c8af24d26997b3b961397daa9d5b2581444805933629a4cb/lxml-6.1.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86281fbdd6a8162756f8d603f37e3435bfa38043adb79c6dc6a2dfee065e7525", size = 5595843, upload-time = "2026-05-18T19:20:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/bc/86/ab008a7dc360711b66858d61c80a5979a70a09f2aa2b05d9698df80b803d/lxml-6.1.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5d7152ec39ca7c402d8fb9bad86140a15b9503bd0c54484e3f1bbe3dd37ceca", size = 5224515, upload-time = "2026-05-18T19:20:06.381Z" }, + { url = "https://files.pythonhosted.org/packages/75/c6/2702ff375e728e34f56d9a45339a9cf7e4427e917f542225242d63a05afa/lxml-6.1.1-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:88d8cb75b9d82858497a5393e3c63cfbf03035225e4b35a49ed7ccb151e4dc0e", size = 5312511, upload-time = "2026-05-18T19:20:09.308Z" }, + { url = "https://files.pythonhosted.org/packages/b7/57/a5807c98f87a86f10ef9ffab35516df7c0f0c4b6d5d33e9f608ab9c04a31/lxml-6.1.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f64ec5397ea6a41fc1b4af0380d79b44a755b5531dcaccd9940fb260dca93038", size = 4639206, upload-time = "2026-05-18T19:20:11.704Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e1/8a0a2c35734812395f4da4eaf33748a7e5705bfb2a58b128da764339d5ec/lxml-6.1.1-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d34bbf07dbc7ca5970671b1512e928991fb5e9d95365636c9b2d8b4f53af405e", size = 5232404, upload-time = "2026-05-18T19:20:14.064Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e2/0e6a4dd5ad84d01d99aa7bae7cfefd4a760a0e0f8176818241de17d9b6c0/lxml-6.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:17e0e18d4ad8adbd0399291bc44845b69d9dd68439a3cdebdf35ff902ec05072", size = 5083769, upload-time = "2026-05-18T19:19:23.758Z" }, + { url = "https://files.pythonhosted.org/packages/a0/7e/161f33d463f6ffc1c7679104b65086dea120080d49dde4d238f015aaee2f/lxml-6.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:3ab541146f1f6968c462d6c2ac495148e8cdba2f8347700b2141b6ec5a75bf52", size = 4758936, upload-time = "2026-05-18T19:19:27.256Z" }, + { url = "https://files.pythonhosted.org/packages/f1/fb/2369825e3f6ca99305bf9f7b7085fda91c8b0922a89e54d900974aa3ef85/lxml-6.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2a0217714657e023ef4293500f65aa20fce6164c8fd6b08fa5bd4a859fb14b9b", size = 5620296, upload-time = "2026-05-18T19:19:29.993Z" }, + { url = "https://files.pythonhosted.org/packages/30/90/d61e383146f74c5ab683947ea14dc7b82778838ab9b95ea73a23b60d0191/lxml-6.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:05a82eb6e1530a64f26225b55cbd178113bd0b5af1c2b625f25e5296742c26d2", size = 5228598, upload-time = "2026-05-18T19:19:33.523Z" }, + { url = "https://files.pythonhosted.org/packages/76/2d/2dafd8149e94b05bb070690efd5bb2680720681e03ff03fc57d2b70a1105/lxml-6.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9e36f163528fc50cbef305f02a5fd66d404edf7049cdaff211dbc2cba5a7013e", size = 5247845, upload-time = "2026-05-18T19:19:36.649Z" }, + { url = "https://files.pythonhosted.org/packages/ce/68/b30e913340c380ddac9580c6e6230991fc37240ec4f64704833e4f3e2769/lxml-6.1.1-cp314-cp314t-win32.whl", hash = "sha256:649dda677cf3bd6ac9ae14007ba0c824ded8ce5808b53fc7431d9140399118c1", size = 3897345, upload-time = "2026-05-18T19:17:33.562Z" }, + { url = "https://files.pythonhosted.org/packages/3c/4e/9eb2af5335545f9fbcd7af57bcf87c6025d31eaa31b14ec184a6c8675328/lxml-6.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:793033d6c5cdf33a573f910d9bea14ef8f5771820411d118da8e1182edb53d5e", size = 4393350, upload-time = "2026-05-18T19:18:10.076Z" }, + { url = "https://files.pythonhosted.org/packages/7f/2c/0f1e93c636720e8a3eb59af2bfda99d98b55891e1c53bc30c2e0e865f01b/lxml-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c", size = 3817223, upload-time = "2026-05-19T19:22:56.823Z" }, + { url = "https://files.pythonhosted.org/packages/b5/32/86a3f0f724a3a402d4627937a7fc27b160e45e7012b4adf47f6e1e844511/lxml-6.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:31033dc34636ea6b7d5cc11b1ddbda78a14de858ba9d3e1ed4b69a3085bc521e", size = 3930127, upload-time = "2026-05-18T19:19:02.27Z" }, + { url = "https://files.pythonhosted.org/packages/40/44/d832e82af08723761556d004b1d04d281c09f9a8cecd7d3148548c9941a3/lxml-6.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3893c14c4b6ac5b2d54ba8cf03e99fe5104e592de491f19bd6b82756c09f8004", size = 4210769, upload-time = "2026-05-18T19:20:41.427Z" }, + { url = "https://files.pythonhosted.org/packages/6d/39/0dc5949f759ed7d951e0bb8c2f2d9d7aca1908d22352fa84a8afd2ea54af/lxml-6.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c07da4cebf6889f03ebac8d238f62318e29f495de0aa18a51ea14e61ae907e2e", size = 4318163, upload-time = "2026-05-18T19:20:44.702Z" }, + { url = "https://files.pythonhosted.org/packages/e6/fb/8ab3845fe046ba4cbf74536bcf6801a774b7caf4350de1c5d37f1f0a9e90/lxml-6.1.1-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6f0ce10945fab9c4c06ce14e22af9059d1a87493a9af4501a5b0b9187e21cf2", size = 4250945, upload-time = "2026-05-18T19:20:47.385Z" }, + { url = "https://files.pythonhosted.org/packages/68/1b/7553ab136894374ffae8851ec06f98f511cd8e66246e41b6be059d0a7289/lxml-6.1.1-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f8844cd288697c6425c9beba919302241e3278871dc6519515e72b04e987abcf", size = 4401664, upload-time = "2026-05-18T19:20:50.489Z" }, + { url = "https://files.pythonhosted.org/packages/db/a4/441aee36c6f6b249823d20fd91f9be9ab89d7c5a8ae542a4a4ca6d342d56/lxml-6.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:ed21202aec73cda4d55d1ce57b389aadb90ffb044e6cd1080b8347efe1b1ec84", size = 3508989, upload-time = "2026-05-18T19:18:38.158Z" }, +] + +[[package]] +name = "mako" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/12/b5fa2353e2754cd67fb9f83793fa48ff42c213a5da7e719869d2301f6ab8/mako-1.4.1.tar.gz", hash = "sha256:d7904710b662996425a21627710c4777c45053146942cf8a7aebf757c92b8c27", size = 410165, upload-time = "2026-08-05T06:10:56.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/54/12ed58d458474aaab5c3d180173e745a4fe131bb330370596876d19ff60f/mako-1.4.1-py3-none-any.whl", hash = "sha256:a359d9a94a541213958742b2698d0a7757bb83551767bc468a74b9905aba9617", size = 80010, upload-time = "2026-08-05T06:10:58.248Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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" }, +] + +[[package]] +name = "matplotlib-inline" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/c0/9f7c9a46090390368a4d7bcb76bb87a4a36c421e4c0792cdb53486ffac7a/matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79", size = 8150, upload-time = "2026-05-08T17:33:33.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mutf8" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/74/bfce3ab421b39503c6cd11a6bc812a763154082a4f69d55831d762e58fcd/mutf8-1.1.0.tar.gz", hash = "sha256:279796be8926ea53ac226a831727725387f3acadcfc5aa723f8a5ada559dae3a", size = 11827, upload-time = "2026-07-10T18:23:34.969Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/03/22e4f2f7feb962825dafa11a07d743ac67052c5aca5e1c80119f245a0d03/mutf8-1.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f6767a8e1e3dea2b8d9c98d14906049b6b63dcddc87c79e60d9c38a72b19640b", size = 13189, upload-time = "2026-07-11T15:30:56.907Z" }, + { url = "https://files.pythonhosted.org/packages/f3/02/8a0072308d9a0b60eb18c24356cc40320b046c376ae464cc47d8a610a86e/mutf8-1.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:afd923aaf773542d2cbfb8dbe6cbc2045498efa1e72e86d1ac4d3aacf7e14941", size = 13501, upload-time = "2026-07-10T18:22:47.189Z" }, + { url = "https://files.pythonhosted.org/packages/ee/cc/ad0e4c15567e58c9409e77f35bf209db0b2acfd715055c7a63e2755ec87c/mutf8-1.1.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:072ad993d20b244755a8d6ab0ba6fee5c974741dd0acf96df6cc585f0c3c61ad", size = 23408, upload-time = "2026-07-10T18:22:48.338Z" }, + { url = "https://files.pythonhosted.org/packages/51/7d/31df8202123f6d61ec001b4612bf180a04d994988d5fb7c105ef229dd47f/mutf8-1.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fea1eaa3fc7e9da366b9199d19540d311eb2c953436936b96c6c1f9e81ebf501", size = 24441, upload-time = "2026-07-10T18:22:49.301Z" }, + { url = "https://files.pythonhosted.org/packages/5e/06/8eeb7d5b713d95718cd556137161806a6da3840e972ec7018ceb85a2abcc/mutf8-1.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4aa9af7ce710850dae09ff5ef9bfcc27a3f48cd301b0b14d6b30a2ac12970cbf", size = 23889, upload-time = "2026-07-10T18:22:50.227Z" }, + { url = "https://files.pythonhosted.org/packages/f7/bc/a9070513ffbc221a2983bf5657a55f47b26ef350f1218ff65b86bf1912ed/mutf8-1.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:350cf53ff2922e322be8141a3b67e4455dc7df2832545113039a85697bb1b802", size = 23321, upload-time = "2026-07-10T18:22:51.123Z" }, + { url = "https://files.pythonhosted.org/packages/58/ac/b9e45da2f00b0120f2f43fd1781dd139175bb5e1051e13af208858e639e0/mutf8-1.1.0-cp310-cp310-win32.whl", hash = "sha256:f7e59cbf2e959029aecbcdb8bcb64fb6db56bf62945c44cf9a8c7b495ad36236", size = 14471, upload-time = "2026-07-10T18:22:52.015Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/da17d272c5c393580a4038332f2a569be8fcc3dc168e5245de6e83213212/mutf8-1.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:3e3cb461153512a22567ee03a2e3104c6359443161ae83ec386107fb4d09124a", size = 14898, upload-time = "2026-07-10T18:22:52.994Z" }, + { url = "https://files.pythonhosted.org/packages/9b/5b/4deca08eea67d85e6ba14e7cbe0060f16a04536119499e249ff5528efa08/mutf8-1.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:258e6fc37cbdb90f9ee571eb993b4632a6dd9d408159c33b72161cb5fecd00d6", size = 13506, upload-time = "2026-07-10T18:22:53.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/03/eb72e6d0587fc0daeb6162258efadf2ab3a3e90d2f07a7f88c7ca7a57180/mutf8-1.1.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8af3095f7afd5dfc32fb0a662eba659472357e83259b7b4f1f3298fb6d4f904d", size = 24685, upload-time = "2026-07-10T18:22:54.889Z" }, + { url = "https://files.pythonhosted.org/packages/fc/9c/3a9456bf13ba225e588adf4b091c4053b3f20099623d7dcba9883e5252c4/mutf8-1.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbcc40de7b02f81a0c1a8bbd43ebbf9e45c7f8bf57cf9d46176c6cc9fa65e17b", size = 25606, upload-time = "2026-07-10T18:22:55.826Z" }, + { url = "https://files.pythonhosted.org/packages/3f/47/c27bc134ad4643303d99388d6b4762b42b734fd097d5d0614c390af47110/mutf8-1.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f748e3f7dcb9f6b7e642bdf6168b72a7fd5674182a827236bf8c7165672d322b", size = 25013, upload-time = "2026-07-10T18:22:56.772Z" }, + { url = "https://files.pythonhosted.org/packages/05/29/31ff1ddcb34be6b6a80e84e0836daf2cce5de4420f6bda5b2dd555682350/mutf8-1.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3d2a46bf14e16011a113b7b839e3be6c1d5ccd47bc5a4897799af83309cc4ffc", size = 24603, upload-time = "2026-07-10T18:22:57.766Z" }, + { url = "https://files.pythonhosted.org/packages/e0/15/dd5425ebc2c330d3b5dd69bb19d7d1ec7ab0984de30614c19066e0bfbe66/mutf8-1.1.0-cp311-cp311-win32.whl", hash = "sha256:ce85b33e06592c457163c03e1856e21504286b7d3cab7dbe45cc51b8931e3b01", size = 14497, upload-time = "2026-07-10T18:22:58.858Z" }, + { url = "https://files.pythonhosted.org/packages/85/9f/ab528d9f43c640eece2d3940368fa96021c9d227466752f377c21de0fe9b/mutf8-1.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:bf764c02c9c6a1806cb6523052340dbf1c45b676a180230e85ac85144aea048d", size = 14899, upload-time = "2026-07-10T18:22:59.754Z" }, + { url = "https://files.pythonhosted.org/packages/83/35/a3074929620a649c6ad0557d55eeb25d2c22d1b2470b9d1bd9dd332bede7/mutf8-1.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d6682df2b8a7d6e8a769ae6b3295adce8ee032c020ce98f7bb31692b664882c", size = 13508, upload-time = "2026-07-10T18:23:00.662Z" }, + { url = "https://files.pythonhosted.org/packages/16/c2/667233e5871f4fd1cb4b4cfe6d540b5f88ff644b143bc78367cf68621eed/mutf8-1.1.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d5e852720303c709f0b8150603032518818c56d378d17c403cb42cfe399b9bb8", size = 24900, upload-time = "2026-07-10T18:23:01.635Z" }, + { url = "https://files.pythonhosted.org/packages/18/0d/b234f2ba0a9b91cfaf9ce1386c967a0b8284e5b1e101ee8162cf09803dd9/mutf8-1.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0e1abf471047c80793a0d29b4dccdc2b93e052245f38280c852cc14f966b1b28", size = 25811, upload-time = "2026-07-10T18:23:02.695Z" }, + { url = "https://files.pythonhosted.org/packages/26/63/5843c0b540884cee9b72658451dc156bcd1f30ea87ad4477f6ac48b1aa6e/mutf8-1.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3eb8e73167356159bfecbfbe30fccb5aa72167e4e56220e3e0ac0f34703a7554", size = 25191, upload-time = "2026-07-10T18:23:03.809Z" }, + { url = "https://files.pythonhosted.org/packages/7b/12/f4e3e6284ad5e7164c9110786f4498d5f4ac870e142d271f2d99d53c4281/mutf8-1.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:640f14ccb0b61c8f74de6d20291bac6512e6f1852888e3b9b2277e38291131da", size = 24809, upload-time = "2026-07-10T18:23:04.765Z" }, + { url = "https://files.pythonhosted.org/packages/99/d7/13c3730942c8c93c8b856330e8976d6271f6dc05f6f40ffc59314316b6c9/mutf8-1.1.0-cp312-cp312-win32.whl", hash = "sha256:2cd6b7f78a1a9a1041e9c1b2b565cf6586aaa64cb0ab6fb20b3e08dddb759384", size = 14504, upload-time = "2026-07-10T18:23:05.696Z" }, + { url = "https://files.pythonhosted.org/packages/de/91/22f6f24ed635082c349a53893cffb149b61d2223be6a7651b23d6747d1fc/mutf8-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:fce9b80cd05214a2c886ee936e587db381ea10f4c4d45ba81689c0b9b76e6ebb", size = 14895, upload-time = "2026-07-10T18:23:06.562Z" }, + { url = "https://files.pythonhosted.org/packages/16/df/dd8db9d0318e1dbaf98cac4acc6b4a83efe4cff130b561f643bdb05a6e65/mutf8-1.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2fe4c83cacf6fbd230a5a429a51afcdf56efbb7692938358b9eb572ef0cfa1f5", size = 13508, upload-time = "2026-07-10T18:23:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/d8/b2/042128bbcd8df8388fa370686a2ee1f770aed44d1f9c8d6aeda63cd10a8d/mutf8-1.1.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7569229befe0317125802d9220e5735771e3f0ac90bda0fa4ef2dac7d27bcd3a", size = 24895, upload-time = "2026-07-10T18:23:08.572Z" }, + { url = "https://files.pythonhosted.org/packages/3c/5e/f3d343ac37c9818f1e7a74675b040439b024b68458a9c07265a32007d541/mutf8-1.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f904baade198aaf87910166ac0e0d8c42970a63ccc3d230e7fc626456cf22d30", size = 25809, upload-time = "2026-07-10T18:23:09.538Z" }, + { url = "https://files.pythonhosted.org/packages/46/7c/946842eaea8772f99d44f921eb25322d3fca6126a799607a26c4a3fc19a8/mutf8-1.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6914897b0b682322ef85df9ded02869207174f4ae58434b060c58e7da9adb913", size = 25214, upload-time = "2026-07-10T18:23:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/66/51/9ea7a069459be457278cc0355c3b45ca30b4b9b930aa3095c48e8d3e4a76/mutf8-1.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e208659e973213c24bd78f84787adb23c0eb8beaceef77911e006a59a3bebce3", size = 24840, upload-time = "2026-07-10T18:23:11.515Z" }, + { url = "https://files.pythonhosted.org/packages/46/9a/8231b544b67a0c9383ab1e5c418526ed321eec1c432fab6aefbea7366944/mutf8-1.1.0-cp313-cp313-win32.whl", hash = "sha256:85ad5bb6c40ba10d18d7bf407c13a25f9fadeb27641fc691e04529609cdbcbd5", size = 14500, upload-time = "2026-07-10T18:23:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/5c/dd/a9ee3bf536f45c7ab5d144fc240abc215c683d88e6fdbb63edd0bb64ef35/mutf8-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:867270fa62a507c2a80591d5f444d96f8739507cb4a135b03319e37ca727f3c7", size = 14893, upload-time = "2026-07-10T18:23:13.418Z" }, + { url = "https://files.pythonhosted.org/packages/57/d0/8114ed0298fd2d90a6a9fe24ff8b2519db800c9c8523af2b5a8b39fddb53/mutf8-1.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:81dcc89c127bf4bf7453c9790032ab2fd2881a5edd6bf464d9e737e7d8a9dcdb", size = 13514, upload-time = "2026-07-10T18:23:14.389Z" }, + { url = "https://files.pythonhosted.org/packages/69/64/ff71996313f10fac965c99c1b2780d92105cae5ed349b4aa30d94a6b37a7/mutf8-1.1.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:31e8c701128647f8795054e30b0e2f1bb0bcd6e89ab50f0cbf9803413adf98d9", size = 24937, upload-time = "2026-07-10T18:23:15.339Z" }, + { url = "https://files.pythonhosted.org/packages/3b/14/30f26bbe63c0206206d06b7ab65a44ddb62408050670b01ef35a4df5693c/mutf8-1.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d51b2297b9e0908d21ee41962305c0552d918bcbcb5733c018e98afc4c5d0da4", size = 25871, upload-time = "2026-07-10T18:23:16.312Z" }, + { url = "https://files.pythonhosted.org/packages/fb/8d/11b0b4bff9d7cfc585c9d6974e2d4846dc50c722294a06c1c0287bbac3b1/mutf8-1.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:42abec0a8f96b03d27edd01a9e18fc375fd1be3166d8fd03b164f5b3cab0abd4", size = 25276, upload-time = "2026-07-10T18:23:17.233Z" }, + { url = "https://files.pythonhosted.org/packages/7c/6c/5541bc1d05c153ab617dbe7e2620db01653cb4dcab753c76cea6fce59283/mutf8-1.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ea196f3a49186a3a28e583244fe893d11f1077285b939fcfecc3b5eaf58882eb", size = 24854, upload-time = "2026-07-10T18:23:18.209Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0f/ecf865df12af83033a585bee2c750ba7f8e919e5c5d76af3e1f91759aa37/mutf8-1.1.0-cp314-cp314-win32.whl", hash = "sha256:e7801dbdd5f22763f21558e817804f9eb0c8fc94f7ea238c8f5380c2e1b23b62", size = 14692, upload-time = "2026-07-10T18:23:19.124Z" }, + { url = "https://files.pythonhosted.org/packages/bc/3d/3afe3481fe99959a0dc0e7122d8114d50c044e227d4693ed366d96faf2ca/mutf8-1.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:2696f757d799fa6f43584cc201fe8bf7a882de13459c5522b169c91ce4e09560", size = 15122, upload-time = "2026-07-10T18:23:20.011Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9e/e7b9b40db43806ab5cdac20deb705f08b874a6af0ec7d4614e191334c368/mutf8-1.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3364bd2f0b302be14d4d7e2ab4617bf81e99bcbd16d36cc135b344bb2aa46f84", size = 13644, upload-time = "2026-07-10T18:23:20.9Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/04886543e51cf665f7cabaf1f9fed0988141c0cce9eb499ad9b60c9b3b03/mutf8-1.1.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:63bdc07d838c05a5579ab00222192cf573057364e89471f8e64fbf453cfe8145", size = 25452, upload-time = "2026-07-10T18:23:21.842Z" }, + { url = "https://files.pythonhosted.org/packages/d7/3f/c3ba1c00f8ec49c29e77ce49e061cc1058f8c48d9ec74b7795361a3b2b0e/mutf8-1.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e28f11e5b8ab759fe662bad8c6cbbdab7420639613725b6875cc2c73995032d0", size = 26443, upload-time = "2026-07-10T18:23:22.809Z" }, + { url = "https://files.pythonhosted.org/packages/5c/6a/501f5b35728ede5b74460401c3c281ec54dfdd4acefd682aa7362d0575fb/mutf8-1.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7c257c6c88821c067a167accde6f5b4af6440d641d993a3a557e80314c076f4d", size = 25819, upload-time = "2026-07-10T18:23:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/f6/8e/f5fc3a213ed3ebe5d8912cd6ebc29c735661237a51226839d4d0aabc1fae/mutf8-1.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3c4ef2a7c2c807973e4e5c063cb5459f3979efe8f86f9e71eccdd86fc77b306e", size = 25397, upload-time = "2026-07-10T18:23:24.933Z" }, + { url = "https://files.pythonhosted.org/packages/29/fc/f75fefacb5cb7d42170628422827202770386ad98e27a0f2b59df1c605c8/mutf8-1.1.0-cp314-cp314t-win32.whl", hash = "sha256:3ba488375e86d185c187b39d90f8894b996128f721e8ff3eae72dbf6c66d92e8", size = 14828, upload-time = "2026-07-10T18:23:25.902Z" }, + { url = "https://files.pythonhosted.org/packages/c1/7f/8505e1bfa0fa6110f5aeef3a5e62bb234862da3c18ad129fb8e499116ae7/mutf8-1.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:a441d6542d874062e8223ef868195940f281051f4eba82c2d420de12bb133415", size = 15272, upload-time = "2026-07-10T18:23:26.951Z" }, +] + +[[package]] +name = "networkx" +version = "3.4.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/1d/06475e1cd5264c0b870ea2cc6fdb3e37177c1e565c43f56ff17a10e3937f/networkx-3.4.2.tar.gz", hash = "sha256:307c3669428c5362aab27c8a1260aa8f47c4e91d3891f48be0141738d8d053e1", size = 2151368, upload-time = "2024-10-21T12:39:38.695Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/54/dd730b32ea14ea797530a4479b2ed46a6fb250f682a9cfb997e968bf0261/networkx-3.4.2-py3-none-any.whl", hash = "sha256:df5d4365b724cf81b8c6a7312509d0c22386097011ad1abe274afd5e9d3bbc5f", size = 1723263, upload-time = "2024-10-21T12:39:36.247Z" }, +] + +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "parso" +version = "0.8.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/4b/90c937815137d43ce71ba043cd3566221e9df6b9c805f24b5d138c9d40a7/parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1", size = 401824, upload-time = "2026-05-01T23:13:02.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", size = 107025, upload-time = "2026-05-01T23:12:58.867Z" }, +] + +[[package]] +name = "pexpect" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.11.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/d7/e7bfbc86e9f99ff7807e24de7703f032e9c9ba80bb355cf26e0e9bc5a75e/platformdirs-4.11.3.tar.gz", hash = "sha256:66a73d38a849810252df809a3d8bcbda8e26f6c189920e7535ad608a48dbb5ab", size = 33050, upload-time = "2026-08-13T22:43:27.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/a9/c34aebedd3a4c9afe5101b1b8713710b3fec18087c8a36c35d2f909861bd/platformdirs-4.11.3-py3-none-any.whl", hash = "sha256:5ed065d443751de711da036041a7a214122efc4a4de393b3f4137ba5576540e7", size = 23491, upload-time = "2026-08-13T22:43:26.121Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +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" } +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" }, +] + +[[package]] +name = "pre-commit" +version = "4.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/89/1f3e8e1fc3e97de0fa963495832f581f025f29471602a309e48808244292/pre_commit-4.6.2.tar.gz", hash = "sha256:8f5d7bfb021ecdbcd9d49d89847082dd24172ccde534390081a679ad046e2441", size = 198670, upload-time = "2026-08-10T22:07:18.421Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/e2/bbb7129c9e7999a6b8ee9cca3b66486c25c423ab5a75f34071798b74ce94/pre_commit-4.6.2-py2.py3-none-any.whl", hash = "sha256:e2dde9a75d3bce11bd3831c26d134df00a2803c1d818be6a0383c3dcda25dc4e", size = 226202, upload-time = "2026-08-10T22:07:16.942Z" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.53" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/ea/39b988c938f75cb75d7045b5c69f8bfed47ee2152c8837fb403de29d6fb8/prompt_toolkit-3.0.53.tar.gz", hash = "sha256:9ec8a0ad96d5c56148b3f914aa79c1564c3fde5d2e6b876e7bc327e353cf8fa6", size = 435492, upload-time = "2026-07-26T20:56:14.758Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/6f/84908cad2d6aa5144abcf7b42709fe4fdb459bc640ec7ac5786e7693dabc/prompt_toolkit-3.0.53-py3-none-any.whl", hash = "sha256:01c0891d7f9237d5e339f7d3e42cdae80b7534abb1c7c0e3352efba6231492f2", size = 392288, upload-time = "2026-07-26T20:56:12.512Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, +] + +[[package]] +name = "pure-eval" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydot" +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyparsing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/35/b17cb89ff865484c6a20ef46bf9d95a5f07328292578de0b295f4a6beec2/pydot-4.0.1.tar.gz", hash = "sha256:c2148f681c4a33e08bf0e26a9e5f8e4099a82e0e2a068098f32ce86577364ad5", size = 162594, upload-time = "2025-06-17T20:09:56.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/32/a7125fb28c4261a627f999d5fb4afff25b523800faed2c30979949d6facd/pydot-4.0.1-py3-none-any.whl", hash = "sha256:869c0efadd2708c0be1f916eb669f3d664ca684bc57ffb7ecc08e70d5e93fee6", size = 37087, upload-time = "2025-06-17T20:09:55.25Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/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://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "pytest-timeout" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, +] + +[[package]] +name = "python-discovery" +version = "1.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/b7/ac44da2cf0e53ada0e419033c2d058219c95dc1403126f163304c9e814b1/python_discovery-1.5.2.tar.gz", hash = "sha256:45fd4f20a4e3f9b7bf2e0817870bc8e3b320a19658da177af800768c82dbf354", size = 82350, upload-time = "2026-08-12T14:05:26.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/45/689603d04b3bb8d7faa00f25c24acef993aab7813b3dbbfc472a459ab0b5/python_discovery-1.5.2-py3-none-any.whl", hash = "sha256:3e338c2d0f15dfaeea57493f4c2c6caebe0e998ea815c30ae8bf8ee21f1112d3", size = 38350, upload-time = "2026-08-12T14:05:25.113Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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://files.pythonhosted.org/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 = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/61/b3/3213589383f8f1b3938781bd1278713f6d18621a14992b3e81fefb8a5ef9/ruff-0.16.3.tar.gz", hash = "sha256:e76d33a347661a84b5be6d043d0347fdc745dfdcf825a8f4fed64b5e26eebdf2", size = 4891904, upload-time = "2026-08-13T15:17:13.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/96/493770daebd68c0a67f1549fdf519f53be51fc435186c0585bcc272fd76c/ruff-0.16.3-py3-none-linux_armv6l.whl", hash = "sha256:0c5710e247a58a4521e66e124ba9a74655b414f61ba3a2e9e3811e11098f48f7", size = 10902799, upload-time = "2026-08-13T15:16:27.382Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e6/2becf3942fddc29a29b8df47691d456fb1085391a694f74d84513251418c/ruff-0.16.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fe155130631a2471fd2e14a7a664a4dfbd7194b8229c3d7b2a40b21178639081", size = 11135539, upload-time = "2026-08-13T15:16:30.87Z" }, + { url = "https://files.pythonhosted.org/packages/3e/1e/4b8b72f0d006dbf19326aa99f9ca0ee2ff374187c4d301cf529a51aa06fe/ruff-0.16.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e2ed719e14aa64d895c2ee922594a90a43c861a93f0575a95ff8c47cdbd13eb9", size = 10475095, upload-time = "2026-08-13T15:16:33.259Z" }, + { url = "https://files.pythonhosted.org/packages/92/32/2201fa49ba1f6c101ee321e83f051ac7a4b8d07b0ef6b4d3f2772b302275/ruff-0.16.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e0b1da805eb043654645d74d5de1e5ce2edc686e40790d2b86f56d71cc06a84", size = 10668771, upload-time = "2026-08-13T15:16:35.65Z" }, + { url = "https://files.pythonhosted.org/packages/c3/66/4afc5c8363bd04d45effce1b7c8713ca037d7a6740b7451a2403a6e3a972/ruff-0.16.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a37bdea0bbe21780f590bf437d6412c8c4e1b6cd010f91a65c2c40c5e5f5f870", size = 10699568, upload-time = "2026-08-13T15:16:38.195Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/c67d246bf36bf1698551c56de39e95cd07f70e64433e0098e6267d77061b/ruff-0.16.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09571e6d1288ed9be475207a3ac04ada404f1cd898104be0f6ab8d7df438575b", size = 11499365, upload-time = "2026-08-13T15:16:40.623Z" }, + { url = "https://files.pythonhosted.org/packages/67/0b/00ecbceb99a263af7b12f6f05ac3c92bc47b905e91adc3f207a836e3bc01/ruff-0.16.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c18c5a101eb540010638cc1ff3c84944d3adb3df62b8d98ca8f22ba484d3413", size = 12311728, upload-time = "2026-08-13T15:16:43.564Z" }, + { url = "https://files.pythonhosted.org/packages/54/b2/b7b3bb54f4d3f7db504e476ad4ab8de530dceebe2c061384b2757ee419e8/ruff-0.16.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8457c44f15033c85ddbb77b15d451df9e24e4bd03b628396dd3610cedc3b8f82", size = 11699896, upload-time = "2026-08-13T15:16:46.209Z" }, + { url = "https://files.pythonhosted.org/packages/c7/30/4c468429ac195addc5ee1b717b6ab1b66632786737ca3b2ed3443fb0c26a/ruff-0.16.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:294b95c4ae0cda9388525c2047778aa758d6b8d4bb876fd4e9eaa3ebc92343eb", size = 11058736, upload-time = "2026-08-13T15:16:48.823Z" }, + { url = "https://files.pythonhosted.org/packages/43/67/7a113cdaddf24b64d7f75b1242a99d04c82fcef4f6921fdbb832beaffb5f/ruff-0.16.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3d0c7c40c87c2a820509c31ba007968da6e1306468c067b2d82fbfdbcd0e8474", size = 11586911, upload-time = "2026-08-13T15:16:51.913Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c1/2e66f24c0f3ead25a5e660111778685e505e5da353c82802bf49f0cbe7b9/ruff-0.16.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9f738c0fdfa8eed0b2ce7fb27ee7258208a92a68d7949e62aa15164bc7b389da", size = 10954265, upload-time = "2026-08-13T15:16:54.763Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ba/4cee23bf52cba9a058d3726de623624daf50ef9638868edd86f4126157f6/ruff-0.16.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fb785f0be25abe69d320415cd4f833b59e17ba7613d9ba6a958023b6bceb0a50", size = 10709886, upload-time = "2026-08-13T15:16:57.339Z" }, + { url = "https://files.pythonhosted.org/packages/82/df/7da7194fa5d9dc0a285f7e6fa5a4722e7c63faac0b45b614ded9314363a1/ruff-0.16.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c5536e3acfbf9563085aa2be7b13c629c3077e902afc5b941ac44024dbb9f506", size = 11210392, upload-time = "2026-08-13T15:17:00.171Z" }, + { url = "https://files.pythonhosted.org/packages/35/85/7795f6e817af050e7517bf3e7aa9b061cce70ef33d280aad902c956c1ecf/ruff-0.16.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a2d85c02f9b8e165d85e6779184d38c4132de12603dab59c51c28e22584f9e4d", size = 11626910, upload-time = "2026-08-13T15:17:03.299Z" }, + { url = "https://files.pythonhosted.org/packages/78/9b/475b927cf27a5cbbda3c7bafb69ed6ff77e1d7923d5d85f17c2749d7ae32/ruff-0.16.3-py3-none-win32.whl", hash = "sha256:388cdf2166642bd9b13d52b5932d3170f34f8abed7e8d9a855f1d84b83645a0a", size = 10931415, upload-time = "2026-08-13T15:17:05.726Z" }, + { url = "https://files.pythonhosted.org/packages/b2/99/e2a2bfc4fbf0a1e8a916bc9ebe6fe6c58cc34c28e0ffc6ce281d572d1c2e/ruff-0.16.3-py3-none-win_amd64.whl", hash = "sha256:e80a7d69ca2a6d1c4d352ec91458cdca6e56c83cdbcabd93e4abe1e53591d948", size = 11445993, upload-time = "2026-08-13T15:17:08.353Z" }, + { url = "https://files.pythonhosted.org/packages/69/3e/4132e539aed78c148854d4997a2685b0ed4dc4e87110b59ce528564e184e/ruff-0.16.3-py3-none-win_arm64.whl", hash = "sha256:b8ca152da82c1acc1fa8d5874b15951935f0eef46f10e6954c83859011b6178a", size = 11399302, upload-time = "2026-08-13T15:17:10.908Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/21/77b4c147963073040dc3c3a5cb7a8c3001a1893c0209432cb77f9df836aa/sqlalchemy-2.0.52.tar.gz", hash = "sha256:5e2d46356ac2ccb7d268ab6c2319ac6a2b42f1b8d5fd8bd3d46855cd82abee97", size = 9945637, upload-time = "2026-08-11T19:07:09.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/d7/e0354e7334d33ea2795db3ecbe2977026c05a1ecf8ba4b5953c329872453/sqlalchemy-2.0.52-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a7438774e1091192fc50a2bd8ceff5c596912d00ecd46587e88effdea7826101", size = 2172616, upload-time = "2026-08-11T20:58:21.078Z" }, + { url = "https://files.pythonhosted.org/packages/5b/64/98eef682e6946eb1b4195a9a2393db4662ebfcc89f823ae78b938765c3c0/sqlalchemy-2.0.52-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c1b7ed45bf87b214e0a9def9c2313949067efe6269db5ef18d542ee13250af7", size = 3279798, upload-time = "2026-08-11T21:00:03.535Z" }, + { url = "https://files.pythonhosted.org/packages/20/05/5b96afc1407c314347ad006b72bb251fb68ef84d05505ebf8a39bc47fcde/sqlalchemy-2.0.52-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:309cc8ba50fc5d2174189dfcd49cdf7aa711f8346afcff19f2642ae4fc449c14", size = 3277555, upload-time = "2026-08-11T21:05:47.932Z" }, + { url = "https://files.pythonhosted.org/packages/50/69/ce6776724511d1b5dd40477b08d6a5f0953a45375e092dfa852b1857732c/sqlalchemy-2.0.52-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2f9eccf8793c8c3f8dd2dfd11b9e400cb27d1d19370ef732b66017e212107822", size = 3231246, upload-time = "2026-08-11T21:00:05.184Z" }, + { url = "https://files.pythonhosted.org/packages/72/19/ab0cb9ccdafa2419c796ae62f8740aedb903f1e93bb326064b1a0147e458/sqlalchemy-2.0.52-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9255ceb65a80c1b001129060b63ee776a2e9c288be3b662be36dfbb888fffdcd", size = 3250763, upload-time = "2026-08-11T21:05:49.664Z" }, + { url = "https://files.pythonhosted.org/packages/41/f2/3c9b54b61bec4f493c0007dc9e2700c963c5b32667e21a506f1aca7a8115/sqlalchemy-2.0.52-cp310-cp310-win32.whl", hash = "sha256:2e15b1d1116a64fc399b8c2694a83f3e792fdc58df28514a81e1dc4f8cf22729", size = 2132169, upload-time = "2026-08-11T21:09:48.108Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a1/934bb6cf543a398c72784d1fc777eb530559c16ebe1549fa7611e5989ce9/sqlalchemy-2.0.52-cp310-cp310-win_amd64.whl", hash = "sha256:11560064cc4696e772298b6221ede59e646386d9f2a85d549365473b972f7850", size = 2156289, upload-time = "2026-08-11T21:09:49.429Z" }, + { url = "https://files.pythonhosted.org/packages/6b/08/cc5f7627b92f1456bc0b5fb7e98af4600248abe422a44da0d17a3fe6a448/sqlalchemy-2.0.52-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0c3ce43907374889f3352bdcc6195c970148a2cb71574cd0237a5071a37fb6c", size = 2172460, upload-time = "2026-08-11T20:58:22.429Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/9a2abad8bfc8fdcd38c64adc056aeefab7aaa96ecd32f5e8c140e6375f17/sqlalchemy-2.0.52-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a0d48c4b80717c61385b4e966e087c839a66cfd7b780641dcb428f4dba65608", size = 3355720, upload-time = "2026-08-11T21:00:06.746Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/e75597b5841043e3c74055d00d4feb53d9a49a5c89ba2450d2d9aab53597/sqlalchemy-2.0.52-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:938325a5373267afc53bfbe72983b20fbd64ca47842aac62433c3da1137ecff1", size = 3354394, upload-time = "2026-08-11T21:05:51.454Z" }, + { url = "https://files.pythonhosted.org/packages/12/25/410fbc6c2f1fa8310f4ef1b6847d47d0ac1c042c7b4e81eaaca063d030a9/sqlalchemy-2.0.52-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5f8438a98d49424acf69d0d53c0a522951dfe49a6f2d86417fbb37ad3066ab43", size = 3306991, upload-time = "2026-08-11T21:00:08.603Z" }, + { url = "https://files.pythonhosted.org/packages/b2/ba/25ffd5c24681ea4b46e62c80ceca8200ce204de1773366321306cf3f608a/sqlalchemy-2.0.52-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4699dbb8d396d199e7e78fd4d525e3ad3d6008a9c8c0160b87e74c606c2c3736", size = 3327454, upload-time = "2026-08-11T21:05:53.368Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f1/0f1b1d4800e51218e736a06ed55a3b2a59c257600bbaca7673bf13d2dbec/sqlalchemy-2.0.52-cp311-cp311-win32.whl", hash = "sha256:cef328349452ae152637df4d11ce5a0919ecdf0a363e16c830c3518ee33bde72", size = 2131248, upload-time = "2026-08-11T21:09:50.765Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f0/04d2ac5ad66f3d31278f37064ed5f5ef3fe653f7bdaa67036663f223d186/sqlalchemy-2.0.52-cp311-cp311-win_amd64.whl", hash = "sha256:f1c850792a3b25a3ad74dade3f05e4f402cdebfea27438bcadafaa1617f77bcc", size = 2156943, upload-time = "2026-08-11T21:09:51.979Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d5/1b77a026d161f98a08f11af1a5f6c47b98ee7c7e2648af525a1004826c78/sqlalchemy-2.0.52-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:be8c49131665dfe2cc74c498aa1240ffb548d0fd901325dd11c2c7a18956f727", size = 2170940, upload-time = "2026-08-11T20:58:11.25Z" }, + { url = "https://files.pythonhosted.org/packages/54/bd/f444444adb37b5d53753fb1730ee7a421628e2e3b756c4da461af7e6394a/sqlalchemy-2.0.52-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b2d9e507a458832adcfbd8af6e2036ddf069b7710b799448542ebccae2dceee", size = 3383415, upload-time = "2026-08-11T21:02:38.534Z" }, + { url = "https://files.pythonhosted.org/packages/be/57/2eadf93a552568c57e8680b7e58bb5e9770d80942a1bdbaf4f2f63f0d7c8/sqlalchemy-2.0.52-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8738008376d22f30f411ea3efecf39b51110b6996d80bb73786f30bcfdd5fd3b", size = 3398577, upload-time = "2026-08-11T21:16:59.092Z" }, + { url = "https://files.pythonhosted.org/packages/15/c3/2887cf9dd111d1fbf05d22165b404c221ef43e029f7a2695e7302f27a7cc/sqlalchemy-2.0.52-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37a4d548327b6cab9c7d8cdb4e0e82feabee0110c4d150059068e2d1cfbd99ee", size = 3328225, upload-time = "2026-08-11T21:02:40.183Z" }, + { url = "https://files.pythonhosted.org/packages/02/0f/466bdf9e1feeeef5587f868c187d8687e21ff8c85b1775e9041130181132/sqlalchemy-2.0.52-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e49f51a5d59857a7a0dcaf9469febf7197d9394bd88f00d69c2c4e848112cdbf", size = 3357374, upload-time = "2026-08-11T21:17:01.076Z" }, + { url = "https://files.pythonhosted.org/packages/22/20/5c2b4583904af4173076dda1c9e53c9e2ffc7a702d2efde0216bbacbf7cb/sqlalchemy-2.0.52-cp312-cp312-win32.whl", hash = "sha256:afda3ec521d0517d0de783fc70030775841900896d832de5bbd066549290470e", size = 2129366, upload-time = "2026-08-11T21:14:50.991Z" }, + { url = "https://files.pythonhosted.org/packages/ed/06/543dab8ef62d4e9fb96fb31a30c2b8b14a8763bccf48d428294d6b3041c0/sqlalchemy-2.0.52-cp312-cp312-win_amd64.whl", hash = "sha256:2d5e53e36e37129fe0be8b9d08b6e4052c10a963ee6cda56c8c10dcc194b99ca", size = 2157344, upload-time = "2026-08-11T21:14:52.453Z" }, + { url = "https://files.pythonhosted.org/packages/7f/18/e30c6fe1eca1bf34a39fbdd6066121cc9974c850faf6f349eac563697a26/sqlalchemy-2.0.52-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2eb3c6a64b1bfe6704777cfd504e7b8ad093a5f3e03ce67663a5e6742f294e43", size = 2167724, upload-time = "2026-08-11T20:58:12.679Z" }, + { url = "https://files.pythonhosted.org/packages/d0/56/2e17d161a4f7ecc1c2ffb93e607b4e1898bb551b451b283235acb8f6ce47/sqlalchemy-2.0.52-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:923bb183c1dc64fdf7b717965e3d59938ec4f8b8710b419a21ce403e5da9a9e1", size = 3321189, upload-time = "2026-08-11T21:02:41.932Z" }, + { url = "https://files.pythonhosted.org/packages/cf/b8/8490916e893f3f8d74dc9cc54c078619364999dee37047a188e73abbc852/sqlalchemy-2.0.52-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:651d6d8782e80679e6151707c7b490834d46ada526328895abf567f25e63d29c", size = 3338185, upload-time = "2026-08-11T21:17:02.597Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f7/752cc8ee453da222829b3f5c4613614bf750d97429363b70414fa10478e4/sqlalchemy-2.0.52-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b08cddb8989775e3c88799d86704bdfc3ee6e9846118201aa5997f16f27e3a15", size = 3271698, upload-time = "2026-08-11T21:02:43.963Z" }, + { url = "https://files.pythonhosted.org/packages/51/e6/074ade0c07b9e4c8e8bca46820320ed94df9702afdb6f2af06623068d2e6/sqlalchemy-2.0.52-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ab66fa9618269390d4dfa222f2f2f88f7bc4bf5da13905131b818217db7e8057", size = 3308936, upload-time = "2026-08-11T21:17:04.172Z" }, + { url = "https://files.pythonhosted.org/packages/66/07/557c0d04716705599227945ac14e0a17ad0338e899f37d8c2ddff4dcc663/sqlalchemy-2.0.52-cp313-cp313-win32.whl", hash = "sha256:c63bda077685c85ca513286547a531ba57e7a68cf0a7ed3bafcc2bbd18896f4d", size = 2127308, upload-time = "2026-08-11T21:14:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/96/4e/226eda27654318ce525d043025221f689abef883da2c7126f9065121618c/sqlalchemy-2.0.52-cp313-cp313-win_amd64.whl", hash = "sha256:9876b09b9f1ce7398b0ffece585c0a911244c53191187341f6bcae640e133751", size = 2153876, upload-time = "2026-08-11T21:14:55.527Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f5/71cb30af58c9b80a4e1fac0b73bb48f86d497a774a6a2eb6d2f1e657bb73/sqlalchemy-2.0.52-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:410d52be41d17f1a236d19520fbe776257dc16516ed06bd16d433311842aefd9", size = 2169537, upload-time = "2026-08-11T20:58:13.855Z" }, + { url = "https://files.pythonhosted.org/packages/4c/93/d07ebd645d1b07b6b5ed63450a70f063a346a7e0f2c8810daf2e532400cb/sqlalchemy-2.0.52-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfe9ce533dbe4d0a2ae1486546619bd30b76bcd670539a44d910361376175f5e", size = 3319606, upload-time = "2026-08-11T21:02:45.829Z" }, + { url = "https://files.pythonhosted.org/packages/ae/5c/290c84c7c2566ecd3b65baaae0fddec9bc33b033b398a06123bb86fbfc6e/sqlalchemy-2.0.52-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:812bae5138bfc0aa46fb0686da0fc7f581f68e2bbb05bc24c3713bebaedd1437", size = 3323642, upload-time = "2026-08-11T21:17:05.675Z" }, + { url = "https://files.pythonhosted.org/packages/13/f5/2cc160590ca49173359557880b92a0572293ccb899e8f6cedf150c5a3ddf/sqlalchemy-2.0.52-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:50bff43b632a56fbf5ed9afdd76307e1512b62051bcd5afb341ae67205bbb6c8", size = 3268125, upload-time = "2026-08-11T21:02:47.649Z" }, + { url = "https://files.pythonhosted.org/packages/35/f3/ea8933fc9f7d1353e9c2ff9965eae687c4cef181120574591ed2fa0633e1/sqlalchemy-2.0.52-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:49565daf5af554f538e23aef1fc81a95a4e49658f152285e45c02f5fc44f04cd", size = 3289516, upload-time = "2026-08-11T21:17:07.267Z" }, + { url = "https://files.pythonhosted.org/packages/45/67/05cf86541c1e1716fca1e4a996954a439cd74501707cda607fb7cb02ef50/sqlalchemy-2.0.52-cp314-cp314-win32.whl", hash = "sha256:ab9da41e61b9979b910499d633b241df20c51ee5037e5405b11c2faac3cbe1a2", size = 2130249, upload-time = "2026-08-11T21:14:57.273Z" }, + { url = "https://files.pythonhosted.org/packages/96/d7/8ac6ffa1e36169e762ef65bd835046abb2251b1bc17f8f6708e14ed8d31f/sqlalchemy-2.0.52-cp314-cp314-win_amd64.whl", hash = "sha256:a593db51b3bae75db17a5738ad5f992244b3a03863f83c28117ee482c6a3f76d", size = 2156718, upload-time = "2026-08-11T21:14:58.667Z" }, + { url = "https://files.pythonhosted.org/packages/dc/4b/e01a737eef378e734cc6394a82248a6ce13b167dfa36c731075ce9fc9c64/sqlalchemy-2.0.52-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1e61d08bdf4ee2f41024569e3400de7d6734ba498144766b11260936ccfa582", size = 2190344, upload-time = "2026-08-11T19:53:21.393Z" }, + { url = "https://files.pythonhosted.org/packages/b3/3f/3582293d1e185e71d19d7c731c3e2ee20ba21981c4a1115c0806c1f62120/sqlalchemy-2.0.52-py3-none-any.whl", hash = "sha256:3b81b8363a919ce53453591cdb93702e6bd54ade6c4fa2f468fc053baee5ed89", size = 1950700, upload-time = "2026-08-11T20:47:21.603Z" }, +] + +[[package]] +name = "stack-data" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pure-eval" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, +] + +[[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" }, +] + +[[package]] +name = "traitlets" +version = "5.16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/2e/a7fbfe268c8a3b32546930c0297c101d65a4a14c304ad5790a9f478f0e4e/traitlets-5.16.1.tar.gz", hash = "sha256:ed900c2b631aa3a112811139fa97b8d2c3bad5e989656bba4b7e52c7852c18c1", size = 166137, upload-time = "2026-08-03T08:32:36.848Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/66/0d785f0bc5e4315a96c989bb476d0fc07ea4f85132550c7b156ca2035d52/traitlets-5.16.1-py3-none-any.whl", hash = "sha256:f775618166caa0396c8e337099240f2bd3e5e917d203b2e6fbe21a58d3cb1f6b", size = 86211, upload-time = "2026-08-03T08:32:34.48Z" }, +] + +[[package]] +name = "ty" +version = "0.0.72" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/df/656e684bafb13c1d146e7d5b5f3e7978ca177232acc84998ff36427e9462/ty-0.0.72.tar.gz", hash = "sha256:ec2b8066b618df18cab4cb8e992f8da45d360332acb23fa34df7fa29cd1b9d3a", size = 6654939, upload-time = "2026-08-14T21:35:42.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/3b/f51461239a4e66565d4b362f97a3b55fe7fdba2e944068341f87c62f6743/ty-0.0.72-py3-none-linux_armv6l.whl", hash = "sha256:fda86db153ffd85ee52000cf175d6a3f1c0223772cf7c5b6f726200bf92c7b44", size = 12621989, upload-time = "2026-08-14T21:35:01.676Z" }, + { url = "https://files.pythonhosted.org/packages/ca/fb/79ddf683affc679ca856f3510b5640ec3a88a842ba5f654f5d4bc78f1786/ty-0.0.72-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ceb944c612529b9023acfdc9cf4c0dcbb722549f9d17d46baecd1141baf01d7f", size = 12233910, upload-time = "2026-08-14T21:35:04.334Z" }, + { url = "https://files.pythonhosted.org/packages/5d/45/10562a0d84802158db8fa4ec46de54aa9fdcecdeeaabbfe3639ae7042b66/ty-0.0.72-py3-none-macosx_11_0_arm64.whl", hash = "sha256:108d76218333d6c092e5f1cebf8e9b06f25738613a0236a28e2dd47c936ee52c", size = 12084108, upload-time = "2026-08-14T21:35:06.686Z" }, + { url = "https://files.pythonhosted.org/packages/a1/dc/1fe1aef8d697e3509face271a5331700c7aa1d1e44a4b622707bdfa41d4b/ty-0.0.72-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7f3943f186f741a2499a31053872169250c9264a9a49684920e48d8fcf4ef4f5", size = 12132640, upload-time = "2026-08-14T21:35:09.305Z" }, + { url = "https://files.pythonhosted.org/packages/14/46/41ceb265e96969487311a2014bd0e53abb4fbc1395efb2ebe411fcb4db62/ty-0.0.72-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cf283c07dc3cc52ca48a3ad8ab100fb5aec3aebbd03ef6a12d5f910b8e596fc5", size = 12402489, upload-time = "2026-08-14T21:35:11.555Z" }, + { url = "https://files.pythonhosted.org/packages/2b/45/30bf43cb4fd505c5c2dd30fda27dde5f05208686cd21217adec77c954204/ty-0.0.72-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:95f3b6462c38f9f115d10cee21f47fedf715fcf2040daf36eef210359300bc7c", size = 13130835, upload-time = "2026-08-14T21:35:13.746Z" }, + { url = "https://files.pythonhosted.org/packages/31/2f/03bba754d2613f640df168335c41f83f41db150bb515839c60d80e3a7880/ty-0.0.72-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:30caf658feb8ffb250d9e9e47107657a78f5f3425c227df1664d8df2ebe38880", size = 13590392, upload-time = "2026-08-14T21:35:16.839Z" }, + { url = "https://files.pythonhosted.org/packages/04/c7/03c67f00e63005ec41585653dc3096064570b1e6273742baae2798cd242f/ty-0.0.72-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:27bdc012ddfbeec8948e4a6036c0dc39ac7cf2c8ec7c7d48dc7d2fd56d57b399", size = 13309629, upload-time = "2026-08-14T21:35:19.169Z" }, + { url = "https://files.pythonhosted.org/packages/c1/df/102d3b264eb7f2a58dd11952f229bb5150bb5668d176a6154976a6675981/ty-0.0.72-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:802c5970a77d7739e6f499921fbb6984fb7ad8a31d95e1ff42fd46f3642e4f3b", size = 12734028, upload-time = "2026-08-14T21:35:22.099Z" }, + { url = "https://files.pythonhosted.org/packages/61/85/d0737c8c54d0ba67366ddfb9f31d88edf0b02299e65923e6945ae60ebcb5/ty-0.0.72-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:47dce65114fdc615c68ca0edb393b433df0956447e4267df0e264137a789598d", size = 13174832, upload-time = "2026-08-14T21:35:24.71Z" }, + { url = "https://files.pythonhosted.org/packages/1e/31/497f5a96c36d9b586ab6afe0574986835c6fd5b835a89773d2bec4711b49/ty-0.0.72-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:325144fa07e2675d0faa337fcc864213c272a499eb0cfe5bde2fdc62282d27bc", size = 12215005, upload-time = "2026-08-14T21:35:26.892Z" }, + { url = "https://files.pythonhosted.org/packages/df/7d/46e65b17b4966c7cd0140f134380d33d8e84fe6efccd761533ce793dc502/ty-0.0.72-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a5c9f15d0f58e43707d8848274be1821a0ef408eccb8aa7dda28a4a9eddf7640", size = 12421298, upload-time = "2026-08-14T21:35:29.301Z" }, + { url = "https://files.pythonhosted.org/packages/08/2a/12ada4ec17700b3cb1d4fd3bc3e5b1852df9e6885288429318cade87b3c1/ty-0.0.72-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8ee508d64b381871529cc22c412b41071bf5e908b7aa5d66a38f3f6b2573a806", size = 12669242, upload-time = "2026-08-14T21:35:31.444Z" }, + { url = "https://files.pythonhosted.org/packages/1c/1a/4692536880790fb550ed6d44a6096778dc71bb112f2c6d615cebb01a57e5/ty-0.0.72-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3699e2ec7921d44da79d6b089f7bf239b2cc53c4e45a5a38430adc34ee9e9a55", size = 12988199, upload-time = "2026-08-14T21:35:33.749Z" }, + { url = "https://files.pythonhosted.org/packages/9a/0d/f5e5a50322e9c45865e7b7a428ba6cd6527387cf0f2472492ac3cf746243/ty-0.0.72-py3-none-win32.whl", hash = "sha256:f25f72a67bd36cd247707c4784e52fad0b6b4f42a1b7dd14804110fa95c486ed", size = 11939708, upload-time = "2026-08-14T21:35:36.006Z" }, + { url = "https://files.pythonhosted.org/packages/3f/4e/8af3534b2e4214e6184a5a59c34101e94a68d578f081f97b995866bab1bf/ty-0.0.72-py3-none-win_amd64.whl", hash = "sha256:cdeee869341717e1736cea2e2d7856738c6957c320f584ed2f68c8f90100d2f5", size = 12643876, upload-time = "2026-08-14T21:35:38.141Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ea/a2606e654c7276bd08586391a2525b0af3f3bf60228a8c57b2d248f273f9/ty-0.0.72-py3-none-win_arm64.whl", hash = "sha256:1bd3ac3ed4424a6d6990a85dc388556aea012bd752de21349a84b685951de0d8", size = 12394857, upload-time = "2026-08-14T21:35:40.277Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "virtualenv" +version = "21.7.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2d/dc/a6eb1ddfa7f1e390fa599b078453c97edb3f6f846b34fb4eac3e8ea16401/virtualenv-21.7.4.tar.gz", hash = "sha256:c9d960c95fa458171e58222a5ccab7465298e4b6559977865e627c4719f1e825", size = 5345511, upload-time = "2026-08-10T22:54:33.316Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/4c/eb2f52aeeaf30dbd073d315a251a63ae2b8263171ec4428c135140cb0802/virtualenv-21.7.4-py3-none-any.whl", hash = "sha256:376ec93cd6aab3044fa395d7db226db38043b7b5748948044b2a87168525e843", size = 5324444, upload-time = "2026-08-10T22:54:31.515Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.8.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288bec1396f5bfcbc2d925700a4dad61727fd5f2b12f249d/wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda", size = 1466253, upload-time = "2026-06-29T18:11:11.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" }, +] + +[[package]] +name = "win32-setctime" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, +]