pax_global_header00006660000000000000000000000064152526203050014512gustar00rootroot0000000000000052 comment=4159eafff61e070ea921e24d5b951e26a73544d1 plotly-choreographer-4159eaf/000077500000000000000000000000001525262030500163035ustar00rootroot00000000000000plotly-choreographer-4159eaf/.github/000077500000000000000000000000001525262030500176435ustar00rootroot00000000000000plotly-choreographer-4159eaf/.github/workflows/000077500000000000000000000000001525262030500217005ustar00rootroot00000000000000plotly-choreographer-4159eaf/.github/workflows/publish_testpypi.yml000066400000000000000000000062561525262030500260430ustar00rootroot00000000000000# .github/workflows/publish_testpypi.yml --- name: test-n-build on: workflow_dispatch: push: tags: - v* jobs: super-test: strategy: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] python_v: ['3.9', '3.10', '3.12', '3.13', '3.14', '3.14t'] # chrome_v: ['-1'] name: Build and Test runs-on: ${{ matrix.os }} env: UV_PYTHON: ${{ matrix.python_v }} steps: - uses: actions/checkout@v4 with: fetch-depth: 1 - uses: astral-sh/setup-uv@v5 - name: Install Dependencies if: ${{ matrix.os == 'ubuntu-latest' }} run: sudo apt-get update && sudo apt-get install xvfb timeout-minutes: 1 # must actually checkout for version determination - run: git checkout ${{ github.ref_name }} - run: uv python install ${{ matrix.python_v }} # don't modify sync file! messes up version! - run: uv sync --all-extras --locked --no-sources - run: git status - run: git diff --quiet HEAD || { echo "Working tree dirty"; exit 1; } - run: uv build - name: Reinstall from wheel run: > uv pip install dist/choreographer-$(uv run --no-sync --with setuptools-git-versioning setuptools-git-versioning)-py3-none-any.whl - run: uv run --no-sync python --version - run: uv pip freeze - run: uv run --no-sync choreo_get_chrome -v #--i ${{ matrix.chrome_v }} - name: Diagnose run: uv run --no-sync choreo_diagnose --no-run - name: Test if: ${{ ! runner.debug && matrix.os != 'ubuntu-latest' }} run: uv run --no-sync poe test timeout-minutes: 8 - name: Test (Linux) if: ${{ ! runner.debug && matrix.os == 'ubuntu-latest' }} run: xvfb-run uv run --no-sync poe test timeout-minutes: 8 - name: Test (Debug) if: ${{ runner.debug && matrix.os != 'ubuntu-latest' }} env: CHOREO_ENABLE_DEBUG: 1 run: uv run --no-sync poe debug-test timeout-minutes: 20 - name: Test (Debug, Linux) if: ${{ runner.debug && matrix.os == 'ubuntu-latest' }} env: CHOREO_ENABLE_DEBUG: 1 run: xvfb-run uv run --no-sync poe debug-test timeout-minutes: 8 testpypi-publish: name: Upload release to TestPyPI needs: super-test if: ${{ !cancelled() && !failure() && github.event_name == 'push' && github.run_attempt == 1 }} runs-on: ubuntu-latest environment: name: testpypi url: https://test.pypi.org/p/choreographer # Signs this workflow so pypi trusts it permissions: id-token: write steps: - name: Checkout uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v4 - uses: actions/setup-python@v5 with: python-version-file: "pyproject.toml" - run: git checkout ${{ github.ref_name }} - run: uv sync --locked --all-extras --no-sources - run: uv build - name: Publish package distributions to PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: repository-url: https://test.pypi.org/legacy/. plotly-choreographer-4159eaf/.github/workflows/ruff.yml000066400000000000000000000003031525262030500233610ustar00rootroot00000000000000--- name: ruff-wf on: pull_request jobs: ruff: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: astral-sh/ruff-action@v3 with: src: 'src' plotly-choreographer-4159eaf/.github/workflows/test.yml000066400000000000000000000040121525262030500233770ustar00rootroot00000000000000--- name: test-wf on: pull_request: push: tags-ignore: - v* jobs: test-all: strategy: matrix: os: [ubuntu-latest, windows-latest, macos-latest] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v5 - uses: actions/setup-python@v5 with: python-version-file: "pyproject.toml" - name: Install Dependencies if: ${{ matrix.os == 'ubuntu-latest' }} run: sudo apt-get update && sudo apt-get install xvfb timeout-minutes: 1 - name: Install choreographer run: uv sync --no-sources --all-extras --locked - name: Install google-chrome-for-testing run: uv run --no-sources choreo_get_chrome -v - name: Diagnose run: uv run --no-sources choreo_diagnose --no-run timeout-minutes: 1 - name: Test if: ${{ ! runner.debug && matrix.os != 'ubuntu-latest' }} run: uv run --no-sources poe test timeout-minutes: 7 - name: Test (Linux) if: ${{ ! runner.debug && matrix.os == 'ubuntu-latest' }} run: xvfb-run uv run --no-sources poe test timeout-minutes: 7 # Linux only: this is the one test that uses chrome's real 100MB buffer # rather than a shrunken one, so it is worth running somewhere, but it # peaks near 1GB and has been seen to fail once without explanation. - name: Test large messages (Linux) if: ${{ ! runner.debug && matrix.os == 'ubuntu-latest' }} run: xvfb-run uv run --no-sources poe test_slow timeout-minutes: 5 - name: Test (Debug) if: ${{ runner.debug && matrix.os != 'ubuntu-latest' }} env: CHOREO_ENABLE_DEBUG: 1 run: uv run --no-sources poe debug-test timeout-minutes: 20 - name: Test (Debug, Linux) if: ${{ runner.debug && matrix.os == 'ubuntu-latest' }} env: CHOREO_ENABLE_DEBUG: 1 run: xvfb-run uv run --no-sources poe debug-test timeout-minutes: 7 plotly-choreographer-4159eaf/.gitignore000066400000000000000000000003101525262030500202650ustar00rootroot00000000000000# specific browser_exe # normal stuff .venv # ignore build artifacts *.egg-info/ build/ __pycache__ # editor files *.sw* # ipynb .ipynb_checkpoints/ # builds results/* !results/placeholder dist/ plotly-choreographer-4159eaf/.pre-commit-config.yaml000066400000000000000000000053051525262030500225670ustar00rootroot00000000000000# See https://pre-commit.com for more information # See https://pre-commit.com/hooks.html for more hooks %YAML 1.2 --- exclude: "site/.*" default_install_hook_types: [pre-commit, commit-msg] default_stages: [pre-commit] 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-added-large-files - id: check-case-conflict - id: check-merge-conflict - id: check-toml - id: debug-statements - repo: https://github.com/asottile/add-trailing-comma rev: v4.0.0 hooks: - id: add-trailing-comma - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. rev: v0.14.4 hooks: # Run the linter. - id: ruff types_or: [python, pyi] # Run the formatter. - id: ruff-format types_or: [python, pyi] # options: ignore one line things [E701] - repo: https://github.com/adrienverge/yamllint rev: v1.37.1 hooks: - id: yamllint name: yamllint description: This hook runs yamllint. entry: yamllint language: python types: [file, yaml] args: [ '-d', "{ extends: default, rules: { colons: { max-spaces-after: -1 } } }", ] - repo: https://github.com/rhysd/actionlint rev: v1.7.8 hooks: - id: actionlint name: Lint GitHub Actions workflow files description: Runs actionlint to lint GitHub Actions workflow files language: golang types: ["yaml"] files: ^\.github/workflows/ entry: actionlint - repo: https://github.com/jorisroovers/gitlint rev: v0.19.1 hooks: - id: gitlint name: gitlint description: Checks your git commit messages for style. language: python additional_dependencies: ["./gitlint-core[trusted-deps]"] entry: gitlint args: [--staged, -c, "general.ignore=B6,T3", --msg-filename] stages: [commit-msg] - repo: https://github.com/crate-ci/typos rev: v1 hooks: - id: typos - repo: https://github.com/Yelp/detect-secrets rev: v1.5.0 hooks: - id: detect-secrets name: Detect secrets language: python entry: detect-secrets-hook args: [''] - repo: https://github.com/rvben/rumdl-pre-commit rev: v0.0.173 # Use the latest release tag hooks: - id: rumdl # To only check (default): # args: [] # To automatically fix issues: # args: [--fix] - repo: https://github.com/RobertCraigie/pyright-python rev: v1.1.407 # pin a tag; latest as of 2025-10-01 hooks: - id: pyright plotly-choreographer-4159eaf/.python_version000066400000000000000000000000041525262030500213640ustar00rootroot000000000000003.9 plotly-choreographer-4159eaf/CHANGELOG.md000066400000000000000000000202631525262030500201170ustar00rootroot00000000000000# Choreographer changelog For more context, please read through the [release notes](https://github.com/plotly/choreographer/releases). To see all merged commits on the main branch that will be part of the next choreographer release, go to: where X.Y.Z is the semver of the most recent choreographer release. ## [Unreleased] ## [1.4.0] -- 2026-09-16 ### Added - Add `enable_extensions` option to control browser extension loading [[#303](https://github.com/plotly/choreographer/pull/303)], with thanks to @hirohira9119 for the contribution! - Add `proxy_server` browser configuration with a `CHOREO_PROXY_SERVER` environment fallback [[#304](https://github.com/plotly/choreographer/pull/304)], with thanks to @ColumbusLabs for the contribution! - Send `Runtime.callFunctionOn` commands in chunks when they are too big for Chrome's 100MiB devtools buffer [[#306](https://github.com/plotly/choreographer/pull/306)] ### Removed - Drop support for Python 3.8 [[#294](https://github.com/plotly/choreographer/pull/294)] ### Fixed - Improve platform architecture detection for arm on Linux and Windows [[#290](https://github.com/plotly/choreographer/pull/290)], with thanks to @juliabeliaeva for the contribution! - Fix license file and add a valid SPDX identifier to project settings [[#294](https://github.com/plotly/choreographer/pull/294)], with thanks to @ecederstrand for the contribution! - Fix out-of-date docstring on `get_browser_path` [[#300](https://github.com/plotly/choreographer/issues/300)] ## [1.3.0] -- 2026-04-28 Rolls up the changes from the `1.3.0rc0`–`1.3.0rc2` release candidates. ## [1.3.0rc2] -- 2025-12-10 ### Added - Add `--verify_local` option in `choreo_diagnose` [[#288](https://github.com/plotly/choreographer/pull/288)] ### Fixed - Check path validity for browser with `is_file()` [[#288](https://github.com/plotly/choreographer/pull/288)] - Fix local reporting logic in `choreo_diagnose` [[#288](https://github.com/plotly/choreographer/pull/288)] ## [1.3.0rc1] -- 2025-11-30 ### Changed - Look for both the old download path and the new download path when locating chrome [[#286](https://github.com/plotly/choreographer/pull/286)] ## [1.3.0rc0] -- 2025-11-26 ### Added - Add `with_perf` keyword argument to `Session.send_command` / `Target.send_command` to return timing information about browser write/read [[#271](https://github.com/plotly/choreographer/pull/271)] - Add a set of helper functions to await tab loading and send JavaScript [[#279](https://github.com/plotly/choreographer/pull/279)] - Add `force` argument so chrome is not re-downloaded if the requested version already exists [[#277](https://github.com/plotly/choreographer/pull/277)] ### Changed - Switch to a process group for more reliable termination of multi-process chrome [[#280](https://github.com/plotly/choreographer/pull/280)] - Update default chrome from 135.0.7011.0/1418433 to 144.0.7527.0/1544685 [[#277](https://github.com/plotly/choreographer/pull/277)] - Change chrome download path to use the XDG cache directory [[#277](https://github.com/plotly/choreographer/pull/277)] - Make `get_chrome` verbose output print the whole JSON response [[#277](https://github.com/plotly/choreographer/pull/277)] ### Removed - Remove unused system inspection code [[#278](https://github.com/plotly/choreographer/pull/278)] ### Fixed - Add a retry loop to populate targets, since newer chrome takes longer and doesn't populate targets right away [[#282](https://github.com/plotly/choreographer/pull/282), [#283](https://github.com/plotly/choreographer/pull/283)] ## [1.2.1] -- 2025-11-09 ### Added - Add Roadmap ### Changed - Use a custom threadpool for functions that could run during shutdown. Python's stdlib threadpool isn't available during interpreter shutdown nor `atexit`, so it cannot be started or shut down during `atexit`, or relied upon at all. The custom threadpool is now used during `Browser.open()` as well as `Browser.close()`, since some use patterns invoke `open` during shutdown [[#267](https://github.com/plotly/choreographer/pull/267)] - Improve `choreo_diagnose` [[#269](https://github.com/plotly/choreographer/pull/269)] - Improve error messaging - Reorganize functions internally - Bump `logistro` dependency ### Removed - Remove `site` directory [[#270](https://github.com/plotly/choreographer/pull/270)] ## [1.2.0] -- 2025-10-22 ### Changed - Look for several chromium variants in all OSes by default [[#243](https://github.com/plotly/choreographer/pull/243)], with thanks to @hirohira9119 for the contribution! - Replace `ThreadPoolExecutor` with a manually managed pool so it can be used inside `atexit` via `.close()` [[#260](https://github.com/plotly/choreographer/pull/260)] - Upgrade `logistro` to reduce side effects ### Fixed - Delete zipfile after downloading [[#257](https://github.com/plotly/choreographer/pull/257)] ## [1.1.2] -- 2025-10-07 ### Added - Add lock to mark channel open, and an `is_ready` function that checks open/close state [[#239](https://github.com/plotly/choreographer/pull/239)] ### Changed - Appease stricter typer ## [1.1.1] -- 2025-09-18 ### Fixed - Fix bad module access [[#250](https://github.com/plotly/choreographer/pull/250)] ## [1.1.0] -- 2025-09-17 Rolls up the changes from `1.1.0rc0` and `1.1.0rc1`. ## [1.1.0rc1] -- 2025-09-16 ### Changed - Force custom JSON encoder to use the stdlib `json` package [[#249](https://github.com/plotly/choreographer/pull/249)] ## [1.1.0rc0] -- 2025-09-16 ### Added - Add option to register a custom JSON encoder [[#244](https://github.com/plotly/choreographer/pull/244)] ## [1.0.10] -- 2025-08-21 ### Fixed - Simple typing fixes [[#241](https://github.com/plotly/choreographer/pull/241)] ## [1.0.9] -- 2025-06-10 ### Changed - Serializer now accepts unsigned ints [[#232](https://github.com/plotly/choreographer/pull/232)] - Serializer better differentiates between `pandas` and `xarray` [[#232](https://github.com/plotly/choreographer/pull/232)] ## [1.0.8] -- 2025-05-22 ### Changed - Lower default logging verbosity [[#228](https://github.com/plotly/choreographer/pull/228)] ## [1.0.7] -- 2025-04-16 ### Added - Make the default download path public [[#227](https://github.com/plotly/choreographer/pull/227)] ### Changed - Revert ldd strategy to documentation rather than injecting deps [[#227](https://github.com/plotly/choreographer/pull/227)] - Move some verbose logging from `DEBUG` to `DEBUG2` [[#226](https://github.com/plotly/choreographer/pull/226)] ## [1.0.6] -- 2025-04-04 ### Added - Package chromium deps and use them if `ldd` shows they are needed [[#223](https://github.com/plotly/choreographer/pull/223)] - Add `LDD_FAIL` env var and `--ldd_fail` flag to always fail if deps are needed [[#223](https://github.com/plotly/choreographer/pull/223)] - Add `FORCED_PACKAGED_DEPS` env var and `--forced-packaged-deps` flag [[#223](https://github.com/plotly/choreographer/pull/223)] ### Fixed - Fix some API bugs in `choreo_diagnose` [[#223](https://github.com/plotly/choreographer/pull/223)] ## [1.0.5] -- 2025-02-23 ### Added - Add `Browser.is_isolated()` returning whether `/tmp` is sandboxed [[#219](https://github.com/plotly/choreographer/pull/219)] ## [1.0.4] -- 2025-02-11 ### Fixed - Fetch the latest *known* chrome version, not the latest version [[#214](https://github.com/plotly/choreographer/pull/214)] ## [1.0.3] -- 2025-02-10 ### Changed - Improve logging ### Removed - Remove many CLI commands ### Fixed - Fix syntax to make compatible with Python 3.8 [[#200](https://github.com/plotly/choreographer/pull/200)] ## [1.0.2] -- 2025-02-10 No functional changes; noop release. ## [1.0.1] -- 2025-02-09 ### Changed - Improve error messages and add some type checking for user experience ### Fixed - Check for future cancellation before setting, improving error handling [[#197](https://github.com/plotly/choreographer/pull/197), [#198](https://github.com/plotly/choreographer/pull/198)] ## [1.0.0] -- 2025-02-06 ### Added - Add option for whole new window in `create_tab` ### Changed - Increase wait when checking regular close - Decrease freeze for manual bad-close cleanup - Improve parallelization by disabling site-per-process - General logging improvements ### Fixed - Squash race condition plotly-choreographer-4159eaf/CODE_OF_CONDUCT.md000066400000000000000000000060071525262030500211050ustar00rootroot00000000000000# Code of Conduct In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, education, socioeconomic status, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Examples of behavior that contributes to creating a positive environment include: * using welcoming and inclusive language, * being respectful of differing viewpoints and experiences, * gracefully accepting constructive criticism, * focusing on what is best for the community, and * showing empathy towards other community members. Examples of unacceptable behavior by participants include: * the use of sexualized language or imagery and unwelcome sexual attention or advances, * trolling, insulting/derogatory comments, and personal or political attacks, * public or private harassment, * publishing others' private information, such as a physical or electronic address, without explicit permission, and * other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project email address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by emailing the project team. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/) version 1.4. plotly-choreographer-4159eaf/LICENSE.md000066400000000000000000000020521525262030500177060ustar00rootroot00000000000000# MIT License Copyright (c) Plotly, Inc. 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. plotly-choreographer-4159eaf/README.md000066400000000000000000000115011525262030500175600ustar00rootroot00000000000000# Choreographer choreographer allows remote control of browsers from Python. It was created to support image generation from browser-based charting tools, but can be used for other purposes as well. choreographer is available [PyPI](https://pypi.org/project/choreographer) and [github](https://github.com/plotly/choreographer). ## Wait—I Thought This Was Kaleido? [Kaleido][kaleido] is a cross-platform library for generating static images of plots. The original implementation included a custom build of Chrome, which has proven very difficult to maintain. In contrast, this package uses the Chrome binary on the user's machine in the same way as testing tools like [Puppeteer][puppeteer]; the next step is to re-implement Kaleido as a layer on top of it. ## Status choreographer is a work in progress: only Chrome-ish browsers are supported at the moment, though we hope to add others. (Pull requests are greatly appreciated.) Note that we strongly recommend using async/await with this package, but it is not absolutely required. The synchronous functions in this package are intended as building blocks for other asynchronous strategies that Python may favor over async/await in the future. ## Testing ### Process Control Tests - Verbose: `pytest -W error -vvv tests/test_process.py` - Quiet:`pytest -W error -v tests/test_process.py` ### Browser Interaction Tests - Verbose: `pytest --debug -W error -vvv --ignore=tests/test_process.py` - Quiet :`pytest -W error -v --ignore=tests/test_process.py` You can also add "--no-headless" if you want to see the browser pop up. ### Writing Tests - Separate async and sync test files. Add `_sync.py` to synchronous tests. - For process tests, copy the fixtures in `test_process.py` file. - For API tests, use `test_placeholder.py` as the minimum template. ## Help Wanted We need your help to test this package on different platforms and for different use cases. To get started: 1. Clone this repository. 1. Create and activate a Python virtual environment. 1. Install this repository using `pip install .` or the equivalent. 1. Run `dtdoctor` and paste the output into an issue in this repository. ## Quickstart with `asyncio` Save the following code to `example.py` and run with Python. ```python import asyncio import choreographer as choreo async def example(): browser = await choreo.Browser(headless=False) tab = await browser.create_tab("https://google.com") await asyncio.sleep(3) await tab.send_command("Page.navigate", params={"url": "https://github.com"}) await asyncio.sleep(3) if __name__ == "__main__": asyncio.run(example()) ``` Step by step, this example: 1. Imports the required libraries. 1. Defines an `async` function (because `await` can only be used inside `async` functions). 1. Asks choreographer to create a browser. `headless=False` tells it to display the browser on the screen; the default is no display. 1. Wait three seconds for the browser to be created. 1. Create another tab. (Note that users can't rearrange programmatically-generated tabs using the mouse, but that's OK: we're not trying to replace testing tools like [Puppeteer][puppeteer].) 1. Sleep again. 1. Runs the example function. See [the devtools reference][devtools-ref] for a list of possible commands. ### Subscribing to Events Try adding the following to the example shown above: ```python # Callback for printing result async def dump_event(response): print(str(response)) # Callback for raising result as error async def error_event(response): raise Exception(str(response)) browser.subscribe("Target.targetCrashed", error_event) new_tab.subscribe("Page.loadEventFired", dump_event) browser.subscribe("Target.*", dump_event) # dumps all "Target" events response = await new_tab.subscribe_once("Page.lifecycleEvent") # do something with response browser.unsubscribe("Target.*") # events are always sent to a browser or tab, # but the documentation isn't always clear which. # Dumping all: `browser.subscribe("*", dump_event)` (on tab too) # can be useful (but verbose) for debugging. ``` ## Synchronous Use You can use this library without `asyncio`, ```python my_browser = choreo.Browser() # blocking until open ``` However, you must call `browser.pipe.read_jsons(blocking=True|False)` manually, and organizing the results. `browser.run_output_thread()` starts another thread constantly printing messages received from the browser but it can't be used with `asyncio` nor will it play nice with any other read. In other words, unless you're really, really sure you know what you're doing, use `asyncio`. ## Low-Level Use We provide a `Browser` and `Tab` interface, but there are lower-level `Target` and `Session` interfaces if needed. [devtools-ref]: https://chromedevtools.github.io/devtools-protocol/ [kaleido]: https://pypi.org/project/kaleido/ [puppeteer]: https://pptr.dev/ plotly-choreographer-4159eaf/ROADMAP.md000066400000000000000000000021431525262030500177100ustar00rootroot00000000000000# Roadmap - [ ] What happens to the underlying task when we cancel a future in a `protocol.devtools_async_helpers` timeout situation? - [x] Extract local download check to chromium implementation class - [x] Fix up browser deps error (eliminate in-package analysis) - [x] Switch to process group and kill that to catch all chromium children - [x] Add helpers for running javascript - [ ] Add helper just for refreshing page - [ ] Working on better diagnostic information - [ ] Explain to user when their system has security restrictions - [ ] Eliminate synchronous API: it's unused, hard to maintain, and nearly worthless - [ ] Diagnose function should collect a JSON and then print that - [ ] Allow user to build and send their own JSONS - [ ] Get serialization out of the lock - [ ] Add a websockets extra - [ ] Support Firefox - [ ] Support LadyBird (!) - [ ] Test against multiple docker containers - [ ] Do browser-rolling tests - [ ] Do documentation - [ ] Browser Open/Close Status/PipeCheck status should happen at broker level - [ ] Broker should probably be opening browser and running watchdog... plotly-choreographer-4159eaf/docs/000077500000000000000000000000001525262030500172335ustar00rootroot00000000000000plotly-choreographer-4159eaf/docs/Ubuntu_Errata.md000066400000000000000000000013011525262030500223300ustar00rootroot00000000000000# Ubuntu Security Workarounds ## Restrictions Ubuntu has special rules around programs installed outside of its package manager. ### Sandbox #### Root Because sandboxing depends on running subprocesses in underpriviledged modes using OS-level APIs, and if you run as root, all your root process have root-privileges, sandboxing wont work if run as root. #### Ubuntu Ubuntu doesn't let non-package-manager installed apps run w/ sandboxing tools. ### Temporary Files Ubuntu doesn't let apps share temporary files. ## Impacts Even though we recommend testing with our "known-later-version", we have to, in order to test sandbox, use the package manager of ubuntu. So sometimes there are failures. plotly-choreographer-4159eaf/mkdocs.yml000066400000000000000000000015151525262030500203100ustar00rootroot00000000000000--- ### Site metadata ### site_name: choreographer repo_name: github ### Build settings ### docs_dir: 'docs/' nav: - Readme: >- { "dest": "README.md", "src": "../README.md", "replace": {"src='docs/": "src='"} } - User API: >- { "api": "choreographer", "test": ["exports", "_prefix_local"], "tree": "none" } - Developer API: - >- { "api": "choreographer", "test": ["exports", "_prefix_local"], "tree": "packages" } # CLI tools? theme: name: material markdown_extensions: - pymdownx.highlight: anchor_linenums: true line_spans: __span pygments_lang_class: true - pymdownx.inlinehilite - pymdownx.snippets - pymdownx.superfences plugins: - quimeta - quicopy - quiapi plotly-choreographer-4159eaf/pyproject.toml000066400000000000000000000120631525262030500212210ustar00rootroot00000000000000[build-system] requires = ["setuptools>=65.0.0", "wheel", "setuptools-git-versioning"] build-backend = "setuptools.build_meta" [tool.setuptools] [tool.setuptools.packages.find] where = ["src"] namespaces = false [tool.setuptools-git-versioning] enabled = true [tool.setuptools.package-data] choreographer = ['resources/last_known_good_chrome.json'] [project] name = "choreographer" description = "Devtools Protocol implementation for chrome." readme = "README.md" requires-python = ">=3.9" license = "MIT" license-files = [ "LICENSE.md" ] dynamic = ["version"] authors = [ {name = "Andrew Pikul", email="ajpikul@gmail.com"}, {name = "Neyberson Atencio", email="neyberatencio@gmail.com"} ] maintainers = [ {name = "Andrew Pikul", email = "ajpikul@gmail.com"}, ] dependencies = [ "logistro>=2.0.1", "platformdirs>=4.3.6", "simplejson>=3.19.3", ] [project.urls] Homepage = "https://github.com/plotly/choreographer" Repository = "https://github.com/plotly/choreographer" [project.scripts] choreo_diagnose = "choreographer.cli._cli_utils_no_qa:diagnose" choreo_get_chrome = "choreographer.cli._cli_utils:get_chrome_cli" [dependency-groups] dev = [ "async-timeout", "numpy; python_version < '3.11'", "numpy>=2.3.3; python_version >= '3.11'", "mypy>=1.14.1", "types-simplejson>=3.19.0.20241221", "poethepoet>=0.30.0", "pyright>=1.1.406", "pytest", "pytest-asyncio; python_version < '3.14'", "pytest-asyncio>=1.2.0; python_version >= '3.14'", "pytest-xdist", "typing-extensions>=4.13.2", ] # uv doens't allow dependency groups to have separate python requirements # it resolves everything all at once # this group we need to require higher python # and only resolve if explicitly asked for #docs = [ # "mkquixote @ git+ssh://git@github.com/geopozo/mkquixote; python_version >= '3.11'", # "mkdocs>=1.6.1", # "mkdocs-material>=9.5.49", #] [tool.uv.sources] #mkquixote = { path = "../mkquixote", editable = true } #logistro = { path = "../logistro", editable = true } [tool.ruff] src = ["src"] [tool.ruff.lint] select = ["ALL"] ignore = [ "ANN", # no types "COM812", # manual says linter rule conflicts with formatter "CPY001", # Don't require a copyright notice at the top of a file "D203", # No blank before class docstring (D211 = require blank line) "D212", # Commit message style docstring is D213, ignore D212 "EM", # allow strings in raise(), despite python being ugly about it "G004", # fstrings in my logs "ISC001", # manual says litner rule conflicts with formatter "PT003", # scope="function" implied but I like readability "RET504", # Allow else if unnecessary because more readable "RET505", # Allow else if unnecessary because more readable "RET506", # Allow else if unnecessary because more readable "RET507", # Allow else if unnecessary because more readable "RET508", # Allow else if unnecessary because more readable "RUF012", # We don't do typing, so no typing "SIM105", # Too opionated (try-except-pass) "TRY003", # allow long error messages inside raise() ] [tool.ruff.lint.per-file-ignores] "tests/*" = [ "D", # ignore docstring errors "S101", # allow assert "INP001", # no need for __init__ in test directories "T201", # if we're printing in tests, there is a reason "ERA001" ] [tool.pytest.ini_options] asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" log_cli = false addopts = "--import-mode=append" markers = [ "slow: moves enough data to be worth skipping by default (`-m 'not slow'`)", ] # tell poe to use the env we give it, otherwise it detects uv and overrides flags [tool.poe] executor.type = "simple" [tool.poe.tasks] test_proc = "pytest --log-level=1 -W error -n auto -v -rfE --capture=fd tests/test_process.py" test_fn = "pytest --log-level=1 -W error -n auto -v -rfE --capture=fd -m 'not slow' --ignore=tests/test_process.py" # Skip `--log-level=1` and `-n auto` to allow slow tests to complete without # formatting wall of text and to avoid tests using up all memory (~1GB per thread) test_slow = "pytest -W error -v -rfE --capture=fd -m slow --ignore=tests/test_process.py" debug-test_proc = "pytest --log-level=1 -W error -vvvx -rA --show-capture=no --capture=no tests/test_process.py" debug-test_fn = "pytest --log-level=1 -W error -vvvx -rA --show-capture=no --capture=no -m 'not slow' --ignore=tests/test_process.py" [tool.poe.tasks.test] sequence = ["test_proc", "test_fn"] help = "Run all tests quickly" [tool.poe.tasks.debug-test] env = { CHOREO_ENABLE_DEBUG="1" } sequence = ["debug-test_proc", "debug-test_fn"] help = "Run test by test, slowly, quitting after first error" [tool.poe.tasks.filter-test] env = { CHOREO_ENABLE_DEBUG="1" } cmd = "pytest --log-level=1 -W error -vvvx -rA --capture=no --show-capture=no" help = "Run any/all tests one by one with basic settings: can include filename and -k filters" [tool.pyright] venvPath = "." venv = ".venv" include = ["src", "tests"] plotly-choreographer-4159eaf/src/000077500000000000000000000000001525262030500170725ustar00rootroot00000000000000plotly-choreographer-4159eaf/src/choreographer/000077500000000000000000000000001525262030500217225ustar00rootroot00000000000000plotly-choreographer-4159eaf/src/choreographer/__init__.py000066400000000000000000000012461525262030500240360ustar00rootroot00000000000000""" choreographer is a browser controller for python. choreographer is natively async, so while there are two main entrypoints: classes `Browser` and `BrowserSync`, the sync version is very limited, functioning as a building block for more featureful implementations. See the main README for a quickstart. """ import os if os.getenv("CHOREO_ENABLE_DEBUG"): import sys import logistro logistro.betterConfig(level=1) print("DEBUG MODE!", file=sys.stderr) # noqa: T201 from .browser_async import ( Browser, Tab, ) from .browser_sync import ( BrowserSync, TabSync, ) __all__ = [ "Browser", "BrowserSync", "Tab", "TabSync", ] plotly-choreographer-4159eaf/src/choreographer/_brokers/000077500000000000000000000000001525262030500235305ustar00rootroot00000000000000plotly-choreographer-4159eaf/src/choreographer/_brokers/__init__.py000066400000000000000000000003511525262030500256400ustar00rootroot00000000000000from ._async import Broker from ._sync import BrokerSync __all__ = [ "Broker", "BrokerSync", ] # note: should brokers be responsible for closing browser on bad pipe? # note: should the broker be the watchdog, in that case? plotly-choreographer-4159eaf/src/choreographer/_brokers/_async.py000066400000000000000000000335311525262030500253630ustar00rootroot00000000000000from __future__ import annotations import asyncio import warnings from functools import partial from typing import TYPE_CHECKING import logistro from choreographer import channels, protocol from choreographer.utils import _manual_thread_pool # afrom choreographer.channels import ChannelClosedError if TYPE_CHECKING: from collections.abc import MutableMapping from typing import Any from choreographer.browser_async import Browser from choreographer.channels._interface_type import ChannelInterface from choreographer.protocol.devtools_async import Session, Target _logger = logistro.getLogger(__name__) PERFS_MAX = 5000 # maximum number of entries in the perf dicts TRIM_SIZE = 500 # what to save after trimming it class UnhandledMessageWarning(UserWarning): pass class Broker: """Broker is a middleware implementation for asynchronous implementations.""" _browser: Browser """Browser is a reference to the Browser object this broker is brokering for.""" _channel: ChannelInterface """ Channel will be the ChannelInterface implementation (pipe or websocket) that the broker communicates on. """ futures: MutableMapping[protocol.MessageKey, asyncio.Future[Any]] """A mapping of all the futures for all sent commands.""" _subscriptions_futures: MutableMapping[ str, MutableMapping[ str, list[asyncio.Future[Any]], ], ] """A mapping of session id: subscription: list[futures]""" write_perfs: MutableMapping[protocol.MessageKey, tuple[float, float]] read_perfs: MutableMapping[protocol.MessageKey, float] def __init__(self, browser: Browser, channel: ChannelInterface) -> None: """ Construct a broker for a synchronous arragenment w/ both ends. Args: browser: The sync browser implementation. channel: The channel the browser uses to talk on. """ self._browser = browser self._channel = channel self._background_tasks: set[asyncio.Task[Any]] = set() # if its a task you dont want canceled at close (like the close task) self._background_tasks_cancellable: set[asyncio.Task[Any]] = set() # if its a user task, can cancel self._current_read_task: asyncio.Task[Any] | None = None self.futures = {} self.write_perfs = {} self.read_perfs = {} self._subscriptions_futures = {} self._write_lock = asyncio.Lock() self._executor = _manual_thread_pool.ManualThreadExecutor( max_workers=2, name="readwrite_thread", ) def new_subscription_future( self, session_id: str, subscription: str, ) -> asyncio.Future[Any]: _logger.debug( f"Session {session_id} is subscribing to {subscription} one time.", ) if session_id not in self._subscriptions_futures: self._subscriptions_futures[session_id] = {} if subscription not in self._subscriptions_futures[session_id]: self._subscriptions_futures[session_id][subscription] = [] future = asyncio.get_running_loop().create_future() self._subscriptions_futures[session_id][subscription].append(future) return future def clean(self) -> None: _logger.debug("Cancelling message futures") for future in self.futures.values(): if not future.done(): _logger.debug2(f"Cancelling {future}") future.cancel() _logger.debug("Cancelling read task") if self._current_read_task and not self._current_read_task.done(): _logger.debug2(f"Cancelling read: {self._current_read_task}") self._current_read_task.cancel() _logger.debug("Cancelling subscription-futures") for session in self._subscriptions_futures.values(): for query in session.values(): for future in query: if not future.done(): _logger.debug2(f"Cancelling {future}") future.cancel() _logger.debug("Cancelling background tasks") for task in self._background_tasks_cancellable: if not task.done(): _logger.debug2(f"Cancelling {task}") task.cancel() self._executor.shutdown(wait=True, cancel_futures=True) def run_read_loop(self) -> None: # noqa: C901, PLR0915 complexity def check_read_loop_error(result: asyncio.Future[Any]) -> None: if result.cancelled(): _logger.debug("Readloop cancelled") return e = result.exception() if e: _logger.debug("Error in readloop. Will post a close() task.") self._background_tasks.add( asyncio.create_task(self._browser.close()), ) if isinstance(e, channels.ChannelClosedError): _logger.debug("PipeClosedError caught") _logger.debug2("Full Error:", exc_info=e) elif isinstance(e, asyncio.CancelledError): _logger.debug("CancelledError caught.") _logger.debug2("Full Error:", exc_info=e) else: _logger.error("Error in run_read_loop.", exc_info=e) raise e async def read_loop() -> None: # noqa: PLR0912, PLR0915, C901 loop = asyncio.get_running_loop() fn = partial(self._channel.read_jsons, blocking=True) responses, perf = await loop.run_in_executor( executor=self._executor, func=fn, ) _logger.debug(f"Channel read found {len(responses)} json objects.") for response in responses: error = protocol.get_error_from_result(response) key = protocol.calculate_message_key(response) if not key and error: raise protocol.DevtoolsProtocolError(response) # looks for event that we should handle internally self._check_for_closed_session(response) # surrounding lines overlap in idea if protocol.is_event(response): event_session_id = response.get( "sessionId", "", ) _logger.debug2(f"Is event for {event_session_id}") x = self._get_target_session_by_session_id( event_session_id, ) if not x: continue _, event_session = x if not event_session: _logger.error("Found an event that returned no session.") continue _logger.debug( f"Received event {response['method']} for " f"{event_session_id} targeting {event_session}.", ) session_futures = self._subscriptions_futures.get( event_session_id, ) _logger.debug2( "Checking for event subscription future.", ) if session_futures: for query in session_futures: match = ( query.endswith("*") and response["method"].startswith(query[:-1]) ) or (response["method"] == query) if match: _logger.debug2( "Found event subscription future.", ) for future in session_futures[query]: if not future.done(): future.set_result(response) session_futures[query] = [] _logger.debug2( "Checking for event subscription callback.", ) for query in list(event_session.subscriptions): match = ( query.endswith("*") and response["method"].startswith(query[:-1]) ) or (response["method"] == query) _logger.debug2( "Found event subscription callback.", ) if match: t: asyncio.Task[Any] = asyncio.create_task( event_session.subscriptions[query][0](response), ) self._background_tasks_cancellable.add(t) if not event_session.subscriptions[query][1]: event_session.unsubscribe(query) elif key: self.read_perfs[key] = perf _logger.debug(f"Have a response with key {key}") if key in self.futures: _logger.debug(f"Found future for key {key}") future = self.futures.pop(key) elif "error" in response: raise protocol.DevtoolsProtocolError(response) else: raise RuntimeError(f"Couldn't find a future for key: {key}") if not future.done(): future.set_result(response) if len(self.write_perfs) > PERFS_MAX: self.write_perfs = dict( list(self.write_perfs.items())[TRIM_SIZE:], ) self.read_perfs = dict( list(self.read_perfs.items())[TRIM_SIZE:], ) else: warnings.warn( f"Unhandled message type:{response!s}", UnhandledMessageWarning, stacklevel=1, ) read_task = asyncio.create_task(read_loop()) read_task.add_done_callback(check_read_loop_error) self._current_read_task = read_task read_task = asyncio.create_task(read_loop()) read_task.add_done_callback(check_read_loop_error) self._current_read_task = read_task def get_perf( self, obj: protocol.BrowserCommand, ) -> tuple[float, float, float]: """Get the performance tuple for a certain BrowserCommand.""" key = protocol.calculate_message_key(obj) if not key: return (0, 0, 0) return (*self.write_perfs[key], self.read_perfs[key]) async def write_json( self, obj: protocol.BrowserCommand, ) -> protocol.BrowserResponse: protocol.verify_params(obj) key = protocol.calculate_message_key(obj) _logger.debug1(f"Broker writing {obj['method']} with key {key}") if not key: raise RuntimeError( "Message strangely formatted and " "choreographer couldn't figure it out why.", ) loop = asyncio.get_running_loop() future: asyncio.Future[protocol.BrowserResponse] = loop.create_future() self.futures[key] = future _logger.debug(f"Created future: {key} {future}") try: async with self._write_lock: # this should be a queue not a lock loop = asyncio.get_running_loop() perf = await loop.run_in_executor( self._executor, self._channel.write_json, obj, ) self.write_perfs[key] = perf except (_manual_thread_pool.ExecutorClosedError, asyncio.CancelledError) as e: if not future.cancel() or not future.cancelled(): await future # it wasn't canceled, so listen to it before raising raise channels.ChannelClosedError("Executor is closed.") from e except Exception as e: # noqa: BLE001 future.set_exception(e) del self.futures[key] _logger.debug(f"Future for {key} deleted.") return await future def _get_target_session_by_session_id( self, session_id: str, ) -> tuple[Target, Session] | None: if session_id == "": return (self._browser, self._browser.sessions[session_id]) for tab in self._browser.tabs.values(): if session_id in tab.sessions: return (tab, tab.sessions[session_id]) if session_id in self._browser.sessions: return (self._browser, self._browser.sessions[session_id]) return None def _check_for_closed_session(self, response: protocol.BrowserResponse) -> bool: if "method" in response and response["method"] == "Target.detachedFromTarget": session_closed = response["params"].get( "sessionId", "", ) if session_closed == "": _logger.debug2("Found closed session through events.") return True x = self._get_target_session_by_session_id(session_closed) if x: target_closed, _ = x else: return False if target_closed: target_closed._remove_session(session_closed) # noqa: SLF001 _logger.debug( "Using intern subscription key: " "'Target.detachedFromTarget'. " f"Session {session_closed} was closed.", ) _logger.debug2("Found closed session through events.") return True return False else: return False plotly-choreographer-4159eaf/src/choreographer/_brokers/_sync.py000066400000000000000000000045431525262030500252230ustar00rootroot00000000000000from __future__ import annotations import json from threading import Thread from typing import TYPE_CHECKING import logistro from choreographer import protocol from choreographer.channels import ChannelClosedError if TYPE_CHECKING: from typing import Any from choreographer.browser_sync import BrowserSync from choreographer.channels._interface_type import ChannelInterface _logger = logistro.getLogger(__name__) class BrokerSync: """BrokerSync is a middleware implementation for synchronous browsers.""" _browser: BrowserSync """Browser is a reference to the Browser object this broker is brokering for.""" _channel: ChannelInterface """ Channel will be the ChannelInterface implementation (pipe or websocket) that the broker communicates on. """ def __init__(self, browser: BrowserSync, channel: ChannelInterface) -> None: """ Construct a broker for a synchronous arragenment w/ both ends. Args: browser: The sync browser implementation. channel: The channel the browser uses to talk on. """ self._browser = browser self._channel = channel def run_output_thread(self, **kwargs: Any) -> None: """ Run a thread which dumps all browser messages. kwargs is passed to print. Raises: ChannelClosedError: When the channel is closed, this error is raised. """ def run_print() -> None: try: while True: responses = self._channel.read_jsons() for response in responses: print(json.dumps(response, indent=4), **kwargs) # noqa: T201 print in the point except ChannelClosedError: print("ChannelClosedError caught.", **kwargs) # noqa: T201 print is the point _logger.info("Starting thread to dump output to stdout.") Thread(target=run_print).start() def write_json(self, obj: protocol.BrowserCommand) -> protocol.MessageKey | None: """ Send an object down the channel. Args: obj: An object to be serialized to json and written to the channel. """ protocol.verify_params(obj) key = protocol.calculate_message_key(obj) self._channel.write_json(obj) return key def clean(self) -> None: pass plotly-choreographer-4159eaf/src/choreographer/browser_async.py000066400000000000000000000406041525262030500251600ustar00rootroot00000000000000"""Provides the async api: `Browser`, `Tab`.""" # hello, thank you for visiting from __future__ import annotations import asyncio import os import subprocess import warnings from asyncio import Lock from typing import TYPE_CHECKING import logistro from choreographer import protocol from ._brokers import Broker from .browsers import BrowserClosedError, BrowserFailedError, Chromium from .channels import ChannelClosedError, Pipe from .protocol.devtools_async import Session, Target from .utils import TmpDirWarning, _manual_thread_pool from .utils._kill import kill if TYPE_CHECKING: from collections.abc import Generator, MutableMapping from pathlib import Path from types import TracebackType from typing import Any from typing_extensions import Self # 3.9 needs this, could be from typing in 3.10 from .browsers._interface_type import BrowserImplInterface from .channels._interface_type import ChannelInterface MAX_POPULATE_LOOPS = 40 if "CI" in os.environ else 20 _logger = logistro.getLogger(__name__) # Since I added locks to pipes, do we need locks here? class Tab(Target): """A wrapper for `Target`, so user can use `Tab`, not `Target`.""" async def close(self) -> None: """Close the tab.""" await self._broker._browser.close_tab(target_id=self.target_id) # noqa: SLF001 class Browser(Target): """`Browser` is the async implementation of `Browser`.""" subprocess: subprocess.Popen[bytes] | subprocess.Popen[str] """A reference to the `Popen` object.""" tabs: MutableMapping[str, Tab] """A mapping by target_id of all the targets which are open tabs.""" targets: MutableMapping[str, Target] """A mapping by target_id of ALL the targets.""" # Don't init instance attributes with mutables _watch_dog_task: asyncio.Task[Any] | None = None def _make_lock(self) -> None: self._open_lock = Lock() async def _is_open(self) -> bool: # Did we acquire the lock? If so, return true, we locked open. # If we are open, we did not lock open. # fuck, go through this again if self._open_lock.locked(): return True await self._open_lock.acquire() return False def _release_lock(self) -> bool: try: if self._open_lock.locked(): self._open_lock.release() return True else: return False except RuntimeError: return False def __init__( self, path: str | Path | None = None, *, browser_cls: type[BrowserImplInterface] = Chromium, channel_cls: type[ChannelInterface] = Pipe, **kwargs: Any, ) -> None: """ Construct a new browser instance. Args: path: The path to the browser executable. browser_cls: The type of browser (default: `Chromium`). channel_cls: The type of channel to browser (default: `Pipe`). kwargs: The arguments that the browser_cls takes. For example, headless=True/False, enable_gpu=True/False, etc. """ _logger.debug("Attempting to open new browser.") self._process_executor = _manual_thread_pool.ManualThreadExecutor( max_workers=3, name="checking_close", ) self._make_lock() self.tabs = {} self.targets = {} # Compose Resources self._channel = channel_cls() self._broker = Broker(self, self._channel) self._browser_impl = browser_cls(self._channel, path, **kwargs) def is_isolated(self) -> bool: """Return if process is isolated.""" return self._browser_impl.is_isolated() async def open(self) -> None: """Open the browser.""" _logger.info("Opening browser.") if await self._is_open(): raise RuntimeError("Can't re-open the browser") # asyncio's equiv doesn't work in all situations if hasattr(self._browser_impl, "logger_parser"): parser = self._browser_impl.logger_parser else: parser = None self._logger_pipe, _ = logistro.getPipeLogger( "browser_proc", parser=parser, ) def run() -> subprocess.Popen[bytes] | subprocess.Popen[str]: # depends on args self._browser_impl.pre_open() cli = self._browser_impl.get_cli() stderr = self._logger_pipe env = self._browser_impl.get_env() args = self._browser_impl.get_popen_args() return subprocess.Popen( # noqa: S603 cli, stderr=stderr, env=env, **args, ) _logger.debug("Trying to open browser.") loop = asyncio.get_running_loop() self.subprocess = await loop.run_in_executor( self._process_executor, run, ) super().__init__("0", self._broker) self._add_session(Session("", self._broker)) try: _logger.debug("Starting watchdog") self._watch_dog_task = asyncio.create_task(self._watchdog()) _logger.debug("Opening channel.") self._channel.open() # should this and below be in a broker run _logger.debug("Running read loop") self._broker.run_read_loop() await asyncio.sleep(0) # let watchdog start before populate counter = 0 while not self.get_tab(): _logger.debug("Populating Targets") await self.populate_targets() await asyncio.sleep(0.1) counter += 1 if counter >= MAX_POPULATE_LOOPS: break except (BrowserClosedError, BrowserFailedError, asyncio.CancelledError) as e: raise BrowserFailedError( "The browser seemed to close immediately after starting.", "You can set the `logging.Logger` level lower to see more output.", "You may try installing a known working copy of Chrome by running ", "`$ choreo_get_chrome`." "" "It may be your browser auto-updated and will now work upon " "restart. The browser we tried to start is located at " f"{self._browser_impl.path}.", ) from e async def __aenter__(self) -> Self: """Open browser as context to launch on entry and close on exit.""" await self.open() return self # for use with `await Browser()` def __await__(self) -> Generator[Any, Any, Browser]: """If you await the `Browser()`, it will implicitly call `open()`.""" return self.__aenter__().__await__() async def _is_closed(self, wait: int | None = 0) -> bool: if not hasattr(self, "subprocess"): return True if wait == 0: # poll returns None if its open _is_open = self.subprocess.poll() is None return not _is_open else: try: loop = asyncio.get_running_loop() await loop.run_in_executor( self._process_executor, self.subprocess.wait, wait, ) except subprocess.TimeoutExpired: return False except asyncio.CancelledError: return True return True # we encapsulate a portion of close that relates solely to browser shutdown # that's _close(), but close() handles everything around it async def _close(self) -> None: if await self._is_closed(): _logger.debug("No _close(), already is closed") return try: _logger.debug("Trying Browser.close") await self.send_command("Browser.close") except (BrowserClosedError, BrowserFailedError): _logger.debug("Browser is closed trying to send Browser.close") return except ChannelClosedError: _logger.debug("Can't send Browser.close on close channel") except asyncio.CancelledError: _logger.debug("Close was cancelled, _broker must be shutting down.") self._channel.close() if await self._is_closed(wait=3): return if await self._is_closed(): _logger.debug("Browser is closed after closing channel") return _logger.warning("Resorting to unclean kill browser.") kill(self.subprocess) if await self._is_closed(wait=6): return else: raise RuntimeError("Couldn't close or kill browser subprocess") async def close(self) -> None: """Close the browser.""" _logger.info("Closing browser.") if self._watch_dog_task: _logger.debug("Cancelling watchdog.") self._watch_dog_task.cancel() if not self._release_lock(): return # it can never be mid open here, because all of these must # run on the same thread. Do not push open or close to threads. try: _logger.debug("Starting browser close methods.") await self._close() _logger.debug("Browser close methods finished.") except ProcessLookupError: pass self._broker.clean() _logger.debug("Broker cleaned up.") if self._logger_pipe: os.close(self._logger_pipe) # subprocess has it open anyway # could have closed this copy immediately _logger.debug("Logging pipe closed.") self._channel.close() # was not blocky when comment written _logger.debug("Browser channel closed.") self._browser_impl.clean() # os blocky/hangy across networks _logger.debug("Browser implementation cleaned up.") self._process_executor.shutdown(wait=False, cancel_futures=True) async def __aexit__( self, type_: type[BaseException] | None, value: BaseException | None, traceback: TracebackType | None, ) -> bool | None: """Close the browser.""" await self.close() return None async def _watchdog(self) -> None: _executor = _manual_thread_pool.ManualThreadExecutor( max_workers=1, name="watchdog_wait", ) try: with warnings.catch_warnings(): warnings.filterwarnings("ignore", category=TmpDirWarning) _logger.debug("In watchdog") loop = asyncio.get_running_loop() _logger.debug2("Running wait.") await loop.run_in_executor( _executor, self.subprocess.wait, ) _logger.warning("Wait expired, Browser is being closed by watchdog.") self._watch_dog_task = ( None # no need for close to cancel, we're going to finish soon ) await self.close() await asyncio.sleep(1) await loop.run_in_executor( _executor, self._browser_impl.clean, ) # this is a backup except asyncio.CancelledError: pass finally: _executor.shutdown(wait=False, cancel_futures=True) _logger.debug("Watchdog full shutdown (in finally:)") def _add_tab(self, tab: Tab) -> None: if not isinstance(tab, Tab): raise TypeError(f"tab must be an object of {self._tab_type}") self.tabs[tab.target_id] = tab def _remove_tab(self, target_id: str) -> None: if isinstance(target_id, Tab): target_id = target_id.target_id del self.tabs[target_id] def get_tab(self) -> Tab | None: """ Get the first tab if there is one. Useful for default tabs. Returns: A tab object. """ if self.tabs.values(): return next(iter(self.tabs.values())) return None async def populate_targets(self) -> None: """Solicit the actual browser for all targets to add to the browser object.""" if await self._is_closed(): raise BrowserClosedError("populate_targets() called on a closed browser") response = await self.send_command("Target.getTargets") if "error" in response: raise RuntimeError("Could not get targets") from Exception( response["error"], ) for json_response in response["result"]["targetInfos"]: if ( json_response["type"] == "page" and json_response["targetId"] not in self.tabs ): target_id = json_response["targetId"] new_tab = Tab(target_id, self._broker) try: await new_tab.create_session() except protocol.DevtoolsProtocolError as e: if e.code == protocol.Ecode.TARGET_NOT_FOUND: _logger.warning( f"Target {target_id} not found (could be closed before)", ) continue else: raise self._add_tab(new_tab) _logger.debug(f"The target {target_id} was added") async def create_session(self) -> Session: """ Create a browser session. Only in supported browsers, is experimental. Returns: A session object. """ if await self._is_closed(): raise BrowserClosedError("create_session() called on a closed browser") warnings.warn( # noqa: B028 "Creating new sessions on Browser() only works with some " "versions of Chrome, it is experimental.", protocol.ExperimentalFeatureWarning, ) response = await self.send_command("Target.attachToBrowserTarget") if "error" in response: raise RuntimeError( "Could not create session", ) from protocol.DevtoolsProtocolError( response, ) session_id = response["result"]["sessionId"] new_session = Session(session_id, self._broker) self._add_session(new_session) return new_session async def create_tab( self, url: str = "", width: int | None = None, height: int | None = None, *, window: bool = False, ) -> Tab: """ Create a new tab. Args: url: the url to navigate to, default "" width: the width of the tab (headless only) height: the height of the tab (headless only) window: default False, if true, create new window, not tab Returns: a tab. """ if await self._is_closed(): raise BrowserClosedError("create_tab() called on a closed browser.") params: MutableMapping[str, Any] = {"url": url} if width: params["width"] = width if height: params["height"] = height if window: params["newWindow"] = True response = await self.send_command("Target.createTarget", params=params) if "error" in response: raise RuntimeError( "Could not create tab", ) from protocol.DevtoolsProtocolError( response, ) target_id = response["result"]["targetId"] new_tab = Tab(target_id, self._broker) self._add_tab(new_tab) await new_tab.create_session() return new_tab async def close_tab(self, target_id: str) -> protocol.BrowserResponse: """ Close a tab by its id. Args: target_id: the targetId of the tab to close. """ if await self._is_closed(): raise BrowserClosedError("close_tab() called on a closed browser") if isinstance(target_id, Target): target_id = target_id.target_id # NOTE: we don't need to manually remove sessions because # sessions are intrinsically handled by events response = await self.send_command( command="Target.closeTarget", params={"targetId": target_id}, ) self._remove_tab(target_id) if "error" in response: raise RuntimeError( "Could not close tab", ) from protocol.DevtoolsProtocolError( response, ) return response plotly-choreographer-4159eaf/src/choreographer/browser_sync.py000066400000000000000000000146501525262030500250210ustar00rootroot00000000000000"""Provides the sync api: `BrowserSync`, `TabSync`.""" from __future__ import annotations import os import subprocess from threading import Lock from typing import TYPE_CHECKING import logistro from ._brokers import BrokerSync from .browsers import BrowserClosedError, BrowserFailedError, Chromium from .channels import ChannelClosedError, Pipe from .protocol.devtools_sync import SessionSync, TargetSync from .utils._kill import kill if TYPE_CHECKING: from collections.abc import MutableMapping from pathlib import Path from types import TracebackType from typing import Any from typing_extensions import Self # 3.9 needs this, could be from typing in 3.10 from .browsers._interface_type import BrowserImplInterface from .channels._interface_type import ChannelInterface _logger = logistro.getLogger(__name__) class TabSync(TargetSync): """A wrapper for `TargetSync`, so user can use `TabSync`, not `TargetSync`.""" class BrowserSync(TargetSync): """`BrowserSync` is the sync implementation of `Browser`.""" # A list of the types that are essential to use # with this class tabs: MutableMapping[str, TabSync] """A mapping by target_id of all the targets which are open tabs.""" targets: MutableMapping[str, TargetSync] """A mapping by target_id of ALL the targets.""" # Don't init instance attributes with mutables def _make_lock(self) -> None: self._open_lock = Lock() def _is_open(self) -> bool: return not self._open_lock.acquire(blocking=False) def _release_lock(self) -> bool: try: if self._open_lock.locked(): self._open_lock.release() return True else: return False except RuntimeError: return False def __init__( self, path: str | Path | None = None, *, browser_cls: type[BrowserImplInterface] = Chromium, channel_cls: type[ChannelInterface] = Pipe, **kwargs: Any, ) -> None: """ Construct a new browser instance. Args: path: The path to the browser executable. browser_cls: The type of browser (default: `Chromium`). channel_cls: The type of channel to browser (default: `Pipe`). kwargs: The arguments that the browser_cls takes. For example, headless=True/False, enable_gpu=True/False, etc. """ _logger.debug("Attempting to open new browser.") self._make_lock() self.tabs = {} self.targets = {} # Compose Resources self._channel = channel_cls() self._broker = BrokerSync(self, self._channel) self._browser_impl = browser_cls(self._channel, path, **kwargs) if hasattr(browser_cls, "logger_parser"): parser = browser_cls.logger_parser else: parser = None self._logger_pipe, _ = logistro.getPipeLogger( "browser_proc", parser=parser, ) def open(self) -> None: """Open the browser.""" if self._is_open(): raise RuntimeError("Can't re-open the browser") self.subprocess = subprocess.Popen( # noqa: S603 self._browser_impl.get_cli(), stderr=self._logger_pipe, env=self._browser_impl.get_env(), **self._browser_impl.get_popen_args(), ) super().__init__("0", self._broker) self._add_session(SessionSync("", self._broker)) self._channel.open() def __enter__(self) -> Self: """Open browser as context to launch on entry and close on exit.""" self.open() return self def _is_closed(self, wait: int | None = 0) -> bool: if wait == 0: return self.subprocess.poll() is None else: try: self.subprocess.wait(wait) except subprocess.TimeoutExpired: return False return True def _close(self) -> None: if self._is_closed(): return try: self.send_command("Browser.close") except (BrowserClosedError, BrowserFailedError): _logger.debug("Browser is closed trying to send Browser.close") return except ChannelClosedError: _logger.debug("Can send browser.close on close channel") self._channel.close() if self._is_closed(wait=3): return # try kiling kill(self.subprocess) if self._is_closed(wait=4): return else: raise RuntimeError("Couldn't close or kill browser subprocess") def close(self) -> None: """Close the browser.""" self._broker.clean() _logger.debug("Broker cleaned up.") if not self._release_lock(): return try: _logger.debug("Trying to close browser.") self._close() _logger.debug("browser._close() called successfully.") except ProcessLookupError: pass if self._logger_pipe: os.close(self._logger_pipe) _logger.debug("Logging pipe closed.") self._channel.close() _logger.debug("Browser channel closed.") self._browser_impl.clean() _logger.debug("Browser implementation cleaned up.") def __exit__( self, type_: type[BaseException] | None, value: BaseException | None, traceback: TracebackType | None, ) -> None: # None instead of False is fine, eases type checking """Close the browser.""" self.close() def _add_tab(self, tab: TabSync) -> None: if not isinstance(tab, TabSync): raise TypeError("tab must be an object of TabSync") self.tabs[tab.target_id] = tab def _remove_tab(self, target_id: str) -> None: if isinstance(target_id, TabSync): target_id = target_id.target_id del self.tabs[target_id] def get_tab(self) -> TabSync | None: """Get the first tab if there is one. Useful for default tabs.""" if self.tabs.values(): return next(iter(self.tabs.values())) return None # wrap our broker for convenience def start_output_thread(self, **kwargs: Any) -> None: """ Start a separate thread that dumps all messages received to stdout. Args: kwargs: passed directly to print(). """ self._broker.run_output_thread(**kwargs) plotly-choreographer-4159eaf/src/choreographer/browsers/000077500000000000000000000000001525262030500235705ustar00rootroot00000000000000plotly-choreographer-4159eaf/src/choreographer/browsers/__init__.py000066400000000000000000000005211525262030500256770ustar00rootroot00000000000000"""Contains implementations of browsers that choreographer can open.""" from ._errors import BrowserClosedError, BrowserDepsError, BrowserFailedError from .chromium import ChromeNotFoundError, Chromium __all__ = [ "BrowserClosedError", "BrowserDepsError", "BrowserFailedError", "ChromeNotFoundError", "Chromium", ] plotly-choreographer-4159eaf/src/choreographer/browsers/_chrome_constants.py000066400000000000000000000063541525262030500276620ustar00rootroot00000000000000from __future__ import annotations import os import platform from dataclasses import dataclass chrome_names = ( "chrome", "Chrome", "google-chrome", "google-chrome-stable", "Chrome.app", "Google Chrome", "Google Chrome.app", "Google Chrome for Testing", ) chromium_names = ("chromium", "chromium-browser", "Chromium") edge_names = ( "msedge", "Microsoft Edge", "microsoft-edge", "microsoft-edge-beta", "microsoft-edge-dev", ) brave_names = ("brave", "Brave Browser", "brave-browser") vivaldi_names = ("vivaldi", "Vivaldi") if platform.system() == "Windows": _windows_app_dirs = [ _p for _p in [ os.environ.get("PROGRAMFILES", ""), os.environ.get("PROGRAMFILES(X86)", ""), os.environ.get("LOCALAPPDATA", ""), ] if _p ] _chrome_suffix = r"\Google\Chrome\Application\chrome.exe" typical_chrome_paths = tuple(_p + _chrome_suffix for _p in _windows_app_dirs) _chromium_suffix = r"\Chromium\Application\chrome.exe" typical_chromium_paths = tuple(_p + _chromium_suffix for _p in _windows_app_dirs) _edge_suffix = r"\Microsoft\Edge\Application\msedge.exe" typical_edge_paths = tuple(_p + _edge_suffix for _p in _windows_app_dirs) _brave_suffix = r"\BraveSoftware\Brave-Browser\Application\brave.exe" typical_brave_paths = tuple(_p + _edge_suffix for _p in _windows_app_dirs) _vivaldi_suffix = r"\Vivaldi\Application\vivaldi.exe" typical_vivaldi_paths = tuple(_p + _brave_suffix for _p in _windows_app_dirs) elif platform.system() == "Linux": typical_chrome_paths = ( "/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/usr/bin/chrome", ) typical_chromium_paths = ( "/usr/bin/chromium", "/usr/bin/chromium-browser", ) typical_edge_paths = ( "/usr/bin/microsoft-edge", "/usr/bin/microsoft-edge-beta", "/usr/bin/microsoft-edge-dev", ) typical_brave_paths = ( "/usr/bin/brave-browser", "/opt/brave.com/brave/brave-browser", ) typical_vivaldi_paths = ("/usr/bin/vivaldi",) else: # assume mac, or system == "Darwin" typical_chrome_paths = ( "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", ) typical_chromium_paths = ("/Applications/Chromium.app/Contents/MacOS/Chromium",) typical_edge_paths = ( "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", ) typical_brave_paths = ( "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", ) typical_vivaldi_paths = ("/Applications/Vivaldi.app/Contents/MacOS/Vivaldi",) @dataclass(frozen=True) class BrowserInfo: __slots__ = ("exe_names", "ms_prog_id", "typical_paths") exe_names: tuple[str, ...] ms_prog_id: str typical_paths: tuple[str, ...] chromium_based_browsers = { "chrome": BrowserInfo(chrome_names, "ChromeHTML", typical_chrome_paths), "chromium": BrowserInfo(chromium_names, "ChromiumHTML", typical_chromium_paths), "edge": BrowserInfo(edge_names, "MSEdgeHTM", typical_edge_paths), "brave": BrowserInfo(brave_names, "BraveHTML", typical_brave_paths), "vivaldi": BrowserInfo(vivaldi_names, "VivaldiHTM", typical_vivaldi_paths), } plotly-choreographer-4159eaf/src/choreographer/browsers/_errors.py000066400000000000000000000030571525262030500256220ustar00rootroot00000000000000class BrowserClosedError(RuntimeError): """An error for when the browser is closed accidentally (during access).""" class BrowserFailedError(RuntimeError): """An error for when the browser fails to launch.""" # not currently used but keeping copy + not breaking API class BrowserDepsError(BrowserFailedError): """An error for when the browser is closed because of missing libs.""" def __init__(self) -> None: msg = ( "It seems like you are running a slim version of your " "operating system and are missing some common dependencies. " "The following command should install the required " "dependencies on most systems:\n" "\n" "$ sudo apt update && sudo apt-get install libnss3 " "libatk-bridge2.0-0 libcups2 libxcomposite1 libxdamage1 " "libxfixes3 libxrandr2 libgbm1 libxkbcommon0 libpango-1.0-0 " "libcairo2 libasound2\n" "\n" "If you have already run the above command and are still " "seeing this error, or the above command fails, consult the " "Kaleido documentation for operating system to install " "chromium dependencies.\n" "\n" "For support, run the command `choreo_diagnose` and create " "an issue with its output." ) super().__init__(msg) # BrowserDeps being a more specific type of Failure. # And Closed not necessarily being related (you can intentionally closed, # and something else can error because its closed.) plotly-choreographer-4159eaf/src/choreographer/browsers/_interface_type.py000066400000000000000000000031131525262030500273000ustar00rootroot00000000000000"""Provides the basic protocol class (the abstract base) for a protocol.""" from __future__ import annotations from typing import TYPE_CHECKING, Protocol if TYPE_CHECKING: import logging from collections.abc import Mapping, MutableMapping, Sequence from pathlib import Path from typing import Any from choreographer.channels._interface_type import ChannelInterface class BrowserImplInterface(Protocol): """Defines the basic interface of a channel.""" path: str | Path | None """The OS path to the operating system.""" @classmethod def find_browser( cls, *, skip_local: bool, skip_typical: bool = False, ) -> str | None: ... ### Tries to find a working copy of itself, using our OS methods ### as well as its own methods. See the Chromium implementation. @classmethod def logger_parser( cls, record: logging.LogRecord, _old: MutableMapping[str, Any], ) -> bool: ... # This method will be used as the `filter()` method on the `logging.Filter()` # attached to all incoming logs from the browser process. # The log will be entirely ignored if return is False. def __init__( self, channel: ChannelInterface, path: Path | str | None = None, **kwargs: Any, ) -> None: ... def pre_open(self) -> None: ... def get_popen_args(self) -> Mapping[str, Any]: ... def get_cli(self) -> Sequence[str]: ... def get_env(self) -> MutableMapping[str, str]: ... def clean(self) -> None: ... def is_isolated(self) -> bool: ... plotly-choreographer-4159eaf/src/choreographer/browsers/_unix_pipe_chromium_wrapper.py000066400000000000000000000032451525262030500317500ustar00rootroot00000000000000""" _unix_pipe_chromium_wrapper.py provides proper fds to chrome. By running chromium in a new process (this wrapper), we guarantee the user hasn't stolen one of our desired file descriptors, which the OS gives away first-come-first-serve everytime someone opens a file. chromium demands we use 3 and 4. """ from __future__ import annotations import os # importing modules has side effects, so we do this before imports # ruff: noqa: E402 # chromium reads on 3, writes on 4 os.dup2(0, 3) # make our stdin their input os.dup2(1, 4) # make our stdout their output _inheritable = True os.set_inheritable(4, _inheritable) os.set_inheritable(3, _inheritable) import signal import subprocess import sys from functools import partial from typing import TYPE_CHECKING import logistro if TYPE_CHECKING: from types import FrameType _logger = logistro.getLogger("chrome_wrapper") # we're a wrapper, the cli is everything that came after us cli = sys.argv[1:] print(f"wrapper CLI: {cli}", file=sys.stderr) # noqa: T201 goes to pipe/logger anyway process = subprocess.Popen(cli, pass_fds=(3, 4)) # noqa: S603 untrusted input def kill_proc( process: subprocess.Popen[bytes], _sig_num: int, _frame: FrameType | None, ) -> None: process.terminate() process.wait(5) # 5 seconds to clean up nicely, it's a lot process.kill() kp = partial(kill_proc, process) signal.signal(signal.SIGTERM, kp) signal.signal(signal.SIGINT, kp) process.wait() # not great but it seems that # pipe isn't always closed when chrome closes # so we pretend to be chrome and send a bye instead # also, above depends on async/sync, platform, etc print("{bye}") # noqa: T201 we need print here plotly-choreographer-4159eaf/src/choreographer/browsers/chromium.py000066400000000000000000000262111525262030500257670ustar00rootroot00000000000000"""Provides a class proving tools for running chromium browsers.""" from __future__ import annotations import os import platform import re import subprocess import sys from pathlib import Path from typing import TYPE_CHECKING import logistro if platform.system() == "Windows": import msvcrt from choreographer.channels import Pipe from choreographer.cli._cli_utils import ( get_chrome_download_path, get_old_chrome_download_path, ) from choreographer.utils import TmpDirectory, get_browser_path from ._chrome_constants import chromium_based_browsers if TYPE_CHECKING: import logging from collections.abc import Mapping, MutableMapping, Sequence from typing import Any from choreographer.channels._interface_type import ChannelInterface _chromium_wrapper_path = ( Path(__file__).resolve().parent / "_unix_pipe_chromium_wrapper.py" ) _packaged_chromium_libs = Path(__file__).resolve().parent / "packaged_chromium_libs" _logger = logistro.getLogger(__name__) def _is_exe(path: str | Path) -> bool: try: return os.access(path, os.X_OK) except: # noqa: E722 bare except ok, weird errors, best effort. return False _logs_parser_regex = re.compile(r"\d*:\d*:\d*\/\d*\.\d*:") class ChromeNotFoundError(RuntimeError): """Raise when browser path can't be determined.""" class Chromium: """ Chromium represents an implementation of the chromium browser. It also includes chromium-like browsers (chrome, edge, and brave). """ path: str | Path | None """The path to the chromium executable.""" extensions_enabled: bool """True to enable browser extensions. True by default.""" gpu_enabled: bool """True if we should use the gpu. False by default for compatibility.""" headless: bool """True if we should not show the browser. True by default.""" proxy_server: str | None """Proxy server passed to Chromium, if configured.""" sandbox_enabled: bool """True to enable the sandbox. False by default.""" skip_local: bool """True if we want to avoid looking for our local download when searching path.""" tmp_dir: TmpDirectory """A reference to a temporary directory object the chromium needs to store data.""" @classmethod def find_browser( cls, *, skip_local: bool, skip_typical: bool = False, verify_local: bool = False, ) -> str | None: """Find a chromium based browser.""" if not skip_local: if ((p := get_chrome_download_path(mkdir=False)) and p.exists()) or ( (p := get_old_chrome_download_path()) and p.exists() ): return str(p) elif verify_local: raise RuntimeError("verify_local set to True, local not found.") elif verify_local: raise ValueError("Cannot set both skip_local and verify_local.") for name, browser_data in chromium_based_browsers.items(): _logger.debug(f"Looking for a {name} browser.") path = get_browser_path( executable_names=browser_data.exe_names, ms_prog_id=browser_data.ms_prog_id, ) if not path and not skip_typical: for candidate in browser_data.typical_paths: if _is_exe(candidate): path = candidate break if path: return path return None @classmethod def logger_parser( cls, record: logging.LogRecord, _old: MutableMapping[str, Any], ) -> bool: """ Remove chromium timestamp from chromium's logs. This method will be used as the `filter()` method on the `logging.Filter()` attached to all incoming logs from the browser process. Args: record: the `logging.LogRecord` object to read/modify _old: data that was already stripped out. """ # replace the chromium timestamp because we do our own record.msg = _logs_parser_regex.sub("", record.msg) return True def __init__( self, channel: ChannelInterface, path: Path | str | None = None, **kwargs: Any, ) -> None: """ Construct a chromium browser implementation. Args: channel: the `choreographer.Channel` we'll be using (WebSockets? Pipe?) path: path to the browser kwargs: enable_extensions (default True): Enable extensions? enable_gpu (default False): Turn on GPU? Doesn't work in all envs. headless (default True): Run the browser in headless mode? proxy_server (default None): Proxy server URL passed to Chromium. Falls back to the `CHOREO_PROXY_SERVER` environment variable. enable_sandbox (default False): Enable sandbox- a persnickety thing depending on environment, OS, user, etc tmp_dir (default None): Manually set the temporary directory Raises: RuntimeError: Too many kwargs, or browser not found. NotImplementedError: Pipe is the only channel type it'll accept right now. """ _logger.info(f"Chromium init'ed with kwargs {kwargs}") self.path = path self.extensions_enabled = kwargs.pop("enable_extensions", True) self.gpu_enabled = kwargs.pop("enable_gpu", False) self.headless = kwargs.pop("headless", True) self.proxy_server = kwargs.pop("proxy_server", None) or os.environ.get( "CHOREO_PROXY_SERVER", ) self.sandbox_enabled = kwargs.pop("enable_sandbox", False) self._tmp_dir_path = kwargs.pop("tmp_dir", None) if kwargs: raise RuntimeError( f"Chromium.get_cli() received invalid args: {kwargs.keys()}", ) self.skip_local = bool( "ubuntu" in platform.version().lower() and self.sandbox_enabled, ) if self.skip_local: _logger.warning( "Forced skipping local. Ubuntu sandbox requires package manager.", ) if not self.path: self.path = Chromium.find_browser(skip_local=self.skip_local) if not self.path or not Path(self.path).is_file(): raise ChromeNotFoundError( "Browser not found. You can use get_chrome() or " "choreo_get_chrome from bash. please see documentation. " f"Local copy ignored: {self.skip_local}. ", f"Path calculated:: {self.path}.", ) _logger.info(f"Found chromium path: {self.path}") self._channel = channel if not isinstance(channel, Pipe): raise NotImplementedError("Websocket style channels not implemented yet.") self._is_isolated = "snap" in str(self.path) def pre_open(self) -> None: """Prepare browser for opening.""" self.tmp_dir = TmpDirectory( path=self._tmp_dir_path, sneak=self._is_isolated, ) _logger.info(f"Temporary directory at: {self.tmp_dir.path}") def is_isolated(self) -> bool: """ Return if /tmp directory is isolated by OS. Returns: bool indicating if /tmp is isolated. """ return self._is_isolated def get_popen_args(self) -> Mapping[str, Any]: """Return the args needed to runc chromium with `subprocess.Popen()`.""" args = {} # need to check pipe if platform.system() == "Windows": args["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore [attr-defined] args["close_fds"] = False else: args["close_fds"] = True if isinstance(self._channel, Pipe): args["stdin"] = self._channel.from_choreo_to_external args["stdout"] = self._channel.from_external_to_choreo args["start_new_session"] = True _logger.debug(f"Returning args: {args}") return args def get_cli(self) -> Sequence[str]: """Return the CLI command for chromium.""" if platform.system() != "Windows": cli = [ str(sys.executable), str(_chromium_wrapper_path), str(self.path), ] else: cli = [ str(self.path), ] if not self.extensions_enabled: cli.append("--disable-extensions") if not self.gpu_enabled: cli.append("--disable-gpu") if self.headless: cli.append("--headless") if self.proxy_server: cli.append(f"--proxy-server={self.proxy_server}") if not self.sandbox_enabled: cli.append("--no-sandbox") cli.extend( [ "--disable-breakpad", "--allow-file-access-from-files", "--enable-logging=stderr", f"--user-data-dir={self.tmp_dir.path}", "--no-first-run", "--enable-unsafe-swiftshader", "--disable-dev-shm-usage", "--disable-background-media-suspend", "--disable-lazy-loading", "--disable-background-timer-throttling", "--disable-backgrounding-occluded-windows", "--disable-renderer-backgrounding", "--disable-component-update", "--disable-hang-monitor", "--disable-popup-blocking", "--disable-prompt-on-repost", "--disable-ipc-flooding-protection", "--disable-sync", "--metrics-recording-only", "--password-store=basic", "--use-mock-keychain", "--no-default-browser-check", "--no-process-per-site", "--disable-web-security", ], ) if isinstance(self._channel, Pipe): cli.append("--remote-debugging-pipe") if platform.system() == "Windows": # its gonna read on 3 # its gonna write on 4 r_handle = msvcrt.get_osfhandle(self._channel.from_choreo_to_external) # type: ignore [attr-defined] w_handle = msvcrt.get_osfhandle(self._channel.from_external_to_choreo) # type: ignore [attr-defined] _inheritable = True os.set_handle_inheritable(r_handle, _inheritable) # type: ignore [attr-defined] os.set_handle_inheritable(w_handle, _inheritable) # type: ignore [attr-defined] cli += [ f"--remote-debugging-io-pipes={r_handle!s},{w_handle!s}", ] _logger.debug(f"Returning cli: {cli}") return cli def get_env(self) -> MutableMapping[str, str]: """Return the env needed for chromium.""" env = os.environ.copy() return env def clean(self) -> None: """Clean up any leftovers form browser, like tmp files.""" if hasattr(self, "tmp_dir"): self.tmp_dir.clean() def __del__(self) -> None: """Delete the temporary file and run `clean()`.""" self.clean() plotly-choreographer-4159eaf/src/choreographer/channels/000077500000000000000000000000001525262030500235155ustar00rootroot00000000000000plotly-choreographer-4159eaf/src/choreographer/channels/README.txt000066400000000000000000000014471525262030500252210ustar00rootroot00000000000000Browsers often accept two communication channels: websockets and pipes. Both classes we implement will support `write_jsons()` and `read_jsons()` with the same interface (as well as `__init__()` and `close()`). But the browser implementation in _browsers/ will have to get specific information from the pipe/websocket in order to properly build the CLI command for the browser. For example, the CLI command needs to know the file numbers (file descriptors) for reading writing if using `Pipe()`, so `Pipe()` has the attributes: `.from_external_to_choreo` and `.from_choreo_to_external`. They're part of the interface as well. In the same vein, when websockets in implemented, the CLI command will need to know the port it's on. There may need to be a `open()` function for after the CLI command is run. plotly-choreographer-4159eaf/src/choreographer/channels/__init__.py000066400000000000000000000006701525262030500256310ustar00rootroot00000000000000""" Channels are classes that choreo and the browser use to communicate. This is a low-level part of the API. """ from ._errors import ( BlockWarning, ChannelClosedError, JSONError, MessageTooLargeError, ) from ._wire import register_custom_encoder from .pipe import Pipe __all__ = [ "BlockWarning", "ChannelClosedError", "JSONError", "MessageTooLargeError", "Pipe", "register_custom_encoder", ] plotly-choreographer-4159eaf/src/choreographer/channels/_errors.py000066400000000000000000000031751525262030500255500ustar00rootroot00000000000000from __future__ import annotations class BlockWarning(UserWarning): """A warning for when block modification operations used on incompatible OS.""" class ChannelClosedError(IOError): """An error to throw when the channel has closed from either end or error.""" class JSONError(RuntimeError): """Another JSONError.""" class MessageTooLargeError(RuntimeError): """ An error for when a message won't fit in the browser's receive buffer. The browser closes the connection outright if we write a message bigger than its buffer, so we refuse to write it and raise this instead. """ size: int """The size, in bytes, of the message we refused to write.""" max_size: int """The largest message the browser will accept.""" payload: str | None """ The serialized message. It is kept so that callers who know how to break the message up can reuse it instead of serializing all over again. It is deliberately left out of the error text: it can be hundreds of megabytes. """ def __init__(self, size: int, max_size: int, payload: str | None = None) -> None: """ Construct a MessageTooLargeError. Args: size: the size of the message in bytes. max_size: the largest message the browser will accept. payload: the serialized message, if the caller should have it. """ super().__init__( f"Message is {size} bytes, which is over the browser's " f"{max_size} byte limit. It was not sent.", ) self.size = size self.max_size = max_size self.payload = payload plotly-choreographer-4159eaf/src/choreographer/channels/_interface_type.py000066400000000000000000000025101525262030500272250ustar00rootroot00000000000000"""Provides the basic protocol class (the abstract interface) for a channel.""" from __future__ import annotations from typing import TYPE_CHECKING, Protocol if TYPE_CHECKING: from collections.abc import Mapping, Sequence from typing import Any from choreographer.protocol import BrowserResponse class ChannelInterface(Protocol): """Defines the basic interface of a channel.""" # Not sure I like the obj type def write_json(self, obj: Mapping[str, Any]) -> tuple[float, float]: ... # """ # Accept an object and send it doesnt the channel serialized. # # Args: # obj: the object to send to the browser. # # """ def read_jsons( self, *, blocking: bool = True, ) -> tuple[Sequence[BrowserResponse], float]: ... # """ # Read all available jsons in the channel and returns a list of complete ones. # # Args: # blocking: should this method block on read or return immediately. # """ def close(self) -> None: ... # """Close the channel.""" def open(self) -> None: ... # """Open the channel.""" def is_ready(self) -> bool: ... # """Return true if comm channel is active.""" # Can't docstring protocols! EW! plotly-choreographer-4159eaf/src/choreographer/channels/_wire.py000066400000000000000000000056411525262030500252020ustar00rootroot00000000000000from __future__ import annotations import json from typing import TYPE_CHECKING import logistro import simplejson from simplejson import errors as sjerrors from ._errors import JSONError if TYPE_CHECKING: from typing import Any _logger = logistro.getLogger(__name__) _custom_encoder: type[json.JSONEncoder] | None = None def register_custom_encoder(e: type[json.JSONEncoder] | None) -> None: global _custom_encoder # noqa: PLW0603 what other choice do we have _custom_encoder = e class MultiEncoder(simplejson.JSONEncoder): """Special json encoder for numpy types.""" # docs say subclassing inferior, just pass this method as a kward def default(self, o: Any) -> Any: if hasattr(o, "dtype") and o.dtype.kind in ("i", "u") and o.shape == (): return int(o) elif hasattr(o, "dtype") and o.dtype.kind == "f" and o.shape == (): return float(o) elif hasattr(o, "dtype") and o.shape != (): if hasattr(o, "values") and hasattr(o.values, "tolist"): return o.values.tolist() if hasattr(o, "tolist"): return o.tolist() elif hasattr(o, "isoformat"): return o.isoformat() return simplejson.JSONEncoder.default(self, o) def serialize_str(obj: Any) -> str: """ Serialize an object to a JSON string. Use `serialize()` to return a value encoded as bytes, which is the format accepted by Chrome. `serialize_str()` exists for callers that need to split a large string along character boundaries before encoding as bytes. Encoding uses the encoder given to `register_custom_encoder()`, or `MultiEncoder` on top of `simplejson` when none is registered. Which one is in use decides what counts as serializable. Args: obj: Any Python object that serializes to JSON. """ try: if not _custom_encoder: message = simplejson.dumps( obj, ensure_ascii=False, ignore_nan=True, cls=MultiEncoder, ) else: message = json.dumps(obj, cls=_custom_encoder) except (json.JSONDecodeError, simplejson.JSONDecodeError) as e: raise JSONError from e _logger.debug(f"Serialized: {message[:15]}...{message[-15:]}, size: {len(message)}") _logger.debug2(f"Whole message: {message}") return message def serialize(obj: Any) -> bytes: """ Serialize an object to UTF-8 encoded JSON, ready for the wire. Args: obj: Any Python object that serializes to JSON. """ return serialize_str(obj).encode("utf-8") def deserialize(message: str) -> Any: """ Read one JSON message from the browser back into Python objects. Args: message: One JSON message, already decoded from bytes. """ try: return simplejson.loads(message) except sjerrors.JSONDecodeError as e: raise JSONError from e plotly-choreographer-4159eaf/src/choreographer/channels/pipe.py000066400000000000000000000242561525262030500250350ustar00rootroot00000000000000"""Provides a channel based on operating system file pipes.""" from __future__ import annotations import os import platform import sys import time import warnings from threading import Lock from typing import TYPE_CHECKING import logistro from . import _wire as wire from ._errors import ( BlockWarning, ChannelClosedError, JSONError, MessageTooLargeError, ) if TYPE_CHECKING: from collections.abc import Mapping, Sequence from typing import Any from choreographer.protocol import BrowserResponse _with_block = bool(sys.version_info[:3] >= (3, 12) or platform.system() != "Windows") _logger = logistro.getLogger(__name__) MAX_MESSAGE_SIZE = 100 * 1024 * 1024 """ The biggest message Chrome will read off the pipe, in bytes. This mirrors `kReceiveBufferSizeForDevTools` in Chrome's `content/browser/devtools/devtools_pipe_handler.cc`. """ # should be closing my ends from the start? # if we're a pipe we expect these public attributes class Pipe: """Defines an operating system pipe.""" from_external_to_choreo: int """Consumers need this, it is the channel the browser uses to talk to choreo.""" from_choreo_to_external: int """Consumers needs this, it is the channel choreo writes to the browser on.""" shutdown_lock: Lock """Once this is locked, the pipe is closed and can't be reopened.""" def __init__(self) -> None: """Construct a pipe using os functions.""" # This is where pipe listens (from browser) # So pass the write to browser self._read_from_browser, self._write_from_browser = list(os.pipe()) # This is where pipe writes (to browser) # So pass the read to browser self._read_to_browser, self._write_to_browser = list(os.pipe()) # Popen will write stdout of wrapper to this (dupping 4) # Browser will write directly to this if not using wrapper self.from_external_to_choreo = self._write_from_browser # Popen will read this into stdin of wrapper (dupping 3) # Browser will read directly from this if not using wrapper # which dupes stdin to expected fd (4?) self.from_choreo_to_external = self._read_to_browser # These won't be used on windows directly, they'll be t-formed to # windows-style handles. But let another layer handle that. # this is just a convenience to prevent multiple shutdowns self.shutdown_lock = Lock() # should be private self._open_lock = Lock() # should be private def is_ready(self) -> bool: """Return true if pipe open.""" return not self.shutdown_lock.locked() and self._open_lock.locked() def open(self) -> None: """ Open the channel. In a sense, __init__ creates the pipe. The OS opens it. Here we're just marking it open for use, that said. We only use locks here for indications, we never actually lock, because the broker is in charge of all async/parallel stuff. """ if not self._open_lock.acquire(blocking=False): raise RuntimeError("Cannot open same pipe twice.") def write_json(self, obj: Mapping[str, Any]) -> tuple[float, float]: """ Send one json message down the pipe. Args: obj: Any python object that serializes to JSON. Raises: ChannelClosedError: If the pipe was never opened or is already closed, or if the OS write fails. A failed write closes the pipe, so nothing can be sent after this. MessageTooLargeError: If the message won't fit in Chrome's buffer. Nothing is written, so the channel is still good afterwards. The error carries the serialized message so that callers who can break it up don't have to serialize it a second time. TypeError: If `obj` contains something the encoder doesn't know how to turn into JSON. UnicodeEncodeError: If the serialized message contains lone surrogates, which have no UTF-8 representation. """ if not self.is_ready(): raise ChannelClosedError( "The communication channel was either never " "opened or closed. Was .open() or .close() called?", ) message = wire.serialize_str(obj) encoded_message = message.encode("utf-8") + b"\0" if len(encoded_message) > MAX_MESSAGE_SIZE: # Don't close(): we haven't written anything, the pipe is fine. raise MessageTooLargeError( len(encoded_message), MAX_MESSAGE_SIZE, payload=message, ) _logger.debug( f"Writing message {encoded_message[:15]!r}...{encoded_message[-15:]!r}, " f"size: {len(encoded_message)}.", ) _logger.debug2(f"Full Message: {encoded_message!r}") start = time.perf_counter() try: ret = os.write(self._write_to_browser, encoded_message) _logger.debug( f"***Wrote {ret}/{len(encoded_message)}***", ) if ret != len(encoded_message): _logger.critical( f"***Did not write entire message. {ret}/{len(encoded_message)}***", ) except OSError as e: self.close() raise ChannelClosedError from e return (start, time.perf_counter()) def read_jsons( # noqa: PLR0912, PLR0915, C901 branches, complexity self, *, blocking: bool = True, ) -> tuple[Sequence[BrowserResponse], float]: """ Read from the pipe and return one or more jsons in a list. Args: blocking: The read option can be set to block or not. Returns: A list of jsons. """ jsons: list[BrowserResponse] = [] if not self.is_ready(): raise ChannelClosedError( "The communication channel was either never " "opened or closed. Was .open() or .close() called?", ) if not _with_block and not blocking: warnings.warn( # noqa: B028 "Windows python version < 3.12 does not support non-blocking", BlockWarning, ) try: if _with_block: os.set_blocking(self._read_from_browser, blocking) except OSError as e: self.close() raise ChannelClosedError from e raw_buffer = None # if we fail in read, we already defined loop_count = 1 try: raw_buffer = os.read( self._read_from_browser, 10000, ) # 10MB buffer, nbd, doesn't matter w/ this _logger.debug( f"First read in loop: {raw_buffer[:15]!r}...{raw_buffer[-15:]!r}. " f"size: {len(raw_buffer)}.", ) _logger.debug2(f"Whole buffer: {raw_buffer!r}") if not raw_buffer or raw_buffer == b"{bye}\n": if raw_buffer: _logger.debug(f"Received {raw_buffer!r}. is bye?") # we seem to need {bye} even if chrome closes NOTE self.close() raise ChannelClosedError while raw_buffer[-1] != 0: _logger.debug("Partial message from browser received.") loop_count += 1 if _with_block: os.set_blocking(self._read_from_browser, True) raw_buffer += os.read(self._read_from_browser, 10000) except BlockingIOError: _logger.debug("BlockingIOError") return jsons, time.perf_counter() except OSError as e: _logger.debug("OSError") self.close() if not raw_buffer or raw_buffer == b"{bye}\n": raise ChannelClosedError from e # this could be hard to test as it is a real OS corner case finally: _logger.debug( f"Total loops: {loop_count}, " f"Final size: {len(raw_buffer) if raw_buffer else 0}.", ) _logger.debug2(f"Whole buffer: {raw_buffer!r}") if raw_buffer is None: return jsons, time.perf_counter() decoded_buffer = raw_buffer.decode("utf-8") raw_messages = decoded_buffer.split("\0") _logger.debug(f"Received {len(raw_messages)} raw_messages.") for raw_message in raw_messages: if raw_message: try: jsons.append(wire.deserialize(raw_message)) except JSONError: _logger.exception("JSONError decoding message. Ignoring") except: _logger.exception("Error in trying to decode JSON off our read.") raise return jsons, time.perf_counter() def _unblock_fd(self, fd: int) -> None: try: if _with_block: os.set_blocking(fd, False) except Exception: # noqa: BLE001, S110 OS errors are not consistent, catch blind + pass pass def _close_fd(self, fd: int) -> None: try: os.close(fd) except Exception: # noqa: BLE001, S110 OS errors are not consistent, catch blind + pass pass def _fake_bye(self) -> None: self._unblock_fd(self._write_from_browser) try: os.write(self._write_from_browser, b"{bye}\n") except Exception: # noqa: BLE001, S110 OS errors are not consistent, catch blind + pass pass def close(self) -> None: """Close the pipe.""" if self.shutdown_lock.acquire(blocking=False): if platform.system() == "Windows": self._fake_bye() self._unblock_fd(self._write_from_browser) self._unblock_fd(self._read_from_browser) self._unblock_fd(self._write_to_browser) self._unblock_fd(self._read_to_browser) self._close_fd(self._write_to_browser) # no more writes self._close_fd(self._write_from_browser) # we're done with writes self._close_fd(self._read_from_browser) # no more attempts at read self._close_fd(self._read_to_browser) plotly-choreographer-4159eaf/src/choreographer/cli/000077500000000000000000000000001525262030500224715ustar00rootroot00000000000000plotly-choreographer-4159eaf/src/choreographer/cli/__init__.py000066400000000000000000000003231525262030500246000ustar00rootroot00000000000000"""cli provides some tools that are used on the commandline (and to download chrome).""" from ._cli_utils import ( get_chrome, get_chrome_sync, ) __all__ = [ "get_chrome", "get_chrome_sync", ] plotly-choreographer-4159eaf/src/choreographer/cli/_cli_utils.py000066400000000000000000000227621525262030500252020ustar00rootroot00000000000000from __future__ import annotations import argparse import asyncio import json import platform import shutil import sys import urllib.request import warnings import zipfile from functools import partial from pathlib import Path import logistro from choreographer.cli.defaults import default_download_path, old_download_path _logger = logistro.getLogger(__name__) _chrome_for_testing_url = "https://googlechromelabs.github.io/chrome-for-testing/known-good-versions-with-downloads.json" supported_platform_strings = ["linux64", "win32", "win64", "mac-x64", "mac-arm64"] # Returns the detected platform string, including ones not in # supported_platform_strings (e.g. linux-arm64, win-arm64) so callers # can surface them in error messages. def get_google_platform_string() -> str | None: arch_size_detected = "64" if sys.maxsize > 2**32 else "32" machine = platform.machine().lower() is_arm = machine in {"arm64", "aarch64"} arch_detected = "arm" if is_arm else "x" if platform.system() == "Windows": if not is_arm: return "win" + arch_size_detected return "win-" + arch_detected + arch_size_detected if platform.system() == "Linux": if not is_arm: return "linux" + arch_size_detected return "linux-" + arch_detected + arch_size_detected if platform.system() == "Darwin": return "mac-" + arch_detected + arch_size_detected return None def get_google_supported_platform_string() -> str | None: chrome_platform_detected = get_google_platform_string() if chrome_platform_detected in supported_platform_strings: return chrome_platform_detected return None def get_chrome_download_path( root=default_download_path, *, mkdir=True, ) -> Path | None: _chrome_platform_detected = get_google_supported_platform_string() if not _chrome_platform_detected: return None _default_exe_path = root if mkdir: _default_exe_path.mkdir(parents=True, exist_ok=True) if platform.system().startswith("Linux"): _default_exe_path /= f"chrome-{_chrome_platform_detected}/chrome" elif platform.system().startswith("Darwin"): _default_exe_path /= ( f"chrome-{_chrome_platform_detected}" "/Google Chrome for Testing.app" "/Contents" "/MacOS" "/Google Chrome for Testing" ) elif platform.system().startswith("Win"): _default_exe_path /= f"chrome-{_chrome_platform_detected}/chrome.exe" return _default_exe_path # can remove this and it's default after some time - 2025/11/29 def get_old_chrome_download_path(): return get_chrome_download_path( root=old_download_path, mkdir=False, ) # https://stackoverflow.com/questions/39296101/python-zipfile-removes-execute-permissions-from-binaries class _ZipFilePermissions(zipfile.ZipFile): def _extract_member(self, member, targetpath, pwd): # type: ignore [no-untyped-def] if not isinstance(member, zipfile.ZipInfo): member = self.getinfo(member) path = super()._extract_member(member, targetpath, pwd) # type: ignore [misc] # High 16 bits are os specific (bottom is st_mode flag) attr = member.external_attr >> 16 if attr != 0: Path(path).chmod(attr) return path def get_chrome_sync( # noqa: C901, PLR0912, PLR0915 arch: str | None = None, i: int | None = None, path: str | Path = default_download_path, *, verbose: bool = False, force: bool = False, ) -> Path | str: """Download chrome synchronously: see `get_chrome()`.""" if isinstance(path, str): path = Path(path) arch = arch or get_google_platform_string() if arch is None: raise RuntimeError( f"Could not detect a supported platform on {platform.system()!r}. " f"Pass --arch explicitly, one of: " f"{', '.join(supported_platform_strings)}.", ) if arch not in supported_platform_strings: raise RuntimeError( f"Detected platform {arch!r} is not supported by Chrome for Testing. " f"Supported: {', '.join(supported_platform_strings)}.", ) if i: _logger.info("Loading chrome from list") raw_json = urllib.request.urlopen( _chrome_for_testing_url, ).read() browser_list = json.loads( raw_json, ) version_obj = browser_list["versions"][i] raw_json = json.dumps(version_obj) else: _logger.info("Using last known good version of chrome") raw_json = ( Path(__file__).resolve().parent.parent / "resources" / "last_known_good_chrome.json" ).read_text() version_obj = json.loads( raw_json, ) version_string = f"{version_obj['version']}\n{version_obj['revision']}" chromium_sources = version_obj["downloads"]["chrome"] for src in chromium_sources: if src["platform"] == arch: url = src["url"] break else: raise RuntimeError( "You must specify an arch, one of: " f"{', '.join(supported_platform_strings)}. " f"{arch} is not supported.", ) if verbose: print(raw_json) # noqa: T201 allow print in cli version_tag = path / "version_tag.txt" path.mkdir(parents=True, exist_ok=True) if arch.startswith("linux"): exe_path = path / f"chrome-{arch}" / "chrome" elif arch.startswith("mac"): exe_path = ( path / f"chrome-{arch}" / "Google Chrome for Testing.app" / "Contents" / "MacOS" / "Google Chrome for Testing" ) elif arch.startswith("win"): exe_path = path / f"chrome-{arch}" / "chrome.exe" else: raise RuntimeError("Couldn't calculate exe_name, unsupported architecture.") if ( exe_path.exists() and version_tag.is_file() and version_tag.read_text() == version_string and not force ): return exe_path else: if exe_path.exists(): # delete it if exe_path.is_dir(): shutil.rmtree(exe_path) else: exe_path.unlink() # It really should always be a dir but in testing we fake it if version_tag.exists(): # delete it version_tag.unlink() # Download zip_path = path / "chrome.zip" with urllib.request.urlopen(url) as response, zip_path.open("wb") as out_file: # noqa: S310 audit url shutil.copyfileobj(response, out_file) with _ZipFilePermissions(zip_path, "r") as zip_ref: zip_ref.extractall(path) zip_path.unlink() version_tag.write_text(version_string) return exe_path async def get_chrome( arch: str | None = None, i: int | None = None, path: str | Path = default_download_path, *, verbose: bool = False, force: bool = False, ) -> Path | str: """ Download google chrome from google-chrome-for-testing server. Args: arch: the target platform/os, as understood by google's json directory. i: the chrome version: -1 being the latest version, 0 being the oldest still in the testing directory. path: where to download it too (the folder). verbose: print out version found force: download chrome again even if already present at that version """ loop = asyncio.get_running_loop() fn = partial( get_chrome_sync, arch=arch, i=i, path=path, verbose=verbose, force=force, ) return await loop.run_in_executor( executor=None, func=fn, ) def get_chrome_cli() -> None: if "ubuntu" in platform.version().lower(): warnings.warn( # noqa: B028 "You are using `get_browser()` on Ubuntu." " Ubuntu is **very strict** about where binaries come from." " While sandbox is already off by default, do not set" " enable_sandbox to True OR you must install from Ubuntu's" " package manager.", UserWarning, ) parser = argparse.ArgumentParser( description="Will download Chrome for testing. All arguments optional.", parents=[logistro.parser], ) parser.add_argument( "--i", "-i", type=int, dest="i", help=( "Google offers thousands of chrome versions for download. " "'-i 0' is the oldest, '-i -1' is the newest: array syntax" ), ) parser.add_argument( "--arch", dest="arch", help="linux64|win32|win64|mac-x64|mac-arm64", ) parser.add_argument( "--path", dest="path", help="Where to store the download.", ) parser.add_argument( "-v", "--verbose", dest="verbose", action="store_true", help="Display found version number if using -i (to stdout)", ) parser.add_argument( "-f", "--force", dest="force", action="store_true", default=False, help="Force download even if already present.", ) parser.set_defaults(path=default_download_path) parser.set_defaults(arch=None) parser.set_defaults(verbose=False) parsed = parser.parse_args() i = parsed.i arch = parsed.arch path = Path(parsed.path) force = parsed.force verbose = parsed.verbose print(get_chrome_sync(arch=arch, i=i, path=path, verbose=verbose, force=force)) # noqa: T201 allow print in cli plotly-choreographer-4159eaf/src/choreographer/cli/_cli_utils_no_qa.py000066400000000000000000000120651525262030500263520ustar00rootroot00000000000000import argparse import asyncio import platform import subprocess import sys import time from pathlib import Path import logistro # diagnose function is too weird and ruff guts it # ruff has line-level and file-level QA suppression # so lets give diagnose a separate file # ruff: noqa: PLR0915, C901, BLE001, S607, PERF203, TRY002, T201, PLR0912, SLF001 # ruff: noqa: PLC0415 # ruff: noqa: F401, ERA001 # temporary, sync # ruff: noqa: PLC0415 - import at time of file # in order, exceptions are: # - function complexity (statements?) # - function complexity (algo measure) # - validate subprocess input arguments # - blind exception # - partial executable path (bash not /bin/bash) # - performance overhead of try-except in loop # - make own exceptions # - no print def diagnose() -> None: logistro.betterConfig(level=1) from choreographer import Browser, BrowserSync from choreographer.browsers.chromium import Chromium from choreographer.utils._which import browser_which parser = argparse.ArgumentParser( description="tool to help debug problems", parents=[logistro.parser], ) parser.add_argument("--no-run", dest="run", action="store_false") parser.add_argument("--show", dest="headless", action="store_false") parser.add_argument( "--verify_local", dest="verify_local", action="store_true", ) parser.set_defaults(run=True) parser.set_defaults(headless=True) parser.set_defaults(verify_local=False) args, _ = parser.parse_known_args() run = args.run headless = args.headless verify_local = args.verify_local fail = [] print("*".center(50, "*")) print("SYSTEM:".center(50, "*")) print(platform.system()) print(platform.release()) print(platform.version()) print(platform.uname()) print("*".center(50, "*")) print("BROWSER:".center(50, "*")) try: local_path = Chromium.find_browser(skip_local=False, verify_local=True) if not local_path or not Path(local_path).exists(): print(f"Local doesn't exist at {local_path}") else: print(f"Found local: {local_path}") except RuntimeError: print("Didn't find local.") if verify_local: raise browser_path = Chromium.find_browser(skip_local=True) print(browser_path) print("*".center(50, "*")) print("BROWSER_INIT_CHECK (DEPS)".center(50, "*")) if not browser_path: print("No browser, found can't check for deps.") else: b = Browser() b._browser_impl.pre_open() cli = b._browser_impl.get_cli() env = b._browser_impl.get_env() # noqa: F841 pargs = b._browser_impl.get_popen_args() b._browser_impl.clean() del b print("*** cli:") for arg in cli: print(" " * 8 + str(arg)) # potential security issue # print("*** env:") # for k, v in env.items(): # print(" " * 8 + f"{k}:{v}") print("*** Popen args:") for k, v in pargs.items(): print(" " * 8 + f"{k}:{v}") print("*".center(50, "*")) print("VERSION INFO:".center(50, "*")) try: print("pip:".center(25, "*")) print(subprocess.check_output([sys.executable, "-m", "pip", "freeze"]).decode()) except Exception as e: print(f"Error w/ pip: {e}") try: print("uv:".center(25, "*")) print(subprocess.check_output(["uv", "pip", "freeze"]).decode()) except Exception as e: print(f"Error w/ uv: {e}") try: print("git:".center(25, "*")) print( subprocess.check_output( ["git", "describe", "--tags", "--long", "--always"], ).decode(), ) except Exception as e: print(f"Error w/ git: {e}") finally: print(sys.version) print(sys.version_info) print("Done with version info.".center(50, "*")) if run: print("*".center(50, "*")) print("Actual Run Tests".center(50, "*")) async def test_headless() -> None: browser = await Browser(headless=headless) await asyncio.sleep(3) await browser.close() try: print("Async Test Headless".center(50, "*")) asyncio.run(test_headless()) except Exception as e: fail.append(("Async test headless", e)) finally: print("Done with async test headless".center(50, "*")) print() sys.stdout.flush() sys.stderr.flush() if fail: import traceback for exception in fail: try: print(f"Error in: {exception[0]}") traceback.print_exception( type(exception[1]), exception[1], exception[1].__traceback__, ) except Exception: print("Couldn't print traceback for:") print(str(exception)) raise Exception( "There was an exception during full async run, see above.", ) print("Thank you! Please share these results with us!") plotly-choreographer-4159eaf/src/choreographer/cli/defaults.py000066400000000000000000000005711525262030500246550ustar00rootroot00000000000000"""Defaults used when arguments not supplied.""" from pathlib import Path from platformdirs import PlatformDirs default_download_path = ( Path( PlatformDirs("choreographer", "plotly").user_data_dir, ) / "deps" ) """The path where we download chrome if no path argument is supplied.""" old_download_path = Path(__file__).resolve().parent / "browser_exe" plotly-choreographer-4159eaf/src/choreographer/errors.py000066400000000000000000000014751525262030500236170ustar00rootroot00000000000000"""A compilation of the errors available in choreographer.""" from ._brokers._async import UnhandledMessageWarning from .browsers import ( BrowserClosedError, BrowserDepsError, BrowserFailedError, ChromeNotFoundError, ) from .channels import BlockWarning, ChannelClosedError, MessageTooLargeError from .protocol import ( DevtoolsProtocolError, ExperimentalFeatureWarning, MessageTypeError, MissingKeyError, ) from .utils import TmpDirWarning __all__ = [ "BlockWarning", "BrowserClosedError", "BrowserDepsError", "BrowserFailedError", "ChannelClosedError", "ChromeNotFoundError", "DevtoolsProtocolError", "ExperimentalFeatureWarning", "MessageTooLargeError", "MessageTypeError", "MissingKeyError", "TmpDirWarning", "UnhandledMessageWarning", ] plotly-choreographer-4159eaf/src/choreographer/protocol/000077500000000000000000000000001525262030500235635ustar00rootroot00000000000000plotly-choreographer-4159eaf/src/choreographer/protocol/__init__.py000066400000000000000000000135041525262030500256770ustar00rootroot00000000000000""" Provides various implementations of Session and Target. It includes helpers and constants for the Chrome Devtools Protocol. """ from __future__ import annotations from collections.abc import MutableMapping from enum import Enum from typing import Any, NewType, Optional, cast BrowserResponse = NewType("BrowserResponse", MutableMapping[str, Any]) """The type for a response from the browser. Is really a `dict()`.""" BrowserCommand = NewType("BrowserCommand", MutableMapping[str, Any]) """The type for a command to the browser. Is really a `dict()`.""" MessageKey = NewType("MessageKey", tuple[str, Optional[int]]) """The type for id'ing a message/response. It is `tuple(session_id, message_id)`.""" class Ecode(Enum): """Ecodes are a list of possible error codes chrome returns.""" TARGET_NOT_FOUND = -32602 """Self explanatory.""" class DevtoolsProtocolError(Exception): """Raise a general error reported by the devtools protocol.""" def __init__(self, response: BrowserResponse) -> None: """ Construct a new DevtoolsProtocolError. Args: response: the json response that contains the error """ super().__init__(response) self.code = response["error"]["code"] self.message = response["error"]["message"] class MessageTypeError(TypeError): """An error for poorly formatted devtools protocol message.""" def __init__(self, key: str, value: Any, expected_type: type) -> None: """ Construct a message about a poorly formed protocol message. Args: key: the key that has the badly typed value value: the type of the value that is incorrect expected_type: the type that was expected """ value = type(value) if not isinstance(value, type) else value super().__init__( f"Message with key {key} must have type {expected_type}, not {value}.", ) class MissingKeyError(ValueError): """An error for poorly formatted devtools protocol message.""" def __init__(self, key: str, obj: BrowserCommand) -> None: """ Construct a MissingKeyError specifying which key was missing. Args: key: the missing key obj: the message without the key """ super().__init__( f"Message missing required key/s {key}. Message received: {obj}", ) class ExperimentalFeatureWarning(UserWarning): """An warning to report that a feature may or may not work.""" def verify_params(obj: BrowserCommand) -> None: """ Verify the message obj hast he proper keys and values. Args: obj: the object to check. Raises: MissingKeyError: if a key is missing. MessageTypeError: if a value type is incorrect. RuntimeError: if there are strange keys. """ n_keys = 0 required_keys = {"id": int, "method": str} for key, type_key in required_keys.items(): if key not in obj: raise MissingKeyError(key, obj) if not isinstance(obj[key], type_key): raise MessageTypeError(key, type(obj[key]), type_key) n_keys += 2 if "params" in obj: n_keys += 1 if "sessionId" in obj: n_keys += 1 if len(obj.keys()) != n_keys: raise RuntimeError( "Message objects must have id and method keys, " "and may have params and sessionId keys.", ) def calculate_message_key(msg: BrowserResponse | BrowserCommand) -> MessageKey | None: """ Given a message to/from the browser, calculate the key corresponding to the command. Every message is uniquely identified by its sessionId and id (counter). Args: msg: the message for which to calculate the key. """ session_id = msg.get("sessionId", "") message_id = msg.get("id") if message_id is None: return None return MessageKey((session_id, message_id)) def match_message_key(response: BrowserResponse, key: MessageKey) -> bool: """ Report True if a response matches with a certain key (sessionId, id). Args: response: the object response from the browser key: the (sessionId, id) key tubple we're looking for """ session_id, message_id = key if ("session_id" not in response and session_id == "") or ( # is browser session "session_id" in response and response["session_id"] == session_id # is session ): pass else: return False if "id" in response and str(response["id"]) == str(message_id): pass else: return False return True def is_event(response: BrowserResponse) -> bool: """Return true if the browser response is an event notification.""" required_keys = {"method", "params"} return required_keys <= response.keys() and "id" not in response def get_target_id_from_result(response: BrowserResponse) -> str | None: """ Extract target id from a browser response. Args: response: the browser response to extract the targetId from. """ if "result" in response and "targetId" in response["result"]: return cast("str", response["result"]["targetId"]) else: return None def get_session_id_from_result(response: BrowserResponse) -> str | None: """ Extract session id from a browser response. Args: response: the browser response to extract the sessionId from. """ if "result" in response and "sessionId" in response["result"]: return cast("str", response["result"]["sessionId"]) else: return None def get_error_from_result(response: BrowserResponse) -> str | None: """ Extract error from a browser response. Args: response: the browser response to extract the error from. """ if "error" in response: return cast("str", response["error"]) else: return None plotly-choreographer-4159eaf/src/choreographer/protocol/_chunking.py000066400000000000000000000207411525262030500261060ustar00rootroot00000000000000""" Break up `Runtime.callFunctionOn` commands that are too big for the pipe. Chrome reads one complete JSON message at a time off the devtools pipe, and it won't read one bigger than 100MB (see `channels.pipe.MAX_MESSAGE_SIZE`). Write more than that and Chrome closes the connection rather than reading it. The devtools protocol has no way to split a message, so in general an oversized command is simply an error. `Runtime.callFunctionOn` is the exception. We can stash pieces of the message in a JavaScript array on the page, then run a function that glues them back together. That's what this module does. None of this is part of the public API. `Session.send_command` falls back to it on its own, and the function you asked Chrome to run can't tell the difference: it still receives the same parsed arguments it always would. """ from __future__ import annotations import itertools import math from typing import TYPE_CHECKING import logistro from . import DevtoolsProtocolError if TYPE_CHECKING: from collections.abc import MutableMapping from typing import Any from . import BrowserCommand, BrowserResponse from .devtools_async import Session _logger = logistro.getLogger(__name__) CHUNK_SIZE = 10 * 1024 * 1024 """ How much of the message to put in each piece, in characters. Deliberately well under `MAX_MESSAGE_SIZE` to avoid issues with high byte count UTF-8 characters and escaped characters. """ _CHUNKABLE_METHOD = "Runtime.callFunctionOn" """The one command we know how to break up.""" _HELPER_METHOD = "Runtime.callFunctionOn" """ How we send our own setup, push, and cleanup calls. This is always `Runtime.callFunctionOn`, whatever the oversized command we were handed happens to be. It only matches `_CHUNKABLE_METHOD` today because that is the one method we can break up. """ _STORE = "window.__choreo_chunks" # `arguments` entries can also be `objectId` or `unserializableValue` # handles, which refer to things that only exist in the browser. Those can't # be rebuilt from the text of the message, so we only take the plain ones. _VALUE_KEY = "value" _INIT_FN = f"function(k){{ ({_STORE} = {_STORE} || {{}})[k] = []; }}" _PUSH_FN = f"function(k, c){{ {_STORE}[k].push(c); }}" _DELETE_FN = f"function(k){{ if ({_STORE}) {{ delete {_STORE}[k]; }} }}" _counter = itertools.count() def is_chunkable(command: BrowserCommand) -> bool: """ Report whether we know how to break this command up. Args: command: The command that was too big to send """ method = command.get("method") if method != _CHUNKABLE_METHOD: _logger.debug(f"Can't chunk {method}: only {_CHUNKABLE_METHOD} can be.") return False params = command.get("params") or {} if params.get("throwOnSideEffect"): _logger.debug( "Can't chunk a command with throwOnSideEffect: breaking it up means " "writing to the page, which is what the flag forbids.", ) return False if not params.get("functionDeclaration"): _logger.debug("Can't chunk: no functionDeclaration to wrap.") return False arguments = params.get("arguments") or [] # If even one argument is a browser-side handle we can't rebuild the call # from the message text, so we don't try if not arguments or not all( isinstance(argument, dict) and _VALUE_KEY in argument for argument in arguments ): _logger.debug( "Can't chunk: every argument has to be a plain " f"{_VALUE_KEY!r}, and at least one has to exist.", ) return False return True def _build_wrapper(user_fn: str) -> str: """ Wrap the caller's function in one that rebuilds its arguments first. What we glue back together is the whole original command, not just the big argument, because that is exactly what we already had serialized. The extra envelope is a few hundred bytes on top of a very large message, and pulling the arguments out of it in JavaScript is cheap. """ return ( "function(k){" "try{" f"var cmd = JSON.parse({_STORE}[k].join(''));" "var args = cmd.params.arguments.map(function(a){ return a.value; });" # Add newline to make sure a trailing comment doesn't swallow `apply`` f"return ({user_fn}\n).apply(this, args);" "}finally{" f"delete {_STORE}[k];" "}" "}" ) def _raise_for_error(response: BrowserResponse) -> BrowserResponse: if "error" in response: raise DevtoolsProtocolError(response) return response def _js_error_text(details: MutableMapping[str, Any]) -> str: """Pull the readable part out of a `Runtime.exceptionDetails`.""" # `text` is usually just "Uncaught", so check exception exception = details.get("exception") or {} return exception.get("description") or details.get("text") or "unknown error" async def _send( session: Session, params: MutableMapping[str, Any], ) -> BrowserResponse: """Send one piece, going around the too-big fallback so we can't recurse.""" response = _raise_for_error( await session._send_no_retry(_HELPER_METHOD, params), # noqa: SLF001 ) # Check for JS error response since that won't throw a top-level error exception_details = response.get("result", {}).get("exceptionDetails") if exception_details: raise RuntimeError( f"Chunked send failed in the page: {_js_error_text(exception_details)}", ) return response async def send_chunked( session: Session, command: BrowserCommand, payload: str, ) -> tuple[BrowserResponse, BrowserCommand]: """ Send an oversized `Runtime.callFunctionOn` in pieces. Args: session: The session the original command was sent on. command: The original command, too big to send in one go. payload: The already serialized command, from the error the pipe raised. We slice this rather than serializing all over again. Returns: The response to the final call, and the command that produced it (the caller needs it to look up timings). """ params: MutableMapping[str, Any] = dict(command["params"]) # Grab all potential identifiers for the execution context execution_context_params = { key: params[key] for key in ("executionContextId", "objectId", "uniqueContextId") if key in params } # Two calls in one page must not share a store, or they'd eat each other. key = f"{session.session_id or 'browser'}:{next(_counter)}" n_chunks = math.ceil(len(payload) / CHUNK_SIZE) _logger.info( f"Message too big for one write, sending it as {n_chunks} pieces " f"under key {key}.", ) try: await _send( session, { **execution_context_params, "functionDeclaration": _INIT_FN, "arguments": [{"value": key}], }, ) for start in range(0, len(payload), CHUNK_SIZE): await _send( session, { **execution_context_params, "functionDeclaration": _PUSH_FN, "arguments": [ {"value": key}, {"value": payload[start : start + CHUNK_SIZE]}, ], }, ) params["functionDeclaration"] = _build_wrapper( command["params"]["functionDeclaration"], ) params["arguments"] = [{"value": key}] # Everything else the caller asked for (awaitPromise, returnByValue, # silent, userGesture, the execution context) rides along untouched. final_command = session._build_command( # noqa: SLF001 _HELPER_METHOD, params, ) response = await session._send_built(final_command) # noqa: SLF001 except Exception: await _cleanup(session, execution_context_params, key) raise return response, final_command async def _cleanup( session: Session, execution_context_params: MutableMapping[str, Any], key: str, ) -> None: """Drop the store if we bailed out before the wrapper could.""" try: await _send( session, { **execution_context_params, "functionDeclaration": _DELETE_FN, "arguments": [{"value": key}], }, ) except Exception: # noqa: BLE001 we're already failing, don't make it worse _logger.debug(f"Couldn't clean up chunk store {key}.") plotly-choreographer-4159eaf/src/choreographer/protocol/devtools_async.py000066400000000000000000000323161525262030500271760ustar00rootroot00000000000000"""Provide a lower-level async interface to the Devtools Protocol.""" from __future__ import annotations import inspect from typing import TYPE_CHECKING, overload import logistro from choreographer import protocol from choreographer.channels import MessageTooLargeError from . import _chunking if TYPE_CHECKING: import asyncio from collections.abc import Coroutine, MutableMapping from typing import Any, Callable, Literal from choreographer._brokers import Broker _logger = logistro.getLogger(__name__) class Session: """A session is a single conversation with a single target.""" session_id: str """The id of the session given by the browser.""" message_id: int """All messages are counted per session and this is the current message id.""" subscriptions: MutableMapping[ str, tuple[ Callable[[protocol.BrowserResponse], Coroutine[Any, Any, Any]], bool, ], ] def __init__(self, session_id: str, broker: Broker) -> None: """ Construct a session from the browser as an object. A session is like an open conversation with a target. All commands are sent on sessions. Args: broker: a reference to the browser's broker session_id: the id given by the browser """ if not isinstance(session_id, str): raise TypeError("session_id must be a string") # Resources self._broker = broker # State self.session_id = session_id _logger.debug(f"New session: {session_id}") self.message_id = 0 self.subscriptions = {} @overload async def send_command( self, command: str, params: MutableMapping[str, Any] | None = None, *, with_perf: Literal[False] = False, ) -> protocol.BrowserResponse: ... @overload async def send_command( self, command: str, params: MutableMapping[str, Any] | None = None, *, with_perf: Literal[True], ) -> tuple[protocol.BrowserResponse, tuple[float, float, float]]: ... async def send_command( self, command: str, params: MutableMapping[str, Any] | None = None, *, with_perf: bool = False, ) -> ( tuple[protocol.BrowserResponse, tuple[float, float, float]] | protocol.BrowserResponse ): """ Send a devtools command on the session. https://chromedevtools.github.io/devtools-protocol/ Args: command: devtools command to send params: the parameters to send with_perf (bool): Return the optional tuple. Returns: A message key (session, message id) tuple or None (Optional) A tuple[float, float, float] representing perf_counters() for write start, end, and read end. On a chunked command, this covers only the final message, not the pieces that carried the payload, so it reads as far quicker than the call really was. Raises: MessageTooLargeError: If the message is too big for Chrome's buffer and isn't a `Runtime.callFunctionOn` we can break up. """ json_command = self._build_command(command, params) _logger.debug( f"Cmd '{command}', param keys '{params.keys() if params else ''}', " f"sessionId '{self.session_id}'", ) _logger.debug2(f"Full params: {str(params).replace('%', '%%')}") try: response = await self._send_built(json_command) except MessageTooLargeError as e: if e.payload is None or not _chunking.is_chunkable(json_command): raise # Break the command up into chunks so they can be sent piece by piece # and reassmbled without hitting the CDP limit response, json_command = await _chunking.send_chunked( self, json_command, e.payload, ) if with_perf: return (response, self._broker.get_perf(json_command)) return response def _build_command( self, command: str, params: MutableMapping[str, Any] | None = None, ) -> protocol.BrowserCommand: """Give a command an id and wrap it up for the browser.""" current_id = self.message_id self.message_id += 1 json_command = protocol.BrowserCommand( { "id": current_id, "method": command, }, ) if self.session_id: json_command["sessionId"] = self.session_id if params: json_command["params"] = params return json_command async def _send_built( self, json_command: protocol.BrowserCommand, ) -> protocol.BrowserResponse: """Write a command with no too-big fallback, so we can't recurse.""" return await self._broker.write_json(json_command) async def _send_no_retry( self, command: str, params: MutableMapping[str, Any] | None = None, ) -> protocol.BrowserResponse: """Build and write a command with no too-big fallback.""" return await self._send_built(self._build_command(command, params)) def subscribe( self, string: str, callback: Callable[[protocol.BrowserResponse], Coroutine[Any, Any, Any]], *, repeating: bool = True, ) -> None: """ Subscribe to an event on this session. Args: string: the name of the event. Can use * wildcard at the end. callback: the callback (which takes a message dict and returns nothing) repeating: default True, should the callback execute more than once """ if not inspect.iscoroutinefunction(callback): raise TypeError( "Call back must be be `async def` type function.", ) if string in self.subscriptions: raise ValueError( "You are already subscribed to this string, " "duplicate subscriptions are not allowed.", ) else: # so this should be per session # and that means we need a list of all sessions self.subscriptions[string] = (callback, repeating) def unsubscribe(self, string: str) -> None: """ Remove a subscription. Args: string: the subscription to remove. """ if string not in self.subscriptions: return del self.subscriptions[string] def subscribe_once(self, string: str) -> asyncio.Future[Any]: """ Return a future for a browser event. Generally python asyncio doesn't recommend futures. But in this case, one must call subscribe_once and await it later, generally because they must subscribe and then provoke the event. Args: string: the event to subscribe to Returns: A future to be awaited later, the complete event. """ return self._broker.new_subscription_future(self.session_id, string) class Target: """A target like a browser, tab, or others. It sends commands. It has sessions.""" target_id: str """The browser's ID of the target.""" sessions: MutableMapping[str, Session] """A list of all the sessions for this target.""" def __init__(self, target_id: str, broker: Broker): """ Create a target after one ahs been created by the browser. Args: broker: a reference to the browser's broker target_id: the id given by the browser """ if not isinstance(target_id, str): raise TypeError("target_id must be string") # Resources self._broker = broker # States self.sessions = {} self.target_id = target_id _logger.debug(f"Created new target {target_id}.") def _add_session(self, session: Session) -> None: if not isinstance(session, Session): raise TypeError("session must be a session type class") self.sessions[session.session_id] = session def _remove_session(self, session_id: str) -> None: if isinstance(session_id, Session): session_id = session_id.session_id _ = self.sessions.pop(session_id, None) def get_session(self) -> Session: """Retrieve the first session of the target, if it exists.""" if not self.sessions.values(): raise RuntimeError( "Cannot use this method without at least one valid session", ) session = next(iter(self.sessions.values())) return session @overload async def send_command( self, command: str, params: MutableMapping[str, Any] | None = None, *, with_perf: Literal[False] = False, ) -> protocol.BrowserResponse: ... @overload async def send_command( self, command: str, params: MutableMapping[str, Any] | None = None, *, with_perf: Literal[True], ) -> tuple[protocol.BrowserResponse, tuple[float, float, float]]: ... async def send_command( self, command: str, params: MutableMapping[str, Any] | None = None, *, with_perf: bool = False, ) -> ( protocol.BrowserResponse | tuple[protocol.BrowserResponse, tuple[float, float, float]] ): """ Send a command to the first session in a target. https://chromedevtools.github.io/devtools-protocol/ Args: command: devtools command to send params: the parameters to send with_perf (bool): Also return perf tuple """ if not self.sessions.values(): raise RuntimeError("Cannot send_command without at least one valid session") session = self.get_session() # so mypy can't handle bool = Literal[True, False] # so this is suboptimal but it quiets typer if with_perf: return await session.send_command(command, params, with_perf=True) else: return await session.send_command(command, params, with_perf=False) async def create_session(self) -> Session: """Create a new session on this target.""" response = await self._broker._browser.send_command( # noqa: SLF001 yeah we need the browser :-( "Target.attachToTarget", params={"targetId": self.target_id, "flatten": True}, ) if "error" in response: raise RuntimeError( "Could not create session", ) from protocol.DevtoolsProtocolError( response, ) session_id = response["result"]["sessionId"] new_session = Session(session_id, self._broker) self._add_session(new_session) return new_session # async only async def close_session( self, session_id: str, ) -> protocol.BrowserResponse: """ Close a session by session_id. Args: session_id: the session to close """ if isinstance(session_id, Session): session_id = session_id.session_id response = await self._broker._browser.send_command( # noqa: SLF001 we need browser command="Target.detachFromTarget", params={"sessionId": session_id}, ) self._remove_session(session_id) if "error" in response: raise RuntimeError( "Could not close session", ) from protocol.DevtoolsProtocolError( response, ) _logger.debug(f"The session {session_id} has been closed.") return response # kinda hate, why do we need this again? def subscribe( self, string: str, callback: Callable[[protocol.BrowserResponse], Coroutine[Any, Any, Any]], *, repeating: bool = True, ) -> None: """ Subscribe to an event on the main session of this target. Args: string: the name of the event. Can use * wildcard at the end. callback: the callback (which takes a message dict and returns nothing) repeating: default True, should the callback execute more than once """ session = self.get_session() session.subscribe(string, callback, repeating=repeating) def unsubscribe(self, string: str) -> None: """ Remove a subscription. Args: string: the subscription to remove. """ session = self.get_session() session.unsubscribe(string) def subscribe_once(self, string: str) -> asyncio.Future[Any]: """ Return a future for a browser event for the first session of this target. Generally python asyncio doesn't recommend futures. But in this case, one must call subscribe_once and await it later, generally because they must subscribe and then provoke the event. Args: string: the event to subscribe to Returns: A future to be awaited later, the complete event. """ session = self.get_session() return session.subscribe_once(string) plotly-choreographer-4159eaf/src/choreographer/protocol/devtools_async_helpers.py000066400000000000000000000147621525262030500307250ustar00rootroot00000000000000"""Async helper functions for common Chrome DevTools Protocol patterns.""" from __future__ import annotations import asyncio from typing import TYPE_CHECKING import logistro if TYPE_CHECKING: from choreographer import Browser, Tab from choreographer.protocol.devtools_async import Session from . import BrowserResponse _logger = logistro.getLogger(__name__) # Abit about the mechanics of chrome: # Whether or not a Page.loadEventFired event fires is a bit # racey. Optimistically, it's buffered and fired after subscription # even if the event happened in the past. # Doesn't seem to always work out that way, so we also use # JavaScript to create a "loaded" event, but for the case # where we need to timeout- loading a page that never resolves, # the browser might actually load an about:blank instead and then # fire the event, misleading the user, so we check the url. async def _check_document_ready(session: Session, url: str) -> BrowserResponse: return await session.send_command( "Runtime.evaluate", params={ "expression": """ new Promise((resolve) => { if ( (document.readyState === 'complete') && (window.location==`""" # CONCATENATE! f"{url!s}" """`) ){ resolve("Was complete"); } else { window.addEventListener( 'load', () => resolve("Event loaded") ); } }) """, "awaitPromise": True, "returnByValue": True, }, ) async def create_and_wait( browser: Browser, url: str = "", *, timeout: float = 30.0, ) -> Tab: """ Create a new tab and wait for it to load. Args: browser: Browser instance url: URL to navigate to (default: blank page) timeout: Seconds to wait for page load (default: 30.0) Returns: The created Tab """ _logger.debug("Creating tab") tab = await browser.create_tab(url) _logger.debug("Creating session") temp_session = await tab.create_session() try: _logger.debug("Subscribing to loadEven and enabling events.") load_future = temp_session.subscribe_once("Page.loadEventFired") await temp_session.send_command("Page.enable") await temp_session.send_command("Runtime.enable") if url: try: # JavaScript evaluation to check if document is loaded js_ready_future = asyncio.create_task( _check_document_ready(temp_session, url), ) _logger.debug(f"Starting wait: timeout={timeout}") # Race between the two methods: first one to complete wins done, pending = await asyncio.wait( [ load_future, js_ready_future, ], return_when=asyncio.FIRST_COMPLETED, timeout=timeout, ) _logger.debug(f"Finish wait, is done? {bool(done)}") for task in pending: _logger.debug(f"Cancelling: {task}") task.cancel() if not done: _logger.debug("Timeout waiting for js or event") raise asyncio.TimeoutError( # noqa: TRY301 "Page load timeout", ) else: _logger.debug(f"Task which finished: {done}") except ( asyncio.TimeoutError, asyncio.CancelledError, TimeoutError, ) as e: # Stop the page load when timeout occurs _logger.debug("Need to stop page loading, error.", exc_info=e) await temp_session.send_command("Page.stopLoading") raise finally: _logger.debug("Closing session") await tab.close_session(temp_session.session_id) _logger.debug("Returning tab.") return tab async def navigate_and_wait( tab: Tab, url: str, *, timeout: float = 30.0, ) -> Tab: """ Navigate an existing tab to a URL and wait for it to load. Args: tab: Tab to navigate url: URL to navigate to timeout: Seconds to wait for page load (default: 30.0) Returns: The Tab after navigation completes """ temp_session = await tab.create_session() try: # Subscribe BEFORE enabling domains to avoid race condition load_future = temp_session.subscribe_once("Page.loadEventFired") await temp_session.send_command("Page.enable") await temp_session.send_command("Runtime.enable") try: async def _freezers() -> None: # If no resolve, will freeze await temp_session.send_command("Page.navigate", params={"url": url}) # Can freeze if resolve bad await load_future await asyncio.wait_for(_freezers(), timeout=timeout) except (asyncio.TimeoutError, asyncio.CancelledError, TimeoutError): # Stop the navigation when timeout occurs await temp_session.send_command("Page.stopLoading") raise finally: await tab.close_session(temp_session.session_id) return tab async def execute_js_and_wait( tab: Tab, expression: str, *, timeout: float = 30.0, ) -> BrowserResponse: """ Execute JavaScript in a tab and return the result. Args: tab: Tab to execute JavaScript in expression: JavaScript expression to evaluate timeout: Seconds to wait for execution (default: 30.0) Returns: Response dict from Runtime.evaluate with 'result' and optional 'exceptionDetails' """ temp_session = await tab.create_session() try: await temp_session.send_command("Page.enable") await temp_session.send_command("Runtime.enable") response = await asyncio.wait_for( temp_session.send_command( "Runtime.evaluate", params={ "expression": expression, "awaitPromise": True, "returnByValue": True, }, ), timeout=timeout, ) return response finally: await tab.close_session(temp_session.session_id) plotly-choreographer-4159eaf/src/choreographer/protocol/devtools_sync.py000066400000000000000000000111311525262030500270250ustar00rootroot00000000000000"""Provide a lower-level sync interface to the Devtools Protocol.""" from __future__ import annotations from typing import TYPE_CHECKING import logistro from choreographer import protocol if TYPE_CHECKING: from collections.abc import MutableMapping from typing import Any from choreographer._brokers import BrokerSync _logger = logistro.getLogger(__name__) class SessionSync: """A session is a single conversation with a single target.""" session_id: str """The id of the session given by the browser.""" message_id: int """All messages are counted per session and this is the current message id.""" def __init__(self, session_id: str, broker: BrokerSync) -> None: """ Construct a session from the browser as an object. A session is like an open conversation with a target. All commands are sent on sessions. Args: broker: a reference to the browser's broker session_id: the id given by the browser """ if not isinstance(session_id, str): raise TypeError("session_id must be a string") # Resources self._broker = broker # State self.session_id = session_id _logger.debug(f"New session: {session_id}.") self.message_id = 0 def send_command( self, command: str, params: MutableMapping[str, Any] | None = None, ) -> Any: """ Send a devtools command on the session. https://chromedevtools.github.io/devtools-protocol/ Args: command: devtools command to send params: the parameters to send Returns: A message key (session, message id) tuple or None Raises: MessageTooLargeError: If the message is too big for Chrome's buffer. Use the async `Session` if you need to send something that big. """ current_id = self.message_id self.message_id += 1 json_command = protocol.BrowserCommand( { "id": current_id, "method": command, }, ) if self.session_id: json_command["sessionId"] = self.session_id if params: json_command["params"] = params _logger.debug( f"Sending {command} with {params} on session {self.session_id}", ) return self._broker.write_json(json_command) class TargetSync: """A target like a browser, tab, or others. It sends commands. It has sessions.""" target_id: str """The browser's ID of the target.""" sessions: MutableMapping[str, SessionSync] """A list of all the sessions for this target.""" def __init__(self, target_id: str, broker: BrokerSync): """ Create a target after one ahs been created by the browser. Args: broker: a reference to the browser's broker target_id: the id given by the browser """ if not isinstance(target_id, str): raise TypeError("target_id must be string") # Resources self._broker = broker # States self.sessions = {} self.target_id = target_id _logger.debug(f"Created new target {target_id}.") def _add_session(self, session: SessionSync) -> None: if not isinstance(session, SessionSync): raise TypeError("session must be a session type class") self.sessions[session.session_id] = session def _remove_session(self, session_id: str) -> None: if isinstance(session_id, SessionSync): session_id = session_id.session_id _ = self.sessions.pop(session_id, None) def get_session(self) -> SessionSync: """Retrieve the first session of the target, if it exists.""" if not self.sessions.values(): raise RuntimeError( "Cannot use this method without at least one valid session", ) session = next(iter(self.sessions.values())) return session def send_command( self, command: str, params: MutableMapping[str, Any] | None = None, ) -> Any: """ Send a command to the first session in a target. https://chromedevtools.github.io/devtools-protocol/ Args: command: devtools command to send params: the parameters to send """ if not self.sessions.values(): raise RuntimeError("Cannot send_command without at least one valid session") session = self.get_session() return session.send_command(command, params) plotly-choreographer-4159eaf/src/choreographer/py.typed000066400000000000000000000000001525262030500234070ustar00rootroot00000000000000plotly-choreographer-4159eaf/src/choreographer/pyrightconfig.json000066400000000000000000000000441525262030500254670ustar00rootroot00000000000000{ "typeCheckingMode": "strict", } plotly-choreographer-4159eaf/src/choreographer/resources/000077500000000000000000000000001525262030500237345ustar00rootroot00000000000000plotly-choreographer-4159eaf/src/choreographer/resources/__init__.py000066400000000000000000000000321525262030500260400ustar00rootroot00000000000000"""A data folder only.""" plotly-choreographer-4159eaf/src/choreographer/resources/last_known_good_chrome.json000066400000000000000000000042561525262030500313620ustar00rootroot00000000000000{"version": "142.0.7444.175", "revision": "1522585", "downloads": {"chrome": [{"platform": "linux64", "url": "https://storage.googleapis.com/chrome-for-testing-public/142.0.7444.175/linux64/chrome-linux64.zip"}, {"platform": "mac-arm64", "url": "https://storage.googleapis.com/chrome-for-testing-public/142.0.7444.175/mac-arm64/chrome-mac-arm64.zip"}, {"platform": "mac-x64", "url": "https://storage.googleapis.com/chrome-for-testing-public/142.0.7444.175/mac-x64/chrome-mac-x64.zip"}, {"platform": "win32", "url": "https://storage.googleapis.com/chrome-for-testing-public/142.0.7444.175/win32/chrome-win32.zip"}, {"platform": "win64", "url": "https://storage.googleapis.com/chrome-for-testing-public/142.0.7444.175/win64/chrome-win64.zip"}], "chromedriver": [{"platform": "linux64", "url": "https://storage.googleapis.com/chrome-for-testing-public/142.0.7444.175/linux64/chromedriver-linux64.zip"}, {"platform": "mac-arm64", "url": "https://storage.googleapis.com/chrome-for-testing-public/142.0.7444.175/mac-arm64/chromedriver-mac-arm64.zip"}, {"platform": "mac-x64", "url": "https://storage.googleapis.com/chrome-for-testing-public/142.0.7444.175/mac-x64/chromedriver-mac-x64.zip"}, {"platform": "win32", "url": "https://storage.googleapis.com/chrome-for-testing-public/142.0.7444.175/win32/chromedriver-win32.zip"}, {"platform": "win64", "url": "https://storage.googleapis.com/chrome-for-testing-public/142.0.7444.175/win64/chromedriver-win64.zip"}], "chrome-headless-shell": [{"platform": "linux64", "url": "https://storage.googleapis.com/chrome-for-testing-public/142.0.7444.175/linux64/chrome-headless-shell-linux64.zip"}, {"platform": "mac-arm64", "url": "https://storage.googleapis.com/chrome-for-testing-public/142.0.7444.175/mac-arm64/chrome-headless-shell-mac-arm64.zip"}, {"platform": "mac-x64", "url": "https://storage.googleapis.com/chrome-for-testing-public/142.0.7444.175/mac-x64/chrome-headless-shell-mac-x64.zip"}, {"platform": "win32", "url": "https://storage.googleapis.com/chrome-for-testing-public/142.0.7444.175/win32/chrome-headless-shell-win32.zip"}, {"platform": "win64", "url": "https://storage.googleapis.com/chrome-for-testing-public/142.0.7444.175/win64/chrome-headless-shell-win64.zip"}]}} plotly-choreographer-4159eaf/src/choreographer/utils/000077500000000000000000000000001525262030500230625ustar00rootroot00000000000000plotly-choreographer-4159eaf/src/choreographer/utils/README.txt000066400000000000000000000003101525262030500245520ustar00rootroot00000000000000sys_utils provide: a `which_browser()` function for finding the browser a more robust `TmpDirectory` class for creating and managing those a `kill()` function to be used when destroying processes. plotly-choreographer-4159eaf/src/choreographer/utils/__init__.py000066400000000000000000000003571525262030500252000ustar00rootroot00000000000000"""Contains functions and class that primarily help us with the OS.""" from ._tmpfile import TmpDirectory, TmpDirWarning from ._which import get_browser_path __all__ = [ "TmpDirWarning", "TmpDirectory", "get_browser_path", ] plotly-choreographer-4159eaf/src/choreographer/utils/_kill.py000066400000000000000000000022701525262030500245270ustar00rootroot00000000000000from __future__ import annotations import os import platform import subprocess import logistro if (_system := platform.system()) != "Windows": import signal _logger = logistro.getLogger(__name__) def kill(process: subprocess.Popen[bytes] | subprocess.Popen[str]) -> None: if _system == "Windows": subprocess.call( # noqa: S603, false positive, input fine ["taskkill", "/F", "/T", "/PID", str(process.pid)], # noqa: S607 windows full path... stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL, timeout=6, ) return try: os.killpg( process.pid, signal.SIGTERM, # type: ignore[reportPossiblyUnboundVariable] ) except ProcessLookupError: process.terminate() _logger.debug("Called terminate (a light kill).") try: process.wait(timeout=6) except subprocess.TimeoutExpired: _logger.debug("Calling kill (a heavy kill).") try: os.killpg( process.pid, signal.SIGKILL, # type: ignore[reportPossiblyUnboundVariable] ) except ProcessLookupError: process.kill() plotly-choreographer-4159eaf/src/choreographer/utils/_manual_thread_pool.py000066400000000000000000000057461525262030500274440ustar00rootroot00000000000000from __future__ import annotations import queue import threading from concurrent.futures import Executor, Future from typing import TYPE_CHECKING import logistro if TYPE_CHECKING: from typing import Any, Callable, TypeVar try: from typing import ParamSpec except ImportError: from typing_extensions import ParamSpec _P = ParamSpec("_P") # Runtime special generic that gives you access to fn sig _T = TypeVar("_T") _logger = logistro.getLogger(__name__) class ExecutorClosedError(RuntimeError): """Raise if submitting when executor is closed.""" class ManualThreadExecutor(Executor): def __init__( self, *, max_workers: int = 2, daemon: bool = True, name: str = "manual-exec", ) -> None: self._q: queue.Queue[ tuple[ # could be typed more specifically if singleton @ submit() Callable[..., Any], Any, Any, Future[Any], ] | None ] = queue.Queue() self._stop = False self._threads = [] self.name = name for i in range(max_workers): t = threading.Thread( target=self._worker, name=f"{name}-{i}", daemon=daemon, ) t.start() self._threads.append(t) def _worker(self) -> None: while True: item = self._q.get() if item is None: # sentinel return fn, args, kwargs, fut = item if fut.set_running_or_notify_cancel(): try: res = fn(*args, **kwargs) except BaseException as e: # noqa: BLE001 yes we catch and set fut.set_exception(e) else: fut.set_result(res) self._q.task_done() # _T is generic so we can mar def submit( self, fn: Callable[_P, _T], /, *args: _P.args, **kwargs: _P.kwargs, ) -> Future[_T]: fut: Future[_T] = Future() if self._stop: fut.set_exception(ExecutorClosedError("Cannot submit tasks.")) return fut self._q.put((fn, args, kwargs, fut)) return fut def shutdown( self, wait: bool = True, # noqa: FBT001, FBT002 overriding, can't change args *, cancel_futures: bool = False, ) -> None: self._stop = True if cancel_futures: # Drain queue and cancel pending try: while True: full = self._q.get_nowait() if full is not None: _, _, _, fut = full fut.cancel() self._q.task_done() except queue.Empty: pass for _ in self._threads: self._q.put(None) if wait: for t in self._threads: t.join(timeout=2.0) plotly-choreographer-4159eaf/src/choreographer/utils/_tmpfile.py000066400000000000000000000162451525262030500252430ustar00rootroot00000000000000from __future__ import annotations import os import platform import shutil import stat import sys import tempfile import time import warnings from pathlib import Path from typing import TYPE_CHECKING import logistro if TYPE_CHECKING: from collections.abc import MutableMapping, Sequence from typing import Any, Callable _logger = logistro.getLogger(__name__) class TmpDirWarning(UserWarning): """A warning if for whatever reason we can't eliminate the tmp dir.""" class TmpDirectory: """ The python stdlib `TemporaryDirectory` wrapper for easier use. Python's `TemporaryDirectory` suffered a couple API changes that mean you can't call it the same way for similar versions. This wrapper is also much more aggressive about deleting the directory when it's done, not necessarily relying on OS functions. """ temp_dir: tempfile.TemporaryDirectory[str] """A reference to the underlying python `TemporaryDirectory` implementation.""" path: Path """The path to the temporary directory.""" exists: bool """A flag to indicate if the directory still exists.""" def __init__(self, path: str | None = None, *, sneak: bool = False): """ Construct a wrapped `TemporaryDirectory`. Args: path: manually specify the directory to use sneak: (default False) avoid using /tmp Ubuntu's snap will sandbox /tmp """ self._with_onexc = bool(sys.version_info[:3] >= (3, 12)) args: MutableMapping[str, Any] = {} if path: args = {"dir": path} elif sneak: args = {"prefix": ".choreographer-", "dir": Path.home()} if platform.system() != "Windows": self.temp_dir = tempfile.TemporaryDirectory(**args) else: # is windows vinfo = sys.version_info[:3] if vinfo >= (3, 12): self.temp_dir = tempfile.TemporaryDirectory( # type: ignore [call-overload, unused-ignore] delete=False, ignore_cleanup_errors=True, **args, ) elif vinfo >= (3, 10): self.temp_dir = tempfile.TemporaryDirectory( # type: ignore [call-overload, unused-ignore] ignore_cleanup_errors=True, **args, ) else: self.temp_dir = tempfile.TemporaryDirectory(**args) self.path = Path(self.temp_dir.name) _logger.info(f"Temp directory created: {self.path}.") self.exists = True def _delete_manually( # noqa: C901, PLR0912 self, *, check_only: bool = False, quiet: bool = False, ) -> tuple[ int, int, Sequence[tuple[Path, BaseException]], ]: if not self.path.exists(): self.exists = False return 0, 0, [] n_dirs = 0 n_files = 0 errors = [] for root, dirs, files in os.walk(self.path, topdown=False): n_dirs += len(dirs) n_files += len(files) if not check_only: for f in files: fp = Path(root) / f _logger.debug2(f"Have file {fp}") try: fp.chmod(stat.S_IWUSR) fp.unlink(missing_ok=True) _logger.debug2("Deleted") except Exception as e: # noqa: BLE001 yes catch and report errors.append((fp, e)) for d in dirs: fp = Path(root) / d _logger.debug2(f"Have directory {fp}") try: fp.chmod(stat.S_IWUSR) fp.rmdir() _logger.debug2("Deleted") except Exception as e: # noqa: BLE001 yes catch and report errors.append((fp, e)) # clean up directory if not check_only: try: self.path.chmod(stat.S_IWUSR) self.path.rmdir() except Exception as e: # noqa: BLE001 yes catch and report errors.append((self.path, e)) if check_only: if n_dirs or n_files: self.exists = True else: self.exists = False elif errors: if not quiet: warnings.warn( # noqa: B028 "The temporary directory could not be deleted, " f"execution will continue. errors: {errors}", TmpDirWarning, ) self.exists = True else: self.exists = False return n_dirs, n_files, errors def clean(self) -> None: # noqa: C901 """Try several different ways to eliminate the temporary directory.""" try: # no faith in this python implementation, always fails with windows # very unstable recently as well, lots new arguments in tempfile package if hasattr(self, "temp_dir") and self.temp_dir: self.temp_dir.cleanup() self.exists = False _logger.info("TemporaryDirectory.cleanup() worked.") except Exception as e: # noqa: BLE001 we try many ways to clean, this is the first one _logger.info(f"TemporaryDirectory.cleanup() failed. Error {e}") # bad typing but tough def remove_readonly( func: Callable[[str], None], path: str | Path, _excinfo: Any, ) -> None: try: Path(path).chmod(stat.S_IWUSR) func(str(path)) except FileNotFoundError: pass try: if self._with_onexc: shutil.rmtree(self.path, onexc=remove_readonly) # type: ignore [call-arg, unused-ignore] else: shutil.rmtree(self.path, onerror=remove_readonly) self.exists = False if hasattr(self, "temp_dir"): del self.temp_dir _logger.info("shutil.rmtree worked.") except FileNotFoundError: self.exists = False if hasattr(self, "temp_dir"): del self.temp_dir _logger.info("shutil.rmtree worked.") except Exception as e: _logger.debug("Error during tmp file removal.", exc_info=e) self._delete_manually(check_only=True) if not self.exists: return _logger.info(f"shutil.rmtree() failed to delete temporary file. Error {e}") def extra_clean() -> None: i = 0 tries = 4 while self.path.exists() and i < tries: _logger.info(f"Extra manual clean executing {i}.") self._delete_manually(quiet=True) i += 1 time.sleep(2) if self.path.exists(): self._delete_manually(quiet=False) # testing doesn't look threads so I guess we'll block extra_clean() if self.path.exists(): _logger.warning("Temporary dictory couldn't be removed manually.") plotly-choreographer-4159eaf/src/choreographer/utils/_which.py000066400000000000000000000055331525262030500247030ustar00rootroot00000000000000from __future__ import annotations import os import platform import re import shutil from typing import TYPE_CHECKING import logistro _logger = logistro.getLogger() if TYPE_CHECKING: from collections.abc import Sequence from pathlib import Path from typing import Any def _is_exe(path: str | Path) -> bool: try: return os.access(path, os.X_OK) except: # noqa: E722 bare except ok, weird errors, best effort. return False def _which_from_windows_reg(key: str) -> str | None: try: import winreg # noqa: PLC0415 don't import if not windows pls command = winreg.QueryValueEx( # type: ignore [attr-defined] winreg.OpenKey( # type: ignore [attr-defined] winreg.HKEY_CLASSES_ROOT, # type: ignore [attr-defined] f"{key}\\shell\\open\\command", 0, winreg.KEY_READ, # type: ignore [attr-defined] ), "", )[0] exe = re.search('"(.*?)"', command).group(1) # type: ignore [union-attr] except Exception: # noqa: BLE001 don't care why, best effort search return None return exe def browser_which( executable_names: Sequence[str], *, ms_prog_id: str | None = None, ) -> str | None: """ Look for and return first name found in PATH. Args: executable_names: the list of names to look for ms_prog_id: A windows registry ID string to lookup program paths """ if isinstance(executable_names, str): executable_names = [executable_names] if platform.system() == "Windows": os.environ["NoDefaultCurrentDirectoryInExePath"] = "0" # noqa: SIM112 var name set by windows if ( ms_prog_id and (path := _which_from_windows_reg(ms_prog_id)) and _is_exe(path) ): return path for exe in executable_names: path = shutil.which(exe) if path and _is_exe(path): return path return None def get_browser_path(*args: Any, **kwargs: Any) -> str | None: # noqa: D417, don't pass args explicitly """ Call `browser_which()` but check for user override first. Returns the path set by the ``BROWSER_PATH`` environment variable if set, otherwise searches ``PATH`` via `browser_which()`. Accepts the same arguments as `browser_which()`: Args: executable_names: the list of names to look for ms_prog_id: A windows registry ID string to lookup program paths Note: This function does not handle the choreographer-managed local browser download. To include (or skip) a locally downloaded browser in the search, use `choreographer.browsers.chromium.Chromium.find_browser`, which accepts a ``skip_local`` argument. """ return os.environ.get("BROWSER_PATH", browser_which(*args, **kwargs)) plotly-choreographer-4159eaf/tests/000077500000000000000000000000001525262030500174455ustar00rootroot00000000000000plotly-choreographer-4159eaf/tests/conftest.py000066400000000000000000000075541525262030500216570ustar00rootroot00000000000000import asyncio import logging import logistro import pytest import pytest_asyncio import choreographer as choreo from choreographer import errors _logger = logistro.getLogger(__name__) @pytest.fixture(params=[True, False], ids=["enable_sandbox", ""]) def sandbox(request): return request.param @pytest.fixture(params=[True, False], ids=["enable_gpu", ""]) def gpu(request): return request.param @pytest.fixture(params=[True, False], ids=["headless", ""]) def headless(request): return request.param @pytest.fixture(params=[True, False], ids=["enable_extensions", ""]) def extension(request): return request.param # --headless is the default flag for most tests, # but you can set --no-headless if you want to watch def pytest_addoption(parser): parser.addoption("--headless", action="store_true", dest="headless", default=True) parser.addoption("--no-headless", dest="headless", action="store_false") # browser fixture will supply a browser for you @pytest_asyncio.fixture(scope="function", loop_scope="function") async def browser(request): _logger.info("Fixture building browser.") headless = request.config.getoption("--headless") browser = await choreo.Browser( headless=headless, ) yield browser _logger.info("Fixture closing browser.") try: await browser.close() except errors.BrowserClosedError: pass if ( hasattr(browser._browser_impl, "tmp_dir") # noqa: SLF001 and browser._browser_impl.tmp_dir.exists # type: ignore[reportAttributeAccessIssue] # noqa: SLF001 ): raise RuntimeError( "Temporary directory not deleted successfully: " f"{browser._browser_impl.tmp_dir.path}", # type: ignore[reportAttributeAccessIssue] # noqa: SLF001 ) # add a timeout if tests requests browser # but if tests creates their own browser they are responsible # a fixture can be used to specify the timeout: timeout=10 # else it uses pytest.default_timeout @pytest.hookimpl(tryfirst=True, hookwrapper=True) def pytest_runtest_setup(item): # not even sure if this working # typer doesn't like item: pytest.Item w/ funcargs # but this is the recommended way # some people say do trylast yield if "browser" in item.funcargs: _logger.info("Hook setting test timeout.") raw_test_fn = item.obj timeouts = [k for k in item.funcargs if k.startswith("timeout")] timeout = ( item.funcargs[timeouts[-1]] if timeouts else pytest.default_timeout # type: ignore[reportAttributeAccessIssue] ) if ( item.get_closest_marker("asyncio") and timeout ): # "closest" because markers can be function/session/package etc async def wrapped_test_fn(*args, **kwargs): try: return await asyncio.wait_for( raw_test_fn(*args, **kwargs), timeout=timeout, ) except TimeoutError: pytest.fail( f"Test {item.name} failed a timeout. " "This can be extended, but shouldn't be. See conftest.py.", ) item.obj = wrapped_test_fn def pytest_configure(): # change this by command line TODO pytest.default_timeout = 24 # type: ignore[reportAttributeAccessIssue] # pytest shuts down its capture before logging/threads finish @pytest.fixture(scope="session", autouse=True) def cleanup_logging_handlers(request): capture = request.config.getoption("--capture") != "no" try: yield finally: if capture: _logger.info("Conftest cleaning up handlers.") for handler in logging.root.handlers[:]: handler.flush() if isinstance(handler, logging.StreamHandler): logging.root.removeHandler(handler) plotly-choreographer-4159eaf/tests/pyrightconfig.json000066400000000000000000000000451525262030500232130ustar00rootroot00000000000000{ "typeCheckingMode": "standard" } plotly-choreographer-4159eaf/tests/test_browser.py000066400000000000000000000126511525262030500225460ustar00rootroot00000000000000import asyncio import logistro import pytest import choreographer as choreo from choreographer import errors from choreographer.protocol import devtools_async # We no longer use live URLs to as not depend on the network # allows to create a browser pool for tests pytestmark = pytest.mark.asyncio(loop_scope="function") _logger = logistro.getLogger(__name__) @pytest.mark.asyncio async def test_create_and_close_tab(browser): _logger.info("testing...") tab = await browser.create_tab("") assert isinstance(tab, choreo.Tab) assert tab.target_id in browser.tabs await browser.close_tab(tab) assert tab.target_id not in browser.tabs @pytest.mark.asyncio async def test_create_and_close_session(browser): _logger.info("testing...") with pytest.warns(errors.ExperimentalFeatureWarning): session = await browser.create_session() assert isinstance(session, devtools_async.Session) assert session.session_id in browser.sessions await browser.close_session(session) assert session.session_id not in browser.sessions # Along with testing, this could be repurposed as well to diagnose # This deserves some thought re. difference between write_json and send_command @pytest.mark.asyncio async def test_broker_write_json(browser): _logger.info("testing...") # Test valid request with correct id and method response = await browser._broker.write_json( # noqa: SLF001 {"id": 0, "method": "Target.getTargets"}, ) assert "result" in response and "targetInfos" in response["result"] # noqa: PT018 I like this assertion # Test invalid method name should return error response = await browser._broker.write_json( # noqa: SLF001 {"id": 2, "method": "dkadklqwmd"}, ) assert "error" in response # Test missing 'id' key with pytest.raises( errors.MissingKeyError, ): await browser._broker.write_json( # noqa: SLF001 {"method": "Target.getTargets"}, ) # Test missing 'method' key with pytest.raises( errors.MissingKeyError, ): await browser._broker.write_json( # noqa: SLF001 {"id": 1}, ) # Test empty dictionary with pytest.raises( errors.MissingKeyError, ): await browser._broker.write_json({}) # noqa: SLF001 # Test invalid parameter in the message with pytest.raises( RuntimeError, ): await browser._broker.write_json( # noqa: SLF001 {"id": 0, "method": "Target.getTargets", "invalid_parameter": "kamksamdk"}, ) # Test int method should return error with pytest.raises( errors.MessageTypeError, ): await browser._broker.write_json( # noqa: SLF001 {"id": 3, "method": 12345}, ) # Test non-integer id should return error with pytest.raises( errors.MessageTypeError, ): await browser._broker.write_json( # noqa: SLF001 {"id": "string", "method": "Target.getTargets"}, ) @pytest.mark.asyncio async def test_browser_send_command(browser): _logger.info("testing...") # Test valid request with correct command response = await browser.send_command(command="Target.getTargets") assert "result" in response and "targetInfos" in response["result"] # noqa: PT018 I like this assertion # Test invalid method name should return error response = await browser.send_command(command="dkadklqwmd") assert "error" in response # Test int method should return error with pytest.raises( errors.MessageTypeError, ): await browser.send_command(command=12345) @pytest.mark.asyncio async def test_browser_send_command_with_perf(browser): _logger.info("testing...") perfs = [] # Run multiple commands and collect perf data for _ in range(3): response, perf = await browser.send_command( command="Target.getTargets", with_perf=True, ) assert "result" in response and "targetInfos" in response["result"] # noqa: PT018 I like this assertion # Validate perf is a tuple of 3 floats assert isinstance(perf, tuple) assert all(isinstance(t, float) for t in perf) # Validate timing makes sense (write_start <= write_end <= read_end) write_start, write_end, read_end = perf assert write_start <= write_end assert write_start <= read_end # read_end is tough because it happens on a different processor # and it might actually be somewhat skewed perfs.append(perf) await asyncio.sleep(0.1) # Verify each perf tuple is distinct and sequential for i in range(len(perfs) - 1): _, _, prev_read_end = perfs[i] next_write_start, _, _ = perfs[i + 1] # Next command should start after previous command ended assert prev_read_end <= next_write_start, "Commands should be sequential" @pytest.mark.asyncio async def test_populate_targets(browser): _logger.info("testing...") await browser.send_command(command="Target.createTarget", params={"url": ""}) await browser.populate_targets() assert len(browser.tabs) >= 1 @pytest.mark.asyncio async def test_get_tab(browser): _logger.info("testing...") await browser.create_tab("") assert browser.get_tab() == next(iter(browser.tabs.values())) await browser.create_tab() await browser.create_tab("") assert browser.get_tab() == next(iter(browser.tabs.values())) plotly-choreographer-4159eaf/tests/test_browser_search.py000066400000000000000000000025761525262030500241000ustar00rootroot00000000000000import os import platform import stat import sys from pathlib import Path import pytest from choreographer.browsers import chromium from choreographer.cli._cli_utils import get_chrome_download_path def test_internal(tmp_path): if platform.system() == "Windows": pytest.skip("Windows hard to trick about PATH.") _o = str(os.environ["PATH"]) os.environ["PATH"] = str(tmp_path) print(os.environ["PATH"]) names = ["chrome", "chromium", "msedge", "brave", "vivaldi"] paths = [] for n in names: p = tmp_path / (n) p.touch() p.chmod(p.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) paths.append(p) for _p, _n in zip(paths, names): _r = chromium.Chromium.find_browser(skip_local=True, skip_typical=True) assert _r assert Path(_r).stem == _n _p.unlink() os.environ["PATH"] = _o def test_canary(): # This ensures we are finding the local install if os.getenv("TEST_SYSTEM_BROWSER"): pytest.skip("Okay, no need to test for local.") _r = chromium.Chromium.find_browser(skip_local=False, skip_typical=True) print(sys.path, file=sys.stderr) print(f"found {_r}", file=sys.stderr) print(f"download path: {get_chrome_download_path()}", file=sys.stderr) assert _r assert Path(_r) == get_chrome_download_path() # if _r != get_chrome_download_path(): plotly-choreographer-4159eaf/tests/test_chromium.py000066400000000000000000000040561525262030500227060ustar00rootroot00000000000000from __future__ import annotations from choreographer.browsers.chromium import Chromium from choreographer.channels import Pipe def _get_cli(tmp_path, **kwargs) -> list[str]: executable = tmp_path / "chrome" executable.touch() channel = Pipe() browser = Chromium(channel, executable, **kwargs) browser.pre_open() try: return list(browser.get_cli()) finally: browser.clean() channel.close() def test_proxy_server_argument(tmp_path, monkeypatch): monkeypatch.delenv("CHOREO_PROXY_SERVER", raising=False) cli = _get_cli(tmp_path, proxy_server="http://proxy.example:8080") assert "--proxy-server=http://proxy.example:8080" in cli def test_proxy_server_environment_variable(tmp_path, monkeypatch): monkeypatch.setenv("CHOREO_PROXY_SERVER", "socks5://proxy.example:1080") cli = _get_cli(tmp_path) assert "--proxy-server=socks5://proxy.example:1080" in cli def test_proxy_server_argument_overrides_environment(tmp_path, monkeypatch): monkeypatch.setenv("CHOREO_PROXY_SERVER", "http://environment.example:8080") cli = _get_cli(tmp_path, proxy_server="http://argument.example:8080") assert "--proxy-server=http://argument.example:8080" in cli assert "--proxy-server=http://environment.example:8080" not in cli def test_proxy_server_is_not_added_by_default(tmp_path, monkeypatch): monkeypatch.delenv("CHOREO_PROXY_SERVER", raising=False) cli = _get_cli(tmp_path) assert not any(arg.startswith("--proxy-server=") for arg in cli) def test_none_proxy_server_uses_environment_fallback(tmp_path, monkeypatch): monkeypatch.setenv("CHOREO_PROXY_SERVER", "http://environment.example:8080") cli = _get_cli(tmp_path, proxy_server=None) assert "--proxy-server=http://environment.example:8080" in cli def test_empty_proxy_server_uses_environment_fallback(tmp_path, monkeypatch): monkeypatch.setenv("CHOREO_PROXY_SERVER", "http://environment.example:8080") cli = _get_cli(tmp_path, proxy_server="") assert "--proxy-server=http://environment.example:8080" in cli plotly-choreographer-4159eaf/tests/test_chunking.py000066400000000000000000000330531525262030500226700ustar00rootroot00000000000000""" Tests for sending commands that are too big for one write. The real limit is 100MB, which is too slow to test against, so nearly every test here shrinks it with monkeypatch. The whole point of the feature is that a chunked call and a normal call are indistinguishable, so most of these run the same call both ways and compare. """ import asyncio import json import logistro import pytest import pytest_asyncio from choreographer.channels import MessageTooLargeError, pipe from choreographer.protocol import _chunking pytestmark = pytest.mark.asyncio(loop_scope="function") _logger = logistro.getLogger(__name__) _N_VALUES = 2000 _MIN_CHUNKED_MESSAGES = 3 # init + at least one push + the real call _MIN_REAL_CHUNKED_MESSAGES = 5 # at the real 10MiB chunk size, expect about 14 _ECHO_FN = ( "function(spec, tag){" "return JSON.stringify({" "type: typeof spec," "n: spec.values.length," "first: spec.values[0]," "last: spec.values[spec.values.length - 1]," "text: spec.text," "tag: tag" "});" "}" ) def _spec(n=_N_VALUES, text="plain"): return {"values": list(range(n)), "text": text} @pytest_asyncio.fixture(scope="function", loop_scope="function") async def js(browser): """Give back a tab session with a JavaScript context to run functions in.""" tab = await browser.create_tab("") session = await tab.create_session() context = session.subscribe_once("Runtime.executionContextCreated") await session.send_command("Page.enable") await session.send_command("Runtime.enable") js_id = (await context)["params"]["context"]["id"] yield session, js_id await tab.close_session(session.session_id) await browser.close_tab(tab.target_id) @pytest_asyncio.fixture(scope="function", loop_scope="function") async def js_unique(browser): """Like `js`, but hands back the context's uniqueId instead of its id.""" tab = await browser.create_tab("") session = await tab.create_session() context = session.subscribe_once("Runtime.executionContextCreated") await session.send_command("Page.enable") await session.send_command("Runtime.enable") unique_id = (await context)["params"]["context"]["uniqueId"] yield session, unique_id await tab.close_session(session.session_id) await browser.close_tab(tab.target_id) async def _call(session, js_id, fn=_ECHO_FN, args=None, **extra): params = { "functionDeclaration": fn, "arguments": [{"value": a} for a in (args if args is not None else [])], "executionContextId": js_id, "returnByValue": False, "awaitPromise": True, } params.update(extra) return await session.send_command("Runtime.callFunctionOn", params=params) def _value(response): return response["result"]["result"]["value"] async def _store_keys(session, js_id): response = await _call( session, js_id, fn=( "function(){" "return JSON.stringify(" "window.__choreo_chunks ? Object.keys(window.__choreo_chunks) : []" ");" "}" ), ) return json.loads(_value(response)) def _shrink(monkeypatch, max_size=4096, chunk_size=512): monkeypatch.setattr(pipe, "MAX_MESSAGE_SIZE", max_size) monkeypatch.setattr(_chunking, "CHUNK_SIZE", chunk_size) async def test_chunked_matches_unchunked(js, monkeypatch): _logger.info("testing...") session, js_id = js args = [_spec(), "hello"] plain = _value(await _call(session, js_id, args=args)) _shrink(monkeypatch) chunked = _value(await _call(session, js_id, args=args)) assert json.loads(chunked) == json.loads(plain) assert json.loads(chunked)["type"] == "object" assert json.loads(chunked)["n"] == _N_VALUES async def test_chunking_actually_happened(js, monkeypatch): _logger.info("testing...") session, js_id = js _shrink(monkeypatch) before = session.message_id await _call(session, js_id, args=[_spec(), "hello"]) assert session.message_id - before > _MIN_CHUNKED_MESSAGES async def test_store_is_cleaned_up(js, monkeypatch): _logger.info("testing...") session, js_id = js _shrink(monkeypatch) await _call(session, js_id, args=[_spec(), "hello"]) monkeypatch.undo() assert await _store_keys(session, js_id) == [] async def test_error_in_function_still_reports_and_cleans_up(js, monkeypatch): _logger.info("testing...") session, js_id = js _shrink(monkeypatch) response = await _call( session, js_id, fn="function(spec){ throw new Error('boom ' + spec.values.length); }", args=[_spec()], ) assert "exceptionDetails" in response["result"] assert "boom 2000" in json.dumps(response["result"]["exceptionDetails"]) monkeypatch.undo() assert await _store_keys(session, js_id) == [] async def test_promise_is_awaited(js, monkeypatch): _logger.info("testing...") session, js_id = js _shrink(monkeypatch) response = await _call( session, js_id, fn=( "function(spec){return Promise.resolve('resolved ' + spec.values.length);}" ), args=[_spec()], ) assert _value(response) == "resolved 2000" async def test_return_by_value(js, monkeypatch): _logger.info("testing...") session, js_id = js _shrink(monkeypatch) response = await _call( session, js_id, fn="function(spec){ return {n: spec.values.length}; }", args=[_spec()], returnByValue=True, ) assert _value(response) == {"n": 2000} async def test_with_perf_survives_chunking(js, monkeypatch): """ `with_perf` has to keep working when the send gets broken up. Timings are looked up by message key, and the original command's write never happened, so its key has no entry in `write_perfs`. `send_chunked` hands back the command that actually went out so the lookup lands on that one instead. Drop that and this raises `KeyError`. Note the timings then describe only the final message, not the pushes. """ _logger.info("testing...") session, js_id = js _shrink(monkeypatch) response, perf = await session.send_command( "Runtime.callFunctionOn", params={ "functionDeclaration": _ECHO_FN, "arguments": [{"value": _spec()}, {"value": "hello"}], "executionContextId": js_id, "awaitPromise": True, }, with_perf=True, ) assert json.loads(_value(response))["n"] == _N_VALUES write_start, write_end, read_end = perf assert write_start <= write_end # The read loop takes read_end on a different thread, so read_end can # precede write_end. We do the same thing in test_browser.py. assert write_start <= read_end async def test_function_ending_in_a_comment(js, monkeypatch): _logger.info("testing...") session, js_id = js _shrink(monkeypatch) response = await _call( session, js_id, fn="function(spec){ return spec.values.length; } // trailing comment", args=[_spec()], returnByValue=True, ) assert _value(response) == _N_VALUES async def test_non_ascii_survives_the_split(js, monkeypatch): _logger.info("testing...") session, js_id = js text = "héllo — 😀 中文 " * 200 args = [_spec(text=text), "tag"] plain = json.loads(_value(await _call(session, js_id, args=args))) _shrink(monkeypatch) chunked = json.loads(_value(await _call(session, js_id, args=args))) assert chunked["text"] == text assert chunked == plain async def test_several_large_arguments(js, monkeypatch): _logger.info("testing...") session, js_id = js _shrink(monkeypatch) response = await _call( session, js_id, fn=( "function(a, b, c){" "return JSON.stringify([a.values.length, b.values.length, c]);" "}" ), args=[_spec(1000), _spec(1500), "tail"], ) assert json.loads(_value(response)) == [1000, 1500, "tail"] async def test_concurrent_chunked_calls_do_not_collide(js, monkeypatch): _logger.info("testing...") session, js_id = js _shrink(monkeypatch) responses = await asyncio.gather( _call(session, js_id, args=[_spec(1000), "first"]), _call(session, js_id, args=[_spec(2000), "second"]), ) first, second = (json.loads(_value(r)) for r in responses) assert (first["n"], first["tag"]) == (1000, "first") assert (second["n"], second["tag"]) == (2000, "second") monkeypatch.undo() assert await _store_keys(session, js_id) == [] async def test_unchunkable_command_raises(js, monkeypatch): _logger.info("testing...") session, js_id = js _shrink(monkeypatch) with pytest.raises(MessageTooLargeError): await session.send_command( "Page.navigate", params={"url": "data:text/html," + ("x" * 8192)}, ) # The channel was never written to, so the session still works. monkeypatch.undo() assert _value(await _call(session, js_id, args=[_spec(10), "after"])) async def test_lost_store_mid_send_raises(js, monkeypatch): _logger.info("testing...") session, js_id = js _shrink(monkeypatch) # Intentionally break init function to trigger this error monkeypatch.setattr(_chunking, "_INIT_FN", "function(k){ }") with pytest.raises(RuntimeError, match="Chunked send failed in the page"): await _call(session, js_id, args=[_spec(), "hello"]) async def test_unique_context_id_is_followed(js_unique, monkeypatch): _logger.info("testing...") session, unique_id = js_unique _shrink(monkeypatch) response = await session.send_command( "Runtime.callFunctionOn", params={ "functionDeclaration": _ECHO_FN, "arguments": [{"value": _spec()}, {"value": "hello"}], "uniqueContextId": unique_id, "awaitPromise": True, }, ) got = json.loads(_value(response)) assert got["n"] == _N_VALUES assert got["tag"] == "hello" async def test_throw_on_side_effect_is_refused(js, monkeypatch): _logger.info("testing...") session, js_id = js _shrink(monkeypatch) with pytest.raises(MessageTooLargeError): await _call(session, js_id, args=[_spec(), "hello"], throwOnSideEffect=True) # The important half: we declined before writing anything to the page. monkeypatch.undo() assert await _store_keys(session, js_id) == [] async def test_object_id_arguments_are_not_chunked(js, monkeypatch): """Browser-side handles can't be rebuilt from text, so don't try.""" _logger.info("testing...") session, js_id = js handle = await _call( session, js_id, fn="function(){ return {a: 1}; }", ) object_id = handle["result"]["result"]["objectId"] _shrink(monkeypatch) with pytest.raises(MessageTooLargeError): await session.send_command( "Runtime.callFunctionOn", params={ "functionDeclaration": "function(o, pad){ return o.a; }", "arguments": [{"objectId": object_id}, {"value": "x" * 8192}], "executionContextId": js_id, }, ) async def test_oversized_function_does_not_recurse(js, monkeypatch): """ A huge functionDeclaration can't be helped by chunking. The wrapper we'd build is bigger than the message we're replacing, so it has to give up rather than fall back into itself forever. """ _logger.info("testing...") session, js_id = js _shrink(monkeypatch) with pytest.raises(MessageTooLargeError): await _call( session, js_id, fn="function(x){ var s = '" + ("y" * 8192) + "'; return s.length; }", args=[1], ) @pytest.mark.slow async def test_real_oversized_payload(js): """ The only test that uses Chrome's actual limit, so it is the slow one. The padding is stamped with its own offset every 1000 characters, so a dropped piece or two pieces arriving out of order changes the answer. A payload of all one character would hide both. """ _logger.info("testing...") session, js_id = js stride = 1000 n_pieces = 115_000 # About 110MiB (over the limit) pad = "".join(f"{i:08d}" + "y" * (stride - 8) for i in range(n_pieces)) marks = [0, 5_000, 50_000, n_pieces - 1] spec = {"pad": pad, "tail": "héllo 😀"} before = session.message_id response = await _call( session, js_id, fn=( "function(spec, offsets, stride){" "return JSON.stringify({" "len: spec.pad.length," "tail: spec.tail," "marks: offsets.map(function(k){" "return spec.pad.substr(k * stride, 8);" "})" "});" "}" ), args=[spec, marks, stride], ) got = json.loads(_value(response)) sent = session.message_id - before # Without this the test would still pass if the payload ever slipped under # the real limit, quietly measuring nothing. assert sent > _MIN_REAL_CHUNKED_MESSAGES, ( f"only {sent} messages sent: the payload did not get chunked" ) assert got["len"] == n_pieces * stride assert got["tail"] == "héllo 😀" assert got["marks"] == [f"{k:08d}" for k in marks] assert await _store_keys(session, js_id) == [] async def test_error_text_does_not_include_payload(): _logger.info("testing...") error = MessageTooLargeError(200, 100, payload="SECRET" * 100) assert "SECRET" not in str(error) assert "SECRET" not in repr(error) assert error.payload is not None plotly-choreographer-4159eaf/tests/test_devtools_async_helpers.py000066400000000000000000000064271525262030500256450ustar00rootroot00000000000000import asyncio import logistro import pytest from choreographer.protocol.devtools_async_helpers import ( create_and_wait, execute_js_and_wait, navigate_and_wait, ) pytestmark = pytest.mark.asyncio(loop_scope="function") _logger = logistro.getLogger(__name__) # Errata: don't use data urls, whether or not they load is variable # depends on how long chrome has been open for, how they were entered, # etc @pytest.mark.asyncio async def test_create_and_wait(browser): """Test create_and_wait with both valid data URL and blank URL.""" _logger.info("testing create_and_wait...") # Count tabs before initial_tab_count = len(browser.tabs) # Create a simple HTML page as a data URL data_url = "https://www.example.com" # Test 1: Create tab with data URL - should succeed tab1 = await create_and_wait(browser, url=data_url, timeout=5.0) assert tab1 is not None # Verify the page loaded correctly using execute_js_and_wait result = await execute_js_and_wait(tab1, "window.location.href", timeout=5.0) location = result["result"]["result"]["value"] assert location.startswith(data_url) # Test 2: Create tab without URL - should succeed (blank page) tab2 = await create_and_wait(browser, url="", timeout=5.0) assert tab2 is not None # Verify we have 2 more tabs final_tab_count = len(browser.tabs) assert final_tab_count == initial_tab_count + 2 # Test 3: Create tab with bad URL that won't load - should timeout with pytest.raises(asyncio.TimeoutError): await create_and_wait(browser, url="http://192.0.2.1:9999", timeout=0.5) @pytest.mark.asyncio async def test_navigate_and_wait(browser): """Test navigate_and_wait with both valid data URL and bad URL.""" _logger.info("testing navigate_and_wait...") # Create two blank tabs first tab = await browser.create_tab("") # navigating to dataurls seems to be fine right now, # but if one day you have an error here, # change to the strategy above # Create a data URL with identifiable content html_content1 = "

Navigation Test 1

" data_url1 = f"data:text/html,{html_content1}" html_content2 = "

Navigation Test 2

" data_url2 = f"data:text/html,{html_content2}" # Test 1: Navigate first tab to valid data URL - should succeed result_tab1 = await navigate_and_wait(tab, url=data_url1, timeout=5.0) assert result_tab1 is tab # Verify the navigation succeeded using execute_js_and_wait result = await execute_js_and_wait(tab, "window.location.href", timeout=5.0) location = result["result"]["result"]["value"] assert location.startswith("data:text/html") # Test 2: Navigate second tab to another valid data URL - should succeed result_tab2 = await navigate_and_wait(tab, url=data_url2, timeout=5.0) assert result_tab2 is tab # Verify the navigation succeeded result = await execute_js_and_wait(tab, "window.location.href", timeout=5.0) location = result["result"]["result"]["value"] assert location.startswith("data:text/html") # Test 3: Navigate to bad URL that won't load - should timeout with pytest.raises(asyncio.TimeoutError): await navigate_and_wait(tab, url="http://192.0.2.1:9999", timeout=0.5) plotly-choreographer-4159eaf/tests/test_get_chrome.py000066400000000000000000000062231525262030500231750ustar00rootroot00000000000000import io import zipfile from unittest.mock import patch import pytest from choreographer.cli._cli_utils import get_chrome_sync @pytest.fixture def mock_last_known_good_json(): """Mock the last known good chrome version.""" return { "version": "135.0.7011.0", "revision": "1418433", "downloads": { "chrome": [ {"platform": "linux64", "url": "https://example.com/linux64.zip"}, {"platform": "mac-arm64", "url": "https://example.com/mac-arm64.zip"}, {"platform": "win64", "url": "https://example.com/win64.zip"}, ], }, } # Thanks Claude! def test_get_chrome_sync_download_behavior( tmp_path, mock_last_known_good_json, ): """Test chrome download/skip behavior: existing, force, version change.""" exe_path = tmp_path / "chrome-linux64" / "chrome" version_tag = tmp_path / "version_tag.txt" # Setup: create a fake existing installation with matching version exe_path.parent.mkdir(parents=True) exe_path.write_text("old chrome") version_tag.write_text("135.0.7011.0\n1418433") # Mock the URL download def create_mock_zip_response(): # Create a fresh zip buffer each time zip_buffer = io.BytesIO() with zipfile.ZipFile(zip_buffer, "w") as zf: zf.writestr("chrome-linux64/chrome", "newly downloaded chrome") zip_buffer.seek(0) # Return BytesIO directly - it already has all file-like methods needed return zip_buffer # Patch json.load to return our mock data (avoid broad Path.read_text patch) with ( patch("json.loads", return_value=mock_last_known_good_json), patch( "urllib.request.urlopen", side_effect=lambda url: create_mock_zip_response(), # noqa: ARG005 ), ): # a) First call without force - should return existing, no download result = get_chrome_sync(arch="linux64", path=tmp_path, force=False) assert result == exe_path assert exe_path.read_text() == "old chrome" # Should still be old # b) Call with force=True - should download and replace result = get_chrome_sync(arch="linux64", path=tmp_path, force=True) assert result == exe_path assert exe_path.read_text() == "newly downloaded chrome" assert version_tag.read_text() == "135.0.7011.0\n1418433" # c) Call again without force - should return existing, no re-download # (Modify the file to verify it doesn't get re-downloaded) exe_path.write_text("manually modified chrome") result = get_chrome_sync(arch="linux64", path=tmp_path, force=False) assert result == exe_path assert exe_path.read_text() == "manually modified chrome" # d) Change version on disk - should trigger download (version mismatch) version_tag.write_text("999.0.0.0\n999999") # Old/different version result = get_chrome_sync(arch="linux64", path=tmp_path, force=False) assert result == exe_path assert exe_path.read_text() == "newly downloaded chrome" assert version_tag.read_text() == "135.0.7011.0\n1418433" # Updated to new plotly-choreographer-4159eaf/tests/test_placeholder.py000066400000000000000000000005431525262030500233420ustar00rootroot00000000000000import asyncio import logistro import pytest # allows to create a browser pool for tests pytestmark = pytest.mark.asyncio(loop_scope="function") _logger = logistro.getLogger(__name__) async def test_placeholder(browser): _logger.info("testing...") assert "result" in await browser.send_command("Target.getTargets") await asyncio.sleep(0) plotly-choreographer-4159eaf/tests/test_process.py000066400000000000000000000101131525262030500225300ustar00rootroot00000000000000import asyncio import os import platform import signal import subprocess import logistro import pytest from async_timeout import timeout import choreographer as choreo from choreographer import errors # allows to create a browser pool for tests pytestmark = pytest.mark.asyncio(loop_scope="function") _logger = logistro.getLogger(__name__) @pytest.mark.asyncio(loop_scope="function") async def test_context(headless, sandbox, gpu, extension): _logger.info("testing...") if sandbox and "ubuntu" in platform.version().lower(): pytest.skip( "Ubuntu doesn't support sandbox unless installed from snap.", ) elif sandbox: _logger.info( "Not skipping sandbox: " f"Sandbox: {sandbox}," f"Version: {platform.version().lower()}", ) async with timeout(pytest.default_timeout): # type: ignore[reportAttributeAccessIssue] async with choreo.Browser( headless=headless, enable_sandbox=sandbox, enable_gpu=gpu, enable_extensions=extension, ) as browser: response = await browser.send_command(command="Target.getTargets") assert "result" in response and "targetInfos" in response["result"] # noqa: PT018 combined assert if len(response["result"]["targetInfos"]) == 0: await browser.create_tab() assert isinstance(browser.get_tab(), choreo.Tab) tab = browser.get_tab() assert tab is not None assert len(tab.sessions) == 1 # let asyncio do some cleaning up if it wants, may prevent warnings await asyncio.sleep(0) if hasattr(browser._browser_impl, "tmp_dir"): # noqa: SLF001 assert not browser._browser_impl.tmp_dir.exists # type: ignore[reportAttributeAccessIssue] # noqa: SLF001 @pytest.mark.asyncio(loop_scope="function") async def test_no_context(headless, sandbox, gpu, extension): _logger.info("testing...") if sandbox and "ubuntu" in platform.version().lower(): pytest.skip("Ubuntu doesn't support sandbox unless installed from snap.") browser = await choreo.Browser( headless=headless, enable_sandbox=sandbox, enable_gpu=gpu, enable_extensions=extension, ) try: async with timeout(pytest.default_timeout): # type: ignore[reportAttributeAccessIssue] response = await browser.send_command(command="Target.getTargets") assert "result" in response and "targetInfos" in response["result"] # noqa: PT018 combined assert if len(response["result"]["targetInfos"]) == 0: await browser.create_tab() assert isinstance(browser.get_tab(), choreo.Tab) tab = browser.get_tab() assert tab is not None assert len(tab.sessions) == 1 finally: await browser.close() await asyncio.sleep(0) if hasattr(browser._browser_impl, "tmp_dir"): # noqa: SLF001 assert not browser._browser_impl.tmp_dir.exists # type: ignore[reportAttributeAccessIssue] # noqa: SLF001 # Harass choreographer with a kill in this test to see if its clean in a way # tempdir may survive protected by chromium subprocess surviving the kill @pytest.mark.asyncio(loop_scope="function") async def test_watchdog(headless): _logger.info("testing...") browser = await choreo.Browser( headless=headless, ) if platform.system() == "Windows": # Blocking process here because it ensures the kill will occur rn subprocess.call( # noqa: S603, ASYNC221 sanitize input, blocking process ["taskkill", "/F", "/T", "/PID", str(browser.subprocess.pid)], # noqa: S607 stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL, ) else: os.kill(browser.subprocess.pid, signal.SIGKILL) await asyncio.sleep(0.5) with pytest.raises( (errors.ChannelClosedError, errors.BrowserClosedError, asyncio.CancelledError), ): await browser.send_command(command="Target.getTargets") await browser.close() await asyncio.sleep(0) plotly-choreographer-4159eaf/tests/test_serializer.py000066400000000000000000000037761525262030500232440ustar00rootroot00000000000000from __future__ import annotations try: from datetime import UTC, datetime # type: ignore [attr-defined] except ImportError: from datetime import datetime, timezone UTC = timezone.utc import json from typing import TYPE_CHECKING import logistro import numpy as np import pytest import choreographer.channels._wire as wire from choreographer.channels import register_custom_encoder if TYPE_CHECKING: from typing import Any # allows to create a browser pool for tests pytestmark = pytest.mark.asyncio(loop_scope="function") _timestamp = datetime(1970, 1, 1, tzinfo=UTC) data = [1, 2.00, 3, float("nan"), float("inf"), float("-inf"), _timestamp] expected_message = b'[1, 2.0, 3, null, null, null, "1970-01-01T00:00:00+00:00"]' converted_type = [int, float, int, type(None), type(None), type(None), str] _logger = logistro.getLogger(__name__) async def test_custom_encoder(): class NonsenseEncoder(json.JSONEncoder): def iterencode( self, o: Any, _one_shot: bool = False, # noqa: FBT001, FBT002 ) -> Any: _ = o yield "Test Passed." def encode(self, o: Any) -> Any: _ = o return "Test Passed." register_custom_encoder(NonsenseEncoder) message = wire.serialize(data) assert message == b"Test Passed." register_custom_encoder(None) message = wire.serialize(data) assert message == expected_message @pytest.mark.asyncio async def test_de_serialize(): _logger.info("testing...") message = wire.serialize(data) assert message == expected_message obj = wire.deserialize(message.decode()) assert len(obj) == len(converted_type) for o, t in zip(obj, converted_type): assert isinstance(o, t) message_np = wire.serialize(np.array(data)) assert message_np == expected_message obj_np = wire.deserialize(message_np.decode()) assert len(obj_np) == len(converted_type) for o, t in zip(obj_np, converted_type): assert isinstance(o, t) plotly-choreographer-4159eaf/tests/test_session.py000066400000000000000000000023331525262030500225420ustar00rootroot00000000000000import warnings import logistro import pytest import pytest_asyncio from choreographer import errors # allows to create a browser pool for tests pytestmark = pytest.mark.asyncio(loop_scope="function") _logger = logistro.getLogger(__name__) @pytest_asyncio.fixture(scope="function", loop_scope="function") async def session(browser): _logger.info("testing...") with warnings.catch_warnings(): warnings.simplefilter("ignore", errors.ExperimentalFeatureWarning) session_browser = await browser.create_session() yield session_browser await browser.close_session(session_browser) @pytest.mark.asyncio async def test_session_send_command(session): _logger.info("testing...") # Test valid request with correct command response = await session.send_command(command="Target.getTargets") assert "result" in response and "targetInfos" in response["result"] # noqa: PT018 I like this assertion # Test invalid method name should return error response = await session.send_command(command="dkadklqwmd") assert "error" in response # Test int method should return error with pytest.raises( errors.MessageTypeError, ): await session.send_command(command=12345) plotly-choreographer-4159eaf/tests/test_tab.py000066400000000000000000000053701525262030500216310ustar00rootroot00000000000000import asyncio import logistro import pytest from choreographer import errors from choreographer.protocol import devtools_async # allows to create a browser pool for tests pytestmark = pytest.mark.asyncio(loop_scope="function") _logger = logistro.getLogger(__name__) # this ignores extra stuff in received- only that we at least have what is expected def check_response_dictionary(response_received, response_expected): for k, v in response_expected.items(): if isinstance(v, dict): check_response_dictionary(v, v) assert ( response_received.get( k, float("NaN"), ) == v ), f"Expected: {response_expected}\nReceived: {response_received}" @pytest.mark.asyncio async def test_create_and_close_session(browser): _logger.info("testing...") tab = await browser.create_tab("") session = await tab.create_session() assert isinstance(session, devtools_async.Session) await tab.close_session(session) assert session.session_id not in tab.sessions @pytest.mark.asyncio async def test_tab_send_command(browser): _logger.info("testing...") tab = await browser.create_tab("") # Test valid request with correct command response = await tab.send_command(command="Page.enable") check_response_dictionary(response, {"result": {}}) # Test invalid method name should return error response = await tab.send_command(command="dkadklqwmd") assert "error" in response # Test int method should return error with pytest.raises( errors.MessageTypeError, ): await tab.send_command(command=12345) @pytest.mark.asyncio async def test_subscribe_once(browser): _logger.info("testing...") tab = await browser.create_tab("") subscription_result = tab.subscribe_once("Page.*") _ = await tab.send_command("Page.enable") _ = await tab.send_command("Page.reload") _ = await subscription_result assert not subscription_result.exception() @pytest.mark.asyncio async def test_subscribe_and_unsubscribe(browser): _logger.info("testing...") tab = await browser.create_tab("") counter = 0 old_counter = counter async def count_event(_r): nonlocal counter counter += 1 tab.subscribe("Page.*", count_event) assert "Page.*" in next(iter(tab.sessions.values())).subscriptions await tab.send_command("Page.enable") await tab.send_command("Page.reload") await asyncio.sleep(0.15) assert counter > old_counter tab.unsubscribe("Page.*") old_counter = counter assert "Page.*" not in next(iter(tab.sessions.values())).subscriptions await tab.send_command("Page.enable") await tab.send_command("Page.reload") assert old_counter == counter plotly-choreographer-4159eaf/uv.lock000066400000000000000000004343721525262030500176240ustar00rootroot00000000000000version = 1 revision = 3 requires-python = ">=3.9" resolution-markers = [ "python_full_version >= '3.14'", "python_full_version >= '3.11' and python_full_version < '3.14'", "python_full_version == '3.10.*'", "python_full_version < '3.10'", ] [[package]] name = "async-timeout" version = "5.0.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, ] [[package]] name = "backports-asyncio-runner" version = "1.2.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, ] [[package]] name = "choreographer" source = { editable = "." } dependencies = [ { name = "logistro" }, { name = "platformdirs", version = "4.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "platformdirs", version = "4.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "simplejson" }, ] [package.dev-dependencies] dev = [ { name = "async-timeout" }, { name = "mypy" }, { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "numpy", version = "2.3.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "poethepoet" }, { name = "pyright" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-xdist" }, { name = "types-simplejson" }, { name = "typing-extensions" }, ] [package.metadata] requires-dist = [ { name = "logistro", specifier = ">=2.0.1" }, { name = "platformdirs", specifier = ">=4.3.6" }, { name = "simplejson", specifier = ">=3.19.3" }, ] [package.metadata.requires-dev] dev = [ { name = "async-timeout" }, { name = "mypy", specifier = ">=1.14.1" }, { name = "numpy", marker = "python_full_version < '3.11'" }, { name = "numpy", marker = "python_full_version >= '3.11'", specifier = ">=2.3.3" }, { name = "poethepoet", specifier = ">=0.30.0" }, { name = "pyright", specifier = ">=1.1.406" }, { name = "pytest" }, { name = "pytest-asyncio", marker = "python_full_version < '3.14'" }, { name = "pytest-asyncio", marker = "python_full_version >= '3.14'", specifier = ">=1.2.0" }, { name = "pytest-xdist" }, { name = "types-simplejson", specifier = ">=3.19.0.20241221" }, { name = "typing-extensions", specifier = ">=4.13.2" }, ] [[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 = "exceptiongroup" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, ] [[package]] name = "execnet" version = "2.1.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/bb/ff/b4c0dc78fbe20c3e59c0c7334de0c27eb4001a2b2017999af398bf730817/execnet-2.1.1.tar.gz", hash = "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3", size = 166524, upload-time = "2024-04-08T09:04:19.245Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/43/09/2aea36ff60d16dd8879bdb2f5b3ee0ba8d08cbbdcdfe870e695ce3784385/execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc", size = 40612, upload-time = "2024-04-08T09:04:17.414Z" }, ] [[package]] name = "iniconfig" version = "2.1.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.10'", ] sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, ] [[package]] name = "iniconfig" version = "2.3.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.14'", "python_full_version >= '3.11' and python_full_version < '3.14'", "python_full_version == '3.10.*'", ] 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 = "logistro" version = "2.0.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/08/90/bfd7a6fab22bdfafe48ed3c4831713cb77b4779d18ade5e248d5dbc0ca22/logistro-2.0.1.tar.gz", hash = "sha256:8446affc82bab2577eb02bfcbcae196ae03129287557287b6a070f70c1985047", size = 8398, upload-time = "2025-11-01T02:41:18.81Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/6aa79ba3570bddd1bf7e951c6123f806751e58e8cce736bad77b2cf348d7/logistro-2.0.1-py3-none-any.whl", hash = "sha256:06ffa127b9fb4ac8b1972ae6b2a9d7fde57598bf5939cd708f43ec5bba2d31eb", size = 8555, upload-time = "2025-11-01T02:41:17.587Z" }, ] [[package]] name = "mypy" version = "1.18.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mypy-extensions" }, { name = "pathspec" }, { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c0/77/8f0d0001ffad290cef2f7f216f96c814866248a0b92a722365ed54648e7e/mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b", size = 3448846, upload-time = "2025-09-19T00:11:10.519Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/03/6f/657961a0743cff32e6c0611b63ff1c1970a0b482ace35b069203bf705187/mypy-1.18.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c1eab0cf6294dafe397c261a75f96dc2c31bffe3b944faa24db5def4e2b0f77c", size = 12807973, upload-time = "2025-09-19T00:10:35.282Z" }, { url = "https://files.pythonhosted.org/packages/10/e9/420822d4f661f13ca8900f5fa239b40ee3be8b62b32f3357df9a3045a08b/mypy-1.18.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7a780ca61fc239e4865968ebc5240bb3bf610ef59ac398de9a7421b54e4a207e", size = 11896527, upload-time = "2025-09-19T00:10:55.791Z" }, { url = "https://files.pythonhosted.org/packages/aa/73/a05b2bbaa7005f4642fcfe40fb73f2b4fb6bb44229bd585b5878e9a87ef8/mypy-1.18.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:448acd386266989ef11662ce3c8011fd2a7b632e0ec7d61a98edd8e27472225b", size = 12507004, upload-time = "2025-09-19T00:11:05.411Z" }, { url = "https://files.pythonhosted.org/packages/4f/01/f6e4b9f0d031c11ccbd6f17da26564f3a0f3c4155af344006434b0a05a9d/mypy-1.18.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9e171c465ad3901dc652643ee4bffa8e9fef4d7d0eece23b428908c77a76a66", size = 13245947, upload-time = "2025-09-19T00:10:46.923Z" }, { url = "https://files.pythonhosted.org/packages/d7/97/19727e7499bfa1ae0773d06afd30ac66a58ed7437d940c70548634b24185/mypy-1.18.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:592ec214750bc00741af1f80cbf96b5013d81486b7bb24cb052382c19e40b428", size = 13499217, upload-time = "2025-09-19T00:09:39.472Z" }, { url = "https://files.pythonhosted.org/packages/9f/4f/90dc8c15c1441bf31cf0f9918bb077e452618708199e530f4cbd5cede6ff/mypy-1.18.2-cp310-cp310-win_amd64.whl", hash = "sha256:7fb95f97199ea11769ebe3638c29b550b5221e997c63b14ef93d2e971606ebed", size = 9766753, upload-time = "2025-09-19T00:10:49.161Z" }, { url = "https://files.pythonhosted.org/packages/88/87/cafd3ae563f88f94eec33f35ff722d043e09832ea8530ef149ec1efbaf08/mypy-1.18.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:807d9315ab9d464125aa9fcf6d84fde6e1dc67da0b6f80e7405506b8ac72bc7f", size = 12731198, upload-time = "2025-09-19T00:09:44.857Z" }, { url = "https://files.pythonhosted.org/packages/0f/e0/1e96c3d4266a06d4b0197ace5356d67d937d8358e2ee3ffac71faa843724/mypy-1.18.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:776bb00de1778caf4db739c6e83919c1d85a448f71979b6a0edd774ea8399341", size = 11817879, upload-time = "2025-09-19T00:09:47.131Z" }, { url = "https://files.pythonhosted.org/packages/72/ef/0c9ba89eb03453e76bdac5a78b08260a848c7bfc5d6603634774d9cd9525/mypy-1.18.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1379451880512ffce14505493bd9fe469e0697543717298242574882cf8cdb8d", size = 12427292, upload-time = "2025-09-19T00:10:22.472Z" }, { url = "https://files.pythonhosted.org/packages/1a/52/ec4a061dd599eb8179d5411d99775bec2a20542505988f40fc2fee781068/mypy-1.18.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1331eb7fd110d60c24999893320967594ff84c38ac6d19e0a76c5fd809a84c86", size = 13163750, upload-time = "2025-09-19T00:09:51.472Z" }, { url = "https://files.pythonhosted.org/packages/c4/5f/2cf2ceb3b36372d51568f2208c021870fe7834cf3186b653ac6446511839/mypy-1.18.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ca30b50a51e7ba93b00422e486cbb124f1c56a535e20eff7b2d6ab72b3b2e37", size = 13351827, upload-time = "2025-09-19T00:09:58.311Z" }, { url = "https://files.pythonhosted.org/packages/c8/7d/2697b930179e7277529eaaec1513f8de622818696857f689e4a5432e5e27/mypy-1.18.2-cp311-cp311-win_amd64.whl", hash = "sha256:664dc726e67fa54e14536f6e1224bcfce1d9e5ac02426d2326e2bb4e081d1ce8", size = 9757983, upload-time = "2025-09-19T00:10:09.071Z" }, { url = "https://files.pythonhosted.org/packages/07/06/dfdd2bc60c66611dd8335f463818514733bc763e4760dee289dcc33df709/mypy-1.18.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33eca32dd124b29400c31d7cf784e795b050ace0e1f91b8dc035672725617e34", size = 12908273, upload-time = "2025-09-19T00:10:58.321Z" }, { url = "https://files.pythonhosted.org/packages/81/14/6a9de6d13a122d5608e1a04130724caf9170333ac5a924e10f670687d3eb/mypy-1.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a3c47adf30d65e89b2dcd2fa32f3aeb5e94ca970d2c15fcb25e297871c8e4764", size = 11920910, upload-time = "2025-09-19T00:10:20.043Z" }, { url = "https://files.pythonhosted.org/packages/5f/a9/b29de53e42f18e8cc547e38daa9dfa132ffdc64f7250e353f5c8cdd44bee/mypy-1.18.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d6c838e831a062f5f29d11c9057c6009f60cb294fea33a98422688181fe2893", size = 12465585, upload-time = "2025-09-19T00:10:33.005Z" }, { url = "https://files.pythonhosted.org/packages/77/ae/6c3d2c7c61ff21f2bee938c917616c92ebf852f015fb55917fd6e2811db2/mypy-1.18.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01199871b6110a2ce984bde85acd481232d17413868c9807e95c1b0739a58914", size = 13348562, upload-time = "2025-09-19T00:10:11.51Z" }, { url = "https://files.pythonhosted.org/packages/4d/31/aec68ab3b4aebdf8f36d191b0685d99faa899ab990753ca0fee60fb99511/mypy-1.18.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a2afc0fa0b0e91b4599ddfe0f91e2c26c2b5a5ab263737e998d6817874c5f7c8", size = 13533296, upload-time = "2025-09-19T00:10:06.568Z" }, { url = "https://files.pythonhosted.org/packages/9f/83/abcb3ad9478fca3ebeb6a5358bb0b22c95ea42b43b7789c7fb1297ca44f4/mypy-1.18.2-cp312-cp312-win_amd64.whl", hash = "sha256:d8068d0afe682c7c4897c0f7ce84ea77f6de953262b12d07038f4d296d547074", size = 9828828, upload-time = "2025-09-19T00:10:28.203Z" }, { url = "https://files.pythonhosted.org/packages/5f/04/7f462e6fbba87a72bc8097b93f6842499c428a6ff0c81dd46948d175afe8/mypy-1.18.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:07b8b0f580ca6d289e69209ec9d3911b4a26e5abfde32228a288eb79df129fcc", size = 12898728, upload-time = "2025-09-19T00:10:01.33Z" }, { url = "https://files.pythonhosted.org/packages/99/5b/61ed4efb64f1871b41fd0b82d29a64640f3516078f6c7905b68ab1ad8b13/mypy-1.18.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ed4482847168439651d3feee5833ccedbf6657e964572706a2adb1f7fa4dfe2e", size = 11910758, upload-time = "2025-09-19T00:10:42.607Z" }, { url = "https://files.pythonhosted.org/packages/3c/46/d297d4b683cc89a6e4108c4250a6a6b717f5fa96e1a30a7944a6da44da35/mypy-1.18.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3ad2afadd1e9fea5cf99a45a822346971ede8685cc581ed9cd4d42eaf940986", size = 12475342, upload-time = "2025-09-19T00:11:00.371Z" }, { url = "https://files.pythonhosted.org/packages/83/45/4798f4d00df13eae3bfdf726c9244bcb495ab5bd588c0eed93a2f2dd67f3/mypy-1.18.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a431a6f1ef14cf8c144c6b14793a23ec4eae3db28277c358136e79d7d062f62d", size = 13338709, upload-time = "2025-09-19T00:11:03.358Z" }, { url = "https://files.pythonhosted.org/packages/d7/09/479f7358d9625172521a87a9271ddd2441e1dab16a09708f056e97007207/mypy-1.18.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ab28cc197f1dd77a67e1c6f35cd1f8e8b73ed2217e4fc005f9e6a504e46e7ba", size = 13529806, upload-time = "2025-09-19T00:10:26.073Z" }, { url = "https://files.pythonhosted.org/packages/71/cf/ac0f2c7e9d0ea3c75cd99dff7aec1c9df4a1376537cb90e4c882267ee7e9/mypy-1.18.2-cp313-cp313-win_amd64.whl", hash = "sha256:0e2785a84b34a72ba55fb5daf079a1003a34c05b22238da94fcae2bbe46f3544", size = 9833262, upload-time = "2025-09-19T00:10:40.035Z" }, { url = "https://files.pythonhosted.org/packages/5a/0c/7d5300883da16f0063ae53996358758b2a2df2a09c72a5061fa79a1f5006/mypy-1.18.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:62f0e1e988ad41c2a110edde6c398383a889d95b36b3e60bcf155f5164c4fdce", size = 12893775, upload-time = "2025-09-19T00:10:03.814Z" }, { url = "https://files.pythonhosted.org/packages/50/df/2cffbf25737bdb236f60c973edf62e3e7b4ee1c25b6878629e88e2cde967/mypy-1.18.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8795a039bab805ff0c1dfdb8cd3344642c2b99b8e439d057aba30850b8d3423d", size = 11936852, upload-time = "2025-09-19T00:10:51.631Z" }, { url = "https://files.pythonhosted.org/packages/be/50/34059de13dd269227fb4a03be1faee6e2a4b04a2051c82ac0a0b5a773c9a/mypy-1.18.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ca1e64b24a700ab5ce10133f7ccd956a04715463d30498e64ea8715236f9c9c", size = 12480242, upload-time = "2025-09-19T00:11:07.955Z" }, { url = "https://files.pythonhosted.org/packages/5b/11/040983fad5132d85914c874a2836252bbc57832065548885b5bb5b0d4359/mypy-1.18.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d924eef3795cc89fecf6bedc6ed32b33ac13e8321344f6ddbf8ee89f706c05cb", size = 13326683, upload-time = "2025-09-19T00:09:55.572Z" }, { url = "https://files.pythonhosted.org/packages/e9/ba/89b2901dd77414dd7a8c8729985832a5735053be15b744c18e4586e506ef/mypy-1.18.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20c02215a080e3a2be3aa50506c67242df1c151eaba0dcbc1e4e557922a26075", size = 13514749, upload-time = "2025-09-19T00:10:44.827Z" }, { url = "https://files.pythonhosted.org/packages/25/bc/cc98767cffd6b2928ba680f3e5bc969c4152bf7c2d83f92f5a504b92b0eb/mypy-1.18.2-cp314-cp314-win_amd64.whl", hash = "sha256:749b5f83198f1ca64345603118a6f01a4e99ad4bf9d103ddc5a3200cc4614adf", size = 9982959, upload-time = "2025-09-19T00:10:37.344Z" }, { url = "https://files.pythonhosted.org/packages/3f/a6/490ff491d8ecddf8ab91762d4f67635040202f76a44171420bcbe38ceee5/mypy-1.18.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:25a9c8fb67b00599f839cf472713f54249a62efd53a54b565eb61956a7e3296b", size = 12807230, upload-time = "2025-09-19T00:09:49.471Z" }, { url = "https://files.pythonhosted.org/packages/eb/2e/60076fc829645d167ece9e80db9e8375648d210dab44cc98beb5b322a826/mypy-1.18.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c2b9c7e284ee20e7598d6f42e13ca40b4928e6957ed6813d1ab6348aa3f47133", size = 11895666, upload-time = "2025-09-19T00:10:53.678Z" }, { url = "https://files.pythonhosted.org/packages/97/4a/1e2880a2a5dda4dc8d9ecd1a7e7606bc0b0e14813637eeda40c38624e037/mypy-1.18.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6985ed057513e344e43a26cc1cd815c7a94602fb6a3130a34798625bc2f07b6", size = 12499608, upload-time = "2025-09-19T00:09:36.204Z" }, { url = "https://files.pythonhosted.org/packages/00/81/a117f1b73a3015b076b20246b1f341c34a578ebd9662848c6b80ad5c4138/mypy-1.18.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22f27105f1525ec024b5c630c0b9f36d5c1cc4d447d61fe51ff4bd60633f47ac", size = 13244551, upload-time = "2025-09-19T00:10:17.531Z" }, { url = "https://files.pythonhosted.org/packages/9b/61/b9f48e1714ce87c7bf0358eb93f60663740ebb08f9ea886ffc670cea7933/mypy-1.18.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:030c52d0ea8144e721e49b1f68391e39553d7451f0c3f8a7565b59e19fcb608b", size = 13491552, upload-time = "2025-09-19T00:10:13.753Z" }, { url = "https://files.pythonhosted.org/packages/c9/66/b2c0af3b684fa80d1b27501a8bdd3d2daa467ea3992a8aa612f5ca17c2db/mypy-1.18.2-cp39-cp39-win_amd64.whl", hash = "sha256:aa5e07ac1a60a253445797e42b8b2963c9675563a94f11291ab40718b016a7a0", size = 9765635, upload-time = "2025-09-19T00:10:30.993Z" }, { url = "https://files.pythonhosted.org/packages/87/e3/be76d87158ebafa0309946c4a73831974d4d6ab4f4ef40c3b53a385a66fd/mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e", size = 2352367, upload-time = "2025-09-19T00:10:15.489Z" }, ] [[package]] name = "mypy-extensions" version = "1.1.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] [[package]] name = "nodeenv" version = "1.9.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, ] [[package]] name = "numpy" version = "2.0.2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.10'", ] sdist = { url = "https://files.pythonhosted.org/packages/a9/75/10dd1f8116a8b796cb2c737b674e02d02e80454bda953fa7e65d8c12b016/numpy-2.0.2.tar.gz", hash = "sha256:883c987dee1880e2a864ab0dc9892292582510604156762362d9326444636e78", size = 18902015, upload-time = "2024-08-26T20:19:40.945Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/21/91/3495b3237510f79f5d81f2508f9f13fea78ebfdf07538fc7444badda173d/numpy-2.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:51129a29dbe56f9ca83438b706e2e69a39892b5eda6cedcb6b0c9fdc9b0d3ece", size = 21165245, upload-time = "2024-08-26T20:04:14.625Z" }, { url = "https://files.pythonhosted.org/packages/05/33/26178c7d437a87082d11019292dce6d3fe6f0e9026b7b2309cbf3e489b1d/numpy-2.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f15975dfec0cf2239224d80e32c3170b1d168335eaedee69da84fbe9f1f9cd04", size = 13738540, upload-time = "2024-08-26T20:04:36.784Z" }, { url = "https://files.pythonhosted.org/packages/ec/31/cc46e13bf07644efc7a4bf68df2df5fb2a1a88d0cd0da9ddc84dc0033e51/numpy-2.0.2-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:8c5713284ce4e282544c68d1c3b2c7161d38c256d2eefc93c1d683cf47683e66", size = 5300623, upload-time = "2024-08-26T20:04:46.491Z" }, { url = "https://files.pythonhosted.org/packages/6e/16/7bfcebf27bb4f9d7ec67332ffebee4d1bf085c84246552d52dbb548600e7/numpy-2.0.2-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:becfae3ddd30736fe1889a37f1f580e245ba79a5855bff5f2a29cb3ccc22dd7b", size = 6901774, upload-time = "2024-08-26T20:04:58.173Z" }, { url = "https://files.pythonhosted.org/packages/f9/a3/561c531c0e8bf082c5bef509d00d56f82e0ea7e1e3e3a7fc8fa78742a6e5/numpy-2.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2da5960c3cf0df7eafefd806d4e612c5e19358de82cb3c343631188991566ccd", size = 13907081, upload-time = "2024-08-26T20:05:19.098Z" }, { url = "https://files.pythonhosted.org/packages/fa/66/f7177ab331876200ac7563a580140643d1179c8b4b6a6b0fc9838de2a9b8/numpy-2.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:496f71341824ed9f3d2fd36cf3ac57ae2e0165c143b55c3a035ee219413f3318", size = 19523451, upload-time = "2024-08-26T20:05:47.479Z" }, { url = "https://files.pythonhosted.org/packages/25/7f/0b209498009ad6453e4efc2c65bcdf0ae08a182b2b7877d7ab38a92dc542/numpy-2.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a61ec659f68ae254e4d237816e33171497e978140353c0c2038d46e63282d0c8", size = 19927572, upload-time = "2024-08-26T20:06:17.137Z" }, { url = "https://files.pythonhosted.org/packages/3e/df/2619393b1e1b565cd2d4c4403bdd979621e2c4dea1f8532754b2598ed63b/numpy-2.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d731a1c6116ba289c1e9ee714b08a8ff882944d4ad631fd411106a30f083c326", size = 14400722, upload-time = "2024-08-26T20:06:39.16Z" }, { url = "https://files.pythonhosted.org/packages/22/ad/77e921b9f256d5da36424ffb711ae79ca3f451ff8489eeca544d0701d74a/numpy-2.0.2-cp310-cp310-win32.whl", hash = "sha256:984d96121c9f9616cd33fbd0618b7f08e0cfc9600a7ee1d6fd9b239186d19d97", size = 6472170, upload-time = "2024-08-26T20:06:50.361Z" }, { url = "https://files.pythonhosted.org/packages/10/05/3442317535028bc29cf0c0dd4c191a4481e8376e9f0db6bcf29703cadae6/numpy-2.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:c7b0be4ef08607dd04da4092faee0b86607f111d5ae68036f16cc787e250a131", size = 15905558, upload-time = "2024-08-26T20:07:13.881Z" }, { url = "https://files.pythonhosted.org/packages/8b/cf/034500fb83041aa0286e0fb16e7c76e5c8b67c0711bb6e9e9737a717d5fe/numpy-2.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:49ca4decb342d66018b01932139c0961a8f9ddc7589611158cb3c27cbcf76448", size = 21169137, upload-time = "2024-08-26T20:07:45.345Z" }, { url = "https://files.pythonhosted.org/packages/4a/d9/32de45561811a4b87fbdee23b5797394e3d1504b4a7cf40c10199848893e/numpy-2.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:11a76c372d1d37437857280aa142086476136a8c0f373b2e648ab2c8f18fb195", size = 13703552, upload-time = "2024-08-26T20:08:06.666Z" }, { url = "https://files.pythonhosted.org/packages/c1/ca/2f384720020c7b244d22508cb7ab23d95f179fcfff33c31a6eeba8d6c512/numpy-2.0.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:807ec44583fd708a21d4a11d94aedf2f4f3c3719035c76a2bbe1fe8e217bdc57", size = 5298957, upload-time = "2024-08-26T20:08:15.83Z" }, { url = "https://files.pythonhosted.org/packages/0e/78/a3e4f9fb6aa4e6fdca0c5428e8ba039408514388cf62d89651aade838269/numpy-2.0.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8cafab480740e22f8d833acefed5cc87ce276f4ece12fdaa2e8903db2f82897a", size = 6905573, upload-time = "2024-08-26T20:08:27.185Z" }, { url = "https://files.pythonhosted.org/packages/a0/72/cfc3a1beb2caf4efc9d0b38a15fe34025230da27e1c08cc2eb9bfb1c7231/numpy-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a15f476a45e6e5a3a79d8a14e62161d27ad897381fecfa4a09ed5322f2085669", size = 13914330, upload-time = "2024-08-26T20:08:48.058Z" }, { url = "https://files.pythonhosted.org/packages/ba/a8/c17acf65a931ce551fee11b72e8de63bf7e8a6f0e21add4c937c83563538/numpy-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13e689d772146140a252c3a28501da66dfecd77490b498b168b501835041f951", size = 19534895, upload-time = "2024-08-26T20:09:16.536Z" }, { url = "https://files.pythonhosted.org/packages/ba/86/8767f3d54f6ae0165749f84648da9dcc8cd78ab65d415494962c86fac80f/numpy-2.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9ea91dfb7c3d1c56a0e55657c0afb38cf1eeae4544c208dc465c3c9f3a7c09f9", size = 19937253, upload-time = "2024-08-26T20:09:46.263Z" }, { url = "https://files.pythonhosted.org/packages/df/87/f76450e6e1c14e5bb1eae6836478b1028e096fd02e85c1c37674606ab752/numpy-2.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c1c9307701fec8f3f7a1e6711f9089c06e6284b3afbbcd259f7791282d660a15", size = 14414074, upload-time = "2024-08-26T20:10:08.483Z" }, { url = "https://files.pythonhosted.org/packages/5c/ca/0f0f328e1e59f73754f06e1adfb909de43726d4f24c6a3f8805f34f2b0fa/numpy-2.0.2-cp311-cp311-win32.whl", hash = "sha256:a392a68bd329eafac5817e5aefeb39038c48b671afd242710b451e76090e81f4", size = 6470640, upload-time = "2024-08-26T20:10:19.732Z" }, { url = "https://files.pythonhosted.org/packages/eb/57/3a3f14d3a759dcf9bf6e9eda905794726b758819df4663f217d658a58695/numpy-2.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:286cd40ce2b7d652a6f22efdfc6d1edf879440e53e76a75955bc0c826c7e64dc", size = 15910230, upload-time = "2024-08-26T20:10:43.413Z" }, { url = "https://files.pythonhosted.org/packages/45/40/2e117be60ec50d98fa08c2f8c48e09b3edea93cfcabd5a9ff6925d54b1c2/numpy-2.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:df55d490dea7934f330006d0f81e8551ba6010a5bf035a249ef61a94f21c500b", size = 20895803, upload-time = "2024-08-26T20:11:13.916Z" }, { url = "https://files.pythonhosted.org/packages/46/92/1b8b8dee833f53cef3e0a3f69b2374467789e0bb7399689582314df02651/numpy-2.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8df823f570d9adf0978347d1f926b2a867d5608f434a7cff7f7908c6570dcf5e", size = 13471835, upload-time = "2024-08-26T20:11:34.779Z" }, { url = "https://files.pythonhosted.org/packages/7f/19/e2793bde475f1edaea6945be141aef6c8b4c669b90c90a300a8954d08f0a/numpy-2.0.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9a92ae5c14811e390f3767053ff54eaee3bf84576d99a2456391401323f4ec2c", size = 5038499, upload-time = "2024-08-26T20:11:43.902Z" }, { url = "https://files.pythonhosted.org/packages/e3/ff/ddf6dac2ff0dd50a7327bcdba45cb0264d0e96bb44d33324853f781a8f3c/numpy-2.0.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:a842d573724391493a97a62ebbb8e731f8a5dcc5d285dfc99141ca15a3302d0c", size = 6633497, upload-time = "2024-08-26T20:11:55.09Z" }, { url = "https://files.pythonhosted.org/packages/72/21/67f36eac8e2d2cd652a2e69595a54128297cdcb1ff3931cfc87838874bd4/numpy-2.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05e238064fc0610c840d1cf6a13bf63d7e391717d247f1bf0318172e759e692", size = 13621158, upload-time = "2024-08-26T20:12:14.95Z" }, { url = "https://files.pythonhosted.org/packages/39/68/e9f1126d757653496dbc096cb429014347a36b228f5a991dae2c6b6cfd40/numpy-2.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0123ffdaa88fa4ab64835dcbde75dcdf89c453c922f18dced6e27c90d1d0ec5a", size = 19236173, upload-time = "2024-08-26T20:12:44.049Z" }, { url = "https://files.pythonhosted.org/packages/d1/e9/1f5333281e4ebf483ba1c888b1d61ba7e78d7e910fdd8e6499667041cc35/numpy-2.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:96a55f64139912d61de9137f11bf39a55ec8faec288c75a54f93dfd39f7eb40c", size = 19634174, upload-time = "2024-08-26T20:13:13.634Z" }, { url = "https://files.pythonhosted.org/packages/71/af/a469674070c8d8408384e3012e064299f7a2de540738a8e414dcfd639996/numpy-2.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec9852fb39354b5a45a80bdab5ac02dd02b15f44b3804e9f00c556bf24b4bded", size = 14099701, upload-time = "2024-08-26T20:13:34.851Z" }, { url = "https://files.pythonhosted.org/packages/d0/3d/08ea9f239d0e0e939b6ca52ad403c84a2bce1bde301a8eb4888c1c1543f1/numpy-2.0.2-cp312-cp312-win32.whl", hash = "sha256:671bec6496f83202ed2d3c8fdc486a8fc86942f2e69ff0e986140339a63bcbe5", size = 6174313, upload-time = "2024-08-26T20:13:45.653Z" }, { url = "https://files.pythonhosted.org/packages/b2/b5/4ac39baebf1fdb2e72585c8352c56d063b6126be9fc95bd2bb5ef5770c20/numpy-2.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:cfd41e13fdc257aa5778496b8caa5e856dc4896d4ccf01841daee1d96465467a", size = 15606179, upload-time = "2024-08-26T20:14:08.786Z" }, { url = "https://files.pythonhosted.org/packages/43/c1/41c8f6df3162b0c6ffd4437d729115704bd43363de0090c7f913cfbc2d89/numpy-2.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9059e10581ce4093f735ed23f3b9d283b9d517ff46009ddd485f1747eb22653c", size = 21169942, upload-time = "2024-08-26T20:14:40.108Z" }, { url = "https://files.pythonhosted.org/packages/39/bc/fd298f308dcd232b56a4031fd6ddf11c43f9917fbc937e53762f7b5a3bb1/numpy-2.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:423e89b23490805d2a5a96fe40ec507407b8ee786d66f7328be214f9679df6dd", size = 13711512, upload-time = "2024-08-26T20:15:00.985Z" }, { url = "https://files.pythonhosted.org/packages/96/ff/06d1aa3eeb1c614eda245c1ba4fb88c483bee6520d361641331872ac4b82/numpy-2.0.2-cp39-cp39-macosx_14_0_arm64.whl", hash = "sha256:2b2955fa6f11907cf7a70dab0d0755159bca87755e831e47932367fc8f2f2d0b", size = 5306976, upload-time = "2024-08-26T20:15:10.876Z" }, { url = "https://files.pythonhosted.org/packages/2d/98/121996dcfb10a6087a05e54453e28e58694a7db62c5a5a29cee14c6e047b/numpy-2.0.2-cp39-cp39-macosx_14_0_x86_64.whl", hash = "sha256:97032a27bd9d8988b9a97a8c4d2c9f2c15a81f61e2f21404d7e8ef00cb5be729", size = 6906494, upload-time = "2024-08-26T20:15:22.055Z" }, { url = "https://files.pythonhosted.org/packages/15/31/9dffc70da6b9bbf7968f6551967fc21156207366272c2a40b4ed6008dc9b/numpy-2.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e795a8be3ddbac43274f18588329c72939870a16cae810c2b73461c40718ab1", size = 13912596, upload-time = "2024-08-26T20:15:42.452Z" }, { url = "https://files.pythonhosted.org/packages/b9/14/78635daab4b07c0930c919d451b8bf8c164774e6a3413aed04a6d95758ce/numpy-2.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b258c385842546006213344c50655ff1555a9338e2e5e02a0756dc3e803dd", size = 19526099, upload-time = "2024-08-26T20:16:11.048Z" }, { url = "https://files.pythonhosted.org/packages/26/4c/0eeca4614003077f68bfe7aac8b7496f04221865b3a5e7cb230c9d055afd/numpy-2.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5fec9451a7789926bcf7c2b8d187292c9f93ea30284802a0ab3f5be8ab36865d", size = 19932823, upload-time = "2024-08-26T20:16:40.171Z" }, { url = "https://files.pythonhosted.org/packages/f1/46/ea25b98b13dccaebddf1a803f8c748680d972e00507cd9bc6dcdb5aa2ac1/numpy-2.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9189427407d88ff25ecf8f12469d4d39d35bee1db5d39fc5c168c6f088a6956d", size = 14404424, upload-time = "2024-08-26T20:17:02.604Z" }, { url = "https://files.pythonhosted.org/packages/c8/a6/177dd88d95ecf07e722d21008b1b40e681a929eb9e329684d449c36586b2/numpy-2.0.2-cp39-cp39-win32.whl", hash = "sha256:905d16e0c60200656500c95b6b8dca5d109e23cb24abc701d41c02d74c6b3afa", size = 6476809, upload-time = "2024-08-26T20:17:13.553Z" }, { url = "https://files.pythonhosted.org/packages/ea/2b/7fc9f4e7ae5b507c1a3a21f0f15ed03e794c1242ea8a242ac158beb56034/numpy-2.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:a3f4ab0caa7f053f6797fcd4e1e25caee367db3112ef2b6ef82d749530768c73", size = 15911314, upload-time = "2024-08-26T20:17:36.72Z" }, { url = "https://files.pythonhosted.org/packages/8f/3b/df5a870ac6a3be3a86856ce195ef42eec7ae50d2a202be1f5a4b3b340e14/numpy-2.0.2-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7f0a0c6f12e07fa94133c8a67404322845220c06a9e80e85999afe727f7438b8", size = 21025288, upload-time = "2024-08-26T20:18:07.732Z" }, { url = "https://files.pythonhosted.org/packages/2c/97/51af92f18d6f6f2d9ad8b482a99fb74e142d71372da5d834b3a2747a446e/numpy-2.0.2-pp39-pypy39_pp73-macosx_14_0_x86_64.whl", hash = "sha256:312950fdd060354350ed123c0e25a71327d3711584beaef30cdaa93320c392d4", size = 6762793, upload-time = "2024-08-26T20:18:19.125Z" }, { url = "https://files.pythonhosted.org/packages/12/46/de1fbd0c1b5ccaa7f9a005b66761533e2f6a3e560096682683a223631fe9/numpy-2.0.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26df23238872200f63518dd2aa984cfca675d82469535dc7162dc2ee52d9dd5c", size = 19334885, upload-time = "2024-08-26T20:18:47.237Z" }, { url = "https://files.pythonhosted.org/packages/cc/dc/d330a6faefd92b446ec0f0dfea4c3207bb1fef3c4771d19cf4543efd2c78/numpy-2.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a46288ec55ebbd58947d31d72be2c63cbf839f0a63b49cb755022310792a3385", size = 15828784, upload-time = "2024-08-26T20:19:11.19Z" }, ] [[package]] name = "numpy" version = "2.2.6" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version == '3.10.*'", ] sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" }, { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" }, { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" }, { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" }, { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" }, { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" }, { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" }, { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" }, { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" }, { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" }, { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" }, { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" }, { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" }, { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" }, { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" }, { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" }, { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" }, { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" }, { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" }, { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" }, { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" }, { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" }, { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" }, { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" }, { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" }, { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" }, { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" }, { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" }, { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" }, { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" }, { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" }, { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" }, { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" }, { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" }, { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" }, { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" }, { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" }, { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" }, { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" }, { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" }, { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" }, { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" }, { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" }, { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" }, ] [[package]] name = "numpy" version = "2.3.4" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.14'", "python_full_version >= '3.11' and python_full_version < '3.14'", ] sdist = { url = "https://files.pythonhosted.org/packages/b5/f4/098d2270d52b41f1bd7db9fc288aaa0400cb48c2a3e2af6fa365d9720947/numpy-2.3.4.tar.gz", hash = "sha256:a7d018bfedb375a8d979ac758b120ba846a7fe764911a64465fd87b8729f4a6a", size = 20582187, upload-time = "2025-10-15T16:18:11.77Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/60/e7/0e07379944aa8afb49a556a2b54587b828eb41dc9adc56fb7615b678ca53/numpy-2.3.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e78aecd2800b32e8347ce49316d3eaf04aed849cd5b38e0af39f829a4e59f5eb", size = 21259519, upload-time = "2025-10-15T16:15:19.012Z" }, { url = "https://files.pythonhosted.org/packages/d0/cb/5a69293561e8819b09e34ed9e873b9a82b5f2ade23dce4c51dc507f6cfe1/numpy-2.3.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7fd09cc5d65bda1e79432859c40978010622112e9194e581e3415a3eccc7f43f", size = 14452796, upload-time = "2025-10-15T16:15:23.094Z" }, { url = "https://files.pythonhosted.org/packages/e4/04/ff11611200acd602a1e5129e36cfd25bf01ad8e5cf927baf2e90236eb02e/numpy-2.3.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:1b219560ae2c1de48ead517d085bc2d05b9433f8e49d0955c82e8cd37bd7bf36", size = 5381639, upload-time = "2025-10-15T16:15:25.572Z" }, { url = "https://files.pythonhosted.org/packages/ea/77/e95c757a6fe7a48d28a009267408e8aa382630cc1ad1db7451b3bc21dbb4/numpy-2.3.4-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:bafa7d87d4c99752d07815ed7a2c0964f8ab311eb8168f41b910bd01d15b6032", size = 6914296, upload-time = "2025-10-15T16:15:27.079Z" }, { url = "https://files.pythonhosted.org/packages/a3/d2/137c7b6841c942124eae921279e5c41b1c34bab0e6fc60c7348e69afd165/numpy-2.3.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36dc13af226aeab72b7abad501d370d606326a0029b9f435eacb3b8c94b8a8b7", size = 14591904, upload-time = "2025-10-15T16:15:29.044Z" }, { url = "https://files.pythonhosted.org/packages/bb/32/67e3b0f07b0aba57a078c4ab777a9e8e6bc62f24fb53a2337f75f9691699/numpy-2.3.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7b2f9a18b5ff9824a6af80de4f37f4ec3c2aab05ef08f51c77a093f5b89adda", size = 16939602, upload-time = "2025-10-15T16:15:31.106Z" }, { url = "https://files.pythonhosted.org/packages/95/22/9639c30e32c93c4cee3ccdb4b09c2d0fbff4dcd06d36b357da06146530fb/numpy-2.3.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9984bd645a8db6ca15d850ff996856d8762c51a2239225288f08f9050ca240a0", size = 16372661, upload-time = "2025-10-15T16:15:33.546Z" }, { url = "https://files.pythonhosted.org/packages/12/e9/a685079529be2b0156ae0c11b13d6be647743095bb51d46589e95be88086/numpy-2.3.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:64c5825affc76942973a70acf438a8ab618dbd692b84cd5ec40a0a0509edc09a", size = 18884682, upload-time = "2025-10-15T16:15:36.105Z" }, { url = "https://files.pythonhosted.org/packages/cf/85/f6f00d019b0cc741e64b4e00ce865a57b6bed945d1bbeb1ccadbc647959b/numpy-2.3.4-cp311-cp311-win32.whl", hash = "sha256:ed759bf7a70342f7817d88376eb7142fab9fef8320d6019ef87fae05a99874e1", size = 6570076, upload-time = "2025-10-15T16:15:38.225Z" }, { url = "https://files.pythonhosted.org/packages/7d/10/f8850982021cb90e2ec31990291f9e830ce7d94eef432b15066e7cbe0bec/numpy-2.3.4-cp311-cp311-win_amd64.whl", hash = "sha256:faba246fb30ea2a526c2e9645f61612341de1a83fb1e0c5edf4ddda5a9c10996", size = 13089358, upload-time = "2025-10-15T16:15:40.404Z" }, { url = "https://files.pythonhosted.org/packages/d1/ad/afdd8351385edf0b3445f9e24210a9c3971ef4de8fd85155462fc4321d79/numpy-2.3.4-cp311-cp311-win_arm64.whl", hash = "sha256:4c01835e718bcebe80394fd0ac66c07cbb90147ebbdad3dcecd3f25de2ae7e2c", size = 10462292, upload-time = "2025-10-15T16:15:42.896Z" }, { url = "https://files.pythonhosted.org/packages/96/7a/02420400b736f84317e759291b8edaeee9dc921f72b045475a9cbdb26b17/numpy-2.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ef1b5a3e808bc40827b5fa2c8196151a4c5abe110e1726949d7abddfe5c7ae11", size = 20957727, upload-time = "2025-10-15T16:15:44.9Z" }, { url = "https://files.pythonhosted.org/packages/18/90/a014805d627aa5750f6f0e878172afb6454552da929144b3c07fcae1bb13/numpy-2.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c2f91f496a87235c6aaf6d3f3d89b17dba64996abadccb289f48456cff931ca9", size = 14187262, upload-time = "2025-10-15T16:15:47.761Z" }, { url = "https://files.pythonhosted.org/packages/c7/e4/0a94b09abe89e500dc748e7515f21a13e30c5c3fe3396e6d4ac108c25fca/numpy-2.3.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:f77e5b3d3da652b474cc80a14084927a5e86a5eccf54ca8ca5cbd697bf7f2667", size = 5115992, upload-time = "2025-10-15T16:15:50.144Z" }, { url = "https://files.pythonhosted.org/packages/88/dd/db77c75b055c6157cbd4f9c92c4458daef0dd9cbe6d8d2fe7f803cb64c37/numpy-2.3.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:8ab1c5f5ee40d6e01cbe96de5863e39b215a4d24e7d007cad56c7184fdf4aeef", size = 6648672, upload-time = "2025-10-15T16:15:52.442Z" }, { url = "https://files.pythonhosted.org/packages/e1/e6/e31b0d713719610e406c0ea3ae0d90760465b086da8783e2fd835ad59027/numpy-2.3.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77b84453f3adcb994ddbd0d1c5d11db2d6bda1a2b7fd5ac5bd4649d6f5dc682e", size = 14284156, upload-time = "2025-10-15T16:15:54.351Z" }, { url = "https://files.pythonhosted.org/packages/f9/58/30a85127bfee6f108282107caf8e06a1f0cc997cb6b52cdee699276fcce4/numpy-2.3.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4121c5beb58a7f9e6dfdee612cb24f4df5cd4db6e8261d7f4d7450a997a65d6a", size = 16641271, upload-time = "2025-10-15T16:15:56.67Z" }, { url = "https://files.pythonhosted.org/packages/06/f2/2e06a0f2adf23e3ae29283ad96959267938d0efd20a2e25353b70065bfec/numpy-2.3.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:65611ecbb00ac9846efe04db15cbe6186f562f6bb7e5e05f077e53a599225d16", size = 16059531, upload-time = "2025-10-15T16:15:59.412Z" }, { url = "https://files.pythonhosted.org/packages/b0/e7/b106253c7c0d5dc352b9c8fab91afd76a93950998167fa3e5afe4ef3a18f/numpy-2.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dabc42f9c6577bcc13001b8810d300fe814b4cfbe8a92c873f269484594f9786", size = 18578983, upload-time = "2025-10-15T16:16:01.804Z" }, { url = "https://files.pythonhosted.org/packages/73/e3/04ecc41e71462276ee867ccbef26a4448638eadecf1bc56772c9ed6d0255/numpy-2.3.4-cp312-cp312-win32.whl", hash = "sha256:a49d797192a8d950ca59ee2d0337a4d804f713bb5c3c50e8db26d49666e351dc", size = 6291380, upload-time = "2025-10-15T16:16:03.938Z" }, { url = "https://files.pythonhosted.org/packages/3d/a8/566578b10d8d0e9955b1b6cd5db4e9d4592dd0026a941ff7994cedda030a/numpy-2.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:985f1e46358f06c2a09921e8921e2c98168ed4ae12ccd6e5e87a4f1857923f32", size = 12787999, upload-time = "2025-10-15T16:16:05.801Z" }, { url = "https://files.pythonhosted.org/packages/58/22/9c903a957d0a8071b607f5b1bff0761d6e608b9a965945411f867d515db1/numpy-2.3.4-cp312-cp312-win_arm64.whl", hash = "sha256:4635239814149e06e2cb9db3dd584b2fa64316c96f10656983b8026a82e6e4db", size = 10197412, upload-time = "2025-10-15T16:16:07.854Z" }, { url = "https://files.pythonhosted.org/packages/57/7e/b72610cc91edf138bc588df5150957a4937221ca6058b825b4725c27be62/numpy-2.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c090d4860032b857d94144d1a9976b8e36709e40386db289aaf6672de2a81966", size = 20950335, upload-time = "2025-10-15T16:16:10.304Z" }, { url = "https://files.pythonhosted.org/packages/3e/46/bdd3370dcea2f95ef14af79dbf81e6927102ddf1cc54adc0024d61252fd9/numpy-2.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a13fc473b6db0be619e45f11f9e81260f7302f8d180c49a22b6e6120022596b3", size = 14179878, upload-time = "2025-10-15T16:16:12.595Z" }, { url = "https://files.pythonhosted.org/packages/ac/01/5a67cb785bda60f45415d09c2bc245433f1c68dd82eef9c9002c508b5a65/numpy-2.3.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:3634093d0b428e6c32c3a69b78e554f0cd20ee420dcad5a9f3b2a63762ce4197", size = 5108673, upload-time = "2025-10-15T16:16:14.877Z" }, { url = "https://files.pythonhosted.org/packages/c2/cd/8428e23a9fcebd33988f4cb61208fda832800ca03781f471f3727a820704/numpy-2.3.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:043885b4f7e6e232d7df4f51ffdef8c36320ee9d5f227b380ea636722c7ed12e", size = 6641438, upload-time = "2025-10-15T16:16:16.805Z" }, { url = "https://files.pythonhosted.org/packages/3e/d1/913fe563820f3c6b079f992458f7331278dcd7ba8427e8e745af37ddb44f/numpy-2.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4ee6a571d1e4f0ea6d5f22d6e5fbd6ed1dc2b18542848e1e7301bd190500c9d7", size = 14281290, upload-time = "2025-10-15T16:16:18.764Z" }, { url = "https://files.pythonhosted.org/packages/9e/7e/7d306ff7cb143e6d975cfa7eb98a93e73495c4deabb7d1b5ecf09ea0fd69/numpy-2.3.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc8a63918b04b8571789688b2780ab2b4a33ab44bfe8ccea36d3eba51228c953", size = 16636543, upload-time = "2025-10-15T16:16:21.072Z" }, { url = "https://files.pythonhosted.org/packages/47/6a/8cfc486237e56ccfb0db234945552a557ca266f022d281a2f577b98e955c/numpy-2.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:40cc556d5abbc54aabe2b1ae287042d7bdb80c08edede19f0c0afb36ae586f37", size = 16056117, upload-time = "2025-10-15T16:16:23.369Z" }, { url = "https://files.pythonhosted.org/packages/b1/0e/42cb5e69ea901e06ce24bfcc4b5664a56f950a70efdcf221f30d9615f3f3/numpy-2.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ecb63014bb7f4ce653f8be7f1df8cbc6093a5a2811211770f6606cc92b5a78fd", size = 18577788, upload-time = "2025-10-15T16:16:27.496Z" }, { url = "https://files.pythonhosted.org/packages/86/92/41c3d5157d3177559ef0a35da50f0cda7fa071f4ba2306dd36818591a5bc/numpy-2.3.4-cp313-cp313-win32.whl", hash = "sha256:e8370eb6925bb8c1c4264fec52b0384b44f675f191df91cbe0140ec9f0955646", size = 6282620, upload-time = "2025-10-15T16:16:29.811Z" }, { url = "https://files.pythonhosted.org/packages/09/97/fd421e8bc50766665ad35536c2bb4ef916533ba1fdd053a62d96cc7c8b95/numpy-2.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:56209416e81a7893036eea03abcb91c130643eb14233b2515c90dcac963fe99d", size = 12784672, upload-time = "2025-10-15T16:16:31.589Z" }, { url = "https://files.pythonhosted.org/packages/ad/df/5474fb2f74970ca8eb978093969b125a84cc3d30e47f82191f981f13a8a0/numpy-2.3.4-cp313-cp313-win_arm64.whl", hash = "sha256:a700a4031bc0fd6936e78a752eefb79092cecad2599ea9c8039c548bc097f9bc", size = 10196702, upload-time = "2025-10-15T16:16:33.902Z" }, { url = "https://files.pythonhosted.org/packages/11/83/66ac031464ec1767ea3ed48ce40f615eb441072945e98693bec0bcd056cc/numpy-2.3.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:86966db35c4040fdca64f0816a1c1dd8dbd027d90fca5a57e00e1ca4cd41b879", size = 21049003, upload-time = "2025-10-15T16:16:36.101Z" }, { url = "https://files.pythonhosted.org/packages/5f/99/5b14e0e686e61371659a1d5bebd04596b1d72227ce36eed121bb0aeab798/numpy-2.3.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:838f045478638b26c375ee96ea89464d38428c69170360b23a1a50fa4baa3562", size = 14302980, upload-time = "2025-10-15T16:16:39.124Z" }, { url = "https://files.pythonhosted.org/packages/2c/44/e9486649cd087d9fc6920e3fc3ac2aba10838d10804b1e179fb7cbc4e634/numpy-2.3.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d7315ed1dab0286adca467377c8381cd748f3dc92235f22a7dfc42745644a96a", size = 5231472, upload-time = "2025-10-15T16:16:41.168Z" }, { url = "https://files.pythonhosted.org/packages/3e/51/902b24fa8887e5fe2063fd61b1895a476d0bbf46811ab0c7fdf4bd127345/numpy-2.3.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:84f01a4d18b2cc4ade1814a08e5f3c907b079c847051d720fad15ce37aa930b6", size = 6739342, upload-time = "2025-10-15T16:16:43.777Z" }, { url = "https://files.pythonhosted.org/packages/34/f1/4de9586d05b1962acdcdb1dc4af6646361a643f8c864cef7c852bf509740/numpy-2.3.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:817e719a868f0dacde4abdfc5c1910b301877970195db9ab6a5e2c4bd5b121f7", size = 14354338, upload-time = "2025-10-15T16:16:46.081Z" }, { url = "https://files.pythonhosted.org/packages/1f/06/1c16103b425de7969d5a76bdf5ada0804b476fed05d5f9e17b777f1cbefd/numpy-2.3.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85e071da78d92a214212cacea81c6da557cab307f2c34b5f85b628e94803f9c0", size = 16702392, upload-time = "2025-10-15T16:16:48.455Z" }, { url = "https://files.pythonhosted.org/packages/34/b2/65f4dc1b89b5322093572b6e55161bb42e3e0487067af73627f795cc9d47/numpy-2.3.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2ec646892819370cf3558f518797f16597b4e4669894a2ba712caccc9da53f1f", size = 16134998, upload-time = "2025-10-15T16:16:51.114Z" }, { url = "https://files.pythonhosted.org/packages/d4/11/94ec578896cdb973aaf56425d6c7f2aff4186a5c00fac15ff2ec46998b46/numpy-2.3.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:035796aaaddfe2f9664b9a9372f089cfc88bd795a67bd1bfe15e6e770934cf64", size = 18651574, upload-time = "2025-10-15T16:16:53.429Z" }, { url = "https://files.pythonhosted.org/packages/62/b7/7efa763ab33dbccf56dade36938a77345ce8e8192d6b39e470ca25ff3cd0/numpy-2.3.4-cp313-cp313t-win32.whl", hash = "sha256:fea80f4f4cf83b54c3a051f2f727870ee51e22f0248d3114b8e755d160b38cfb", size = 6413135, upload-time = "2025-10-15T16:16:55.992Z" }, { url = "https://files.pythonhosted.org/packages/43/70/aba4c38e8400abcc2f345e13d972fb36c26409b3e644366db7649015f291/numpy-2.3.4-cp313-cp313t-win_amd64.whl", hash = "sha256:15eea9f306b98e0be91eb344a94c0e630689ef302e10c2ce5f7e11905c704f9c", size = 12928582, upload-time = "2025-10-15T16:16:57.943Z" }, { url = "https://files.pythonhosted.org/packages/67/63/871fad5f0073fc00fbbdd7232962ea1ac40eeaae2bba66c76214f7954236/numpy-2.3.4-cp313-cp313t-win_arm64.whl", hash = "sha256:b6c231c9c2fadbae4011ca5e7e83e12dc4a5072f1a1d85a0a7b3ed754d145a40", size = 10266691, upload-time = "2025-10-15T16:17:00.048Z" }, { url = "https://files.pythonhosted.org/packages/72/71/ae6170143c115732470ae3a2d01512870dd16e0953f8a6dc89525696069b/numpy-2.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:81c3e6d8c97295a7360d367f9f8553973651b76907988bb6066376bc2252f24e", size = 20955580, upload-time = "2025-10-15T16:17:02.509Z" }, { url = "https://files.pythonhosted.org/packages/af/39/4be9222ffd6ca8a30eda033d5f753276a9c3426c397bb137d8e19dedd200/numpy-2.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7c26b0b2bf58009ed1f38a641f3db4be8d960a417ca96d14e5b06df1506d41ff", size = 14188056, upload-time = "2025-10-15T16:17:04.873Z" }, { url = "https://files.pythonhosted.org/packages/6c/3d/d85f6700d0a4aa4f9491030e1021c2b2b7421b2b38d01acd16734a2bfdc7/numpy-2.3.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:62b2198c438058a20b6704351b35a1d7db881812d8512d67a69c9de1f18ca05f", size = 5116555, upload-time = "2025-10-15T16:17:07.499Z" }, { url = "https://files.pythonhosted.org/packages/bf/04/82c1467d86f47eee8a19a464c92f90a9bb68ccf14a54c5224d7031241ffb/numpy-2.3.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:9d729d60f8d53a7361707f4b68a9663c968882dd4f09e0d58c044c8bf5faee7b", size = 6643581, upload-time = "2025-10-15T16:17:09.774Z" }, { url = "https://files.pythonhosted.org/packages/0c/d3/c79841741b837e293f48bd7db89d0ac7a4f2503b382b78a790ef1dc778a5/numpy-2.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd0c630cf256b0a7fd9d0a11c9413b42fef5101219ce6ed5a09624f5a65392c7", size = 14299186, upload-time = "2025-10-15T16:17:11.937Z" }, { url = "https://files.pythonhosted.org/packages/e8/7e/4a14a769741fbf237eec5a12a2cbc7a4c4e061852b6533bcb9e9a796c908/numpy-2.3.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5e081bc082825f8b139f9e9fe42942cb4054524598aaeb177ff476cc76d09d2", size = 16638601, upload-time = "2025-10-15T16:17:14.391Z" }, { url = "https://files.pythonhosted.org/packages/93/87/1c1de269f002ff0a41173fe01dcc925f4ecff59264cd8f96cf3b60d12c9b/numpy-2.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15fb27364ed84114438fff8aaf998c9e19adbeba08c0b75409f8c452a8692c52", size = 16074219, upload-time = "2025-10-15T16:17:17.058Z" }, { url = "https://files.pythonhosted.org/packages/cd/28/18f72ee77408e40a76d691001ae599e712ca2a47ddd2c4f695b16c65f077/numpy-2.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:85d9fb2d8cd998c84d13a79a09cc0c1091648e848e4e6249b0ccd7f6b487fa26", size = 18576702, upload-time = "2025-10-15T16:17:19.379Z" }, { url = "https://files.pythonhosted.org/packages/c3/76/95650169b465ececa8cf4b2e8f6df255d4bf662775e797ade2025cc51ae6/numpy-2.3.4-cp314-cp314-win32.whl", hash = "sha256:e73d63fd04e3a9d6bc187f5455d81abfad05660b212c8804bf3b407e984cd2bc", size = 6337136, upload-time = "2025-10-15T16:17:22.886Z" }, { url = "https://files.pythonhosted.org/packages/dc/89/a231a5c43ede5d6f77ba4a91e915a87dea4aeea76560ba4d2bf185c683f0/numpy-2.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:3da3491cee49cf16157e70f607c03a217ea6647b1cea4819c4f48e53d49139b9", size = 12920542, upload-time = "2025-10-15T16:17:24.783Z" }, { url = "https://files.pythonhosted.org/packages/0d/0c/ae9434a888f717c5ed2ff2393b3f344f0ff6f1c793519fa0c540461dc530/numpy-2.3.4-cp314-cp314-win_arm64.whl", hash = "sha256:6d9cd732068e8288dbe2717177320723ccec4fb064123f0caf9bbd90ab5be868", size = 10480213, upload-time = "2025-10-15T16:17:26.935Z" }, { url = "https://files.pythonhosted.org/packages/83/4b/c4a5f0841f92536f6b9592694a5b5f68c9ab37b775ff342649eadf9055d3/numpy-2.3.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:22758999b256b595cf0b1d102b133bb61866ba5ceecf15f759623b64c020c9ec", size = 21052280, upload-time = "2025-10-15T16:17:29.638Z" }, { url = "https://files.pythonhosted.org/packages/3e/80/90308845fc93b984d2cc96d83e2324ce8ad1fd6efea81b324cba4b673854/numpy-2.3.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9cb177bc55b010b19798dc5497d540dea67fd13a8d9e882b2dae71de0cf09eb3", size = 14302930, upload-time = "2025-10-15T16:17:32.384Z" }, { url = "https://files.pythonhosted.org/packages/3d/4e/07439f22f2a3b247cec4d63a713faae55e1141a36e77fb212881f7cda3fb/numpy-2.3.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:0f2bcc76f1e05e5ab58893407c63d90b2029908fa41f9f1cc51eecce936c3365", size = 5231504, upload-time = "2025-10-15T16:17:34.515Z" }, { url = "https://files.pythonhosted.org/packages/ab/de/1e11f2547e2fe3d00482b19721855348b94ada8359aef5d40dd57bfae9df/numpy-2.3.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:8dc20bde86802df2ed8397a08d793da0ad7a5fd4ea3ac85d757bf5dd4ad7c252", size = 6739405, upload-time = "2025-10-15T16:17:36.128Z" }, { url = "https://files.pythonhosted.org/packages/3b/40/8cd57393a26cebe2e923005db5134a946c62fa56a1087dc7c478f3e30837/numpy-2.3.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e199c087e2aa71c8f9ce1cb7a8e10677dc12457e7cc1be4798632da37c3e86e", size = 14354866, upload-time = "2025-10-15T16:17:38.884Z" }, { url = "https://files.pythonhosted.org/packages/93/39/5b3510f023f96874ee6fea2e40dfa99313a00bf3ab779f3c92978f34aace/numpy-2.3.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85597b2d25ddf655495e2363fe044b0ae999b75bc4d630dc0d886484b03a5eb0", size = 16703296, upload-time = "2025-10-15T16:17:41.564Z" }, { url = "https://files.pythonhosted.org/packages/41/0d/19bb163617c8045209c1996c4e427bccbc4bbff1e2c711f39203c8ddbb4a/numpy-2.3.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:04a69abe45b49c5955923cf2c407843d1c85013b424ae8a560bba16c92fe44a0", size = 16136046, upload-time = "2025-10-15T16:17:43.901Z" }, { url = "https://files.pythonhosted.org/packages/e2/c1/6dba12fdf68b02a21ac411c9df19afa66bed2540f467150ca64d246b463d/numpy-2.3.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e1708fac43ef8b419c975926ce1eaf793b0c13b7356cfab6ab0dc34c0a02ac0f", size = 18652691, upload-time = "2025-10-15T16:17:46.247Z" }, { url = "https://files.pythonhosted.org/packages/f8/73/f85056701dbbbb910c51d846c58d29fd46b30eecd2b6ba760fc8b8a1641b/numpy-2.3.4-cp314-cp314t-win32.whl", hash = "sha256:863e3b5f4d9915aaf1b8ec79ae560ad21f0b8d5e3adc31e73126491bb86dee1d", size = 6485782, upload-time = "2025-10-15T16:17:48.872Z" }, { url = "https://files.pythonhosted.org/packages/17/90/28fa6f9865181cb817c2471ee65678afa8a7e2a1fb16141473d5fa6bacc3/numpy-2.3.4-cp314-cp314t-win_amd64.whl", hash = "sha256:962064de37b9aef801d33bc579690f8bfe6c5e70e29b61783f60bcba838a14d6", size = 13113301, upload-time = "2025-10-15T16:17:50.938Z" }, { url = "https://files.pythonhosted.org/packages/54/23/08c002201a8e7e1f9afba93b97deceb813252d9cfd0d3351caed123dcf97/numpy-2.3.4-cp314-cp314t-win_arm64.whl", hash = "sha256:8b5a9a39c45d852b62693d9b3f3e0fe052541f804296ff401a72a1b60edafb29", size = 10547532, upload-time = "2025-10-15T16:17:53.48Z" }, { url = "https://files.pythonhosted.org/packages/b1/b6/64898f51a86ec88ca1257a59c1d7fd077b60082a119affefcdf1dd0df8ca/numpy-2.3.4-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6e274603039f924c0fe5cb73438fa9246699c78a6df1bd3decef9ae592ae1c05", size = 21131552, upload-time = "2025-10-15T16:17:55.845Z" }, { url = "https://files.pythonhosted.org/packages/ce/4c/f135dc6ebe2b6a3c77f4e4838fa63d350f85c99462012306ada1bd4bc460/numpy-2.3.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d149aee5c72176d9ddbc6803aef9c0f6d2ceeea7626574fc68518da5476fa346", size = 14377796, upload-time = "2025-10-15T16:17:58.308Z" }, { url = "https://files.pythonhosted.org/packages/d0/a4/f33f9c23fcc13dd8412fc8614559b5b797e0aba9d8e01dfa8bae10c84004/numpy-2.3.4-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:6d34ed9db9e6395bb6cd33286035f73a59b058169733a9db9f85e650b88df37e", size = 5306904, upload-time = "2025-10-15T16:18:00.596Z" }, { url = "https://files.pythonhosted.org/packages/28/af/c44097f25f834360f9fb960fa082863e0bad14a42f36527b2a121abdec56/numpy-2.3.4-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:fdebe771ca06bb8d6abce84e51dca9f7921fe6ad34a0c914541b063e9a68928b", size = 6819682, upload-time = "2025-10-15T16:18:02.32Z" }, { url = "https://files.pythonhosted.org/packages/c5/8c/cd283b54c3c2b77e188f63e23039844f56b23bba1712318288c13fe86baf/numpy-2.3.4-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e92defe6c08211eb77902253b14fe5b480ebc5112bc741fd5e9cd0608f847", size = 14422300, upload-time = "2025-10-15T16:18:04.271Z" }, { url = "https://files.pythonhosted.org/packages/b0/f0/8404db5098d92446b3e3695cf41c6f0ecb703d701cb0b7566ee2177f2eee/numpy-2.3.4-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13b9062e4f5c7ee5c7e5be96f29ba71bc5a37fed3d1d77c37390ae00724d296d", size = 16760806, upload-time = "2025-10-15T16:18:06.668Z" }, { url = "https://files.pythonhosted.org/packages/95/8e/2844c3959ce9a63acc7c8e50881133d86666f0420bcde695e115ced0920f/numpy-2.3.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:81b3a59793523e552c4a96109dde028aa4448ae06ccac5a76ff6532a85558a7f", size = 12973130, upload-time = "2025-10-15T16:18:09.397Z" }, ] [[package]] name = "packaging" version = "25.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] [[package]] name = "pastel" version = "0.2.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/76/f1/4594f5e0fcddb6953e5b8fe00da8c317b8b41b547e2b3ae2da7512943c62/pastel-0.2.1.tar.gz", hash = "sha256:e6581ac04e973cac858828c6202c1e1e81fee1dc7de7683f3e1ffe0bfd8a573d", size = 7555, upload-time = "2020-09-16T19:21:12.43Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/aa/18/a8444036c6dd65ba3624c63b734d3ba95ba63ace513078e1580590075d21/pastel-0.2.1-py2.py3-none-any.whl", hash = "sha256:4349225fcdf6c2bb34d483e523475de5bb04a5c10ef711263452cb37d7dd4364", size = 5955, upload-time = "2020-09-16T19:21:11.409Z" }, ] [[package]] name = "pathspec" version = "0.12.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, ] [[package]] name = "platformdirs" version = "4.4.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.10'", ] sdist = { url = "https://files.pythonhosted.org/packages/23/e8/21db9c9987b0e728855bd57bff6984f67952bea55d6f75e055c46b5383e8/platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf", size = 21634, upload-time = "2025-08-26T14:32:04.268Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654, upload-time = "2025-08-26T14:32:02.735Z" }, ] [[package]] name = "platformdirs" version = "4.5.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.14'", "python_full_version >= '3.11' and python_full_version < '3.14'", "python_full_version == '3.10.*'", ] sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, ] [[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 = "poethepoet" version = "0.37.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pastel" }, { name = "pyyaml" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a5/f2/273fe54a78dc5c6c8dd63db71f5a6ceb95e4648516b5aeaeff4bde804e44/poethepoet-0.37.0.tar.gz", hash = "sha256:73edf458707c674a079baa46802e21455bda3a7f82a408e58c31b9f4fe8e933d", size = 68570, upload-time = "2025-08-11T18:00:29.103Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/92/1b/5337af1a6a478d25a3e3c56b9b4b42b0a160314e02f4a0498d5322c8dac4/poethepoet-0.37.0-py3-none-any.whl", hash = "sha256:861790276315abcc8df1b4bd60e28c3d48a06db273edd3092f3c94e1a46e5e22", size = 90062, upload-time = "2025-08-11T18:00:27.595Z" }, ] [[package]] name = "pygments" version = "2.19.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] [[package]] name = "pyright" version = "1.1.407" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nodeenv" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a6/1b/0aa08ee42948b61745ac5b5b5ccaec4669e8884b53d31c8ec20b2fcd6b6f/pyright-1.1.407.tar.gz", hash = "sha256:099674dba5c10489832d4a4b2d302636152a9a42d317986c38474c76fe562262", size = 4122872, upload-time = "2025-10-24T23:17:15.145Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/dc/93/b69052907d032b00c40cb656d21438ec00b3a471733de137a3f65a49a0a0/pyright-1.1.407-py3-none-any.whl", hash = "sha256:6dd419f54fcc13f03b52285796d65e639786373f433e243f8b94cf93a7444d21", size = 5997008, upload-time = "2025-10-24T23:17:13.159Z" }, ] [[package]] name = "pytest" version = "8.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "packaging" }, { name = "pluggy" }, { name = "pygments" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, ] [[package]] name = "pytest-asyncio" version = "1.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, { name = "pytest" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" }, ] [[package]] name = "pytest-xdist" version = "3.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "execnet" }, { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, ] [[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" }, { url = "https://files.pythonhosted.org/packages/9f/62/67fc8e68a75f738c9200422bf65693fb79a4cd0dc5b23310e5202e978090/pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da", size = 184450, upload-time = "2025-09-25T21:33:00.618Z" }, { url = "https://files.pythonhosted.org/packages/ae/92/861f152ce87c452b11b9d0977952259aa7df792d71c1053365cc7b09cc08/pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917", size = 174319, upload-time = "2025-09-25T21:33:02.086Z" }, { url = "https://files.pythonhosted.org/packages/d0/cd/f0cfc8c74f8a030017a2b9c771b7f47e5dd702c3e28e5b2071374bda2948/pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9", size = 737631, upload-time = "2025-09-25T21:33:03.25Z" }, { url = "https://files.pythonhosted.org/packages/ef/b2/18f2bd28cd2055a79a46c9b0895c0b3d987ce40ee471cecf58a1a0199805/pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5", size = 836795, upload-time = "2025-09-25T21:33:05.014Z" }, { url = "https://files.pythonhosted.org/packages/73/b9/793686b2d54b531203c160ef12bec60228a0109c79bae6c1277961026770/pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a", size = 750767, upload-time = "2025-09-25T21:33:06.398Z" }, { url = "https://files.pythonhosted.org/packages/a9/86/a137b39a611def2ed78b0e66ce2fe13ee701a07c07aebe55c340ed2a050e/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926", size = 727982, upload-time = "2025-09-25T21:33:08.708Z" }, { url = "https://files.pythonhosted.org/packages/dd/62/71c27c94f457cf4418ef8ccc71735324c549f7e3ea9d34aba50874563561/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7", size = 755677, upload-time = "2025-09-25T21:33:09.876Z" }, { url = "https://files.pythonhosted.org/packages/29/3d/6f5e0d58bd924fb0d06c3a6bad00effbdae2de5adb5cda5648006ffbd8d3/pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0", size = 142592, upload-time = "2025-09-25T21:33:10.983Z" }, { url = "https://files.pythonhosted.org/packages/f0/0c/25113e0b5e103d7f1490c0e947e303fe4a696c10b501dea7a9f49d4e876c/pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007", size = 158777, upload-time = "2025-09-25T21:33:15.55Z" }, ] [[package]] name = "simplejson" version = "3.20.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/41/f4/a1ac5ed32f7ed9a088d62a59d410d4c204b3b3815722e2ccfb491fa8251b/simplejson-3.20.2.tar.gz", hash = "sha256:5fe7a6ce14d1c300d80d08695b7f7e633de6cd72c80644021874d985b3393649", size = 85784, upload-time = "2025-09-26T16:29:36.64Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/78/09/2bf3761de89ea2d91bdce6cf107dcd858892d0adc22c995684878826cc6b/simplejson-3.20.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6d7286dc11af60a2f76eafb0c2acde2d997e87890e37e24590bb513bec9f1bc5", size = 94039, upload-time = "2025-09-26T16:27:29.283Z" }, { url = "https://files.pythonhosted.org/packages/0f/33/c3277db8931f0ae9e54b9292668863365672d90fb0f632f4cf9829cb7d68/simplejson-3.20.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c01379b4861c3b0aa40cba8d44f2b448f5743999aa68aaa5d3ef7049d4a28a2d", size = 75894, upload-time = "2025-09-26T16:27:30.378Z" }, { url = "https://files.pythonhosted.org/packages/fa/ea/ae47b04d03c7c8a7b7b1a8b39a6e27c3bd424e52f4988d70aca6293ff5e5/simplejson-3.20.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a16b029ca25645b3bc44e84a4f941efa51bf93c180b31bd704ce6349d1fc77c1", size = 76116, upload-time = "2025-09-26T16:27:31.42Z" }, { url = "https://files.pythonhosted.org/packages/4b/42/6c9af551e5a8d0f171d6dce3d9d1260068927f7b80f1f09834e07887c8c4/simplejson-3.20.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e22a5fb7b1437ffb057e02e1936a3bfb19084ae9d221ec5e9f4cf85f69946b6", size = 138827, upload-time = "2025-09-26T16:27:32.486Z" }, { url = "https://files.pythonhosted.org/packages/2b/22/5e268bbcbe9f75577491e406ec0a5536f5b2fa91a3b52031fea51cd83e1d/simplejson-3.20.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8b6ff02fc7b8555c906c24735908854819b0d0dc85883d453e23ca4c0445d01", size = 146772, upload-time = "2025-09-26T16:27:34.036Z" }, { url = "https://files.pythonhosted.org/packages/71/b4/800f14728e2ad666f420dfdb57697ca128aeae7f991b35759c09356b829a/simplejson-3.20.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2bfc1c396ad972ba4431130b42307b2321dba14d988580c1ac421ec6a6b7cee3", size = 134497, upload-time = "2025-09-26T16:27:35.211Z" }, { url = "https://files.pythonhosted.org/packages/c1/b9/c54eef4226c6ac8e9a389bbe5b21fef116768f97a2dc1a683c716ffe66ef/simplejson-3.20.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a97249ee1aee005d891b5a211faf58092a309f3d9d440bc269043b08f662eda", size = 138172, upload-time = "2025-09-26T16:27:36.44Z" }, { url = "https://files.pythonhosted.org/packages/09/36/4e282f5211b34620f1b2e4b51d9ddaab5af82219b9b7b78360a33f7e5387/simplejson-3.20.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f1036be00b5edaddbddbb89c0f80ed229714a941cfd21e51386dc69c237201c2", size = 140272, upload-time = "2025-09-26T16:27:37.605Z" }, { url = "https://files.pythonhosted.org/packages/aa/b0/94ad2cf32f477c449e1f63c863d8a513e2408d651c4e58fe4b6a7434e168/simplejson-3.20.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5d6f5bacb8cdee64946b45f2680afa3f54cd38e62471ceda89f777693aeca4e4", size = 140468, upload-time = "2025-09-26T16:27:39.015Z" }, { url = "https://files.pythonhosted.org/packages/e5/46/827731e4163be3f987cb8ee90f5d444161db8f540b5e735355faa098d9bc/simplejson-3.20.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8db6841fb796ec5af632f677abf21c6425a1ebea0d9ac3ef1a340b8dc69f52b8", size = 148700, upload-time = "2025-09-26T16:27:40.171Z" }, { url = "https://files.pythonhosted.org/packages/c7/28/c32121064b1ec2fb7b5d872d9a1abda62df064d35e0160eddfa907118343/simplejson-3.20.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c0a341f7cc2aae82ee2b31f8a827fd2e51d09626f8b3accc441a6907c88aedb7", size = 141323, upload-time = "2025-09-26T16:27:41.324Z" }, { url = "https://files.pythonhosted.org/packages/46/b6/c897c54326fe86dd12d101981171a49361949f4728294f418c3b86a1af77/simplejson-3.20.2-cp310-cp310-win32.whl", hash = "sha256:27f9c01a6bc581d32ab026f515226864576da05ef322d7fc141cd8a15a95ce53", size = 74377, upload-time = "2025-09-26T16:27:42.533Z" }, { url = "https://files.pythonhosted.org/packages/ad/87/a6e03d4d80cca99c1fee4e960f3440e2f21be9470e537970f960ca5547f1/simplejson-3.20.2-cp310-cp310-win_amd64.whl", hash = "sha256:c0a63ec98a4547ff366871bf832a7367ee43d047bcec0b07b66c794e2137b476", size = 76081, upload-time = "2025-09-26T16:27:43.945Z" }, { url = "https://files.pythonhosted.org/packages/b9/3e/96898c6c66d9dca3f9bd14d7487bf783b4acc77471b42f979babbb68d4ca/simplejson-3.20.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:06190b33cd7849efc413a5738d3da00b90e4a5382fd3d584c841ac20fb828c6f", size = 92633, upload-time = "2025-09-26T16:27:45.028Z" }, { url = "https://files.pythonhosted.org/packages/6b/a2/cd2e10b880368305d89dd540685b8bdcc136df2b3c76b5ddd72596254539/simplejson-3.20.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4ad4eac7d858947a30d2c404e61f16b84d16be79eb6fb316341885bdde864fa8", size = 75309, upload-time = "2025-09-26T16:27:46.142Z" }, { url = "https://files.pythonhosted.org/packages/5d/02/290f7282eaa6ebe945d35c47e6534348af97472446951dce0d144e013f4c/simplejson-3.20.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b392e11c6165d4a0fde41754a0e13e1d88a5ad782b245a973dd4b2bdb4e5076a", size = 75308, upload-time = "2025-09-26T16:27:47.542Z" }, { url = "https://files.pythonhosted.org/packages/43/91/43695f17b69e70c4b0b03247aa47fb3989d338a70c4b726bbdc2da184160/simplejson-3.20.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:51eccc4e353eed3c50e0ea2326173acdc05e58f0c110405920b989d481287e51", size = 143733, upload-time = "2025-09-26T16:27:48.673Z" }, { url = "https://files.pythonhosted.org/packages/9b/4b/fdcaf444ac1c3cbf1c52bf00320c499e1cf05d373a58a3731ae627ba5e2d/simplejson-3.20.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:306e83d7c331ad833d2d43c76a67f476c4b80c4a13334f6e34bb110e6105b3bd", size = 153397, upload-time = "2025-09-26T16:27:49.89Z" }, { url = "https://files.pythonhosted.org/packages/c4/83/21550f81a50cd03599f048a2d588ffb7f4c4d8064ae091511e8e5848eeaa/simplejson-3.20.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f820a6ac2ef0bc338ae4963f4f82ccebdb0824fe9caf6d660670c578abe01013", size = 141654, upload-time = "2025-09-26T16:27:51.168Z" }, { url = "https://files.pythonhosted.org/packages/cf/54/d76c0e72ad02450a3e723b65b04f49001d0e73218ef6a220b158a64639cb/simplejson-3.20.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21e7a066528a5451433eb3418184f05682ea0493d14e9aae690499b7e1eb6b81", size = 144913, upload-time = "2025-09-26T16:27:52.331Z" }, { url = "https://files.pythonhosted.org/packages/3f/49/976f59b42a6956d4aeb075ada16ad64448a985704bc69cd427a2245ce835/simplejson-3.20.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:438680ddde57ea87161a4824e8de04387b328ad51cfdf1eaf723623a3014b7aa", size = 144568, upload-time = "2025-09-26T16:27:53.41Z" }, { url = "https://files.pythonhosted.org/packages/60/c7/30bae30424ace8cd791ca660fed454ed9479233810fe25c3f3eab3d9dc7b/simplejson-3.20.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:cac78470ae68b8d8c41b6fca97f5bf8e024ca80d5878c7724e024540f5cdaadb", size = 146239, upload-time = "2025-09-26T16:27:54.502Z" }, { url = "https://files.pythonhosted.org/packages/79/3e/7f3b7b97351c53746e7b996fcd106986cda1954ab556fd665314756618d2/simplejson-3.20.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7524e19c2da5ef281860a3d74668050c6986be15c9dd99966034ba47c68828c2", size = 154497, upload-time = "2025-09-26T16:27:55.885Z" }, { url = "https://files.pythonhosted.org/packages/1d/48/7241daa91d0bf19126589f6a8dcbe8287f4ed3d734e76fd4a092708947be/simplejson-3.20.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e9b6d845a603b2eef3394eb5e21edb8626cd9ae9a8361d14e267eb969dbe413", size = 148069, upload-time = "2025-09-26T16:27:57.039Z" }, { url = "https://files.pythonhosted.org/packages/e6/f4/ef18d2962fe53e7be5123d3784e623859eec7ed97060c9c8536c69d34836/simplejson-3.20.2-cp311-cp311-win32.whl", hash = "sha256:47d8927e5ac927fdd34c99cc617938abb3624b06ff86e8e219740a86507eb961", size = 74158, upload-time = "2025-09-26T16:27:58.265Z" }, { url = "https://files.pythonhosted.org/packages/35/fd/3d1158ecdc573fdad81bf3cc78df04522bf3959758bba6597ba4c956c74d/simplejson-3.20.2-cp311-cp311-win_amd64.whl", hash = "sha256:ba4edf3be8e97e4713d06c3d302cba1ff5c49d16e9d24c209884ac1b8455520c", size = 75911, upload-time = "2025-09-26T16:27:59.292Z" }, { url = "https://files.pythonhosted.org/packages/9d/9e/1a91e7614db0416885eab4136d49b7303de20528860ffdd798ce04d054db/simplejson-3.20.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:4376d5acae0d1e91e78baeba4ee3cf22fbf6509d81539d01b94e0951d28ec2b6", size = 93523, upload-time = "2025-09-26T16:28:00.356Z" }, { url = "https://files.pythonhosted.org/packages/5e/2b/d2413f5218fc25608739e3d63fe321dfa85c5f097aa6648dbe72513a5f12/simplejson-3.20.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f8fe6de652fcddae6dec8f281cc1e77e4e8f3575249e1800090aab48f73b4259", size = 75844, upload-time = "2025-09-26T16:28:01.756Z" }, { url = "https://files.pythonhosted.org/packages/ad/f1/efd09efcc1e26629e120fef59be059ce7841cc6e1f949a4db94f1ae8a918/simplejson-3.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:25ca2663d99328d51e5a138f22018e54c9162438d831e26cfc3458688616eca8", size = 75655, upload-time = "2025-09-26T16:28:03.037Z" }, { url = "https://files.pythonhosted.org/packages/97/ec/5c6db08e42f380f005d03944be1af1a6bd501cc641175429a1cbe7fb23b9/simplejson-3.20.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:12a6b2816b6cab6c3fd273d43b1948bc9acf708272074c8858f579c394f4cbc9", size = 150335, upload-time = "2025-09-26T16:28:05.027Z" }, { url = "https://files.pythonhosted.org/packages/81/f5/808a907485876a9242ec67054da7cbebefe0ee1522ef1c0be3bfc90f96f6/simplejson-3.20.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac20dc3fcdfc7b8415bfc3d7d51beccd8695c3f4acb7f74e3a3b538e76672868", size = 158519, upload-time = "2025-09-26T16:28:06.5Z" }, { url = "https://files.pythonhosted.org/packages/66/af/b8a158246834645ea890c36136584b0cc1c0e4b83a73b11ebd9c2a12877c/simplejson-3.20.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:db0804d04564e70862ef807f3e1ace2cc212ef0e22deb1b3d6f80c45e5882c6b", size = 148571, upload-time = "2025-09-26T16:28:07.715Z" }, { url = "https://files.pythonhosted.org/packages/20/05/ed9b2571bbf38f1a2425391f18e3ac11cb1e91482c22d644a1640dea9da7/simplejson-3.20.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:979ce23ea663895ae39106946ef3d78527822d918a136dbc77b9e2b7f006237e", size = 152367, upload-time = "2025-09-26T16:28:08.921Z" }, { url = "https://files.pythonhosted.org/packages/81/2c/bad68b05dd43e93f77994b920505634d31ed239418eb6a88997d06599983/simplejson-3.20.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a2ba921b047bb029805726800819675249ef25d2f65fd0edb90639c5b1c3033c", size = 150205, upload-time = "2025-09-26T16:28:10.086Z" }, { url = "https://files.pythonhosted.org/packages/69/46/90c7fc878061adafcf298ce60cecdee17a027486e9dce507e87396d68255/simplejson-3.20.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:12d3d4dc33770069b780cc8f5abef909fe4a3f071f18f55f6d896a370fd0f970", size = 151823, upload-time = "2025-09-26T16:28:11.329Z" }, { url = "https://files.pythonhosted.org/packages/ab/27/b85b03349f825ae0f5d4f780cdde0bbccd4f06c3d8433f6a3882df887481/simplejson-3.20.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aff032a59a201b3683a34be1169e71ddda683d9c3b43b261599c12055349251e", size = 158997, upload-time = "2025-09-26T16:28:12.917Z" }, { url = "https://files.pythonhosted.org/packages/71/ad/d7f3c331fb930638420ac6d236db68e9f4c28dab9c03164c3cd0e7967e15/simplejson-3.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:30e590e133b06773f0dc9c3f82e567463df40598b660b5adf53eb1c488202544", size = 154367, upload-time = "2025-09-26T16:28:14.393Z" }, { url = "https://files.pythonhosted.org/packages/f0/46/5c67324addd40fa2966f6e886cacbbe0407c03a500db94fb8bb40333fcdf/simplejson-3.20.2-cp312-cp312-win32.whl", hash = "sha256:8d7be7c99939cc58e7c5bcf6bb52a842a58e6c65e1e9cdd2a94b697b24cddb54", size = 74285, upload-time = "2025-09-26T16:28:15.931Z" }, { url = "https://files.pythonhosted.org/packages/fa/c9/5cc2189f4acd3a6e30ffa9775bf09b354302dbebab713ca914d7134d0f29/simplejson-3.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:2c0b4a67e75b945489052af6590e7dca0ed473ead5d0f3aad61fa584afe814ab", size = 75969, upload-time = "2025-09-26T16:28:17.017Z" }, { url = "https://files.pythonhosted.org/packages/5e/9e/f326d43f6bf47f4e7704a4426c36e044c6bedfd24e072fb8e27589a373a5/simplejson-3.20.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:90d311ba8fcd733a3677e0be21804827226a57144130ba01c3c6a325e887dd86", size = 93530, upload-time = "2025-09-26T16:28:18.07Z" }, { url = "https://files.pythonhosted.org/packages/35/28/5a4b8f3483fbfb68f3f460bc002cef3a5735ef30950e7c4adce9c8da15c7/simplejson-3.20.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:feed6806f614bdf7f5cb6d0123cb0c1c5f40407ef103aa935cffaa694e2e0c74", size = 75846, upload-time = "2025-09-26T16:28:19.12Z" }, { url = "https://files.pythonhosted.org/packages/7a/4d/30dfef83b9ac48afae1cf1ab19c2867e27b8d22b5d9f8ca7ce5a0a157d8c/simplejson-3.20.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6b1d8d7c3e1a205c49e1aee6ba907dcb8ccea83651e6c3e2cb2062f1e52b0726", size = 75661, upload-time = "2025-09-26T16:28:20.219Z" }, { url = "https://files.pythonhosted.org/packages/09/1d/171009bd35c7099d72ef6afd4bb13527bab469965c968a17d69a203d62a6/simplejson-3.20.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:552f55745044a24c3cb7ec67e54234be56d5d6d0e054f2e4cf4fb3e297429be5", size = 150579, upload-time = "2025-09-26T16:28:21.337Z" }, { url = "https://files.pythonhosted.org/packages/61/ae/229bbcf90a702adc6bfa476e9f0a37e21d8c58e1059043038797cbe75b8c/simplejson-3.20.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2da97ac65165d66b0570c9e545786f0ac7b5de5854d3711a16cacbcaa8c472d", size = 158797, upload-time = "2025-09-26T16:28:22.53Z" }, { url = "https://files.pythonhosted.org/packages/90/c5/fefc0ac6b86b9108e302e0af1cf57518f46da0baedd60a12170791d56959/simplejson-3.20.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f59a12966daa356bf68927fca5a67bebac0033cd18b96de9c2d426cd11756cd0", size = 148851, upload-time = "2025-09-26T16:28:23.733Z" }, { url = "https://files.pythonhosted.org/packages/43/f1/b392952200f3393bb06fbc4dd975fc63a6843261705839355560b7264eb2/simplejson-3.20.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133ae2098a8e162c71da97cdab1f383afdd91373b7ff5fe65169b04167da976b", size = 152598, upload-time = "2025-09-26T16:28:24.962Z" }, { url = "https://files.pythonhosted.org/packages/f4/b4/d6b7279e52a3e9c0fa8c032ce6164e593e8d9cf390698ee981ed0864291b/simplejson-3.20.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7977640af7b7d5e6a852d26622057d428706a550f7f5083e7c4dd010a84d941f", size = 150498, upload-time = "2025-09-26T16:28:26.114Z" }, { url = "https://files.pythonhosted.org/packages/62/22/ec2490dd859224326d10c2fac1353e8ad5c84121be4837a6dd6638ba4345/simplejson-3.20.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b530ad6d55e71fa9e93e1109cf8182f427a6355848a4ffa09f69cc44e1512522", size = 152129, upload-time = "2025-09-26T16:28:27.552Z" }, { url = "https://files.pythonhosted.org/packages/33/ce/b60214d013e93dd9e5a705dcb2b88b6c72bada442a97f79828332217f3eb/simplejson-3.20.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:bd96a7d981bf64f0e42345584768da4435c05b24fd3c364663f5fbc8fabf82e3", size = 159359, upload-time = "2025-09-26T16:28:28.667Z" }, { url = "https://files.pythonhosted.org/packages/99/21/603709455827cdf5b9d83abe726343f542491ca8dc6a2528eb08de0cf034/simplejson-3.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f28ee755fadb426ba2e464d6fcf25d3f152a05eb6b38e0b4f790352f5540c769", size = 154717, upload-time = "2025-09-26T16:28:30.288Z" }, { url = "https://files.pythonhosted.org/packages/3c/f9/dc7f7a4bac16cf7eb55a4df03ad93190e11826d2a8950052949d3dfc11e2/simplejson-3.20.2-cp313-cp313-win32.whl", hash = "sha256:472785b52e48e3eed9b78b95e26a256f59bb1ee38339be3075dad799e2e1e661", size = 74289, upload-time = "2025-09-26T16:28:31.809Z" }, { url = "https://files.pythonhosted.org/packages/87/10/d42ad61230436735c68af1120622b28a782877146a83d714da7b6a2a1c4e/simplejson-3.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:a1a85013eb33e4820286139540accbe2c98d2da894b2dcefd280209db508e608", size = 75972, upload-time = "2025-09-26T16:28:32.883Z" }, { url = "https://files.pythonhosted.org/packages/b8/2d/7c4968c60ddc8b504b77301cc80d6e75cd0269b81a779b01d66d8f36dcb8/simplejson-3.20.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b3bf76512ccb07d47944ebdca44c65b781612d38b9098566b4bb40f713fc4047", size = 94039, upload-time = "2025-09-26T16:29:17.406Z" }, { url = "https://files.pythonhosted.org/packages/e8/e4/d96b56fb87f245240b514c1fe552e76c17e09f0faa1f61137b2296f81529/simplejson-3.20.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:214e26acf2dfb9ff3314e65c4e168a6b125bced0e2d99a65ea7b0f169db1e562", size = 75893, upload-time = "2025-09-26T16:29:18.534Z" }, { url = "https://files.pythonhosted.org/packages/09/4f/be411eeb52ab21d6d4c00722b632dd2bd430c01a47dfed3c15ef5ad7ee6e/simplejson-3.20.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2fb1259ca9c385b0395bad59cdbf79535a5a84fb1988f339a49bfbc57455a35a", size = 76104, upload-time = "2025-09-26T16:29:19.66Z" }, { url = "https://files.pythonhosted.org/packages/66/6f/3bd0007b64881a90a058c59a4869b1b4f130ddb86a726f884fafc67e5ef7/simplejson-3.20.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c34e028a2ba8553a208ded1da5fa8501833875078c4c00a50dffc33622057881", size = 138261, upload-time = "2025-09-26T16:29:20.822Z" }, { url = "https://files.pythonhosted.org/packages/15/5d/b6d0b71508e503c759a0a7563cb2c28716ec8af9828ca9f5b59023011406/simplejson-3.20.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b538f9d9e503b0dd43af60496780cb50755e4d8e5b34e5647b887675c1ae9fee", size = 146397, upload-time = "2025-09-26T16:29:22.363Z" }, { url = "https://files.pythonhosted.org/packages/19/24/40b3e5a3ca5e6f80cc1c639fcd5565ae087e72e8656dea780f02302ddc97/simplejson-3.20.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ab998e416ded6c58f549a22b6a8847e75a9e1ef98eb9fbb2863e1f9e61a4105b", size = 134020, upload-time = "2025-09-26T16:29:23.615Z" }, { url = "https://files.pythonhosted.org/packages/b9/8c/8fc2c2734ac9e514124635b25ca8f7e347db1ded4a30417ee41e78e6d61c/simplejson-3.20.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a8f1c307edf5fbf0c6db3396c5d3471409c4a40c7a2a466fbc762f20d46601a", size = 137598, upload-time = "2025-09-26T16:29:24.835Z" }, { url = "https://files.pythonhosted.org/packages/2e/d9/15036d7f43c6208fb0fbc827f9f897c1f577fba02aeb7a8a223581da4925/simplejson-3.20.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5a7bbac80bdb82a44303f5630baee140aee208e5a4618e8b9fde3fc400a42671", size = 139770, upload-time = "2025-09-26T16:29:26.244Z" }, { url = "https://files.pythonhosted.org/packages/73/cc/18374fb9dfcb4827b692ca5a33bdb607384ca06cdb645e0b863022dae8a3/simplejson-3.20.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:5ef70ec8fe1569872e5a3e4720c1e1dcb823879a3c78bc02589eb88fab920b1f", size = 139884, upload-time = "2025-09-26T16:29:28.51Z" }, { url = "https://files.pythonhosted.org/packages/5c/a2/1526d4152806670124dd499ff831726a92bd7e029e8349c4affa78ea8845/simplejson-3.20.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:cb11c09c99253a74c36925d461c86ea25f0140f3b98ff678322734ddc0f038d7", size = 148166, upload-time = "2025-09-26T16:29:29.789Z" }, { url = "https://files.pythonhosted.org/packages/a4/77/fc16d41b5f67a2591c9b6ff7b0f6aed2b2aed1b6912bb346b61279697638/simplejson-3.20.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:66f7c78c6ef776f8bd9afaad455e88b8197a51e95617bcc44b50dd974a7825ba", size = 140778, upload-time = "2025-09-26T16:29:31.025Z" }, { url = "https://files.pythonhosted.org/packages/4a/97/a26ef6b7387349623c042f329df70a4f3baf3a365fe6d1154d73da1dcf5a/simplejson-3.20.2-cp39-cp39-win32.whl", hash = "sha256:619ada86bfe3a5aa02b8222ca6bfc5aa3e1075c1fb5b3263d24ba579382df472", size = 74339, upload-time = "2025-09-26T16:29:32.648Z" }, { url = "https://files.pythonhosted.org/packages/dc/b7/94c6049a99e3c04eed2064e91295370b7429e2361188e35a78df562312e0/simplejson-3.20.2-cp39-cp39-win_amd64.whl", hash = "sha256:44a6235e09ca5cc41aa5870a952489c06aa4aee3361ae46daa947d8398e57502", size = 76067, upload-time = "2025-09-26T16:29:34.184Z" }, { url = "https://files.pythonhosted.org/packages/05/5b/83e1ff87eb60ca706972f7e02e15c0b33396e7bdbd080069a5d1b53cf0d8/simplejson-3.20.2-py3-none-any.whl", hash = "sha256:3b6bb7fb96efd673eac2e4235200bfffdc2353ad12c54117e1e4e2fc485ac017", size = 57309, upload-time = "2025-09-26T16:29:35.312Z" }, ] [[package]] name = "tomli" version = "2.3.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" }, { url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" }, { url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" }, { url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" }, { url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" }, { url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" }, { url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" }, { url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" }, { url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" }, { url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" }, { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" }, { url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" }, { url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" }, { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" }, { url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" }, { url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" }, { url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" }, { url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" }, { url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" }, { url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" }, { url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" }, { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" }, { url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" }, { url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" }, { url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" }, { url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" }, { url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" }, { url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" }, { url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" }, { url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" }, { url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" }, { url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" }, { url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" }, { url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" }, { url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" }, { url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" }, { url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" }, { url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" }, { url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" }, { url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" }, { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, ] [[package]] name = "types-simplejson" version = "3.20.0.20250822" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/df/6b/96d43a90cd202bd552cdd871858a11c138fe5ef11aeb4ed8e8dc51389257/types_simplejson-3.20.0.20250822.tar.gz", hash = "sha256:2b0bfd57a6beed3b932fd2c3c7f8e2f48a7df3978c9bba43023a32b3741a95b0", size = 10608, upload-time = "2025-08-22T03:03:35.36Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/3c/9f/8e2c9e6aee9a2ff34f2ffce6ccd9c26edeef6dfd366fde611dc2e2c00ab9/types_simplejson-3.20.0.20250822-py3-none-any.whl", hash = "sha256:b5e63ae220ac7a1b0bb9af43b9cb8652237c947981b2708b0c776d3b5d8fa169", size = 10417, upload-time = "2025-08-22T03:03:34.485Z" }, ] [[package]] name = "typing-extensions" version = "4.15.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ]