diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..950a3beda --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,108 @@ +name: CI + +on: + workflow_dispatch: + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + python-version: ["3.9", "3.10", "3.11", "3.12"] + qt-version: ["qt5"] + + steps: + - uses: actions/checkout@v4 + + # Set up display for GUI testing on Linux + - name: Set up display (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y xvfb herbstluftwm + export DISPLAY=:99.0 + Xvfb :99 -screen 0 1400x900x24 -ac +extension GLX +render & + sleep 3 + herbstluftwm & + sleep 1 + env: + DISPLAY: :99.0 + + - name: Install uv + uses: astral-sh/setup-uv@v3 + with: + version: "latest" + + - name: Set up Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} + + - name: Install dependencies + run: uv sync --dev --extra ${{ matrix.qt-version }} + + - name: Install phylib from branch + run: | + if [ "${{ github.ref_name }}" = "master" ]; then + uv add "git+https://github.com/cortex-lab/phylib.git@master" + elif [ "${{ github.ref_name }}" = "dev" ]; then + uv add "git+https://github.com/cortex-lab/phylib.git@dev" + else + uv add "git+https://github.com/cortex-lab/phylib.git@${{ github.ref_name }}" + fi + shell: bash + + - name: Install additional test dependencies + run: | + uv add "git+https://github.com/kwikteam/klusta.git" + uv add "git+https://github.com/kwikteam/klustakwik2.git" + + - name: Lint with ruff + run: uv run ruff check phy + + - name: Check formatting with ruff + run: uv run ruff format --check phy + + - name: Test with pytest (Linux) + if: runner.os == 'Linux' + run: uv run make test-full + env: + DISPLAY: :99.0 + QT_QPA_PLATFORM: offscreen + + - name: Test with pytest (Windows/macOS) + if: runner.os != 'Linux' + run: uv run pytest --cov=phy --cov-report=xml phy + env: + QT_QPA_PLATFORM: offscreen + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + file: ./coverage.xml + fail_ci_if_error: false + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + build: + runs-on: ubuntu-latest + needs: test + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v3 + with: + version: "latest" + + - name: Set up Python + run: uv python install 3.9 + + - name: Build package + run: uv build + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml index 12f226ab0..c952b76e7 100644 --- a/.github/workflows/codespell.yml +++ b/.github/workflows/codespell.yml @@ -3,10 +3,7 @@ name: Codespell on: - push: - branches: [master] - pull_request: - branches: [master] + workflow_dispatch: permissions: contents: read diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index b0d620c77..115238a6c 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -1,9 +1,6 @@ name: Install and Test with Pip on: - pull_request: - push: - branches: [ "main", "master" ] workflow_dispatch: diff --git a/.gitignore b/.gitignore index 859487e4a..0330682f1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +local_tests contrib data doc diff --git a/.release-smoke/latest-testpypi-version.txt b/.release-smoke/latest-testpypi-version.txt new file mode 100644 index 000000000..70578a000 --- /dev/null +++ b/.release-smoke/latest-testpypi-version.txt @@ -0,0 +1 @@ +2.1.0rc1.dev3 diff --git a/MANIFEST.in b/MANIFEST.in index b5bb9ca52..caee74cfa 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,15 +1,9 @@ -include LICENSE +include LICENSE.md include README.md -include requirements.txt -include requirements-dev.txt -recursive-include tests * -recursive-include phy/electrode/probes *.prb recursive-include phy/plot/glsl *.vert *.frag *.glsl -recursive-include phy/plot/static *.npy *.gz *.txt -recursive-include phy/cluster/static *.html *.css +recursive-include phy/plot/static *.gz recursive-include phy/gui/static *.html *.css *.js *.ttf *.png -recursive-include phy/gui/static/icons *.html *.css *.js *.ttf *.png recursive-include phy/apps/*/static *.json recursive-exclude * __pycache__ recursive-exclude * *.py[co] diff --git a/Makefile b/Makefile index e095c074a..0844a2ea3 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,18 @@ +ifeq ($(OS),Windows_NT) +SHELL := C:/Program Files/Git/bin/bash.exe +.SHELLFLAGS := -lc +PYTHON_BIN ?= python +else +PYTHON_BIN ?= python3 +endif + +export PYTHON ?= $(PYTHON_BIN) + clean-build: rm -fr build/ rm -fr dist/ rm -fr *.egg-info + rm -fr .eggs/ clean-pyc: find . -name '*.pyc' -exec rm -f {} + @@ -9,29 +20,114 @@ clean-pyc: find . -name '*~' -exec rm -f {} + find . -name '__pycache__' -exec rm -fr {} + -clean: clean-build clean-pyc +clean-test: + rm -fr .tox/ + rm -f .coverage + rm -fr htmlcov/ + rm -fr .pytest_cache/ + rm -fr .ruff_cache/ + rm -fr .release-smoke/ + +clean: clean-build clean-pyc clean-test + +install: + uv sync --dev lint: - flake8 phy + uv run ruff check phy + +format: + uv run ruff format phy + +format-check: + uv run ruff format --check phy + +lint-fix: + uv run ruff check --fix phy -# Test everything except apps. -test: lint - py.test -xvv --cov-report= --cov=phy phy --ignore=phy/apps/ --cov-append - coverage report --omit */phy/apps/*,*/phy/plot/gloo/* +# Test everything except apps +test: lint format-check + uv run pytest -xvv --cov-report= --cov=phy phy --ignore=phy/apps/ --cov-append + uv run coverage report --omit "*/phy/apps/*,*/phy/plot/gloo/*" -# Test just the apps. +# Test just the apps test-apps: lint - py.test --cov-report term-missing --cov=phy.apps phy/apps/ --cov-append + uv run pytest --cov-report term-missing --cov=phy.apps phy/apps/ --cov-append -# Test everything. +# Test everything test-full: test test-apps - coverage report --omit */phy/plot/gloo/* + uv run coverage report --omit "*/phy/plot/gloo/*" + +test-fast: + uv run pytest phy doc: - python3 tools/api.py && python tools/extract_shortcuts.py && python tools/plugins_doc.py + uv run python tools/api.py && uv run python tools/extract_shortcuts.py && uv run python tools/plugins_doc.py build: - python3 setup.py sdist --formats=zip + uv build upload: - twine upload dist/* + uv publish + +upload-test: + uv publish --publish-url https://test.pypi.org/legacy/ + +publish-test: + $(PYTHON_BIN) scripts/release_publish.py publish-testpypi-dev + +version-test: + $(PYTHON_BIN) scripts/release_publish.py print-latest-testpypi-version + +publish-pypi: + $(PYTHON_BIN) scripts/release_publish.py publish-pypi + +coverage: + uv run coverage html + +dev: install lint format test + +ci: lint format-check test-full build + +RELEASE_SMOKE_DATASET ?= $(CURDIR)/../phy-data/template +PYPROJECT_VERSION := $(shell $(PYTHON_BIN) scripts/release_publish.py print-current-version) +SMOKE_VERSION ?= +PYPI_SMOKE_VERSION = $(or $(SMOKE_VERSION),$(PYPROJECT_VERSION)) +TEST_SMOKE_VERSION = $(or $(SMOKE_VERSION),$(shell $(PYTHON_BIN) scripts/release_publish.py print-latest-testpypi-version)) +RELEASE_SMOKE_INDEX_URL ?= +RELEASE_SMOKE_EXTRA_INDEX_URL ?= + +define run_smoke + RELEASE_SMOKE_DATASET="$(RELEASE_SMOKE_DATASET)" \ + RELEASE_SMOKE_ENV="$(1)" \ + RELEASE_SMOKE_VERSION="$(2)" \ + RELEASE_SMOKE_INDEX_URL="$(3)" \ + RELEASE_SMOKE_EXTRA_INDEX_URL="$(4)" \ + bash scripts/release_smoke_test.sh $(5) +endef + +define run_open + RELEASE_SMOKE_DATASET="$(RELEASE_SMOKE_DATASET)" \ + RELEASE_SMOKE_ENV="$(1)" \ + bash scripts/release_smoke_test.sh open +endef + +smoke-local: build + $(call run_smoke,$(CURDIR)/.release-smoke/local,,,,local) + +open-local: + $(call run_open,$(CURDIR)/.release-smoke/local) + +smoke-pypi: + $(call run_smoke,$(CURDIR)/.release-smoke/pypi-$(PYPI_SMOKE_VERSION),$(PYPI_SMOKE_VERSION),$(RELEASE_SMOKE_INDEX_URL),$(RELEASE_SMOKE_EXTRA_INDEX_URL),pypi) + +open-pypi: + $(call run_open,$(CURDIR)/.release-smoke/pypi-$(PYPI_SMOKE_VERSION)) + +smoke-test: + $(call run_smoke,$(CURDIR)/.release-smoke/testpypi-$(TEST_SMOKE_VERSION),$(TEST_SMOKE_VERSION),https://test.pypi.org/simple/,https://pypi.org/simple/,pypi) + +open-test: + $(call run_open,$(CURDIR)/.release-smoke/testpypi-$(TEST_SMOKE_VERSION)) + +.PHONY: clean-build clean-pyc clean-test clean install lint format format-check lint-fix test test-apps test-full test-fast doc build upload upload-test publish-test version-test publish-pypi coverage dev ci smoke-local open-local smoke-pypi open-pypi smoke-test open-test diff --git a/README.md b/README.md index c111da106..fa58bea6f 100644 --- a/README.md +++ b/README.md @@ -2,131 +2,163 @@ [![Install and Test with Pip](https://github.com/cortex-lab/phy/actions/workflows/python-test.yml/badge.svg)](https://github.com/cortex-lab/phy/actions/workflows/python-test.yml) [![codecov.io](https://img.shields.io/codecov/c/github/cortex-lab/phy.svg)](http://codecov.io/github/cortex-lab/phy) -[![Documentation Status](https://readthedocs.org/projects/phy/badge/?version=latest)](https://phy.readthedocs.io/en/latest/?badge=latest) +[![Documentation](https://img.shields.io/badge/docs-phy.cortexlab.net-blue.svg)](https://phy.cortexlab.net) [![GitHub release](https://img.shields.io/github/release/cortex-lab/phy.svg)](https://github.com/cortex-lab/phy/releases/latest) [![PyPI release](https://img.shields.io/pypi/v/phy.svg)](https://pypi.python.org/pypi/phy) +[**phy**](https://github.com/cortex-lab/phy) is an open-source Python library providing a graphical user interface for visualization and manual curation of large-scale electrophysiological data. It is optimized for high-density multielectrode arrays containing hundreds to thousands of recording sites, especially Neuropixels recordings. -[**phy**](https://github.com/cortex-lab/phy) is an open-source Python library providing a graphical user interface for visualization and manual curation of large-scale electrophysiological data. It is optimized for high-density multielectrode arrays containing hundreds to thousands of recording sites (mostly [Neuropixels probes](https://www.ucl.ac.uk/neuropixels/)). +[![phy 2.1.0rc1 screenshot](https://user-images.githubusercontent.com/1942359/74028054-c284b880-49a9-11ea-8815-1b7e727a8644.png)](https://user-images.githubusercontent.com/1942359/74028054-c284b880-49a9-11ea-8815-1b7e727a8644.png) -Phy provides two GUIs: +## Current status -* **Template GUI** (recommended): for datasets sorted with KiloSort and Spyking Circus, -* **Kwik GUI** (legacy): for datasets sorted with klusta and klustakwik2. +As of March 2026, `phy 2.1.0rc1` is a maintenance-focused release candidate for the current 2.x line. +The main goals of this release are: -[![phy 2.0b1 screenshot](https://user-images.githubusercontent.com/1942359/74028054-c284b880-49a9-11ea-8815-1b7e727a8644.png)](https://user-images.githubusercontent.com/1942359/74028054-c284b880-49a9-11ea-8815-1b7e727a8644.png) +* dependency and packaging modernization +* replacing a fragile legacy web-based GUI component with a Qt-native implementation +* improving display reliability on modern systems +* collecting feedback from beta testers before a final `2.1.0` release +Dataset formats are unchanged. Some plugins that relied on internal HTML or web-based GUI components may need updates. -## What's new -* [5 June 2024] phy 2.0 beta 6, bug fixes, install work, fixing some deprecations -* [7 Sep 2021] Release of phy 2.0 beta 5, with some install and bug fixes -* [7 Feb 2020] Release of phy 2.0 beta 1, with many new views, new features, various improvements and bug fixes... +## Supported workflows +phy currently provides three main entry points: -## Links - -* [Documentation](http://phy.readthedocs.org/en/latest/) -* [Mailing list](https://groups.google.com/forum/#!forum/phy-users) - - -## Hardware requirements - -It is recommended to store the data on a SSD for performance reasons. - -There are no specific GPU requirements as long as relatively recent graphics and OpenGL drivers are installed on the system. +* **Template GUI**: the main and recommended workflow for datasets sorted with KiloSort and Spyking Circus +* **Kwik GUI**: a legacy workflow for datasets sorted with klusta and klustakwik2 +* **Trace GUI**: an experimental raw-data viewer for opening continuous electrophysiology recordings directly +Current testing and maintenance work is focused on modern Linux, macOS, and Windows environments. Linux is still the best-covered platform, but cross-platform testing is active during the `2.1.0rc1` cycle. -## Installation instructions +## Installation -Run the following commands in a terminal (currently working for Linux machines): +Install phy in a fresh Python 3.10+ environment: -1. Create a new conda environment with the conda dependencies: +```bash +python -m pip install --upgrade pip +pip install phy +``` - ``` - conda create -n phy2 -y python=3.11 cython dask h5py joblib matplotlib numpy pillow pip pyopengl pyqt pyqtwebengine pytest python qtconsole requests responses scikit-learn scipy traitlets - ``` +This installs the GUI runtime dependencies as part of the main package. -2. Activate the new conda environment with `conda activate phy2` +If you plan to use the legacy Kwik GUI, also install: -3. Install the development version of phy: `pip install git+https://github.com/cortex-lab/phy.git` +```bash +pip install klusta klustakwik2 +``` -4. [OPTIONAL] If you plan to use the Kwik GUI, type `pip install klusta klustakwik2` +## Quick start -5. Phy should now be installed. Open the GUI on a dataset as follows (the phy2 environment should still be activated): +Open the Template GUI on a spike sorting output directory containing `params.py`: ```bash cd path/to/my/spikesorting/output phy template-gui params.py ``` -6. If there are problems with this method we also have an `environment.yml` file which allows for -automatic install of the necessary packages. Give that a try. +Other useful commands: +```bash +phy template-describe params.py +phy kwik-gui path/to/file.kwik +phy trace-gui path/to/raw.bin --sample-rate 30000 --dtype int16 --n-channels 384 +``` -### Dealing with the error `ModuleNotFoundError: No module named 'PyQt5.QtWebEngineWidget` +## Available GUIs and commands -In some environments, you might get an error message related to QtWebEngineWidget. Run the command `pip install PyQtWebEngine` and try launching phy again. This command should not run if the error message doesn't appear, as it could break the PyQt5 installation. +### Template GUI +Use the Template GUI for current template-based workflows such as KiloSort and Spyking Circus. -### Upgrading from phy 1 to phy 2 +```bash +phy template-gui params.py +``` -* Do not install phy 1 and phy 2 in the same Python environment. -* It is recommended to delete `~/.phy/*GUI/state.json` when upgrading. +To inspect a dataset from the terminal without launching the GUI: +```bash +phy template-describe params.py +``` -### Developer instructions (and instructions for some Windows machines) +### Kwik GUI -To install the development version of phy in a fresh environment, do: +The Kwik GUI is still available for legacy kwik datasets, but it is no longer the primary workflow. ```bash -git clone git@github.com:cortex-lab/phy.git -cd phy -pip install -r requirements.txt -pip install -r requirements-dev.txt -pip install -e . -cd .. -git clone git@github.com:cortex-lab/phylib.git -cd phylib -pip install -e . --upgrade +phy kwik-gui path/to/file.kwik ``` -### Mac Install +### Trace GUI -Since the switch to M-series chips Mac install for Phy is not being officially supported. -Rarely people are able to hack together a version with old versions of python etc. +The Trace GUI is still experimental and opens raw electrophysiology recordings directly. -### Troubleshooting +```bash +phy trace-gui path/to/raw.bin --sample-rate 30000 --dtype int16 --n-channels 384 +``` -* [See a list of common issues.](https://phy.readthedocs.io/en/latest/troubleshooting/) -* [Raise a GitHub issue.](https://github.com/cortex-lab/phy/issues) +## Running phy from Python +You can also launch phy from Python or IPython, which can be useful for debugging or profiling: -## Running phy from a Python script +```python +from phy.apps.template import template_gui -In addition to launching phy from the terminal with the `phy` command, you can also launch it from a Python script or an IPython terminal. This may be useful when debugging or profiling. Here's a code example to copy-paste in a new `launch.py` text file within your data directory: +template_gui("params.py") +``` +## Developer setup + +To work on phy itself in a fresh checkout: + +```bash +git clone git@github.com:cortex-lab/phy.git +cd phy +uv sync --dev ``` -from phy.apps.template import template_gui -template_gui("params.py") + +If you are working on phy together with a local checkout of `phylib`, install that checkout in editable mode: + +```bash +git clone git@github.com:cortex-lab/phylib.git +cd phylib +pip install -e . --upgrade ``` +## Troubleshooting and docs + +* [Documentation](https://phy.cortexlab.net) +* [Troubleshooting](https://phy.cortexlab.net/troubleshooting/) +* [GitHub issues](https://github.com/cortex-lab/phy/issues) +* [Mailing list](https://groups.google.com/forum/#!forum/phy-users) ## Credits **phy** is developed and maintained by [Cyrille Rossant](https://cyrille.rossant.net). * [International Brain Laboratory](https://internationalbrainlab.org) -* [Cortex Lab (UCL)](https://www.ucl.ac.uk/cortexlab/) ([Kenneth Harris](https://www.ucl.ac.uk/biosciences/people/harris-kenneth) and [Matteo Carandini](https://www.carandinilab.net/)). +* [Cortex Lab (UCL)](https://www.ucl.ac.uk/cortexlab/) ([Kenneth Harris](https://www.ucl.ac.uk/biosciences/people/harris-kenneth) and [Matteo Carandini](https://www.carandinilab.net/)) Contributors to the repository are: +* Maxime Beau * [Alessio Buccino](https://github.com/alejoe91) +* Thad Czuba * [Michael Economo](https://github.com/mswallac) +* Einsied * [Cedric Gestes](https://github.com/cgestes) -* [Dan Goodman](http://thesamovar.net/) +* Yaroslav Halchenko * [Max Hunter](https://iris.ucl.ac.uk/iris/browse/profile?upi=MLDHU99) * [Shabnam Kadir](https://iris.ucl.ac.uk/iris/browse/profile?upi=SKADI56) +* [Zach McKenzie](https://github.com/zm711) +* Sam Minkowicz * [Christopher Nolan](https://github.com/crnolan) +* [Jesús Peñaloza](https://github.com/jpenalozaa) +* [Luke Shaheen](https://github.com/LukeShaheen) * [Martin Spacek](http://mspacek.github.io/) * [Nick Steinmetz](http://www.nicksteinmetz.com/) +* Olivier Winter +* szapp +* ycanerol diff --git a/conftest.py b/conftest.py new file mode 100644 index 000000000..b00434518 --- /dev/null +++ b/conftest.py @@ -0,0 +1,15 @@ +import os +import warnings + +os.environ.setdefault("JUPYTER_PLATFORM_DIRS", "1") +warnings.filterwarnings( + "ignore", + message=r"Jupyter is migrating its paths to use standard platformdirs.*", + category=DeprecationWarning, +) +warnings.filterwarnings( + "ignore", + message=r"tostring\(\) is deprecated\. Use tobytes\(\) instead\.", + category=DeprecationWarning, + append=False, +) diff --git a/.travis.yml b/deprecated/.travis.yml similarity index 100% rename from .travis.yml rename to deprecated/.travis.yml diff --git a/environment.yml b/deprecated/environment.yml similarity index 72% rename from environment.yml rename to deprecated/environment.yml index 7523a8bfc..b606ea202 100644 --- a/environment.yml +++ b/deprecated/environment.yml @@ -3,26 +3,22 @@ channels: - conda-forge - defaults dependencies: - - python=3.11 + - python=3.10 - pip - git - - numpy<2.0 + - numpy - matplotlib - scipy - h5py - pyqt - - pyopengl=3.1.6 + - pyopengl>=3.1.9 - pyqtwebengine - pytest - qtconsole - requests - responses - traitlets - - dask - - cython - pillow - - scikit-learn - - joblib=1.4.2 + - joblib - pip: - git+https://github.com/cortex-lab/phy.git - diff --git a/requirements-dev.txt b/deprecated/requirements-dev.txt similarity index 100% rename from requirements-dev.txt rename to deprecated/requirements-dev.txt diff --git a/requirements.txt b/deprecated/requirements.txt similarity index 60% rename from requirements.txt rename to deprecated/requirements.txt index ff17d7e4f..4940653c0 100644 --- a/requirements.txt +++ b/deprecated/requirements.txt @@ -1,22 +1,18 @@ phylib>=2.6.4 mtscomp -numpy<2.0 +numpy matplotlib scipy h5py -dask -cython pillow colorcet -pyopengl==3.1.6 +pyopengl>=3.1.9 requests qtconsole tqdm -joblib==1.4.2 +joblib click mkdocs -PyQtWebEngine PyQt5 responses traitlets -scikit-learn diff --git a/setup.cfg b/deprecated/setup.cfg similarity index 100% rename from setup.cfg rename to deprecated/setup.cfg diff --git a/setup.py b/deprecated/setup.py similarity index 63% rename from setup.py rename to deprecated/setup.py index e65eb1ff2..c6aa55c58 100644 --- a/setup.py +++ b/deprecated/setup.py @@ -4,9 +4,9 @@ """Installation script.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ import os import os.path as op @@ -15,15 +15,18 @@ from setuptools import setup -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Setup -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def _package_tree(pkgroot): path = op.dirname(__file__) - subdirs = [op.relpath(i[0], path).replace(op.sep, '.') - for i in os.walk(op.join(path, pkgroot)) - if '__init__.py' in i[2]] + subdirs = [ + op.relpath(i[0], path).replace(op.sep, '.') + for i in os.walk(op.join(path, pkgroot)) + if '__init__.py' in i[2] + ] return subdirs @@ -52,23 +55,34 @@ def _package_tree(pkgroot): setup( name='phy', version=version, - license="BSD", + license='BSD', description='Interactive visualization and manual spike sorting of large-scale ephys data', long_description=readme, - long_description_content_type="text/markdown", + long_description_content_type='text/markdown', author='Cyrille Rossant (cortex-lab/UCL/IBL)', author_email='cyrille.rossant+pypi@gmail.com', url='https://phy.cortexlab.net', packages=_package_tree('phy'), package_dir={'phy': 'phy'}, package_data={ - 'phy': ['*.vert', '*.frag', '*.glsl', '*.npy', '*.gz', '*.txt', '*.json', - '*.html', '*.css', '*.js', '*.prb', '*.ttf', '*.png'], + 'phy': [ + '*.vert', + '*.frag', + '*.glsl', + '*.npy', + '*.gz', + '*.txt', + '*.json', + '*.html', + '*.css', + '*.js', + '*.prb', + '*.ttf', + '*.png', + ], }, entry_points={ - 'console_scripts': [ - 'phy = phy.apps:phycli' - ], + 'console_scripts': ['phy = phy.apps:phycli'], }, install_requires=require, include_package_data=True, @@ -78,8 +92,8 @@ def _package_tree(pkgroot): 'Intended Audience :: Developers', 'License :: OSI Approved :: BSD License', 'Natural Language :: English', - "Framework :: IPython", + 'Framework :: IPython', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.10', ], ) diff --git a/docs/announcement_2.1.0rc1.md b/docs/announcement_2.1.0rc1.md new file mode 100644 index 000000000..54f48e896 --- /dev/null +++ b/docs/announcement_2.1.0rc1.md @@ -0,0 +1,30 @@ +# TO BE DELETED: phy 2.1.0rc1 announcement copy + +Copy-paste this announcement text wherever you need it, then delete this file. + +The canonical release notes now live in [release.md](release.md). + +## Announcement text + +With substantial help from AI-assisted development, it has been possible to put time and effort +into a new maintenance release for `phy`, and `2.1.0rc1` is now available for testing. + +This release candidate does not change the dataset file formats. The main work so far is +dependency and packaging modernization, together with replacing a fragile legacy web-based GUI +component with a Qt-native implementation. + +This should improve reliability on systems where the old embedded web component caused display +problems such as blank panes or white windows. Some plugins relying on internal HTML or web-based +GUI components may need updates. + +For this release, the goal is not to bring in major new features or feature requests. The goal is +mainly to make `phy` work better for users again, and then continue improving it based on feedback +from testing. + +We are looking for beta testers over at least the next couple of months, especially on modern +Linux, macOS and Windows setups, remote sessions, and plugin-based workflows. + +Feedback on installation, rendering, view behavior, and plugin compatibility is particularly +useful. + +For detailed testing notes and local release smoke test commands, see the [release notes](release.md). diff --git a/docs/api.md b/docs/api.md index e9e869ed2..8aee63113 100644 --- a/docs/api.md +++ b/docs/api.md @@ -45,8 +45,6 @@ phy: interactive visualization and manual spike sorting of large-scale ephys dat * [phy.gui.DockWidget](#phyguidockwidget) * [phy.gui.GUI](#phyguigui) * [phy.gui.GUIState](#phyguiguistate) -* [phy.gui.HTMLBuilder](#phyguihtmlbuilder) -* [phy.gui.HTMLWidget](#phyguihtmlwidget) * [phy.gui.IPythonView](#phyguiipythonview) * [phy.gui.KeyValueWidget](#phyguikeyvaluewidget) * [phy.gui.Snippets](#phyguisnippets) @@ -1430,170 +1428,27 @@ Update the state of a view instance. --- -### phy.gui.HTMLBuilder - -Build an HTML widget. - ---- - -#### HTMLBuilder.add_header - - -**`HTMLBuilder.add_header(self, s)`** - -Add HTML headers. - ---- - -#### HTMLBuilder.add_script - - -**`HTMLBuilder.add_script(self, s)`** - -Add Javascript code. - ---- - -#### HTMLBuilder.add_script_src - - -**`HTMLBuilder.add_script_src(self, filename)`** - -Add a link to a Javascript file. - ---- - -#### HTMLBuilder.add_style - - -**`HTMLBuilder.add_style(self, s)`** - -Add a CSS style. - ---- - -#### HTMLBuilder.add_style_src - - -**`HTMLBuilder.add_style_src(self, filename)`** - -Add a link to a stylesheet URL. - ---- - -#### HTMLBuilder.set_body - - -**`HTMLBuilder.set_body(self, body)`** - -Set the HTML body of the widget. - ---- - -#### HTMLBuilder.set_body_src - - -**`HTMLBuilder.set_body_src(self, filename)`** - -Set the path to an HTML file containing the body of the widget. - ---- - -#### HTMLBuilder.html - - -**`HTMLBuilder.html`** - -Return the reconstructed HTML code of the widget. - ---- - -### phy.gui.HTMLWidget - -An HTML widget that is displayed with Qt, with Javascript support and Python-Javascript -interactions capabilities. These interactions are asynchronous in Qt5, which requires -extensive use of callback functions in Python, as well as synchronization primitives -for unit tests. - -**Constructor** - - -* `parent : Widget` - -* `title : window title` - -* `debounce_events : list-like` - The list of event names, raised by the underlying HTML widget, that should be debounced. - ---- - -#### HTMLWidget.build - - -**`HTMLWidget.build(self, callback=None)`** - -Rebuild the HTML code of the widget. - ---- - -#### HTMLWidget.eval_js - - -**`HTMLWidget.eval_js(self, expr, callback=None)`** - -Evaluate a Javascript expression. - -**Parameters** - - -* `expr : str` - A Javascript expression. - -* `callback : function` - A Python function that is called once the Javascript expression has been - evaluated. It takes as input the output of the Javascript expression. - ---- - -#### HTMLWidget.set_html - - -**`HTMLWidget.set_html(self, html, callback=None)`** - -Set the HTML code. - ---- - -#### HTMLWidget.view_source - - -**`HTMLWidget.view_source(self, callback=None)`** +### phy.gui.IPythonView -View the HTML source of the widget. +A view with an IPython console living in the same Python process as the GUI. --- -#### HTMLWidget.debouncer - - -**`HTMLWidget.debouncer`** - -Widget debouncer. +#### IPythonView.attach ---- -### phy.gui.IPythonView +**`IPythonView.attach(self, gui, **kwargs)`** -A view with an IPython console living in the same Python process as the GUI. +Add the view to the GUI, start the kernel, and inject the specified variables. --- -#### IPythonView.attach +#### IPythonView.closeEvent -**`IPythonView.attach(self, gui, **kwargs)`** +**`IPythonView.closeEvent(self, event)`** -Add the view to the GUI, start the kernel, and inject the specified variables. +closeEvent(self, a0: Optional[QCloseEvent]) --- @@ -1780,10 +1635,7 @@ the end. ### phy.gui.Table -A sortable table with support for selection. Derives from HTMLWidget. - -This table uses the following Javascript implementation: https://github.com/kwikteam/tablejs -This Javascript class builds upon ListJS: https://listjs.com/ +A sortable native Qt table with a compatibility API for legacy callers. --- @@ -1792,16 +1644,16 @@ This Javascript class builds upon ListJS: https://listjs.com/ **`Table.add(self, objects)`** -Add objects object to the table. + --- -#### Table.build +#### Table.add_style -**`Table.build(self, callback=None)`** +**`Table.add_style(self, style)`** -Rebuild the HTML code of the widget. +Append a stylesheet fragment. --- @@ -1810,7 +1662,16 @@ Rebuild the HTML code of the widget. **`Table.change(self, objects)`** -Change some objects. + + +--- + +#### Table.clear_temporary_files + + +**`Table.clear_temporary_files(self)`** + +Compatibility no-op kept for callers from the removed WebEngine path. --- @@ -1819,27 +1680,16 @@ Change some objects. **`Table.eval_js(self, expr, callback=None)`** -Evaluate a Javascript expression. - -The `table` Javascript variable can be used to interact with the underlying Javascript -table. -The table has sortable columns, a filter text box, support for single and multi selection -of rows. Rows can be skippable (used for ignored clusters in phy). -The table can raise Javascript events that are relayed to Python. Objects are -transparently serialized and deserialized in JSON. Basic types (numbers, strings, lists) -are transparently converted between Python and Javascript. +--- -**Parameters** +#### Table.eventFilter -* `expr : str` - A Javascript expression. +**`Table.eventFilter(self, obj, event)`** -* `callback : function` - A Python function that is called once the Javascript expression has been - evaluated. It takes as input the output of the Javascript expression. +eventFilter(self, a0: Optional[QObject], a1: Optional[QEvent]) -> bool --- @@ -1848,7 +1698,7 @@ are transparently converted between Python and Javascript. **`Table.filter(self, text='')`** -Filter the view with a Javascript expression. + --- @@ -1857,7 +1707,7 @@ Filter the view with a Javascript expression. **`Table.first(self, callback=None)`** -Select the first item. + --- @@ -1866,7 +1716,7 @@ Select the first item. **`Table.get(self, id, callback=None)`** -Get the object given its id. + --- @@ -1875,7 +1725,7 @@ Get the object given its id. **`Table.get_current_sort(self, callback=None)`** -Get the current sort as a tuple `(name, dir)`. + --- @@ -1884,7 +1734,7 @@ Get the current sort as a tuple `(name, dir)`. **`Table.get_ids(self, callback=None)`** -Get the list of ids. + --- @@ -1893,7 +1743,7 @@ Get the list of ids. **`Table.get_next_id(self, callback=None)`** -Get the next non-skipped row id. + --- @@ -1902,7 +1752,7 @@ Get the next non-skipped row id. **`Table.get_previous_id(self, callback=None)`** -Get the previous non-skipped row id. + --- @@ -1911,7 +1761,25 @@ Get the previous non-skipped row id. **`Table.get_selected(self, callback=None)`** -Get the currently selected rows. + + +--- + +#### Table.get_selected_ids + + +**`Table.get_selected_ids(self)`** + + + +--- + +#### Table.get_sibling_id + + +**`Table.get_sibling_id(self, row_id=None, direction='next')`** + + --- @@ -1920,7 +1788,7 @@ Get the currently selected rows. **`Table.is_ready(self)`** -Whether the widget has been fully loaded. + --- @@ -1929,7 +1797,16 @@ Whether the widget has been fully loaded. **`Table.last(self, callback=None)`** -Select the last item. + + +--- + +#### Table.minimumSizeHint + + +**`Table.minimumSizeHint(self)`** + +minimumSizeHint(self) -> QSize --- @@ -1938,7 +1815,7 @@ Select the last item. **`Table.next(self, callback=None)`** -Select the next non-skipped row. + --- @@ -1947,7 +1824,7 @@ Select the next non-skipped row. **`Table.previous(self, callback=None)`** -Select the previous non-skipped row. + --- @@ -1956,7 +1833,7 @@ Select the previous non-skipped row. **`Table.remove(self, ids)`** -Remove some objects from their ids. + --- @@ -1965,7 +1842,7 @@ Remove some objects from their ids. **`Table.remove_all(self)`** -Remove all rows in the table. + --- @@ -1974,7 +1851,7 @@ Remove all rows in the table. **`Table.remove_all_and_add(self, objects)`** -Remove all rows in the table and add new objects. + --- @@ -1983,7 +1860,7 @@ Remove all rows in the table and add new objects. **`Table.scroll_to(self, id)`** -Scroll until a given row is visible. + --- @@ -1992,11 +1869,25 @@ Scroll until a given row is visible. **`Table.select(self, ids, callback=None, **kwargs)`** -Select some rows in the table from Python. -This function calls `table.select()` in Javascript, which raises a Javascript event -relayed to Python. This sequence of actions is the same when the user selects -rows directly in the HTML view. + +--- + +#### Table.select_toggle + + +**`Table.select_toggle(self, row_id)`** + + + +--- + +#### Table.select_until + + +**`Table.select_until(self, row_id)`** + + --- @@ -2005,34 +1896,34 @@ rows directly in the HTML view. **`Table.set_busy(self, busy)`** -Set the busy state of the GUI. + --- -#### Table.set_html +#### Table.set_selected_index_offset + +**`Table.set_selected_index_offset(self, n)`** -**`Table.set_html(self, html, callback=None)`** -Set the HTML code. --- -#### Table.sort_by +#### Table.sizeHint -**`Table.sort_by(self, name, sort_dir='asc')`** +**`Table.sizeHint(self)`** -Sort by a given variable. +sizeHint(self) -> QSize --- -#### Table.view_source +#### Table.sort_by + +**`Table.sort_by(self, name, sort_dir='asc')`** -**`Table.view_source(self, callback=None)`** -View the HTML source of the widget. --- @@ -2041,7 +1932,7 @@ View the HTML source of the widget. **`Table.debouncer`** -Widget debouncer. + --- @@ -2273,21 +2164,25 @@ Remove all visuals except those marked `clearable=False`. --- -#### BaseCanvas.emit +#### BaseCanvas.close -**`BaseCanvas.emit(self, name, **kwargs)`** +**`BaseCanvas.close(self)`** -Raise an internal event and call `on_xxx()` on attached objects. +Close the OpenGL canvas. + +The Qt offscreen platform plugin crashes when closing a shown QOpenGLWindow after +rendering. Hiding the window avoids the native crash and is sufficient for headless +test cleanup. --- -#### BaseCanvas.event +#### BaseCanvas.emit -**`BaseCanvas.event(self, e)`** +**`BaseCanvas.emit(self, name, **kwargs)`** -Touch event. +Raise an internal event and call `on_xxx()` on attached objects. --- @@ -4165,6 +4060,19 @@ Remove all visuals except those marked `clearable=False`. --- +#### PlotCanvas.close + + +**`PlotCanvas.close(self)`** + +Close the OpenGL canvas. + +The Qt offscreen platform plugin crashes when closing a shown QOpenGLWindow after +rendering. Hiding the window avoids the native crash and is sufficient for headless +test cleanup. + +--- + #### PlotCanvas.emit @@ -4201,15 +4109,6 @@ Enable pan zoom in the canvas. --- -#### PlotCanvas.event - - -**`PlotCanvas.event(self, e)`** - -Touch event. - ---- - #### PlotCanvas.get_size @@ -6545,16 +6444,16 @@ Display a table of all clusters with metrics and labels as columns. Derive from **`ClusterView.add(self, objects)`** -Add objects object to the table. + --- -#### ClusterView.build +#### ClusterView.add_style -**`ClusterView.build(self, callback=None)`** +**`ClusterView.add_style(self, style)`** -Rebuild the HTML code of the widget. +Append a stylesheet fragment. --- @@ -6563,7 +6462,16 @@ Rebuild the HTML code of the widget. **`ClusterView.change(self, objects)`** -Change some objects. + + +--- + +#### ClusterView.clear_temporary_files + + +**`ClusterView.clear_temporary_files(self)`** + +Compatibility no-op kept for callers from the removed WebEngine path. --- @@ -6572,27 +6480,16 @@ Change some objects. **`ClusterView.eval_js(self, expr, callback=None)`** -Evaluate a Javascript expression. -The `table` Javascript variable can be used to interact with the underlying Javascript -table. -The table has sortable columns, a filter text box, support for single and multi selection -of rows. Rows can be skippable (used for ignored clusters in phy). - -The table can raise Javascript events that are relayed to Python. Objects are -transparently serialized and deserialized in JSON. Basic types (numbers, strings, lists) -are transparently converted between Python and Javascript. +--- -**Parameters** +#### ClusterView.eventFilter -* `expr : str` - A Javascript expression. +**`ClusterView.eventFilter(self, obj, event)`** -* `callback : function` - A Python function that is called once the Javascript expression has been - evaluated. It takes as input the output of the Javascript expression. +eventFilter(self, a0: Optional[QObject], a1: Optional[QEvent]) -> bool --- @@ -6601,7 +6498,7 @@ are transparently converted between Python and Javascript. **`ClusterView.filter(self, text='')`** -Filter the view with a Javascript expression. + --- @@ -6610,7 +6507,7 @@ Filter the view with a Javascript expression. **`ClusterView.first(self, callback=None)`** -Select the first item. + --- @@ -6619,7 +6516,7 @@ Select the first item. **`ClusterView.get(self, id, callback=None)`** -Get the object given its id. + --- @@ -6628,7 +6525,7 @@ Get the object given its id. **`ClusterView.get_current_sort(self, callback=None)`** -Get the current sort as a tuple `(name, dir)`. + --- @@ -6637,7 +6534,7 @@ Get the current sort as a tuple `(name, dir)`. **`ClusterView.get_ids(self, callback=None)`** -Get the list of ids. + --- @@ -6646,7 +6543,7 @@ Get the list of ids. **`ClusterView.get_next_id(self, callback=None)`** -Get the next non-skipped row id. + --- @@ -6655,7 +6552,7 @@ Get the next non-skipped row id. **`ClusterView.get_previous_id(self, callback=None)`** -Get the previous non-skipped row id. + --- @@ -6664,7 +6561,25 @@ Get the previous non-skipped row id. **`ClusterView.get_selected(self, callback=None)`** -Get the currently selected rows. + + +--- + +#### ClusterView.get_selected_ids + + +**`ClusterView.get_selected_ids(self)`** + + + +--- + +#### ClusterView.get_sibling_id + + +**`ClusterView.get_sibling_id(self, row_id=None, direction='next')`** + + --- @@ -6673,7 +6588,7 @@ Get the currently selected rows. **`ClusterView.is_ready(self)`** -Whether the widget has been fully loaded. + --- @@ -6682,7 +6597,16 @@ Whether the widget has been fully loaded. **`ClusterView.last(self, callback=None)`** -Select the last item. + + +--- + +#### ClusterView.minimumSizeHint + + +**`ClusterView.minimumSizeHint(self)`** + +minimumSizeHint(self) -> QSize --- @@ -6691,7 +6615,7 @@ Select the last item. **`ClusterView.next(self, callback=None)`** -Select the next non-skipped row. + --- @@ -6700,7 +6624,7 @@ Select the next non-skipped row. **`ClusterView.previous(self, callback=None)`** -Select the previous non-skipped row. + --- @@ -6709,7 +6633,7 @@ Select the previous non-skipped row. **`ClusterView.remove(self, ids)`** -Remove some objects from their ids. + --- @@ -6718,7 +6642,7 @@ Remove some objects from their ids. **`ClusterView.remove_all(self)`** -Remove all rows in the table. + --- @@ -6727,7 +6651,7 @@ Remove all rows in the table. **`ClusterView.remove_all_and_add(self, objects)`** -Remove all rows in the table and add new objects. + --- @@ -6736,7 +6660,7 @@ Remove all rows in the table and add new objects. **`ClusterView.scroll_to(self, id)`** -Scroll until a given row is visible. + --- @@ -6745,11 +6669,25 @@ Scroll until a given row is visible. **`ClusterView.select(self, ids, callback=None, **kwargs)`** -Select some rows in the table from Python. -This function calls `table.select()` in Javascript, which raises a Javascript event -relayed to Python. This sequence of actions is the same when the user selects -rows directly in the HTML view. + +--- + +#### ClusterView.select_toggle + + +**`ClusterView.select_toggle(self, row_id)`** + + + +--- + +#### ClusterView.select_until + + +**`ClusterView.select_until(self, row_id)`** + + --- @@ -6758,16 +6696,16 @@ rows directly in the HTML view. **`ClusterView.set_busy(self, busy)`** -Set the busy state of the GUI. + --- -#### ClusterView.set_html +#### ClusterView.set_selected_index_offset + +**`ClusterView.set_selected_index_offset(self, n)`** -**`ClusterView.set_html(self, html, callback=None)`** -Set the HTML code. --- @@ -6780,21 +6718,21 @@ Set the cluster view state, with a specified sort. --- -#### ClusterView.sort_by +#### ClusterView.sizeHint -**`ClusterView.sort_by(self, name, sort_dir='asc')`** +**`ClusterView.sizeHint(self)`** -Sort by a given variable. +sizeHint(self) -> QSize --- -#### ClusterView.view_source +#### ClusterView.sort_by + +**`ClusterView.sort_by(self, name, sort_dir='asc')`** -**`ClusterView.view_source(self, callback=None)`** -View the HTML source of the widget. --- @@ -6803,7 +6741,7 @@ View the HTML source of the widget. **`ClusterView.debouncer`** -Widget debouncer. + --- @@ -9191,16 +9129,16 @@ in the cluster view. **`SimilarityView.add(self, objects)`** -Add objects object to the table. + --- -#### SimilarityView.build +#### SimilarityView.add_style -**`SimilarityView.build(self, callback=None)`** +**`SimilarityView.add_style(self, style)`** -Rebuild the HTML code of the widget. +Append a stylesheet fragment. --- @@ -9209,7 +9147,16 @@ Rebuild the HTML code of the widget. **`SimilarityView.change(self, objects)`** -Change some objects. + + +--- + +#### SimilarityView.clear_temporary_files + + +**`SimilarityView.clear_temporary_files(self)`** + +Compatibility no-op kept for callers from the removed WebEngine path. --- @@ -9218,27 +9165,16 @@ Change some objects. **`SimilarityView.eval_js(self, expr, callback=None)`** -Evaluate a Javascript expression. - -The `table` Javascript variable can be used to interact with the underlying Javascript -table. -The table has sortable columns, a filter text box, support for single and multi selection -of rows. Rows can be skippable (used for ignored clusters in phy). -The table can raise Javascript events that are relayed to Python. Objects are -transparently serialized and deserialized in JSON. Basic types (numbers, strings, lists) -are transparently converted between Python and Javascript. +--- -**Parameters** +#### SimilarityView.eventFilter -* `expr : str` - A Javascript expression. +**`SimilarityView.eventFilter(self, obj, event)`** -* `callback : function` - A Python function that is called once the Javascript expression has been - evaluated. It takes as input the output of the Javascript expression. +eventFilter(self, a0: Optional[QObject], a1: Optional[QEvent]) -> bool --- @@ -9247,7 +9183,7 @@ are transparently converted between Python and Javascript. **`SimilarityView.filter(self, text='')`** -Filter the view with a Javascript expression. + --- @@ -9256,7 +9192,7 @@ Filter the view with a Javascript expression. **`SimilarityView.first(self, callback=None)`** -Select the first item. + --- @@ -9265,7 +9201,7 @@ Select the first item. **`SimilarityView.get(self, id, callback=None)`** -Get the object given its id. + --- @@ -9274,7 +9210,7 @@ Get the object given its id. **`SimilarityView.get_current_sort(self, callback=None)`** -Get the current sort as a tuple `(name, dir)`. + --- @@ -9283,7 +9219,7 @@ Get the current sort as a tuple `(name, dir)`. **`SimilarityView.get_ids(self, callback=None)`** -Get the list of ids. + --- @@ -9292,7 +9228,7 @@ Get the list of ids. **`SimilarityView.get_next_id(self, callback=None)`** -Get the next non-skipped row id. + --- @@ -9301,7 +9237,7 @@ Get the next non-skipped row id. **`SimilarityView.get_previous_id(self, callback=None)`** -Get the previous non-skipped row id. + --- @@ -9310,7 +9246,25 @@ Get the previous non-skipped row id. **`SimilarityView.get_selected(self, callback=None)`** -Get the currently selected rows. + + +--- + +#### SimilarityView.get_selected_ids + + +**`SimilarityView.get_selected_ids(self)`** + + + +--- + +#### SimilarityView.get_sibling_id + + +**`SimilarityView.get_sibling_id(self, row_id=None, direction='next')`** + + --- @@ -9319,7 +9273,7 @@ Get the currently selected rows. **`SimilarityView.is_ready(self)`** -Whether the widget has been fully loaded. + --- @@ -9328,7 +9282,16 @@ Whether the widget has been fully loaded. **`SimilarityView.last(self, callback=None)`** -Select the last item. + + +--- + +#### SimilarityView.minimumSizeHint + + +**`SimilarityView.minimumSizeHint(self)`** + +minimumSizeHint(self) -> QSize --- @@ -9337,7 +9300,7 @@ Select the last item. **`SimilarityView.next(self, callback=None)`** -Select the next non-skipped row. + --- @@ -9346,7 +9309,7 @@ Select the next non-skipped row. **`SimilarityView.previous(self, callback=None)`** -Select the previous non-skipped row. + --- @@ -9355,7 +9318,7 @@ Select the previous non-skipped row. **`SimilarityView.remove(self, ids)`** -Remove some objects from their ids. + --- @@ -9364,7 +9327,7 @@ Remove some objects from their ids. **`SimilarityView.remove_all(self)`** -Remove all rows in the table. + --- @@ -9373,7 +9336,7 @@ Remove all rows in the table. **`SimilarityView.remove_all_and_add(self, objects)`** -Remove all rows in the table and add new objects. + --- @@ -9391,7 +9354,7 @@ Recreate the similarity view, given the selected clusters in the cluster view. **`SimilarityView.scroll_to(self, id)`** -Scroll until a given row is visible. + --- @@ -9400,29 +9363,34 @@ Scroll until a given row is visible. **`SimilarityView.select(self, ids, callback=None, **kwargs)`** -Select some rows in the table from Python. -This function calls `table.select()` in Javascript, which raises a Javascript event -relayed to Python. This sequence of actions is the same when the user selects -rows directly in the HTML view. --- -#### SimilarityView.set_busy +#### SimilarityView.select_toggle + + +**`SimilarityView.select_toggle(self, row_id)`** -**`SimilarityView.set_busy(self, busy)`** -Set the busy state of the GUI. +--- + +#### SimilarityView.select_until + + +**`SimilarityView.select_until(self, row_id)`** + + --- -#### SimilarityView.set_html +#### SimilarityView.set_busy + +**`SimilarityView.set_busy(self, busy)`** -**`SimilarityView.set_html(self, html, callback=None)`** -Set the HTML code. --- @@ -9445,21 +9413,21 @@ Set the cluster view state, with a specified sort. --- -#### SimilarityView.sort_by +#### SimilarityView.sizeHint -**`SimilarityView.sort_by(self, name, sort_dir='asc')`** +**`SimilarityView.sizeHint(self)`** -Sort by a given variable. +sizeHint(self) -> QSize --- -#### SimilarityView.view_source +#### SimilarityView.sort_by + +**`SimilarityView.sort_by(self, name, sort_dir='asc')`** -**`SimilarityView.view_source(self, callback=None)`** -View the HTML source of the widget. --- @@ -9468,7 +9436,7 @@ View the HTML source of the widget. **`SimilarityView.debouncer`** -Widget debouncer. + --- @@ -9489,7 +9457,7 @@ Component that brings manual clustering facilities to a GUI: * `ClusterMeta` instance: change cluster metadata (e.g. group). * Cluster selection. * Many manual clustering-related actions, snippets, shortcuts, etc. -* Two HTML tables : `ClusterView` and `SimilarityView`. +* Two native Qt tables: `ClusterView` and `SimilarityView`. **Constructor** @@ -9568,7 +9536,7 @@ Only used in the automated testing suite. **`Supervisor.filter(self, text)`** -Filter the clusters using a Javascript expression on the column names. +Filter the clusters using a boolean expression on the column names. --- @@ -11603,7 +11571,7 @@ equivalent to this: #### phy.apps.format_exception -**`phy.apps.format_exception(etype, value, tb, limit=None, chain=True)`** +**`phy.apps.format_exception(exc, /, value=, tb=, limit=None, chain=True, **kwargs)`** Format a stack trace and the exception information. @@ -12008,36 +11976,6 @@ but cannot instantiate a WindowsPath on a POSIX system or vice versa. --- -#### Path.None - - -**`Path.None`** - -attrgetter(attr, ...) --> attrgetter object - -Return a callable object that fetches the given attribute(s) from its operand. -After f = attrgetter('name'), the call f(r) returns r.name. -After g = attrgetter('name', 'date'), the call g(r) returns (r.name, r.date). -After h = attrgetter('name.first', 'name.last'), the call h(r) returns -(r.name.first, r.name.last). - ---- - -#### Path.None - - -**`Path.None`** - -attrgetter(attr, ...) --> attrgetter object - -Return a callable object that fetches the given attribute(s) from its operand. -After f = attrgetter('name'), the call f(r) returns r.name. -After g = attrgetter('name', 'date'), the call g(r) returns (r.name, r.date). -After h = attrgetter('name.first', 'name.last'), the call h(r) returns -(r.name.first, r.name.last). - ---- - ### phy.apps.QtDialogLogger Display a message box for all errors. @@ -12740,6 +12678,16 @@ Close all memmapped files. --- +#### TemplateModel.cluster_waveforms + + +**`TemplateModel.cluster_waveforms(self)`** + +Computes the cluster waveforms for split and merged clusters +:return: + +--- + #### TemplateModel.describe @@ -12749,6 +12697,24 @@ Display basic information about the dataset. --- +#### TemplateModel.get_amplitudes_true + + +**`TemplateModel.get_amplitudes_true(self, sample2unit=1.0, use='templates')`** + +Convert spike amplitude values to input amplitudes units +via scaling by unwhitened template waveform. +:param sample2unit float: factor to convert the raw data to a physical unit (defaults 1.) +:returns: spike_amplitudes_volts: np.array [nspikes] spike amplitudes in raw data units +:returns: templates_volts: np.array[ntemplates, nsamples, nchannels]: templates +in raw data units +:returns: template_amps_volts: np.array[ntemplates]: average templates amplitudes + in raw data units +To scale the template for template matching, +raw_data_volts = templates_volts * spike_amplitudes_volts / template_amps_volts + +--- + #### TemplateModel.get_cluster_channels @@ -12761,7 +12727,7 @@ Return the most relevant channels of a cluster. #### TemplateModel.get_cluster_mean_waveforms -**`TemplateModel.get_cluster_mean_waveforms(self, cluster_id)`** +**`TemplateModel.get_cluster_mean_waveforms(self, cluster_id, unwhiten=True)`** Return the mean template waveforms of a cluster, as a weighted average of the template waveforms from which the cluster originates from. @@ -12786,6 +12752,15 @@ Return the spike ids that belong to a given template. --- +#### TemplateModel.get_depths + + +**`TemplateModel.get_depths(self)`** + +Compute spike depths based on spike pc features and probe depths. + +--- + #### TemplateModel.get_features @@ -12795,10 +12770,19 @@ Return sparse features for given spikes. --- +#### TemplateModel.get_merge_map + + +**`TemplateModel.get_merge_map(self)`** + +"Gets the maps of merges and splits between spikes.clusters and spikes.templates + +--- + #### TemplateModel.get_template -**`TemplateModel.get_template(self, template_id, channel_ids=None, amplitude_threshold=None)`** +**`TemplateModel.get_template(self, template_id, channel_ids=None, amplitude_threshold=None, unwhiten=True)`** Get data about a template. @@ -12889,9 +12873,36 @@ Save the spike clusters. #### TemplateModel.save_spikes_subset_waveforms -**`TemplateModel.save_spikes_subset_waveforms(self, max_n_spikes_per_template=None, max_n_channels=None)`** +**`TemplateModel.save_spikes_subset_waveforms(self, max_n_spikes_per_template=None, max_n_channels=None, sample2unit=1.0)`** + + + +--- + +#### TemplateModel.clusters_amplitudes + + +**`TemplateModel.clusters_amplitudes`** + +Returns the average amplitude per cluster + +--- + +#### TemplateModel.clusters_channels + + +**`TemplateModel.clusters_channels`** + +Returns a vector of peak channels for all clusters waveforms + +--- + +#### TemplateModel.clusters_waveforms_durations + +**`TemplateModel.clusters_waveforms_durations`** +Returns a vector of waveform durations (ms) for all clusters --- @@ -12909,7 +12920,7 @@ Returns the average amplitude per cluster **`TemplateModel.templates_channels`** -Returns a vector of peak channels for all templates +Returns a vector of peak channels for all templates waveforms --- diff --git a/docs/customization.md b/docs/customization.md index 812d702fa..057f56562 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -138,13 +138,10 @@ This object (`controller.supervisor`) is responsible for creating the cluster an * **Clustering**: manages the cluster assignments and the related undo stack. * **ClusterMeta**: manages the cluster groups and labels, and the related undo stack. * **History**: a generic undo stack used by the two classes above. -* **HTMLWidget**: a generic HTML widget with Javascript-Python communication handled by PyQt5. * **Table**: a table used by the cluster and similarity views. * **Context**: manages the memory and disk cache, using joblib. * **ClusterColorSelector**: manages the cluster color mapping. -*Note*: the code of the table is in a separate Javascript project, `tablejs` that uses the `ListJS` library. - #### Context Disk cache and memory cache are stored in the `.phy` subdirectory within the data directory. Functions retrieving cluster-dependent data such as waveforms, templates, and so on, are all cached for performance reasons. It is important to ensure that this directory is stored on an SSD. diff --git a/docs/index.md b/docs/index.md index cba067c2a..2cfadc0ec 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,18 +2,20 @@ [**phy**](https://github.com/cortex-lab/phy) is an open-source Python library providing a graphical user interface for visualization and manual curation of large-scale electrophysiological data. It is optimized for high-density multielectrode arrays containing hundreds to thousands of recording sites (mostly [Neuropixels probes](https://www.ucl.ac.uk/neuropixels/)). -[![phy 2.0b1 screenshot](https://user-images.githubusercontent.com/1942359/74028054-c284b880-49a9-11ea-8815-1b7e727a8644.png)](https://user-images.githubusercontent.com/1942359/74028054-c284b880-49a9-11ea-8815-1b7e727a8644.png) +Current release candidate: [phy 2.1.0rc1](release.md). + +[![phy 2.1.0rc1 screenshot](https://user-images.githubusercontent.com/1942359/74028054-c284b880-49a9-11ea-8815-1b7e727a8644.png)](https://user-images.githubusercontent.com/1942359/74028054-c284b880-49a9-11ea-8815-1b7e727a8644.png) ## Spike sorting programs phy can open datasets spike-sorted with the following programs: -* [KiloSort](https://github.com/MouseLand/Kilosort2/) +* [KiloSort](https://github.com/MouseLand/Kilosort) * [SpykingCircus](https://spyking-circus.readthedocs.io/en/latest/) -* [klusta](http://klusta.readthedocs.org/en/latest/) +* [klusta](https://klusta.readthedocs.io/en/latest/) -KiloSort and SpykingCircus are spike sorting programs based on template matching. They use a file format based on `.npy` ([NumPy binary arrays](https://docs.scipy.org/doc/numpy-1.14.2/neps/npy-format.html)) and `.tsv` files (tab-separated files). +KiloSort and SpykingCircus are spike sorting programs based on template matching. They use a file format based on `.npy` ([NumPy binary arrays](https://numpy.org/doc/stable/reference/generated/numpy.lib.format.html)) and `.tsv` files (tab-separated files). klusta is a legacy spike sorting program based on an automatic clustering algorithm. It uses the [kwik format](https://klusta.readthedocs.io/en/latest/kwik/#kwik-format) based on HDF5. While klusta and the kwik format are still supported by phy, they are no longer actively maintained. diff --git a/docs/plugins.md b/docs/plugins.md index 049c733ad..aaa028373 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -352,15 +352,15 @@ class ExampleClusterStatsPlugin(IPlugin): ## Customizing the styling of the cluster view -The cluster view is written in HTML/CSS/Javascript. The styling can be customized in a plugin as follows. +The cluster view is a native Qt table. The styling can be customized in a plugin as follows. -In this example, we change the text color of "good" clusters in the cluster view. +In this example, we change the header styling of the cluster view. ![image](https://user-images.githubusercontent.com/1942359/59463245-dd585a80-8e25-11e9-9fe3-56aa4c3733c7.png) ```python # import from plugins/cluster_view_styling.py -"""Show how to customize the styling of the cluster view with CSS.""" +"""Show how to customize the styling of the cluster view with Qt stylesheet fragments.""" from phy import IPlugin from phy.cluster.supervisor import ClusterView @@ -368,17 +368,12 @@ from phy.cluster.supervisor import ClusterView class ExampleClusterViewStylingPlugin(IPlugin): def attach_to_controller(self, controller): - # We add a custom CSS style to the ClusterView. + # We add a custom stylesheet fragment to the ClusterView. ClusterView._styles += """ - - /* This CSS selector represents all rows for good clusters. */ - table tr[data-group='good'] { - - /* We change the text color. Many other CSS attributes can be changed, - such as background-color, the font weight, etc. */ - color: red; + QHeaderView::section { + color: #f5c542; + font-weight: bold; } - """ ``` @@ -432,12 +427,12 @@ class ExampleActionPlugin(IPlugin): submenu='My submenu', shortcut='ctrl+c', prompt=True, prompt_default=lambda: 10) def select_n_first_clusters(n_clusters): - # All cluster view methods are called with a callback function because of the - # asynchronous nature of Python-Javascript interactions in Qt5. + # All cluster view methods are called with a callback function because the + # table API is asynchronous. @controller.supervisor.cluster_view.get_ids def get_cluster_ids(cluster_ids): """This function is called when the ordered list of cluster ids is returned - by the Javascript view.""" + by the cluster view.""" # We select the first n_clusters clusters. controller.supervisor.select(cluster_ids[:n_clusters]) diff --git a/docs/release.md b/docs/release.md index 0bcf4804e..245e18ce9 100644 --- a/docs/release.md +++ b/docs/release.md @@ -1,9 +1,70 @@ # Release notes -Current version is phy v2.0b1 (beta 1) (7 Feb 2020). +Current version is phy v2.1.0rc1 (release candidate 1) (11 Mar 2026). -## New views +## phy 2.1.0rc1 + +With substantial help from AI-assisted development, it has been possible to put time and effort +into this maintenance release for the current 2.x line. + +`phy 2.1.0rc1` is focused on making the existing software work better for users. It is not meant +to bring in major new features or feature requests at this stage. More work will likely still be +needed based on user feedback during the release candidate period. + +### Main points + +* No changes to the dataset or file formats +* Dependency updates and packaging cleanup +* Replacement of a fragile legacy web-based GUI component with a Qt-native implementation +* Expected improvement on systems where the old embedded web component caused blank panes, white windows, or related display failures + +### What to test + +* Installation on current Linux, macOS and Windows environments +* GUI startup and rendering behavior +* Cluster selection and view updates +* Feature, waveform, amplitude, and trace views +* Remote desktop, Wayland, and GPU-specific setups +* Plugin-based workflows + +### Compatibility notes + +* Dataset formats are unchanged +* Some plugins relying on internal HTML or other web-based GUI components may need updates + +### Notes for plugin maintainers + +The main compatibility risk for plugins is on the GUI side. The legacy web-based component has +been replaced with a Qt-native implementation, so plugins depending on internal HTML or other +web-based GUI pieces may need to be updated. + +Plugins using supported Python-side controller, event, or view APIs are more likely to keep +working unchanged, but they should still be tested. + +### Testing window + +Testing for `2.1.0rc1` is expected to stay open for at least the next couple of months before a final `2.1.0` release. + +### Feedback + +When reporting issues, please include: + +* operating system +* Python version +* installation method +* local or remote session details +* whether plugins are in use +* a minimal error message or reproduction if possible + + +## Historical notes + + +### phy 2.0 beta 1 + + +#### New views * **Cluster scatter view**: a scatter plot of all clusters, on two user-defined dimensions (for example, depth vs firing rate). The marker size and colors can also depend on two additional user-defined dimensions. @@ -43,14 +104,14 @@ Current version is phy v2.0b1 (beta 1) (7 Feb 2020). -## New features +#### New features * **Split clusters in the amplitude view or in the template feature view**, in addition to the feature view * **Cluster view**: * Dynamically **filter** the list of clusters based on cluster metrics and labels (using JavaScript syntax) * Snippets to quickly **sort and filter** clusters * New default columns: mean firing rate, and template waveform amplitude - * The styling can be customized with CSS in a plugin (see plugin examples in the documentation) + * The styling can be customized in a plugin (see plugin examples in the documentation) * **Amplitude view**: * Show an histogram of amplitudes overlaid with the amplitudes * Support for multiple types of amplitudes (template waveform amplitude, raw waveform amplitude, feature amplitude) @@ -88,7 +149,7 @@ Current version is phy v2.0b1 (beta 1) (7 Feb 2020). * Support for **multiple channel shanks**: add a `channel_shanks.npy` file with shape `(n_channels,` ), with the shank integer index of every channel. -## Improvements +#### Improvements * A new file `cluster_info.tsv` is automatically saved, containing all information from the cluster view. * Minimal high-level data access API in the Template Model @@ -103,7 +164,7 @@ Current version is phy v2.0b1 (beta 1) (7 Feb 2020). * Documentation rewritten from scratch, with many examples -## Internal changes +#### Internal changes * Support **Python 3.7+**, dropped Python 2.x support (reached End Of Life) * Updated to **PyQt5** from PyQt4, which is now unsupported @@ -113,15 +174,9 @@ Current version is phy v2.0b1 (beta 1) (7 Feb 2020). * Moved the phy GitHub repository from kwikteam to cortex-lab organization -## Notes for plugin maintainers +#### Notes for plugin maintainers The following changes may affect phy plugins: * The `add_view` and `view_actions_created` events have been removed. * You should now use the new event `view_attached(view, gui)` that is emitted when a view is attached to the GUI. - - -## [coming soon] Upcoming features - -* Support for events: PSTH view, trial-based raster plots, etc. -* More efficient GPU-based plotting diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 000000000..246a5c135 --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,156 @@ +# Maintainer release workflow + +This page is for maintainers preparing TestPyPI or PyPI releases. It is not part of the +user-facing release notes. + +## Local release smoke test + +The repository contains a local packaging smoke test that mirrors the end-user install flow in an +isolated virtual environment and uses the small template dataset from `../phy-data/template/`. + +Run this before upload to validate the built wheel: + +```bash +make smoke-local +make open-local +``` + +`make smoke-local` builds the wheel, creates `.release-smoke/local`, installs `phy` from +the local wheel into that fresh environment, checks imports and CLI entry points, and runs: + +```bash +phy template-describe ../phy-data/template/params.py +``` + +`make open-local` launches the GUI from that isolated environment so that you can confirm +the dataset opens correctly. + +After upload to TestPyPI or PyPI, validate the published package in another fresh environment: + +```bash +make smoke-pypi +make open-pypi +``` + +For TestPyPI, use: + +```bash +make smoke-test +make open-test +``` + +`smoke-pypi` and `open-pypi` default to the version from `pyproject.toml`. +`smoke-test` and `open-test` default to the recorded latest TestPyPI dev build. +You can override either with `SMOKE_VERSION=`. + +This validates the intended user path: a plain install in a fresh environment, followed by opening +a dataset with `phy template-gui`. + +## Disposable TestPyPI dev releases + +TestPyPI does not let you overwrite an existing file for the same version, so the repository +includes a disposable dev-release workflow around the current checked-in release candidate version. + +Before publishing, provide a TestPyPI API token. Username/password uploads are rejected by +TestPyPI. The helper accepts `TESTPYPI_TOKEN`, `TEST_PYPI_TOKEN`, or `UV_PUBLISH_TOKEN`, and also +falls back to `~/.pypirc` under `[testpypi]` when it contains: + +```bash +username = __token__ +password = pypi-... +``` + +Run: + +```bash +make publish-test +``` + +This command: + +* reads the current version from `pyproject.toml` (for example `2.1.0rc1`) +* queries TestPyPI for existing `2.1.0rc1.devN` releases +* picks the next free version such as `2.1.0rc1.dev3` +* creates a temporary staged copy of the repository +* updates the version in that temporary copy only +* builds and publishes that disposable version to TestPyPI +* records the published version in `.release-smoke/latest-testpypi-version.txt` + +Your working tree keeps the original final candidate version unchanged. + +After publishing the disposable TestPyPI build, validate exactly that uploaded version with: + +```bash +make smoke-test +make open-test +``` + +You can print the recorded version directly with: + +```bash +make version-test +``` + +## Final PyPI publish + +Once the disposable TestPyPI builds have been validated on your different operating systems and you +are ready to publish the exact checked-in version from `pyproject.toml`, run: + +The final publish helper accepts `PYPI_TOKEN` or `UV_PUBLISH_TOKEN`, and also falls back to +`~/.pypirc` under `[pypi]` with: + +```bash +username = __token__ +password = pypi-... +``` + +Then run: + +```bash +make publish-pypi +``` + +This target refuses to publish if the checked-in version still contains `.dev`. + +## Typical RC release checklist + +For a normal release-candidate cycle, the usual command sequence is: + +```bash +# 1. Local code and packaging checks +make test-fast +make build +make smoke-local + +# 2. Publish a disposable TestPyPI build for this RC line +make publish-test + +# 3. Verify that uploaded TestPyPI build on this machine +make smoke-test +make open-test + +# 4. Repeat step 3 on your other OS machines +make version-test +# then on the other machine, after recording that version locally: +make smoke-test +make open-test + +# 5. Once everything is green, publish the checked-in RC version to real PyPI +make publish-pypi + +# 6. Verify the real PyPI release +make smoke-pypi +make open-pypi +``` + +If a TestPyPI upload fails validation, fix the issue locally and run +`make publish-test` again. It will automatically choose the next free `.devN` +version without changing the checked-in RC version. + +If another machine does not yet have `.release-smoke/latest-testpypi-version.txt`, you can still +fall back to: + +```bash +make smoke-test SMOKE_VERSION= +make open-test SMOKE_VERSION= +``` diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index c5f163e6f..00cfef23a 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -81,17 +81,6 @@ The channel labels displayed in the views may be invalid. This may happen becaus 3. Launch phy again. -### Error "No module named 'PyQt5.QtWebEngineWidgets'" - -Do: - -``` -pip install PyQtWebEngine -``` - -**Note**: make sure that PyQt5 is installed either with conda (default), or with pip, but not with both. Otherwise, conflicts may occur. - - ### Error "No module named PyQt5.sip" If you receive the error: `No module named PyQt5.sip`, try to run the following commands in your conda environment (solution found by Claire Ward): diff --git a/docs/visualization.md b/docs/visualization.md index 25971eb1f..9ecd4d63d 100644 --- a/docs/visualization.md +++ b/docs/visualization.md @@ -2,7 +2,7 @@ Currently, phy provides two GUIs: -* the **Template GUI** for [**KiloSort**](https://github.com/MouseLand/Kilosort2/)/[**SpykingCircus**](https://spyking-circus.readthedocs.io/) datasets. +* the **Template GUI** for [**KiloSort**](https://github.com/MouseLand/Kilosort/)/[**SpykingCircus**](https://spyking-circus.readthedocs.io/) datasets. * the **Kwik GUI** for Kwik datasets, obtained with the [**klusta**](https://github.com/kwikteam/klusta/) spike-sorting program (not actively maintained). These GUIs let you visualize ephys data that has already been spike-sorted. You can also refine the clustering manually if needed. You can also use the GUI as a platform for interactive ephys data analysis. The **IPython view** lets you interact with the data interactively from within the GUI. @@ -103,12 +103,11 @@ Rows in the cluster view are shown in different colors according to the cluster #### Cluster filtering -You can filter the list of clusters shown in the cluster view, in the `filter` text box at the top of the cluster view. Type a boolean expression using the column names as variables, and press `Enter`. Press `Escape` to clear the filtering. You can also use the `:f` snippet. The syntax is Javascript. Here are a few examples: +You can filter the list of clusters shown in the cluster view, in the `filter` text box at the top of the cluster view. Type a boolean expression using the column names as variables, and press `Enter`. Press `Escape` to clear the filtering. You can also use the `:f` snippet. Here are a few examples: * `group == 'good'` : only show good clusters * `n_spikes > 10000` : only show clusters that have more than 10,000 spikes * `group != 'noise' && depth >= 1000` : only show non-noise clusters at a depth larger than 1000 -`` ![image](https://user-images.githubusercontent.com/1942359/58951225-d8920780-8790-11e9-8b3c-a048f929875b.png) diff --git a/phy/__init__.py b/phy/__init__.py index b1e036f4c..aafba81cb 100644 --- a/phy/__init__.py +++ b/phy/__init__.py @@ -1,12 +1,11 @@ -# -*- coding: utf-8 -*- # flake8: noqa """phy: interactive visualization and manual spike sorting of large-scale ephys data.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ import atexit import logging @@ -22,13 +21,13 @@ from .utils.plugin import IPlugin, get_plugin, discover_plugins -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Global variables and functions -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ __author__ = 'Cyrille Rossant' __email__ = 'cyrille.rossant at gmail.com' -__version__ = '2.0b6' +__version__ = '2.1.0rc1' __version_git__ = __version__ + _git_version() @@ -50,4 +49,5 @@ def on_exit(): # pragma: no cover def test(): # pragma: no cover """Run the full testing suite of phy.""" import pytest + pytest.main() diff --git a/phy/apps/__init__.py b/phy/apps/__init__.py index de67a26fb..db51602ea 100644 --- a/phy/apps/__init__.py +++ b/phy/apps/__init__.py @@ -1,37 +1,31 @@ -# -*- coding: utf-8 -*- - """CLI tool.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ -from contextlib import contextmanager import logging -from pathlib import Path import sys +from contextlib import contextmanager +from pathlib import Path from traceback import format_exception import click - -from phylib import add_default_handler, _Formatter # noqa -from phylib import _logger_date_fmt, _logger_fmt # noqa +from phylib import _Formatter, _logger_date_fmt, _logger_fmt, add_default_handler # noqa # noqa from phy import __version_git__ from phy.gui.qt import QtDialogLogger -from phy.utils.profiling import _enable_profiler, _enable_pdb - -from .base import ( # noqa - BaseController, WaveformMixin, FeatureMixin, TemplateMixin, TraceMixin) +from phy.utils.profiling import _enable_pdb, _enable_profiler +from .base import BaseController, FeatureMixin, TemplateMixin, TraceMixin, WaveformMixin # noqa logger = logging.getLogger(__name__) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # CLI utils -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ DEBUG = False if '--debug' in sys.argv: # pragma: no cover @@ -53,19 +47,20 @@ sys.argv.remove('--lprof') -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Set up logging with the CLI tool -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def exceptionHandler(exception_type, exception, traceback): # pragma: no cover tb = ''.join(format_exception(exception_type, exception, traceback)) - logger.error("An error has occurred (%s): %s\n%s", exception_type.__name__, exception, tb) + logger.error('An error has occurred (%s): %s\n%s', exception_type.__name__, exception, tb) @contextmanager def capture_exceptions(): # pragma: no cover """Log exceptions instead of crashing the GUI, and display an error dialog on errors.""" - logger.debug("Start capturing exceptions.") + logger.debug('Start capturing exceptions.') # Add a custom exception hook. excepthook = sys.excepthook @@ -84,12 +79,13 @@ def capture_exceptions(): # pragma: no cover # Remove the dialog exception handler. logging.getLogger('phy').removeHandler(handler) - logger.debug("Stop capturing exceptions.") + logger.debug('Stop capturing exceptions.') -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Root CLI tool -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + @click.group() @click.version_option(version=__version_git__) @@ -102,25 +98,30 @@ def phycli(ctx): add_default_handler(level='DEBUG' if DEBUG else 'INFO', logger=logging.getLogger('mtscomp')) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # GUI command wrapper -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ def _gui_command(f): """Command options for GUI commands.""" f = click.option( - '--clear-cache/--no-clear-cache', default=False, - help="Clear the .phy cache in the data directory.")(f) + '--clear-cache/--no-clear-cache', + default=False, + help='Clear the .phy cache in the data directory.', + )(f) f = click.option( - '--clear-state/--no-clear-state', default=False, - help="Clear the GUI state in `~/.phy/` and in `.phy`.")(f) + '--clear-state/--no-clear-state', + default=False, + help='Clear the GUI state in `~/.phy/` and in `.phy`.', + )(f) return f -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Raw data GUI -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + @phycli.command('trace-gui') # pragma: no cover @click.argument('dat-path', type=click.Path(exists=True)) @@ -134,15 +135,17 @@ def _gui_command(f): def cli_trace_gui(ctx, dat_path, **kwargs): """Launch the trace GUI on a raw data file.""" from .trace.gui import trace_gui + with capture_exceptions(): kwargs['n_channels_dat'] = kwargs.pop('n_channels') kwargs['order'] = 'F' if kwargs.pop('fortran', None) else None trace_gui(dat_path, **kwargs) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Template GUI -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + @phycli.command('template-gui') # pragma: no cover @click.argument('params-path', type=click.Path(exists=True)) @@ -151,10 +154,12 @@ def cli_trace_gui(ctx, dat_path, **kwargs): def cli_template_gui(ctx, params_path, **kwargs): """Launch the template GUI on a params.py file.""" from .template.gui import template_gui + prof = __builtins__.get('profile', None) with capture_exceptions(): if prof: from phy.utils.profiling import _profile + return _profile(prof, 'template_gui(params_path)', globals(), locals()) template_gui(params_path, **kwargs) @@ -165,12 +170,14 @@ def cli_template_gui(ctx, params_path, **kwargs): def cli_template_describe(ctx, params_path): """Describe a template file.""" from .template.gui import template_describe + template_describe(params_path) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Kwik GUI -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + # Create the `phy cluster-manual file.kwik` command. @phycli.command('kwik-gui') # pragma: no cover @@ -182,6 +189,7 @@ def cli_template_describe(ctx, params_path): def cli_kwik_gui(ctx, path, channel_group=None, clustering=None, **kwargs): """Launch the Kwik GUI on a Kwik file.""" from .kwik.gui import kwik_gui + with capture_exceptions(): assert path kwik_gui(path, channel_group=channel_group, clustering=clustering, **kwargs) @@ -195,13 +203,15 @@ def cli_kwik_gui(ctx, path, channel_group=None, clustering=None, **kwargs): def cli_kwik_describe(ctx, path, channel_group=0, clustering='main'): """Describe a Kwik file.""" from .kwik.gui import kwik_describe + assert path kwik_describe(path, channel_group=channel_group, clustering=clustering) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Conversion -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + @phycli.command('alf-convert') @click.argument('subdirs', nargs=-1, type=click.Path(exists=True, file_okay=False, dir_okay=True)) @@ -227,9 +237,10 @@ def cli_alf_convert(ctx, subdirs, out_dir): c.convert(out_dir) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Waveform extraction -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + @phycli.command('extract-waveforms') @click.argument('params-path', type=click.Path(exists=True)) @@ -237,11 +248,13 @@ def cli_alf_convert(ctx, subdirs, out_dir): @click.option('--nc', type=int, default=16) @click.pass_context def template_extract_waveforms( - ctx, params_path, n_spikes_per_cluster, nc=None): # pragma: no cover + ctx, params_path, n_spikes_per_cluster, nc=None +): # pragma: no cover """Extract spike waveforms.""" from phylib.io.model import load_model model = load_model(params_path) model.save_spikes_subset_waveforms( - max_n_spikes_per_template=n_spikes_per_cluster, max_n_channels=nc) + max_n_spikes_per_template=n_spikes_per_cluster, max_n_channels=nc + ) model.close() diff --git a/phy/apps/base.py b/phy/apps/base.py index 3acd868a6..824448f62 100644 --- a/phy/apps/base.py +++ b/phy/apps/base.py @@ -1,35 +1,44 @@ -# -*- coding: utf-8 -*- - """Base controller to make clustering GUIs.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ -from functools import partial import inspect import logging import os -from pathlib import Path import shutil +from functools import partial +from pathlib import Path import numpy as np -from scipy.signal import butter, lfilter - from phylib import _add_log_file from phylib.io.array import SpikeSelector, _flatten from phylib.stats import correlograms, firing_rate -from phylib.utils import Bunch, emit, connect, unconnect +from phylib.utils import Bunch, connect, emit, unconnect from phylib.utils._misc import write_tsv +from scipy.signal import butter, lfilter from phy.cluster._utils import RotatingProperty from phy.cluster.supervisor import Supervisor -from phy.cluster.views.base import ManualClusteringView, BaseColorView from phy.cluster.views import ( - WaveformView, FeatureView, TraceView, TraceImageView, CorrelogramView, AmplitudeView, - ScatterView, ProbeView, RasterView, TemplateView, ISIView, FiringRateView, ClusterScatterView, - select_traces) + AmplitudeView, + ClusterScatterView, + CorrelogramView, + FeatureView, + FiringRateView, + ISIView, + ProbeView, + RasterView, + ScatterView, + TemplateView, + TraceImageView, + TraceView, + WaveformView, + select_traces, +) +from phy.cluster.views.base import BaseColorView, ManualClusteringView from phy.cluster.views.trace import _iter_spike_waveforms from phy.gui import GUI from phy.gui.gui import _prompt_save @@ -42,9 +51,10 @@ logger = logging.getLogger(__name__) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Utils -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def _concatenate_parents_attributes(cls, name): """Return the concatenation of class attributes of a given name among all parents of a @@ -54,7 +64,7 @@ def _concatenate_parents_attributes(cls, name): class Selection(Bunch): def __init__(self, controller): - super(Selection, self).__init__() + super().__init__() self.controller = controller @property @@ -67,19 +77,20 @@ class StatusBarHandler(logging.Handler): def __init__(self, gui): self.gui = gui - super(StatusBarHandler, self).__init__() + super().__init__() def emit(self, record): self.gui.status_message = self.format(record) -#-------------------------------------------------------------------------- +# -------------------------------------------------------------------------- # Raw data filtering -#-------------------------------------------------------------------------- +# -------------------------------------------------------------------------- + class RawDataFilter(RotatingProperty): def __init__(self): - super(RawDataFilter, self).__init__() + super().__init__() self.add('raw', lambda x, axis=None: x) def add_default_filter(self, sample_rate): @@ -92,6 +103,7 @@ def high_pass(arr, axis=0): arr = lfilter(b, a, arr, axis=axis) arr = np.flip(arr, axis=axis) return arr + self.set('high_pass') def add_filter(self, fun=None, name=None): @@ -99,7 +111,7 @@ def add_filter(self, fun=None, name=None): if fun is None: # pragma: no cover return partial(self.add_filter, name=name) name = name or fun.__name__ - logger.debug("Add filter `%s`.", name) + logger.debug('Add filter `%s`.', name) self.add(name, fun) def apply(self, arr, axis=None, name=None): @@ -107,31 +119,31 @@ def apply(self, arr, axis=None, name=None): self.set(name or self.current) fun = self.get() if fun: - logger.log(5, "Applying filter `%s` to raw data.", self.current) + logger.log(5, 'Applying filter `%s` to raw data.', self.current) arrf = fun(arr, axis=axis) assert arrf.shape == arr.shape arr = arrf return arr -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # View mixins -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + -class WaveformMixin(object): +class WaveformMixin: n_spikes_waveforms = 100 batch_size_waveforms = 10 _state_params = ( - 'n_spikes_waveforms', 'batch_size_waveforms', + 'n_spikes_waveforms', + 'batch_size_waveforms', ) _new_views = ('WaveformView',) # Map an amplitude type to a method name. - _amplitude_functions = ( - ('raw', 'get_spike_raw_amplitudes'), - ) + _amplitude_functions = (('raw', 'get_spike_raw_amplitudes'),) _waveform_functions = ( ('waveforms', '_get_waveforms'), @@ -179,8 +191,8 @@ def get_mean_spike_raw_amplitudes(self, cluster_id): return np.mean(self.get_spike_raw_amplitudes(spike_ids)) def _get_waveforms_with_n_spikes( - self, cluster_id, n_spikes_waveforms, current_filter=None): - + self, cluster_id, n_spikes_waveforms, current_filter=None + ): # HACK: we pass self.raw_data_filter.current_filter so that it is cached properly. pos = self.model.channel_positions @@ -188,11 +200,14 @@ def _get_waveforms_with_n_spikes( if self.model.spike_waveforms is not None: subset_spikes = self.model.spike_waveforms.spike_ids spike_ids = self.selector( - n_spikes_waveforms, [cluster_id], subset_spikes=subset_spikes) + n_spikes_waveforms, [cluster_id], subset_spikes=subset_spikes + ) # Or keep spikes from a subset of the chunks for performance reasons (decompression will # happen on the fly here). else: - spike_ids = self.selector(n_spikes_waveforms, [cluster_id], subset_chunks=True) + spike_ids = self.selector( + n_spikes_waveforms, [cluster_id], subset_chunks=True + ) # Get the best channels. channel_ids = self.get_best_channels(cluster_id) @@ -217,23 +232,27 @@ def _get_waveforms_with_n_spikes( def _get_waveforms(self, cluster_id): """Return a selection of waveforms for a cluster.""" return self._get_waveforms_with_n_spikes( - cluster_id, self.n_spikes_waveforms, current_filter=self.raw_data_filter.current) + cluster_id, + self.n_spikes_waveforms, + current_filter=self.raw_data_filter.current, + ) def _get_mean_waveforms(self, cluster_id, current_filter=None): """Get the mean waveform of a cluster on its best channels.""" b = self._get_waveforms(cluster_id) if b.data is not None: b.data = b.data.mean(axis=0)[np.newaxis, ...] - b['alpha'] = 1. + b['alpha'] = 1.0 return b def _set_view_creator(self): - super(WaveformMixin, self)._set_view_creator() + super()._set_view_creator() self.view_creator['WaveformView'] = self.create_waveform_view def _get_waveforms_dict(self): waveform_functions = _concatenate_parents_attributes( - self.__class__, '_waveform_functions') + self.__class__, '_waveform_functions' + ) return {name: getattr(self, method) for name, method in waveform_functions} def create_waveform_view(self): @@ -255,7 +274,10 @@ def on_view_attached(view_, gui): # NOTE: this callback function is called in WaveformView.attach(). @view.actions.add( - alias='wn', prompt=True, prompt_default=lambda: str(self.n_spikes_waveforms)) + alias='wn', + prompt=True, + prompt_default=lambda: str(self.n_spikes_waveforms), + ) def change_n_spikes_waveforms(n_spikes_waveforms): """Change the number of spikes displayed in the waveform view.""" self.n_spikes_waveforms = n_spikes_waveforms @@ -271,19 +293,18 @@ def on_close_view(view_, gui): return view -class FeatureMixin(object): +class FeatureMixin: n_spikes_features = 2500 n_spikes_features_background = 2500 _state_params = ( - 'n_spikes_features', 'n_spikes_features_background', + 'n_spikes_features', + 'n_spikes_features_background', ) _new_views = ('FeatureView',) - _amplitude_functions = ( - ('feature', 'get_spike_feature_amplitudes'), - ) + _amplitude_functions = (('feature', 'get_spike_feature_amplitudes'),) _cached = ( '_get_features', @@ -291,7 +312,8 @@ class FeatureMixin(object): ) def get_spike_feature_amplitudes( - self, spike_ids, channel_id=None, channel_ids=None, pc=None, **kwargs): + self, spike_ids, channel_id=None, channel_ids=None, pc=None, **kwargs + ): """Return the features for the specified channel and PC.""" if self.model.features is None: return @@ -300,11 +322,11 @@ def get_spike_feature_amplitudes( if features is None: # pragma: no cover return assert features.shape[0] == len(spike_ids) - logger.log(5, "Show channel %s and PC %s in amplitude view.", channel_id, pc) + logger.log(5, 'Show channel %s and PC %s in amplitude view.', channel_id, pc) return features[:, 0, pc or 0] def create_amplitude_view(self): - view = super(FeatureMixin, self).create_amplitude_view() + view = super().create_amplitude_view() if self.model.features is None: return view @@ -338,7 +360,7 @@ def _get_feature_view_spike_ids(self, cluster_id=None, load_all=False): assert len(spike_ids) spike_ids = np.intersect1d(spike_ids, self.model.spike_waveforms.spike_ids) if len(spike_ids) == 0: - logger.debug("empty spikes for cluster %s", str(cluster_id)) + logger.debug('empty spikes for cluster %s', str(cluster_id)) return spike_ids # Retrieve features from the self.model.features array. elif self.model.features is not None: @@ -357,13 +379,13 @@ def _get_feature_view_spike_times(self, cluster_id=None, load_all=False): return spike_times = self._get_spike_times_reordered(spike_ids) return Bunch( - data=spike_times, - spike_ids=spike_ids, - lim=(0., self.model.duration)) + data=spike_times, spike_ids=spike_ids, lim=(0.0, self.model.duration) + ) def _get_spike_features(self, spike_ids, channel_ids): if len(spike_ids) == 0: # pragma: no cover return Bunch() + channel_ids = np.asarray(channel_ids, dtype=np.int64) data = self.model.get_features(spike_ids, channel_ids) assert data.shape[:2] == (len(spike_ids), len(channel_ids)) # Replace NaN values by zeros. @@ -372,7 +394,11 @@ def _get_spike_features(self, spike_ids, channel_ids): assert np.isnan(data).sum() == 0 channel_labels = self._get_channel_labels(channel_ids) return Bunch( - data=data, spike_ids=spike_ids, channel_ids=channel_ids, channel_labels=channel_labels) + data=data, + spike_ids=spike_ids, + channel_ids=channel_ids, + channel_labels=channel_labels, + ) def _get_features(self, cluster_id=None, channel_ids=None, load_all=False): """Return the features of a given cluster on specified channels.""" @@ -386,12 +412,15 @@ def _get_features(self, cluster_id=None, channel_ids=None, load_all=False): return self._get_spike_features(spike_ids, channel_ids) def create_feature_view(self): - if self.model.features is None and getattr(self.model, 'spike_waveforms', None) is None: + if ( + self.model.features is None + and getattr(self.model, 'spike_waveforms', None) is None + ): # NOTE: we can still construct the feature view when there are spike waveforms. return view = FeatureView( features=self._get_features, - attributes={'time': self._get_feature_view_spike_times} + attributes={'time': self._get_feature_view_spike_times}, ) @connect @@ -420,11 +449,11 @@ def on_close_view(view_, gui): return view def _set_view_creator(self): - super(FeatureMixin, self)._set_view_creator() + super()._set_view_creator() self.view_creator['FeatureView'] = self.create_feature_view -class TemplateMixin(object): +class TemplateMixin: """Support templates. The model needs to implement specific properties and methods. @@ -443,13 +472,9 @@ class TemplateMixin(object): _new_views = ('TemplateView',) - _amplitude_functions = ( - ('template', 'get_spike_template_amplitudes'), - ) + _amplitude_functions = (('template', 'get_spike_template_amplitudes'),) - _waveform_functions = ( - ('templates', '_get_template_waveforms'), - ) + _waveform_functions = (('templates', '_get_template_waveforms'),) _cached = ( 'get_amplitudes', @@ -467,10 +492,10 @@ class TemplateMixin(object): ) def __init__(self, *args, **kwargs): - super(TemplateMixin, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) def _get_amplitude_functions(self): - out = super(TemplateMixin, self)._get_amplitude_functions() + out = super()._get_amplitude_functions() if getattr(self.model, 'template_features', None) is not None: out['template_feature'] = self.get_spike_template_features return out @@ -507,7 +532,7 @@ def get_cluster_amplitude(self, cluster_id): def _set_cluster_metrics(self): """Add an amplitude column in the cluster view.""" - super(TemplateMixin, self)._set_cluster_metrics() + super()._set_cluster_metrics() self.cluster_metrics['amp'] = self.get_cluster_amplitude def get_spike_template_amplitudes(self, spike_ids, **kwargs): @@ -554,7 +579,9 @@ def _get_template_waveforms(self, cluster_id): masks = count / float(count.max()) masks = np.tile(masks.reshape((-1, 1)), (1, len(channel_ids))) # Get all templates from which this cluster stems from. - templates = [self.model.get_template(template_id) for template_id in template_ids] + templates = [ + self.model.get_template(template_id) for template_id in template_ids + ] # Construct the waveforms array. ns = self.model.n_samples_waveforms data = np.zeros((len(template_ids), ns, self.model.n_channels)) @@ -567,7 +594,9 @@ def _get_template_waveforms(self, cluster_id): channel_ids=channel_ids, channel_labels=self._get_channel_labels(channel_ids), channel_positions=pos[channel_ids], - masks=masks, alpha=1.) + masks=masks, + alpha=1.0, + ) def _get_all_templates(self, cluster_ids): """Get the template waveforms of a set of clusters.""" @@ -581,7 +610,7 @@ def _get_all_templates(self, cluster_ids): return out def _set_view_creator(self): - super(TemplateMixin, self)._set_view_creator() + super()._set_view_creator() self.view_creator['TemplateView'] = self.create_template_view def create_template_view(self): @@ -596,27 +625,31 @@ def create_template_view(self): return view -class TraceMixin(object): - +class TraceMixin: _new_views = ('TraceView', 'TraceImageView') waveform_duration = 1.0 # in milliseconds def _get_traces(self, interval, show_all_spikes=False): """Get traces and spike waveforms.""" traces_interval = select_traces( - self.model.traces, interval, sample_rate=self.model.sample_rate) + self.model.traces, interval, sample_rate=self.model.sample_rate + ) # Filter the loaded traces. traces_interval = self.raw_data_filter.apply(traces_interval, axis=0) out = Bunch(data=traces_interval) - out.waveforms = list(_iter_spike_waveforms( - interval=interval, - traces_interval=traces_interval, - model=self.model, - supervisor=self.supervisor, - n_samples_waveforms=int(round(1e-3 * self.waveform_duration * self.model.sample_rate)), - get_best_channels=self.get_channel_amplitudes, - show_all_spikes=show_all_spikes, - )) + out.waveforms = list( + _iter_spike_waveforms( + interval=interval, + traces_interval=traces_interval, + model=self.model, + supervisor=self.supervisor, + n_samples_waveforms=int( + round(1e-3 * self.waveform_duration * self.model.sample_rate) + ), + get_best_channels=self.get_channel_amplitudes, + show_all_spikes=show_all_spikes, + ) + ) return out def _trace_spike_times(self): @@ -646,6 +679,7 @@ def create_trace_view(self): # Update the get_traces() function with show_all_spikes. def _get_traces(interval): return self._get_traces(interval, show_all_spikes=view.show_all_spikes) + view.traces = _get_traces view.ex_status = self.raw_data_filter.current @@ -697,16 +731,17 @@ def on_close_view(view_, gui): return view def _set_view_creator(self): - super(TraceMixin, self)._set_view_creator() + super()._set_view_creator() self.view_creator['TraceView'] = self.create_trace_view self.view_creator['TraceImageView'] = self.create_trace_image_view -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Base Controller -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + -class BaseController(object): +class BaseController: """Base controller for manual clustering GUI. Constructor @@ -821,8 +856,7 @@ class BaseController(object): # Pairs (amplitude_type_name, method_name) where amplitude methods return spike amplitudes # of a given type. - _amplitude_functions = ( - ) + _amplitude_functions = () n_spikes_correlograms = 100000 @@ -832,7 +866,8 @@ class BaseController(object): # Controller attributes to load/save in the GUI state. _state_params = ( - 'n_spikes_amplitudes', 'n_spikes_correlograms', + 'n_spikes_amplitudes', + 'n_spikes_correlograms', 'raw_data_filter_name', ) @@ -853,8 +888,12 @@ class BaseController(object): # Views to load by default. _new_views = ( - 'ClusterScatterView', 'CorrelogramView', 'AmplitudeView', - 'ISIView', 'FiringRateView', 'ProbeView', + 'ClusterScatterView', + 'CorrelogramView', + 'AmplitudeView', + 'ISIView', + 'FiringRateView', + 'ProbeView', ) default_shortcuts = { @@ -864,10 +903,15 @@ class BaseController(object): default_snippets = {} def __init__( - self, dir_path=None, config_dir=None, model=None, - clear_cache=None, clear_state=None, - enable_threading=True, **kwargs): - + self, + dir_path=None, + config_dir=None, + model=None, + clear_cache=None, + clear_state=None, + enable_threading=True, + **kwargs, + ): self._enable_threading = enable_threading assert dir_path @@ -878,7 +922,9 @@ def __init__( _add_log_file(Path(dir_path) / 'phy.log') # Create or reuse a Model instance (any object) - self.model = self._create_model(dir_path=dir_path, **kwargs) if model is None else model + self.model = ( + self._create_model(dir_path=dir_path, **kwargs) if model is None else model + ) # Set up the cache. self._set_cache(clear_cache) @@ -899,7 +945,9 @@ def __init__( # The controller.default_views can be set by the child class, otherwise it is computed # by concatenating all parents _new_views. if getattr(self, 'default_views', None) is None: - self.default_views = _concatenate_parents_attributes(self.__class__, '_new_views') + self.default_views = _concatenate_parents_attributes( + self.__class__, '_new_views' + ) self._async_callers = {} self.config_dir = config_dir @@ -907,15 +955,19 @@ def __init__( if clear_state: self._clear_state() - self.selection = Selection(self) # keep track of selected clusters, spikes, channels, etc. + self.selection = Selection( + self + ) # keep track of selected clusters, spikes, channels, etc. # Attach plugins before setting up the supervisor, so that plugins # can register callbacks to events raised during setup. # For example, 'request_cluster_metrics' to specify custom metrics # in the cluster and similarity views. self.attached_plugins = attach_plugins( - self, config_dir=config_dir, - plugins=kwargs.get('plugins', None), dirs=kwargs.get('plugin_dirs', None), + self, + config_dir=config_dir, + plugins=kwargs.get('plugins'), + dirs=kwargs.get('plugin_dirs'), ) # Cache the methods specified in self._memcached and self._cached. All method names @@ -938,19 +990,19 @@ def _create_model(self, dir_path=None, **kwargs): return def _clear_cache(self): - logger.warn("Deleting the cache directory %s.", self.cache_dir) + logger.warning('Deleting the cache directory %s.', self.cache_dir) shutil.rmtree(self.cache_dir, ignore_errors=True) def _clear_state(self): """Clear the global and local GUI state files.""" state_path = _gui_state_path(self.gui_name, config_dir=self.config_dir) if state_path.exists(): - logger.warning("Deleting %s.", state_path) + logger.warning('Deleting %s.', state_path) state_path.unlink() local_path = self.cache_dir / 'state.json' if local_path.exists(): local_path.unlink() - logger.warning("Deleting %s.", local_path) + logger.warning('Deleting %s.', local_path) def _set_cache(self, clear_cache=None): """Set up the cache, clear it if required, and create the Context instance.""" @@ -969,7 +1021,9 @@ def _set_view_creator(self): 'ClusterScatterView': self.create_cluster_scatter_view, 'CorrelogramView': self.create_correlogram_view, 'ISIView': self._make_histogram_view(ISIView, self._get_isi), - 'FiringRateView': self._make_histogram_view(FiringRateView, self._get_firing_rate), + 'FiringRateView': self._make_histogram_view( + FiringRateView, self._get_firing_rate + ), 'AmplitudeView': self.create_amplitude_view, 'ProbeView': self.create_probe_view, 'RasterView': self.create_raster_view, @@ -977,8 +1031,10 @@ def _set_view_creator(self): } # Spike attributes. for name, arr in getattr(self.model, 'spike_attributes', {}).items(): - view_name = 'Spike%sView' % name.title() - self.view_creator[view_name] = self._make_spike_attributes_view(view_name, name, arr) + view_name = f'Spike{name.title()}View' + self.view_creator[view_name] = self._make_spike_attributes_view( + view_name, name, arr + ) def _set_cluster_metrics(self): """Set the cluster metrics dictionary with some default metrics.""" @@ -1035,7 +1091,8 @@ def _set_selector(self): def spikes_per_cluster(cluster_id): return self.supervisor.clustering.spikes_per_cluster.get( - cluster_id, np.array([], dtype=np.int64)) + cluster_id, np.array([], dtype=np.int64) + ) try: chunk_bounds = self.model.traces.chunk_bounds @@ -1046,7 +1103,8 @@ def spikes_per_cluster(cluster_id): get_spikes_per_cluster=spikes_per_cluster, spike_times=self.model.spike_samples, # NOTE: chunk_bounds is in samples, not seconds chunk_bounds=chunk_bounds, - n_chunks_kept=self.n_chunks_kept) + n_chunks_kept=self.n_chunks_kept, + ) def _cache_methods(self): """Cache methods as specified in `self._memcached` and `self._cached`.""" @@ -1060,12 +1118,13 @@ def _get_channel_labels(self, channel_ids=None): """Return the labels of a list of channels.""" if channel_ids is None: channel_ids = np.arange(self.model.n_channels) - if (hasattr(self.model, 'channel_mapping') and - getattr(self.model, 'show_mapped_channels', self.default_show_mapped_channels)): + if hasattr(self.model, 'channel_mapping') and getattr( + self.model, 'show_mapped_channels', self.default_show_mapped_channels + ): channel_labels = self.model.channel_mapping[channel_ids] else: channel_labels = channel_ids - return ['%d' % ch for ch in channel_labels] + return [f'{ch}' for ch in channel_labels] # Internal view methods # ------------------------------------------------------------------------- @@ -1097,6 +1156,7 @@ def _update_plot(): view.set_cluster_ids(self.supervisor.shown_cluster_ids) # Replot the view entirely. view.plot() + if is_async: ac.set(_update_plot) else: @@ -1169,8 +1229,12 @@ def _save_cluster_info(self): for d in cluster_info: d['cluster_id'] = d.pop('id') write_tsv( - self.dir_path / 'cluster_info.tsv', cluster_info, - first_field='cluster_id', exclude_fields=('is_masked',), n_significant_figures=8) + self.dir_path / 'cluster_info.tsv', + cluster_info, + first_field='cluster_id', + exclude_fields=('is_masked',), + n_significant_figures=8, + ) # Model methods # ------------------------------------------------------------------------- @@ -1196,19 +1260,18 @@ def get_best_channel_label(self, cluster_id): def get_best_channels(self, cluster_id): # pragma: no cover """Return the best channels of a given cluster. To be overridden.""" logger.warning( - "This method should be overridden and return a non-empty list of best channels.") + 'This method should be overridden and return a non-empty list of best channels.' + ) return [] def get_channel_amplitudes(self, cluster_id): # pragma: no cover """Return the best channels of a given cluster along with their relative amplitudes. To be overridden.""" - logger.warning( - "This method should be overridden.") + logger.warning('This method should be overridden.') return [] def get_channel_shank(self, cluster_id): - """Return the shank of a cluster's best channel, if the channel_shanks array is available. - """ + """Return the shank of a cluster's best channel, if the channel_shanks array is available.""" best_channel_id = self.get_best_channel(cluster_id) return self.model.channel_shanks[best_channel_id] @@ -1220,8 +1283,10 @@ def get_probe_depth(self, cluster_id): def get_clusters_on_channel(self, channel_id): """Return all clusters which have the specified channel among their best channels.""" return [ - cluster_id for cluster_id in self.supervisor.clustering.cluster_ids - if channel_id in self.get_best_channels(cluster_id)] + cluster_id + for cluster_id in self.supervisor.clustering.cluster_ids + if channel_id in self.get_best_channels(cluster_id) + ] # Default similarity functions # ------------------------------------------------------------------------- @@ -1243,8 +1308,10 @@ def peak_channel_similarity(self, cluster_id): """ ch = self.get_best_channel(cluster_id) return [ - (other, 1.) for other in self.supervisor.clustering.cluster_ids - if ch in self.get_best_channels(other)] + (other, 1.0) + for other in self.supervisor.clustering.cluster_ids + if ch in self.get_best_channels(other) + ] # Public spike methods # ------------------------------------------------------------------------- @@ -1269,8 +1336,10 @@ def get_background_spike_ids(self, n=None): def _get_spike_times_reordered(self, spike_ids): """Get spike times, reordered if needed.""" spike_times = self.model.spike_times - if (self.selection.get('do_reorder', None) and - getattr(self.model, 'spike_times_reordered', None) is not None): + if ( + self.selection.get('do_reorder', None) + and getattr(self.model, 'spike_times_reordered', None) is not None + ): spike_times = self.model.spike_times_reordered spike_times = spike_times[spike_ids] return spike_times @@ -1279,7 +1348,8 @@ def _get_amplitude_functions(self): """Return a dictionary mapping amplitude names to corresponding methods.""" # Concatenation of all _amplitude_functions attributes in the class hierarchy. amplitude_functions = _concatenate_parents_attributes( - self.__class__, '_amplitude_functions') + self.__class__, '_amplitude_functions' + ) return {name: getattr(self, method) for name, method in amplitude_functions} def _get_amplitude_spike_ids(self, cluster_id, load_all=False): @@ -1305,7 +1375,9 @@ def _amplitude_getter(self, cluster_ids, name=None, load_all=False): out = [] n = self.n_spikes_amplitudes if not load_all else None # Find the first cluster, used to determine the best channels. - first_cluster = next(cluster_id for cluster_id in cluster_ids if cluster_id is not None) + first_cluster = next( + cluster_id for cluster_id in cluster_ids if cluster_id is not None + ) # Best channels of the first cluster. channel_ids = self.get_best_channels(first_cluster) # Best channel of the first cluster. @@ -1332,11 +1404,19 @@ def _amplitude_getter(self, cluster_ids, name=None, load_all=False): if cluster_id is not None: # Cluster spikes. spike_ids = self.get_spike_ids( - cluster_id, n=n, subset_spikes=subset_spikes, subset_chunks=subset_chunks) + cluster_id, + n=n, + subset_spikes=subset_spikes, + subset_chunks=subset_chunks, + ) else: # Background spikes. spike_ids = self.selector( - n, other_clusters, subset_spikes=subset_spikes, subset_chunks=subset_chunks) + n, + other_clusters, + subset_spikes=subset_spikes, + subset_chunks=subset_chunks, + ) # Get the spike times. spike_times = self._get_spike_times_reordered(spike_ids) if name in ('feature', 'raw'): @@ -1346,23 +1426,30 @@ def _amplitude_getter(self, cluster_ids, name=None, load_all=False): pc = self.selection.get('feature_pc', None) # Call the spike amplitude getter function. amplitudes = f( - spike_ids, channel_ids=channel_ids, channel_id=channel_id, pc=pc, - first_cluster=first_cluster) + spike_ids, + channel_ids=channel_ids, + channel_id=channel_id, + pc=pc, + first_cluster=first_cluster, + ) if amplitudes is None: continue assert amplitudes.shape == spike_ids.shape == spike_times.shape - out.append(Bunch( - amplitudes=amplitudes, - spike_ids=spike_ids, - spike_times=spike_times, - )) + out.append( + Bunch( + amplitudes=amplitudes, + spike_ids=spike_ids, + spike_times=spike_times, + ) + ) return out def create_amplitude_view(self): """Create the amplitude view.""" amplitudes_dict = { name: partial(self._amplitude_getter, name=name) - for name in sorted(self._get_amplitude_functions())} + for name in sorted(self._get_amplitude_functions()) + } if not amplitudes_dict: return # NOTE: we disable raw amplitudes for now as they're either too slow to load, @@ -1479,15 +1566,21 @@ def _get_correlograms(self, cluster_ids, bin_size, window_size): st = self.model.spike_times[spike_ids] sc = self.supervisor.clustering.spike_clusters[spike_ids] return correlograms( - st, sc, sample_rate=self.model.sample_rate, cluster_ids=cluster_ids, - bin_size=bin_size, window_size=window_size) + st, + sc, + sample_rate=self.model.sample_rate, + cluster_ids=cluster_ids, + bin_size=bin_size, + window_size=window_size, + ) def _get_correlograms_rate(self, cluster_ids, bin_size): """Return the baseline firing rate of the cross- and auto-correlograms of clusters.""" spike_ids = self.selector(self.n_spikes_correlograms, cluster_ids) sc = self.supervisor.clustering.spike_clusters[spike_ids] return firing_rate( - sc, cluster_ids=cluster_ids, bin_size=bin_size, duration=self.model.duration) + sc, cluster_ids=cluster_ids, bin_size=bin_size, duration=self.model.duration + ) def create_correlogram_view(self): """Create a correlogram view.""" @@ -1513,8 +1606,10 @@ def create_probe_view(self): def _make_histogram_view(self, view_cls, method): """Return a function that creates a HistogramView of a given class.""" + def _make(): return view_cls(cluster_stat=method) + return _make def _get_isi(self, cluster_id): @@ -1534,6 +1629,7 @@ def _get_firing_rate(self, cluster_id): def _make_spike_attributes_view(self, view_name, name, arr): """Create a special class deriving from ScatterView for each spike attribute.""" + def coords(cluster_ids, load_all=False): n = self.n_spikes_amplitudes if not load_all else None bunchs = [] @@ -1553,6 +1649,7 @@ def coords(cluster_ids, load_all=False): def _make(): return view_cls(coords=coords) + return _make # IPython View @@ -1563,8 +1660,12 @@ def create_ipython_view(self): view = IPythonView() view.start_kernel() view.inject( - controller=self, c=self, m=self.model, s=self.supervisor, - emit=emit, connect=connect, + controller=self, + c=self, + m=self.model, + s=self.supervisor, + emit=emit, + connect=connect, ) return view @@ -1577,6 +1678,7 @@ def at_least_one_view(self, view_name): To be called before creating a GUI. """ + @connect(sender=self) def on_gui_ready(sender, gui): # Add a view automatically. @@ -1584,14 +1686,17 @@ def on_gui_ready(sender, gui): gui.create_and_add_view(view_name) def create_misc_actions(self, gui): - # Toggle spike reorder. @gui.view_actions.add( shortcut=self.default_shortcuts['toggle_spike_reorder'], - checkable=True, checked=False) + checkable=True, + checked=False, + ) def toggle_spike_reorder(checked): """Toggle spike time reordering.""" - logger.debug("%s spike time reordering.", 'Enable' if checked else 'Disable') + logger.debug( + '%s spike time reordering.', 'Enable' if checked else 'Disable' + ) emit('toggle_spike_reorder', self, checked) # Action to switch the raw data filter in the trace and waveform views. @@ -1608,7 +1713,9 @@ def switch_raw_data_filter(): # Update the waveform view. for v in gui.list_views(WaveformView): if v.auto_update: - v.on_select_threaded(self.supervisor, self.supervisor.selected, gui=gui) + v.on_select_threaded( + self.supervisor, self.supervisor.selected, gui=gui + ) v.ex_status = filter_name v.update_status() @@ -1623,11 +1730,13 @@ def _add_default_color_schemes(self, view): None: 3, 'unsorted': 3, } - logger.debug("Adding default color schemes to %s.", view.name) + logger.debug('Adding default color schemes to %s.', view.name) def group_index(cluster_id): group = self.supervisor.cluster_meta.get('group', cluster_id) - return group_colors.get(group, 0) # TODO: better handling of colors for custom groups + return group_colors.get( + group, 0 + ) # TODO: better handling of colors for custom groups depth = self.supervisor.cluster_metrics['depth'] fr = self.supervisor.cluster_metrics['fr'] @@ -1640,8 +1749,13 @@ def group_index(cluster_id): ] for name, colormap, fun, categorical, logarithmic in schemes: view.add_color_scheme( - name=name, fun=fun, cluster_ids=self.supervisor.clustering.cluster_ids, - colormap=colormap, categorical=categorical, logarithmic=logarithmic) + name=name, + fun=fun, + cluster_ids=self.supervisor.clustering.cluster_ids, + colormap=colormap, + categorical=categorical, + logarithmic=logarithmic, + ) # Default color scheme. if not hasattr(view, 'color_scheme_name'): view.color_schemes.set('random') @@ -1663,11 +1777,13 @@ def create_gui(self, default_views=None, **kwargs): subtitle=str(self.dir_path), config_dir=self.config_dir, local_path=self.cache_dir / 'state.json', - default_state_path=Path(inspect.getfile(self.__class__)).parent / 'static/state.json', + default_state_path=Path(inspect.getfile(self.__class__)).parent + / 'static/state.json', view_creator=self.view_creator, default_views=default_views, enable_threading=self._enable_threading, - **kwargs) + **kwargs, + ) # Set all state parameters from the GUI state. state_params = _concatenate_parents_attributes(self.__class__, '_state_params') @@ -1690,8 +1806,13 @@ def on_view_attached(view, gui_): if isinstance(view, ManualClusteringView): # Add auto update button. view.dock.add_button( - name='auto_update', icon='f021', checkable=True, checked=view.auto_update, - event='toggle_auto_update', callback=view.toggle_auto_update) + name='auto_update', + icon='f021', + checkable=True, + checked=view.auto_update, + event='toggle_auto_update', + callback=view.toggle_auto_update, + ) # Show selected clusters when adding new views in the GUI. view.on_select(cluster_ids=self.supervisor.selected_clusters) @@ -1736,12 +1857,15 @@ def on_close(sender): # Save the memcache when closing the GUI. @connect(sender=gui) # noqa def on_close(sender): # noqa - # Gather all GUI state attributes from views that are local and thus need # to be saved in the data directory. - for view in gui.views: - local_keys = getattr(view, 'local_state_attrs', []) - local_keys = ['%s.%s' % (view.name, key) for key in local_keys] + for view in list(gui.views): + try: + local_keys = getattr(view, 'local_state_attrs', []) + view_name = view.name + except RuntimeError: + continue + local_keys = [f'{view_name}.{key}' for key in local_keys] gui.state.add_local_keys(local_keys) # Update the controller params in the GUI state. diff --git a/phy/apps/kwik/__init__.py b/phy/apps/kwik/__init__.py index 3df209f7d..636b8028f 100644 --- a/phy/apps/kwik/__init__.py +++ b/phy/apps/kwik/__init__.py @@ -1,11 +1,10 @@ -# -*- coding: utf-8 -*- # flake8: noqa """Kwik GUI.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ from .gui import KwikController, kwik_describe, kwik_gui # noqa diff --git a/phy/apps/kwik/gui.py b/phy/apps/kwik/gui.py index 870ed6fb8..3fb1958c5 100644 --- a/phy/apps/kwik/gui.py +++ b/phy/apps/kwik/gui.py @@ -1,27 +1,25 @@ -# -*- coding: utf-8 -*- - """Kwik GUI.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ import logging -from pathlib import Path import shutil +from pathlib import Path from tempfile import TemporaryDirectory import numpy as np - from phylib.stats.clusters import get_waveform_amplitude from phylib.utils import Bunch, connect from phylib.utils.geometry import linear_positions -from phy.utils.context import Context -from phy.gui import create_app, run_app -from ..base import WaveformMixin, FeatureMixin, TraceMixin, BaseController from phy.cluster.supervisor import Supervisor +from phy.gui import create_app, run_app +from phy.utils.context import Context + +from ..base import BaseController, FeatureMixin, TraceMixin, WaveformMixin logger = logging.getLogger(__name__) @@ -29,23 +27,31 @@ from klusta.kwik import KwikModel from klusta.launch import cluster except ImportError: # pragma: no cover - logger.debug("Package klusta not installed: the KwikGUI will not work.") + KwikModel = None + cluster = None + logger.debug('Package klusta not installed: the KwikGUI will not work.') -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Kwik GUI -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def _backup(path): """Backup a file.""" assert path.exists() - path_backup = str(path) + '.bak' + path_backup = f'{str(path)}.bak' if not Path(path_backup).exists(): - logger.info("Backup `%s`.", path_backup) + logger.info('Backup `%s`.', path_backup) shutil.copy(str(path), str(path_backup)) -class KwikModelGUI(KwikModel): +def _require_klusta(): + if KwikModel is None: # pragma: no cover + raise ImportError('Package klusta is required for the Kwik GUI.') + + +class KwikModelGUI(KwikModel if KwikModel is not None else object): @property def features(self): return self.all_features @@ -97,12 +103,13 @@ class KwikController(WaveformMixin, FeatureMixin, TraceMixin, BaseController): ) def __init__(self, kwik_path=None, **kwargs): + _require_klusta() assert kwik_path kwik_path = Path(kwik_path) dir_path = kwik_path.parent - self.channel_group = kwargs.get('channel_group', None) - self.clustering = kwargs.get('clustering', None) - super(KwikController, self).__init__(kwik_path=kwik_path, dir_path=dir_path, **kwargs) + self.channel_group = kwargs.get('channel_group') + self.clustering = kwargs.get('clustering') + super().__init__(kwik_path=kwik_path, dir_path=dir_path, **kwargs) # Internal methods # ------------------------------------------------------------------------- @@ -113,7 +120,7 @@ def _set_cache(self, clear_cache=None): if self.channel_group is not None: self.cache_dir = self.cache_dir / str(self.channel_group) if clear_cache: - logger.warn("Deleting the cache directory %s.", self.cache_dir) + logger.warning('Deleting the cache directory %s.', self.cache_dir) shutil.rmtree(self.cache_dir, ignore_errors=True) self.context = Context(self.cache_dir) @@ -124,7 +131,7 @@ def _create_model(self, **kwargs): model = KwikModelGUI(str(kwik_path), **kwargs) # HACK: handle badly formed channel positions if model.channel_positions.ndim == 1: # pragma: no cover - logger.warning("Unable to read the channel positions, generating mock ones.") + logger.warning('Unable to read the channel positions, generating mock ones.') model.probe.positions = linear_positions(len(model.channel_positions)) return model @@ -158,7 +165,7 @@ def recluster(cluster_ids=None): # Selected clusters. cluster_ids = supervisor.selected spike_ids = self.selector(None, cluster_ids) - logger.info("Running KlustaKwik on %d spikes.", len(spike_ids)) + logger.info('Running KlustaKwik on %d spikes.', len(spike_ids)) # Run KK2 in a temporary directory to avoid side effects. n = 10 @@ -180,13 +187,23 @@ def _get_masks(self, cluster_id): return self.model.all_masks[spike_ids] def _get_mean_masks(self, cluster_id): - return np.mean(self._get_masks(cluster_id), axis=0) + masks = self._get_masks(cluster_id) + if not len(masks): + return np.zeros((self.model.n_channels,), dtype=np.float32) + return np.mean(masks, axis=0) def _get_waveforms(self, cluster_id): """Return a selection of waveforms for a cluster.""" pos = self.model.channel_positions spike_ids = self.selector(self.n_spikes_waveforms, [cluster_id]) data = self.model.all_waveforms[spike_ids] + if not len(data): + return Bunch( + data=np.zeros((0, 0, self.model.n_channels), dtype=np.float32), + channel_ids=np.arange(self.model.n_channels), + channel_positions=pos, + masks=np.zeros((0, self.model.n_channels), dtype=np.float32), + ) mm = self._get_mean_masks(cluster_id) mw = np.mean(data, axis=0) amp = get_waveform_amplitude(mm, mw) @@ -202,9 +219,11 @@ def _get_waveforms(self, cluster_id): def _get_mean_waveforms(self, cluster_id): b = self._get_waveforms(cluster_id).copy() + if not len(b.data): + return b b.data = np.mean(b.data, axis=0)[np.newaxis, ...] - b.masks = np.mean(b.masks, axis=0)[np.newaxis, ...] ** .1 - b['alpha'] = 1. + b.masks = np.mean(b.masks, axis=0)[np.newaxis, ...] ** 0.1 + b['alpha'] = 1.0 return b # Public methods @@ -214,7 +233,7 @@ def get_best_channels(self, cluster_id): """Get the best channels of a given cluster.""" mm = self._get_mean_masks(cluster_id) channel_ids = np.argsort(mm)[::-1] - ind = mm[channel_ids] > .1 + ind = mm[channel_ids] > 0.1 if np.sum(ind) > 0: channel_ids = channel_ids[ind] else: # pragma: no cover @@ -233,16 +252,17 @@ def on_save_clustering(self, sender, spike_clusters, groups, *labels): self._save_cluster_info() -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Kwik commands -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def kwik_gui(path, channel_group=None, clustering=None, **kwargs): # pragma: no cover """Launch the Kwik GUI.""" assert path + _require_klusta() create_app() - controller = KwikController( - path, channel_group=channel_group, clustering=clustering, **kwargs) + controller = KwikController(path, channel_group=channel_group, clustering=clustering, **kwargs) gui = controller.create_gui() gui.show() run_app() @@ -252,4 +272,5 @@ def kwik_gui(path, channel_group=None, clustering=None, **kwargs): # pragma: no def kwik_describe(path, channel_group=None, clustering=None): """Describe a template dataset.""" assert path + _require_klusta() KwikModel(path, channel_group=channel_group, clustering=clustering).describe() diff --git a/phy/apps/kwik/tests/test_gui.py b/phy/apps/kwik/tests/test_gui.py index c397f6c73..5046af9e7 100644 --- a/phy/apps/kwik/tests/test_gui.py +++ b/phy/apps/kwik/tests/test_gui.py @@ -1,23 +1,22 @@ -# -*- coding: utf-8 -*- - """Testing the Kwik GUI.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ import logging -from pathlib import Path import shutil import unittest +from pathlib import Path from phylib.io.datasets import download_test_file from phylib.utils.testing import captured_output from phy.apps.tests.test_base import BaseControllerTests +from phy.cluster.views import WaveformView from phy.plot.tests import key_press + from ..gui import KwikController, kwik_describe -from phy.cluster.views import WaveformView logger = logging.getLogger(__name__) @@ -32,8 +31,12 @@ def _kwik_controller(tempdir, kwik_only=False): shutil.copy(loc_path, tempdir / loc_path.name) kwik_path = tempdir / 'hybrid_10sec.kwik' return KwikController( - kwik_path, channel_group=0, config_dir=tempdir / 'config', - clear_cache=True, enable_threading=False) + kwik_path, + channel_group=0, + config_dir=tempdir / 'config', + clear_cache=True, + enable_threading=False, + ) def test_kwik_describe(qtbot, tempdir): diff --git a/phy/apps/template/__init__.py b/phy/apps/template/__init__.py index d97bbd0e0..19b623477 100644 --- a/phy/apps/template/__init__.py +++ b/phy/apps/template/__init__.py @@ -1,11 +1,9 @@ -# -*- coding: utf-8 -*- - """Template GUI.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ from phylib.io.model import TemplateModel, from_sparse, get_template_params, load_model # noqa from .gui import TemplateController, template_describe, template_gui # noqa diff --git a/phy/apps/template/gui.py b/phy/apps/template/gui.py index 797cdfc96..1bbbc8ca0 100644 --- a/phy/apps/template/gui.py +++ b/phy/apps/template/gui.py @@ -1,18 +1,15 @@ -# -*- coding: utf-8 -*- - """Template GUI.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ import logging from operator import itemgetter from pathlib import Path import numpy as np - from phylib import _add_log_file from phylib.io.model import TemplateModel, load_model from phylib.io.traces import MtscompEphysReader @@ -20,22 +17,25 @@ from phy.cluster.views import ScatterView from phy.gui import create_app, run_app -from ..base import WaveformMixin, FeatureMixin, TemplateMixin, TraceMixin, BaseController + +from ..base import BaseController, FeatureMixin, TemplateMixin, TraceMixin, WaveformMixin logger = logging.getLogger(__name__) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Custom views -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + class TemplateFeatureView(ScatterView): """Scatter view showing the template features.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Template Controller -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + class TemplateController(WaveformMixin, FeatureMixin, TemplateMixin, TraceMixin, BaseController): """Controller for the Template GUI. @@ -81,7 +81,7 @@ class TemplateController(WaveformMixin, FeatureMixin, TemplateMixin, TraceMixin, # ------------------------------------------------------------------------- def _get_waveforms_dict(self): - waveforms_dict = super(TemplateController, self)._get_waveforms_dict() + waveforms_dict = super()._get_waveforms_dict() # Remove waveforms and mean_waveforms if there is no raw data file. if self.model.traces is None and self.model.spike_waveforms is None: waveforms_dict.pop('waveforms', None) @@ -92,7 +92,7 @@ def _create_model(self, dir_path=None, **kwargs): return TemplateModel(dir_path=dir_path, **kwargs) def _set_supervisor(self): - super(TemplateController, self)._set_supervisor() + super()._set_supervisor() supervisor = self.supervisor @@ -107,7 +107,7 @@ def split_init(cluster_ids=None): supervisor.actions.split(s, self.model.spike_templates[s]) def _set_similarity_functions(self): - super(TemplateController, self)._set_similarity_functions() + super()._set_similarity_functions() self.similarity_functions['template'] = self.template_similarity self.similarity = 'template' @@ -139,7 +139,7 @@ def _get_template_features(self, cluster_ids, load_all=False): ] def _set_view_creator(self): - super(TemplateController, self)._set_view_creator() + super()._set_view_creator() self.view_creator['TemplateFeatureView'] = self.create_template_feature_view # Public methods @@ -156,9 +156,9 @@ def get_best_channels(self, cluster_id): def get_channel_amplitudes(self, cluster_id): """Return the channel amplitudes of the best channels of a given cluster.""" template_id = self.get_template_for_cluster(cluster_id) - template = self.model.get_template(template_id, amplitude_threshold=.5) + template = self.model.get_template(template_id, amplitude_threshold=0.5) if not template: # pragma: no cover - return [0], [0.] + return [0], [0.0] m, M = template.amplitude.min(), template.amplitude.max() d = (M - m) if m < M else 1.0 return template.channel_ids, (template.amplitude - m) / d @@ -171,9 +171,6 @@ def template_similarity(self, cluster_id): sims = np.max(self.model.similar_templates[temp_i, :], axis=0) def _sim_ij(cj): - # Templates of the cluster. - if cj < self.model.n_templates: - return float(sims[cj]) temp_j = np.nonzero(self.get_template_counts(cj))[0] return float(np.max(sims[temp_j])) @@ -195,9 +192,10 @@ def create_template_feature_view(self): return TemplateFeatureView(coords=self._get_template_features) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Template commands -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def template_gui(params_path, **kwargs): # pragma: no cover """Launch the Template GUI.""" @@ -210,8 +208,7 @@ def template_gui(params_path, **kwargs): # pragma: no cover # Automatically export spike waveforms when using compressed raw ephys. if model.spike_waveforms is None and isinstance(model.traces, MtscompEphysReader): # TODO: customizable values below. - model.save_spikes_subset_waveforms( - max_n_spikes_per_template=500, max_n_channels=16) + model.save_spikes_subset_waveforms(max_n_spikes_per_template=500, max_n_channels=16) create_app() controller = TemplateController(model=model, dir_path=dir_path, **kwargs) diff --git a/phy/apps/template/tests/test_gui.py b/phy/apps/template/tests/test_gui.py index 474724bef..81a06b594 100644 --- a/phy/apps/template/tests/test_gui.py +++ b/phy/apps/template/tests/test_gui.py @@ -1,39 +1,41 @@ -# -*- coding: utf-8 -*- - """Testing the Template GUI.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ import logging -from pathlib import Path import re import unittest +from pathlib import Path import numpy as np - -from phylib.io.model import load_model, get_template_params +from phylib.io.model import get_template_params, load_model from phylib.io.tests.conftest import _make_dataset from phylib.utils.testing import captured_output -from phy.apps.tests.test_base import MinimalControllerTests, BaseControllerTests, GlobalViewsTests -from ..gui import ( - template_describe, TemplateController, TemplateFeatureView) +from phy.apps.tests.test_base import BaseControllerTests, GlobalViewsTests, MinimalControllerTests + +from ..gui import TemplateController, TemplateFeatureView, template_describe logger = logging.getLogger(__name__) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Tests -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def _template_controller(tempdir, dir_path, **kwargs): kwargs.update(get_template_params(dir_path / 'params.py')) return TemplateController( - config_dir=tempdir / 'config', plugin_dirs=[plugins_dir()], + config_dir=tempdir / 'config', + plugin_dirs=[plugins_dir()], clear_cache=kwargs.pop('clear_cache', True), - clear_state=True, enable_threading=False, **kwargs) + clear_state=True, + enable_threading=False, + **kwargs, + ) def test_template_describe(qtbot, tempdir): @@ -45,6 +47,7 @@ def test_template_describe(qtbot, tempdir): class TemplateControllerTests(GlobalViewsTests, BaseControllerTests): """Base template controller tests.""" + @classmethod def _create_dataset(cls, tempdir): # pragma: no cover """To be overridden in child classes.""" @@ -78,7 +81,8 @@ def test_template_split_init(self): def test_spike_attribute_views(self): """Open all available spike attribute views.""" view_names = [ - name for name in self.controller.view_creator.keys() if name.startswith('Spike')] + name for name in self.controller.view_creator.keys() if name.startswith('Spike') + ] for name in view_names: self.gui.create_and_add_view(name) self.qtbot.wait(250) @@ -110,8 +114,8 @@ def test_z1_close_reopen(self): # Recreate the controller on the model. self.__class__._controller = _template_controller( - self.__class__._tempdir, self.__class__._dataset.parent, - clear_cache=False) + self.__class__._tempdir, self.__class__._dataset.parent, clear_cache=False + ) self.__class__._create_gui() # Check that the data has been saved. @@ -164,9 +168,10 @@ def test_template_feature_view_split(self): return -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Test plugins -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def plugins_dir(): """Path to the directory with the builtin plugins.""" @@ -190,7 +195,6 @@ def _make_plugin_test_case(plugin_name): """Generate a special test class with a plugin attached to the controller.""" class TemplateControllerPluginTests(MinimalControllerTests, unittest.TestCase): - @classmethod def _create_dataset(cls, tempdir): return _make_dataset(tempdir, param='dense', has_spike_attributes=False) @@ -216,4 +220,4 @@ def test_a2_minimal(self): # Dynamically define test classes for each builtin plugin. for plugin_name in plugin_names(): - globals()['TemplateController%sTests' % plugin_name] = _make_plugin_test_case(plugin_name) + globals()[f'TemplateController{plugin_name}Tests'] = _make_plugin_test_case(plugin_name) diff --git a/phy/apps/tests/test_base.py b/phy/apps/tests/test_base.py index 0319ca932..6a874f535 100644 --- a/phy/apps/tests/test_base.py +++ b/phy/apps/tests/test_base.py @@ -1,45 +1,51 @@ -# -*- coding: utf-8 -*- - """Integration tests for the GUIs.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ -from itertools import cycle, islice import logging import os -from pathlib import Path import shutil import tempfile import unittest +from itertools import cycle, islice +from pathlib import Path import numpy as np -from pytestqt.plugin import QtBot - from phylib.io.mock import ( - artificial_features, artificial_traces, artificial_spike_clusters, artificial_spike_samples, - artificial_waveforms + artificial_features, + artificial_spike_clusters, + artificial_spike_samples, + artificial_traces, + artificial_waveforms, ) - -from phylib.utils import connect, unconnect, Bunch, reset, emit +from phylib.utils import Bunch, connect, emit, reset, unconnect +from pytest import mark +from pytestqt.plugin import QtBot from phy.cluster.views import ( - WaveformView, FeatureView, AmplitudeView, TraceView, TemplateView, + AmplitudeView, + FeatureView, + TemplateView, + TraceView, + WaveformView, ) from phy.gui.qt import Debouncer, create_app from phy.gui.widgets import Barrier from phy.plot.tests import mouse_click -from ..base import BaseController, WaveformMixin, FeatureMixin, TraceMixin, TemplateMixin + +from ..base import BaseController, FeatureMixin, TemplateMixin, TraceMixin, WaveformMixin logger = logging.getLogger(__name__) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Mock models and controller classes -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + -class MyModel(object): +class MyModel: seed = np.random.seed(0) n_channels = 8 n_spikes = 20000 @@ -54,7 +60,7 @@ class MyModel(object): metadata = {'group': {3: 'noise', 4: 'mua', 5: 'good'}} sample_rate = 10000 spike_attributes = {} - amplitudes = np.random.normal(size=n_spikes, loc=1, scale=.1) + amplitudes = np.random.normal(size=n_spikes, loc=1, scale=0.1) spike_clusters = artificial_spike_clusters(n_spikes, n_clusters) spike_templates = spike_clusters spike_samples = artificial_spike_samples(n_spikes) @@ -78,7 +84,8 @@ def get_template(self, template_id): nc = self.n_channels // 2 return Bunch( template=artificial_waveforms(1, self.n_samples_waveforms, nc)[0, ...], - channel_ids=self._get_some_channels(template_id, nc)) + channel_ids=self._get_some_channels(template_id, nc), + ) def save_spike_clusters(self, spike_clusters): pass @@ -99,51 +106,50 @@ def get_channel_amplitudes(self, cluster_id): class MyControllerW(WaveformMixin, MyController): """With waveform view.""" - pass class MyControllerF(FeatureMixin, MyController): """With feature view.""" - pass class MyControllerT(TraceMixin, MyController): """With trace view.""" - pass class MyControllerTmp(TemplateMixin, MyController): """With templates.""" - pass class MyControllerFull(TemplateMixin, WaveformMixin, FeatureMixin, TraceMixin, MyController): """With everything.""" - pass def _mock_controller(tempdir, cls): model = MyModel() return cls( - dir_path=tempdir, config_dir=tempdir / 'config', model=model, - clear_cache=True, enable_threading=False) + dir_path=tempdir, + config_dir=tempdir / 'config', + model=model, + clear_cache=True, + enable_threading=False, + ) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Base classes -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ -class MinimalControllerTests(object): +class MinimalControllerTests: # Methods to override - #-------------------------------------------------------------------------- + # -------------------------------------------------------------------------- @classmethod def get_controller(cls, tempdir): raise NotImplementedError() # Convenient properties - #-------------------------------------------------------------------------- + # -------------------------------------------------------------------------- @property def qtbot(self): @@ -186,7 +192,7 @@ def amplitude_view(self): return self.gui.list_views(AmplitudeView)[0] # Convenience methods - #-------------------------------------------------------------------------- + # -------------------------------------------------------------------------- def stop(self): # pragma: no cover """Used for debugging.""" @@ -230,10 +236,10 @@ def redo(self): def move(self, w): s = self.supervisor - getattr(s.actions, 'move_%s' % w)() + getattr(s.actions, f'move_{w}')() s.block() - def lasso(self, view, scale=1.): + def lasso(self, view, scale=1.0): w, h = view.canvas.get_size() w *= scale h *= scale @@ -243,7 +249,7 @@ def lasso(self, view, scale=1.): mouse_click(self.qtbot, view.canvas, (1, h - 1), modifiers=('Control',)) # Fixtures - #-------------------------------------------------------------------------- + # -------------------------------------------------------------------------- @classmethod def setUpClass(cls): @@ -268,15 +274,16 @@ def _create_gui(cls): b = Barrier() connect(b('cluster_view'), event='ready', sender=s.cluster_view) connect(b('similarity_view'), event='ready', sender=s.similarity_view) - cls._gui.show() + with cls._qtbot.waitExposed(cls._gui): + cls._gui.show() # cls._qtbot.addWidget(cls._gui) - cls._qtbot.waitForWindowShown(cls._gui) b.wait() @classmethod def _close_gui(cls): cls._gui.close() cls._gui.deleteLater() + cls._qtbot.wait(100) # NOTE: make sure all callback functions are unconnected at the end of the tests # to avoid side-effects and spurious dependencies between tests. @@ -284,9 +291,8 @@ def _close_gui(cls): class BaseControllerTests(MinimalControllerTests): - # Common test methods - #-------------------------------------------------------------------------- + # -------------------------------------------------------------------------- def test_common_01(self): """Select one cluster.""" @@ -355,7 +361,7 @@ def test_common_11(self): self.gui.view_actions.switch_raw_data_filter() -class GlobalViewsTests(object): +class GlobalViewsTests: def test_global_filter_1(self): self.next() cv = self.supervisor.cluster_view @@ -366,9 +372,10 @@ def test_global_sort_1(self): emit('table_sort', cv, self.cluster_ids[::-1]) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Mock test cases -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + class MockControllerTests(MinimalControllerTests, GlobalViewsTests, unittest.TestCase): """Empty mock controller.""" @@ -377,8 +384,14 @@ class MockControllerTests(MinimalControllerTests, GlobalViewsTests, unittest.Tes def get_controller(cls, tempdir): return _mock_controller(tempdir, MyController) + @mark.filterwarnings( + 'ignore:Parsing dates involving a day of month without a year specified is ambiguious:DeprecationWarning' + ) def test_create_ipython_view(self): - self.gui.create_and_add_view('IPythonView') + view = self.gui.create_and_add_view('IPythonView') + view.stop() + view.dock.close() + self.qtbot.wait(100) def test_create_raster_view(self): view = self.gui.create_and_add_view('RasterView') @@ -431,7 +444,7 @@ def feature_view(self): def test_feature_view_split(self): self.next() n = max(self.cluster_ids) - self.lasso(self.feature_view, .1) + self.lasso(self.feature_view, 0.1) self.split() # Split one cluster => Two new clusters should be selected after the split. self.assertEqual(self.selected[:2], [n + 1, n + 2]) @@ -501,6 +514,7 @@ def test_split_template_amplitude(self): class MockControllerFullTests(MinimalControllerTests, unittest.TestCase): """Mock controller with all views.""" + @classmethod def get_controller(cls, tempdir): return _mock_controller(tempdir, MyControllerFull) diff --git a/phy/apps/trace/__init__.py b/phy/apps/trace/__init__.py index 6a48da638..3000ebc51 100644 --- a/phy/apps/trace/__init__.py +++ b/phy/apps/trace/__init__.py @@ -1,10 +1,8 @@ -# -*- coding: utf-8 -*- - """Trace GUI.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ from .gui import create_trace_gui, trace_gui # noqa diff --git a/phy/apps/trace/gui.py b/phy/apps/trace/gui.py index fbf9f1f1d..d5dd4b84e 100644 --- a/phy/apps/trace/gui.py +++ b/phy/apps/trace/gui.py @@ -1,11 +1,9 @@ -# -*- coding: utf-8 -*- - """Trace GUI.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ import logging @@ -14,14 +12,15 @@ from phy.apps.template import get_template_params from phy.cluster.views.trace import TraceView, select_traces -from phy.gui import create_app, run_app, GUI +from phy.gui import GUI, create_app, run_app logger = logging.getLogger(__name__) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Trace GUI -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def create_trace_gui(obj, **kwargs): """Create the Trace GUI. @@ -50,8 +49,10 @@ def create_trace_gui(obj, **kwargs): return create_trace_gui(next(iter(params.pop('dat_path'))), **params) kwargs = { - k: v for k, v in kwargs.items() - if k in ('sample_rate', 'n_channels_dat', 'dtype', 'offset')} + k: v + for k, v in kwargs.items() + if k in ('sample_rate', 'n_channels_dat', 'dtype', 'offset') + } traces = get_ephys_reader(obj, **kwargs) create_app() @@ -59,9 +60,7 @@ def create_trace_gui(obj, **kwargs): gui.set_default_actions() def _get_traces(interval): - return Bunch( - data=select_traces( - traces, interval, sample_rate=traces.sample_rate)) + return Bunch(data=select_traces(traces, interval, sample_rate=traces.sample_rate)) # TODO: load channel information diff --git a/phy/apps/trace/tests/test_gui.py b/phy/apps/trace/tests/test_gui.py index f41e17937..248c9d02a 100644 --- a/phy/apps/trace/tests/test_gui.py +++ b/phy/apps/trace/tests/test_gui.py @@ -1,10 +1,8 @@ -# -*- coding: utf-8 -*- - """Testing the Trace GUI.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ import logging @@ -15,13 +13,14 @@ logger = logging.getLogger(__name__) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Tests -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def test_trace_gui_1(qtbot, template_path): # noqa gui = create_trace_gui(template_path) - gui.show() qtbot.addWidget(gui) - qtbot.waitForWindowShown(gui) + with qtbot.waitExposed(gui): + gui.show() gui.close() diff --git a/phy/cluster/__init__.py b/phy/cluster/__init__.py index 65e25a46e..b9aca068a 100644 --- a/phy/cluster/__init__.py +++ b/phy/cluster/__init__.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # flake8: noqa """Manual clustering facilities.""" diff --git a/phy/cluster/_history.py b/phy/cluster/_history.py index 472e73dec..c1ba5ae6c 100644 --- a/phy/cluster/_history.py +++ b/phy/cluster/_history.py @@ -1,17 +1,16 @@ -# -*- coding: utf-8 -*- - """History class for undo stack.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # History class -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + -class History(object): +class History: """Implement a history of actions with an undo stack.""" def __init__(self, base_item=None): @@ -84,7 +83,7 @@ def add(self, item): """Add an item in the history.""" self._check_index() # Possibly truncate the history up to the current point. - self._history = self._history[:self._index + 1] + self._history = self._history[: self._index + 1] # Append the item self._history.append(item) # Increment the index. @@ -130,7 +129,7 @@ class GlobalHistory(History): """Merge several controllers with different undo stacks.""" def __init__(self, process_ups=None): - super(GlobalHistory, self).__init__(()) + super().__init__(()) self.process_ups = process_ups def action(self, *controllers): @@ -152,8 +151,7 @@ def undo(self): if controllers is None: ups = () else: - ups = tuple([controller.undo() - for controller in controllers]) + ups = tuple([controller.undo() for controller in controllers]) if self.process_ups is not None: return self.process_ups(ups) else: @@ -169,8 +167,7 @@ def redo(self): if controllers is None: ups = () else: - ups = tuple([controller.redo() for - controller in controllers]) + ups = tuple([controller.redo() for controller in controllers]) if self.process_ups is not None: return self.process_ups(ups) else: diff --git a/phy/cluster/_utils.py b/phy/cluster/_utils.py index aa36c62fd..390f35cb0 100644 --- a/phy/cluster/_utils.py +++ b/phy/cluster/_utils.py @@ -1,25 +1,24 @@ -# -*- coding: utf-8 -*- - """Clustering utility functions.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ - -import numpy as np +# ------------------------------------------------------------------------------ -from copy import deepcopy import logging +from copy import deepcopy -from ._history import History +import numpy as np from phylib.utils import Bunch, _as_list, _is_list, emit, silent +from ._history import History + logger = logging.getLogger(__name__) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Utility functions -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def _update_cluster_selection(clusters, up): clusters = list(clusters) @@ -30,7 +29,7 @@ def _update_cluster_selection(clusters, up): def _join(clusters): - return '[{}]'.format(', '.join(map(str, clusters))) + return f'[{", ".join(map(str, clusters))}]' def create_cluster_meta(cluster_groups): @@ -45,9 +44,10 @@ def create_cluster_meta(cluster_groups): return meta -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # UpdateInfo class -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + class UpdateInfo(Bunch): """Object created every time the dataset is modified via a clustering or cluster metadata @@ -79,47 +79,48 @@ class UpdateInfo(Bunch): when redoing the undone action. """ + def __init__(self, **kwargs): - d = dict( - description='', - history=None, - spike_ids=[], - added=[], - deleted=[], - descendants=[], - metadata_changed=[], - metadata_value=None, - undo_state=None, - ) + d = { + 'description': '', + 'history': None, + 'spike_ids': [], + 'added': [], + 'deleted': [], + 'descendants': [], + 'metadata_changed': [], + 'metadata_value': None, + 'undo_state': None, + } d.update(kwargs) - super(UpdateInfo, self).__init__(d) + super().__init__(d) # NOTE: we have to ensure we only use native types and not NumPy arrays so that # the history stack works correctly. assert all(not isinstance(v, np.ndarray) for v in self.values()) def __repr__(self): desc = self.description - h = ' ({})'.format(self.history) if self.history else '' + h = f' ({self.history})' if self.history else '' if not desc: return '' elif desc in ('merge', 'assign'): a, d = _join(self.added), _join(self.deleted) - return '<{desc}{h} {d} => {a}>'.format( - desc=desc, a=a, d=d, h=h) + return f'<{desc}{h} {d} => {a}>' elif desc.startswith('metadata'): c = _join(self.metadata_changed) m = self.metadata_value - return '<{desc}{h} {c} => {m}>'.format( - desc=desc, c=c, m=m, h=h) + return f'<{desc}{h} {c} => {m}>' return '' -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # ClusterMetadataUpdater class -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + -class ClusterMeta(object): +class ClusterMeta: """Handle cluster metadata changes.""" + def __init__(self): self._fields = {} self._reset_data() @@ -147,7 +148,7 @@ def func(cluster): def from_dict(self, dic): """Import data from a `{cluster_id: {field: value}}` dictionary.""" - #self._reset_data() + # self._reset_data() # Do not raise events here. with silent(): for cluster, vals in dic.items(): @@ -192,10 +193,11 @@ def set(self, field, clusters, value, add_to_stack=True): self._data[cluster] = {} self._data[cluster][field] = value - up = UpdateInfo(description='metadata_' + field, - metadata_changed=clusters, - metadata_value=value, - ) + up = UpdateInfo( + description=f'metadata_{field}', + metadata_changed=clusters, + metadata_value=value, + ) undo_state = emit('request_undo_state', self, up) if add_to_stack: @@ -229,7 +231,7 @@ def set_from_descendants(self, descendants, largest_old_cluster=None): # This maps old cluster ids to their values. old_values = {old: self.get(field, old) for old, _ in descendants} # This is the set of new clusters. - new_clusters = set(new for _, new in descendants) + new_clusters = {new for _, new in descendants} # This is the set of old non-default values. old_values_set = set(old_values.values()) if default in old_values_set: @@ -305,8 +307,10 @@ def redo(self): # Property cycle # ----------------------------------------------------------------------------- -class RotatingProperty(object): + +class RotatingProperty: """A key-value property of a view that can switch between several predefined values.""" + def __init__(self): self._choices = {} self._current = None diff --git a/phy/cluster/clustering.py b/phy/cluster/clustering.py index c750b2b55..fe54919a7 100644 --- a/phy/cluster/clustering.py +++ b/phy/cluster/clustering.py @@ -1,27 +1,26 @@ -# -*- coding: utf-8 -*- - """Clustering structure.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ import logging import numpy as np - +from phylib.io.array import _spikes_in_clusters, _spikes_per_cluster, _unique from phylib.utils._types import _as_array, _is_array_like -from phylib.io.array import _unique, _spikes_in_clusters, _spikes_per_cluster -from ._utils import UpdateInfo -from ._history import History from phylib.utils.event import emit +from ._history import History +from ._utils import UpdateInfo + logger = logging.getLogger(__name__) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Clustering class -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def _extend_spikes(spike_ids, spike_clusters): """Return all spikes belonging to the clusters containing the specified @@ -58,7 +57,7 @@ def _extend_assignment(spike_ids, old_spike_clusters, spike_clusters_rel, new_cl assert spike_clusters_rel.min() >= 0 # We renumber the new cluster indices. - new_spike_clusters = (spike_clusters_rel + (new_cluster_id - spike_clusters_rel.min())) + new_spike_clusters = spike_clusters_rel + (new_cluster_id - spike_clusters_rel.min()) # We find the spikes belonging to modified clusters. extended_spike_ids = _extend_spikes(spike_ids, old_spike_clusters) @@ -71,11 +70,12 @@ def _extend_assignment(spike_ids, old_spike_clusters, spike_clusters_rel, new_cl _, extended_spike_clusters = np.unique(extended_spike_clusters, return_inverse=True) # Generate new cluster numbers. k = new_spike_clusters.max() + 1 - extended_spike_clusters += (k - extended_spike_clusters.min()) + extended_spike_clusters += k - extended_spike_clusters.min() # Finally, we concatenate spike_ids and extended_spike_ids. return _concatenate_spike_clusters( - (spike_ids, new_spike_clusters), (extended_spike_ids, extended_spike_clusters)) + (spike_ids, new_spike_clusters), (extended_spike_ids, extended_spike_clusters) + ) def _assign_update_info(spike_ids, old_spike_clusters, new_spike_clusters): @@ -95,7 +95,7 @@ def _assign_update_info(spike_ids, old_spike_clusters, new_spike_clusters): return update_info -class Clustering(object): +class Clustering: """Handle cluster changes in a set of spikes. Constructor @@ -139,9 +139,8 @@ class Clustering(object): """ - def __init__(self, spike_clusters, new_cluster_id=None, - spikes_per_cluster=None): - super(Clustering, self).__init__() + def __init__(self, spike_clusters, new_cluster_id=None, spikes_per_cluster=None): + super().__init__() self._undo_stack = History(base_item=(None, None, None)) # Spike -> cluster mapping. self._spike_clusters = _as_array(spike_clusters) @@ -217,7 +216,7 @@ def spikes_in_clusters(self, clusters): return _spikes_in_clusters(self.spike_clusters, clusters) # Actions - #-------------------------------------------------------------------------- + # -------------------------------------------------------------------------- def _update_cluster_ids(self, to_remove=None, to_add=None): # Update the list of non-empty cluster ids. @@ -234,7 +233,7 @@ def _update_cluster_ids(self, to_remove=None, to_add=None): # spikes_per_cluster array. coherent = np.all(np.isin(self._cluster_ids, sorted(self._spikes_per_cluster))) if not coherent: - logger.debug("Recompute spikes_per_cluster manually: this might take a while.") + logger.debug('Recompute spikes_per_cluster manually: this might take a while.') sc = self._spike_clusters self._spikes_per_cluster = _spikes_per_cluster(sc) @@ -277,7 +276,6 @@ def _do_assign(self, spike_ids, new_spike_clusters): return up def _do_merge(self, spike_ids, cluster_ids, to): - # Create the UpdateInfo instance here. descendants = [(cluster, to) for cluster in cluster_ids] largest_old_cluster = np.bincount(self.spike_clusters[spike_ids]).argmax() @@ -320,18 +318,19 @@ def merge(self, cluster_ids, to=None): """ if not _is_array_like(cluster_ids): - raise ValueError("The first argument should be a list or an array.") + raise ValueError('The first argument should be a list or an array.') cluster_ids = sorted(cluster_ids) if not set(cluster_ids) <= set(self.cluster_ids): - raise ValueError("Some clusters do not exist.") + raise ValueError('Some clusters do not exist.') # Find the new cluster number. if to is None: to = self.new_cluster_id() if to < self.new_cluster_id(): raise ValueError( - "The new cluster numbers should be higher than {0}.".format(self.new_cluster_id())) + f'The new cluster numbers should be higher than {self.new_cluster_id()}.' + ) # NOTE: we could have called self.assign() here, but we don't. # We circumvent self.assign() for performance reasons. @@ -404,8 +403,10 @@ def assign(self, spike_ids, spike_clusters_rel=0): if len(spike_ids) == 0: return UpdateInfo() assert len(spike_ids) == len(spike_clusters_rel) - assert spike_ids.min() >= 0 - assert spike_ids.max() < self._n_spikes, "Some spikes don't exist." + if spike_ids.min() < 0: + raise ValueError('Some spikes do not exist.') + if spike_ids.max() >= self._n_spikes: + raise ValueError('Some spikes do not exist.') # Normalize the spike-cluster assignment such that # there are only new or dead clusters, not modified clusters. @@ -413,7 +414,8 @@ def assign(self, spike_ids, spike_clusters_rel=0): # belong to clusters affected by the operation, will be assigned # to brand new clusters. spike_ids, cluster_ids = _extend_assignment( - spike_ids, self._spike_clusters, spike_clusters_rel, self.new_cluster_id()) + spike_ids, self._spike_clusters, spike_clusters_rel, self.new_cluster_id() + ) up = self._do_assign(spike_ids, cluster_ids) undo_state = emit('request_undo_state', self, up) diff --git a/phy/cluster/supervisor.py b/phy/cluster/supervisor.py index 759c823c8..fe39de1d7 100644 --- a/phy/cluster/supervisor.py +++ b/phy/cluster/supervisor.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - """Manual clustering GUI component.""" @@ -7,21 +5,21 @@ # Imports # ----------------------------------------------------------------------------- -from functools import partial import inspect import logging +from functools import partial import numpy as np +from phylib.utils import Bunch, connect, emit, unconnect + +from phy.gui.actions import Actions +from phy.gui.qt import _block, _wait, set_busy +from phy.gui.widgets import Barrier, Table, _uniq from ._history import GlobalHistory from ._utils import create_cluster_meta from .clustering import Clustering -from phylib.utils import Bunch, emit, connect, unconnect -from phy.gui.actions import Actions -from phy.gui.qt import _block, set_busy, _wait -from phy.gui.widgets import Table, HTMLWidget, _uniq, Barrier - logger = logging.getLogger(__name__) @@ -29,6 +27,7 @@ # Utility functions # ----------------------------------------------------------------------------- + def _process_ups(ups): # pragma: no cover """This function processes the UpdateInfo instances of the two undo stacks (clustering and cluster metadata) and concatenates them @@ -46,7 +45,7 @@ def _process_ups(ups): # pragma: no cover def _ensure_all_ints(l): - if (l is None or l == []): + if l is None or l == []: return for i in range(len(l)): l[i] = int(l[i]) @@ -56,7 +55,8 @@ def _ensure_all_ints(l): # Tasks # ----------------------------------------------------------------------------- -class TaskLogger(object): + +class TaskLogger: """Internal object that gandles all clustering actions and the automatic actions that should follow as part of the "wizard".""" @@ -77,7 +77,14 @@ def enqueue(self, sender, name, *args, output=None, **kwargs): """Enqueue an action, which has a sender, a function name, a list of arguments, and an optional output.""" logger.log( - 5, "Enqueue %s %s %s %s (%s)", sender.__class__.__name__, name, args, kwargs, output) + 5, + 'Enqueue %s %s %s %s (%s)', + sender.__class__.__name__, + name, + args, + kwargs, + output, + ) self._queue.append((sender, name, args, kwargs)) def dequeue(self): @@ -101,7 +108,9 @@ def _callback(self, task, output): def _eval(self, task): """Evaluate a task and call a callback function.""" sender, name, args, kwargs = task - logger.log(5, "Calling %s.%s(%s)", sender.__class__.__name__, name, args, kwargs) + logger.log( + 5, 'Calling %s.%s(%s)', sender.__class__.__name__, name, args, kwargs + ) f = getattr(sender, name) callback = partial(self._callback, task) argspec = inspect.getfullargspec(f) @@ -112,6 +121,7 @@ def _eval(self, task): # HACK: use on_cluster event instead of callback. def _cluster_callback(tsender, up): self._callback(task, up) + connect(_cluster_callback, event='cluster', sender=self.supervisor) f(*args, **kwargs) unconnect(_cluster_callback) @@ -129,8 +139,8 @@ def process(self): def enqueue_after(self, task, output): """Enqueue tasks after a given action.""" sender, name, args, kwargs = task - f = lambda *args, **kwargs: logger.log(5, "No method _after_%s", name) - getattr(self, '_after_%s' % name, f)(task, output) + f = lambda *args, **kwargs: logger.log(5, 'No method _after_%s', name) + getattr(self, f'_after_{name}', f)(task, output) def _after_merge(self, task, output): """Tasks that should follow a merge.""" @@ -180,7 +190,9 @@ def _after_move(self, task, output): def _after_undo(self, task, output): """Task that should follow an undo.""" - last_action = self.last_task(name_not_in=('select', 'next', 'previous', 'undo', 'redo')) + last_action = self.last_task( + name_not_in=('select', 'next', 'previous', 'undo', 'redo') + ) self._select_state(self.last_state(last_action)) def _after_redo(self, task, output): @@ -192,8 +204,7 @@ def _after_redo(self, task, output): def _select_state(self, state): """Enqueue select actions when a state (selected clusters and similar clusters) is set.""" cluster_ids, next_cluster, similar, next_similar = state - self.enqueue( - self.cluster_view, 'select', cluster_ids, update_views=False if similar else True) + self.enqueue(self.cluster_view, 'select', cluster_ids, update_views=not similar) if similar: self.enqueue(self.similarity_view, 'select', similar) @@ -203,7 +214,14 @@ def _log(self, task, output): assert sender assert name logger.log( - 5, "Log %s %s %s %s (%s)", sender.__class__.__name__, name, args, kwargs, output) + 5, + 'Log %s %s %s %s (%s)', + sender.__class__.__name__, + name, + args, + kwargs, + output, + ) args = [a.tolist() if isinstance(a, np.ndarray) else a for a in args] task = (sender, name, args, kwargs, output) # Avoid successive duplicates (even if sender is different). @@ -216,8 +234,10 @@ def log(self, sender, name, *args, output=None, **kwargs): def last_task(self, name=None, name_not_in=()): """Return the last executed task.""" - for (sender, name_, args, kwargs, output) in reversed(self._history): - if (name and name_ == name) or (name_not_in and name_ and name_ not in name_not_in): + for sender, name_, args, kwargs, output in reversed(self._history): + if (name and name_ == name) or ( + name_not_in and name_ and name_ not in name_not_in + ): assert name_ return (sender, name_, args, kwargs, output) @@ -230,23 +250,31 @@ def last_state(self, task=None): if task: i = self._history.index(task) h = self._history[:i] - for (sender, name, args, kwargs, output) in reversed(h): + for sender, name, args, kwargs, output in reversed(h): # Last selection is cluster view selection: return the state. - if (sender == self.similarity_view and similarity_state == (None, None) and - name in ('select', 'next', 'previous')): - similarity_state = (output['selected'], output['next']) if output else (None, None) - if (sender == self.cluster_view and - cluster_state == (None, None) and - name in ('select', 'next', 'previous')): - cluster_state = (output['selected'], output['next']) if output else (None, None) + if ( + sender == self.similarity_view + and similarity_state == (None, None) + and name in ('select', 'next', 'previous') + ): + similarity_state = ( + (output['selected'], output['next']) if output else (None, None) + ) + if ( + sender == self.cluster_view + and cluster_state == (None, None) + and name in ('select', 'next', 'previous') + ): + cluster_state = ( + (output['selected'], output['next']) if output else (None, None) + ) return (*cluster_state, *similarity_state) def show_history(self): """Show the history stack.""" - print("=== History ===") + print('=== History ===') for sender, name, args, kwargs, output in self._history: - print( - '{: <24} {: <8}'.format(sender.__class__.__name__, name), *args, output, kwargs) + print(f'{sender.__class__.__name__: <24} {name: <8}', *args, output, kwargs) def has_finished(self): """Return whether the queue has finished being processed.""" @@ -257,7 +285,7 @@ def has_finished(self): # Cluster view and similarity view # ----------------------------------------------------------------------------- -_CLUSTER_VIEW_STYLES = ''' +_CLUSTER_VIEW_STYLES = """ table tr[data-group='good'] { color: #86D16D; } @@ -269,7 +297,7 @@ def has_finished(self): table tr[data-group='noise'] { color: #777; } -''' +""" class ClusterView(Table): @@ -295,14 +323,13 @@ class ClusterView(Table): def __init__(self, *args, data=None, columns=(), sort=None): # NOTE: debounce select events. - HTMLWidget.__init__( - self, *args, title=self.__class__.__name__, debounce_events=('select',)) + Table.__init__(self, *args, title=self.__class__.__name__, debounce_events=('select',)) self._set_styles() self._reset_table(data=data, columns=columns, sort=sort) def _reset_table(self, data=None, columns=(), sort=None): """Recreate the table with specified columns, data, and sort.""" - emit(self._view_name + '_init', self) + emit(f'{self._view_name}_init', self) # Ensure 'id' is the first column. if 'id' in columns: columns.remove('id') @@ -314,14 +341,14 @@ def _reset_table(self, data=None, columns=(), sort=None): assert col in columns assert columns[0] == 'id' - # Allow to have etc. which allows for CSS styling. + # Keep group metadata available so the table can style rows based on cluster group. value_names = columns + [{'data': ['group']}] # Default sort. sort = sort or ('n_spikes', 'desc') self._init_table(columns=columns, value_names=value_names, data=data, sort=sort) def _set_styles(self): - self.builder.add_style(self._styles) + self.add_style(self._styles) @property def state(self): @@ -370,7 +397,7 @@ class SimilarityView(ClusterView): def set_selected_index_offset(self, n): """Set the index of the selected cluster, used for correct coloring in the similarity view.""" - self.eval_js('table._setSelectedIndexOffset(%d);' % n) + Table.set_selected_index_offset(self, n) def reset(self, cluster_ids): """Recreate the similarity view, given the selected clusters in the cluster view.""" @@ -380,7 +407,8 @@ def reset(self, cluster_ids): # Clear the table. if similar: self.remove_all_and_add( - [cl for cl in similar[0] if cl['id'] not in cluster_ids]) + [cl for cl in similar[0] if cl['id'] not in cluster_ids] + ) else: # pragma: no cover self.remove_all() return similar @@ -390,32 +418,28 @@ def reset(self, cluster_ids): # ActionCreator # ----------------------------------------------------------------------------- -class ActionCreator(object): + +class ActionCreator: """Companion class to the Supervisor that manages the related GUI actions.""" default_shortcuts = { # Clustering. 'merge': 'g', 'split': 'k', - 'label': 'l', - # Move. 'move_best_to_noise': 'alt+n', 'move_best_to_mua': 'alt+m', 'move_best_to_good': 'alt+g', 'move_best_to_unsorted': 'alt+u', - 'move_similar_to_noise': 'ctrl+n', 'move_similar_to_mua': 'ctrl+m', 'move_similar_to_good': 'ctrl+g', 'move_similar_to_unsorted': 'ctrl+u', - 'move_all_to_noise': 'ctrl+alt+n', 'move_all_to_mua': 'ctrl+alt+m', 'move_all_to_good': 'ctrl+alt+g', 'move_all_to_unsorted': 'ctrl+alt+u', - # Wizard. 'first': 'home', 'last': 'end', @@ -425,11 +449,9 @@ class ActionCreator(object): 'unselect_similar': 'backspace', 'next_best': 'down', 'previous_best': 'up', - # Misc. 'undo': 'ctrl+z', 'redo': ('ctrl+shift+z', 'ctrl+y'), - 'clear_filter': 'esc', } @@ -454,9 +476,9 @@ def add(self, which, name, **kwargs): emit_fun = partial(emit, 'action', self, method_name, *method_args) f = getattr(self.supervisor, method_name, None) docstring = inspect.getdoc(f) if f else name - if not kwargs.get('docstring', None): + if not kwargs.get('docstring'): kwargs['docstring'] = docstring - getattr(self, '%s_actions' % which).add(emit_fun, name=name, **kwargs) + getattr(self, f'{which}_actions').add(emit_fun, name=name, **kwargs) def attach(self, gui): """Attach the GUI and create the menus.""" @@ -464,11 +486,21 @@ def attach(self, gui): ds = self.default_shortcuts dsp = self.default_snippets self.edit_actions = Actions( - gui, name='Edit', menu='&Edit', insert_menu_before='&View', - default_shortcuts=ds, default_snippets=dsp) + gui, + name='Edit', + menu='&Edit', + insert_menu_before='&View', + default_shortcuts=ds, + default_snippets=dsp, + ) self.select_actions = Actions( - gui, name='Select', menu='Sele&ct', insert_menu_before='&View', - default_shortcuts=ds, default_snippets=dsp) + gui, + name='Select', + menu='Sele&ct', + insert_menu_before='&View', + default_shortcuts=ds, + default_snippets=dsp, + ) # Create the actions. self._create_edit_actions() @@ -491,11 +523,13 @@ def _create_edit_actions(self): for which in ('best', 'similar', 'all'): for group in ('noise', 'mua', 'good', 'unsorted'): self.add( - w, 'move_%s_to_%s' % (which, group), + w, + f'move_{which}_to_{group}', method_name='move', method_args=(group, which), - submenu='Move %s to' % which, - docstring='Move %s to %s.' % (which, group)) + submenu=f'Move {which} to', + docstring=f'Move {which} to {group}.', + ) self.edit_actions.separator() # Label. @@ -518,9 +552,14 @@ def _create_select_actions(self): # Sort by: for column in getattr(self.supervisor, 'columns', ()): self.add( - w, 'sort_by_%s' % column.lower(), method_name='sort', method_args=(column,), - docstring='Sort by %s' % column, - submenu='Sort by', alias='s%s' % column.replace('_', '')[:2]) + w, + f'sort_by_{column.lower()}', + method_name='sort', + method_args=(column,), + docstring=f'Sort by {column}', + submenu='Sort by', + alias=f's{column.replace("_", "")[:2]}', + ) self.select_actions.separator() self.add(w, 'first') @@ -556,18 +595,19 @@ def _create_toolbar(self, gui): # Clustering GUI component # ----------------------------------------------------------------------------- + def _is_group_masked(group): return group in ('noise', 'mua') -class Supervisor(object): +class Supervisor: """Component that brings manual clustering facilities to a GUI: * `Clustering` instance: merge, split, undo, redo. * `ClusterMeta` instance: change cluster metadata (e.g. group). * Cluster selection. * Many manual clustering-related actions, snippets, shortcuts, etc. - * Two HTML tables : `ClusterView` and `SimilarityView`. + * Two native Qt tables: `ClusterView` and `SimilarityView`. Constructor ----------- @@ -608,9 +648,17 @@ class Supervisor(object): """ def __init__( - self, spike_clusters=None, cluster_groups=None, cluster_metrics=None, - cluster_labels=None, similarity=None, new_cluster_id=None, sort=None, context=None): - super(Supervisor, self).__init__() + self, + spike_clusters=None, + cluster_groups=None, + cluster_metrics=None, + cluster_labels=None, + similarity=None, + new_cluster_id=None, + sort=None, + context=None, + ): + super().__init__() self.context = context self.similarity = similarity # function cluster => [(cl, sim), ...] self.actions = None # will be set when attaching the GUI @@ -629,14 +677,17 @@ def __init__( self.columns = ['id'] # n_spikes comes from cluster_metrics self.columns += list(self.cluster_metrics.keys()) self.columns += [ - label for label in self.cluster_labels.keys() - if label not in self.columns + ['group']] + label + for label in self.cluster_labels.keys() + if label not in self.columns + ['group'] + ] # Create Clustering and ClusterMeta. # Load the cached spikes_per_cluster array. spc = context.load('spikes_per_cluster') if context else None self.clustering = Clustering( - spike_clusters, spikes_per_cluster=spc, new_cluster_id=new_cluster_id) + spike_clusters, spikes_per_cluster=spc, new_cluster_id=new_cluster_id + ) # Cache the spikes_per_cluster array. self._save_spikes_per_cluster() @@ -670,7 +721,8 @@ def on_cluster(sender, up): # the largest cluster wins and its value is set to its descendants. if up.added: self.cluster_meta.set_from_descendants( - up.descendants, largest_old_cluster=up.largest_old_cluster) + up.descendants, largest_old_cluster=up.largest_old_cluster + ) emit('cluster', self, up) @connect(sender=self.cluster_meta) # noqa @@ -688,29 +740,36 @@ def _save_spikes_per_cluster(self): """Cache on the disk the dictionary with the spikes belonging to each cluster.""" if not self.context: return - self.context.save('spikes_per_cluster', self.clustering.spikes_per_cluster, kind='pickle') + self.context.save( + 'spikes_per_cluster', self.clustering.spikes_per_cluster, kind='pickle' + ) def _log_action(self, sender, up): """Log the clustering action (merge, split).""" if sender != self.clustering: return if up.history: - logger.info(up.history.title() + " cluster assign.") + logger.info(f'{up.history.title()} cluster assign.') elif up.description == 'merge': - logger.info("Merge clusters %s to %s.", ', '.join(map(str, up.deleted)), up.added[0]) + logger.info( + 'Merge clusters %s to %s.', ', '.join(map(str, up.deleted)), up.added[0] + ) else: - logger.info("Assigned %s spikes.", len(up.spike_ids)) + logger.info('Assigned %s spikes.', len(up.spike_ids)) def _log_action_meta(self, sender, up): """Log the cluster meta action (move, label).""" if sender != self.cluster_meta: return if up.history: - logger.info(up.history.title() + " move.") + logger.info(f'{up.history.title()} move.') else: logger.info( - "Change %s for clusters %s to %s.", up.description, - ', '.join(map(str, up.metadata_changed)), up.metadata_value) + 'Change %s for clusters %s to %s.', + up.description, + ', '.join(map(str, up.metadata_changed)), + up.metadata_value, + ) # Skip cluster metadata other than groups. if up.description != 'metadata_group': @@ -721,14 +780,14 @@ def _save_new_cluster_id(self, sender, up): easier cache consistency.""" new_cluster_id = self.clustering.new_cluster_id() if self.context: - logger.log(5, "Save the new cluster id: %d.", new_cluster_id) - self.context.save('new_cluster_id', dict(new_cluster_id=new_cluster_id)) + logger.log(5, 'Save the new cluster id: %d.', new_cluster_id) + self.context.save('new_cluster_id', {'new_cluster_id': new_cluster_id}) def _save_gui_state(self, gui): """Save the GUI state with the cluster view and similarity view.""" gui.state.update_view_state(self.cluster_view, self.cluster_view.state) - # Clear temporary HTML files + # Compatibility no-op on the native table implementation. self.cluster_view.clear_temporary_files() self.similarity_view.clear_temporary_files() @@ -738,8 +797,10 @@ def _get_similar_clusters(self, sender, cluster_id): # Only keep existing clusters. clusters_set = set(self.clustering.cluster_ids) data = [ - dict(similarity='%.3f' % s, **self.get_cluster_info(c)) - for c, s in sim if c in clusters_set] + dict(similarity=f'{s:.3f}', **self.get_cluster_info(c)) + for c, s in sim + if c in clusters_set + ] return data def get_cluster_info(self, cluster_id, exclude=()): @@ -752,7 +813,7 @@ def get_cluster_info(self, cluster_id, exclude=()): for key in self.cluster_meta.fields: # includes group out[key] = self.cluster_meta.get(key, cluster_id) - out['is_masked'] = _is_group_masked(out.get('group', None)) + out['is_masked'] = _is_group_masked(out.get('group')) return {k: v for k, v in out.items() if k not in exclude} def _create_views(self, gui=None, sort=None): @@ -762,16 +823,20 @@ def _create_views(self, gui=None, sort=None): # Create the cluster view. self.cluster_view = ClusterView( - gui, data=self.cluster_info, columns=self.columns, sort=sort) + gui, data=self.cluster_info, columns=self.columns, sort=sort + ) # Update the action flow and similarity view when selection changes. connect(self._clusters_selected, event='select', sender=self.cluster_view) # Create the similarity view. self.similarity_view = SimilarityView( - gui, columns=self.columns + ['similarity'], sort=('similarity', 'desc')) + gui, columns=self.columns + ['similarity'], sort=('similarity', 'desc') + ) connect( - self._get_similar_clusters, event='request_similar_clusters', - sender=self.similarity_view) + self._get_similar_clusters, + event='request_similar_clusters', + sender=self.similarity_view, + ) connect(self._similar_selected, event='select', sender=self.similarity_view) # Change the state after every clustering action, according to the action flow. @@ -779,26 +844,27 @@ def _create_views(self, gui=None, sort=None): def _reset_cluster_view(self): """Recreate the cluster view.""" - logger.debug("Reset the cluster view.") + logger.debug('Reset the cluster view.') self.cluster_view._reset_table( - data=self.cluster_info, columns=self.columns, sort=self._sort) + data=self.cluster_info, columns=self.columns, sort=self._sort + ) def _clusters_added(self, cluster_ids): """Update the cluster and similarity views when new clusters are created.""" - logger.log(5, "Clusters added: %s", cluster_ids) + logger.log(5, 'Clusters added: %s', cluster_ids) data = [self.get_cluster_info(cluster_id) for cluster_id in cluster_ids] self.cluster_view.add(data) self.similarity_view.add(data) def _clusters_removed(self, cluster_ids): """Update the cluster and similarity views when clusters are removed.""" - logger.log(5, "Clusters removed: %s", cluster_ids) + logger.log(5, 'Clusters removed: %s', cluster_ids) self.cluster_view.remove(cluster_ids) self.similarity_view.remove(cluster_ids) def _cluster_metadata_changed(self, field, cluster_ids, value): """Update the cluster and similarity views when clusters metadata is updated.""" - logger.log(5, "%s changed for %s to %s", field, cluster_ids, value) + logger.log(5, '%s changed for %s to %s', field, cluster_ids, value) data = [{'id': cluster_id, field: value} for cluster_id in cluster_ids] for _ in data: _['is_masked'] = _is_group_masked(_.get('group', None)) @@ -814,7 +880,7 @@ def _clusters_selected(self, sender, obj, **kwargs): cluster_ids = obj['selected'] next_cluster = obj['next'] kwargs = obj.get('kwargs', {}) - logger.debug("Clusters selected: %s (%s)", cluster_ids, next_cluster) + logger.debug('Clusters selected: %s (%s)', cluster_ids, next_cluster) self.task_logger.log(self.cluster_view, 'select', cluster_ids, output=obj) # Update the similarity view when the cluster view selection changes. self.similarity_view.reset(cluster_ids) @@ -826,7 +892,9 @@ def _clusters_selected(self, sender, obj, **kwargs): emit('select', self, self.selected, **kwargs) if cluster_ids: self.cluster_view.scroll_to(cluster_ids[-1]) - self.cluster_view.dock.set_status('clusters: %s' % ', '.join(map(str, cluster_ids))) + self.cluster_view.dock.set_status( + f'clusters: {", ".join(map(str, cluster_ids))}' + ) def _similar_selected(self, sender, obj): """When clusters are selected in the similarity view, register the action in the history @@ -836,23 +904,30 @@ def _similar_selected(self, sender, obj): similar = obj['selected'] next_similar = obj['next'] kwargs = obj.get('kwargs', {}) - logger.debug("Similar clusters selected: %s (%s)", similar, next_similar) + logger.debug('Similar clusters selected: %s (%s)', similar, next_similar) self.task_logger.log(self.similarity_view, 'select', similar, output=obj) emit('select', self, self.selected, **kwargs) if similar: self.similarity_view.scroll_to(similar[-1]) - self.similarity_view.dock.set_status('similar clusters: %s' % ', '.join(map(str, similar))) + self.similarity_view.dock.set_status( + f'similar clusters: {", ".join(map(str, similar))}' + ) def _on_action(self, sender, name, *args): """Called when an action is triggered: enqueue and process the task.""" assert sender == self.action_creator + # Ignore wizard navigation requests triggered while another selection task is still + # being processed. This keeps an explicit select followed immediately by next() + # from advancing two steps in one block cycle. + if name == 'next' and self.task_logger._processing: + return # The GUI should not be busy when calling a new action. if 'select' not in name and self._is_busy: - logger.log(5, "The GUI is busy, waiting before calling the action.") + logger.log(5, 'The GUI is busy, waiting before calling the action.') try: _block(lambda: not self._is_busy) except Exception: - logger.warning("The GUI is busy, could not execute `%s`.", name) + logger.warning('The GUI is busy, could not execute `%s`.', name) return # Enqueue the requested action. self.task_logger.enqueue(self, name, *args) @@ -867,7 +942,10 @@ def _after_action(self, sender, up): self._clusters_added(up.added) self._clusters_removed(up.deleted) self._cluster_metadata_changed( - up.description.replace('metadata_', ''), up.metadata_changed, up.metadata_value) + up.description.replace('metadata_', ''), + up.metadata_changed, + up.metadata_value, + ) # After the action has finished, we process the pending actions, # like selection of new clusters in the tables. self.task_logger.process() @@ -878,7 +956,7 @@ def _set_busy(self, busy): return self._is_busy = busy # Set the busy cursor. - logger.log(5, "GUI is %sbusy" % ('' if busy else 'not ')) + logger.log(5, f'GUI is {"" if busy else "not "}busy') set_busy(busy) # Let the cluster views know that the GUI is busy. self.cluster_view.set_busy(busy) @@ -898,7 +976,7 @@ def select(self, *cluster_ids, callback=None): if cluster_ids and isinstance(cluster_ids[0], (tuple, list)): cluster_ids = list(cluster_ids[0]) + list(cluster_ids[1:]) # Remove non-existing clusters from the selection. - #cluster_ids = self._keep_existing_clusters(cluster_ids) + # cluster_ids = self._keep_existing_clusters(cluster_ids) # Update the cluster view selection. self.cluster_view.select(cluster_ids, callback=callback) @@ -910,7 +988,7 @@ def sort(self, column, sort_dir='desc'): self.cluster_view.sort_by(column, sort_dir=sort_dir) def filter(self, text): - """Filter the clusters using a Javascript expression on the column names.""" + """Filter the clusters using a boolean expression on the column names.""" self.cluster_view.filter(text) def clear_filter(self): @@ -922,7 +1000,10 @@ def clear_filter(self): @property def cluster_info(self): """The cluster view table as a list of per-cluster dictionaries.""" - return [self.get_cluster_info(cluster_id) for cluster_id in self.clustering.cluster_ids] + return [ + self.get_cluster_info(cluster_id) + for cluster_id in self.clustering.cluster_ids + ] @property def shown_cluster_ids(self): @@ -948,7 +1029,8 @@ def attach(self, gui): # Create the cluster view and similarity view. self._create_views( - gui=gui, sort=gui.state.get('ClusterView', {}).get('current_sort', None)) + gui=gui, sort=gui.state.get('ClusterView', {}).get('current_sort', None) + ) # Create the TaskLogger. self.task_logger = TaskLogger( @@ -1038,11 +1120,9 @@ def split(self, spike_ids=None, spike_clusters_rel=0): assert spike_ids.dtype == np.int64 assert spike_ids.ndim == 1 if len(spike_ids) == 0: - logger.warning( - """No spikes selected, cannot split.""") + logger.warning("""No spikes selected, cannot split.""") return - out = self.clustering.split( - spike_ids, spike_clusters_rel=spike_clusters_rel) + out = self.clustering.split(spike_ids, spike_clusters_rel=spike_clusters_rel) self._global_history.action(self.clustering) return out @@ -1056,8 +1136,7 @@ def fields(self): def get_labels(self, field): """Return the labels of all clusters, for a given label name.""" - return {c: self.cluster_meta.get(field, c) - for c in self.clustering.cluster_ids} + return {c: self.cluster_meta.get(field, c) for c in self.clustering.cluster_ids} def label(self, name, value, cluster_ids=None): """Assign a label to some clusters.""" @@ -1071,7 +1150,7 @@ def label(self, name, value, cluster_ids=None): self._global_history.action(self.cluster_meta) # Add column if needed. if name != 'group' and name not in self.columns: - logger.debug("Add column %s.", name) + logger.debug('Add column %s.', name) self.columns.append(name) self._reset_cluster_view() @@ -1088,15 +1167,14 @@ def move(self, group, which): if not which: return _ensure_all_ints(which) - logger.debug("Move %s to %s.", which, group) + logger.debug('Move %s to %s.', which, group) group = 'unsorted' if group is None else group self.label('group', group, cluster_ids=which) # Wizard actions # ------------------------------------------------------------------------- - # There are callbacks because these functions call Javascript functions that return - # asynchronously in Qt5. + # There are callbacks because the table API remains asynchronous for compatibility. def reset_wizard(self, callback=None): """Reset the wizard.""" @@ -1108,19 +1186,27 @@ def next_best(self, callback=None): def previous_best(self, callback=None): """Select the previous best cluster in the cluster view.""" - self.cluster_view.previous(callback=callback or partial(emit, 'wizard_done', self)) + self.cluster_view.previous( + callback=callback or partial(emit, 'wizard_done', self) + ) def next(self, callback=None): """Select the next cluster in the similarity view.""" state = self.task_logger.last_state() if not state or not state[0]: - self.cluster_view.first(callback=callback or partial(emit, 'wizard_done', self)) + self.cluster_view.first( + callback=callback or partial(emit, 'wizard_done', self) + ) else: - self.similarity_view.next(callback=callback or partial(emit, 'wizard_done', self)) + self.similarity_view.next( + callback=callback or partial(emit, 'wizard_done', self) + ) def previous(self, callback=None): """Select the previous cluster in the similarity view.""" - self.similarity_view.previous(callback=callback or partial(emit, 'wizard_done', self)) + self.similarity_view.previous( + callback=callback or partial(emit, 'wizard_done', self) + ) def unselect_similar(self, callback=None): """Select only the clusters in the cluster view.""" @@ -1139,7 +1225,11 @@ def last(self, callback=None): def is_dirty(self): """Return whether there are any pending changes.""" - return self._is_dirty if self._is_dirty in (False, True) else len(self._global_history) > 1 + return ( + self._is_dirty + if self._is_dirty in (False, True) + else len(self._global_history) > 1 + ) def undo(self): """Undo the last action.""" @@ -1159,11 +1249,14 @@ def save(self): spike_clusters = self.clustering.spike_clusters groups = { c: self.cluster_meta.get('group', c) or 'unsorted' - for c in self.clustering.cluster_ids} + for c in self.clustering.cluster_ids + } # List of tuples (field_name, dictionary). labels = [ - (field, self.get_labels(field)) for field in self.cluster_meta.fields - if field not in ('next_cluster')] + (field, self.get_labels(field)) + for field in self.cluster_meta.fields + if field not in ('next_cluster') + ] emit('save_clustering', self, spike_clusters, groups, *labels) # Cache the spikes_per_cluster array. self._save_spikes_per_cluster() diff --git a/phy/cluster/tests/conftest.py b/phy/cluster/tests/conftest.py index 26f2c8054..1add218bd 100644 --- a/phy/cluster/tests/conftest.py +++ b/phy/cluster/tests/conftest.py @@ -1,23 +1,22 @@ -# -*- coding: utf-8 -*- - """Test fixtures.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +from phylib.io.array import get_closest_clusters from pytest import fixture -from phylib.io.array import get_closest_clusters import phy.gui.qt # Reduce the debouncer delay for tests. phy.gui.qt.Debouncer.delay = 1 -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Fixtures -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + @fixture def cluster_ids(): @@ -41,4 +40,5 @@ def similarity(cluster_ids): def similarity(c): return get_closest_clusters(c, cluster_ids, sim) + return similarity diff --git a/phy/cluster/tests/test_clustering.py b/phy/cluster/tests/test_clustering.py index 5e7f1d40e..ccbf74dcf 100644 --- a/phy/cluster/tests/test_clustering.py +++ b/phy/cluster/tests/test_clustering.py @@ -1,27 +1,29 @@ -# -*- coding: utf-8 -*- - """Test clustering.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ import numpy as np from numpy.testing import assert_array_equal as ae -from pytest import raises - +from phylib.io.array import ( + _spikes_in_clusters, +) from phylib.io.mock import artificial_spike_clusters -from phylib.io.array import (_spikes_in_clusters,) from phylib.utils import connect -from ..clustering import (_extend_spikes, - _concatenate_spike_clusters, - _extend_assignment, - Clustering) +from pytest import raises +from ..clustering import ( + Clustering, + _concatenate_spike_clusters, + _extend_assignment, + _extend_spikes, +) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Test assignments -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def test_extend_spikes_simple(): spike_clusters = np.array([3, 5, 2, 9, 5, 5, 2]) @@ -62,10 +64,9 @@ def test_extend_spikes(): def test_concatenate_spike_clusters(): - spikes, clusters = _concatenate_spike_clusters(([1, 5, 4], - [10, 50, 40]), - ([2, 0, 3, 6], - [20, 0, 30, 60])) + spikes, clusters = _concatenate_spike_clusters( + ([1, 5, 4], [10, 50, 40]), ([2, 0, 3, 6], [20, 0, 30, 60]) + ) ae(spikes, np.arange(7)) ae(clusters, np.arange(0, 60 + 1, 10)) @@ -82,28 +83,31 @@ def test_extend_assignment(): # This should not depend on the index chosen. for to in (123, 0, 1, 2, 3): clusters_rel = [123] * len(spike_ids) - new_spike_ids, new_cluster_ids = _extend_assignment(spike_ids, - spike_clusters, - clusters_rel, - 10, - ) + new_spike_ids, new_cluster_ids = _extend_assignment( + spike_ids, + spike_clusters, + clusters_rel, + 10, + ) ae(new_spike_ids, [0, 2, 6]) ae(new_cluster_ids, [10, 10, 11]) # Second case: we assign the spikes to different clusters. clusters_rel = [0, 1] - new_spike_ids, new_cluster_ids = _extend_assignment(spike_ids, - spike_clusters, - clusters_rel, - 10, - ) + new_spike_ids, new_cluster_ids = _extend_assignment( + spike_ids, + spike_clusters, + clusters_rel, + 10, + ) ae(new_spike_ids, [0, 2, 6]) ae(new_cluster_ids, [10, 11, 12]) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Test clustering -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def test_clustering_split(): spike_clusters = np.array([2, 5, 3, 2, 7, 5, 2]) @@ -115,22 +119,24 @@ def test_clustering_split(): assert clustering.n_spikes == n_spikes ae(clustering.spike_ids, np.arange(n_spikes)) - splits = [[0], - [1], - [2], - [0, 1], - [0, 2], - [1, 2], - [0, 1, 2], - [3], - [4], - [3, 4], - [6], - [6, 5], - [0, 6], - [0, 3, 6], - [0, 2, 6], - np.arange(7)] + splits = [ + [0], + [1], + [2], + [0, 1], + [0, 2], + [1, 2], + [0, 1, 2], + [3], + [4], + [3, 4], + [6], + [6, 5], + [0, 6], + [0, 3, 6], + [0, 2, 6], + np.arange(7), + ] # Test many splits. for to_split in splits: @@ -156,7 +162,7 @@ def test_clustering_descendants_merge(): up = clustering.merge([2, 3]) new = up.added[0] assert new == 8 - assert set(up.descendants) == set([(2, 8), (3, 8)]) + assert set(up.descendants) == {(2, 8), (3, 8)} with raises(ValueError): up = clustering.merge([2, 8]) @@ -164,7 +170,7 @@ def test_clustering_descendants_merge(): up = clustering.merge([5, 8]) new = up.added[0] assert new == 9 - assert set(up.descendants) == set([(5, 9), (8, 9)]) + assert set(up.descendants) == {(5, 9), (8, 9)} def test_clustering_descendants_split(): @@ -173,44 +179,44 @@ def test_clustering_descendants_split(): # Instantiate a Clustering instance. clustering = Clustering(spike_clusters) - with raises(Exception): + with raises(ValueError): clustering.split([-1]) - with raises(Exception): + with raises(ValueError): clustering.split([8]) # First split. up = clustering.split([0]) assert up.deleted == [2] assert up.added == [8, 9] - assert set(up.descendants) == set([(2, 8), (2, 9)]) + assert set(up.descendants) == {(2, 8), (2, 9)} ae(clustering.spike_clusters, [8, 5, 3, 9, 7, 5, 9]) # Undo. up = clustering.undo() assert up.deleted == [8, 9] assert up.added == [2] - assert set(up.descendants) == set([(8, 2), (9, 2)]) + assert set(up.descendants) == {(8, 2), (9, 2)} ae(clustering.spike_clusters, spike_clusters) # Redo. up = clustering.redo() assert up.deleted == [2] assert up.added == [8, 9] - assert set(up.descendants) == set([(2, 8), (2, 9)]) + assert set(up.descendants) == {(2, 8), (2, 9)} ae(clustering.spike_clusters, [8, 5, 3, 9, 7, 5, 9]) # Second split: just replace cluster 8 by 10 (1 spike in it). up = clustering.split([0]) assert up.deleted == [8] assert up.added == [10] - assert set(up.descendants) == set([(8, 10)]) + assert set(up.descendants) == {(8, 10)} ae(clustering.spike_clusters, [10, 5, 3, 9, 7, 5, 9]) # Undo again. up = clustering.undo() assert up.deleted == [10] assert up.added == [8] - assert set(up.descendants) == set([(10, 8)]) + assert set(up.descendants) == {(10, 8)} ae(clustering.spike_clusters, [8, 5, 3, 9, 7, 5, 9]) @@ -466,7 +472,7 @@ def test_clustering_long(): assert np.all(clustering.spike_clusters[:10] == new_cluster) # Merge. - my_spikes_0 = np.nonzero(np.in1d(clustering.spike_clusters, [2, 3]))[0] + my_spikes_0 = np.nonzero(np.isin(clustering.spike_clusters, [2, 3]))[0] info = clustering.merge([2, 3]) my_spikes = info.spike_ids ae(my_spikes, my_spikes_0) @@ -477,7 +483,7 @@ def test_clustering_long(): clustering.spike_clusters[:] = spike_clusters_base[:] clustering._new_cluster_id = 11 - my_spikes_0 = np.nonzero(np.in1d(clustering.spike_clusters, [4, 6]))[0] + my_spikes_0 = np.nonzero(np.isin(clustering.spike_clusters, [4, 6]))[0] info = clustering.merge([4, 6], 11) my_spikes = info.spike_ids ae(my_spikes, my_spikes_0) diff --git a/phy/cluster/tests/test_history.py b/phy/cluster/tests/test_history.py index af43c8780..6c6aa5acd 100644 --- a/phy/cluster/tests/test_history.py +++ b/phy/cluster/tests/test_history.py @@ -1,19 +1,17 @@ -# -*- coding: utf-8 -*- - """Tests of history structure.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ import numpy as np -from .._history import History, GlobalHistory - +from .._history import GlobalHistory, History -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Tests -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def test_history(): history = History() @@ -76,19 +74,19 @@ def test_iter_history(): for i, item in enumerate(history): # Assert item if i > 0: - assert id(item) == id(locals()['item{0:d}'.format(i - 1)]) + assert id(item) == id(locals()[f'item{i - 1:d}']) for i, item in enumerate(history.iter(1, 2)): assert i == 0 # Assert item assert history.current_position == 3 - assert id(item) == id(locals()['item{0:d}'.format(i)]) + assert id(item) == id(locals()[f'item{i:d}']) for i, item in enumerate(history.iter(2, 3)): assert i == 0 # Assert item assert history.current_position == 3 - assert id(item) == id(locals()['item{0:d}'.format(i + 1)]) + assert id(item) == id(locals()[f'item{i + 1:d}']) def test_global_history(): @@ -141,4 +139,4 @@ def test_global_history(): assert gh.undo() == '' assert gh.redo() == 'h1 first' assert gh.redo() == 'h2 first' - assert gh.redo() == 'h1 second' + 'h2 second' + assert gh.redo() == 'h1 secondh2 second' diff --git a/phy/cluster/tests/test_supervisor.py b/phy/cluster/tests/test_supervisor.py index 27c20695c..8f6a0fcac 100644 --- a/phy/cluster/tests/test_supervisor.py +++ b/phy/cluster/tests/test_supervisor.py @@ -1,26 +1,30 @@ -# -*- coding: utf-8 -*- - """Test GUI component.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ -#from contextlib import contextmanager +# from contextlib import contextmanager -from pytest import fixture import numpy as np from numpy.testing import assert_array_equal as ae +from phylib.utils import Bunch, connect, emit +from pytest import fixture -from .. import supervisor as _supervisor -from ..supervisor import ( - Supervisor, TaskLogger, ClusterView, SimilarityView, ActionCreator) from phy.gui import GUI -from phy.gui.widgets import Barrier from phy.gui.qt import qInstallMessageHandler from phy.gui.tests.test_widgets import _assert, _wait_until_table_ready +from phy.gui.widgets import Barrier from phy.utils.context import Context -from phylib.utils import connect, Bunch, emit + +from .. import supervisor as _supervisor +from ..supervisor import ( + ActionCreator, + ClusterView, + SimilarityView, + Supervisor, + TaskLogger, +) def handler(msg_type, msg_log_context, msg_string): @@ -30,9 +34,10 @@ def handler(msg_type, msg_log_context, msg_string): qInstallMessageHandler(handler) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Fixtures -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + @fixture def gui(tempdir, qtbot): @@ -41,8 +46,8 @@ def gui(tempdir, qtbot): gui = GUI(position=(200, 100), size=(500, 500), config_dir=tempdir) gui.set_default_actions() - gui.show() - qtbot.waitForWindowShown(gui) + with qtbot.waitExposed(gui): + gui.show() yield gui qtbot.wait(5) gui.close() @@ -51,8 +56,7 @@ def gui(tempdir, qtbot): @fixture -def supervisor(qtbot, gui, cluster_ids, cluster_groups, cluster_labels, - similarity, tempdir): +def supervisor(qtbot, gui, cluster_ids, cluster_groups, cluster_labels, similarity, tempdir): spike_clusters = np.repeat(cluster_ids, 2) s = Supervisor( @@ -71,13 +75,14 @@ def supervisor(qtbot, gui, cluster_ids, cluster_groups, cluster_labels, return s -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Test tasks -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + @fixture def tl(): - class MockClusterView(object): + class MockClusterView: _selected = [0] def select(self, cl, callback=None, **kwargs): @@ -93,7 +98,7 @@ def previous(self, callback=None): # pragma: no cover class MockSimilarityView(MockClusterView): pass - class MockSupervisor(object): + class MockSupervisor: def merge(self, cluster_ids, to, callback=None): callback(Bunch(deleted=cluster_ids, added=[to])) @@ -192,17 +197,22 @@ def test_task_move_all(tl): assert tl.last_state() == ([1], 2, [101], 102) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Test cluster and similarity views -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + @fixture def data(): - _data = [{"id": i, - "n_spikes": 100 - 10 * i, - "group": {2: 'noise', 3: 'noise', 5: 'mua', 8: 'good'}.get(i, None), - "is_masked": i in (2, 3, 5), - } for i in range(10)] + _data = [ + { + 'id': i, + 'n_spikes': 100 - 10 * i, + 'group': {2: 'noise', 3: 'noise', 5: 'mua', 8: 'good'}.get(i), + 'is_masked': i in (2, 3, 5), + } + for i in range(10) + ] return _data @@ -232,7 +242,6 @@ def on_request_similar_clusters(sender, cluster_id): def test_cluster_view_extra_columns(qtbot, gui, data): - for cl in data: cl['my_metrics'] = cl['id'] * 1000 @@ -240,9 +249,10 @@ def test_cluster_view_extra_columns(qtbot, gui, data): _wait_until_table_ready(qtbot, cv) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Test ActionCreator -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def test_action_creator_1(qtbot, gui): ac = ActionCreator() @@ -250,9 +260,10 @@ def test_action_creator_1(qtbot, gui): gui.show() -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Test GUI component -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def _select(supervisor, cluster_ids, similar=None): supervisor.task_logger.enqueue(supervisor.cluster_view, 'select', cluster_ids) @@ -295,12 +306,11 @@ def test_supervisor_busy(qtbot, supervisor): assert not supervisor._is_busy -def test_supervisor_cluster_metrics( - qtbot, gui, cluster_ids, cluster_groups, similarity, tempdir): +def test_supervisor_cluster_metrics(qtbot, gui, cluster_ids, cluster_groups, similarity, tempdir): spike_clusters = np.repeat(cluster_ids, 2) def my_metrics(cluster_id): - return cluster_id ** 2 + return cluster_id**2 cluster_metrics = {'my_metrics': my_metrics} @@ -344,7 +354,6 @@ def test_supervisor_select_order(qtbot, supervisor): def test_supervisor_edge_cases(supervisor): - # Empty selection at first. ae(supervisor.clustering.cluster_ids, [0, 1, 2, 10, 11, 20, 30]) @@ -386,7 +395,6 @@ def test_supervisor_save(qtbot, gui, supervisor): def test_supervisor_skip(qtbot, gui, supervisor): - # yield [0, 1, 2, 10, 11, 20, 30] # # i, g, N, i, g, N, N expected = [30, 20, 11, 2, 1] @@ -419,7 +427,6 @@ def test_supervisor_filter(qtbot, supervisor): def test_supervisor_merge_1(qtbot, supervisor): - _select(supervisor, [30], [20]) _assert_selected(supervisor, [30, 20]) @@ -477,7 +484,6 @@ def test_supervisor_merge_move(qtbot, supervisor): def test_supervisor_split_0(qtbot, supervisor): - _select(supervisor, [1, 2]) _assert_selected(supervisor, [1, 2]) @@ -496,7 +502,6 @@ def test_supervisor_split_0(qtbot, supervisor): def test_supervisor_split_1(supervisor): - supervisor.select_actions.select([1, 2]) supervisor.block() @@ -526,7 +531,6 @@ def test_supervisor_split_2(gui, similarity): def test_supervisor_state(tempdir, qtbot, gui, supervisor): - supervisor.select(1) cv = supervisor.cluster_view @@ -544,12 +548,11 @@ def test_supervisor_state(tempdir, qtbot, gui, supervisor): def test_supervisor_label(supervisor): - _select(supervisor, [20]) - supervisor.label("my_field", 3.14) + supervisor.label('my_field', 3.14) supervisor.block() - supervisor.label("my_field", 1.23, cluster_ids=30) + supervisor.label('my_field', 1.23, cluster_ids=30) supervisor.block() assert 'my_field' in supervisor.fields @@ -558,9 +561,8 @@ def test_supervisor_label(supervisor): def test_supervisor_label_cluster_1(supervisor): - _select(supervisor, [20, 30]) - supervisor.label("my_field", 3.14) + supervisor.label('my_field', 3.14) supervisor.block() # Same value for the old clusters. @@ -574,10 +576,9 @@ def test_supervisor_label_cluster_1(supervisor): def test_supervisor_label_cluster_2(supervisor): - _select(supervisor, [20]) - supervisor.label("my_field", 3.14) + supervisor.label('my_field', 3.14) supervisor.block() # One of the parents. @@ -592,10 +593,9 @@ def test_supervisor_label_cluster_2(supervisor): def test_supervisor_label_cluster_3(supervisor): - # Conflict: largest cluster wins. _select(supervisor, [20, 30]) - supervisor.label("my_field", 3.14) + supervisor.label('my_field', 3.14) supervisor.block() # Create merged cluster from 20 and 30. @@ -607,7 +607,7 @@ def test_supervisor_label_cluster_3(supervisor): assert supervisor.get_labels('my_field')[new] == 3.14 # Now, we label a smaller cluster. - supervisor.label("my_field", 2.718, cluster_ids=[10]) + supervisor.label('my_field', 2.718, cluster_ids=[10]) # We merge the large and small cluster together. up = supervisor.merge(up.added + [10]) @@ -618,7 +618,6 @@ def test_supervisor_label_cluster_3(supervisor): def test_supervisor_move_1(supervisor): - _select(supervisor, [20]) _assert_selected(supervisor, [20]) @@ -638,7 +637,6 @@ def test_supervisor_move_1(supervisor): def test_supervisor_move_2(supervisor): - _select(supervisor, [20], [10]) _assert_selected(supervisor, [20, 10]) @@ -656,7 +654,6 @@ def test_supervisor_move_2(supervisor): def test_supervisor_move_3(qtbot, supervisor): - supervisor.select_actions.next() supervisor.block() _assert_selected(supervisor, [30]) @@ -673,13 +670,12 @@ def test_supervisor_move_3(qtbot, supervisor): supervisor.block() _assert_selected(supervisor, [2]) - supervisor.cluster_meta.get('group', 30) == 'noise' - supervisor.cluster_meta.get('group', 20) == 'mua' - supervisor.cluster_meta.get('group', 11) == 'good' + assert supervisor.cluster_meta.get('group', 30) == 'noise' + assert supervisor.cluster_meta.get('group', 20) == 'mua' + assert supervisor.cluster_meta.get('group', 11) == 'good' def test_supervisor_move_4(supervisor): - _select(supervisor, [30], [20]) _assert_selected(supervisor, [30, 20]) @@ -695,9 +691,9 @@ def test_supervisor_move_4(supervisor): supervisor.block() _assert_selected(supervisor, [30, 1]) - supervisor.cluster_meta.get('group', 20) == 'noise' - supervisor.cluster_meta.get('group', 11) == 'mua' - supervisor.cluster_meta.get('group', 2) == 'good' + assert supervisor.cluster_meta.get('group', 20) == 'noise' + assert supervisor.cluster_meta.get('group', 11) == 'mua' + assert supervisor.cluster_meta.get('group', 2) == 'good' def test_supervisor_move_5(supervisor): @@ -720,18 +716,17 @@ def test_supervisor_move_5(supervisor): supervisor.block() _assert_selected(supervisor, []) - supervisor.cluster_meta.get('group', 30) == 'noise' - supervisor.cluster_meta.get('group', 20) == 'noise' + assert supervisor.cluster_meta.get('group', 30) == 'noise' + assert supervisor.cluster_meta.get('group', 20) == 'noise' - supervisor.cluster_meta.get('group', 11) == 'mua' - supervisor.cluster_meta.get('group', 10) == 'mua' + assert supervisor.cluster_meta.get('group', 11) == 'mua' + assert supervisor.cluster_meta.get('group', 10) == 'mua' - supervisor.cluster_meta.get('group', 2) == 'good' - supervisor.cluster_meta.get('group', 1) == 'good' + assert supervisor.cluster_meta.get('group', 2) == 'good' + assert supervisor.cluster_meta.get('group', 1) == 'mua' def test_supervisor_reset(qtbot, supervisor): - supervisor.select_actions.select([10, 11]) supervisor.select_actions.reset_wizard() @@ -756,7 +751,6 @@ def test_supervisor_reset(qtbot, supervisor): def test_supervisor_nav(qtbot, supervisor): - supervisor.select_actions.reset_wizard() supervisor.block() _assert_selected(supervisor, [30]) diff --git a/phy/cluster/tests/test_utils.py b/phy/cluster/tests/test_utils.py index 720f90142..3cc4cdf83 100644 --- a/phy/cluster/tests/test_utils.py +++ b/phy/cluster/tests/test_utils.py @@ -1,31 +1,36 @@ -# -*- coding: utf-8 -*- - """Tests of manual clustering utility functions.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ import logging from pytest import raises -from .._utils import (ClusterMeta, UpdateInfo, RotatingProperty, - _update_cluster_selection, create_cluster_meta) +from .._utils import ( + ClusterMeta, + RotatingProperty, + UpdateInfo, + _update_cluster_selection, + create_cluster_meta, +) logger = logging.getLogger(__name__) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Tests -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def test_create_cluster_meta(): - cluster_groups = {2: 3, - 3: 3, - 5: 1, - 7: 2, - } + cluster_groups = { + 2: 3, + 3: 3, + 5: 1, + 7: 2, + } meta = create_cluster_meta(cluster_groups) assert meta.group(2) == 3 assert meta.group(3) == 3 @@ -143,11 +148,12 @@ def test_metadata_history_complex(): def test_metadata_descendants(): """Test ClusterMeta history.""" - data = {0: {'group': 0}, - 1: {'group': 1}, - 2: {'group': 2}, - 3: {'group': 3}, - } + data = { + 0: {'group': 0}, + 1: {'group': 1}, + 2: {'group': 2}, + 3: {'group': 3}, + } meta = ClusterMeta() @@ -191,8 +197,7 @@ def test_update_info(): logger.debug(UpdateInfo(description='hello')) logger.debug(UpdateInfo(deleted=range(5), added=[5], description='merge')) logger.debug(UpdateInfo(deleted=range(5), added=[5], description='assign')) - logger.debug(UpdateInfo(deleted=range(5), added=[5], - description='assign', history='undo')) + logger.debug(UpdateInfo(deleted=range(5), added=[5], description='assign', history='undo')) logger.debug(UpdateInfo(metadata_changed=[2, 3], description='metadata')) diff --git a/phy/cluster/views/__init__.py b/phy/cluster/views/__init__.py index 5fdf76daa..36d01d50a 100644 --- a/phy/cluster/views/__init__.py +++ b/phy/cluster/views/__init__.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - """Manual clustering views.""" diff --git a/phy/cluster/views/amplitude.py b/phy/cluster/views/amplitude.py index d4aea1d38..4bba0a19c 100644 --- a/phy/cluster/views/amplitude.py +++ b/phy/cluster/views/amplitude.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - """Amplitude view.""" @@ -10,15 +8,15 @@ import logging import numpy as np - -from phy.utils.color import selected_cluster_color, add_alpha from phylib.utils._types import _as_array from phylib.utils.event import emit from phy.cluster._utils import RotatingProperty -from phy.plot.transform import Rotate, Scale, Translate, Range, NDC -from phy.plot.visuals import ScatterVisual, HistogramVisual, PatchVisual -from .base import ManualClusteringView, MarkerSizeMixin, LassoMixin +from phy.plot.transform import NDC, Range, Rotate, Scale, Translate +from phy.plot.visuals import HistogramVisual, PatchVisual, ScatterVisual +from phy.utils.color import add_alpha, selected_cluster_color + +from .base import LassoMixin, ManualClusteringView, MarkerSizeMixin from .histogram import _compute_histogram logger = logging.getLogger(__name__) @@ -28,6 +26,7 @@ # Amplitude view # ----------------------------------------------------------------------------- + class AmplitudeView(MarkerSizeMixin, LassoMixin, ManualClusteringView): """This view displays an amplitude plot for all selected clusters. @@ -49,20 +48,20 @@ class AmplitudeView(MarkerSizeMixin, LassoMixin, ManualClusteringView): _default_position = 'right' # Alpha channel of the markers in the scatter plot. - marker_alpha = 1. - time_range_color = (1., 1., 0., .25) + marker_alpha = 1.0 + time_range_color = (1.0, 1.0, 0.0, 0.25) # Number of bins in the histogram. n_bins = 100 # Alpha channel of the histogram in the background. - histogram_alpha = .5 + histogram_alpha = 0.5 # Quantile used for scaling of the amplitudes (less than 1 to avoid outliers). - quantile = .99 + quantile = 0.99 # Size of the histogram, between 0 and 1. - histogram_scale = .25 + histogram_scale = 0.25 default_shortcuts = { 'change_marker_size': 'alt+wheel', @@ -74,7 +73,7 @@ class AmplitudeView(MarkerSizeMixin, LassoMixin, ManualClusteringView): } def __init__(self, amplitudes=None, amplitudes_type=None, duration=None): - super(AmplitudeView, self).__init__() + super().__init__() self.state_attrs += ('amplitudes_type',) self.canvas.enable_axes() @@ -95,27 +94,33 @@ def __init__(self, amplitudes=None, amplitudes_type=None, duration=None): assert self.amplitudes_type in self.amplitudes self.cluster_ids = () - self.duration = duration or 1. + self.duration = duration or 1.0 # Histogram visual. self.hist_visual = HistogramVisual() - self.hist_visual.transforms.add([ - Range(NDC, (-1, -1, 1, -1 + 2 * self.histogram_scale)), - Rotate('cw'), - Scale((1, -1)), - Translate((2.05, 0)), - ]) + self.hist_visual.transforms.add( + [ + Range(NDC, (-1, -1, 1, -1 + 2 * self.histogram_scale)), + Rotate('cw'), + Scale((1, -1)), + Translate((2.05, 0)), + ] + ) self.canvas.add_visual(self.hist_visual) - self.canvas.panzoom.zoom = self.canvas.panzoom._default_zoom = (.75, 1) - self.canvas.panzoom.pan = self.canvas.panzoom._default_pan = (-.25, 0) + self.canvas.panzoom.zoom = self.canvas.panzoom._default_zoom = (0.75, 1) + self.canvas.panzoom.pan = self.canvas.panzoom._default_pan = (-0.25, 0) # Yellow vertical bar showing the selected time interval. self.patch_visual = PatchVisual(primitive_type='triangle_fan') - self.patch_visual.inserter.insert_vert(''' + self.patch_visual.inserter.insert_vert( + """ const float MIN_INTERVAL_SIZE = 0.01; uniform float u_interval_size; - ''', 'header') - self.patch_visual.inserter.insert_vert(''' + """, + 'header', + ) + self.patch_visual.inserter.insert_vert( + """ gl_Position.y = pos_orig.y; // The following is used to ensure that (1) the bar width increases with the zoom level @@ -126,7 +131,9 @@ def __init__(self, amplitudes=None, amplitudes_type=None, duration=None): // vertex is on the left or right edge of the bar. gl_Position.x += w * (-1 + 2 * int(a_position.z == 0)); - ''', 'after_transforms') + """, + 'after_transforms', + ) self.canvas.add_visual(self.patch_visual) # Scatter plot. @@ -140,11 +147,15 @@ def _get_data_bounds(self, bunchs): return (0, 0, self.duration, 1) m = min( np.quantile(bunch.amplitudes, 1 - self.quantile) - for bunch in bunchs if len(bunch.amplitudes)) + for bunch in bunchs + if len(bunch.amplitudes) + ) m = min(0, m) # ensure ymin <= 0 M = max( np.quantile(bunch.amplitudes, self.quantile) - for bunch in bunchs if len(bunch.amplitudes)) + for bunch in bunchs + if len(bunch.amplitudes) + ) return (0, m, self.duration, M) def _add_histograms(self, bunchs): @@ -164,14 +175,16 @@ def show_time_range(self, interval=(0, 0)): start, end = interval x0 = -1 + 2 * (start / self.duration) x1 = -1 + 2 * (end / self.duration) - xm = .5 * (x0 + x1) - pos = np.array([ - [xm, -1], - [xm, +1], - [xm, +1], - [xm, -1], - ]) - self.patch_visual.program['u_interval_size'] = .5 * (x1 - x0) + xm = 0.5 * (x0 + x1) + pos = np.array( + [ + [xm, -1], + [xm, +1], + [xm, +1], + [xm, -1], + ] + ) + self.patch_visual.program['u_interval_size'] = 0.5 * (x1 - x0) self.patch_visual.set_data(pos=pos, color=self.time_range_color, depth=[0, 0, 1, 1]) self.canvas.update() @@ -185,11 +198,13 @@ def _plot_cluster(self, bunch): self.hist_visual.add_batch_data( hist=bunch.histogram, ylim=self._ylim, - color=add_alpha(bunch.color, self.histogram_alpha)) + color=add_alpha(bunch.color, self.histogram_alpha), + ) # Scatter plot. self.visual.add_batch_data( - pos=bunch.pos, color=bunch.color, size=ms, data_bounds=self.data_bounds) + pos=bunch.pos, color=bunch.color, size=ms, data_bounds=self.data_bounds + ) def get_clusters_data(self, load_all=None): """Return a list of Bunch instances, with attributes pos and spike_ids.""" @@ -214,7 +229,9 @@ def get_clusters_data(self, load_all=None): bunch.color = ( selected_cluster_color(i - 1, self.marker_alpha) # Background amplitude color. - if cluster_id is not None else (.5, .5, .5, .5)) + if cluster_id is not None + else (0.5, 0.5, 0.5, 0.5) + ) return bunchs def plot(self, **kwargs): @@ -225,7 +242,7 @@ def plot(self, **kwargs): self.data_bounds = self._get_data_bounds(bunchs) bunchs = self._add_histograms(bunchs) # Use the same scale for all histograms. - self._ylim = max(bunch.histogram.max() for bunch in bunchs) if bunchs else 1. + self._ylim = max(bunch.histogram.max() for bunch in bunchs) if bunchs else 1.0 self.visual.reset_batch() self.hist_visual.reset_batch() @@ -240,20 +257,24 @@ def plot(self, **kwargs): def attach(self, gui): """Attach the view to the GUI.""" - super(AmplitudeView, self).attach(gui) + super().attach(gui) # Amplitude type actions. def _make_amplitude_action(a): def callback(): self.amplitudes_type = a self.plot() + return callback for a in self.amplitudes_types.keys(): - name = 'Change amplitudes type to %s' % a + name = f'Change amplitudes type to {a}' self.actions.add( - _make_amplitude_action(a), show_shortcut=False, - name=name, view_submenu='Change amplitudes type') + _make_amplitude_action(a), + show_shortcut=False, + name=name, + view_submenu='Change amplitudes type', + ) self.actions.add(self.next_amplitudes_type, set_busy=True) self.actions.add(self.previous_amplitudes_type, set_busy=True) @@ -273,13 +294,13 @@ def amplitudes_type(self, value): def next_amplitudes_type(self): """Switch to the next amplitudes type.""" self.amplitudes_types.next() - logger.debug("Switch to amplitudes type: %s.", self.amplitudes_types.current) + logger.debug('Switch to amplitudes type: %s.', self.amplitudes_types.current) self.plot() def previous_amplitudes_type(self): """Switch to the previous amplitudes type.""" self.amplitudes_types.previous() - logger.debug("Switch to amplitudes type: %s.", self.amplitudes_types.current) + logger.debug('Switch to amplitudes type: %s.', self.amplitudes_types.current) self.plot() def on_mouse_click(self, e): diff --git a/phy/cluster/views/base.py b/phy/cluster/views/base.py index 40f1aac99..94d7d622c 100644 --- a/phy/cluster/views/base.py +++ b/phy/cluster/views/base.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - """Manual clustering views.""" @@ -7,18 +5,18 @@ # Imports # ----------------------------------------------------------------------------- -from functools import partial import gc import logging +from functools import partial import numpy as np - -from phylib.utils import Bunch, connect, unconnect, emit +from phylib.utils import Bunch, connect, emit, unconnect from phylib.utils.geometry import range_transform + from phy.cluster._utils import RotatingProperty from phy.gui import Actions -from phy.gui.qt import AsyncCaller, screenshot, screenshot_default_path, thread_pool, Worker -from phy.plot import PlotCanvas, NDC, extend_bounds +from phy.gui.qt import AsyncCaller, Worker, screenshot, screenshot_default_path, thread_pool +from phy.plot import NDC, PlotCanvas, extend_bounds from phy.utils.color import ClusterColorSelector logger = logging.getLogger(__name__) @@ -28,6 +26,7 @@ # Manual clustering view # ----------------------------------------------------------------------------- + def _get_bunch_bounds(bunch): """Return the data bounds of a bunch.""" if 'data_bounds' in bunch and bunch.data_bounds is not None: @@ -37,7 +36,7 @@ def _get_bunch_bounds(bunch): return (xmin, ymin, xmax, ymax) -class ManualClusteringView(object): +class ManualClusteringView: """Base class for clustering views. Typical property objects: @@ -58,6 +57,7 @@ class ManualClusteringView(object): - `toggle_auto_update(view)` """ + default_shortcuts = {} default_snippets = {} auto_update = True # automatically update the view when the cluster selection changes @@ -108,7 +108,6 @@ def _plot_cluster(self, bunch): To override. """ - pass def _update_axes(self): """Update the axes.""" @@ -213,7 +212,7 @@ def finished(): # HACK: disable threading mechanism for now # if getattr(gui, '_enable_threading', True): - if 0: # pragma: no cover + if 0: # pragma: no cover # This is only for OpenGL views. self.canvas.set_lazy(True) thread_pool().start(worker) @@ -255,12 +254,17 @@ def attach(self, gui): self.set_state(gui.state.get_view_state(self)) self.actions = Actions( - gui, name=self.name, view=self, - default_shortcuts=shortcuts, default_snippets=self.default_snippets) + gui, + name=self.name, + view=self, + default_shortcuts=shortcuts, + default_snippets=self.default_snippets, + ) # Freeze and unfreeze the view when selecting clusters. self.actions.add( - self.toggle_auto_update, checkable=True, checked=self.auto_update, show_shortcut=False) + self.toggle_auto_update, checkable=True, checked=self.auto_update, show_shortcut=False + ) self.actions.add(self.screenshot, show_shortcut=False) self.actions.add(self.close, show_shortcut=False) self.actions.separator() @@ -273,7 +277,7 @@ def attach(self, gui): def on_close_view(view_, gui): if view_ != self: return - logger.debug("Close view %s.", self.name) + logger.debug('Close view %s.', self.name) self._closed = True gui.remove_menu(self.name) unconnect(on_select) @@ -301,7 +305,7 @@ def status(self): def update_status(self): if hasattr(self, 'dock'): - self.dock.set_status('%s %s' % (self.status, self.ex_status)) + self.dock.set_status(f'{self.status} {self.ex_status}') # ------------------------------------------------------------------------- # Misc public methods @@ -309,7 +313,7 @@ def update_status(self): def toggle_auto_update(self, checked): """When on, the view is automatically updated when the cluster selection changes.""" - logger.debug("%s auto update for %s.", 'Enable' if checked else 'Disable', self.name) + logger.debug('%s auto update for %s.', 'Enable' if checked else 'Disable', self.name) self.auto_update = checked emit('toggle_auto_update', self, checked) @@ -334,7 +338,7 @@ def set_state(self, state): May be overridden. """ - logger.debug("Set state for %s.", getattr(self, 'name', self.__class__.__name__)) + logger.debug('Set state for %s.', getattr(self, 'name', self.__class__.__name__)) for k, v in state.items(): setattr(self, k, v) @@ -356,12 +360,13 @@ def close(self): # Mixins for manual clustering views # ----------------------------------------------------------------------------- -class BaseWheelMixin(object): + +class BaseWheelMixin: def on_mouse_wheel(self, e): pass -class BaseGlobalView(object): +class BaseGlobalView: """A view that shows all clusters instead of the selected clusters. This view shows the clusters in the same order as in the cluster view. It reacts to sorting @@ -420,11 +425,10 @@ def on_select(self, sender=None, cluster_ids=(), **kwargs): class BaseColorView(BaseWheelMixin): - """Provide facilities to add and select color schemes in the view. - """ + """Provide facilities to add and select color schemes in the view.""" def __init__(self, *args, **kwargs): - super(BaseColorView, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.state_attrs += ('color_scheme',) # Color schemes. @@ -432,8 +436,14 @@ def __init__(self, *args, **kwargs): self.add_color_scheme(fun=0, name='blank', colormap='blank', categorical=True) def add_color_scheme( - self, fun=None, name=None, cluster_ids=None, - colormap=None, categorical=None, logarithmic=None): + self, + fun=None, + name=None, + cluster_ids=None, + colormap=None, + categorical=None, + logarithmic=None, + ): """Add a color scheme to the view. Can be used as follows: ```python @@ -445,24 +455,33 @@ def on_view_attached(gui, view): """ if fun is None: return partial( - self.add_color_scheme, name=name, cluster_ids=cluster_ids, - colormap=colormap, categorical=categorical, logarithmic=logarithmic) + self.add_color_scheme, + name=name, + cluster_ids=cluster_ids, + colormap=colormap, + categorical=categorical, + logarithmic=logarithmic, + ) field = name or fun.__name__ cs = ClusterColorSelector( - fun, cluster_ids=cluster_ids, - colormap=colormap, categorical=categorical, logarithmic=logarithmic) + fun, + cluster_ids=cluster_ids, + colormap=colormap, + categorical=categorical, + logarithmic=logarithmic, + ) self.color_schemes.add(field, cs) def get_cluster_colors(self, cluster_ids, alpha=1.0): """Return the cluster colors depending on the currently-selected color scheme.""" cs = self.color_schemes.get() if cs is None: # pragma: no cover - raise RuntimeError("Make sure that at least a color scheme is added.") + raise RuntimeError('Make sure that at least a color scheme is added.') return cs.get_colors(cluster_ids, alpha=alpha) def _neighbor_color_scheme(self, dir=+1): name = self.color_schemes._neighbor(dir=dir) - logger.debug("Switch to `%s` color scheme in %s.", name, self.__class__.__name__) + logger.debug('Switch to `%s` color scheme in %s.', name, self.__class__.__name__) self.update_color() self.update_select_color() self.update_status() @@ -477,11 +496,9 @@ def previous_color_scheme(self): def update_color(self): """Update the cluster colors depending on the current color scheme. To be overridden.""" - pass def update_select_color(self): """Update the cluster colors after the cluster selection changes.""" - pass @property def color_scheme(self): @@ -491,13 +508,13 @@ def color_scheme(self): @color_scheme.setter def color_scheme(self, color_scheme): """Change the current color scheme.""" - logger.debug("Set color scheme to %s.", color_scheme) + logger.debug('Set color scheme to %s.', color_scheme) self.color_schemes.set(color_scheme) self.update_color() self.update_status() def attach(self, gui): - super(BaseColorView, self).attach(gui) + super().attach(gui) # Set the current color scheme to the GUI state color scheme (automatically set # in self.color_scheme). self.color_schemes.set(self.color_scheme) @@ -506,13 +523,17 @@ def attach(self, gui): def _make_color_scheme_action(cs): def callback(): self.color_scheme = cs + return callback for cs in self.color_schemes.keys(): - name = 'Change color scheme to %s' % cs + name = f'Change color scheme to {cs}' self.actions.add( - _make_color_scheme_action(cs), show_shortcut=False, - name=name, view_submenu='Change color scheme') + _make_color_scheme_action(cs), + show_shortcut=False, + name=name, + view_submenu='Change color scheme', + ) self.actions.add(self.next_color_scheme) self.actions.add(self.previous_color_scheme) @@ -520,7 +541,7 @@ def callback(): def on_mouse_wheel(self, e): # pragma: no cover """Change the scaling with the wheel.""" - super(BaseColorView, self).on_mouse_wheel(e) + super().on_mouse_wheel(e) if e.modifiers == ('Shift',): if e.delta > 0: self.next_color_scheme() @@ -532,6 +553,7 @@ class ScalingMixin(BaseWheelMixin): """Provide features to change the scaling. Implement increase, decrease, reset actions, as well as control+wheel shortcut.""" + _scaling_param_increment = 1.1 _scaling_param_min = 1e-3 _scaling_param_max = 1e3 @@ -539,7 +561,7 @@ class ScalingMixin(BaseWheelMixin): _scaling_modifiers = ('Control',) def attach(self, gui): - super(ScalingMixin, self).attach(gui) + super().attach(gui) self.actions.add(self.increase) self.actions.add(self.decrease) self.actions.add(self.reset_scaling) @@ -547,7 +569,7 @@ def attach(self, gui): def on_mouse_wheel(self, e): # pragma: no cover """Change the scaling with the wheel.""" - super(ScalingMixin, self).on_mouse_wheel(e) + super().on_mouse_wheel(e) if e.modifiers == self._scaling_modifiers: if e.delta > 0: self.increase() @@ -565,14 +587,16 @@ def _set_scaling_value(self, value): # pragma: no cover def increase(self): """Increase the scaling parameter.""" value = self._get_scaling_value() - self._set_scaling_value(min( - self._scaling_param_max, value * self._scaling_param_increment)) + self._set_scaling_value( + min(self._scaling_param_max, value * self._scaling_param_increment) + ) def decrease(self): """Decrease the scaling parameter.""" value = self._get_scaling_value() - self._set_scaling_value(max( - self._scaling_param_min, value / self._scaling_param_increment)) + self._set_scaling_value( + max(self._scaling_param_min, value / self._scaling_param_increment) + ) def reset_scaling(self): """Reset the scaling to the default value.""" @@ -580,14 +604,14 @@ def reset_scaling(self): class MarkerSizeMixin(BaseWheelMixin): - _marker_size = 5. - _default_marker_size = 5. + _marker_size = 5.0 + _default_marker_size = 5.0 _marker_size_min = 1e-2 _marker_size_max = 1e2 _marker_size_increment = 1.1 def __init__(self, *args, **kwargs): - super(MarkerSizeMixin, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.state_attrs += ('marker_size',) self.local_state_attrs += () @@ -607,7 +631,7 @@ def marker_size(self, val): self.canvas.update() def attach(self, gui): - super(MarkerSizeMixin, self).attach(gui) + super().attach(gui) self.actions.add(self.increase_marker_size) self.actions.add(self.decrease_marker_size) self.actions.add(self.reset_marker_size) @@ -616,12 +640,14 @@ def attach(self, gui): def increase_marker_size(self): """Increase the scaling parameter.""" self.marker_size = min( - self._marker_size_max, self.marker_size * self._marker_size_increment) + self._marker_size_max, self.marker_size * self._marker_size_increment + ) def decrease_marker_size(self): """Decrease the scaling parameter.""" self.marker_size = max( - self._marker_size_min, self.marker_size / self._marker_size_increment) + self._marker_size_min, self.marker_size / self._marker_size_increment + ) def reset_marker_size(self): """Reset the scaling to the default value.""" @@ -629,7 +655,7 @@ def reset_marker_size(self): def on_mouse_wheel(self, e): # pragma: no cover """Change the scaling with the wheel.""" - super(MarkerSizeMixin, self).on_mouse_wheel(e) + super().on_mouse_wheel(e) if e.modifiers == ('Alt',): if e.delta > 0: self.increase_marker_size() @@ -637,10 +663,10 @@ def on_mouse_wheel(self, e): # pragma: no cover self.decrease_marker_size() -class LassoMixin(object): +class LassoMixin: def on_request_split(self, sender=None): """Return the spikes enclosed by the lasso.""" - if (self.canvas.lasso.count < 3 or not len(self.cluster_ids)): # pragma: no cover + if self.canvas.lasso.count < 3 or not len(self.cluster_ids): # pragma: no cover return np.array([], dtype=np.int64) # Get all points from all clusters. @@ -661,7 +687,7 @@ def on_request_split(self, sender=None): pos.append(points) spike_ids.append(bunch.spike_ids) if not pos: # pragma: no cover - logger.warning("Empty lasso.") + logger.warning('Empty lasso.') return np.array([]) pos = np.vstack(pos) pos = range_transform([self.data_bounds], [NDC], pos) @@ -673,5 +699,5 @@ def on_request_split(self, sender=None): return np.unique(spike_ids[ind]) def attach(self, gui): - super(LassoMixin, self).attach(gui) + super().attach(gui) connect(self.on_request_split) diff --git a/phy/cluster/views/cluscatter.py b/phy/cluster/views/cluscatter.py index 9fa9dd09c..1220b48c6 100644 --- a/phy/cluster/views/cluscatter.py +++ b/phy/cluster/views/cluscatter.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - """Cluster scatter view.""" @@ -10,13 +8,13 @@ import logging import numpy as np +from phylib.utils import connect, emit, unconnect +from phy.plot.transform import NDC, range_transform +from phy.plot.visuals import ScatterVisual, TextVisual from phy.utils.color import _add_selected_clusters_colors -from phylib.utils import emit, connect, unconnect -from phy.plot.transform import range_transform, NDC -from phy.plot.visuals import ScatterVisual, TextVisual -from .base import ManualClusteringView, BaseGlobalView, MarkerSizeMixin, BaseColorView +from .base import BaseColorView, BaseGlobalView, ManualClusteringView, MarkerSizeMixin logger = logging.getLogger(__name__) @@ -25,6 +23,7 @@ # Template view # ----------------------------------------------------------------------------- + class ClusterScatterView(MarkerSizeMixin, BaseColorView, BaseGlobalView, ManualClusteringView): """This view shows all clusters in a customizable scatter plot. @@ -38,16 +37,17 @@ class ClusterScatterView(MarkerSizeMixin, BaseColorView, BaseGlobalView, ManualC Maps plot dimension to cluster attributes. """ + _default_position = 'right' - _scaling = 1. - _default_alpha = .75 + _scaling = 1.0 + _default_alpha = 0.75 _min_marker_size = 5.0 _max_marker_size = 30.0 _dims = ('x_axis', 'y_axis', 'size') # NOTE: this is not the actual marker size, but a scaling factor for the normal marker size. - _marker_size = 1. - _default_marker_size = 1. + _marker_size = 1.0 + _default_marker_size = 1.0 x_axis = '' y_axis = '' @@ -71,13 +71,16 @@ class ClusterScatterView(MarkerSizeMixin, BaseColorView, BaseGlobalView, ManualC 'set_size': 'css', } - def __init__( - self, cluster_ids=None, cluster_info=None, bindings=None, **kwargs): - super(ClusterScatterView, self).__init__(**kwargs) + def __init__(self, cluster_ids=None, cluster_info=None, bindings=None, **kwargs): + super().__init__(**kwargs) self.state_attrs += ( 'scaling', - 'x_axis', 'y_axis', 'size', - 'x_axis_log_scale', 'y_axis_log_scale', 'size_log_scale', + 'x_axis', + 'y_axis', + 'size', + 'x_axis_log_scale', + 'y_axis_log_scale', + 'size_log_scale', ) self.local_state_attrs += () @@ -105,8 +108,10 @@ def __init__( def _update_labels(self): self.label_visual.set_data( - pos=[[-1, -1], [1, 1]], text=[self.x_axis, self.y_axis], - anchor=[[1.25, 3], [-3, -1.25]]) + pos=[[-1, -1], [1, 1]], + text=[self.x_axis, self.y_axis], + anchor=[[1.25, 3], [-3, -1.25]], + ) # Data access # ------------------------------------------------------------------------- @@ -118,7 +123,7 @@ def bindings(self): def get_cluster_data(self, cluster_id): """Return the data of one cluster.""" data = self.cluster_info(cluster_id) - return {k: data.get(v, 0.) for k, v in self.bindings.items()} + return {k: data.get(v, 0.0) for k, v in self.bindings.items()} def get_clusters_data(self, cluster_ids): """Return the data of a set of clusters, as a dictionary {cluster_id: Bunch}.""" @@ -156,13 +161,15 @@ def prepare_position(self): # Create the x array. x = np.array( - [self.cluster_data[cluster_id]['x_axis'] or 0. for cluster_id in self.all_cluster_ids]) + [self.cluster_data[cluster_id]['x_axis'] or 0.0 for cluster_id in self.all_cluster_ids] + ) if self.x_axis_log_scale: x = np.log(1.0 + x - x.min()) # Create the y array. y = np.array( - [self.cluster_data[cluster_id]['y_axis'] or 0. for cluster_id in self.all_cluster_ids]) + [self.cluster_data[cluster_id]['y_axis'] or 0.0 for cluster_id in self.all_cluster_ids] + ) if self.y_axis_log_scale: y = np.log(1.0 + y - y.min()) @@ -174,7 +181,8 @@ def prepare_position(self): def prepare_size(self): """Compute the marker sizes.""" size = np.array( - [self.cluster_data[cluster_id]['size'] or 1. for cluster_id in self.all_cluster_ids]) + [self.cluster_data[cluster_id]['size'] or 1.0 for cluster_id in self.all_cluster_ids] + ) # Log scale for the size. if self.size_log_scale: size = np.log(1.0 + size - size.min()) @@ -229,7 +237,8 @@ def update_select_color(self): selected_clusters = self.cluster_ids if selected_clusters is not None and len(selected_clusters) > 0: colors = _add_selected_clusters_colors( - selected_clusters, self.all_cluster_ids, self.marker_colors.copy()) + selected_clusters, self.all_cluster_ids, self.marker_colors.copy() + ) self.visual.set_color(colors) self.canvas.update() @@ -238,9 +247,11 @@ def plot(self, **kwargs): if self.marker_positions is None: self.prepare_data() self.visual.set_data( - pos=self.marker_positions, color=self.marker_colors, + pos=self.marker_positions, + color=self.marker_colors, size=self.marker_sizes * self._marker_size, # marker size scaling factor - data_bounds=self.data_bounds) + data_bounds=self.data_bounds, + ) self.canvas.axes.reset_data_bounds(self.data_bounds) self.canvas.update() @@ -261,7 +272,7 @@ def change_bindings(self, **kwargs): def toggle_log_scale(self, dim, checked): """Toggle logarithmic scaling for one of the dimensions.""" self._size_min = None - setattr(self, '%s_log_scale' % dim, checked) + setattr(self, f'{dim}_log_scale', checked) self.prepare_data() self.plot() self.canvas.update() @@ -283,34 +294,43 @@ def set_size(self, field): def attach(self, gui): """Attach the GUI.""" - super(ClusterScatterView, self).attach(gui) + super().attach(gui) def _make_action(dim, name): def callback(): self.change_bindings(**{dim: name}) + return callback def _make_log_toggle(dim): def callback(checked): self.toggle_log_scale(dim, checked) + return callback # Change the bindings. for dim in self._dims: - view_submenu = 'Change %s' % dim + view_submenu = f'Change {dim}' # Change to every cluster info. for name in self.fields: self.actions.add( - _make_action(dim, name), show_shortcut=False, - name='Change %s to %s' % (dim, name), view_submenu=view_submenu) + _make_action(dim, name), + show_shortcut=False, + name=f'Change {dim} to {name}', + view_submenu=view_submenu, + ) # Toggle logarithmic scale. self.actions.separator(view_submenu=view_submenu) self.actions.add( - _make_log_toggle(dim), checkable=True, view_submenu=view_submenu, - name='Toggle log scale for %s' % dim, show_shortcut=False, - checked=getattr(self, '%s_log_scale' % dim)) + _make_log_toggle(dim), + checkable=True, + view_submenu=view_submenu, + name=f'Toggle log scale for {dim}', + show_shortcut=False, + checked=getattr(self, f'{dim}_log_scale'), + ) self.actions.separator() self.actions.add(self.set_x_axis, prompt=True, prompt_default=lambda: self.x_axis) @@ -327,7 +347,7 @@ def on_lasso_updated(sender, polygon): pos = range_transform([self.data_bounds], [NDC], self.marker_positions) ind = self.canvas.lasso.in_polygon(pos) cluster_ids = self.all_cluster_ids[ind] - emit("request_select", self, list(cluster_ids)) + emit('request_select', self, list(cluster_ids)) @connect(sender=self) def on_close_view(view_, gui): @@ -341,7 +361,7 @@ def on_close_view(view_, gui): self._update_labels() def on_select(self, *args, **kwargs): - super(ClusterScatterView, self).on_select(*args, **kwargs) + super().on_select(*args, **kwargs) self.update_select_color() def on_cluster(self, sender, up): @@ -351,7 +371,7 @@ def on_cluster(self, sender, up): @property def status(self): - return 'Size: %s. Color scheme: %s.' % (self.size, self.color_scheme) + return f'Size: {self.size}. Color scheme: {self.color_scheme}.' # Interactivity # ------------------------------------------------------------------------- @@ -365,7 +385,7 @@ def on_mouse_click(self, e): marker_pos = range_transform([self.data_bounds], [NDC], self.marker_positions) cluster_rel = np.argmin(((marker_pos - pos) ** 2).sum(axis=1)) cluster_id = self.all_cluster_ids[cluster_rel] - logger.debug("Click on cluster %d with button %s.", cluster_id, b) + logger.debug('Click on cluster %d with button %s.', cluster_id, b) if 'Shift' in e.modifiers: emit('select_more', self, [cluster_id]) else: diff --git a/phy/cluster/views/correlogram.py b/phy/cluster/views/correlogram.py index d6a3f321a..2a1d9d612 100644 --- a/phy/cluster/views/correlogram.py +++ b/phy/cluster/views/correlogram.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - """Correlogram view.""" @@ -10,12 +8,13 @@ import logging import numpy as np +from phylib.io.array import _clip +from phylib.utils import Bunch from phy.plot.transform import Scale from phy.plot.visuals import HistogramVisual, LineVisual, TextVisual -from phylib.io.array import _clip -from phylib.utils import Bunch -from phy.utils.color import selected_cluster_color, _override_hsv, add_alpha +from phy.utils.color import _override_hsv, add_alpha, selected_cluster_color + from .base import ManualClusteringView, ScalingMixin logger = logging.getLogger(__name__) @@ -25,6 +24,7 @@ # Correlogram view # ----------------------------------------------------------------------------- + class CorrelogramView(ScalingMixin, ManualClusteringView): """A view showing the autocorrelogram of the selected clusters, and all cross-correlograms of cluster pairs. @@ -70,14 +70,18 @@ class CorrelogramView(ScalingMixin, ManualClusteringView): } def __init__(self, correlograms=None, firing_rate=None, sample_rate=None, **kwargs): - super(CorrelogramView, self).__init__(**kwargs) + super().__init__(**kwargs) self.state_attrs += ( - 'bin_size', 'window_size', 'refractory_period', 'uniform_normalization') + 'bin_size', + 'window_size', + 'refractory_period', + 'uniform_normalization', + ) self.local_state_attrs += () self.canvas.set_layout(layout='grid') # Outside margin to show labels. - self.canvas.gpu_transforms.add(Scale(.9)) + self.canvas.gpu_transforms.add(Scale(0.9)) assert sample_rate > 0 self.sample_rate = float(sample_rate) @@ -97,7 +101,7 @@ def __init__(self, correlograms=None, firing_rate=None, sample_rate=None, **kwar self.line_visual = LineVisual() self.canvas.add_visual(self.line_visual) - self.text_visual = TextVisual(color=(1., 1., 1., 1.)) + self.text_visual = TextVisual(color=(1.0, 1.0, 1.0, 1.0)) self.canvas.add_visual(self.text_visual) # ------------------------------------------------------------------------- @@ -127,23 +131,27 @@ def get_clusters_data(self, load_all=None): b.pair_index = i, j b.color = selected_cluster_color(i, 1) if i != j: - b.color = add_alpha(_override_hsv(b.color[:3], s=.1, v=1)) + b.color = add_alpha(_override_hsv(b.color[:3], s=0.1, v=1)) bunchs.append(b) return bunchs def _plot_pair(self, bunch): # Plot the histogram. self.correlogram_visual.add_batch_data( - hist=bunch.correlogram, color=bunch.color, - ylim=bunch.data_bounds[3], box_index=bunch.pair_index) + hist=bunch.correlogram, + color=bunch.color, + ylim=bunch.data_bounds[3], + box_index=bunch.pair_index, + ) # Plot the firing rate. - gray = (.25, .25, .25, 1.) + gray = (0.25, 0.25, 0.25, 1.0) if bunch.firing_rate is not None: # Line. pos = np.array([[0, bunch.firing_rate, bunch.data_bounds[2], bunch.firing_rate]]) self.line_visual.add_batch_data( - pos=pos, color=gray, data_bounds=bunch.data_bounds, box_index=bunch.pair_index) + pos=pos, color=gray, data_bounds=bunch.data_bounds, box_index=bunch.pair_index + ) # # Text. # self.text_visual.add_batch_data( # pos=[bunch.data_bounds[2], bunch.firing_rate], @@ -154,12 +162,13 @@ def _plot_pair(self, bunch): # ) # Refractory period. - xrp0 = round((self.window_size * .5 - self.refractory_period) / self.bin_size) - xrp1 = round((self.window_size * .5 + self.refractory_period) / self.bin_size) + 1 + xrp0 = round((self.window_size * 0.5 - self.refractory_period) / self.bin_size) + xrp1 = round((self.window_size * 0.5 + self.refractory_period) / self.bin_size) + 1 ylim = bunch.data_bounds[3] pos = np.array([[xrp0, 0, xrp0, ylim], [xrp1, 0, xrp1, ylim]]) self.line_visual.add_batch_data( - pos=pos, color=gray, data_bounds=bunch.data_bounds, box_index=bunch.pair_index) + pos=pos, color=gray, data_bounds=bunch.data_bounds, box_index=bunch.pair_index + ) def _plot_labels(self): n = len(self.cluster_ids) @@ -228,19 +237,21 @@ def toggle_labels(self, checked): def attach(self, gui): """Attach the view to the GUI.""" - super(CorrelogramView, self).attach(gui) + super().attach(gui) self.actions.add(self.toggle_normalization, shortcut='n', checkable=True) self.actions.add(self.toggle_labels, checkable=True, checked=True) self.actions.separator() + self.actions.add(self.set_bin, prompt=True, prompt_default=lambda: self.bin_size * 1000) self.actions.add( - self.set_bin, prompt=True, prompt_default=lambda: self.bin_size * 1000) - self.actions.add( - self.set_window, prompt=True, prompt_default=lambda: self.window_size * 1000) + self.set_window, prompt=True, prompt_default=lambda: self.window_size * 1000 + ) self.actions.add( - self.set_refractory_period, prompt=True, - prompt_default=lambda: self.refractory_period * 1000) + self.set_refractory_period, + prompt=True, + prompt_default=lambda: self.refractory_period * 1000, + ) self.actions.separator() # ------------------------------------------------------------------------- @@ -263,11 +274,11 @@ def _set_bin_window(self, bin_size=None, window_size=None): @property def status(self): b, w = self.bin_size * 1000, self.window_size * 1000 - return '{:.1f} ms ({:.1f} ms)'.format(w, b) + return f'{w:.1f} ms ({b:.1f} ms)' def set_refractory_period(self, value): """Set the refractory period (in milliseconds).""" - self.refractory_period = _clip(value, .1, 100) * 1e-3 + self.refractory_period = _clip(value, 0.1, 100) * 1e-3 self.plot() def set_bin(self, bin_size): @@ -298,7 +309,7 @@ def decrease(self): def on_mouse_wheel(self, e): # pragma: no cover """Change the scaling with the wheel.""" - super(CorrelogramView, self).on_mouse_wheel(e) + super().on_mouse_wheel(e) if e.modifiers == ('Alt',): - self._set_bin_window(bin_size=self.bin_size * 1.1 ** e.delta) + self._set_bin_window(bin_size=self.bin_size * 1.1**e.delta) self.plot() diff --git a/phy/cluster/views/feature.py b/phy/cluster/views/feature.py index dd8bd39c6..772d92749 100644 --- a/phy/cluster/views/feature.py +++ b/phy/cluster/views/feature.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - """Feature view.""" @@ -11,11 +9,12 @@ import re import numpy as np - from phylib.utils import Bunch, emit -from phy.utils.color import selected_cluster_color + from phy.plot.transform import Range -from phy.plot.visuals import ScatterVisual, TextVisual, LineVisual +from phy.plot.visuals import LineVisual, ScatterVisual, TextVisual +from phy.utils.color import selected_cluster_color + from .base import ManualClusteringView, MarkerSizeMixin, ScalingMixin logger = logging.getLogger(__name__) @@ -25,6 +24,7 @@ # Feature view # ----------------------------------------------------------------------------- + def _get_default_grid(): """In the grid specification, 0 corresponds to the best channel, 1 to the second best, and so on. A, B, C refer to the PC components.""" @@ -38,18 +38,15 @@ def _get_default_grid(): def _get_point_color(clu_idx=None): - if clu_idx is not None: - color = selected_cluster_color(clu_idx, .5) - else: - color = (.5,) * 4 + color = selected_cluster_color(clu_idx, 0.5) if clu_idx is not None else (0.5,) * 4 assert len(color) == 4 return color def _get_point_masks(masks=None, clu_idx=None): - masks = masks if masks is not None else 1. + masks = masks if masks is not None else 1.0 # NOTE: we add the cluster relative index for the computation of the depth on the GPU. - return masks * .99999 + (clu_idx or 0) + return masks * 0.99999 + (clu_idx or 0) def _get_masks_max(px, py): @@ -97,7 +94,7 @@ class FeatureView(MarkerSizeMixin, ScalingMixin, ManualClusteringView): # Whether to disable automatic selection of channels. fixed_channels = False - feature_scaling = 1. + feature_scaling = 1.0 default_shortcuts = { 'change_marker_size': 'alt+wheel', @@ -109,14 +106,16 @@ class FeatureView(MarkerSizeMixin, ScalingMixin, ManualClusteringView): } def __init__(self, features=None, attributes=None, **kwargs): - super(FeatureView, self).__init__(**kwargs) + super().__init__(**kwargs) self.state_attrs += ('fixed_channels', 'feature_scaling') assert features self.features = features self._lim = 1 - self.grid_dim = _get_default_grid() # 2D array where every item a string like `0A,1B` + self.grid_dim = ( + _get_default_grid() + ) # 2D array where every item a string like `0A,1B` self.n_rows, self.n_cols = np.array(self.grid_dim).shape self.canvas.set_layout('grid', shape=(self.n_rows, self.n_cols)) self.canvas.enable_lasso() @@ -226,7 +225,7 @@ def _plot_points(self, bunch, clu_idx=None): py = self._get_axis_data(bunch, dim_y, cluster_id=cluster_id) # Skip empty data. if px is None or py is None: # pragma: no cover - logger.warning("Skipping empty data for cluster %d.", cluster_id) + logger.warning('Skipping empty data for cluster %d.', cluster_id) return assert px.data.shape == py.data.shape xmin, xmax = self._get_axis_bounds(dim_x, px) @@ -238,7 +237,8 @@ def _plot_points(self, bunch, clu_idx=None): # Prepare the batch visual with all subplots # for the selected cluster. self.visual.add_batch_data( - x=px.data, y=py.data, + x=px.data, + y=py.data, color=_get_point_color(clu_idx), # Reduced marker size for background features size=self._marker_size, @@ -260,7 +260,7 @@ def _plot_points(self, bunch, clu_idx=None): box_index=(i, j), ) self.text_visual.add_batch_data( - pos=[0, -1.], + pos=[0, -1.0], anchor=[0, 1], text=label_x, data_bounds=None, @@ -271,9 +271,8 @@ def _plot_axes(self): self.line_visual.reset_batch() for i, j, dim_x, dim_y in self._iter_subplots(): self.line_visual.add_batch_data( - pos=[[-1., 0., +1., 0.], - [0., -1., 0., +1.]], - color=(.5, .5, .5, .5), + pos=[[-1.0, 0.0, +1.0, 0.0], [0.0, -1.0, 0.0, +1.0]], + color=(0.5, 0.5, 0.5, 0.5), box_index=(i, j), data_bounds=None, ) @@ -282,7 +281,10 @@ def _plot_axes(self): def _get_lim(self, bunchs): if not bunchs: # pragma: no cover return 1 - m, M = min(bunch.data.min() for bunch in bunchs), max(bunch.data.max() for bunch in bunchs) + m, M = ( + min(bunch.data.min() for bunch in bunchs), + max(bunch.data.max() for bunch in bunchs), + ) M = max(abs(m), abs(M)) return M @@ -306,7 +308,9 @@ def get_clusters_data(self, fixed_channels=None, load_all=None): # Specify the channel ids if these are fixed, otherwise # choose the first cluster's best channels. c = self.channel_ids if fixed_channels else None - bunchs = [self.features(cluster_id, channel_ids=c) for cluster_id in self.cluster_ids] + bunchs = [ + self.features(cluster_id, channel_ids=c) for cluster_id in self.cluster_ids + ] bunchs = [b for b in bunchs if b] if not bunchs: # pragma: no cover return [] @@ -318,26 +322,32 @@ def get_clusters_data(self, fixed_channels=None, load_all=None): common_channels = list(channel_ids) # Intersection (with order kept) of channels belonging to all clusters. for bunch in bunchs: - common_channels = [c for c in bunch.get('channel_ids', []) if c in common_channels] + common_channels = [ + c for c in bunch.get('channel_ids', []) if c in common_channels + ] # The selected channels will be (1) the channels common to all clusters, followed # by (2) remaining channels from the first cluster (excluding those already selected # in (1)). n = len(channel_ids) not_common_channels = [c for c in channel_ids if c not in common_channels] - channel_ids = common_channels + not_common_channels[:n - len(common_channels)] + channel_ids = common_channels + not_common_channels[: n - len(common_channels)] assert len(channel_ids) > 0 # Choose the channels automatically unless fixed_channels is set. - if (not fixed_channels or self.channel_ids is None): + if not fixed_channels or self.channel_ids is None: self.channel_ids = channel_ids assert len(self.channel_ids) # Channel labels. self.channel_labels = {} for d in bunchs: - chl = d.get('channel_labels', ['%d' % ch for ch in d.get('channel_ids', [])]) - self.channel_labels.update({ - channel_id: chl[i] for i, channel_id in enumerate(d.get('channel_ids', []))}) + chl = d.get('channel_labels', [f'{ch}' for ch in d.get('channel_ids', [])]) + self.channel_labels.update( + { + channel_id: chl[i] + for i, channel_id in enumerate(d.get('channel_ids', [])) + } + ) return bunchs @@ -349,7 +359,8 @@ def plot(self, **kwargs): # Fix the channels if the view updates after a cluster event # and there are new clusters. fixed_channels = ( - self.fixed_channels or kwargs.get('fixed_channels', None) or added is not None) + self.fixed_channels or kwargs.get('fixed_channels') or added is not None + ) # Get the clusters data. bunchs = self.get_clusters_data(fixed_channels=fixed_channels) @@ -385,11 +396,13 @@ def plot(self, **kwargs): def attach(self, gui): """Attach the view to the GUI.""" - super(FeatureView, self).attach(gui) + super().attach(gui) self.actions.add( self.toggle_automatic_channel_selection, - checked=not self.fixed_channels, checkable=True) + checked=not self.fixed_channels, + checkable=True, + ) self.actions.add(self.clear_channels) self.actions.separator() @@ -402,7 +415,7 @@ def status(self): if self.channel_ids is None: # pragma: no cover return '' channel_labels = [self.channel_labels[ch] for ch in self.channel_ids[:2]] - return 'channels: %s' % ', '.join(channel_labels) + return f'channels: {", ".join(channel_labels)}' # Dimension selection # ------------------------------------------------------------------------- @@ -434,7 +447,7 @@ def on_select_channel(self, sender=None, channel_id=None, key=None, button=None) assert channels[0] != channels[1] # Remove duplicate channels. self.channel_ids = _uniq(channels) - logger.debug("Choose channels %d and %d in feature view.", *channels[:2]) + logger.debug('Choose channels %d and %d in feature view.', *channels[:2]) # Fix the channels temporarily. self.plot(fixed_channels=True) self.update_status() @@ -455,19 +468,24 @@ def on_mouse_click(self, e): if channel_pc is None: return channel_id, pc = channel_pc - logger.debug("Click on feature dim %s, channel id %s, PC %s.", dim, channel_id, pc) + logger.debug( + 'Click on feature dim %s, channel id %s, PC %s.', + dim, + channel_id, + pc, + ) else: # When the selected dimension is an attribute, e.g. "time". pc = None # Take the channel id in the other dimension. channel_pc = self._get_channel_and_pc(other_dim) channel_id = channel_pc[0] if channel_pc is not None else None - logger.debug("Click on feature dim %s.", dim) + logger.debug('Click on feature dim %s.', dim) emit('select_feature', self, dim=dim, channel_id=channel_id, pc=pc) def on_request_split(self, sender=None): """Return the spikes enclosed by the lasso.""" - if (self.canvas.lasso.count < 3 or not len(self.cluster_ids)): # pragma: no cover + if self.canvas.lasso.count < 3 or not len(self.cluster_ids): # pragma: no cover return np.array([], dtype=np.int64) assert len(self.channel_ids) @@ -482,7 +500,9 @@ def on_request_split(self, sender=None): for cluster_id in self.cluster_ids: # Load all spikes. - bunch = self.features(cluster_id, channel_ids=self.channel_ids, load_all=True) + bunch = self.features( + cluster_id, channel_ids=self.channel_ids, load_all=True + ) if not bunch: continue px = self._get_axis_data(bunch, dim_x, cluster_id=cluster_id, load_all=True) diff --git a/phy/cluster/views/histogram.py b/phy/cluster/views/histogram.py index 13f8dfb0e..f6c98ae28 100644 --- a/phy/cluster/views/histogram.py +++ b/phy/cluster/views/histogram.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - """Histogram view.""" @@ -10,10 +8,11 @@ import logging import numpy as np - from phylib.io.array import _clip + from phy.plot.visuals import HistogramVisual, TextVisual from phy.utils.color import selected_cluster_color + from .base import ManualClusteringView, ScalingMixin logger = logging.getLogger(__name__) @@ -23,8 +22,10 @@ # Histogram view # ----------------------------------------------------------------------------- + def _compute_histogram( - data, x_max=None, x_min=None, n_bins=None, normalize=True, ignore_zeros=False): + data, x_max=None, x_min=None, n_bins=None, normalize=True, ignore_zeros=False +): """Compute the histogram of an array.""" assert x_min <= x_max assert n_bins >= 0 @@ -37,7 +38,7 @@ def _compute_histogram( return histogram # Normalize by the integral of the histogram. hist_sum = histogram.sum() * (bins[1] - bins[0]) - return histogram / (hist_sum or 1.) + return histogram / (hist_sum or 1.0) def _first_not_null(*l): @@ -68,7 +69,7 @@ class HistogramView(ScalingMixin, ManualClusteringView): n_bins = 100 # Step on the x axis when changing the histogram range with the mouse wheel. - x_delta = .01 # in seconds + x_delta = 0.01 # in seconds # Minimum value on the x axis (determines the range of the histogram) # If None, then `data.min()` is used. @@ -90,17 +91,17 @@ class HistogramView(ScalingMixin, ManualClusteringView): } default_snippets = { - 'set_n_bins': '%sn' % alias_char, - 'set_bin_size (%s)' % bin_unit: '%sb' % alias_char, - 'set_x_min (%s)' % bin_unit: '%smin' % alias_char, - 'set_x_max (%s)' % bin_unit: '%smax' % alias_char, + 'set_n_bins': f'{alias_char}n', + f'set_bin_size ({bin_unit})': f'{alias_char}b', + f'set_x_min ({bin_unit})': f'{alias_char}min', + f'set_x_max ({bin_unit})': f'{alias_char}max', } _state_attrs = ('n_bins', 'x_min', 'x_max') _local_state_attrs = () def __init__(self, cluster_stat=None): - super(HistogramView, self).__init__() + super().__init__() self.state_attrs += self._state_attrs self.local_state_attrs += self._local_state_attrs self.canvas.set_layout(layout='stacked', n_plots=1) @@ -114,7 +115,7 @@ def __init__(self, cluster_stat=None): # self.plot_visual = PlotVisual() # self.canvas.add_visual(self.plot_visual) - self.text_visual = TextVisual(color=(1., 1., 1., 1.)) + self.text_visual = TextVisual(color=(1.0, 1.0, 1.0, 1.0)) self.canvas.add_visual(self.text_visual) def _plot_cluster(self, bunch): @@ -124,7 +125,8 @@ def _plot_cluster(self, bunch): # Update the visual's data. self.visual.add_batch_data( - hist=bunch.histogram, ylim=bunch.ylim, color=bunch.color, box_index=bunch.index) + hist=bunch.histogram, ylim=bunch.ylim, color=bunch.color, box_index=bunch.index + ) # # Plot. # plot = bunch.get('plot', None) @@ -142,7 +144,9 @@ def _plot_cluster(self, bunch): text = text.splitlines() n = len(text) self.text_visual.add_batch_data( - text=text, pos=[(-1, .8)] * n, anchor=[(1, -1 - 2 * i) for i in range(n)], + text=text, + pos=[(-1, 0.8)] * n, + anchor=[(1, -1 - 2 * i) for i in range(n)], box_index=bunch.index, ) @@ -163,7 +167,8 @@ def get_clusters_data(self, load_all=None): # Compute the histogram. bunch.histogram = _compute_histogram( - bunch.data, x_min=self.x_min, x_max=self.x_max, n_bins=self.n_bins) + bunch.data, x_min=self.x_min, x_max=self.x_max, n_bins=self.n_bins + ) bunch.ylim = bunch.histogram.max() bunch.color = selected_cluster_color(i) @@ -199,27 +204,38 @@ def plot(self, **kwargs): def attach(self, gui): """Attach the view to the GUI.""" - super(HistogramView, self).attach(gui) + super().attach(gui) self.actions.add( - self.set_n_bins, alias=self.alias_char + 'n', - prompt=True, prompt_default=lambda: self.n_bins) + self.set_n_bins, + alias=f'{self.alias_char}n', + prompt=True, + prompt_default=lambda: self.n_bins, + ) self.actions.add( - self.set_bin_size, alias=self.alias_char + 'b', - prompt=True, prompt_default=lambda: self.bin_size) + self.set_bin_size, + alias=f'{self.alias_char}b', + prompt=True, + prompt_default=lambda: self.bin_size, + ) self.actions.add( - self.set_x_min, alias=self.alias_char + 'min', - prompt=True, prompt_default=lambda: self.x_min) + self.set_x_min, + alias=f'{self.alias_char}min', + prompt=True, + prompt_default=lambda: self.x_min, + ) self.actions.add( - self.set_x_max, alias=self.alias_char + 'max', - prompt=True, prompt_default=lambda: self.x_max) + self.set_x_max, + alias=f'{self.alias_char}max', + prompt=True, + prompt_default=lambda: self.x_max, + ) self.actions.separator() @property def status(self): f = 1 if self.bin_unit == 's' else 1000 - return '[{:.1f}{u}, {:.1f}{u:s}]'.format( - (self.x_min or 0) * f, (self.x_max or 0) * f, u=self.bin_unit) + return f'[{(self.x_min or 0) * f:.1f}{self.bin_unit}, {(self.x_max or 0) * f:.1f}{self.bin_unit:s}]' # Histogram parameters # ------------------------------------------------------------------------- @@ -235,7 +251,7 @@ def _set_scaling_value(self, value): def set_n_bins(self, n_bins): """Set the number of bins in the histogram.""" self.n_bins = n_bins - logger.debug("Change number of bins to %d for %s.", n_bins, self.__class__.__name__) + logger.debug('Change number of bins to %d for %s.', n_bins, self.__class__.__name__) self.plot() @property @@ -252,7 +268,7 @@ def set_bin_size(self, bin_size): if self.bin_unit == 'ms': bin_size /= 1000 self.n_bins = np.round((self.x_max - self.x_min) / bin_size) - logger.debug("Change number of bins to %d for %s.", self.n_bins, self.__class__.__name__) + logger.debug('Change number of bins to %d for %s.', self.n_bins, self.__class__.__name__) self.plot() def set_x_min(self, x_min): @@ -263,7 +279,7 @@ def set_x_min(self, x_min): if x_min == self.x_max: return self.x_min = x_min - logger.log(5, "Change x min to %s for %s.", x_min, self.__class__.__name__) + logger.log(5, 'Change x min to %s for %s.', x_min, self.__class__.__name__) self.plot() def set_x_max(self, x_max): @@ -274,19 +290,19 @@ def set_x_max(self, x_max): if x_max == self.x_min: return self.x_max = x_max - logger.log(5, "Change x max to %s for %s.", x_max, self.__class__.__name__) + logger.log(5, 'Change x max to %s for %s.', x_max, self.__class__.__name__) self.plot() def on_mouse_wheel(self, e): # pragma: no cover """Change the scaling with the wheel.""" - super(HistogramView, self).on_mouse_wheel(e) + super().on_mouse_wheel(e) if e.modifiers == ('Shift',): - self.x_min *= 1.1 ** e.delta + self.x_min *= 1.1**e.delta self.x_min = min(self.x_min, self.x_max) if self.x_min < self.x_max: self.plot() elif e.modifiers == ('Alt',): - self.n_bins /= 1.05 ** e.delta + self.n_bins /= 1.05**e.delta self.n_bins = int(self.n_bins) self.n_bins = max(2, self.n_bins) self.plot() @@ -294,9 +310,10 @@ def on_mouse_wheel(self, e): # pragma: no cover class ISIView(HistogramView): """Histogram view showing the interspike intervals.""" + x_min = 0 - x_max = .05 # window size is 50 ms by default - n_bins = int(x_max / .001) # by default, 1 bin = 1 ms + x_max = 0.05 # window size is 50 ms by default + n_bins = int(x_max / 0.001) # by default, 1 bin = 1 ms alias_char = 'isi' # provide `:isisn` (set number of bins) and `:isim` (set max bin) snippets bin_unit = 'ms' # user-provided bin values in milliseconds, but stored in seconds @@ -305,15 +322,16 @@ class ISIView(HistogramView): } default_snippets = { - 'set_n_bins': '%sn' % alias_char, - 'set_bin_size (%s)' % bin_unit: '%sb' % alias_char, - 'set_x_min (%s)' % bin_unit: '%smin' % alias_char, - 'set_x_max (%s)' % bin_unit: '%smax' % alias_char, + 'set_n_bins': f'{alias_char}n', + f'set_bin_size ({bin_unit})': f'{alias_char}b', + f'set_x_min ({bin_unit})': f'{alias_char}min', + f'set_x_max ({bin_unit})': f'{alias_char}max', } class FiringRateView(HistogramView): """Histogram view showing the time-dependent firing rate.""" + n_bins = 200 alias_char = 'fr' bin_unit = 's' @@ -327,8 +345,8 @@ class FiringRateView(HistogramView): } default_snippets = { - 'set_n_bins': '%sn' % alias_char, - 'set_bin_size (%s)' % bin_unit: '%sb' % alias_char, - 'set_x_min (%s)' % bin_unit: '%smin' % alias_char, - 'set_x_max (%s)' % bin_unit: '%smax' % alias_char, + 'set_n_bins': f'{alias_char}n', + f'set_bin_size ({bin_unit})': f'{alias_char}b', + f'set_x_min ({bin_unit})': f'{alias_char}min', + f'set_x_max ({bin_unit})': f'{alias_char}max', } diff --git a/phy/cluster/views/probe.py b/phy/cluster/views/probe.py index 43d535e24..60fc03013 100644 --- a/phy/cluster/views/probe.py +++ b/phy/cluster/views/probe.py @@ -1,19 +1,18 @@ -# -*- coding: utf-8 -*- - """Probe view.""" # ----------------------------------------------------------------------------- # Imports # ----------------------------------------------------------------------------- -from collections import defaultdict import logging +from collections import defaultdict import numpy as np - -from phy.utils.color import selected_cluster_color from phylib.utils.geometry import get_non_overlapping_boxes + from phy.plot.visuals import ScatterVisual, TextVisual +from phy.utils.color import selected_cluster_color + from .base import ManualClusteringView logger = logging.getLogger(__name__) @@ -23,13 +22,14 @@ # Probe view # ----------------------------------------------------------------------------- + def _get_pos_data_bounds(positions): positions, _ = get_non_overlapping_boxes(positions) x, y = positions.T xmin, ymin, xmax, ymax = x.min(), y.min(), x.max(), y.max() w = xmax - xmin h = ymax - ymin - k = .05 + k = 0.05 data_bounds = (xmin - w * k, ymin - h * k, xmax + w * k, ymax + h * k) return positions, data_bounds @@ -64,20 +64,22 @@ class ProbeView(ManualClusteringView): selected_marker_size = 15 # Alpha value of the dead channels. - dead_channel_alpha = .25 + dead_channel_alpha = 0.25 do_show_labels = False def __init__( - self, positions=None, best_channels=None, channel_labels=None, - dead_channels=None, **kwargs): - super(ProbeView, self).__init__(**kwargs) + self, positions=None, best_channels=None, channel_labels=None, dead_channels=None, **kwargs + ): + super().__init__(**kwargs) self.state_attrs += ('do_show_labels',) # Normalize positions. assert positions.ndim == 2 assert positions.shape[1] == 2 - positions = positions.astype(np.float32) + # `get_non_overlapping_boxes()` can fail to converge on float32 inputs for larger + # probes; keep float64 through the layout computation. + positions = positions.astype(np.float64) self.positions, self.data_bounds = _get_pos_data_bounds(positions) self.n_channels = positions.shape[0] @@ -91,13 +93,16 @@ def __init__( # Probe visual. color = np.ones((self.n_channels, 4)) - color[:, :3] = .5 + color[:, :3] = 0.5 # Change alpha value for dead channels. if len(self.dead_channels): color[self.dead_channels, 3] = self.dead_channel_alpha self.probe_visual.set_data( - pos=self.positions, data_bounds=self.data_bounds, - color=color, size=self.unselected_marker_size) + pos=self.positions, + data_bounds=self.data_bounds, + color=color, + size=self.unselected_marker_size, + ) # Cluster visual. self.cluster_visual = ScatterVisual() @@ -109,14 +114,19 @@ def __init__( self.text_visual = TextVisual() self.text_visual.inserter.insert_vert('uniform float n_channels;', 'header') self.text_visual.inserter.add_varying( - 'float', 'v_discard', + 'float', + 'v_discard', 'float((n_channels >= 200 * u_zoom.y) && ' - '(mod(int(a_string_index), int(n_channels / (200 * u_zoom.y))) >= 1))') + '(mod(int(a_string_index), int(n_channels / (200 * u_zoom.y))) >= 1))', + ) self.text_visual.inserter.insert_frag('if (v_discard > 0) discard;', 'end') self.canvas.add_visual(self.text_visual) self.text_visual.set_data( - pos=self.positions, text=self.channel_labels, anchor=[0, -1], - data_bounds=self.data_bounds, color=color + pos=self.positions, + text=self.channel_labels, + anchor=[0, -1], + data_bounds=self.data_bounds, + color=color, ) self.text_visual.program['n_channels'] = self.n_channels self.canvas.update() @@ -128,7 +138,7 @@ def _get_clu_positions(self, cluster_ids): cluster_channels = {i: self.best_channels(cl) for i, cl in enumerate(cluster_ids)} # List of clusters per channel. - clusters_per_channel = defaultdict(lambda: []) + clusters_per_channel = defaultdict(list) for clu_idx, channels in cluster_channels.items(): for channel in channels: clusters_per_channel[channel].append(clu_idx) @@ -141,7 +151,7 @@ def _get_clu_positions(self, cluster_ids): for i, clu_idx in enumerate(clusters_per_channel[channel_id]): n = len(clusters_per_channel[channel_id]) # Translation. - t = .025 * w * (i - .5 * (n - 1)) + t = 0.025 * w * (i - 0.5 * (n - 1)) x += t alpha = 1.0 if channel_id not in self.dead_channels else self.dead_channel_alpha clu_pos.append((x, y)) @@ -155,12 +165,13 @@ def on_select(self, cluster_ids=(), **kwargs): return pos, colors = self._get_clu_positions(cluster_ids) self.cluster_visual.set_data( - pos=pos, color=colors, size=self.selected_marker_size, data_bounds=self.data_bounds) + pos=pos, color=colors, size=self.selected_marker_size, data_bounds=self.data_bounds + ) self.canvas.update() def attach(self, gui): """Attach the view to the GUI.""" - super(ProbeView, self).attach(gui) + super().attach(gui) self.actions.add(self.toggle_show_labels, checkable=True, checked=self.do_show_labels) if not self.do_show_labels: @@ -168,7 +179,7 @@ def attach(self, gui): def toggle_show_labels(self, checked): """Toggle the display of the channel ids.""" - logger.debug("Set show labels to %s.", checked) + logger.debug('Set show labels to %s.', checked) self.do_show_labels = checked self.text_visual._hidden = not checked self.canvas.update() diff --git a/phy/cluster/views/raster.py b/phy/cluster/views/raster.py index 54f62fe1a..9a1b00aa0 100644 --- a/phy/cluster/views/raster.py +++ b/phy/cluster/views/raster.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - """Scatter view.""" @@ -10,13 +8,13 @@ import logging import numpy as np - from phylib.io.array import _index_of from phylib.utils import emit -from phy.utils.color import _add_selected_clusters_colors -from .base import ManualClusteringView, BaseGlobalView, MarkerSizeMixin, BaseColorView from phy.plot.visuals import ScatterVisual +from phy.utils.color import _add_selected_clusters_colors + +from .base import BaseColorView, BaseGlobalView, ManualClusteringView, MarkerSizeMixin logger = logging.getLogger(__name__) @@ -25,6 +23,7 @@ # Raster view # ----------------------------------------------------------------------------- + class RasterView(MarkerSizeMixin, BaseColorView, BaseGlobalView, ManualClusteringView): """This view shows a raster plot of all clusters. @@ -61,24 +60,27 @@ def __init__(self, spike_times, spike_clusters, cluster_ids=None, **kwargs): self.set_spike_clusters(spike_clusters) self.set_cluster_ids(cluster_ids) - super(RasterView, self).__init__(**kwargs) + super().__init__(**kwargs) self.canvas.set_layout('stacked', origin='top', n_plots=self.n_clusters, has_clip=False) self.canvas.enable_axes() self.visual = ScatterVisual( marker='vbar', - marker_scaling=''' + marker_scaling=""" point_size = v_size * u_zoom.y + 5.; float width = 0.2; float height = 0.5; vec2 marker_size = point_size * vec2(width, height); marker_size.x = clamp(marker_size.x, 1, 20); - ''', + """, ) - self.visual.inserter.insert_vert(''' + self.visual.inserter.insert_vert( + """ gl_PointSize = a_size * u_zoom.y + 5.0; - ''', 'end') + """, + 'end', + ) self.canvas.add_visual(self.visual) self.canvas.panzoom.set_constrain_bounds((-1, -2, +1, +2)) @@ -119,11 +121,12 @@ def _get_box_index(self): def _get_color(self, box_index, selected_clusters=None): """Return, for every spike, its color, based on its box index.""" - cluster_colors = self.get_cluster_colors(self.all_cluster_ids, alpha=.75) + cluster_colors = self.get_cluster_colors(self.all_cluster_ids, alpha=0.75) # Selected cluster colors. if selected_clusters is not None: cluster_colors = _add_selected_clusters_colors( - selected_clusters, self.all_cluster_ids, cluster_colors) + selected_clusters, self.all_cluster_ids, cluster_colors + ) return cluster_colors[box_index, :] # Main methods @@ -148,7 +151,7 @@ def update_color(self): @property def status(self): - return 'Color scheme: %s' % self.color_scheme + return f'Color scheme: {self.color_scheme}' def plot(self, **kwargs): """Make the raster plot.""" @@ -163,8 +166,8 @@ def plot(self, **kwargs): self.data_bounds = self._get_data_bounds() self.visual.set_data( - x=x, y=y, color=color, size=self.marker_size, - data_bounds=(0, -1, self.duration, 1)) + x=x, y=y, color=color, size=self.marker_size, data_bounds=(0, -1, self.duration, 1) + ) self.visual.set_box_index(box_index) self.canvas.stacked.n_boxes = self.n_clusters self._update_axes() @@ -173,14 +176,14 @@ def plot(self, **kwargs): def attach(self, gui): """Attach the view to the GUI.""" - super(RasterView, self).attach(gui) + super().attach(gui) self.actions.add(self.increase_marker_size) self.actions.add(self.decrease_marker_size) self.actions.separator() def on_select(self, *args, **kwargs): - super(RasterView, self).on_select(*args, **kwargs) + super().on_select(*args, **kwargs) self.update_color() def zoom_to_time_range(self, interval): @@ -188,8 +191,8 @@ def zoom_to_time_range(self, interval): if not interval: return t0, t1 = interval - w = .5 * (t1 - t0) # half width - tm = .5 * (t0 + t1) + w = 0.5 * (t1 - t0) # half width + tm = 0.5 * (t0 + t1) w = min(5, w) # minimum 5s time range t0, t1 = tm - w, tm + w x0 = -1 + 2 * t0 / self.duration @@ -208,7 +211,7 @@ def on_mouse_click(self, e): # Get mouse position in NDC. cluster_idx, _ = self.canvas.stacked.box_map(e.pos) cluster_id = self.all_cluster_ids[cluster_idx] - logger.debug("Click on cluster %d with button %s.", cluster_id, b) + logger.debug('Click on cluster %d with button %s.', cluster_id, b) if 'Shift' in e.modifiers: emit('select_more', self, [cluster_id]) else: diff --git a/phy/cluster/views/scatter.py b/phy/cluster/views/scatter.py index 948e0fd12..9d5924e37 100644 --- a/phy/cluster/views/scatter.py +++ b/phy/cluster/views/scatter.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - """Scatter view.""" @@ -12,9 +10,10 @@ import numpy as np -from phy.utils.color import selected_cluster_color, spike_colors -from .base import ManualClusteringView, MarkerSizeMixin, LassoMixin from phy.plot.visuals import ScatterVisual +from phy.utils.color import selected_cluster_color, spike_colors + +from .base import LassoMixin, ManualClusteringView, MarkerSizeMixin logger = logging.getLogger(__name__) @@ -23,6 +22,7 @@ # Scatter view # ----------------------------------------------------------------------------- + class ScatterView(MarkerSizeMixin, LassoMixin, ManualClusteringView): """This view displays a scatter plot for all selected clusters. @@ -44,7 +44,7 @@ class ScatterView(MarkerSizeMixin, LassoMixin, ManualClusteringView): } def __init__(self, coords=None, **kwargs): - super(ScatterView, self).__init__(**kwargs) + super().__init__(**kwargs) # Save the marker size in the global and local view's config. self.canvas.enable_axes() @@ -57,7 +57,8 @@ def __init__(self, coords=None, **kwargs): def _plot_cluster(self, bunch): ms = self._marker_size self.visual.add_batch_data( - pos=bunch.pos, color=bunch.color, size=ms, data_bounds=self.data_bounds) + pos=bunch.pos, color=bunch.color, size=ms, data_bounds=self.data_bounds + ) def _get_split_cluster_data(self, bunchs): """Get the data when there is one Bunch per cluster.""" @@ -70,7 +71,7 @@ def _get_split_cluster_data(self, bunchs): bunch.pos = np.c_[bunch.x, bunch.y] assert bunch.pos.ndim == 2 assert 'spike_ids' in bunch - bunch.color = selected_cluster_color(i, .75) + bunch.color = selected_cluster_color(i, 0.75) return bunchs def _get_collated_cluster_data(self, bunch): @@ -92,14 +93,15 @@ def get_clusters_data(self, load_all=None): bunchs = self.coords(self.cluster_ids, load_all=load_all) or () else: logger.warning( - "The view `%s` may not load all spikes when using the lasso for splitting.", - self.__class__.__name__) + 'The view `%s` may not load all spikes when using the lasso for splitting.', + self.__class__.__name__, + ) bunchs = self.coords(self.cluster_ids) if isinstance(bunchs, dict): return [self._get_collated_cluster_data(bunchs)] elif isinstance(bunchs, (list, tuple)): return self._get_split_cluster_data(bunchs) - raise ValueError("The output of `coords()` should be either a list of Bunch, or a Bunch.") + raise ValueError('The output of `coords()` should be either a list of Bunch, or a Bunch.') def plot(self, **kwargs): """Update the view with the current cluster selection.""" diff --git a/phy/cluster/views/template.py b/phy/cluster/views/template.py index b24441f1f..5ffb0a30c 100644 --- a/phy/cluster/views/template.py +++ b/phy/cluster/views/template.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - """Template view.""" @@ -10,14 +8,14 @@ import logging import numpy as np - -from phy.utils.color import _add_selected_clusters_colors from phylib.io.array import _index_of -from phylib.utils import emit, Bunch +from phylib.utils import Bunch, emit from phy.plot import get_linear_x from phy.plot.visuals import PlotVisual -from .base import ManualClusteringView, BaseGlobalView, ScalingMixin, BaseColorView +from phy.utils.color import _add_selected_clusters_colors + +from .base import BaseColorView, BaseGlobalView, ManualClusteringView, ScalingMixin logger = logging.getLogger(__name__) @@ -26,6 +24,7 @@ # Template view # ----------------------------------------------------------------------------- + class TemplateView(ScalingMixin, BaseColorView, BaseGlobalView, ManualClusteringView): """This view shows all template waveforms of all clusters in a large grid of shape `(n_channels, n_clusters)`. @@ -45,8 +44,9 @@ class TemplateView(ScalingMixin, BaseColorView, BaseGlobalView, ManualClustering The list of all clusters to show initially. """ + _default_position = 'right' - _scaling = 1. + _scaling = 1.0 default_shortcuts = { 'change_template_size': 'ctrl+wheel', @@ -58,9 +58,14 @@ class TemplateView(ScalingMixin, BaseColorView, BaseGlobalView, ManualClustering } def __init__( - self, templates=None, channel_ids=None, channel_labels=None, - cluster_ids=None, **kwargs): - super(TemplateView, self).__init__(**kwargs) + self, + templates=None, + channel_ids=None, + channel_labels=None, + cluster_ids=None, + **kwargs, + ): + super().__init__(**kwargs) self.state_attrs += () self.local_state_attrs += ('scaling',) @@ -70,8 +75,10 @@ def __init__( # Channel labels. self.channel_labels = ( - channel_labels if channel_labels is not None else - ['%d' % ch for ch in range(self.n_channels)]) + channel_labels + if channel_labels is not None + else [f'{ch}' for ch in range(self.n_channels)] + ) assert len(self.channel_labels) == self.n_channels # TODO: show channel and cluster labels @@ -108,7 +115,8 @@ def _get_box_index(self, bunch): box_index = np.repeat(box_index, n_samples) box_index = np.c_[ box_index.reshape((-1, 1)), - bunch.cluster_idx * np.ones((n_samples * len(bunch.channel_ids), 1))] + bunch.cluster_idx * np.ones((n_samples * len(bunch.channel_ids), 1)), + ] assert box_index.shape == (len(bunch.channel_ids) * n_samples, 2) assert box_index.size == bunch.template.size * 2 return box_index @@ -131,7 +139,12 @@ def _plot_cluster(self, bunch, color=None): box_index = self._get_box_index(bunch) return Bunch( - x=t, y=wave.T, color=color, box_index=box_index, data_bounds=self.data_bounds) + x=t, + y=wave.T, + color=color, + box_index=box_index, + data_bounds=self.data_bounds, + ) def set_cluster_ids(self, cluster_ids): """Update the cluster ids when their identity or order has changed.""" @@ -142,7 +155,9 @@ def set_cluster_ids(self, cluster_ids): self.cluster_idxs = np.argsort(self.all_cluster_ids) self.sorted_cluster_ids = self.all_cluster_ids[self.cluster_idxs] # Cluster colors, ordered by cluster id. - self.cluster_colors = self.get_cluster_colors(self.sorted_cluster_ids, alpha=.75) + self.cluster_colors = self.get_cluster_colors( + self.sorted_cluster_ids, alpha=0.75 + ) def get_clusters_data(self, load_all=None): """Return all templates data.""" @@ -193,17 +208,20 @@ def update_color(self): selected_clusters = self.cluster_ids if selected_clusters is not None: cluster_colors = _add_selected_clusters_colors( - selected_clusters, self.sorted_cluster_ids, cluster_colors) + selected_clusters, self.sorted_cluster_ids, cluster_colors + ) # Number of vertices per cluster = number of vertices per signal n_vertices_clu = [ - len(self._cluster_box_index[cluster_id]) for cluster_id in self.sorted_cluster_ids] + len(self._cluster_box_index[cluster_id]) + for cluster_id in self.sorted_cluster_ids + ] # The argument passed to set_color() must have 1 row per vertex. self.visual.set_color(np.repeat(cluster_colors, n_vertices_clu, axis=0)) self.canvas.update() @property def status(self): - return 'Color scheme: %s' % self.color_schemes.current + return f'Color scheme: {self.color_schemes.current}' def plot(self, **kwargs): """Make the template plot.""" @@ -228,7 +246,7 @@ def plot(self, **kwargs): self.canvas.update() def on_select(self, *args, **kwargs): - super(TemplateView, self).on_select(*args, **kwargs) + super().on_select(*args, **kwargs) self.update_color() # Scaling @@ -262,7 +280,7 @@ def on_mouse_click(self, e): # Get mouse position in NDC. (channel_idx, cluster_rel), _ = self.canvas.grid.box_map(e.pos) cluster_id = self.all_cluster_ids[cluster_rel] - logger.debug("Click on cluster %d with button %s.", cluster_id, b) + logger.debug('Click on cluster %d with button %s.', cluster_id, b) if 'Shift' in e.modifiers: emit('select_more', self, [cluster_id]) else: diff --git a/phy/cluster/views/tests/conftest.py b/phy/cluster/views/tests/conftest.py index 4df26295e..7931681a8 100644 --- a/phy/cluster/views/tests/conftest.py +++ b/phy/cluster/views/tests/conftest.py @@ -1,19 +1,17 @@ -# -*- coding: utf-8 -*- - """Test cluster views.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ from pytest import fixture from phy.gui import GUI - -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Utilities and fixtures -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + @fixture def gui(tempdir, qtbot): @@ -21,8 +19,8 @@ def gui(tempdir, qtbot): gui.set_default_actions() gui.show() qtbot.wait(1) - #qtbot.addWidget(gui) - #qtbot.waitForWindowShown(gui) + # qtbot.addWidget(gui) + # qtbot.waitForWindowShown(gui) yield gui qtbot.wait(1) gui.close() diff --git a/phy/cluster/views/tests/test_amplitude.py b/phy/cluster/views/tests/test_amplitude.py index f2943de77..aaf2ea6a0 100644 --- a/phy/cluster/views/tests/test_amplitude.py +++ b/phy/cluster/views/tests/test_amplitude.py @@ -1,31 +1,29 @@ -# -*- coding: utf-8 -*- - """Test amplitude view.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ import numpy as np - from phylib.io.mock import artificial_spike_samples from phylib.utils import Bunch, connect from phy.plot.tests import mouse_click + from ..amplitude import AmplitudeView from . import _stop_and_close - -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Test amplitude view -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def test_amplitude_view_0(qtbot, gui): v = AmplitudeView( amplitudes=lambda cluster_ids, load_all=False: None, ) - v.show() - qtbot.waitForWindowShown(v.canvas) + with qtbot.waitExposed(v.canvas): + v.show() v.attach(gui) v.on_select(cluster_ids=[0]) @@ -40,73 +38,90 @@ def test_amplitude_view_1(qtbot, gui): x = np.zeros(1) v = AmplitudeView( amplitudes=lambda cluster_ids, load_all=False: [ - Bunch(amplitudes=x, spike_ids=[0], spike_times=[0])], + Bunch(amplitudes=x, spike_ids=[0], spike_times=[0]) + ], ) - v.show() - qtbot.waitForWindowShown(v.canvas) + with qtbot.waitExposed(v.canvas): + v.show() v.attach(gui) v.on_select(cluster_ids=[0]) - v.show_time_range((.499, .501)) + v.show_time_range((0.499, 0.501)) _stop_and_close(qtbot, v) def test_amplitude_view_2(qtbot, gui): - n = 1000 - st1 = artificial_spike_samples(n) / 20000. - st2 = artificial_spike_samples(n) / 20000. - v = AmplitudeView( - amplitudes={ - 'amp1': lambda cluster_ids, load_all=False: [Bunch( - amplitudes=15 + np.random.randn(n), - spike_ids=np.arange(n), - spike_times=st1, - ) for c in cluster_ids], - 'amp2': lambda cluster_ids, load_all=False: [Bunch( - amplitudes=10 + np.random.randn(n), - spike_ids=np.arange(n), - spike_times=st2, - ) for c in cluster_ids], - }, duration=max(st1.max(), st2.max())) - v.show() - qtbot.waitForWindowShown(v.canvas) - v.attach(gui) - - v.on_select(cluster_ids=[]) - v.on_select(cluster_ids=[0]) - v.on_select(cluster_ids=[0, 2, 3]) - v.on_select(cluster_ids=[0, 2]) + random_state = np.random.get_state() + np.random.seed(0) + rng = np.random.RandomState(0) - v.next_amplitudes_type() - v.previous_amplitudes_type() - v.actions.change_amplitudes_type_to_amp2() - - v.set_state(v.state) - - w, h = v.canvas.get_size() - - _times = [] - - @connect(sender=v) - def on_select_time(sender, time): - _times.append(time) - mouse_click(qtbot, v.canvas, (w / 3, h / 2), modifiers=('Alt',)) - assert len(_times) == 1 - assert np.allclose(_times[0], .5, atol=.01) - - # Split without selection. - spike_ids = v.on_request_split() - assert len(spike_ids) == 0 - - a, b = 50, 1000 - mouse_click(qtbot, v.canvas, (a, a), modifiers=('Control',)) - mouse_click(qtbot, v.canvas, (a, b), modifiers=('Control',)) - mouse_click(qtbot, v.canvas, (b, b), modifiers=('Control',)) - mouse_click(qtbot, v.canvas, (b, a), modifiers=('Control',)) - - # Split lassoed points. - spike_ids = v.on_request_split() - assert len(spike_ids) > 0 - - _stop_and_close(qtbot, v) + n = 1000 + try: + st1 = artificial_spike_samples(n) / 20000.0 + st2 = artificial_spike_samples(n) / 20000.0 + v = AmplitudeView( + amplitudes={ + 'amp1': lambda cluster_ids, load_all=False: [ + Bunch( + amplitudes=15 + rng.randn(n), + spike_ids=np.arange(n), + spike_times=st1, + ) + for c in cluster_ids + ], + 'amp2': lambda cluster_ids, load_all=False: [ + Bunch( + amplitudes=10 + rng.randn(n), + spike_ids=np.arange(n), + spike_times=st2, + ) + for c in cluster_ids + ], + }, + duration=max(st1.max(), st2.max()), + ) + with qtbot.waitExposed(v.canvas): + v.show() + v.attach(gui) + + v.on_select(cluster_ids=[]) + v.on_select(cluster_ids=[0]) + v.on_select(cluster_ids=[0, 2, 3]) + v.on_select(cluster_ids=[0, 2]) + + v.next_amplitudes_type() + v.previous_amplitudes_type() + v.actions.change_amplitudes_type_to_amp2() + + v.set_state(v.state) + + w, h = v.canvas.get_size() + + _times = [] + + @connect(sender=v) + def on_select_time(sender, time): + _times.append(time) + + mouse_click(qtbot, v.canvas, (w / 3, h / 2), modifiers=('Alt',)) + assert len(_times) == 1 + assert np.allclose(_times[0], 0.5, atol=0.01) + + # Split without selection. + spike_ids = v.on_request_split() + assert len(spike_ids) == 0 + + a, b = 50, 1000 + mouse_click(qtbot, v.canvas, (a, a), modifiers=('Control',)) + mouse_click(qtbot, v.canvas, (a, b), modifiers=('Control',)) + mouse_click(qtbot, v.canvas, (b, b), modifiers=('Control',)) + mouse_click(qtbot, v.canvas, (b, a), modifiers=('Control',)) + + # Split lassoed points. + spike_ids = v.on_request_split() + assert len(spike_ids) > 0 + + _stop_and_close(qtbot, v) + finally: + np.random.set_state(random_state) diff --git a/phy/cluster/views/tests/test_base.py b/phy/cluster/views/tests/test_base.py index 76b078f9d..0e8617cf0 100644 --- a/phy/cluster/views/tests/test_base.py +++ b/phy/cluster/views/tests/test_base.py @@ -1,27 +1,28 @@ -# -*- coding: utf-8 -*- - """Test scatter view.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ import numpy as np - from phylib.utils import emit -from phy.utils.color import selected_cluster_color, colormaps + +from phy.utils.color import colormaps, selected_cluster_color + from ..base import BaseColorView, ManualClusteringView from . import _stop_and_close - -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Test manual clustering view -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + class MyView(BaseColorView, ManualClusteringView): def plot(self, **kwargs): for i in range(len(self.cluster_ids)): - self.canvas.scatter(pos=.25 * np.random.randn(100, 2), color=selected_cluster_color(i)) + self.canvas.scatter( + pos=0.25 * np.random.randn(100, 2), color=selected_cluster_color(i) + ) @property def status(self): @@ -52,10 +53,11 @@ def test_manual_clustering_view_2(qtbot, gui): v = MyView() v.canvas.show() v.add_color_scheme( - lambda cid: cid, name='myscheme', colormap=colormaps.rainbow, cluster_ids=[0, 1]) + lambda cid: cid, name='myscheme', colormap=colormaps.rainbow, cluster_ids=[0, 1] + ) v.attach(gui) - class Supervisor(object): + class Supervisor: pass emit('select', Supervisor(), cluster_ids=[0, 1]) diff --git a/phy/cluster/views/tests/test_cluscatter.py b/phy/cluster/views/tests/test_cluscatter.py index 39891ad4e..9ab4eeb21 100644 --- a/phy/cluster/views/tests/test_cluscatter.py +++ b/phy/cluster/views/tests/test_cluscatter.py @@ -1,51 +1,55 @@ -# -*- coding: utf-8 -*- - """Test views.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ import numpy as np from numpy.random import RandomState - from phylib.utils import Bunch, connect, emit from phy.plot.tests import mouse_click + from ..cluscatter import ClusterScatterView from . import _stop_and_close - -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Test cluster scatter view -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def test_cluster_scatter_view_1(qtbot, tempdir, gui): n_clusters = 1000 cluster_ids = np.arange(n_clusters)[2::3] - class Supervisor(object): + class Supervisor: pass + s = Supervisor() def cluster_info(cluster_id): np.random.seed(cluster_id) - return Bunch({ - 'fet1': np.random.randn(), - 'fet2': np.random.randn(), - 'fet3': np.random.uniform(low=5, high=20) - }) + return Bunch( + { + 'fet1': np.random.randn(), + 'fet2': np.random.randn(), + 'fet3': np.random.uniform(low=5, high=20), + } + ) bindings = Bunch({'x_axis': 'fet1', 'y_axis': 'fet2', 'size': 'fet3'}) v = ClusterScatterView(cluster_info=cluster_info, cluster_ids=cluster_ids, bindings=bindings) v.add_color_scheme( - lambda cluster_id: RandomState(cluster_id).rand(), name='depth', - colormap='linear', cluster_ids=cluster_ids) + lambda cluster_id: RandomState(cluster_id).rand(), + name='depth', + colormap='linear', + cluster_ids=cluster_ids, + ) v.show() - v.plot() + with qtbot.waitExposed(v.canvas): + v.plot() v.color_scheme = 'depth' - qtbot.waitForWindowShown(v.canvas) v.attach(gui) @connect(sender=v) @@ -91,14 +95,13 @@ def on_select_more(sender, cluster_ids): # noqa mouse_click(qtbot, v.canvas, pos=(w / 2, h / 2), button='Left', modifiers=()) assert len(_clicked) == 1 - mouse_click( - qtbot, v.canvas, pos=(w / 2, h / 2), button='Left', modifiers=('Shift',)) + mouse_click(qtbot, v.canvas, pos=(w / 2, h / 2), button='Left', modifiers=('Shift',)) assert len(_clicked) == 2 - mouse_click(qtbot, v.canvas, pos=(w * .3, h * .3), button='Left', modifiers=('Control',)) - mouse_click(qtbot, v.canvas, pos=(w * .7, h * .3), button='Left', modifiers=('Control',)) - mouse_click(qtbot, v.canvas, pos=(w * .7, h * .7), button='Left', modifiers=('Control',)) - mouse_click(qtbot, v.canvas, pos=(w * .3, h * .7), button='Left', modifiers=('Control',)) + mouse_click(qtbot, v.canvas, pos=(w * 0.3, h * 0.3), button='Left', modifiers=('Control',)) + mouse_click(qtbot, v.canvas, pos=(w * 0.7, h * 0.3), button='Left', modifiers=('Control',)) + mouse_click(qtbot, v.canvas, pos=(w * 0.7, h * 0.7), button='Left', modifiers=('Control',)) + mouse_click(qtbot, v.canvas, pos=(w * 0.3, h * 0.7), button='Left', modifiers=('Control',)) assert len(v.cluster_ids) >= 1 diff --git a/phy/cluster/views/tests/test_correlogram.py b/phy/cluster/views/tests/test_correlogram.py index c664c0294..3d748e5bd 100644 --- a/phy/cluster/views/tests/test_correlogram.py +++ b/phy/cluster/views/tests/test_correlogram.py @@ -1,37 +1,34 @@ -# -*- coding: utf-8 -*- - """Test correlogram view.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ import numpy as np - from phylib.io.mock import artificial_correlograms from ..correlogram import CorrelogramView from . import _stop_and_close - -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Test correlogram view -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ -def test_correlogram_view(qtbot, gui): +def test_correlogram_view(qtbot, gui): def get_correlograms(cluster_ids, bin_size, window_size): return artificial_correlograms(len(cluster_ids), int(window_size / bin_size)) def get_firing_rate(cluster_ids, bin_size): - return .5 * np.ones((len(cluster_ids), len(cluster_ids))) - - v = CorrelogramView(correlograms=get_correlograms, - firing_rate=get_firing_rate, - sample_rate=100., - ) - v.show() - qtbot.waitForWindowShown(v.canvas) + return 0.5 * np.ones((len(cluster_ids), len(cluster_ids))) + + v = CorrelogramView( + correlograms=get_correlograms, + firing_rate=get_firing_rate, + sample_rate=100.0, + ) + with qtbot.waitExposed(v.canvas): + v.show() v.attach(gui) v.on_select(cluster_ids=[]) @@ -47,8 +44,8 @@ def get_firing_rate(cluster_ids, bin_size): v.set_window(100) v.set_refractory_period(3) - assert v.bin_size == .001 - assert v.window_size == .1 + assert v.bin_size == 0.001 + assert v.window_size == 0.1 assert v.refractory_period == 3e-3 v.increase() diff --git a/phy/cluster/views/tests/test_feature.py b/phy/cluster/views/tests/test_feature.py index 13dd4f87d..ce4ada2c0 100644 --- a/phy/cluster/views/tests/test_feature.py +++ b/phy/cluster/views/tests/test_feature.py @@ -1,26 +1,24 @@ -# -*- coding: utf-8 -*- - """Test views.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ import numpy as np import pytest - from phylib.io.array import _spikes_per_cluster from phylib.io.mock import artificial_features, artificial_spike_clusters from phylib.utils import Bunch, connect + from phy.plot.tests import mouse_click from ..feature import FeatureView, _get_default_grid from . import _stop_and_close - -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Test feature view -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + @pytest.mark.parametrize('n_channels', [5, 1]) def test_feature_view(qtbot, gui, n_channels): @@ -28,17 +26,14 @@ def test_feature_view(qtbot, gui, n_channels): ns = 10000 features = artificial_features(ns, nc, 4) spike_clusters = artificial_spike_clusters(ns, 4) - spike_times = np.linspace(0., 1., ns) + spike_times = np.linspace(0.0, 1.0, ns) spc = _spikes_per_cluster(spike_clusters) def get_spike_ids(cluster_id): - return (spc[cluster_id] if cluster_id is not None else np.arange(ns)) + return spc[cluster_id] if cluster_id is not None else np.arange(ns) def get_features(cluster_id=None, channel_ids=None, spike_ids=None, load_all=None): - if load_all: - spike_ids = spc[cluster_id] - else: - spike_ids = get_spike_ids(cluster_id) + spike_ids = spc[cluster_id] if load_all else get_spike_ids(cluster_id) return Bunch( data=features[spike_ids], spike_ids=spike_ids, @@ -47,11 +42,11 @@ def get_features(cluster_id=None, channel_ids=None, spike_ids=None, load_all=Non ) def get_time(cluster_id=None, load_all=None): - return Bunch(data=spike_times[get_spike_ids(cluster_id)], lim=(0., 1.)) + return Bunch(data=spike_times[get_spike_ids(cluster_id)], lim=(0.0, 1.0)) v = FeatureView(features=get_features, attributes={'time': get_time}) - v.show() - qtbot.waitForWindowShown(v.canvas) + with qtbot.waitExposed(v.canvas): + v.show() v.attach(gui) v.set_grid_dim(_get_default_grid()) diff --git a/phy/cluster/views/tests/test_histogram.py b/phy/cluster/views/tests/test_histogram.py index bd8da6e12..f43b7d277 100644 --- a/phy/cluster/views/tests/test_histogram.py +++ b/phy/cluster/views/tests/test_histogram.py @@ -1,21 +1,19 @@ -# -*- coding: utf-8 -*- - """Test Histogram view.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ import numpy as np - from phylib.utils import Bunch + from ..histogram import HistogramView from . import _stop_and_close - -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Test Histogram view -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def test_histogram_view_0(qtbot, gui): data = np.random.uniform(low=0, high=10, size=5000) @@ -24,11 +22,11 @@ def test_histogram_view_0(qtbot, gui): cluster_stat=lambda cluster_id: Bunch( data=data, # plot=plot, - text='this is:\ncluster %d' % cluster_id, + text=f'this is:\ncluster {cluster_id}', ) ) - v.show() - qtbot.waitForWindowShown(v.canvas) + with qtbot.waitExposed(v.canvas): + v.show() v.attach(gui) v.on_select(cluster_ids=[]) v.on_select(cluster_ids=[0]) @@ -58,9 +56,9 @@ def test_histogram_view_0(qtbot, gui): # Use ms unit. v.bin_unit = 'ms' v.set_x_min(100) - assert v.x_min == .1 + assert v.x_min == 0.1 v.set_x_max(500) - assert v.x_max == .5 + assert v.x_max == 0.5 v.set_n_bins(400) assert v.bin_size == 1 # 1 ms v.set_bin_size(2) diff --git a/phy/cluster/views/tests/test_probe.py b/phy/cluster/views/tests/test_probe.py index 3a63c5cee..e4cd303f7 100644 --- a/phy/cluster/views/tests/test_probe.py +++ b/phy/cluster/views/tests/test_probe.py @@ -1,38 +1,42 @@ -# -*- coding: utf-8 -*- - """Test probe view.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ -import numpy as np +import os -from phylib.utils.geometry import staggered_positions +import numpy as np +import pytest from phylib.utils import emit +from phylib.utils.geometry import staggered_positions from ..probe import ProbeView from . import _stop_and_close - -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Test correlogram view -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ -def test_probe_view(qtbot, gui): +def test_probe_view(qtbot, gui): n = 50 positions = staggered_positions(n) positions = positions.astype(np.int32) - best_channels = lambda cluster_id: range(1, 9, 2) + def best_channels(cluster_id): + return range(1, 9, 2) v = ProbeView(positions=positions, best_channels=best_channels, dead_channels=(3, 7, 12)) v.do_show_labels = False - v.show() - qtbot.waitForWindowShown(v.canvas) + if os.environ.get('QT_QPA_PLATFORM') == 'offscreen': + v.show() + qtbot.wait(50) + else: + with qtbot.waitExposed(v.canvas): + v.show() v.attach(gui) - class Supervisor(object): + class Supervisor: pass v.toggle_show_labels(True) diff --git a/phy/cluster/views/tests/test_raster.py b/phy/cluster/views/tests/test_raster.py index 9b03f4ae1..95774ab87 100644 --- a/phy/cluster/views/tests/test_raster.py +++ b/phy/cluster/views/tests/test_raster.py @@ -1,24 +1,22 @@ -# -*- coding: utf-8 -*- - """Test scatter view.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ import numpy as np - -from phylib.utils import connect from phylib.io.mock import artificial_spike_clusters, artificial_spike_samples +from phylib.utils import connect from phy.plot.tests import mouse_click + from ..raster import RasterView from . import _stop_and_close - -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Test scatter view -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def test_raster_0(qtbot, gui): n = 5 @@ -26,13 +24,14 @@ def test_raster_0(qtbot, gui): spike_clusters = np.arange(n) cluster_ids = np.arange(n) - class Supervisor(object): + class Supervisor: pass + s = Supervisor() v = RasterView(spike_times, spike_clusters) - v.show() - qtbot.waitForWindowShown(v.canvas) + with qtbot.waitExposed(v.canvas): + v.show() v.attach(gui) v.set_cluster_ids(cluster_ids) @@ -58,16 +57,24 @@ def on_request_select(sender, cluster_ids): def on_select_more(sender, cluster_ids): _clicked.append(cluster_ids) - mouse_click(qtbot, v.canvas, pos=(w / 2, 0.), button='Left', modifiers=('Control',)) + mouse_click(qtbot, v.canvas, pos=(w / 2, 0.0), button='Left', modifiers=('Control',)) assert len(_clicked) == 1 assert _clicked == [[0]] mouse_click( - qtbot, v.canvas, pos=(w / 2, h / 2), button='Left', modifiers=('Control', 'Shift',)) + qtbot, + v.canvas, + pos=(w / 2, h / 2), + button='Left', + modifiers=( + 'Control', + 'Shift', + ), + ) assert len(_clicked) == 2 assert _clicked[1][0] in (1, 2) - v.zoom_to_time_range((1., 3.)) + v.zoom_to_time_range((1.0, 3.0)) _stop_and_close(qtbot, v) @@ -75,24 +82,28 @@ def on_select_more(sender, cluster_ids): def test_raster_1(qtbot, gui): ns = 10000 nc = 100 - spike_times = artificial_spike_samples(ns) / 20000. + spike_times = artificial_spike_samples(ns) / 20000.0 spike_clusters = artificial_spike_clusters(ns, nc) cluster_ids = np.arange(4) v = RasterView(spike_times, spike_clusters) @v.add_color_scheme( - name='group', cluster_ids=cluster_ids, - colormap='cluster_group', categorical=True) + name='group', cluster_ids=cluster_ids, colormap='cluster_group', categorical=True + ) def cg(cluster_id): return cluster_id % 4 v.add_color_scheme( - lambda cid: cid, name='random', cluster_ids=cluster_ids, - colormap='categorical', categorical=True) - - v.show() - qtbot.waitForWindowShown(v.canvas) + lambda cid: cid, + name='random', + cluster_ids=cluster_ids, + colormap='categorical', + categorical=True, + ) + + with qtbot.waitExposed(v.canvas): + v.show() v.attach(gui) v.set_cluster_ids(cluster_ids) diff --git a/phy/cluster/views/tests/test_scatter.py b/phy/cluster/views/tests/test_scatter.py index 139476060..594e2d1e3 100644 --- a/phy/cluster/views/tests/test_scatter.py +++ b/phy/cluster/views/tests/test_scatter.py @@ -1,30 +1,27 @@ -# -*- coding: utf-8 -*- - """Test scatter view.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ import numpy as np +from phylib.utils import Bunch from pytest import raises -from phylib.utils import Bunch from phy.plot.tests import mouse_click + from ..scatter import ScatterView from . import _stop_and_close - -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Test scatter view -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def test_scatter_view_0(qtbot, gui): - v = ScatterView( - coords=lambda cluster_ids, load_all=False: None - ) - v.show() - qtbot.waitForWindowShown(v.canvas) + v = ScatterView(coords=lambda cluster_ids, load_all=False: None) + with qtbot.waitExposed(v.canvas): + v.show() v.attach(gui) v.on_select(cluster_ids=[0]) @@ -42,10 +39,11 @@ def test_scatter_view_1(qtbot, gui): x = np.zeros(1) v = ScatterView( coords=lambda cluster_ids: Bunch( - x=x, y=x, spike_ids=[0], spike_clusters=[0], data_bounds=(0, 0, 0, 0)) + x=x, y=x, spike_ids=[0], spike_clusters=[0], data_bounds=(0, 0, 0, 0) + ) ) - v.show() - qtbot.waitForWindowShown(v.canvas) + with qtbot.waitExposed(v.canvas): + v.show() v.attach(gui) v.on_select(cluster_ids=[0]) _stop_and_close(qtbot, v) @@ -54,15 +52,18 @@ def test_scatter_view_1(qtbot, gui): def test_scatter_view_2(qtbot, gui): n = 1000 v = ScatterView( - coords=lambda cluster_ids, load_all=False: [Bunch( - x=np.random.randn(n), - y=np.random.randn(n), - spike_ids=np.arange(n), - data_bounds=None, - ) for c in cluster_ids] + coords=lambda cluster_ids, load_all=False: [ + Bunch( + x=np.random.randn(n), + y=np.random.randn(n), + spike_ids=np.arange(n), + data_bounds=None, + ) + for c in cluster_ids + ] ) - v.show() - qtbot.waitForWindowShown(v.canvas) + with qtbot.waitExposed(v.canvas): + v.show() v.attach(gui) v.on_select(cluster_ids=[]) @@ -99,8 +100,8 @@ def test_scatter_view_3(qtbot, gui): data_bounds=None, ) ) - v.show() - qtbot.waitForWindowShown(v.canvas) + with qtbot.waitExposed(v.canvas): + v.show() v.attach(gui) v.on_select(cluster_ids=[0, 2]) diff --git a/phy/cluster/views/tests/test_template.py b/phy/cluster/views/tests/test_template.py index a5c906f80..2f8176ca8 100644 --- a/phy/cluster/views/tests/test_template.py +++ b/phy/cluster/views/tests/test_template.py @@ -1,24 +1,22 @@ -# -*- coding: utf-8 -*- - """Test views.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ import numpy as np - from phylib.io.mock import artificial_waveforms from phylib.utils import Bunch, connect from phy.plot.tests import mouse_click + from ..template import TemplateView from . import _stop_and_close - -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Test template view -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def test_template_view_0(qtbot, tempdir, gui): n_samples = 50 @@ -26,14 +24,17 @@ def test_template_view_0(qtbot, tempdir, gui): channel_ids = np.arange(n_clusters + 2) def get_templates(cluster_ids): - return {i: Bunch( - template=artificial_waveforms(1, n_samples, 2)[0, ...], - channel_ids=np.arange(i, i + 2), - ) for i in cluster_ids} + return { + i: Bunch( + template=artificial_waveforms(1, n_samples, 2)[0, ...], + channel_ids=np.arange(i, i + 2), + ) + for i in cluster_ids + } v = TemplateView(templates=get_templates, channel_ids=channel_ids) - v.show() - qtbot.waitForWindowShown(v.canvas) + with qtbot.waitExposed(v.canvas): + v.show() v.attach(gui) v.plot() @@ -48,18 +49,22 @@ def test_template_view_1(qtbot, tempdir, gui): cluster_ids = np.arange(n_clusters) def get_templates(cluster_ids): - return {i: Bunch( - template=artificial_waveforms(1, n_samples, 2)[0, ...], - channel_ids=np.arange(i, i + 2), - ) for i in cluster_ids} - - class Supervisor(object): + return { + i: Bunch( + template=artificial_waveforms(1, n_samples, 2)[0, ...], + channel_ids=np.arange(i, i + 2), + ) + for i in cluster_ids + } + + class Supervisor: pass + s = Supervisor() v = TemplateView(templates=get_templates, channel_ids=channel_ids, cluster_ids=cluster_ids) - v.show() - qtbot.waitForWindowShown(v.canvas) + with qtbot.waitExposed(v.canvas): + v.show() v.attach(gui) v.update_color() # should call .plot() instead as update_color() is for subsequent updates @@ -83,11 +88,20 @@ def on_request_select(sender, cluster_ids): def on_select_more(sender, cluster_ids): _clicked.append(cluster_ids) - mouse_click(qtbot, v.canvas, pos=(0, 0.), button='Left', modifiers=('Control',)) + mouse_click(qtbot, v.canvas, pos=(0, 0.0), button='Left', modifiers=('Control',)) assert len(_clicked) == 1 assert _clicked[0] in ([4], [5]) - mouse_click(qtbot, v.canvas, pos=(0, h / 2), button='Left', modifiers=('Control', 'Shift',)) + mouse_click( + qtbot, + v.canvas, + pos=(0, h / 2), + button='Left', + modifiers=( + 'Control', + 'Shift', + ), + ) assert len(_clicked) == 2 assert _clicked[1] == [9] diff --git a/phy/cluster/views/tests/test_trace.py b/phy/cluster/views/tests/test_trace.py index 85ed49b96..a935e0fe7 100644 --- a/phy/cluster/views/tests/test_trace.py +++ b/phy/cluster/views/tests/test_trace.py @@ -1,34 +1,32 @@ -# -*- coding: utf-8 -*- - """Test views.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ import numpy as np from numpy.testing import assert_allclose as ac - -from phylib.io.mock import artificial_traces, artificial_spike_clusters +from phylib.io.mock import artificial_spike_clusters, artificial_traces from phylib.utils import Bunch, connect from phylib.utils.geometry import linear_positions + from phy.plot.tests import mouse_click -from ..trace import TraceView, TraceImageView, select_traces, _iter_spike_waveforms +from ..trace import TraceImageView, TraceView, _iter_spike_waveforms, select_traces from . import _stop_and_close - -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Test trace view -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def test_iter_spike_waveforms(): nc = 5 ns = 20 - sr = 2000. + sr = 2000.0 ch = list(range(nc)) - duration = 1. - st = np.linspace(0.1, .9, ns) + duration = 1.0 + st = np.linspace(0.1, 0.9, ns) sc = artificial_spike_clusters(ns, nc) traces = 10 * artificial_traces(int(round(duration * sr)), nc) @@ -36,13 +34,13 @@ def test_iter_spike_waveforms(): s = Bunch(cluster_meta={}, selected=[0]) for w in _iter_spike_waveforms( - interval=[0., 1.], - traces_interval=traces, - model=m, - supervisor=s, - n_samples_waveforms=ns, - show_all_spikes=True, - get_best_channels=lambda cluster_id: (ch, np.ones(nc)), + interval=[0.0, 1.0], + traces_interval=traces, + model=m, + supervisor=s, + n_samples_waveforms=ns, + show_all_spikes=True, + get_best_channels=lambda cluster_id: (ch, np.ones(nc)), ): assert w @@ -50,16 +48,16 @@ def test_iter_spike_waveforms(): def test_trace_view_1(qtbot, tempdir, gui): nc = 5 ns = 20 - sr = 2000. - duration = 1. - st = np.linspace(0.1, .9, ns) + sr = 2000.0 + duration = 1.0 + st = np.linspace(0.1, 0.9, ns) sc = artificial_spike_clusters(ns, nc) traces = 10 * artificial_traces(int(round(duration * sr)), nc) def get_traces(interval): out = Bunch( data=select_traces(traces, interval, sample_rate=sr), - color=(.75, .75, .75, 1), + color=(0.75, 0.75, 0.75, 1), ) a, b = st.searchsorted(interval) out.waveforms = [] @@ -69,7 +67,7 @@ def get_traces(interval): c = sc[i] s = int(round(t * sr)) d = Bunch( - data=traces[s - k:s + k, :], + data=traces[s - k : s + k, :], start_time=(s - k) / sr, channel_ids=np.arange(5), spike_id=i, @@ -90,8 +88,8 @@ def get_spike_times(): duration=duration, channel_positions=linear_positions(nc), ) - v.show() - qtbot.waitForWindowShown(v.canvas) + with qtbot.waitExposed(v.canvas): + v.show() v.attach(gui) v.on_select(cluster_ids=[]) @@ -101,25 +99,25 @@ def get_spike_times(): v.stacked.add_boxes(v.canvas) - ac(v.stacked.box_size, (.950, .165), atol=1e-3) - v.set_interval((.375, .625)) - assert v.time == .5 + ac(v.stacked.box_size, (0.950, 0.165), atol=1e-3) + v.set_interval((0.375, 0.625)) + assert v.time == 0.5 qtbot.wait(1) - v.go_to(.25) - assert v.time == .25 + v.go_to(0.25) + assert v.time == 0.25 qtbot.wait(1) - v.go_to(-.5) - assert v.time == .125 + v.go_to(-0.5) + assert v.time == 0.125 qtbot.wait(1) v.go_left() - assert v.time == .125 + assert v.time == 0.125 qtbot.wait(1) v.go_right() - ac(v.time, .150) + ac(v.time, 0.150) qtbot.wait(1) v.jump_left() @@ -135,16 +133,16 @@ def get_spike_times(): qtbot.wait(1) # Change interval size. - v.interval = (.25, .75) - ac(v.interval, (.25, .75)) + v.interval = (0.25, 0.75) + ac(v.interval, (0.25, 0.75)) qtbot.wait(1) v.widen() - ac(v.interval, (.1875, .8125)) + ac(v.interval, (0.1875, 0.8125)) qtbot.wait(1) v.narrow() - ac(v.interval, (.25, .75)) + ac(v.interval, (0.25, 0.75)) qtbot.wait(1) v.go_to_start() @@ -185,7 +183,7 @@ def get_spike_times(): qtbot.wait(1) v.increase() - ac(v.stacked.box_size, bs, atol=.05) + ac(v.stacked.box_size, bs, atol=0.05) qtbot.wait(1) v.origin = 'bottom' @@ -200,7 +198,7 @@ def get_spike_times(): def on_select_spike(sender, channel_id=None, spike_id=None, cluster_id=None, key=None): _clicked.append((channel_id, spike_id, cluster_id)) - mouse_click(qtbot, v.canvas, pos=(0., 0.), button='Left', modifiers=('Control',)) + mouse_click(qtbot, v.canvas, pos=(0.0, 0.0), button='Left', modifiers=('Control',)) v.set_state(v.state) @@ -213,28 +211,30 @@ def on_select_spike(sender, channel_id=None, spike_id=None, cluster_id=None, key def on_select_channel(sender, channel_id=None, button=None): _clicked.append((channel_id, button)) - mouse_click(qtbot, v.canvas, pos=(0., 0.), button='Left', modifiers=('Shift',)) - mouse_click(qtbot, v.canvas, pos=(0., 0.), button='Right', modifiers=('Shift',)) + mouse_click(qtbot, v.canvas, pos=(0.0, 0.0), button='Left', modifiers=('Shift',)) + mouse_click(qtbot, v.canvas, pos=(0.0, 0.0), button='Right', modifiers=('Shift',)) assert _clicked == [(2, 'Left'), (2, 'Right')] _stop_and_close(qtbot, v) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Test trace imageview -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def test_trace_image_view_1(qtbot, tempdir, gui): nc = 350 - sr = 2000. - duration = 1. + sr = 2000.0 + duration = 1.0 traces = 10 * artificial_traces(int(round(duration * sr)), nc) def get_traces(interval): - return Bunch(data=select_traces(traces, interval, sample_rate=sr), - color=(.75, .75, .75, 1), - ) + return Bunch( + data=select_traces(traces, interval, sample_rate=sr), + color=(0.75, 0.75, 0.75, 1), + ) v = TraceImageView( traces=get_traces, @@ -243,30 +243,30 @@ def get_traces(interval): duration=duration, channel_positions=linear_positions(nc), ) - v.show() - qtbot.waitForWindowShown(v.canvas) + with qtbot.waitExposed(v.canvas): + v.show() v.attach(gui) v.update_color() - v.set_interval((.375, .625)) - assert v.time == .5 + v.set_interval((0.375, 0.625)) + assert v.time == 0.5 qtbot.wait(1) - v.go_to(.25) - assert v.time == .25 + v.go_to(0.25) + assert v.time == 0.25 qtbot.wait(1) - v.go_to(-.5) - assert v.time == .125 + v.go_to(-0.5) + assert v.time == 0.125 qtbot.wait(1) v.go_left() - assert v.time == .125 + assert v.time == 0.125 qtbot.wait(1) v.go_right() - ac(v.time, .150) + ac(v.time, 0.150) qtbot.wait(1) v.jump_left() @@ -276,16 +276,16 @@ def get_traces(interval): qtbot.wait(1) # Change interval size. - v.interval = (.25, .75) - ac(v.interval, (.25, .75)) + v.interval = (0.25, 0.75) + ac(v.interval, (0.25, 0.75)) qtbot.wait(1) v.widen() - ac(v.interval, (.1875, .8125)) + ac(v.interval, (0.1875, 0.8125)) qtbot.wait(1) v.narrow() - ac(v.interval, (.25, .75)) + ac(v.interval, (0.25, 0.75)) qtbot.wait(1) v.go_to_start() diff --git a/phy/cluster/views/tests/test_waveform.py b/phy/cluster/views/tests/test_waveform.py index 3a50a9f88..1eebfda81 100644 --- a/phy/cluster/views/tests/test_waveform.py +++ b/phy/cluster/views/tests/test_waveform.py @@ -1,26 +1,24 @@ -# -*- coding: utf-8 -*- - """Test views.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ import numpy as np from numpy.testing import assert_allclose as ac - from phylib.io.mock import artificial_waveforms from phylib.utils import Bunch, connect from phylib.utils.geometry import staggered_positions -from phy.plot.tests import mouse_click, key_press, key_release + +from phy.plot.tests import key_press, key_release, mouse_click from ..waveform import WaveformView from . import _stop_and_close - -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Test waveform view -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def test_waveform_view(qtbot, tempdir, gui): nc = 5 @@ -31,17 +29,18 @@ def test_waveform_view(qtbot, tempdir, gui): def get_waveforms(cluster_id): return Bunch( data=w, - masks=np.random.uniform(low=0., high=1., size=(ns, nc)), + masks=np.random.uniform(low=0.0, high=1.0, size=(ns, nc)), channel_ids=np.arange(nc), - channel_labels=['%d' % (ch * 10) for ch in range(nc)], - channel_positions=staggered_positions(nc)) + channel_labels=[f'{ch * 10}' for ch in range(nc)], + channel_positions=staggered_positions(nc), + ) v = WaveformView( waveforms={'waveforms': get_waveforms, 'mean_waveforms': get_waveforms}, - sample_rate=10000., + sample_rate=10000.0, ) - v.show() - qtbot.waitForWindowShown(v.canvas) + with qtbot.waitExposed(v.canvas): + v.show() v.attach(gui) v.on_select(cluster_ids=[]) @@ -99,7 +98,7 @@ def on_select_channel(sender, channel_id=None, button=None, key=None): _clicked.append((channel_id, button, key)) key_press(qtbot, v.canvas, '2') - mouse_click(qtbot, v.canvas, pos=(0., 0.), button='Left') + mouse_click(qtbot, v.canvas, pos=(0.0, 0.0), button='Left') key_release(qtbot, v.canvas, '2') assert _clicked == [(2, 'Left', 2)] diff --git a/phy/cluster/views/trace.py b/phy/cluster/views/trace.py index 07f4e6cc8..e204262e9 100644 --- a/phy/cluster/views/trace.py +++ b/phy/cluster/views/trace.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - """Trace view.""" @@ -10,13 +8,19 @@ import logging import numpy as np - from phylib.utils import Bunch, emit -from phy.utils.color import selected_cluster_color, colormaps, _continuous_colormap, add_alpha + from phy.plot.interact import Stacked from phy.plot.transform import NDC, Range, _fix_coordinate_in_visual -from phy.plot.visuals import PlotVisual, UniformPlotVisual, TextVisual, ImageVisual -from .base import ManualClusteringView, ScalingMixin, BaseColorView +from phy.plot.visuals import ImageVisual, PlotVisual, TextVisual, UniformPlotVisual +from phy.utils.color import ( + _continuous_colormap, + add_alpha, + colormaps, + selected_cluster_color, +) + +from .base import BaseColorView, ManualClusteringView, ScalingMixin logger = logging.getLogger(__name__) @@ -25,6 +29,7 @@ # Trace view # ----------------------------------------------------------------------------- + def select_traces(traces, interval, sample_rate=None): """Load traces in an interval (in seconds).""" start, end = interval @@ -36,8 +41,14 @@ def select_traces(traces, interval, sample_rate=None): def _iter_spike_waveforms( - interval=None, traces_interval=None, model=None, supervisor=None, - n_samples_waveforms=None, get_best_channels=None, show_all_spikes=False): + interval=None, + traces_interval=None, + model=None, + supervisor=None, + n_samples_waveforms=None, + get_best_channels=None, + show_all_spikes=False, +): """Iterate through the spike waveforms belonging in the current trace view.""" m = model p = supervisor @@ -55,7 +66,7 @@ def _iter_spike_waveforms( if is_selected is not show_selected: continue # Skip non-selected spikes if requested. - if (not show_all_spikes and c not in supervisor.selected): + if not show_all_spikes and c not in supervisor.selected: continue # cg = p.cluster_meta.get('group', c) channel_ids, channel_amps = get_best_channels(c) @@ -65,7 +76,7 @@ def _iter_spike_waveforms( continue # Extract the waveform. wave = Bunch( - data=traces_interval[s - k:s + ns - k, channel_ids], + data=traces_interval[s - k : s + ns - k, channel_ids], channel_ids=channel_ids, start_time=(s + s0 - k) / sr, spike_id=i, @@ -106,16 +117,17 @@ class TraceView(ScalingMixin, BaseColorView, ManualClusteringView): Labels of all shown channels. By default, this is just the channel ids. """ + _default_position = 'left' auto_update = True auto_scale = True - interval_duration = .25 # default duration of the interval - shift_amount = .1 + interval_duration = 0.25 # default duration of the interval + shift_amount = 0.1 scaling_coeff_x = 1.25 - trace_quantile = .01 # quantile for auto-scaling - default_trace_color = (.5, .5, .5, 1) - trace_color_0 = (.353, .161, .443) - trace_color_1 = (.133, .404, .396) + trace_quantile = 0.01 # quantile for auto-scaling + default_trace_color = (0.5, 0.5, 0.5, 1) + trace_color_0 = (0.353, 0.161, 0.443) + trace_color_1 = (0.133, 0.404, 0.396) default_shortcuts = { 'change_trace_size': 'ctrl+wheel', 'switch_color_scheme': 'shift+wheel', @@ -146,9 +158,16 @@ class TraceView(ScalingMixin, BaseColorView, ManualClusteringView): } def __init__( - self, traces=None, sample_rate=None, spike_times=None, duration=None, - n_channels=None, channel_positions=None, channel_labels=None, **kwargs): - + self, + traces=None, + sample_rate=None, + spike_times=None, + duration=None, + n_channels=None, + channel_positions=None, + channel_labels=None, + **kwargs, + ): self.do_show_labels = True self.show_all_spikes = False @@ -157,10 +176,10 @@ def __init__( # Sample rate. assert sample_rate > 0 self.sample_rate = float(sample_rate) - self.dt = 1. / self.sample_rate + self.dt = 1.0 / self.sample_rate # Traces and spikes. - assert hasattr(traces, '__call__') + assert callable(traces) self.traces = traces # self.waveforms = None @@ -172,29 +191,41 @@ def __init__( # Channel y ranking. self.channel_positions = ( - channel_positions if channel_positions is not None else - np.c_[np.zeros(n_channels), np.arange(n_channels)]) + channel_positions + if channel_positions is not None + else np.c_[np.zeros(n_channels), np.arange(n_channels)] + ) # channel_y_ranks[i] is the position of channel #i in the trace view. self.channel_y_ranks = np.argsort(np.argsort(self.channel_positions[:, 1])) assert self.channel_y_ranks.shape == (n_channels,) # Channel labels. self.channel_labels = ( - channel_labels if channel_labels is not None else - ['%d' % ch for ch in range(n_channels)]) - assert len(self.channel_labels) == n_channels + channel_labels + if channel_labels is not None + else [f'{ch}' for ch in range(n_channels)] + ) + assert len(self.channel_labels) == self.n_channels # Initialize the view. - super(TraceView, self).__init__(**kwargs) - self.state_attrs += ('origin', 'do_show_labels', 'show_all_spikes', 'auto_scale') - self.local_state_attrs += ('interval', 'scaling',) + super().__init__(**kwargs) + self.state_attrs += ( + 'origin', + 'do_show_labels', + 'show_all_spikes', + 'auto_scale', + ) + self.local_state_attrs += ( + 'interval', + 'scaling', + ) # Visuals. self._create_visuals() # Initial interval. self._interval = None - self.go_to(duration / 2.) + self.go_to(duration / 2.0) self._waveform_times = [] self.canvas.panzoom.set_constrain_bounds((-1, -2, +1, +2)) @@ -207,8 +238,10 @@ def _create_visuals(self): # Gradient of color for the traces. if self.trace_color_0 and self.trace_color_1: self.trace_visual.inserter.insert_frag( - 'gl_FragColor.rgb = mix(vec3%s, vec3%s, (v_signal_index / %d));' % ( - self.trace_color_0, self.trace_color_1, self.n_channels), 'end') + f'gl_FragColor.rgb = mix(vec3{self.trace_color_0}, ' + f'vec3{self.trace_color_1}, (v_signal_index / {self.n_channels}));', + 'end', + ) self.canvas.add_visual(self.trace_visual) self.waveform_visual = PlotVisual() @@ -217,9 +250,11 @@ def _create_visuals(self): self.text_visual = TextVisual() _fix_coordinate_in_visual(self.text_visual, 'x') self.text_visual.inserter.add_varying( - 'float', 'v_discard', + 'float', + 'v_discard', 'float((n_boxes >= 50 * u_zoom.y) && ' - '(mod(int(a_box_index), int(n_boxes / (50 * u_zoom.y))) >= 1))') + '(mod(int(a_box_index), int(n_boxes / (50 * u_zoom.y))) >= 1))', + ) self.text_visual.inserter.insert_frag('if (v_discard > 0) discard;', 'end') self.canvas.add_visual(self.text_visual) @@ -250,7 +285,8 @@ def _plot_traces(self, traces, color=None): self.trace_visual.color = color self.canvas.update_visual( self.trace_visual, - t, traces, + t, + traces, data_bounds=self.data_bounds, box_index=box_index.ravel(), ) @@ -268,7 +304,9 @@ def _plot_spike(self, bunch): i = bunch.select_index c = bunch.spike_cluster cs = self.color_schemes.get() - color = selected_cluster_color(i, alpha=1) if i is not None else cs.get(c, alpha=1) + color = ( + selected_cluster_color(i, alpha=1) if i is not None else cs.get(c, alpha=1) + ) # We could tweak the color of each spike waveform depending on the template amplitude # on each of its best channels. @@ -283,7 +321,9 @@ def _plot_spike(self, bunch): box_index = np.repeat(box_index[:, np.newaxis], n_samples, axis=0) self.waveform_visual.add_batch_data( box_index=box_index, - x=t, y=bunch.data.T, color=color, + x=t, + y=bunch.data.T, + color=color, data_bounds=self.data_bounds, ) @@ -297,7 +337,13 @@ def _plot_waveforms(self, waveforms, **kwargs): for w in waveforms: self._plot_spike(w) self._waveform_times.append( - (w.start_time, w.spike_id, w.spike_cluster, w.get('channel_ids', None))) + ( + w.start_time, + w.spike_id, + w.spike_cluster, + w.get('channel_ids', None), + ) + ) self.canvas.update_visual(self.waveform_visual) else: # pragma: no cover self.waveform_visual.hide() @@ -310,7 +356,7 @@ def _plot_labels(self, traces): self.text_visual.add_batch_data( pos=[self.data_bounds[0], 0], text=ch_label, - anchor=[+1., 0], + anchor=[+1.0, 0], data_bounds=self.data_bounds, box_index=bi, ) @@ -327,10 +373,10 @@ def _restrict_interval(self, interval): end = int(round(end * self.sample_rate)) / self.sample_rate # Restrict the interval to the boundaries of the traces. if start < 0: - end += (-start) + end += -start start = 0 elif end >= self.duration: - start -= (end - self.duration) + start -= end - self.duration end = self.duration start = np.clip(start, 0, end) end = np.clip(end, start, self.duration) @@ -343,13 +389,13 @@ def plot(self, update_traces=True, update_waveforms=True): traces = self.traces(self._interval) if update_traces: - logger.log(5, "Redraw the entire trace view.") + logger.log(5, 'Redraw the entire trace view.') start, end = self._interval # Find the data bounds. if self.auto_scale or getattr(self, 'data_bounds', NDC) == NDC: ymin = np.quantile(traces.data, self.trace_quantile) - ymax = np.quantile(traces.data, 1. - self.trace_quantile) + ymax = np.quantile(traces.data, 1.0 - self.trace_quantile) else: ymin, ymax = self.data_bounds[1], self.data_bounds[3] self.data_bounds = (start, ymin, end, ymax) @@ -358,8 +404,7 @@ def plot(self, update_traces=True, update_waveforms=True): self._waveform_times = [] # Plot the traces. - self._plot_traces( - traces.data, color=traces.get('color', None)) + self._plot_traces(traces.data, color=traces.get('color', None)) # Plot the labels. if self.do_show_labels: @@ -378,7 +423,7 @@ def set_interval(self, interval=None): interval = self._restrict_interval(interval) if interval != self._interval: - logger.log(5, "Redraw the entire trace view.") + logger.log(5, 'Redraw the entire trace view.') self._interval = interval emit('is_busy', self, True) self.plot(update_traces=True, update_waveforms=True) @@ -397,17 +442,21 @@ def on_select(self, cluster_ids=None, **kwargs): def attach(self, gui): """Attach the view to the GUI.""" - super(TraceView, self).attach(gui) + super().attach(gui) - self.actions.add(self.toggle_show_labels, checkable=True, checked=self.do_show_labels) self.actions.add( - self.toggle_highlighted_spikes, checkable=True, checked=self.show_all_spikes) - self.actions.add(self.toggle_auto_scale, checkable=True, checked=self.auto_scale) + self.toggle_show_labels, checkable=True, checked=self.do_show_labels + ) + self.actions.add( + self.toggle_highlighted_spikes, checkable=True, checked=self.show_all_spikes + ) + self.actions.add( + self.toggle_auto_scale, checkable=True, checked=self.auto_scale + ) self.actions.add(self.switch_origin) self.actions.separator() - self.actions.add( - self.go_to, prompt=True, prompt_default=lambda: str(self.time)) + self.actions.add(self.go_to, prompt=True, prompt_default=lambda: str(self.time)) self.actions.separator() self.actions.add(self.go_to_start) @@ -434,7 +483,7 @@ def attach(self, gui): @property def status(self): a, b = self._interval - return '[{:.2f}s - {:.2f}s]. Color scheme: {}.'.format(a, b, self.color_scheme) + return f'[{a:.2f}s - {b:.2f}s]. Color scheme: {self.color_scheme}.' # Origin # ------------------------------------------------------------------------- @@ -453,8 +502,9 @@ def origin(self, value): self.canvas.layout.origin = value else: # pragma: no cover logger.warning( - "Could not set origin to %s because the layout instance was not initialized yet.", - value) + 'Could not set origin to %s because the layout instance was not initialized yet.', + value, + ) def switch_origin(self): """Switch between top and bottom origin for the channels.""" @@ -466,7 +516,7 @@ def switch_origin(self): @property def time(self): """Time at the center of the window.""" - return sum(self._interval) * .5 + return sum(self._interval) * 0.5 @property def interval(self): @@ -482,9 +532,9 @@ def half_duration(self): """Half of the duration of the current interval.""" if self._interval is not None: a, b = self._interval - return (b - a) * .5 + return (b - a) * 0.5 else: - return self.interval_duration * .5 + return self.interval_duration * 0.5 def go_to(self, time): """Go to a specific time (in seconds).""" @@ -506,23 +556,23 @@ def go_to_end(self): def go_right(self): """Go to right.""" start, end = self._interval - delay = (end - start) * .1 + delay = (end - start) * 0.1 self.shift(delay) def go_left(self): """Go to left.""" start, end = self._interval - delay = (end - start) * .1 + delay = (end - start) * 0.1 self.shift(-delay) def jump_right(self): """Jump to right.""" - delay = self.duration * .1 + delay = self.duration * 0.1 self.shift(delay) def jump_left(self): """Jump to left.""" - delay = self.duration * .1 + delay = self.duration * 0.1 self.shift(-delay) def _jump_to_spike(self, delta=+1): @@ -533,11 +583,15 @@ def _jump_to_spike(self, delta=+1): n = len(spike_times) self.go_to(spike_times[(ind + delta) % n]) - def go_to_next_spike(self, ): + def go_to_next_spike( + self, + ): """Jump to the next spike from the first selected cluster.""" self._jump_to_spike(+1) - def go_to_previous_spike(self, ): + def go_to_previous_spike( + self, + ): """Jump to the previous spike from the first selected cluster.""" self._jump_to_spike(-1) @@ -563,14 +617,14 @@ def narrow(self): def toggle_show_labels(self, checked): """Toggle the display of the channel ids.""" - logger.debug("Set show labels to %s.", checked) + logger.debug('Set show labels to %s.', checked) self.do_show_labels = checked self.text_visual.toggle() self.canvas.update() def toggle_auto_scale(self, checked): """Toggle automatic scaling of the traces.""" - logger.debug("Set auto scale to %s.", checked) + logger.debug('Set auto scale to %s.', checked) self.auto_scale = checked def update_color(self): @@ -608,7 +662,11 @@ def on_mouse_click(self, e): # Find the spike and cluster closest to the mouse. db = self.data_bounds # Get the information about the displayed spikes. - wt = [(t, s, c, ch) for t, s, c, ch in self._waveform_times if channel_id in ch] + wt = [ + (t, s, c, ch) + for t, s, c, ch in self._waveform_times + if channel_id in ch + ] if not wt: return # Get the time coordinate of the mouse position. @@ -620,8 +678,13 @@ def on_mouse_click(self, e): # Raise the select_spike event. spike_id = spike_ids[i] cluster_id = spike_clusters[i] - emit('select_spike', self, channel_id=channel_id, - spike_id=spike_id, cluster_id=cluster_id) + emit( + 'select_spike', + self, + channel_id=channel_id, + spike_id=spike_id, + cluster_id=cluster_id, + ) if 'Shift' in e.modifiers: # Get mouse position in NDC. @@ -631,10 +694,10 @@ def on_mouse_click(self, e): def on_mouse_wheel(self, e): # pragma: no cover """Scroll through the data with alt+wheel.""" - super(TraceView, self).on_mouse_wheel(e) + super().on_mouse_wheel(e) if e.modifiers == ('Alt',): start, end = self._interval - delay = e.delta * (end - start) * .1 + delay = e.delta * (end - start) * 0.1 self.shift(-delay) @@ -642,6 +705,7 @@ def on_mouse_wheel(self, e): # pragma: no cover # Trace Image view # ----------------------------------------------------------------------------- + class TraceImageView(TraceView): """This view shows the raw traces as an image @@ -666,6 +730,7 @@ class TraceImageView(TraceView): Labels of all shown channels. By default, this is just the channel ids. """ + default_shortcuts = { 'change_trace_size': 'ctrl+wheel', 'decrease': 'ctrl+alt+down', @@ -691,10 +756,13 @@ def __init__(self, **kwargs): self._scaling = 1 self.vrange = (0, 1) - super(TraceImageView, self).__init__(**kwargs) + super().__init__(**kwargs) self.state_attrs += ('origin', 'auto_scale') - self.local_state_attrs += ('interval', 'scaling',) + self.local_state_attrs += ( + 'interval', + 'scaling', + ) def _create_visuals(self): self.trace_visual = ImageVisual() @@ -714,7 +782,7 @@ def _plot_traces(self, traces, color=None): vmin, vmax = self.vrange image = _continuous_colormap(colormaps.diverging, traces, vmin=vmin, vmax=vmax) - image = add_alpha(image, alpha=1.) + image = add_alpha(image, alpha=1.0) self.trace_visual.set_data(image=image) # Public methods @@ -722,21 +790,20 @@ def _plot_traces(self, traces, color=None): def plot(self, update_traces=True, **kwargs): if update_traces: - logger.log(5, "Redraw the entire trace view.") + logger.log(5, 'Redraw the entire trace view.') traces = self.traces(self._interval) # Find the data bounds. if self.auto_scale or self.vrange == (0, 1): vmin = np.quantile(traces.data, self.trace_quantile) - vmax = np.quantile(traces.data, 1. - self.trace_quantile) + vmax = np.quantile(traces.data, 1.0 - self.trace_quantile) else: # pragma: no cover vmin, vmax = self.vrange self.vrange = (vmin * self.scaling, vmax * self.scaling) # Plot the traces. - self._plot_traces( - traces.data, color=traces.get('color', None)) + self._plot_traces(traces.data, color=traces.get('color', None)) self.canvas.update() diff --git a/phy/cluster/views/waveform.py b/phy/cluster/views/waveform.py index 237a42053..fb276506a 100644 --- a/phy/cluster/views/waveform.py +++ b/phy/cluster/views/waveform.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - """Waveform view.""" @@ -7,18 +5,26 @@ # Imports # ----------------------------------------------------------------------------- -from collections import defaultdict import logging +from collections import defaultdict import numpy as np - from phylib.io.array import _flatten, _index_of from phylib.utils import emit -from phy.utils.color import selected_cluster_color + +from phy.cluster._utils import RotatingProperty from phy.plot import get_linear_x from phy.plot.visuals import ( # noqa - PlotVisual, PlotAggVisual, UniformScatterVisual, TextVisual, LineVisual, _min, _max) -from phy.cluster._utils import RotatingProperty + LineVisual, + PlotAggVisual, + PlotVisual, + TextVisual, + UniformScatterVisual, + _max, + _min, +) +from phy.utils.color import selected_cluster_color + from .base import ManualClusteringView, ScalingMixin logger = logging.getLogger(__name__) @@ -28,11 +34,14 @@ # Waveform view # ----------------------------------------------------------------------------- + def _get_box_pos(bunchs, channel_ids): cp = {} for d in bunchs: cp.update({cid: pos for cid, pos in zip(d.channel_ids, d.channel_positions)}) - return np.stack([cp[cid] for cid in channel_ids]) + # Use float64 positions so phylib's box-size search cannot get stuck on float32 + # midpoint rounding in older installs. + return np.asarray(np.stack([cp[cid] for cid in channel_ids]), dtype=np.float64) def _get_clu_offsets(bunchs): @@ -89,8 +98,8 @@ class WaveformView(ScalingMixin, ManualClusteringView): max_n_clusters = 8 _default_position = 'right' - ax_color = (.75, .75, .75, 1.) - tick_size = 5. + ax_color = (0.75, 0.75, 0.75, 1.0) + tick_size = 5.0 cluster_ids = () default_shortcuts = { @@ -99,14 +108,12 @@ class WaveformView(ScalingMixin, ManualClusteringView): 'next_waveforms_type': 'w', 'previous_waveforms_type': 'shift+w', 'toggle_mean_waveforms': 'm', - # Box scaling. 'widen': 'ctrl+right', 'narrow': 'ctrl+left', 'increase': 'ctrl+up', 'decrease': 'ctrl+down', 'change_box_size': 'ctrl+wheel', - # Probe scaling. 'extend_horizontally': 'shift+right', 'shrink_horizontally': 'shift+left', @@ -122,14 +129,16 @@ def __init__(self, waveforms=None, waveforms_type=None, sample_rate=None, **kwar self.do_show_labels = True self.channel_ids = None self.filtered_tags = () - self.wave_duration = 0. # updated in the plotting method + self.wave_duration = 0.0 # updated in the plotting method self.data_bounds = None self.sample_rate = sample_rate self._status_suffix = '' - assert sample_rate > 0., "The sample rate must be provided to the waveform view." + assert sample_rate > 0.0, ( + 'The sample rate must be provided to the waveform view.' + ) # Initialize the view. - super(WaveformView, self).__init__(**kwargs) + super().__init__(**kwargs) self.state_attrs += ('waveforms_type', 'overlap', 'do_show_labels') self.local_state_attrs += ('box_scaling', 'probe_scaling') @@ -138,7 +147,9 @@ def __init__(self, waveforms=None, waveforms_type=None, sample_rate=None, **kwar # Ensure waveforms is a dictionary, even if there is a single waveforms type. waveforms = waveforms or {} - waveforms = waveforms if isinstance(waveforms, dict) else {'waveforms': waveforms} + waveforms = ( + waveforms if isinstance(waveforms, dict) else {'waveforms': waveforms} + ) self.waveforms = waveforms # Rotating property waveforms types. @@ -156,7 +167,8 @@ def __init__(self, waveforms=None, waveforms_type=None, sample_rate=None, **kwar self.canvas.add_visual(self.line_visual) self.tick_visual = UniformScatterVisual( - marker='vbar', color=self.ax_color, size=self.tick_size) + marker='vbar', color=self.ax_color, size=self.tick_size + ) self.canvas.add_visual(self.tick_visual) # Two types of visuals: thin raw line visual for normal waveforms, thick antialiased @@ -187,7 +199,8 @@ def get_clusters_data(self): if self.waveforms_type not in self.waveforms: return bunchs = [ - self.waveforms_types.get()(cluster_id) for cluster_id in self.cluster_ids] + self.waveforms_types.get()(cluster_id) for cluster_id in self.cluster_ids + ] clu_offsets = _get_clu_offsets(bunchs) n_clu = max(clu_offsets) + 1 # Offset depending on the overlap. @@ -195,7 +208,7 @@ def get_clusters_data(self): bunch.index = i bunch.offset = offset bunch.n_clu = n_clu - bunch.color = selected_cluster_color(i, bunch.get('alpha', .75)) + bunch.color = selected_cluster_color(i, bunch.get('alpha', 0.75)) return bunchs def _plot_cluster(self, bunch): @@ -216,12 +229,14 @@ def _plot_cluster(self, bunch): # Find the x coordinates. t = get_linear_x(n_spikes_clu * n_channels, n_samples) - t = _overlap_transform(t, offset=bunch.offset, n=bunch.n_clu, overlap=self.overlap) + t = _overlap_transform( + t, offset=bunch.offset, n=bunch.n_clu, overlap=self.overlap + ) # HACK: on the GPU, we get the actual masks with fract(masks) # since we add the relative cluster index. We need to ensure # that the masks is never 1.0, otherwise it is interpreted as # 0. - eps = .001 + eps = 0.001 masks = eps + (1 - 2 * eps) * masks # NOTE: we add the cluster index which is used for the # computation of the depth on the GPU. @@ -248,8 +263,13 @@ def _plot_cluster(self, bunch): assert self.data_bounds is not None self._current_visual.add_batch_data( - x=t, y=wave, color=bunch.color, masks=masks, box_index=box_index, - data_bounds=self.data_bounds) + x=t, + y=wave, + color=bunch.color, + masks=masks, + box_index=box_index, + data_bounds=self.data_bounds, + ) # Waveform axes. # -------------- @@ -257,7 +277,8 @@ def _plot_cluster(self, bunch): # Horizontal y=0 lines. ax_db = self.data_bounds a, b = _overlap_transform( - np.array([-1, 1]), offset=bunch.offset, n=bunch.n_clu, overlap=self.overlap) + np.array([-1, 1]), offset=bunch.offset, n=bunch.n_clu, overlap=self.overlap + ) box_index = _index_of(channel_ids_loc, self.channel_ids) box_index = np.repeat(box_index, 2) box_index = np.tile(box_index, n_spikes_clu) @@ -273,18 +294,21 @@ def _plot_cluster(self, bunch): # Vertical ticks every millisecond. steps = np.arange(np.round(self.wave_duration * 1000)) # A vline every millisecond. - x = .001 * steps + x = 0.001 * steps # Scale to [-1, 1], same coordinates as the waveform points. x = -1 + 2 * x / self.wave_duration # Take overlap into account. - x = _overlap_transform(x, offset=bunch.offset, n=bunch.n_clu, overlap=self.overlap) + x = _overlap_transform( + x, offset=bunch.offset, n=bunch.n_clu, overlap=self.overlap + ) x = np.tile(x, len(channel_ids_loc)) # Generate the box index. box_index = _index_of(channel_ids_loc, self.channel_ids) box_index = np.repeat(box_index, x.size // len(box_index)) assert x.size == box_index.size self.tick_visual.add_batch_data( - x=x, y=np.zeros_like(x), + x=x, + y=np.zeros_like(x), data_bounds=ax_db, box_index=box_index, ) @@ -318,14 +342,15 @@ def plot(self, **kwargs): if bunchs[0].data is not None: self.wave_duration = bunchs[0].data.shape[1] / float(self.sample_rate) else: # pragma: no cover - self.wave_duration = 1. + self.wave_duration = 1.0 # Channel labels. channel_labels = {} for d in bunchs: - chl = d.get('channel_labels', ['%d' % ch for ch in d.channel_ids]) - channel_labels.update({ - channel_id: chl[i] for i, channel_id in enumerate(d.channel_ids)}) + chl = d.get('channel_labels', [f'{ch}' for ch in d.channel_ids]) + channel_labels.update( + {channel_id: chl[i] for i, channel_id in enumerate(d.channel_ids)} + ) # Update the Boxed box positions as a function of the selected channels. if channel_ids: @@ -357,10 +382,14 @@ def plot(self, **kwargs): def attach(self, gui): """Attach the view to the GUI.""" - super(WaveformView, self).attach(gui) + super().attach(gui) - self.actions.add(self.toggle_waveform_overlap, checkable=True, checked=self.overlap) - self.actions.add(self.toggle_show_labels, checkable=True, checked=self.do_show_labels) + self.actions.add( + self.toggle_waveform_overlap, checkable=True, checked=self.overlap + ) + self.actions.add( + self.toggle_show_labels, checkable=True, checked=self.do_show_labels + ) self.actions.add(self.next_waveforms_type) self.actions.add(self.previous_waveforms_type) self.actions.add(self.toggle_mean_waveforms, checkable=True) @@ -472,13 +501,15 @@ def toggle_show_labels(self, checked): def on_mouse_click(self, e): """Select a channel by clicking on a box in the waveform view.""" b = e.button - nums = tuple('%d' % i for i in range(10)) + nums = tuple(f'{i}' for i in range(10)) if 'Control' in e.modifiers or e.key in nums: key = int(e.key) if e.key in nums else None # Get mouse position in NDC. channel_idx, _ = self.canvas.boxed.box_map(e.pos) channel_id = self.channel_ids[channel_idx] - logger.debug("Click on channel_id %d with key %s and button %s.", channel_id, key, b) + logger.debug( + 'Click on channel_id %d with key %s and button %s.', channel_id, key, b + ) emit('select_channel', self, channel_id=channel_id, key=key, button=b) @property @@ -492,22 +523,22 @@ def waveforms_type(self, value): def next_waveforms_type(self): """Switch to the next waveforms type.""" self.waveforms_types.next() - logger.debug("Switch to waveforms type %s.", self.waveforms_type) + logger.debug('Switch to waveforms type %s.', self.waveforms_type) self.plot() def previous_waveforms_type(self): """Switch to the previous waveforms type.""" self.waveforms_types.previous() - logger.debug("Switch to waveforms type %s.", self.waveforms_type) + logger.debug('Switch to waveforms type %s.', self.waveforms_type) self.plot() def toggle_mean_waveforms(self, checked): """Switch to the `mean_waveforms` type, if it is available.""" if self.waveforms_type == 'mean_waveforms' and 'waveforms' in self.waveforms: self.waveforms_types.set('waveforms') - logger.debug("Switch to raw waveforms.") + logger.debug('Switch to raw waveforms.') self.plot() elif 'mean_waveforms' in self.waveforms: self.waveforms_types.set('mean_waveforms') - logger.debug("Switch to mean waveforms.") + logger.debug('Switch to mean waveforms.') self.plot() diff --git a/phy/conftest.py b/phy/conftest.py index 5f8eacff5..7f4e36876 100644 --- a/phy/conftest.py +++ b/phy/conftest.py @@ -1,29 +1,74 @@ -# -*- coding: utf-8 -*- - """py.test utilities.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +import os import logging -import numpy as np import warnings +from functools import wraps import matplotlib - +import numpy as np from phylib import add_default_handler from phylib.conftest import * # noqa - -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Common fixtures -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ logger = logging.getLogger('phy') logger.setLevel(10) add_default_handler(5, logger=logger) +os.environ.setdefault('JUPYTER_PLATFORM_DIRS', '1') +# Keep Qt tests headless by default so GUI windows do not interrupt the desktop. +os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen') +warnings.filterwarnings( + 'ignore', + message='Jupyter is migrating its paths to use standard platformdirs', + category=DeprecationWarning, +) +warnings.filterwarnings( + 'ignore', + message=r'tostring\(\) is deprecated\. Use tobytes\(\) instead\.', + category=DeprecationWarning, +) +warnings.filterwarnings( + 'ignore', + category=DeprecationWarning, + module=r'OpenGL\.GL\.VERSION\.GL_2_0', +) + + +def _suppress_pyopengl_tostring_warning(): + try: + from OpenGL.GL.VERSION import GL_2_0 + except Exception: + return + + def _wrap(func): + @wraps(func) + def inner(*args, **kwargs): + with warnings.catch_warnings(): + warnings.filterwarnings( + 'ignore', + message=r'tostring\(\) is deprecated\. Use tobytes\(\) instead\.', + category=DeprecationWarning, + ) + return func(*args, **kwargs) + + return inner + + for name in ('glGetActiveAttrib', 'glGetActiveUniform'): + func = getattr(GL_2_0, name, None) + if callable(func): + setattr(GL_2_0, name, _wrap(func)) + + +_suppress_pyopengl_tostring_warning() + # Fix the random seed in the tests. np.random.seed(2019) @@ -43,3 +88,8 @@ def pytest_generate_tests(metafunc): # pragma: no cover count = int(metafunc.config.option.repeat) metafunc.fixturenames.append('tmp_ct') metafunc.parametrize('tmp_ct', range(count)) + + +def pytest_collection_modifyitems(session, config, items): + """Run app tests after the rest of the suite.""" + items.sort(key=lambda item: ('/phy/apps/' in str(item.fspath).replace(os.sep, '/'), str(item.fspath))) diff --git a/phy/gui/__init__.py b/phy/gui/__init__.py index 456fd0514..6be0fcf90 100644 --- a/phy/gui/__init__.py +++ b/phy/gui/__init__.py @@ -1,12 +1,22 @@ -# -*- coding: utf-8 -*- # flake8: noqa """GUI routines.""" from .qt import ( - require_qt, create_app, run_app, prompt, message_box, input_dialog, busy_cursor, - screenshot, screen_size, is_high_dpi, thread_pool, Worker, Debouncer + require_qt, + create_app, + run_app, + prompt, + message_box, + input_dialog, + busy_cursor, + screenshot, + screen_size, + is_high_dpi, + thread_pool, + Worker, + Debouncer, ) from .gui import GUI, GUIState, DockWidget from .actions import Actions, Snippets -from .widgets import HTMLWidget, HTMLBuilder, Table, IPythonView, KeyValueWidget +from .widgets import Table, IPythonView, KeyValueWidget diff --git a/phy/gui/actions.py b/phy/gui/actions.py index 4364ccde2..90efc805f 100644 --- a/phy/gui/actions.py +++ b/phy/gui/actions.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - """Actions and snippets.""" @@ -8,15 +6,16 @@ # ----------------------------------------------------------------------------- import inspect -from functools import partial, wraps import logging import re import sys import traceback +from functools import partial, wraps -from .qt import QKeySequence, QAction, require_qt, input_dialog, busy_cursor, _get_icon from phylib.utils import Bunch +from .qt import QAction, QKeySequence, _get_icon, busy_cursor, input_dialog, require_qt + logger = logging.getLogger(__name__) @@ -24,6 +23,7 @@ # Snippet parsing utilities # ----------------------------------------------------------------------------- + def _parse_arg(s): """Parse a number or string.""" try: @@ -64,13 +64,13 @@ def _prompt_args(title, docstring, default=None): # There are args, need to display the dialog. # Extract Example: `...` in the docstring to put a predefined text # in the input dialog. - logger.debug("Prompting arguments for %s", title) + logger.debug('Prompting arguments for %s', title) r = re.search('Example: `([^`]+)`', docstring) - docstring_ = docstring[:r.start()].strip() if r else docstring + docstring_ = docstring[: r.start()].strip() if r else docstring try: text = str(default()) if default else (r.group(1) if r else None) except Exception as e: # pragma: no cover - logger.error("Error while handling user input: %s", str(e)) + logger.error('Error while handling user input: %s', str(e)) return s, ok = input_dialog(title, docstring_, text) if not ok or not s: @@ -84,6 +84,7 @@ def _prompt_args(title, docstring, default=None): # Show shortcut utility functions # ----------------------------------------------------------------------------- + def _get_shortcut_string(shortcut): """Return a string representation of a shortcut.""" if not shortcut: @@ -120,7 +121,7 @@ def _show_shortcuts(shortcuts): for n in sorted(shortcuts): shortcut = _get_shortcut_string(shortcuts[n]) if not n.startswith('_') and not shortcut.startswith('-'): - out.append('- {0:<40} {1:s}'.format(n, shortcut)) + out.append(f'- {n:<40} {shortcut:s}') if out: print('Keyboard shortcuts') print('\n'.join(out)) @@ -133,7 +134,7 @@ def _show_snippets(snippets): for n in sorted(snippets): snippet = snippets[n] if not n.startswith('_'): - out.append('- {0:<40} :{1:s}'.format(n, snippet)) + out.append(f'- {n:<40} :{snippet:s}') if out: print('Snippets') print('\n'.join(out)) @@ -153,6 +154,7 @@ def show_shortcuts_snippets(actions): # Actions # ----------------------------------------------------------------------------- + def _alias(name): # Get the alias from the character after & if it exists. alias = name[name.index('&') + 1] if '&' in name else name @@ -170,10 +172,10 @@ def _expected_args(f): f_args.remove('self') # Remove arguments with defaults from the list. if len(argspec.defaults or ()): - f_args = f_args[:-len(argspec.defaults)] + f_args = f_args[: -len(argspec.defaults)] # Remove arguments supplied in a partial. if isinstance(f, partial): - f_args = f_args[len(f.args):] + f_args = f_args[len(f.args) :] f_args = [arg for arg in f_args if arg not in f.keywords] return tuple(f_args) @@ -186,51 +188,51 @@ def _create_qaction(gui, **kwargs): action = QAction(name, gui) # Show an input dialog if there are args. - callback = kwargs.get('callback', None) + callback = kwargs.get('callback') title = getattr(callback, '__name__', 'action') # Number of expected arguments. - n_args = kwargs.get('n_args', None) or len(_expected_args(callback)) + n_args = kwargs.get('n_args') or len(_expected_args(callback)) @wraps(callback) def wrapped(is_checked, *args): - if kwargs.get('checkable', None): + if kwargs.get('checkable'): args = (is_checked,) + args - if kwargs.get('prompt', None): - args += _prompt_args( - title, docstring, default=kwargs.get('prompt_default', None)) or () + if kwargs.get('prompt'): + args += _prompt_args(title, docstring, default=kwargs.get('prompt_default')) or () if not args: # pragma: no cover - logger.debug("User cancelled input prompt, aborting.") + logger.debug('User cancelled input prompt, aborting.') return if len(args) < n_args: logger.warning( - "Invalid function arguments: expecting %d but got %d", n_args, len(args)) + 'Invalid function arguments: expecting %d but got %d', n_args, len(args) + ) return try: # Set a busy cursor if set_busy is True. - with busy_cursor(kwargs.get('set_busy', None)): + with busy_cursor(kwargs.get('set_busy')): return callback(*args) except Exception: # pragma: no cover - logger.warning("Error when executing action %s.", name) + logger.warning('Error when executing action %s.', name) logger.debug(''.join(traceback.format_exception(*sys.exc_info()))) action.triggered.connect(wrapped) - sequence = _get_qkeysequence(kwargs.get('shortcut', None)) + sequence = _get_qkeysequence(kwargs.get('shortcut')) if not isinstance(sequence, (tuple, list)): sequence = [sequence] action.setShortcuts(sequence) - assert kwargs.get('docstring', None) - docstring = re.sub(r'\s+', ' ', kwargs.get('docstring', None)) - docstring += ' (alias: {})'.format(kwargs.get('alias', None)) + assert kwargs.get('docstring') + docstring = re.sub(r'\s+', ' ', kwargs.get('docstring')) + docstring += f' (alias: {kwargs.get("alias")})' action.setStatusTip(docstring) action.setWhatsThis(docstring) - action.setCheckable(kwargs.get('checkable', None)) - action.setChecked(kwargs.get('checked', None)) - if kwargs.get('icon', None): + action.setCheckable(kwargs.get('checkable')) + action.setChecked(kwargs.get('checked')) + if kwargs.get('icon'): action.setIcon(_get_icon(kwargs['icon'])) return action -class Actions(object): +class Actions: """Group of actions bound to a GUI. This class attaches to a GUI and implements the following features: @@ -255,9 +257,18 @@ class Actions(object): Map action names to snippets (regular strings). """ + def __init__( - self, gui, name=None, menu=None, submenu=None, view=None, - insert_menu_before=None, default_shortcuts=None, default_snippets=None): + self, + gui, + name=None, + menu=None, + submenu=None, + view=None, + insert_menu_before=None, + default_shortcuts=None, + default_snippets=None, + ): self._actions_dict = {} self._aliases = {} self._default_shortcuts = default_shortcuts or {} @@ -302,10 +313,28 @@ def _get_menu(self, menu=None, submenu=None, view=None, view_submenu=None): if menu: return self.gui.get_menu(menu) - def add(self, callback=None, name=None, shortcut=None, alias=None, prompt=False, n_args=None, - docstring=None, menu=None, submenu=None, view=None, view_submenu=None, verbose=True, - checkable=False, checked=False, set_busy=False, prompt_default=None, - show_shortcut=True, icon=None, toolbar=False): + def add( + self, + callback=None, + name=None, + shortcut=None, + alias=None, + prompt=False, + n_args=None, + docstring=None, + menu=None, + submenu=None, + view=None, + view_submenu=None, + verbose=True, + checkable=False, + checked=False, + set_busy=False, + prompt_default=None, + show_shortcut=True, + icon=None, + toolbar=False, + ): """Add an action with a keyboard shortcut. Parameters @@ -381,14 +410,15 @@ def add(self, callback=None, name=None, shortcut=None, alias=None, prompt=False, action = _create_qaction(self.gui, **kwargs) action_obj = Bunch(qaction=action, **kwargs) if verbose and not name.startswith('_'): - logger.log(5, "Add action `%s` (%s).", name, _get_shortcut_string(action.shortcut())) + logger.log(5, 'Add action `%s` (%s).', name, _get_shortcut_string(action.shortcut())) self.gui.addAction(action) # Do not show private actions in the menu. if not name.startswith('_'): # Find the menu in which the action should be added. qmenu = self._get_menu( - menu=menu, submenu=submenu, view=view, view_submenu=view_submenu) + menu=menu, submenu=submenu, view=view, view_submenu=view_submenu + ) if qmenu: qmenu.addAction(action) @@ -453,13 +483,13 @@ def run(self, name, *args): # Get the action. action = self._actions_dict.get(name, None) if not action: - raise ValueError("Action `{}` doesn't exist.".format(name)) + raise ValueError(f"Action `{name}` doesn't exist.") if not name.startswith('_'): - logger.debug("Execute action `%s`.", name) + logger.debug('Execute action `%s`.', name) try: return action.callback(*args) except TypeError as e: - logger.warning("Invalid action arguments: " + str(e)) + logger.warning(f'Invalid action arguments: {str(e)}') return def remove(self, name): @@ -486,10 +516,10 @@ def shortcuts(self): if not action.shortcut and not action.alias: continue # Only show alias for actions with no shortcut. - alias_str = ' (:%s)' % action.alias if action.alias != name else '' + alias_str = f' (:{action.alias})' if action.alias != name else '' shortcut = action.shortcut or '-' shortcut = shortcut if isinstance(action.shortcut, str) else ', '.join(shortcut) - out[name] = '%s%s' % (shortcut, alias_str) + out[name] = f'{shortcut}{alias_str}' return out def show_shortcuts(self): @@ -501,14 +531,15 @@ def __contains__(self, name): return name in self._actions_dict def __repr__(self): - return ''.format(sorted(self._actions_dict)) + return f'' # ----------------------------------------------------------------------------- # Snippets # ----------------------------------------------------------------------------- -class Snippets(object): + +class Snippets: """Provide keyboard snippets to quickly execute actions from a GUI. This class attaches to a GUI and an `Actions` instance. To every command @@ -542,11 +573,11 @@ class Snippets(object): """ # HACK: Unicode characters do not seem to work on Python 2 - cursor = '\u200A\u258C' + cursor = '\u200a\u258c' # Allowed characters in snippet mode. # A Qt shortcut will be created for every character. - _snippet_chars = r"abcdefghijklmnopqrstuvwxyz0123456789 ,.;?!_-+~=*/\(){}[]<>&|" + _snippet_chars = r'abcdefghijklmnopqrstuvwxyz0123456789 ,.;?!_-+~=*/\(){}[]<>&|' def __init__(self, gui): self.gui = gui @@ -571,7 +602,7 @@ def command(self): msg = self.gui.status_message n = len(msg) n_cur = len(self.cursor) - return msg[:n - n_cur] + return msg[: n - n_cur] @command.setter def command(self, value): @@ -584,13 +615,13 @@ def _backspace(self): """Erase the last character in the snippet command.""" if self.command == ':': return - logger.log(5, "Snippet keystroke `Backspace`.") + logger.log(5, 'Snippet keystroke `Backspace`.') self.command = self.command[:-1] def _enter(self): """Disable the snippet mode and execute the command.""" command = self.command - logger.log(5, "Snippet keystroke `Enter`.") + logger.log(5, 'Snippet keystroke `Enter`.') # NOTE: we need to set back the actions (mode_off) before running # the command. self.mode_off() @@ -607,29 +638,27 @@ def _create_snippet_actions(self): def _make_func(char): def callback(): - logger.log(5, "Snippet keystroke `%s`.", char) + logger.log(5, 'Snippet keystroke `%s`.', char) self.command += char + return callback # Lowercase letters. - self.actions.add( - name='_snippet_{}'.format(i), - shortcut=char, - callback=_make_func(char)) + self.actions.add(name=f'_snippet_{i}', shortcut=char, callback=_make_func(char)) # Uppercase letters. if char in self._snippet_chars[:26]: self.actions.add( - name='_snippet_{}_upper'.format(i), - shortcut='shift+' + char, - callback=_make_func(char.upper())) + name=f'_snippet_{i}_upper', + shortcut=f'shift+{char}', + callback=_make_func(char.upper()), + ) + self.actions.add(name='_snippet_backspace', shortcut='backspace', callback=self._backspace) self.actions.add( - name='_snippet_backspace', shortcut='backspace', callback=self._backspace) - self.actions.add( - name='_snippet_activate', shortcut=('enter', 'return'), callback=self._enter) - self.actions.add( - name='_snippet_disable', shortcut='escape', callback=self.mode_off) + name='_snippet_activate', shortcut=('enter', 'return'), callback=self._enter + ) + self.actions.add(name='_snippet_disable', shortcut='escape', callback=self.mode_off) def run(self, snippet): """Execute a snippet command. @@ -642,7 +671,7 @@ def run(self, snippet): snippet_args = _parse_snippet(snippet) name = snippet_args[0] - logger.debug("Processing snippet `%s`.", snippet) + logger.debug('Processing snippet `%s`.', snippet) try: # Try to run the snippet on all attached Actions instances. for actions in self.gui.actions: @@ -655,7 +684,7 @@ def run(self, snippet): pass logger.warning("Couldn't find action `%s`.", name) except Exception as e: - logger.warning("Error when executing snippet: \"%s\".", str(e)) + logger.warning('Error when executing snippet: "%s".', str(e)) logger.debug(''.join(traceback.format_exception(*sys.exc_info()))) def is_mode_on(self): @@ -664,7 +693,7 @@ def is_mode_on(self): def mode_on(self): """Enable the snippet mode.""" - logger.debug("Snippet mode enabled, press `escape` to leave this mode.") + logger.debug('Snippet mode enabled, press `escape` to leave this mode.') # Save the current status message. self._status_message = self.gui.status_message self.gui.lock_status() diff --git a/phy/gui/gui.py b/phy/gui/gui.py index cad91aa97..9e7682fcb 100644 --- a/phy/gui/gui.py +++ b/phy/gui/gui.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - """Qt dock window.""" @@ -7,17 +5,37 @@ # Imports # ----------------------------------------------------------------------------- +import logging from collections import defaultdict from functools import partial -import logging -from .qt import ( - QApplication, QWidget, QDockWidget, QHBoxLayout, QVBoxLayout, QPushButton, QLabel, QCheckBox, - QMenu, QToolBar, QStatusBar, QMainWindow, QMessageBox, Qt, QPoint, QSize, _load_font, - _wait, prompt, show_box, screenshot as make_screenshot) -from .state import GUIState, _gui_state_path, _get_default_state_path +from phylib.utils import connect, emit + from .actions import Actions, Snippets -from phylib.utils import emit, connect +from .qt import ( + QApplication, + QCheckBox, + QDockWidget, + QHBoxLayout, + QLabel, + QMainWindow, + QMenu, + QMessageBox, + QPoint, + QPushButton, + QSize, + QStatusBar, + Qt, + QToolBar, + QVBoxLayout, + QWidget, + _load_font, + _wait, + prompt, + show_box, +) +from .qt import screenshot as make_screenshot +from .state import GUIState, _get_default_state_path, _gui_state_path logger = logging.getLogger(__name__) @@ -26,11 +44,13 @@ # GUI utils # ----------------------------------------------------------------------------- + def _try_get_matplotlib_canvas(view): """Get the Qt widget from a matplotlib figure.""" try: - from matplotlib.pyplot import Figure from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg + from matplotlib.pyplot import Figure + if isinstance(view, Figure): view = FigureCanvasQTAgg(view) # Case where the view has a .figure property which is a matplotlib figure. @@ -39,17 +59,19 @@ def _try_get_matplotlib_canvas(view): elif isinstance(getattr(getattr(view, 'canvas', None), 'figure', None), Figure): view = FigureCanvasQTAgg(view.canvas.figure) except ImportError as e: # pragma: no cover - logger.warning("Import error: %s", e) + logger.warning('Import error: %s', e) return view def _try_get_opengl_canvas(view): """Convert from QOpenGLWindow to QOpenGLWidget.""" from phy.plot.base import BaseCanvas + if isinstance(view, BaseCanvas): - return QWidget.createWindowContainer(view) + return view if isinstance(view, QWidget) else QWidget.createWindowContainer(view) elif isinstance(getattr(view, 'canvas', None), BaseCanvas): - return QWidget.createWindowContainer(view.canvas) + canvas = view.canvas + return canvas if isinstance(canvas, QWidget) else QWidget.createWindowContainer(canvas) return view @@ -61,7 +83,7 @@ def _widget_position(widget): # pragma: no cover # Dock widget # ----------------------------------------------------------------------------- -DOCK_TITLE_STYLESHEET = ''' +DOCK_TITLE_STYLESHEET = """ * { padding: 0; margin: 0; @@ -95,10 +117,10 @@ def _widget_position(widget): # pragma: no cover QPushButton:checked { background: #6c717a; } -''' +""" -DOCK_STATUS_STYLESHEET = ''' +DOCK_STATUS_STYLESHEET = """ * { padding: 0; margin: 0; @@ -110,7 +132,7 @@ def _widget_position(widget): # pragma: no cover QLabel { padding: 3px; } -''' +""" class DockWidget(QDockWidget): @@ -126,7 +148,7 @@ class DockWidget(QDockWidget): max_status_length = 64 def __init__(self, *args, widget=None, **kwargs): - super(DockWidget, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) # Load the font awesome font. self._font = _load_font('fa-solid-900.ttf') self._dock_widgets = {} @@ -135,11 +157,18 @@ def __init__(self, *args, widget=None, **kwargs): def closeEvent(self, e): """Qt slot when the window is closed.""" emit('close_dock_widget', self) - super(DockWidget, self).closeEvent(e) + super().closeEvent(e) def add_button( - self, callback=None, text=None, icon=None, checkable=False, - checked=False, event=None, name=None): + self, + callback=None, + text=None, + icon=None, + checkable=False, + checked=False, + event=None, + name=None, + ): """Add a button to the dock title bar, to the right. Parameters @@ -166,8 +195,14 @@ def add_button( """ if callback is None: return partial( - self.add_button, text=text, icon=icon, name=name, - checkable=checkable, checked=checked, event=event) + self.add_button, + text=text, + icon=icon, + name=name, + checkable=checkable, + checked=checked, + event=event, + ) name = name or getattr(callback, '__name__', None) or text assert name @@ -180,12 +215,14 @@ def add_button( button.setToolTip(name) if callback: + @button.clicked.connect def on_clicked(state): return callback(state) # Change the state of the button when this event is called. if event: + @connect(event=event, sender=self.view) def on_state_changed(sender, checked): button.setChecked(checked) @@ -223,6 +260,7 @@ def add_checkbox(self, callback=None, text=None, checked=False, name=None): if checked: checkbox.setCheckState(Qt.Checked if checked else Qt.Unchecked) if callback: + @checkbox.stateChanged.connect def on_state_changed(state): return callback(state == Qt.Checked) @@ -246,7 +284,7 @@ def set_status(self, text): """Set the status text of the widget.""" n = self.max_status_length if len(text) >= n: - text = text[:n // 2] + ' ... ' + text[-n // 2:] + text = f'{text[: n // 2]} ... {text[-n // 2 :]}' self._status.setText(text) def _default_buttons(self): @@ -257,10 +295,17 @@ def _default_buttons(self): # Close button. @self.add_button(name='close', text='✕') def on_close(e): # pragma: no cover - if not self.confirm_before_close_view or show_box( - prompt( - "Close %s?" % self.windowTitle(), - buttons=['yes', 'no'], title='Close?')) == 'yes': + if ( + not self.confirm_before_close_view + or show_box( + prompt( + f'Close {self.windowTitle()}?', + buttons=['yes', 'no'], + title='Close?', + ) + ) + == 'yes' + ): self.close() # Screenshot button. @@ -282,7 +327,7 @@ def on_view_menu(e): # pragma: no cover def _create_menu(self): """Create the contextual menu for this view.""" - self._menu = QMenu("%s menu" % self.objectName(), self) + self._menu = QMenu(f'{self.objectName()} menu', self) def _create_title_bar(self): """Create the title bar.""" @@ -357,10 +402,10 @@ def _create_dock_widget(widget, name, closable=True, floatable=True): dock.setFeatures(options) dock.setAllowedAreas( - Qt.LeftDockWidgetArea | - Qt.RightDockWidgetArea | - Qt.TopDockWidgetArea | - Qt.BottomDockWidgetArea + Qt.LeftDockWidgetArea + | Qt.RightDockWidgetArea + | Qt.TopDockWidgetArea + | Qt.BottomDockWidgetArea ) dock._create_menu() @@ -371,11 +416,12 @@ def _create_dock_widget(widget, name, closable=True, floatable=True): def _get_dock_position(position): - return {'left': Qt.LeftDockWidgetArea, - 'right': Qt.RightDockWidgetArea, - 'top': Qt.TopDockWidgetArea, - 'bottom': Qt.BottomDockWidgetArea, - }[position or 'right'] + return { + 'left': Qt.LeftDockWidgetArea, + 'right': Qt.RightDockWidgetArea, + 'top': Qt.TopDockWidgetArea, + 'bottom': Qt.BottomDockWidgetArea, + }[position or 'right'] def _prompt_save(): # pragma: no cover @@ -385,8 +431,10 @@ def _prompt_save(): # pragma: no cover """ b = prompt( - "Do you want to save your changes before quitting?", - buttons=['save', 'cancel', 'close'], title='Save') + 'Do you want to save your changes before quitting?', + buttons=['save', 'cancel', 'close'], + title='Save', + ) return show_box(b) @@ -400,6 +448,7 @@ def _remove_duplicates(seq): # GUI main window # ----------------------------------------------------------------------------- + class GUI(QMainWindow): """A Qt main window containing docking widgets. This class derives from `QMainWindow`. @@ -447,20 +496,29 @@ class GUI(QMainWindow): has_save_action = True def __init__( - self, position=None, size=None, name=None, subtitle=None, view_creator=None, - view_count=None, default_views=None, config_dir=None, enable_threading=True, **kwargs): + self, + position=None, + size=None, + name=None, + subtitle=None, + view_creator=None, + view_count=None, + default_views=None, + config_dir=None, + enable_threading=True, + **kwargs, + ): # HACK to ensure that closeEvent is called only twice (seems like a # Qt bug). self._enable_threading = enable_threading self._closed = False if not QApplication.instance(): # pragma: no cover - raise RuntimeError("A Qt application must be created.") - super(GUI, self).__init__() - self.setDockOptions( - QMainWindow.AllowTabbedDocks | QMainWindow.AllowNestedDocks) + raise RuntimeError('A Qt application must be created.') + super().__init__() + self.setDockOptions(QMainWindow.AllowTabbedDocks | QMainWindow.AllowNestedDocks) self.setAnimated(False) - logger.debug("Creating GUI.") + logger.debug('Creating GUI.') self._set_name(name, str(subtitle or '')) position = position or (200, 200) @@ -476,28 +534,42 @@ def __init__( # Mapping {name: menuBar}. self._menus = {} ds = self.default_shortcuts - self.file_actions = Actions(self, name='File', menu='&File', default_shortcuts=ds) - self.view_actions = Actions(self, name='View', menu='&View', default_shortcuts=ds) - self.help_actions = Actions(self, name='Help', menu='&Help', default_shortcuts=ds) + self.file_actions = Actions( + self, name='File', menu='&File', default_shortcuts=ds + ) + self.view_actions = Actions( + self, name='View', menu='&View', default_shortcuts=ds + ) + self.help_actions = Actions( + self, name='Help', menu='&Help', default_shortcuts=ds + ) # Views, self._views = [] - self._view_class_indices = defaultdict(int) # Dictionary {view_name: next_usable_index} + self._view_class_indices = defaultdict( + int + ) # Dictionary {view_name: next_usable_index} # Create the GUI state. state_path = _gui_state_path(self.name, config_dir=config_dir) - default_state_path = kwargs.pop('default_state_path', _get_default_state_path(self)) - self.state = GUIState(state_path, default_state_path=default_state_path, **kwargs) + default_state_path = kwargs.pop( + 'default_state_path', _get_default_state_path(self) + ) + self.state = GUIState( + state_path, default_state_path=default_state_path, **kwargs + ) # View creator: dictionary {view_class: function_that_adds_view} self.default_views = default_views or () self.view_creator = view_creator or {} # View count: take the requested one, or the GUI state one. self._requested_view_count = ( - view_count if view_count is not None else self.state.get('view_count', {})) + view_count if view_count is not None else self.state.get('view_count', {}) + ) # If there is still no view count, use a default one. - self._requested_view_count = self._requested_view_count or { - view_name: 1 for view_name in default_views or ()} + self._requested_view_count = self._requested_view_count or dict.fromkeys( + default_views or (), 1 + ) # Status bar. self._lock_status = False @@ -516,7 +588,7 @@ def __init__( @connect(sender=self) def on_show(sender): - logger.debug("Load the geometry state.") + logger.debug('Load the geometry state.') gs = self.state.get('geometry_state', None) self.restore_geometry_state(gs) @@ -524,7 +596,7 @@ def _set_name(self, name, subtitle): """Set the GUI name.""" if name is None: name = self.__class__.__name__ - title = name if not subtitle else name + ' - ' + subtitle + title = name if not subtitle else f'{name} - {subtitle}' self.setWindowTitle(title) self.setObjectName(name) # Set the name in the GUI. @@ -542,6 +614,7 @@ def set_default_actions(self): # File menu. if self.has_save_action: + @self.file_actions.add(icon='f0c7', toolbar=True) def save(): emit('request_save', self) @@ -555,9 +628,10 @@ def exit(): for view_name in sorted(self.view_creator.keys()): self.view_actions.add( partial(self.create_and_add_view, view_name), - name='Add %s' % view_name, - docstring="Add %s" % view_name, - show_shortcut=False) + name=f'Add {view_name}', + docstring=f'Add {view_name}', + show_shortcut=False, + ) self.view_actions.separator() # Help menu. @@ -571,13 +645,15 @@ def show_all_shortcuts(): def about(): # pragma: no cover """Display an about dialog.""" from phy import __version_git__ - msg = "phy {} v{}".format(self.name, __version_git__) + + msg = f'phy {self.name} v{__version_git__}' try: from phylib import __version__ - msg += "\nphylib v{}".format(__version__) + + msg += f'\nphylib v{__version__}' except ImportError: pass - QMessageBox.about(self, "About", msg) + QMessageBox.about(self, 'About', msg) # Events # ------------------------------------------------------------------------- @@ -593,11 +669,11 @@ def closeEvent(self, e): if False in res: # pragma: no cover e.ignore() return - super(GUI, self).closeEvent(e) + super().closeEvent(e) self._closed = True # Save the state to disk when closing the GUI. - logger.debug("Save the geometry state.") + logger.debug('Save the geometry state.') gs = self.save_geometry_state() self.state['geometry_state'] = gs self.state['view_count'] = self.view_count @@ -605,7 +681,7 @@ def closeEvent(self, e): def show(self): """Show the window.""" - super(GUI, self).show() + super().show() emit('show', self) # Views @@ -631,8 +707,10 @@ def list_views(self, *classes): """Return the list of views which are instances of one or several classes.""" s = set(classes) return [ - view for view in self._views - if s.intersection({view.__class__, view.__class__.__name__})] + view + for view in self._views + if s.intersection({view.__class__, view.__class__.__name__}) + ] def get_view(self, cls, index=0): """Return a view from a given class. If there are multiple views of the same class, @@ -655,7 +733,7 @@ def _set_view_name(self, view): # index is the next usable index for the view's class. index = self._view_class_indices.get(cls, 0) assert index >= 1 - name = '%s (%d)' % (basename, index) + name = f'{basename} ({index})' view.name = name return name @@ -668,7 +746,7 @@ def create_and_add_view(self, view_name): # Create the view with the view creation function. view = fn() if view is None: # pragma: no cover - logger.warning("Could not create view %s.", view_name) + logger.warning('Could not create view %s.', view_name) return # Attach the view to the GUI if it has an attach(gui) method, # otherwise add the view. @@ -682,10 +760,17 @@ def create_views(self): """Create and add as many views as specified in view_count.""" self.view_actions.separator() # Keep the order of self.default_views. - view_names = [vn for vn in self.default_views if vn in self._requested_view_count] + view_names = [ + vn for vn in self.default_views if vn in self._requested_view_count + ] # We add the views in the requested view count, but not in the default views. - view_names.extend([ - vn for vn in self._requested_view_count.keys() if vn not in self.default_views]) + view_names.extend( + [ + vn + for vn in self._requested_view_count.keys() + if vn not in self.default_views + ] + ) # Remove duplicates in view names. view_names = _remove_duplicates(view_names) # We add the view in the order they appear in the default views. @@ -697,7 +782,9 @@ def create_views(self): for i in range(n_views): self.create_and_add_view(view_name) - def add_view(self, view, position=None, closable=True, floatable=True, floating=None): + def add_view( + self, view, position=None, closable=True, floatable=True, floating=None + ): """Add a dock widget to the main window. Parameters @@ -715,7 +802,7 @@ def add_view(self, view, position=None, closable=True, floatable=True, floating= """ - logger.debug("Add view %s to GUI.", view.__class__.__name__) + logger.debug('Add view %s to GUI.', view.__class__.__name__) name = self._set_view_name(view) self._views.append(view) @@ -739,7 +826,7 @@ def on_close_dock_widget(sender): emit('close_view', view, self) dock.show() - logger.log(5, "Add %s to GUI.", name) + logger.log(5, 'Add %s to GUI.', name) return dock # Menu bar @@ -752,7 +839,9 @@ def get_menu(self, name, insert_before=None): if not insert_before: self.menuBar().addMenu(menu) else: - self.menuBar().insertMenu(self.get_menu(insert_before).menuAction(), menu) + self.menuBar().insertMenu( + self.get_menu(insert_before).menuAction(), menu + ) self._menus[name] = menu return self._menus[name] @@ -823,6 +912,6 @@ def restore_geometry_state(self, gs): if not gs: return if gs.get('geometry', None): - self.restoreGeometry((gs['geometry'])) + self.restoreGeometry(gs['geometry']) if gs.get('state', None): - self.restoreState((gs['state'])) + self.restoreState(gs['state']) diff --git a/phy/gui/qt.py b/phy/gui/qt.py index c189335b3..68ea71864 100644 --- a/phy/gui/qt.py +++ b/phy/gui/qt.py @@ -1,23 +1,19 @@ -# -*- coding: utf-8 -*- - """Qt utilities.""" # ----------------------------------------------------------------------------- # Imports # ----------------------------------------------------------------------------- -from contextlib import contextmanager -from datetime import datetime -from functools import wraps, partial import logging import os import os.path as op -from pathlib import Path -import shutil import sys -import tempfile -from timeit import default_timer import traceback +from contextlib import contextmanager +from datetime import datetime +from functools import partial, wraps +from pathlib import Path +from timeit import default_timer logger = logging.getLogger(__name__) @@ -30,33 +26,94 @@ # https://riverbankcomputing.com/pipermail/pyqt/2014-January/033681.html from OpenGL import GL # noqa -from PyQt5.QtCore import (Qt, QByteArray, QMetaObject, QObject, # noqa - QVariant, QEventLoop, QTimer, QPoint, QTimer, - QThreadPool, QRunnable, - pyqtSignal, pyqtSlot, QSize, QUrl, - QEvent, QCoreApplication, - qInstallMessageHandler, - ) +from PyQt5.QtCore import ( + Qt, + QByteArray, + QMetaObject, + QAbstractTableModel, + QObject, # noqa + QVariant, + QEventLoop, + QPoint, + QTimer, + QThreadPool, + QRunnable, + pyqtSignal, + pyqtSlot, + QSize, + QEvent, + QCoreApplication, + QModelIndex, + QItemSelectionModel, + QSortFilterProxyModel, + qInstallMessageHandler, +) from PyQt5.QtGui import ( # noqa - QKeySequence, QIcon, QColor, QMouseEvent, QGuiApplication, - QFontDatabase, QWindow, QOpenGLWindow) -from PyQt5.QtWebEngineWidgets import (QWebEngineView, # noqa - QWebEnginePage, - # QWebSettings, - ) -from PyQt5.QtWebChannel import QWebChannel # noqa -from PyQt5.QtWidgets import (# noqa - QAction, QStatusBar, QMainWindow, QDockWidget, QToolBar, - QWidget, QHBoxLayout, QVBoxLayout, QGridLayout, QScrollArea, - QPushButton, QLabel, QCheckBox, QPlainTextEdit, - QLineEdit, QSlider, QSpinBox, QDoubleSpinBox, - QMessageBox, QApplication, QMenu, QMenuBar, - QInputDialog, QOpenGLWidget) + QBrush, + QKeySequence, + QIcon, + QColor, + QPalette, + QMouseEvent, + QGuiApplication, + QFontDatabase, + QWindow, + QOpenGLWindow as _QOpenGLWindow, +) +from PyQt5.QtWidgets import ( # noqa + QAction, + QAbstractItemView, + QHeaderView, + QStatusBar, + QMainWindow, + QDockWidget, + QToolBar, + QWidget, + QHBoxLayout, + QVBoxLayout, + QGridLayout, + QScrollArea, + QPushButton, + QLabel, + QCheckBox, + QPlainTextEdit, + QLineEdit, + QSlider, + QSpinBox, + QStyledItemDelegate, + QStyleOptionViewItem, + QDoubleSpinBox, + QMessageBox, + QApplication, + QMenu, + QMenuBar, + QInputDialog, + QOpenGLWidget, + QStyle, + QTableView, +) + + +if os.environ.get('QT_QPA_PLATFORM') == 'offscreen': + class QOpenGLWindow(QWidget): + """Use a lightweight QWidget-backed compatibility surface in headless mode. + + Qt's offscreen platform plugin can segfault when native OpenGL surfaces try to create + or use a context on macOS. Headless tests only need widget/event semantics, so the + compatibility canvas intentionally avoids calling `initializeGL()` and `paintGL()`. + """ + + def grabFramebuffer(self): + return self.grab() + + +else: + QOpenGLWindow = _QOpenGLWindow # Enable high DPI support. # BUG: uncommenting this create scaling bugs on high DPI screens # on Ubuntu. -#QApplication.setAttribute(Qt.AA_EnableHighDpiScaling) +# QApplication.setAttribute(Qt.AA_EnableHighDpiScaling) # ----------------------------------------------------------------------------- @@ -68,11 +125,13 @@ def mockable(f): """Wrap interactive Qt functions that should be mockable in the testing suite.""" + @wraps(f) def wrapped(*args, **kwargs): if _MOCK is not None: return _MOCK return f(*args, **kwargs) + return wrapped @@ -115,12 +174,14 @@ def require_qt(func): the case. """ + @wraps(func) def wrapped(*args, **kwargs): if not QApplication.instance(): # pragma: no cover - logger.warning("Creating a Qt application.") + logger.warning('Creating a Qt application.') create_app() return func(*args, **kwargs) + return wrapped @@ -135,6 +196,7 @@ def run_app(): # pragma: no cover # Internal utility functions # ----------------------------------------------------------------------------- + @mockable def _button_enum_from_name(name): return getattr(QMessageBox, name.capitalize()) @@ -174,26 +236,28 @@ def _block(until_true, timeout=None): while not until_true() and (default_timer() - t0 < timeout): app = QApplication.instance() - app.processEvents(QEventLoop.AllEvents, - int(timeout * 1000)) + app.processEvents(QEventLoop.AllEvents, int(timeout * 1000)) if not until_true(): - logger.error("Timeout in _block().") + logger.error('Timeout in _block().') # NOTE: make sure we remove any busy cursor. app.restoreOverrideCursor() app.restoreOverrideCursor() - raise RuntimeError("Timeout in _block().") + raise RuntimeError('Timeout in _block().') def _wait(ms): """Wait for a given number of milliseconds, without blocking the GUI.""" from PyQt5 import QtTest + QtTest.QTest.qWait(ms) def _debug_trace(): # pragma: no cover """Set a tracepoint in the Python debugger that works with Qt.""" - from PyQt5.QtCore import pyqtRemoveInputHook from pdb import set_trace + + from PyQt5.QtCore import pyqtRemoveInputHook + pyqtRemoveInputHook() set_trace() @@ -217,6 +281,7 @@ def _load_font(name, size=8): # Public functions # ----------------------------------------------------------------------------- + @mockable def prompt(message, buttons=('yes', 'no'), title='Question'): """Display a dialog with several buttons to confirm or cancel an action. @@ -235,7 +300,7 @@ def prompt(message, buttons=('yes', 'no'), title='Question'): """ buttons = [(button, _button_enum_from_name(button)) for button in buttons] arg_buttons = 0 - for (_, button) in buttons: + for _, button in buttons: arg_buttons |= button box = QMessageBox() box.setWindowTitle(title) @@ -262,6 +327,7 @@ def message_box(message, title='Message', level=None): # pragma: no cover class QtDialogLogger(logging.Handler): """Display a message box for all errors.""" + def emit(self, record): # pragma: no cover msg = self.format(record) message_box(msg, title='An error has occurred', level='critical') @@ -315,8 +381,9 @@ def busy_cursor(activate=True): def screenshot_default_path(widget, dir=None): """Return a default path for the screenshot of a widget.""" from phylib.utils._misc import phy_config_dir + date = datetime.now().strftime('%Y%m%d%H%M%S') - name = 'phy_screenshot_%s_%s.png' % (date, widget.__class__.__name__) + name = f'phy_screenshot_{date}_{widget.__class__.__name__}.png' path = (Path(dir) if dir else phy_config_dir() / 'screenshots') / name path.parent.mkdir(exist_ok=True, parents=True) return path @@ -337,14 +404,16 @@ def screenshot(widget, path=None, dir=None): """ path = path or screenshot_default_path(widget, dir=dir) - path = Path(path).resolve() + path = Path(path).expanduser() + if not path.is_absolute(): + path = Path.cwd() / path if isinstance(widget, QOpenGLWindow): # Special call for OpenGL widgets. widget.grabFramebuffer().save(str(path)) else: # Generic call for regular Qt widgets. widget.grab().save(str(path)) - logger.info("Saved screenshot to %s.", path) + logger.info('Saved screenshot to %s.', path) return path @@ -374,37 +443,46 @@ def _get_icon(icon, size=64, color='black'): # from https://github.com/Pythonity/icon-font-to-png/blob/master/icon_font_to_png/icon_font.py static_dir = op.join(op.dirname(op.abspath(__file__)), 'static/icons/') ttf_file = op.abspath(op.join(static_dir, '../fa-solid-900.ttf')) - output_path = op.join(static_dir, icon + '.png') + output_path = op.join(static_dir, f'{icon}.png') if not op.exists(output_path): # pragma: no cover # Ideally, this should only run on the developer's machine. - logger.debug("Saving icon `%s` using the PIL library.", output_path) + logger.debug('Saving icon `%s` using the PIL library.', output_path) from PIL import Image, ImageDraw, ImageFont + org_size = size size = max(150, size) - image = Image.new("RGBA", (size, size), color=(0, 0, 0, 0)) + image = Image.new('RGBA', (size, size), color=(0, 0, 0, 0)) draw = ImageDraw.Draw(image) font = ImageFont.truetype(ttf_file, int(size)) width, height = draw.textsize(hex_icon, font=font) draw.text( - (float(size - width) / 2, float(size - height) / 2), hex_icon, font=font, fill=color) + (float(size - width) / 2, float(size - height) / 2), + hex_icon, + font=font, + fill=color, + ) # Get bounding box bbox = image.getbbox() # Create an alpha mask - image_mask = Image.new("L", (size, size), 0) + image_mask = Image.new('L', (size, size), 0) draw_mask = ImageDraw.Draw(image_mask) # Draw the icon on the mask draw_mask.text( - (float(size - width) / 2, float(size - height) / 2), hex_icon, font=font, fill=255) + (float(size - width) / 2, float(size - height) / 2), + hex_icon, + font=font, + fill=255, + ) # Create a solid color image and apply the mask - icon_image = Image.new("RGBA", (size, size), color) + icon_image = Image.new('RGBA', (size, size), color) icon_image.putalpha(image_mask) if bbox: @@ -414,7 +492,7 @@ def _get_icon(icon, size=64, color='black'): border_h = int((size - (bbox[3] - bbox[1])) / 2) # Create output image - out_image = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + out_image = Image.new('RGBA', (size, size), (0, 0, 0, 0)) out_image.paste(icon_image, (border_w, border_h)) # If necessary, scale the image to the target size @@ -433,72 +511,20 @@ def _get_icon(icon, size=64, color='black'): # Widgets # ----------------------------------------------------------------------------- + def _static_abs_path(rel_path): """Return the absolute path of a static file saved in this repository.""" return Path(__file__).parent / 'static' / rel_path -class WebPage(QWebEnginePage): - """A Qt web page widget.""" - _raise_on_javascript_error = False - - def javaScriptConsoleMessage(self, level, msg, line, source): - super(WebPage, self).javaScriptConsoleMessage(level, msg, line, source) - msg = "[JS:L%02d] %s" % (line, msg) - f = (partial(logger.log, 5), logger.warning, logger.error)[level] - if self._raise_on_javascript_error and level >= 2: - raise RuntimeError(msg) - f(msg) - - -class WebView(QWebEngineView): - """A generic HTML widget.""" - - def __init__(self, *args): - super(WebView, self).__init__(*args) - self.html = None - assert isinstance(self.window(), QWidget) - self._page = WebPage(self) - self.setPage(self._page) - self.move(100, 100) - self.resize(400, 400) - - def set_html(self, html, callback=None): - """Set the HTML code.""" - self._callback = callback - self.loadFinished.connect(self._loadFinished) - static_dir = str(Path(__file__).parent / 'static') + '/' - - # Create local file from HTML - self.clear_temporary_files() - self._tempdir = Path(tempfile.mkdtemp()) - shutil.copytree(static_dir, self._tempdir / 'html') - file_path = self._tempdir / 'html' / 'page.html' - with open(file_path, 'w') as f: - f.write(html) - file_url = QUrl().fromLocalFile(str(file_path)) - self.page().setUrl(file_url) - - def clear_temporary_files(self): - """Delete the temporary HTML files""" - if hasattr(self, '_tempdir') and self._tempdir.is_dir(): - shutil.rmtree(self._tempdir, ignore_errors=True) - - def _callable(self, data): - self.html = data - if self._callback: - self._callback(self.html) - - def _loadFinished(self, result): - self.page().toHtml(self._callable) - - # ----------------------------------------------------------------------------- # Threading # ----------------------------------------------------------------------------- + class WorkerSignals(QObject): """Object holding some signals for the workers.""" + finished = pyqtSignal() error = pyqtSignal(tuple) result = pyqtSignal(object) @@ -530,8 +556,9 @@ class Worker(QRunnable): **kwargs : function keyword arguments """ + def __init__(self, fn, *args, **kwargs): - super(Worker, self).__init__() + super().__init__() self.fn = fn self.args = args self.kwargs = kwargs @@ -555,7 +582,7 @@ def run(self): # pragma: no cover self.signals.finished.emit() -class Debouncer(object): +class Debouncer: """Debouncer to work in a Qt application. Jobs are submitted at given times. They are executed immediately if the @@ -592,7 +619,9 @@ class Debouncer(object): def __init__(self, delay=None): self.delay = delay or self.delay # minimum delay between job executions, in ms. self._last_submission_time = 0 - self.is_waiting = False # whether we're already waiting for the end of the interactions + self.is_waiting = ( + False # whether we're already waiting for the end of the interactions + ) self.pending_functions = {} # assign keys to pending functions. self._timer = QTimer() self._timer.timeout.connect(self._timer_callback) @@ -600,12 +629,12 @@ def __init__(self, delay=None): def _elapsed_enough(self): """Return whether the elapsed time since the last submission is greater than the threshold.""" - return default_timer() - self._last_submission_time > self.delay * .001 + return default_timer() - self._last_submission_time > self.delay * 0.001 def _timer_callback(self): """Callback for the timer.""" if self._elapsed_enough(): - logger.log(self._log_level, "Stop waiting and triggering.") + logger.log(self._log_level, 'Stop waiting and triggering.') self._timer.stop() self.trigger() @@ -614,12 +643,12 @@ def submit(self, f, *args, key=None, **kwargs): is higher than the threshold, or wait until executing it otherwiser.""" self.pending_functions[key] = (f, args, kwargs) if self._elapsed_enough(): - logger.log(self._log_level, "Triggering action immediately.") + logger.log(self._log_level, 'Triggering action immediately.') # Trigger the action immediately if the delay since the last submission is greater # than the threshold. self.trigger() else: - logger.log(self._log_level, "Waiting...") + logger.log(self._log_level, 'Waiting...') # Otherwise, we start the timer. if not self._timer.isActive(): self._timer.start(25) @@ -631,18 +660,19 @@ def trigger(self): if item is None: continue f, args, kwargs = item - logger.log(self._log_level, "Trigger %s.", f.__name__) + logger.log(self._log_level, 'Trigger %s.', f.__name__) f(*args, **kwargs) self.pending_functions[key] = None - def stop_waiting(self, delay=.1): + def stop_waiting(self, delay=0.1): """Stop waiting and force the pending actions to execute (almost) immediately.""" # The trigger will occur in `delay` seconds. - self._last_submission_time = default_timer() - (self.delay * .001 - delay) + self._last_submission_time = default_timer() - (self.delay * 0.001 - delay) -class AsyncCaller(object): +class AsyncCaller: """Call a Python function after a delay.""" + def __init__(self, delay=10): self._delay = delay self._timer = None diff --git a/phy/gui/state.py b/phy/gui/state.py index 832262fce..b488ca867 100644 --- a/phy/gui/state.py +++ b/phy/gui/state.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - """Qt dock window.""" @@ -10,15 +8,16 @@ try: # pragma: no cover from collections.abc import Mapping # noqa except ImportError: # pragma: no cover - from collections import Mapping # noqa -from copy import deepcopy + from collections.abc import Mapping # noqa import inspect import json import logging -from pathlib import Path import shutil +from copy import deepcopy +from pathlib import Path from phylib.utils import Bunch, _bunchify, load_json, save_json + from phy.utils import ensure_dir_exists, phy_config_dir logger = logging.getLogger(__name__) @@ -28,6 +27,7 @@ # GUI state # ----------------------------------------------------------------------------- + def _get_default_state_path(gui): """Return the path to the default state.json for a given GUI.""" gui_path = Path(inspect.getfile(gui.__class__)) @@ -43,10 +43,10 @@ def _gui_state_path(gui_name, config_dir=None): def _load_state(path): """Load a GUI state from a JSON file.""" try: - logger.debug("Load %s for GUIState.", path) + logger.debug('Load %s for GUIState.', path) data = load_json(str(path)) except json.decoder.JSONDecodeError as e: # pragma: no cover - logger.warning("Error decoding JSON: %s", e) + logger.warning('Error decoding JSON: %s', e) data = {} return _bunchify(data) @@ -57,7 +57,8 @@ def _filter_nested_dict(value, key=None, search_terms=None): # key is None for the root only. # Expression used to test whether we keep a key or not. keep = lambda k: k is None or ( - (not search_terms or k in search_terms) and not k.startswith('_')) + (not search_terms or k in search_terms) and not k.startswith('_') + ) # Process leaves. if not isinstance(value, Mapping): return value if keep(key) else None @@ -133,9 +134,11 @@ class GUIState(Bunch): in the local state, and not the global state. """ + def __init__( - self, path=None, local_path=None, default_state_path=None, local_keys=None, **kwargs): - super(GUIState, self).__init__(**kwargs) + self, path=None, local_path=None, default_state_path=None, local_keys=None, **kwargs + ): + super().__init__(**kwargs) self._path = Path(path) if path else None if self._path: ensure_dir_exists(str(self._path.parent)) @@ -168,19 +171,21 @@ def update_view_state(self, view, state): if name not in self: self[name] = Bunch() self[name].update(state) - logger.debug("Update GUI state for %s", name) + logger.debug('Update GUI state for %s', name) def _copy_default_state(self): """Copy the default GUI state to the user directory.""" if self._default_state_path and self._default_state_path.exists(): logger.debug( - "The GUI state file `%s` doesn't exist, creating a default one...", self._path) + "The GUI state file `%s` doesn't exist, creating a default one...", self._path + ) shutil.copy(self._default_state_path, self._path) - logger.info("Copied %s to %s.", self._default_state_path, self._path) + logger.info('Copied %s to %s.', self._default_state_path, self._path) elif self._default_state_path: # pragma: no cover logger.warning( - "Could not copy non-existing default state file %s.", self._default_state_path) + 'Could not copy non-existing default state file %s.', self._default_state_path + ) def add_local_keys(self, keys): """Add local keys.""" @@ -215,7 +220,7 @@ def _local_data(self): def _save_global(self): """Save the entire GUIState to the global file.""" path = self._path - logger.debug("Save global GUI state to `%s`.", path) + logger.debug('Save global GUI state to `%s`.', path) save_json(str(path), self._global_data) def _save_local(self): @@ -229,7 +234,7 @@ def _save_local(self): return assert self._local_path - logger.debug("Save local GUI state to `%s`.", path) + logger.debug('Save local GUI state to `%s`.', path) save_json(str(path), self._local_data) def save(self): diff --git a/phy/gui/tests/__init__.py b/phy/gui/tests/__init__.py index e69de29bb..6613090c9 100644 --- a/phy/gui/tests/__init__.py +++ b/phy/gui/tests/__init__.py @@ -0,0 +1,3 @@ +def show_and_wait(qtbot, widget): + with qtbot.waitExposed(widget): + widget.show() diff --git a/phy/gui/tests/conftest.py b/phy/gui/tests/conftest.py index f2d836bc4..6acf49758 100644 --- a/phy/gui/tests/conftest.py +++ b/phy/gui/tests/conftest.py @@ -1,28 +1,26 @@ -# -*- coding: utf-8 -*- - """Test gui.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ from pytest import fixture +from . import show_and_wait from ..actions import Actions, Snippets from ..gui import GUI - -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Utilities and fixtures -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + @fixture def gui(tempdir, qtbot): gui = GUI(position=(200, 100), size=(100, 100), config_dir=tempdir) gui.set_default_actions() - gui.show() qtbot.addWidget(gui) - qtbot.waitForWindowShown(gui) + show_and_wait(qtbot, gui) yield gui gui.close() del gui diff --git a/phy/gui/tests/test_actions.py b/phy/gui/tests/test_actions.py index c49724239..2880c00a5 100644 --- a/phy/gui/tests/test_actions.py +++ b/phy/gui/tests/test_actions.py @@ -1,25 +1,30 @@ -# -*- coding: utf-8 -*- - """Test dock.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ from functools import partial +from phylib.utils.testing import captured_logging, captured_output from pytest import raises +from . import show_and_wait from ..actions import ( - _show_shortcuts, _show_snippets, _get_shortcut_string, _get_qkeysequence, _parse_snippet, - _expected_args, Actions) -from phylib.utils.testing import captured_output, captured_logging + Actions, + _expected_args, + _get_qkeysequence, + _get_shortcut_string, + _parse_snippet, + _show_shortcuts, + _show_snippets, +) from ..qt import mock_dialogs - -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Test actions -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def test_expected_args(): assert _expected_args(lambda: 0) == () @@ -79,7 +84,6 @@ def test_actions_default_shortcuts(gui): def test_actions_simple(actions): - _res = [] def _action(*args): @@ -113,21 +117,21 @@ def show_my_shortcuts(): assert '%s' % text - - view.set_html('hello', _assert) - qtbot.addWidget(view) - view.show() - qtbot.waitForWindowShown(view) - _block(lambda: _assert('hello')) - - view.set_html("world") - _block(lambda: _assert('world')) - view.close() - - -def test_javascript_1(qtbot): - view = WebView() - with captured_logging() as buf: - view.set_html('') - qtbot.addWidget(view) - view.show() - qtbot.waitForWindowShown(view) - _block(lambda: view.html is not None) - view.close() - assert buf.getvalue() == "" - - -def test_javascript_2(qtbot): - view = WebView() - view._page._raise_on_javascript_error = True - with qtbot.capture_exceptions() as exceptions: - view.set_html('') - qtbot.addWidget(view) - view.show() - qtbot.waitForWindowShown(view) - _block(lambda: view.html is not None) - view.close() - assert len(exceptions) >= 1 - - def test_screenshot(qtbot, tempdir): - path = tempdir / 'capture.png' - view = WebView() + view = QWidget() assert str(screenshot_default_path(view, dir=tempdir)).startswith(str(tempdir)) - view.set_html('hello', lambda e: screenshot(view, path)) qtbot.addWidget(view) - view.show() - qtbot.waitForWindowShown(view) + show_and_wait(qtbot, view) + screenshot(view, path) _block(lambda: path.exists()) view.close() def test_prompt(qtbot): - assert _button_name_from_enum(QMessageBox.Save) == 'save' assert _button_enum_from_name('save') == QMessageBox.Save - box = prompt("How are you doing?", buttons=['save', 'cancel', 'close']) + box = prompt('How are you doing?', buttons=['save', 'cancel', 'close']) qtbot.mouseClick(box.buttons()[0], Qt.LeftButton) assert 'save' in str(box.clickedButton().text()).lower() diff --git a/phy/gui/tests/test_state.py b/phy/gui/tests/test_state.py index 298984ccd..5fba89abe 100644 --- a/phy/gui/tests/test_state.py +++ b/phy/gui/tests/test_state.py @@ -1,39 +1,40 @@ -# -*- coding: utf-8 -*- - """Test gui.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ import logging import os import shutil -from ..state import GUIState, _gui_state_path, _get_default_state_path from phylib.utils import Bunch, load_json, save_json +from ..state import GUIState, _get_default_state_path, _gui_state_path + logger = logging.getLogger(__name__) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Test GUI state -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + -class MyClass(object): +class MyClass: pass def test_get_default_state_path(): assert str(_get_default_state_path(MyClass())).endswith( - os.sep.join(('gui', 'tests', 'static', 'state.json'))) + os.sep.join(('gui', 'tests', 'static', 'state.json')) + ) def test_gui_state_view_1(tempdir): view = Bunch(name='MyView0') path = _gui_state_path('GUI', tempdir) state = GUIState(path) - state.update_view_state(view, dict(hello='world')) + state.update_view_state(view, {'hello': 'world'}) assert not state.get_view_state(Bunch(name='MyView')) assert not state.get_view_state(Bunch(name='MyView (1)')) assert state.get_view_state(view) == Bunch(hello='world') @@ -44,7 +45,7 @@ def test_gui_state_view_1(tempdir): shutil.copy(state._path, default_path) state._path.unlink() - logger.info("Create new GUI state.") + logger.info('Create new GUI state.') # The default state.json should be automatically copied and loaded. state = GUIState(path, default_state_path=default_path) assert state.MyView0.hello == 'world' diff --git a/phy/gui/tests/test_widgets.py b/phy/gui/tests/test_widgets.py index 4ce0eae3f..6183e0994 100644 --- a/phy/gui/tests/test_widgets.py +++ b/phy/gui/tests/test_widgets.py @@ -1,25 +1,22 @@ -# -*- coding: utf-8 -*- - """Test widgets.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ from functools import partial -from pathlib import Path -from pytest import fixture, mark from phylib.utils import connect, unconnect -from phylib.utils.testing import captured_logging -import phy -from .test_qt import _block -from ..widgets import HTMLWidget, Table, Barrier, IPythonView, KeyValueWidget +from pytest import fixture, mark +from . import show_and_wait +from ..widgets import Barrier, IPythonView, KeyValueWidget, Table +from .test_qt import _block -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Fixtures -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def _assert(f, expected): _out = [] @@ -31,24 +28,24 @@ def _wait_until_table_ready(qtbot, table): b = Barrier() connect(b(1), event='ready', sender=table) - table.show() qtbot.addWidget(table) - qtbot.waitForWindowShown(table) + show_and_wait(qtbot, table) b.wait() @fixture def table(qtbot): - columns = ["id", "count"] - data = [{"id": i, - "count": 100 - 10 * i, - "float": float(i), - "is_masked": True if i in (2, 3, 5) else False, - } for i in range(10)] - table = Table( - columns=columns, - value_names=['id', 'count', {'data': ['is_masked']}], - data=data) + columns = ['id', 'count'] + data = [ + { + 'id': i, + 'count': 100 - 10 * i, + 'float': float(i), + 'is_masked': i in (2, 3, 5), + } + for i in range(10) + ] + table = Table(columns=columns, value_names=['id', 'count', {'data': ['is_masked']}], data=data) _wait_until_table_ready(qtbot, table) yield table @@ -56,148 +53,60 @@ def table(qtbot): table.close() -#------------------------------------------------------------------------------ -# Test widgets -#------------------------------------------------------------------------------ - -def test_widget_empty(qtbot): - widget = HTMLWidget() - widget.build() - widget.show() - qtbot.addWidget(widget) - qtbot.waitForWindowShown(widget) - widget.close() - - -def test_widget_html(qtbot): - widget = HTMLWidget() - widget.builder.add_style('html, body, p {background-color: purple;}') - path = Path(__file__).parent.parent / 'static/styles.css' - widget.builder.add_style_src(path) - widget.builder.add_header('') - widget.builder.set_body('Hello world!') - widget.build() - widget.show() - qtbot.addWidget(widget) - qtbot.waitForWindowShown(widget) - _block(lambda: 'Hello world!' in str(widget.html)) - - _out = [] - - widget.view_source(lambda x: _out.append(x)) - _block(lambda: _out[0].startswith('') if _out else None) - - # qtbot.stop() - widget.close() - - -def test_widget_javascript_1(qtbot): - widget = HTMLWidget() - widget.builder.add_script('var number = 1;') - widget.build() - widget.show() - qtbot.addWidget(widget) - qtbot.waitForWindowShown(widget) - _block(lambda: widget.html is not None) - - _out = [] - - def _callback(res): - _out.append(res) - - widget.eval_js('number', _callback) - _block(lambda: _out == [1]) - - # Test logging from JS. - with captured_logging('phy.gui') as buf: - widget.eval_js('console.warn("hello world!");') - _block(lambda: 'hello world!' in buf.getvalue().lower()) - - # qtbot.stop() - widget.close() - - -@mark.parametrize("event_name", ('select', 'nodebounce')) -def test_widget_javascript_debounce(qtbot, event_name): - phy.gui.qt.Debouncer.delay = 300 - - widget = HTMLWidget(debounce_events=('select',)) - widget.build() - widget.show() - qtbot.addWidget(widget) - qtbot.waitForWindowShown(widget) - _block(lambda: widget.html is not None) - - event_code = lambda i: r''' - var event = new CustomEvent("phy_event", {detail: {name: '%s', data: {'i': %s}}}); - document.dispatchEvent(event); - ''' % (event_name, i) - - _l = [] - - def f(sender, *args): - _l.append(args) - connect(f, sender=widget, event=event_name) - - for i in range(5): - widget.eval_js(event_code(i)) - qtbot.wait(10) - qtbot.wait(500) - - assert len(_l) == (2 if event_name == 'select' else 5) - - # qtbot.stop() - widget.close() - - phy.gui.qt.Debouncer.delay = 1 - - -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Test key value widget -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def test_key_value_1(qtbot): widget = KeyValueWidget() - widget.show() - qtbot.addWidget(widget) - qtbot.waitForWindowShown(widget) + show_and_wait(qtbot, widget) - widget.add_pair("my text", "some text") - widget.add_pair("my text multiline", "some\ntext", 'multiline') - widget.add_pair("my float", 3.5) - widget.add_pair("my int", 3) - widget.add_pair("my bool", True) - widget.add_pair("my list", [1, 5]) + widget.add_pair('my text', 'some text') + widget.add_pair('my text multiline', 'some\ntext', 'multiline') + widget.add_pair('my float', 3.5) + widget.add_pair('my int', 3) + widget.add_pair('my bool', True) + widget.add_pair('my list', [1, 5]) widget.get_widget('my bool').setChecked(False) widget.get_widget('my list[0]').setValue(2) assert widget.to_dict() == { - 'my text': 'some text', 'my text multiline': 'some\ntext', - 'my float': 3.5, 'my int': 3, 'my bool': False, 'my list': [2, 5]} + 'my text': 'some text', + 'my text multiline': 'some\ntext', + 'my float': 3.5, + 'my int': 3, + 'my bool': False, + 'my list': [2, 5], + } # qtbot.stop() widget.close() -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Test IPython view -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + -@mark.filterwarnings("ignore") +@mark.filterwarnings('ignore') def test_ipython_view_1(qtbot): view = IPythonView() view.show() view.start_kernel() + kernel = view.kernel view.stop() + assert not kernel.iopub_thread.thread.is_alive() qtbot.wait(10) view.close() -@mark.filterwarnings("ignore") +@mark.filterwarnings('ignore') def test_ipython_view_2(qtbot, tempdir): from ..gui import GUI + gui = GUI(config_dir=tempdir) gui.set_default_actions() @@ -213,9 +122,10 @@ def test_ipython_view_2(qtbot, tempdir): qtbot.wait(10) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Test table -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def test_barrier_1(qtbot, table): table.select([1]) @@ -243,9 +153,8 @@ def test_table_empty_1(qtbot): def test_table_invalid_column(qtbot): table = Table(data=[{'id': 0, 'a': 'b'}], columns=['id', 'u']) - table.show() qtbot.addWidget(table) - qtbot.waitForWindowShown(table) + show_and_wait(qtbot, table) table.close() @@ -254,7 +163,6 @@ def test_table_0(qtbot, table): def test_table_1(qtbot, table): - assert table.is_ready() table.select([1, 2]) @@ -373,7 +281,7 @@ def test_table_remove_all_and_add_1(qtbot, table): def test_table_remove_all_and_add_2(qtbot, table): - table.remove_all_and_add({"id": 1000}) + table.remove_all_and_add({'id': 1000}) _assert(table.get_ids, [1000]) @@ -406,11 +314,98 @@ def test_table_change_and_sort_2(qtbot, table): def test_table_filter(qtbot, table): - table.filter("id == 5") + table.filter('id == 5') _assert(table.get_ids, [5]) - table.filter("count == 80") + table.filter('count == 80') _assert(table.get_ids, [2]) table.filter() _assert(table.get_ids, list(range(10))) + + +def test_table_filter_comparison_operators(qtbot, table): + table.filter('count > 80') + _assert(table.get_ids, [0, 1]) + + table.filter('count >= 80') + _assert(table.get_ids, [0, 1, 2]) + + table.filter('count < 80') + _assert(table.get_ids, list(range(3, 10))) + + table.filter('count <= 80') + _assert(table.get_ids, list(range(2, 10))) + + table.filter('id != 5') + _assert(table.get_ids, [i for i in range(10) if i != 5]) + + +def test_table_filter_combined_expression(qtbot, table): + table.filter('(count >= 50) && id != 3') + _assert(table.get_ids, [0, 1, 2, 4, 5]) + + +def test_table_filter_or_expression(qtbot, table): + table.filter('(id == 1) || (id == 3)') + _assert(table.get_ids, [1, 3]) + + +def test_table_filter_operator_precedence(qtbot, table): + table.filter('(id == 1 || id == 3) && count < 90') + _assert(table.get_ids, [3]) + + +def test_table_filter_invalid_expression_shows_all_rows(qtbot, table): + table.filter('id ===') + _assert(table.get_ids, list(range(10))) + + +def test_table_filter_missing_field_shows_all_rows(qtbot, table): + table.filter('missing == 1') + _assert(table.get_ids, list(range(10))) + + +def test_table_filter_string_and_null_values(qtbot): + data = [ + {'id': 0, 'group': 'good', 'label': None}, + {'id': 1, 'group': 'mua', 'label': 'x'}, + {'id': 2, 'group': 'noise', 'label': None}, + ] + table = Table( + columns=['id', 'label'], + value_names=['id', 'label', {'data': ['group']}], + data=data, + ) + _wait_until_table_ready(qtbot, table) + + table.filter("group == 'good'") + _assert(table.get_ids, [0]) + + table.filter("group != 'noise'") + _assert(table.get_ids, [0, 1]) + + table.filter("label == 'x'") + _assert(table.get_ids, [1]) + + table.filter('label == null') + _assert(table.get_ids, [0, 2]) + + table.filter('label != null') + _assert(table.get_ids, [1]) + + table.close() + + +def test_table_filter_event_emits_visible_ids(qtbot, table): + emitted = [] + + @connect(sender=table) + def on_table_filter(sender, row_ids): + emitted.append(row_ids) + + table.filter('count >= 80') + _assert(table.get_ids, [0, 1, 2]) + _block(lambda: emitted == [[0, 1, 2]]) + + unconnect(on_table_filter) diff --git a/phy/gui/widgets.py b/phy/gui/widgets.py index 8a3131115..5957b06f2 100644 --- a/phy/gui/widgets.py +++ b/phy/gui/widgets.py @@ -1,68 +1,129 @@ -# -*- coding: utf-8 -*- - -"""HTML widgets for GUIs.""" +"""Qt widgets for GUIs.""" # ----------------------------------------------------------------------------- # Imports # ----------------------------------------------------------------------------- +import inspect import json import logging +import ast +import re from functools import partial -from qtconsole.rich_jupyter_widget import RichJupyterWidget +from phylib.utils import connect, emit +from phylib.utils._misc import _CustomEncoder, _pretty_floats +from phylib.utils._types import _is_integer from qtconsole.inprocess import QtInProcessKernelManager +from qtconsole.rich_jupyter_widget import RichJupyterWidget + +from phy.utils.color import _is_bright, colormaps from .qt import ( - WebView, QObject, QWebChannel, QWidget, QGridLayout, QPlainTextEdit, - QLabel, QLineEdit, QCheckBox, QSpinBox, QDoubleSpinBox, - pyqtSlot, _static_abs_path, _block, Debouncer) -from phylib.utils import emit, connect -from phy.utils.color import colormaps, _is_bright -from phylib.utils._misc import _CustomEncoder, read_text, _pretty_floats -from phylib.utils._types import _is_integer + QApplication, + QBrush, + Debouncer, + QAbstractItemView, + QAbstractTableModel, + QCheckBox, + QDoubleSpinBox, + QEvent, + QGridLayout, + QHeaderView, + QItemSelectionModel, + QLabel, + QLineEdit, + QModelIndex, + QPalette, + QPlainTextEdit, + QSortFilterProxyModel, + QSize, + QSpinBox, + QStyle, + QStyledItemDelegate, + QStyleOptionViewItem, + QTableView, + Qt, + QTimer, + QVBoxLayout, + QColor, + QWidget, + _block, +) logger = logging.getLogger(__name__) +_NO_VALUE = object() # ----------------------------------------------------------------------------- # IPython widget # ----------------------------------------------------------------------------- + +def _ensure_async_ipykernel_methods(kernel): + """Adapt synchronous in-process kernel handlers to the newer awaitable API.""" + if kernel is None: # pragma: no cover + return + + do_history = getattr(kernel, 'do_history', None) + if do_history is not None and not inspect.iscoroutinefunction(do_history): + + async def do_history_async(*args, **kwargs): + shell = getattr(kernel, 'shell', None) + if getattr(shell, 'history_manager', None) is None: + return {'status': 'ok', 'history': []} + return do_history(*args, **kwargs) + + kernel.do_history = do_history_async + + class IPythonView(RichJupyterWidget): """A view with an IPython console living in the same Python process as the GUI.""" def __init__(self, *args, **kwargs): - super(IPythonView, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) + self.kernel_manager = None + self.kernel_client = None + self.kernel = None + self.shell = None def start_kernel(self): """Start the IPython kernel.""" - logger.debug("Starting the kernel.") + logger.debug('Starting the kernel.') self.kernel_manager = QtInProcessKernelManager() self.kernel_manager.start_kernel(show_banner=False) self.kernel_manager.kernel.gui = 'qt' self.kernel = self.kernel_manager.kernel + _ensure_async_ipykernel_methods(self.kernel) self.shell = self.kernel.shell + # This embedded shell should not persist readline history during tests or GUI teardown. + history_manager = getattr(self.shell, 'history_manager', None) + if history_manager is not None: + history_manager.enabled = False try: self.kernel_client = self.kernel_manager.client() self.kernel_client.start_channels() except Exception as e: # pragma: no cover - logger.error("Could not start IPython kernel: %s.", str(e)) + logger.error('Could not start IPython kernel: %s.', str(e)) self.set_default_style('linux') self.exit_requested.connect(self.stop) def inject(self, **kwargs): """Inject variables into the IPython namespace.""" - logger.debug("Injecting variables into the kernel: %s.", ', '.join(kwargs.keys())) + logger.debug( + 'Injecting variables into the kernel: %s.', ', '.join(kwargs.keys()) + ) try: self.kernel.shell.push(kwargs) except Exception as e: # pragma: no cover - logger.error("Could not inject variables to the IPython kernel: %s.", str(e)) + logger.error( + 'Could not inject variables to the IPython kernel: %s.', str(e) + ) def attach(self, gui, **kwargs): """Add the view to the GUI, start the kernel, and inject the specified variables.""" @@ -71,11 +132,13 @@ def attach(self, gui, **kwargs): self.inject(gui=gui, **kwargs) try: import numpy + self.inject(np=numpy) except ImportError: # pragma: no cover pass try: import matplotlib.pyplot as plt + self.inject(plt=plt) except ImportError: # pragma: no cover pass @@ -86,76 +149,57 @@ def on_close_view(view, gui): def stop(self): """Stop the kernel.""" - logger.debug("Stopping the kernel.") - try: - self.kernel_client.stop_channels() - self.kernel_manager.shutdown_kernel() - except Exception as e: # pragma: no cover - logger.error("Could not stop the IPython kernel: %s.", str(e)) - - -# ----------------------------------------------------------------------------- -# HTML widget -# ----------------------------------------------------------------------------- - -# Default CSS style of HTML widgets. -_DEFAULT_STYLE = """ - - * { - font-size: 8pt !important; - } - - html, body, table { - background-color: black; - color: white; - font-family: sans-serif; - font-size: 12pt; - margin: 2px 4px; - } - - input.filter { - width: 100% !important; - } - - table tr[data-is_masked='true'] { - color: #888; - } -""" - - -# Bind the JS events to Python. -_DEFAULT_SCRIPT = """ - document.addEventListener("DOMContentLoaded", function () { - new QWebChannel(qt.webChannelTransport, function (channel) { - var eventEmitter = channel.objects.eventEmitter; - window.eventEmitter = eventEmitter; - - // All phy_events emitted from JS are relayed to - // Python's emitJS(). - document.addEventListener("phy_event", function (e) { - console.debug("Emit from JS global: " + e.detail.name + " " + e.detail.data); - eventEmitter.emitJS(e.detail.name, JSON.stringify(e.detail.data)); - }); - - }); - }); -""" - - -# Default HTML template of the widgets. -_PAGE_TEMPLATE = """ - - - {title:s} - {header:s} - - - -{body:s} - - - -""" + logger.debug('Stopping the kernel.') + kernel = self.kernel + + if self.kernel_client is not None: + try: + self.kernel_client.stop_channels() + except Exception as e: # pragma: no cover + logger.error('Could not stop IPython kernel channels: %s.', str(e)) + self.kernel_client = None + + if kernel is not None: + shell = getattr(kernel, 'shell', None) + if shell is not None: + try: + shell._atexit_once() + except Exception as e: # pragma: no cover + logger.error('Could not finalize IPython shell cleanup: %s.', str(e)) + + for stream_name in ('stdout', 'stderr'): + stream = getattr(kernel, stream_name, None) + if stream is None: + continue + try: + stream.close() + except Exception as e: # pragma: no cover + logger.error( + 'Could not close IPython kernel %s stream: %s.', + stream_name, + str(e), + ) + + iopub_thread = getattr(kernel, 'iopub_thread', None) + if iopub_thread is not None and getattr(iopub_thread, 'thread', None): + try: + iopub_thread.stop() + except Exception as e: # pragma: no cover + logger.error('Could not stop IPython IOPub thread: %s.', str(e)) + + if self.kernel_manager is not None: + try: + self.kernel_manager.shutdown_kernel() + except Exception as e: # pragma: no cover + logger.error('Could not shut down IPython kernel: %s.', str(e)) + self.kernel_manager = None + + self.kernel = None + self.shell = None + + def closeEvent(self, event): + self.stop() + super().closeEvent(event) def _uniq(seq): @@ -165,7 +209,7 @@ def _uniq(seq): return [int(x) for x in seq if not (x in seen or seen_add(x))] -class Barrier(object): +class Barrier: """Implement a synchronization barrier.""" def __init__(self): @@ -199,146 +243,6 @@ def result(self, key): return self._results.get(key, None) -class HTMLBuilder(object): - """Build an HTML widget.""" - - def __init__(self, title=''): - self.title = title - self.headers = [] - self.body = '' - self.add_style(_DEFAULT_STYLE) - - def add_style(self, s): - """Add a CSS style.""" - self.add_header(''.format(s)) - - def add_style_src(self, filename): - """Add a link to a stylesheet URL.""" - self.add_header(('').format(filename)) - - def add_script(self, s): - """Add Javascript code.""" - self.add_header(''.format(s)) - - def add_script_src(self, filename): - """Add a link to a Javascript file.""" - self.add_header(''.format(filename)) - - def add_header(self, s): - """Add HTML headers.""" - self.headers.append(s) - - def set_body_src(self, filename): - """Set the path to an HTML file containing the body of the widget.""" - path = _static_abs_path(filename) - self.set_body(read_text(path)) - - def set_body(self, body): - """Set the HTML body of the widget.""" - self.body = body - - def _build_html(self): - """Build the HTML page.""" - header = '\n'.join(self.headers) - html = _PAGE_TEMPLATE.format(title=self.title, header=header, body=self.body) - return html - - @property - def html(self): - """Return the reconstructed HTML code of the widget.""" - return self._build_html() - - -class JSEventEmitter(QObject): - """Object used to relay the Javascript events to Python. Some vents can be debounced so that - there is a minimal delay between two consecutive events of the same type.""" - _parent = None - - def __init__(self, *args, debounce_events=()): - super(JSEventEmitter, self).__init__(*args) - self._debouncer = Debouncer() - self._debounce_events = debounce_events - - @pyqtSlot(str, str) - def emitJS(self, name, arg_json): - logger.log(5, "Emit from Python %s %s.", name, arg_json) - args = str(name), self._parent, json.loads(str(arg_json)) - # NOTE: debounce some events but not other events coming from JS. - # This is typically used for select events of table widgets. - if name in self._debounce_events: - self._debouncer.submit(emit, *args) - else: - emit(*args) - - -class HTMLWidget(WebView): - """An HTML widget that is displayed with Qt, with Javascript support and Python-Javascript - interactions capabilities. These interactions are asynchronous in Qt5, which requires - extensive use of callback functions in Python, as well as synchronization primitives - for unit tests. - - Constructor - ------------ - - parent : Widget - title : window title - debounce_events : list-like - The list of event names, raised by the underlying HTML widget, that should be debounced. - - """ - def __init__(self, *args, title='', debounce_events=()): - # Due to a limitation of QWebChannel, need to register a Python object - # BEFORE this web view is created?! - self._event = JSEventEmitter(*args, debounce_events=debounce_events) - self._event._parent = self - self.channel = QWebChannel(*args) - self.channel.registerObject('eventEmitter', self._event) - - super(HTMLWidget, self).__init__(*args) - self.page().setWebChannel(self.channel) - - self.builder = HTMLBuilder(title=title) - self.builder.add_script_src('qrc:///qtwebchannel/qwebchannel.js') - self.builder.add_script(_DEFAULT_SCRIPT) - - @property - def debouncer(self): - """Widget debouncer.""" - return self._event._debouncer - - def build(self, callback=None): - """Rebuild the HTML code of the widget.""" - self.set_html(self.builder.html, callback=callback) - - def view_source(self, callback=None): - """View the HTML source of the widget.""" - return self.eval_js( - "document.getElementsByTagName('html')[0].innerHTML", callback=callback) - - # Javascript methods - # ------------------------------------------------------------------------- - - def eval_js(self, expr, callback=None): - """Evaluate a Javascript expression. - - Parameters - ---------- - - expr : str - A Javascript expression. - callback : function - A Python function that is called once the Javascript expression has been - evaluated. It takes as input the output of the Javascript expression. - - """ - logger.log(5, "%s eval JS %s", self.__class__.__name__, expr) - return self.page().runJavaScript(expr, callback or (lambda _: _)) - - -# ----------------------------------------------------------------------------- -# HTML table -# ----------------------------------------------------------------------------- - def dumps(o): """Dump a JSON object into a string, with pretty floats.""" return json.dumps(_pretty_floats(o), cls=_CustomEncoder) @@ -347,216 +251,801 @@ def dumps(o): def _color_styles(): """Use colormap colors in table widget.""" return '\n'.join( - ''' - #table .color-%d > td[class='id'] { - background-color: rgb(%d, %d, %d); - %s - } - ''' % (i, r, g, b, 'color: #000 !important;' if _is_bright((r, g, b)) else '') - for i, (r, g, b) in enumerate(colormaps.default * 255)) - - -class Table(HTMLWidget): - """A sortable table with support for selection. Derives from HTMLWidget. - - This table uses the following Javascript implementation: https://github.com/kwikteam/tablejs - This Javascript class builds upon ListJS: https://listjs.com/ - - """ + f""" + #table .color-{i} > td[class='id'] {{ + background-color: rgb({r}, {g}, {b}); + {'color: #000 !important;' if _is_bright((r, g, b)) else ''} + }} + """ + for i, (r, g, b) in enumerate(colormaps.default * 255) + ) + + +class _TableFilterValidator(ast.NodeVisitor): + """Validate the supported filter-expression subset.""" + + _allowed_compare_ops = ( + ast.Eq, + ast.NotEq, + ast.Lt, + ast.LtE, + ast.Gt, + ast.GtE, + ) + + def __init__(self, allowed_names): + self.allowed_names = set(allowed_names) + + def visit_Expression(self, node): + self.visit(node.body) + + def visit_BoolOp(self, node): + if not isinstance(node.op, (ast.And, ast.Or)): + raise ValueError + for value in node.values: + self.visit(value) + + def visit_UnaryOp(self, node): + if not isinstance(node.op, ast.Not): + raise ValueError + self.visit(node.operand) + + def visit_Compare(self, node): + self.visit(node.left) + for op in node.ops: + if not isinstance(op, self._allowed_compare_ops): + raise ValueError + for comparator in node.comparators: + self.visit(comparator) + + def visit_Name(self, node): + if node.id not in self.allowed_names: + raise ValueError + + def visit_Constant(self, node): + return + + def generic_visit(self, node): + raise ValueError + + +def _compile_filter_expr(expr, allowed_names): + """Compile a filter expression into a Python predicate.""" + if not expr: + return None, False + translated = re.sub(r'(?])!(?!=)', ' not ', expr) + translated = translated.replace('&&', ' and ').replace('||', ' or ') + translated = re.sub(r'\bnull\b', 'None', translated) + translated = re.sub(r'\btrue\b', 'True', translated) + translated = re.sub(r'\bfalse\b', 'False', translated) + tree = ast.parse(translated, mode='eval') + _TableFilterValidator(allowed_names).visit(tree) + code = compile(tree, '', 'eval') + + def predicate(row): + return bool(eval(code, {'__builtins__': {}}, row)) + + return predicate, True + + +class _TableModel(QAbstractTableModel): + """Model backing the native Qt table.""" + + def __init__(self, table): + super().__init__(table) + self._table = table + self._rows = [] + self._rows_by_id = {} + + def rowCount(self, parent=QModelIndex()): + return 0 if parent.isValid() else len(self._rows) + + def columnCount(self, parent=QModelIndex()): + return 0 if parent.isValid() else len(self._table.columns) + + def headerData(self, section, orientation, role=Qt.DisplayRole): + if role != Qt.DisplayRole: + return None + if orientation == Qt.Horizontal and 0 <= section < len(self._table.columns): + return self._table.columns[section] + return section + 1 + + def data(self, index, role=Qt.DisplayRole): + if not index.isValid(): + return None + row = self._rows[index.row()] + column = self._table.columns[index.column()] + value = row.get(column) + + if role in (Qt.DisplayRole, Qt.EditRole): + return '' if value is None else value + if role == Qt.BackgroundRole and column == 'id': + color = self._table._selection_background(row.get('id')) + if color is not None: + return color + if role == Qt.ForegroundRole: + fg = self._table._foreground_color(row, column) + if fg is not None: + return fg + return None + + def set_rows(self, rows): + self.beginResetModel() + self._rows = list(rows) + self._rows_by_id = {row['id']: row for row in self._rows} + self.endResetModel() + + def row_by_id(self, row_id): + return self._rows_by_id.get(row_id) + + def ids(self): + return [row['id'] for row in self._rows] + + +class _TableProxyModel(QSortFilterProxyModel): + """Proxy model with typed sorting and expression filtering.""" + + def __init__(self, table): + super().__init__(table) + self._table = table + self._predicate = None + self.setDynamicSortFilter(True) + + def set_filter_predicate(self, predicate): + self._predicate = predicate + self.invalidateFilter() + + def filterAcceptsRow(self, source_row, source_parent): + if self._predicate is None: + return True + row = self.sourceModel()._rows[source_row] + try: + return self._predicate(row) + except Exception: + return True + + def lessThan(self, left, right): + column = self._table.columns[left.column()] + left_row = self.sourceModel()._rows[left.row()] + right_row = self.sourceModel()._rows[right.row()] + left_value = left_row.get(column) + right_value = right_row.get(column) + if left_value is None and right_value is None: + return False + if left_value is None: + return False + if right_value is None: + return True + try: + return bool(left_value < right_value) + except TypeError: + return bool(str(left_value) < str(right_value)) + + +class _TableItemDelegate(QStyledItemDelegate): + """Custom paint delegate to preserve dark styling and selection colors.""" + + def __init__(self, table): + super().__init__(table) + self._table = table + + def paint(self, painter, option, index): + row = self._table._row_for_proxy_index(index) + if row is None: + return super().paint(painter, option, index) + + column = self._table.columns[index.column()] + row_id = row.get('id') + is_selected = row_id in self._table._selected_ids + + opt = QStyleOptionViewItem(option) + self.initStyleOption(opt, index) + opt.state &= ~QStyle.State_HasFocus + + palette = QPalette(opt.palette) + fg = self._table._foreground_color(row, column) + bg = None + + if is_selected: + bg = self._table._selection_background(row_id) + if fg is None: + fg = QColor('#ffffff') + elif fg is None: + fg = QColor('#ffffff') + + # Paint the selection tint ourselves for every cell in the row so Qt does not fall + # back to per-cell selected-item styling. + if bg is not None: + painter.save() + painter.fillRect(opt.rect, bg) + painter.restore() + opt.backgroundBrush = QBrush() + opt.state &= ~QStyle.State_Selected + + if bg is not None: + palette.setColor(QPalette.Base, bg) + palette.setColor(QPalette.Highlight, bg) + if fg is not None: + palette.setColor(QPalette.Text, fg) + palette.setColor(QPalette.WindowText, fg) + palette.setColor(QPalette.HighlightedText, fg) + opt.palette = palette + super().paint(painter, opt, index) + + +class Table(QWidget): + """A sortable native Qt table with a compatibility API for legacy callers.""" _ready = False def __init__( - self, *args, columns=None, value_names=None, data=None, sort=None, title='', - debounce_events=()): - super(Table, self).__init__(*args, title=title, debounce_events=debounce_events) - self._init_table(columns=columns, value_names=value_names, data=data, sort=sort) - - def eval_js(self, expr, callback=None): - """Evaluate a Javascript expression. - - The `table` Javascript variable can be used to interact with the underlying Javascript - table. - - The table has sortable columns, a filter text box, support for single and multi selection - of rows. Rows can be skippable (used for ignored clusters in phy). + self, + *args, + columns=None, + value_names=None, + data=None, + sort=None, + title='', + debounce_events=(), + ): + super().__init__(*args) + self.setWindowTitle(title) + self._debouncer = Debouncer() + self._debounce_events = set(debounce_events) + self._debouncer.isBusy = False + self.columns = list(columns or ['id']) + self.value_names = list(value_names or self.columns) + self.data = list(data or []) + self._selected_ids = [] + self._selected_index_offset = 0 + self._filter_text = '' + self._filter_is_active = False + self._current_sort = None + self._no_emit = False + self._group_colors = { + 'good': QColor('#86D16D'), + 'mua': QColor('#afafaf'), + 'noise': QColor('#777777'), + } - The table can raise Javascript events that are relayed to Python. Objects are - transparently serialized and deserialized in JSON. Basic types (numbers, strings, lists) - are transparently converted between Python and Javascript. + self.filter_edit = QLineEdit(self) + self.filter_edit.setObjectName('table-filter') + self.filter_edit.returnPressed.connect(self._apply_filter_from_editor) + self.filter_edit.installEventFilter(self) + + self.table_view = QTableView(self) + self.table_view.setSelectionBehavior(QAbstractItemView.SelectRows) + self.table_view.setSelectionMode(QAbstractItemView.ExtendedSelection) + self.table_view.clicked.connect(self._on_row_clicked) + self.table_view.horizontalHeader().sectionClicked.connect(self._on_header_clicked) + self.table_view.verticalHeader().hide() + self.table_view.setShowGrid(False) + self.table_view.setAlternatingRowColors(False) + self.table_view.setFocusPolicy(Qt.NoFocus) + self.table_view.setWordWrap(False) + self.table_view.horizontalHeader().setStretchLastSection(False) + self.table_view.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeToContents) + + layout = QVBoxLayout(self) + layout.setContentsMargins(2, 2, 2, 2) + layout.addWidget(self.filter_edit) + layout.addWidget(self.table_view) + + self._model = _TableModel(self) + self._proxy = _TableProxyModel(self) + self._proxy.setSourceModel(self._model) + self.table_view.setModel(self._proxy) + self.table_view.setItemDelegate(_TableItemDelegate(self)) + self._apply_dark_style() - Parameters - ---------- - - expr : str - A Javascript expression. - callback : function - A Python function that is called once the Javascript expression has been - evaluated. It takes as input the output of the Javascript expression. + self._init_table(columns=columns, value_names=value_names, data=data, sort=sort) - """ - # Avoid JS errors when the table is not yet fully loaded. - expr = 'if (typeof table !== "undefined") ' + expr - return super(Table, self).eval_js(expr, callback=callback) + @property + def debouncer(self): + return self._debouncer + + def eventFilter(self, obj, event): + if obj is self.filter_edit and event.type() == QEvent.KeyPress and event.key() == Qt.Key_Escape: + self.filter_edit.clear() + self.filter('') + return True + return super().eventFilter(obj, event) + + def _normalize_value_names(self, value_names): + names = [] + for value_name in value_names: + if isinstance(value_name, str): + names.append(value_name) + elif isinstance(value_name, dict): + names.extend(value_name.get('data', [])) + return names + + def _apply_dark_style(self): + self.setStyleSheet( + """ + QWidget { + background-color: black; + color: white; + font-size: 10pt; + } + QLineEdit#table-filter { + background-color: black; + color: white; + border: 1px solid #444; + padding: 5px; + selection-background-color: #444; + selection-color: white; + } + QTableView { + background-color: black; + color: white; + border: 0; + outline: 0; + selection-background-color: transparent; + selection-color: white; + gridline-color: #111; + } + QTableView::item { + padding: 4px 6px; + border: 0; + background-color: transparent; + } + QTableView::item:hover { + background-color: #222; + } + QTableView::item:selected { + background-color: transparent; + color: white; + } + QHeaderView::section { + background-color: black; + color: white; + border: 0; + padding: 5px; + } + """ + ) def _init_table(self, columns=None, value_names=None, data=None, sort=None): - """Build the table.""" - columns = columns or ['id'] value_names = value_names or columns data = data or [] - b = self.builder - b.set_body_src('index.html') + self.data = list(data) + self.columns = list(columns) + self.value_names = list(value_names) + self._filterable_names = set(self._normalize_value_names(self.value_names)) + self._filterable_names.update(self.columns) - b.add_style(_color_styles()) - - self.data = data - self.columns = columns - self.value_names = value_names + self._model.set_rows(self.data) + self._fit_columns() emit('pre_build', self) - - data_json = dumps(self.data) - columns_json = dumps(self.columns) - value_names_json = dumps(self.value_names) - sort_json = dumps(sort) - - b.body += ''' - - ''' % (data_json, value_names_json, columns_json, sort_json) - self.build(lambda html: emit('ready', self)) - connect(event='select', sender=self, func=lambda *args: self.update(), last=True) connect(event='ready', sender=self, func=lambda *args: self._set_ready()) + if sort and sort[0]: + self.sort_by(*sort) + self._refresh_selection() + self._schedule_callback(lambda: emit('ready', self)) + def _set_ready(self): - """Set the widget as ready.""" self._ready = True def is_ready(self): - """Whether the widget has been fully loaded.""" return self._ready + def _schedule_callback(self, callback, value=_NO_VALUE): + if callback is None: + return + if value is _NO_VALUE: + QTimer.singleShot(0, callback) + else: + QTimer.singleShot(0, lambda: callback(value)) + + def _emit_event(self, name, payload): + if name in self._debounce_events: + self._debouncer.submit(emit, name, self, payload) + else: + emit(name, self, payload) + + def add_style(self, style): + """Append a stylesheet fragment.""" + existing = self.styleSheet() + self.setStyleSheet(f'{existing}\n{style}' if existing else style) + + def _source_row_for_id(self, row_id): + ids = self._model.ids() + try: + return ids.index(row_id) + except ValueError: + return None + + def _proxy_index_for_id(self, row_id, column=0): + source_row = self._source_row_for_id(row_id) + if source_row is None: + return QModelIndex() + source_index = self._model.index(source_row, column) + return self._proxy.mapFromSource(source_index) + + def _row_for_proxy_index(self, proxy_index): + if not proxy_index.isValid(): + return None + source_index = self._proxy.mapToSource(proxy_index) + if not source_index.isValid(): + return None + return self._model._rows[source_index.row()] + + def _fit_columns(self): + self.table_view.resizeColumnsToContents() + self.table_view.resizeRowsToContents() + + def _visible_ids(self): + ids = [] + for row in range(self._proxy.rowCount()): + proxy_index = self._proxy.index(row, 0) + source_index = self._proxy.mapToSource(proxy_index) + ids.append(self._model._rows[source_index.row()]['id']) + return ids + + def _visible_row_ids(self): + out = [] + for row in range(self._proxy.rowCount()): + proxy_index = self._proxy.index(row, 0) + source_index = self._proxy.mapToSource(proxy_index) + out.append(self._model._rows[source_index.row()]['id']) + return out + + def _is_masked_id(self, row_id): + row = self._model.row_by_id(row_id) + return bool(row and row.get('is_masked')) + + def _selected_visible_ids(self): + visible = set(self._visible_ids()) + return [row_id for row_id in self._selected_ids if row_id in visible] + + def _selection_background(self, row_id): + if row_id not in self._selected_ids: + return None + pos = self._selected_ids.index(row_id) + self._selected_index_offset + colors = list(colormaps.default * 255) + r, g, b = colors[pos % len(colors)] + return QColor(int(r), int(g), int(b), 160) + + def _foreground_color(self, row, column): + if column == 'id' and row.get('id') in self._selected_ids: + pos = self._selected_ids.index(row.get('id')) + self._selected_index_offset + colors = list(colormaps.default * 255) + r, g, b = colors[pos % len(colors)] + if _is_bright((int(r), int(g), int(b))): + return QColor('#000000') + group = row.get('group') + if group in self._group_colors: + return self._group_colors[group] + if row.get('is_masked'): + return QColor('#888888') + return None + + def _refresh_selection(self): + selection_model = self.table_view.selectionModel() + if selection_model is None: + return + selection_model.clearSelection() + for row_id in self._selected_visible_ids(): + index = self._proxy_index_for_id(row_id) + if index.isValid(): + selection_model.select( + index, + QItemSelectionModel.Select | QItemSelectionModel.Rows, + ) + selection_model.setCurrentIndex(QModelIndex(), QItemSelectionModel.NoUpdate) + self.table_view.viewport().update() + + def _selected_payload(self, kwargs=None): + selected = self.get_selected_ids() + next_id = self.get_sibling_id(selected[-1] if selected else None, 'next') + return {'selected': selected, 'next': next_id, 'kwargs': kwargs or {}} + + def _emit_selected(self, kwargs=None): + payload = self._selected_payload(kwargs) + self._emit_event('select', payload) + return payload + + def _apply_filter_from_editor(self): + self.filter(self.filter_edit.text()) + + def _set_filter(self, text, update_text_field=True): + self._filter_text = text or '' + if update_text_field and self.filter_edit.text() != self._filter_text: + self.filter_edit.setText(self._filter_text) + if not self._filter_text: + self._proxy.set_filter_predicate(None) + self._filter_is_active = False + return + try: + predicate, valid = _compile_filter_expr(self._filter_text, self._filterable_names) + except Exception: + predicate, valid = None, False + self._proxy.set_filter_predicate(predicate if valid else None) + self._filter_is_active = valid + + def _on_header_clicked(self, section): + name = self.columns[section] + current = self._current_sort + sort_dir = 'asc' + if current and current[0] == name and current[1] == 'asc': + sort_dir = 'desc' + self.sort_by(name, sort_dir) + + def _selection_anchor_row(self): + visible = self._visible_ids() + selected = self._selected_visible_ids() + if not selected: + return None + return visible.index(selected[-1]) + + def _on_row_clicked(self, index): + row_id = self._visible_ids()[index.row()] + mods = QApplication.keyboardModifiers() + if mods & Qt.ControlModifier or mods & Qt.MetaModifier: + self.select_toggle(row_id) + elif mods & Qt.ShiftModifier: + self.select_until(row_id) + else: + self.select([row_id]) + + def get_selected_ids(self): + visible = set(self._visible_ids()) + return [row_id for row_id in self._selected_ids if row_id in visible] + + def select_toggle(self, row_id): + if row_id in self._selected_ids: + self._selected_ids.remove(row_id) + else: + self._selected_ids.append(row_id) + self._refresh_selection() + return self._emit_selected() + + def select_until(self, row_id): + visible = self._visible_ids() + if row_id not in visible: + return None + anchor = self._selection_anchor_row() + if anchor is None: + return self.select([row_id]) + clicked = visible.index(row_id) + imin, imax = sorted((anchor, clicked)) + for visible_id in visible[imin : imax + 1]: + if visible_id not in self._selected_ids: + self._selected_ids.append(visible_id) + self._refresh_selection() + return self._emit_selected() + + def get_sibling_id(self, row_id=None, direction='next'): + selected = self.get_selected_ids() + if row_id is None: + row_id = selected[0] if selected else None + if row_id is None: + return None + visible = self._visible_ids() + if row_id not in visible: + return None + step = 1 if direction == 'next' else -1 + idx = visible.index(row_id) + step + while 0 <= idx < len(visible): + candidate = visible[idx] + if not self._is_masked_id(candidate): + return candidate + idx += step + return None + + def _move_to_sibling(self, row_id=None, direction='next'): + if not self.get_selected_ids(): + return self._select_first_or_last('first') + new_id = self.get_sibling_id(row_id, direction) + if new_id is None: + return None + return self.select([new_id]) + + def _select_first_or_last(self, which): + visible = self._visible_ids() + ordered = visible if which == 'first' else list(reversed(visible)) + for row_id in ordered: + if not self._is_masked_id(row_id): + return self.select([row_id]) + return None + def sort_by(self, name, sort_dir='asc'): - """Sort by a given variable.""" - logger.log(5, "Sort by `%s` %s.", name, sort_dir) - self.eval_js('table.sort_("{}", "{}");'.format(name, sort_dir)) + logger.log(5, 'Sort by `%s` %s.', name, sort_dir) + if name not in self.columns: + return + column = self.columns.index(name) + order = Qt.AscendingOrder if sort_dir == 'asc' else Qt.DescendingOrder + self._current_sort = (name, sort_dir) + self._proxy.sort(column, order) + self._refresh_selection() + self._fit_columns() + if not self._no_emit: + self._emit_event('table_sort', self._visible_ids()) def filter(self, text=''): - """Filter the view with a Javascript expression.""" - logger.log(5, "Filter table with `%s`.", text) - self.eval_js('table.filter_("{}", true);'.format(text)) + logger.log(5, 'Filter table with `%s`.', text) + self._set_filter(text, update_text_field=True) + self._refresh_selection() + self._fit_columns() + if self._filter_is_active and not self._no_emit: + self._emit_event('table_filter', self._visible_ids()) + + def _async_return(self, value, callback=None): + self._schedule_callback(callback, value) + return value def get_ids(self, callback=None): - """Get the list of ids.""" - self.eval_js('table._getIds();', callback=callback) + return self._async_return(self._visible_ids(), callback) def get_next_id(self, callback=None): - """Get the next non-skipped row id.""" - self.eval_js('table.getSiblingId(undefined, "next");', callback=callback) + return self._async_return(self.get_sibling_id(None, 'next'), callback) def get_previous_id(self, callback=None): - """Get the previous non-skipped row id.""" - self.eval_js('table.getSiblingId(undefined, "previous");', callback=callback) + return self._async_return(self.get_sibling_id(None, 'previous'), callback) def first(self, callback=None): - """Select the first item.""" - self.eval_js('table.selectFirst();', callback=callback) + return self._async_return(self._select_first_or_last('first'), callback) def last(self, callback=None): - """Select the last item.""" - self.eval_js('table.selectLast();', callback=callback) + return self._async_return(self._select_first_or_last('last'), callback) def next(self, callback=None): - """Select the next non-skipped row.""" - self.eval_js('table.moveToSibling(undefined, "next");', callback=callback) + return self._async_return(self._move_to_sibling(None, 'next'), callback) def previous(self, callback=None): - """Select the previous non-skipped row.""" - self.eval_js('table.moveToSibling(undefined, "previous");', callback=callback) + return self._async_return(self._move_to_sibling(None, 'previous'), callback) def select(self, ids, callback=None, **kwargs): - """Select some rows in the table from Python. - - This function calls `table.select()` in Javascript, which raises a Javascript event - relayed to Python. This sequence of actions is the same when the user selects - rows directly in the HTML view. - - """ ids = _uniq(ids) assert all(_is_integer(_) for _ in ids) - self.eval_js('table.select({}, {});'.format(dumps(ids), dumps(kwargs)), callback=callback) + visible = set(self._visible_ids()) + self._selected_ids = [row_id for row_id in ids if row_id in visible] + self._refresh_selection() + payload = self._emit_selected(kwargs) + return self._async_return(payload, callback) def scroll_to(self, id): - """Scroll until a given row is visible.""" - self.eval_js('table._scrollTo({});'.format(id)) + index = self._proxy_index_for_id(id) + if index.isValid(): + self.table_view.scrollTo(index) def set_busy(self, busy): - """Set the busy state of the GUI.""" - self.eval_js('table.setBusy({});'.format('true' if busy else 'false')) + self.debouncer.isBusy = bool(busy) def get(self, id, callback=None): - """Get the object given its id.""" - self.eval_js('table.get("id", {})[0]["_values"]'.format(id), callback=callback) + row = self._model.row_by_id(id) + out = dict(row) if row is not None else None + return self._async_return(out, callback) + + def _ensure_list(self, objects): + if isinstance(objects, dict): + return [objects] + return list(objects) def add(self, objects): - """Add objects object to the table.""" + objects = self._ensure_list(objects) if not objects: return - self.eval_js('table.add_({});'.format(dumps(objects))) + data = self._model._rows + objects + self._model.set_rows(data) + if self._current_sort: + self._no_emit = True + self.sort_by(*self._current_sort) + self._no_emit = False + self._refresh_selection() + self._fit_columns() def change(self, objects): - """Change some objects.""" + objects = self._ensure_list(objects) if not objects: return - self.eval_js('table.change_({});'.format(dumps(objects))) + updated = {obj['id']: obj for obj in objects} + rows = [] + for row in self._model._rows: + patch = updated.get(row['id']) + rows.append({**row, **patch} if patch else dict(row)) + self._model.set_rows(rows) + if self._current_sort: + self._no_emit = True + self.sort_by(*self._current_sort) + self._no_emit = False + self._refresh_selection() + self._fit_columns() def remove(self, ids): - """Remove some objects from their ids.""" + ids = set(ids) if not ids: return - self.eval_js('table.remove_({});'.format(dumps(ids))) + self._selected_ids = [row_id for row_id in self._selected_ids if row_id not in ids] + self._model.set_rows([row for row in self._model._rows if row['id'] not in ids]) + self._refresh_selection() + self._fit_columns() def remove_all(self): - """Remove all rows in the table.""" - self.eval_js('table.removeAll();') + self._selected_ids = [] + self._model.set_rows([]) + self._refresh_selection() + self._fit_columns() def remove_all_and_add(self, objects): - """Remove all rows in the table and add new objects.""" + objects = self._ensure_list(objects) if not objects: return self.remove_all() - self.eval_js('table.removeAllAndAdd({});'.format(dumps(objects))) + self._selected_ids = [] + self._model.set_rows(objects) + if self._current_sort: + self._no_emit = True + self.sort_by(*self._current_sort) + self._no_emit = False + self._refresh_selection() + self._fit_columns() def get_selected(self, callback=None): - """Get the currently selected rows.""" - self.eval_js('table.selected()', callback=callback) + return self._async_return(self.get_selected_ids(), callback) def get_current_sort(self, callback=None): - """Get the current sort as a tuple `(name, dir)`.""" - self.eval_js('table._currentSort()', callback=callback) + value = list(self._current_sort) if self._current_sort else None + return self._async_return(value, callback) + + def set_selected_index_offset(self, n): + self._selected_index_offset = n + self.table_view.viewport().update() + + def clear_temporary_files(self): + """Compatibility no-op kept for callers from the removed WebEngine path.""" + return + + def sizeHint(self): + return QSize(400, 400) + + def minimumSizeHint(self): + return QSize(150, 150) + + def eval_js(self, expr, callback=None): + expr = expr.strip() + result = None + emit_match = re.fullmatch(r'table\.emit\("([^"]+)",\s*(.+)\);?', expr) + if emit_match: + event_name, raw_value = emit_match.groups() + try: + result = json.loads(raw_value) + except Exception: + result = raw_value + self._emit_event(event_name, result) + elif expr == 'table.debouncer.isBusy': + result = self.debouncer.isBusy + elif expr.startswith('table._setSelectedIndexOffset('): + number = int(re.search(r'\((\d+)\)', expr).group(1)) + self.set_selected_index_offset(number) + elif expr == 'table.selected()': + result = self.get_selected_ids() + elif expr == 'table._getIds();': + result = self._visible_ids() + elif expr == 'table._currentSort()': + result = list(self._current_sort) if self._current_sort else None + else: + logger.warning('Unsupported eval_js expression in native table: %s', expr) + self._schedule_callback(callback, result) + return result # ----------------------------------------------------------------------------- # KeyValueWidget # ----------------------------------------------------------------------------- + class KeyValueWidget(QWidget): """A Qt widget that displays a simple form where each field has a name, a type, and accept user input.""" + def __init__(self, *args, **kwargs): - super(KeyValueWidget, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self._items = [] self._layout = QGridLayout(self) @@ -575,7 +1064,7 @@ def add_pair(self, name, default=None, vtype=None): if isinstance(default, list): # Take lists into account. for i, value in enumerate(default): - self.add_pair('%s[%d]' % (name, i), default=value, vtype=vtype) + self.add_pair(f'{name}[{i}]', default=value, vtype=vtype) return if vtype is None and default is not None: vtype = type(default).__name__ @@ -589,8 +1078,8 @@ def add_pair(self, name, default=None, vtype=None): widget.setMaximumHeight(400) elif vtype == 'int': widget = QSpinBox(self) - widget.setMinimum(-1e9) - widget.setMaximum(+1e9) + widget.setMinimum(-10**9) + widget.setMaximum(10**9) widget.setValue(default or 0) elif vtype == 'float': widget = QDoubleSpinBox(self) @@ -601,7 +1090,7 @@ def add_pair(self, name, default=None, vtype=None): widget = QCheckBox(self) widget.setChecked(default is True) else: # pragma: no cover - raise ValueError("Not supported vtype: %s." % vtype) + raise ValueError(f'Not supported vtype: {vtype}.') widget.setMaximumWidth(400) @@ -618,7 +1107,8 @@ def add_pair(self, name, default=None, vtype=None): def names(self): """List of field names.""" return sorted( - set(i[0] if '[' not in i[0] else i[0][:i[0].index('[')] for i in self._items)) + {i[0] if '[' not in i[0] else i[0][: i[0].index('[')] for i in self._items} + ) def get_widget(self, name): """Get the widget of a field.""" @@ -629,15 +1119,15 @@ def get_widget(self, name): def get_value(self, name): """Get the default or user-entered value of a field.""" # Detect if the requested name is a list type. - names = set(i[0] for i in self._items) - if '%s[0]' % name in names: + names = {i[0] for i in self._items} + if f'{name}[0]' in names: out = [] i = 0 - namei = '%s[%d]' % (name, i) + namei = f'{name}[{i}]' while namei in names: out.append(self.get_value(namei)) i += 1 - namei = '%s[%d]' % (name, i) + namei = f'{name}[{i}]' return out for name_, vtype, default, widget in self._items: if name_ == name: diff --git a/phy/plot/__init__.py b/phy/plot/__init__.py index 8ee863111..b0119781c 100644 --- a/phy/plot/__init__.py +++ b/phy/plot/__init__.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # flake8: noqa """Plotting module based on OpenGL. @@ -8,9 +7,9 @@ """ -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ import os.path as op @@ -22,5 +21,13 @@ from .utils import get_linear_x, BatchAccumulator from .interact import Grid, Boxed, Lasso from .visuals import ( - ScatterVisual, UniformScatterVisual, PlotVisual, UniformPlotVisual, HistogramVisual, - TextVisual, LineVisual, ImageVisual, PolygonVisual) + ScatterVisual, + UniformScatterVisual, + PlotVisual, + UniformPlotVisual, + HistogramVisual, + TextVisual, + LineVisual, + ImageVisual, + PolygonVisual, +) diff --git a/phy/plot/axes.py b/phy/plot/axes.py index 34d18994d..e98ddcb15 100644 --- a/phy/plot/axes.py +++ b/phy/plot/axes.py @@ -1,28 +1,26 @@ -# -*- coding: utf-8 -*- - """Axes.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ import numpy as np from matplotlib.ticker import MaxNLocator - - -from .transform import NDC, Range, _fix_coordinate_in_visual -from .visuals import LineVisual, TextVisual from phylib import connect from phylib.utils._types import _is_integer + from phy.gui.qt import is_high_dpi +from .transform import NDC, Range, _fix_coordinate_in_visual +from .visuals import LineVisual, TextVisual -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Utils -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ -class AxisLocator(object): + +class AxisLocator: """Determine the location of ticks in a view. Constructor @@ -85,9 +83,7 @@ def set_view_bounds(self, view_bounds=None): dy = 2 * (y1 - y0) # Get the bounds in data coordinates. - ((dx0, dy0), (dx1, dy1)) = self._tr.apply([ - [x0 - dx, y0 - dy], - [x1 + dx, y1 + dy]]) + ((dx0, dy0), (dx1, dy1)) = self._tr.apply([[x0 - dx, y0 - dy], [x1 + dx, y1 + dy]]) # Compute the ticks in data coordinates. self.xticks = self.locx.tick_values(dx0, dx1) @@ -102,9 +98,9 @@ def set_view_bounds(self, view_bounds=None): self.ytext = [fmt % v for v in self.yticks] -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Axes visual -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ def _set_line_data(xticks, yticks): @@ -125,10 +121,10 @@ def _quant_zoom(z): """Return the zoom level as a positive or negative integer.""" if z == 0: return 0 # pragma: no cover - return int(z) if z >= 1 else -int(1. / z) + return int(z) if z >= 1 else -int(1.0 / z) -class Axes(object): +class Axes: """Dynamic axes that move along the camera when panning and zooming. Constructor @@ -144,7 +140,8 @@ class Axes(object): Whether to show the horizontal grid lines. """ - default_color = (1, 1, 1, .25) + + default_color = (1, 1, 1, 0.25) def __init__(self, data_bounds=None, color=None, show_x=True, show_y=True): self.show_x = show_x @@ -223,8 +220,7 @@ def attach(self, canvas): # Exclude the axes visual from the interact/layout, but keep the PanZoom. interact = getattr(canvas, 'interact', None) exclude_origins = (interact,) if interact else () - canvas.add_visual( - visual, clearable=False, exclude_origins=exclude_origins) + canvas.add_visual(visual, clearable=False, exclude_origins=exclude_origins) self.locator.set_view_bounds(NDC) self.update_visuals() diff --git a/phy/plot/base.py b/phy/plot/base.py index 1b0ccb37f..9c2339738 100644 --- a/phy/plot/base.py +++ b/phy/plot/base.py @@ -1,44 +1,44 @@ -# -*- coding: utf-8 -*- - """Base OpenGL classes.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ -from contextlib import contextmanager import gc import logging import re +from contextlib import contextmanager, suppress from timeit import default_timer import numpy as np +from phylib.utils import Bunch, connect, emit + +from phy.gui.qt import QOpenGLWidget, QOpenGLWindow, Qt, _QOpenGLWindow -from phylib.utils import connect, emit, Bunch -from phy.gui.qt import Qt, QEvent, QOpenGLWindow from . import gloo from .gloo import gl -from .transform import TransformChain, Clip, pixels_to_ndc, Range -from .utils import _load_shader, _get_array, BatchAccumulator - +from .transform import Clip, Range, TransformChain, pixels_to_ndc +from .utils import BatchAccumulator, _get_array, _load_shader logger = logging.getLogger(__name__) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Utils -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def indent(text): - return '\n'.join(' ' + l.strip() for l in text.splitlines()) + return '\n'.join(f' {l.strip()}' for l in text.splitlines()) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Base spike visual -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + -class BaseVisual(object): +class BaseVisual: """A Visual represents one object (or homogeneous set of objects). It is rendered with a single pass of a single gloo program with a single type of GL primitive. @@ -94,9 +94,9 @@ def emit_visual_set_data(self): def set_shader(self, name): """Set the built-in vertex and fragment shader.""" - self.vertex_shader = _load_shader(name + '.vert') - self.fragment_shader = _load_shader(name + '.frag') - self.geometry_shader = _load_shader(name + '.geom') + self.vertex_shader = _load_shader(f'{name}.vert') + self.fragment_shader = _load_shader(f'{name}.frag') + self.geometry_shader = _load_shader(f'{name}.geom') def set_primitive_type(self, primitive_type): """Set the primitive type (points, lines, line_strip, line_fan, triangles).""" @@ -115,12 +115,14 @@ def on_draw(self): # Draw the program. self.program.draw(self.gl_primitive_type, self.index_buffer) else: # pragma: no cover - logger.debug("Skipping drawing visual `%s` because the program " - "has not been built yet.", self) + logger.debug( + 'Skipping drawing visual `%s` because the program has not been built yet.', + self, + ) def on_resize(self, width, height): """Update the window size in the OpenGL program.""" - s = self.program._vertex.code + '\n' + self.program.fragment.code + s = f'{self.program._vertex.code}\n{self.program.fragment.code}' # HACK: ensure that u_window_size appears somewhere in the shaders body (discarding # the headers). s = s.replace('uniform vec2 u_window_size;', '') @@ -141,7 +143,10 @@ def toggle(self): def close(self): """Close the visual.""" - self.program._deactivate() + # In headless/offscreen tests the program may never have been activated, + # so there is no valid GL context or program handle to deactivate. + if getattr(self.program, 'handle', -1) > 0: + self.program._deactivate() del self.program gc.collect() @@ -177,8 +182,12 @@ def add_batch_data(self, **kwargs): # WARNING: size should be the number of items for correct batch array creation, # not the number of vertices. self._acc.add( - data, box_index=box_index, n_items=data._n_items, - n_vertices=data._n_vertices, noconcat=self._noconcat) + data, + box_index=box_index, + n_items=data._n_items, + n_vertices=data._n_vertices, + noconcat=self._noconcat, + ) def reset_batch(self): """Reinitialize the batch.""" @@ -201,27 +210,31 @@ def set_box_index(self, box_index, data=None): self.program['a_box_index'] = a_box_index.astype(np.float32) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Build program with layouts -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def _get_glsl(to_insert, shader_type=None, location=None, exclude_origins=()): """From a `to_insert` list of (shader_type, location, origin, snippet), return the concatenated snippet that satisfies the specified shader type, location, and origin.""" - return '\n'.join(( + return '\n'.join( snippet for (shader_type_, location_, origin_, snippet) in to_insert - if shader_type_ == shader_type and location_ == location and - origin_ not in exclude_origins - )) + if shader_type_ == shader_type + and location_ == location + and origin_ not in exclude_origins + ) def _repl_vars(snippet, varout, varin): - snippet = snippet.replace('{{varout}}', varout if varout != 'gl_Position' else 'pos_tmp') + snippet = snippet.replace( + '{{varout}}', varout if varout != 'gl_Position' else 'pos_tmp' + ) return snippet.replace('{{varin}}', varin) -class GLSLInserter(object): +class GLSLInserter: """Object used to insert GLSL snippets into shader code. This class provides methods to specify the snippets to insert, and the @@ -237,7 +250,9 @@ def __init__(self): def _init_insert(self): self.insert_vert('vec2 {{varout}} = {{varin}};', 'before_transforms', index=0) - self.insert_vert('gl_Position = vec4({{varout}}, 0., 1.);', 'after_transforms', index=0) + self.insert_vert( + 'gl_Position = vec4({{varout}}, 0., 1.);', 'after_transforms', index=0 + ) self.insert_vert('varying vec2 v_{{varout}};\n', 'header', index=0) self.insert_frag('varying vec2 v_{{varout}};\n', 'header', index=0) @@ -289,9 +304,9 @@ def insert_frag(self, glsl, location=None, origin=None, index=None): def add_varying(self, vtype, name, value): """Add a varying variable.""" - self.insert_vert('varying %s %s;' % (vtype, name), 'header') - self.insert_frag('varying %s %s;' % (vtype, name), 'header') - self.insert_vert('%s = %s;' % (name, value), 'end') + self.insert_vert(f'varying {vtype} {name};', 'header') + self.insert_frag(f'varying {vtype} {name};', 'header') + self.insert_vert(f'{name} = {value};', 'end') def add_gpu_transforms(self, tc): """Insert all GLSL snippets from a transform chain.""" @@ -305,7 +320,9 @@ def add_gpu_transforms(self, tc): # Clipping. clip = tc.get('Clip') if clip: - self.insert_frag(clip.glsl('v_{{varout}}'), 'before_transforms', origin=origin) + self.insert_frag( + clip.glsl('v_{{varout}}'), 'before_transforms', origin=origin + ) def insert_into_shaders(self, vertex, fragment, exclude_origins=()): """Insert all GLSL snippets in a vertex and fragment shaders. @@ -345,28 +362,32 @@ def get_frag(t, loc): if not self._variables: logger.debug( "The vertex shader doesn't contain the transform placeholder: skipping the " - "transform chain GLSL insertion.") + 'transform chain GLSL insertion.' + ) return vertex, fragment assert self._variables # Define pos_orig only once. for varout, varin in self._variables: if varout == 'gl_Position': - self.insert_vert('vec2 pos_orig = %s;' % varin, 'before_transforms', index=0) + self.insert_vert( + f'vec2 pos_orig = {varin};', 'before_transforms', index=0 + ) # Replace the variable placeholders. to_insert = [] - for (shader_type, location, origin, glsl) in self._to_insert: + for shader_type, location, origin, glsl in self._to_insert: if '{{varout}}' not in glsl: to_insert.append((shader_type, location, origin, glsl)) else: for varout, varin in self._variables: to_insert.append( - (shader_type, location, origin, _repl_vars(glsl, varout, varin))) + (shader_type, location, origin, _repl_vars(glsl, varout, varin)) + ) # Headers. - vertex = get_vert(to_insert, 'header') + '\n\n' + vertex - fragment = get_frag(to_insert, 'header') + '\n\n' + fragment + vertex = f'{get_vert(to_insert, "header")}\n\n{vertex}' + fragment = f'{get_frag(to_insert, "header")}\n\n{fragment}' # Get the pre and post transforms. vs_insert = get_vert(self._to_insert, 'before_transforms') @@ -377,7 +398,12 @@ def get_frag(t, loc): def repl(m): varout, varin = m.group(1), m.group(2) varout = varout if varout != 'gl_Position' else 'pos_tmp' - return indent(vs_insert).replace('{{varout}}', varout).replace('{{varin}}', varin) + return ( + indent(vs_insert) + .replace('{{varout}}', varout) + .replace('{{varin}}', varin) + ) + vertex = self._transform_regex.sub(repl, vertex) # Insert snippets at the very start of the vertex shader. @@ -386,11 +412,11 @@ def repl(m): # Insert snippets at the very end of the vertex shader. i = vertex.rindex('}') - vertex = vertex[:i] + get_vert(to_insert, 'end') + '}\n' + vertex = f'{vertex[:i] + get_vert(to_insert, "end")}}}\n' # Insert snippets at the very end of the fragment shader. i = fragment.rindex('}') - fragment = fragment[:i] + get_frag(to_insert, 'end') + '}\n' + fragment = f'{fragment[:i] + get_frag(to_insert, "end")}}}\n' # Now, we make the replacements in the fragment shader. fs_insert = r'\1\n' + get_frag(to_insert, 'before_transforms') @@ -404,22 +430,22 @@ def __add__(self, inserter): return self -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Base canvas -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def get_modifiers(e): """Return modifier names from a Qt event.""" m = e.modifiers() return tuple( - name for name in ('Shift', 'Control', 'Alt', 'Meta') if m & getattr(Qt, name + 'Modifier')) + name + for name in ('Shift', 'Control', 'Alt', 'Meta') + if m & getattr(Qt, f'{name}Modifier') + ) -_BUTTON_MAP = { - 1: 'Left', - 2: 'Right', - 4: 'Middle' -} +_BUTTON_MAP = {1: 'Left', 2: 'Right', 4: 'Middle'} _SUPPORTED_KEYS = ( @@ -464,7 +490,7 @@ def mouse_info(e): p = e.pos() x, y = p.x(), p.y() b = e.button() - return (x, y), _BUTTON_MAP.get(b, None) + return (x, y), _BUTTON_MAP.get(b) def key_info(e): @@ -474,7 +500,7 @@ def key_info(e): return chr(key) else: for name in _SUPPORTED_KEYS: - if key == getattr(Qt, 'Key_' + name, None): + if key == getattr(Qt, f'Key_{name}', None): return name @@ -487,21 +513,22 @@ class LazyProgram(gloo.Program): should always be sent from the main GUI thread. """ + def __init__(self, *args, **kwargs): self._update_queue = [] self._is_lazy = False - super(LazyProgram, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) def __setitem__(self, name, data): # Remove all past items with the current name. if self._is_lazy: - self._update_queue[:] = ((n, d) for (n, d) in self._update_queue if n != name) + self._update_queue[:] = ( + (n, d) for (n, d) in self._update_queue if n != name + ) self._update_queue.append((name, data)) else: - try: - super(LazyProgram, self).__setitem__(name, data) - except IndexError: - pass + with suppress(IndexError): + super().__setitem__(name, data) class BaseCanvas(QOpenGLWindow): @@ -513,7 +540,7 @@ class BaseCanvas(QOpenGLWindow): """ def __init__(self, *args, **kwargs): - super(BaseCanvas, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.gpu_transforms = TransformChain() self.inserter = GLSLInserter() self.visuals = [] @@ -527,7 +554,7 @@ def __init__(self, *args, **kwargs): self._mouse_press_button = None self._mouse_press_modifiers = None self._last_mouse_pos = None - self._mouse_press_time = 0. + self._mouse_press_time = 0.0 self._current_key_event = None # Default window size. @@ -542,8 +569,10 @@ def window_to_ndc(self, mouse_pos): account pan and zoom.""" panzoom = getattr(self, 'panzoom', None) ndc = ( - panzoom.window_to_ndc(mouse_pos) if panzoom else - np.asarray(pixels_to_ndc(mouse_pos, size=self.get_size()))) + panzoom.window_to_ndc(mouse_pos) + if panzoom + else np.asarray(pixels_to_ndc(mouse_pos, size=self.get_size())) + ) return ndc # Queue @@ -576,7 +605,7 @@ def remove(self, *visuals): visuals = [v for v in visuals if v is not None] self.visuals[:] = (v for v in self.visuals if v.visual not in visuals) for v in visuals: - logger.log(5, "Remove visual %s.", v) + logger.log(5, 'Remove visual %s.', v) v.close() del v gc.collect() @@ -606,7 +635,7 @@ def add_visual(self, visual, **kwargs): """ if self.has_visual(visual): - logger.log(5, "This visual has already been added.") + logger.log(5, 'This visual has already been added.') return visual.canvas = self # This is the list of origins (mostly, interacts and layouts) that should be ignored @@ -631,12 +660,13 @@ def add_visual(self, visual, **kwargs): gs = getattr(visual, 'geometry_shader', None) if gs: gs = gloo.GeometryShader( - gs, visual.geometry_count, visual.geometry_in, visual.geometry_out) + gs, visual.geometry_count, visual.geometry_in, visual.geometry_out + ) # Finally, we create the visual's program. visual.program = LazyProgram(vs, fs, gs) - logger.log(5, "Vertex shader: %s", vs) - logger.log(5, "Fragment shader: %s", fs) + logger.log(5, 'Vertex shader: %s', vs) + logger.log(5, 'Fragment shader: %s', fs) # Initialize the size. visual.on_resize(self.size().width(), self.size().height()) @@ -647,10 +677,7 @@ def add_visual(self, visual, **kwargs): def has_visual(self, visual): """Return whether a visual belongs to the canvas.""" - for v in self.visuals: - if v.visual == visual: - return True - return False + return any(v.visual == visual for v in self.visuals) def iter_update_queue(self): """Iterate through all OpenGL program updates called in lazy mode.""" @@ -672,7 +699,7 @@ def initializeGL(self): try: gl.enable_depth_mask() except Exception as e: # pragma: no cover - logger.debug("Exception in initializetGL: %s", str(e)) + logger.debug('Exception in initializetGL: %s', str(e)) return def paintGL(self): @@ -687,19 +714,24 @@ def paintGL(self): # Draw all visuals, clearable first, non clearable last. visuals = [v for v in self.visuals if v.get('clearable', True)] visuals += [v for v in self.visuals if not v.get('clearable', True)] - logger.log(5, "Draw %d visuals.", len(visuals)) + logger.log(5, 'Draw %d visuals.', len(visuals)) for v in visuals: visual = v.visual if size != self._size: visual.on_resize(*size) # Do not draw if there are no vertices. - if not visual._hidden and visual.n_vertices > 0 and size[0] > 10 and size[1] > 10: - logger.log(5, "Draw visual `%s`.", visual) + if ( + not visual._hidden + and visual.n_vertices > 0 + and size[0] > 10 + and size[1] > 10 + ): + logger.log(5, 'Draw visual `%s`.', visual) visual.on_draw() self._size = size except Exception as e: # pragma: no cover # raise e - logger.debug("Exception in paintGL: %s", str(e)) + logger.debug('Exception in paintGL: %s', str(e)) return # Events @@ -713,7 +745,7 @@ def attach_events(self, obj): def emit(self, name, **kwargs): """Raise an internal event and call `on_xxx()` on attached objects.""" for obj in self._attached: - f = getattr(obj, 'on_' + name, None) + f = getattr(obj, f'on_{name}', None) if f: f(Bunch(kwargs)) @@ -744,7 +776,7 @@ def mouseReleaseEvent(self, e): """Emit an internal `mouse_release` or `mouse_click` event.""" self._mouse_event('mouse_release', e) # HACK: since there is no mouseClickEvent in Qt, emulate it here. - if default_timer() - self._mouse_press_time < .25: + if default_timer() - self._mouse_press_time < 0.25: self._mouse_event('mouse_click', e) self._mouse_press_position = None self._mouse_press_button = None @@ -765,7 +797,8 @@ def mouseMoveEvent(self, e): modifiers=modifiers, mouse_press_modifiers=self._mouse_press_modifiers, button=self._mouse_press_button, - mouse_press_position=self._mouse_press_position) + mouse_press_position=self._mouse_press_position, + ) self._last_mouse_pos = pos def wheelEvent(self, e): # pragma: no cover @@ -794,47 +827,34 @@ def keyReleaseEvent(self, e): self._key_event('key_release', e) self._current_key_event = None - def event(self, e): # pragma: no cover - """Touch event.""" - out = super(BaseCanvas, self).event(e) - t = e.type() - # Two-finger pinch. - if (t == QEvent.TouchBegin): - self.emit('pinch_begin') - elif (t == QEvent.TouchEnd): - self.emit('pinch_end') - elif (t == QEvent.Gesture): - gesture = e.gesture(Qt.PinchGesture) - if gesture: - (x, y) = gesture.centerPoint().x(), gesture.centerPoint().y() - scale = gesture.scaleFactor() - last_scale = gesture.lastScaleFactor() - rotation = gesture.rotationAngle() - self.emit( - 'pinch', pos=(x, y), - scale=scale, last_scale=last_scale, rotation=rotation) - # General touch event. - elif (t == QEvent.TouchUpdate): - points = e.touchPoints() - # These variables are lists of (x, y) coordinates. - pos, last_pos = zip(*[ - ((p.pos().x(), p.pos.y()), (p.lastPos().x(), p.lastPos.y())) - for p in points]) - self.emit('touch', pos=pos, last_pos=last_pos) - return out + def close(self): + """Close the OpenGL canvas. + + The Qt offscreen platform plugin crashes when closing a shown QOpenGLWindow after + rendering. Hiding the window avoids the native crash and is sufficient for headless + test cleanup. + """ + from os import environ + + if environ.get('QT_QPA_PLATFORM') == 'offscreen' and isinstance(self, _QOpenGLWindow): + self.hide() + return + super().close() def update(self): """Update the OpenGL canvas.""" if not self._is_lazy: - super(BaseCanvas, self).update() + super().update() -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Base layout -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ -class BaseLayout(object): + +class BaseLayout: """Implement global transforms on a canvas, like subplots.""" + canvas = None box_var = None n_dims = 1 @@ -897,13 +917,18 @@ def box_map(self, mouse_pos): def update_visual(self, visual): """Called whenever visual.set_data() is called. Set a_box_index in here.""" - if (visual.n_vertices > 0 and - self.box_var in visual.program and - ((visual.program[self.box_var] is None) or - (visual.program[self.box_var].shape[0] != visual.n_vertices))): - logger.log(5, "Set %s(%d) for %s" % (self.box_var, visual.n_vertices, visual)) + if ( + visual.n_vertices > 0 + and self.box_var in visual.program + and ( + (visual.program[self.box_var] is None) + or (visual.program[self.box_var].shape[0] != visual.n_vertices) + ) + ): + logger.log(5, f'Set {self.box_var}({visual.n_vertices}) for {visual}') visual.program[self.box_var] = _get_array( - self.active_box, (visual.n_vertices, self.n_dims)).astype(np.float32) + self.active_box, (visual.n_vertices, self.n_dims) + ).astype(np.float32) def update(self): """Update all visuals in the attached canvas.""" diff --git a/phy/plot/gloo/array.py b/phy/plot/gloo/array.py index 5e5c22ddf..cfaaf3299 100644 --- a/phy/plot/gloo/array.py +++ b/phy/plot/gloo/array.py @@ -23,10 +23,9 @@ import numpy as np from . import gl -from .gpudata import GPUData -from .globject import GLObject from .buffer import VertexBuffer - +from .globject import GLObject +from .gpudata import GPUData log = logging.getLogger(__name__) @@ -46,40 +45,40 @@ def __init__(self, usage=gl.GL_DYNAMIC_DRAW): @property def need_update(self): - """ Whether object needs to be updated """ + """Whether object needs to be updated""" return self._buffer.need_update def _update(self): - """ Upload all pending data to GPU. """ + """Upload all pending data to GPU.""" self._buffer._update() def _create(self): - """ Create vertex array on GPU """ + """Create vertex array on GPU""" self._handle = gl.glGenVertexArrays(1) - log.debug("GPU: Creating vertex array (id=%d)" % self._id) + log.debug(f'GPU: Creating vertex array (id={self._id})') self._deactivate() self._buffer._create() def _delete(self): - """ Delete vertex array from GPU """ + """Delete vertex array from GPU""" if self._handle > -1: self._buffer._delete() gl.glDeleteVertexArrays(1, np.array([self._handle])) def _activate(self): - """ Bind the array """ + """Bind the array""" - log.debug("GPU: Activating array (id=%d)" % self._id) + log.debug(f'GPU: Activating array (id={self._id})') gl.glBindVertexArray(self._handle) self._buffer._activate() def _deactivate(self): - """ Unbind the current bound array """ + """Unbind the current bound array""" self._buffer._deactivate() - log.debug("GPU: Deactivating array (id=%d)" % self._id) + log.debug(f'GPU: Deactivating array (id={self._id})') gl.glBindVertexArray(0) diff --git a/phy/plot/gloo/atlas.py b/phy/plot/gloo/atlas.py index 611bf1287..471597b8b 100644 --- a/phy/plot/gloo/atlas.py +++ b/phy/plot/gloo/atlas.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # ----------------------------------------------------------------------------- # Copyright (c) 2009-2016 Nicolas P. Rougier. All rights reserved. # Distributed under the (new) BSD License. @@ -19,13 +18,14 @@ import logging import sys -from . texture import Texture2D + +from .texture import Texture2D log = logging.getLogger(__name__) class Atlas(Texture2D): - """ Texture Atlas (two dimensional) + """Texture Atlas (two dimensional) Parameters @@ -53,7 +53,9 @@ class Atlas(Texture2D): def __init__(self): Texture2D.__init__(self) - self.nodes = [(0, 0, self.width), ] + self.nodes = [ + (0, 0, self.width), + ] self.used = 0 def allocate(self, shape): @@ -82,15 +84,16 @@ def allocate(self, shape): y = self._fit(i, width, height) if y >= 0: node = self.nodes[i] - if (y + height < best_height or - (y + height == best_height and node[2] < best_width)): + if y + height < best_height or ( + y + height == best_height and node[2] < best_width + ): best_height = y + height best_index = i best_width = node[2] region = node[0], y, width, height if best_index == -1: - log.warning("No enough free space in atlas") + log.warning('No enough free space in atlas') return None node = region[0], region[1] + height, width @@ -155,7 +158,7 @@ def _fit(self, index, width, height): return y def _merge(self): - """ Merge nodes. """ + """Merge nodes.""" i = 0 while i < len(self.nodes) - 1: diff --git a/phy/plot/gloo/buffer.py b/phy/plot/gloo/buffer.py index 27cc00e80..4a5c434ec 100644 --- a/phy/plot/gloo/buffer.py +++ b/phy/plot/gloo/buffer.py @@ -26,9 +26,8 @@ import numpy as np from . import gl -from .gpudata import GPUData from .globject import GLObject - +from .gpudata import GPUData log = logging.getLogger(__name__) @@ -48,59 +47,59 @@ def __init__(self, target, usage=gl.GL_DYNAMIC_DRAW): @property def need_update(self): - """ Whether object needs to be updated """ + """Whether object needs to be updated""" return self.pending_data is not None def _create(self): - """ Create buffer on GPU """ + """Create buffer on GPU""" self._handle = gl.glGenBuffers(1) self._activate() - log.log(5, "GPU: Creating buffer (id=%d)" % self._id) + log.log(5, f'GPU: Creating buffer (id={self._id})') gl.glBufferData(self._target, self.nbytes, None, self._usage) self._deactivate() def _delete(self): - """ Delete buffer from GPU """ + """Delete buffer from GPU""" if self._handle > -1: gl.glDeleteBuffers(1, np.array([self._handle])) def _activate(self): - """ Bind the buffer to some target """ + """Bind the buffer to some target""" - log.log(5, "GPU: Activating buffer (id=%d)" % self._id) + log.log(5, f'GPU: Activating buffer (id={self._id})') gl.glBindBuffer(self._target, self._handle) def _deactivate(self): - """ Unbind the current bound buffer """ + """Unbind the current bound buffer""" - log.log(5, "GPU: Deactivating buffer (id=%d)" % self._id) + log.log(5, f'GPU: Deactivating buffer (id={self._id})') gl.glBindBuffer(self._target, 0) def _update(self): - """ Upload all pending data to GPU. """ + """Upload all pending data to GPU.""" if self.pending_data: start, stop = self.pending_data offset, nbytes = start, stop - start # offset, nbytes = self.pending_data - data = self.ravel().view(np.ubyte)[offset:offset + nbytes] + data = self.ravel().view(np.ubyte)[offset : offset + nbytes] gl.glBufferSubData(self.target, offset, nbytes, data) self._pending_data = None self._need_update = False class VertexBuffer(Buffer): - """ Buffer for vertex attribute data """ + """Buffer for vertex attribute data""" def __init__(self, usage=gl.GL_DYNAMIC_DRAW): Buffer.__init__(self, gl.GL_ARRAY_BUFFER, usage) class IndexBuffer(Buffer): - """ Buffer for index data """ + """Buffer for index data""" def __init__(self, usage=gl.GL_DYNAMIC_DRAW): Buffer.__init__(self, gl.GL_ELEMENT_ARRAY_BUFFER, usage) diff --git a/phy/plot/gloo/framebuffer.py b/phy/plot/gloo/framebuffer.py index 2e561aab2..ebc187f02 100644 --- a/phy/plot/gloo/framebuffer.py +++ b/phy/plot/gloo/framebuffer.py @@ -39,12 +39,11 @@ def on_draw(dt): from .globject import GLObject from .texture import Texture2D - log = logging.getLogger(__name__) class RenderBuffer(GLObject): - """ Base class for render buffer object. + """Base class for render buffer object. :param GLEnum format: Buffer format :param int width: Buffer width (pixels) @@ -61,17 +60,17 @@ def __init__(self, width=0, height=0, format=None): @property def width(self): - """ Buffer width (read-only). """ + """Buffer width (read-only).""" return self._width @property def height(self): - """ Buffer height (read-only). """ + """Buffer height (read-only).""" return self._height def resize(self, width, height): - """ Resize the buffer (deferred operation). + """Resize the buffer (deferred operation). :param int width: New buffer width (pixels) :param int height: New buffer height (pixels) @@ -83,44 +82,43 @@ def resize(self, width, height): self._height = height def _create(self): - """ Create buffer on GPU """ + """Create buffer on GPU""" - log.debug("GPU: Create render buffer") + log.debug('GPU: Create render buffer') self._handle = gl.glGenRenderbuffers(1) def _delete(self): - """ Delete buffer from GPU """ + """Delete buffer from GPU""" - log.debug("GPU: Deleting render buffer") + log.debug('GPU: Deleting render buffer') gl.glDeleteRenderbuffer(self._handle) def _activate(self): - """ Activate buffer on GPU """ + """Activate buffer on GPU""" - log.debug("GPU: Activate render buffer") + log.debug('GPU: Activate render buffer') gl.glBindRenderbuffer(gl.GL_RENDERBUFFER, self._handle) if self._need_resize: self._resize() self._need_resize = False def _deactivate(self): - """ Deactivate buffer on GPU """ + """Deactivate buffer on GPU""" - log.debug("GPU: Deactivate render buffer") + log.debug('GPU: Deactivate render buffer') gl.glBindRenderbuffer(gl.GL_RENDERBUFFER, 0) def _resize(self): - """ Buffer resize on GPU """ + """Buffer resize on GPU""" # WARNING: width/height should be checked against maximum size # maxsize = gl.glGetParameter(gl.GL_MAX_RENDERBUFFER_SIZE) - log.debug("GPU: Resize render buffer") - gl.glRenderbufferStorage(self._target, self._format, - self._width, self._height) + log.debug('GPU: Resize render buffer') + gl.glRenderbufferStorage(self._target, self._format, self._width, self._height) class ColorBuffer(RenderBuffer): - """ Color buffer object. + """Color buffer object. :param int width: Buffer width (pixels) :param int height: Buffer height (pixel) @@ -134,7 +132,7 @@ def __init__(self, width, height, format=gl.GL_RGBA): class DepthBuffer(RenderBuffer): - """ Depth buffer object. + """Depth buffer object. :param int width: Buffer width (pixels) :param int height: Buffer height (pixel) @@ -148,7 +146,7 @@ def __init__(self, width, height, format=gl.GL_DEPTH_COMPONENT): class StencilBuffer(RenderBuffer): - """ Stencil buffer object + """Stencil buffer object :param int width: Buffer width (pixels) :param int height: Buffer height (pixel) @@ -162,7 +160,7 @@ def __init__(self, width, height, format=gl.GL_STENCIL_INDEX8): class FrameBuffer(GLObject): - """ Framebuffer object. + """Framebuffer object. :param ColorBuffer color: One or several color buffers or None :param DepthBuffer depth: A depth buffer or None @@ -170,8 +168,7 @@ class FrameBuffer(GLObject): """ def __init__(self, color=None, depth=None, stencil=None): - """ - """ + """ """ GLObject.__init__(self) @@ -192,13 +189,13 @@ def __init__(self, color=None, depth=None, stencil=None): @property def color(self): - """ Color buffer attachment(s) (read/write) """ + """Color buffer attachment(s) (read/write)""" return self._color @color.setter def color(self, buffers): - """ Color buffer attachment(s) (read/write) """ + """Color buffer attachment(s) (read/write)""" if not isinstance(buffers, list): buffers = [buffers] @@ -207,9 +204,9 @@ def color(self, buffers): for i, buffer in enumerate(buffers): if self.width != 0 and self.width != buffer.width: - raise ValueError("Buffer width does not match") + raise ValueError('Buffer width does not match') elif self.height != 0 and self.height != buffer.height: - raise ValueError("Buffer height does not match") + raise ValueError('Buffer height does not match') self._width = buffer.width self._height = buffer.height @@ -219,24 +216,23 @@ def color(self, buffers): if isinstance(buffer, (ColorBuffer, Texture2D)) or buffer is None: self._pending_attachments.append((target, buffer)) else: - raise ValueError( - "Buffer must be a ColorBuffer, Texture2D or None") + raise ValueError('Buffer must be a ColorBuffer, Texture2D or None') self._need_attach = True @property def depth(self): - """ Depth buffer attachment (read/write) """ + """Depth buffer attachment (read/write)""" return self._depth @depth.setter def depth(self, buffer): - """ Depth buffer attachment (read/write) """ + """Depth buffer attachment (read/write)""" if self.width != 0 and self.width != buffer.width: - raise ValueError("Buffer width does not match") + raise ValueError('Buffer width does not match') elif self.height != 0 and self.height != buffer.height: - raise ValueError("Buffer height does not match") + raise ValueError('Buffer height does not match') self._width = buffer.width self._height = buffer.height @@ -245,24 +241,23 @@ def depth(self, buffer): if isinstance(buffer, (DepthBuffer, Texture2D)) or buffer is None: self._pending_attachments.append((target, buffer)) else: - raise ValueError( - "Buffer must be a DepthBuffer, Texture2D or None") + raise ValueError('Buffer must be a DepthBuffer, Texture2D or None') self._need_attach = True @property def stencil(self): - """ Stencil buffer attachment (read/write) """ + """Stencil buffer attachment (read/write)""" return self._stencil @stencil.setter def stencil(self, buffer): - """ Stencil buffer attachment (read/write) """ + """Stencil buffer attachment (read/write)""" if self.width != 0 and self.width != buffer.width: - raise ValueError("Buffer width does not match") + raise ValueError('Buffer width does not match') elif self.height != 0 and self.height != buffer.height: - raise ValueError("Buffer height does not match") + raise ValueError('Buffer height does not match') self._width = buffer.width self._height = buffer.height @@ -271,24 +266,23 @@ def stencil(self, buffer): if isinstance(buffer, StencilBuffer) or buffer is None: self._pending_attachments.append((target, buffer)) else: - raise ValueError( - "Buffer must be a StencilBuffer, Texture2D or None") + raise ValueError('Buffer must be a StencilBuffer, Texture2D or None') self._need_attach = True @property def width(self): - """ Buffer width (read only, pixels) """ + """Buffer width (read only, pixels)""" return self._width @property def height(self): - """ Buffer height (read only, pixels) """ + """Buffer height (read only, pixels)""" return self._height def resize(self, width, height): - """ Resize the buffer (deferred operation). + """Resize the buffer (deferred operation). This method will also resize any attached buffers. @@ -327,8 +321,7 @@ def resize(self, width, height): if isinstance(self.stencil, StencilBuffer): self.stencil.resize(width, height) elif isinstance(self.stencil, Texture2D): - stencil = np.resize( - self.stencil, (height, width, self.stencil.shape[2])) + stencil = np.resize(self.stencil, (height, width, self.stencil.shape[2])) stencil = stencil.view(self.stencil.__class__) self.stencil.delete() self.stencil = stencil @@ -338,59 +331,59 @@ def resize(self, width, height): self._need_attach = True def _create(self): - """ Create framebuffer on GPU """ + """Create framebuffer on GPU""" - log.debug("GPU: Create framebuffer") + log.debug('GPU: Create framebuffer') self._handle = gl.glGenFramebuffers(1) def _delete(self): - """ Delete buffer from GPU """ + """Delete buffer from GPU""" - log.debug("GPU: Delete framebuffer") + log.debug('GPU: Delete framebuffer') gl.glDeleteFramebuffer(self._handle) def _activate(self): - """ Activate framebuffer on GPU """ + """Activate framebuffer on GPU""" - log.debug("GPU: Activate render framebuffer") + log.debug('GPU: Activate render framebuffer') gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, self._handle) if self._need_attach: self._attach() self._need_attach = False - attachments = [gl.GL_COLOR_ATTACHMENT0 + - i for i in range(len(self.color))] + attachments = [gl.GL_COLOR_ATTACHMENT0 + i for i in range(len(self.color))] gl.glDrawBuffers(np.array(attachments, dtype=np.uint32)) def _deactivate(self): - """ Deactivate framebuffer on GPU """ + """Deactivate framebuffer on GPU""" - log.debug("GPU: Deactivate render framebuffer") + log.debug('GPU: Deactivate render framebuffer') gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, 0) # gl.glDrawBuffers([gl.GL_COLOR_ATTACHMENT0]) def _attach(self): - """ Attach render buffers to framebuffer """ + """Attach render buffers to framebuffer""" - log.debug("GPU: Attach render buffers") + log.debug('GPU: Attach render buffers') while self._pending_attachments: attachment, buffer = self._pending_attachments.pop(0) if buffer is None: - gl.glFramebufferRenderbuffer(gl.GL_FRAMEBUFFER, attachment, - gl.GL_RENDERBUFFER, 0) + gl.glFramebufferRenderbuffer(gl.GL_FRAMEBUFFER, attachment, gl.GL_RENDERBUFFER, 0) elif isinstance(buffer, RenderBuffer): buffer.activate() - gl.glFramebufferRenderbuffer(gl.GL_FRAMEBUFFER, attachment, - gl.GL_RENDERBUFFER, buffer.handle) + gl.glFramebufferRenderbuffer( + gl.GL_FRAMEBUFFER, attachment, gl.GL_RENDERBUFFER, buffer.handle + ) buffer.deactivate() elif isinstance(buffer, Texture2D): buffer.activate() # INFO: 0 is for mipmap level 0 (default) of the texture - gl.glFramebufferTexture2D(gl.GL_FRAMEBUFFER, attachment, - buffer.target, buffer.handle, 0) + gl.glFramebufferTexture2D( + gl.GL_FRAMEBUFFER, attachment, buffer.target, buffer.handle, 0 + ) buffer.deactivate() else: - raise ValueError("Invalid attachment") + raise ValueError('Invalid attachment') res = gl.glCheckFramebufferStatus(gl.GL_FRAMEBUFFER) if res == gl.GL_FRAMEBUFFER_COMPLETE: @@ -398,17 +391,14 @@ def _attach(self): elif res == 0: raise RuntimeError('Target not equal to GL_FRAMEBUFFER') elif res == gl.GL_FRAMEBUFFER_INCOMPLETE_ATTACHMENT: - raise RuntimeError( - 'FrameBuffer attachments are incomplete.') + raise RuntimeError('FrameBuffer attachments are incomplete.') elif res == gl.GL_FRAMEBUFFER_INCOMPLETE_MISSING_ATTACHMENT: - raise RuntimeError( - 'No valid attachments in the FrameBuffer.') + raise RuntimeError('No valid attachments in the FrameBuffer.') elif res == gl.GL_FRAMEBUFFER_INCOMPLETE_DIMENSIONS: - raise RuntimeError( - 'attachments do not have the same width and height.') + raise RuntimeError('attachments do not have the same width and height.') elif res == gl.GL_FRAMEBUFFER_INCOMPLETE_FORMATS: - raise RuntimeError('Internal format of attachment ' - 'is not renderable.') + raise RuntimeError('Internal format of attachment is not renderable.') elif res == gl.GL_FRAMEBUFFER_UNSUPPORTED: - raise RuntimeError('Combination of internal formats used ' - 'by attachments is not supported.') + raise RuntimeError( + 'Combination of internal formats used by attachments is not supported.' + ) diff --git a/phy/plot/gloo/gl.py b/phy/plot/gloo/gl.py index 48c440fe0..cf80d2006 100644 --- a/phy/plot/gloo/gl.py +++ b/phy/plot/gloo/gl.py @@ -12,30 +12,38 @@ import ctypes import OpenGL + OpenGL.ERROR_ON_COPY = True # -> if set to a True value before importing the numpy/lists support modules, # will cause array operations to raise OpenGL.error.CopyError if the # operation would cause a data-copy in order to make the passed data-type # match the target data-type. -FormatHandler('gloo', - 'OpenGL.arrays.numpymodule.NumpyHandler', [ - 'gloo.buffer.VertexBuffer', - 'gloo.buffer.IndexBuffer', - 'gloo.atlas.Atlas', - 'gloo.texture.Texture2D', - 'gloo.texture.Texture1D', - 'gloo.texture.FloatTexture2D', - 'gloo.texture.FloatTexture1D', - 'gloo.texture.TextureCube', - ]) +FormatHandler( + 'gloo', + 'OpenGL.arrays.numpymodule.NumpyHandler', + [ + 'gloo.buffer.VertexBuffer', + 'gloo.buffer.IndexBuffer', + 'gloo.atlas.Atlas', + 'gloo.texture.Texture2D', + 'gloo.texture.Texture1D', + 'gloo.texture.FloatTexture2D', + 'gloo.texture.FloatTexture1D', + 'gloo.texture.TextureCube', + ], +) def cleanupCallback(context=None): """Create a cleanup callback to clear context-specific storage for the current context""" - def callback(context=contextdata.getContext(context)): + + def callback(context=None): # ← Remove the function call from default """Clean up the context, assumes that the context will *not* render again!""" + if context is None: # ← Handle None case inside the function + context = contextdata.getContext(context) contextdata.cleanupContext(context) + return callback @@ -46,10 +54,10 @@ def clear(color=(0, 0, 0, 10)): def enable_depth_mask(): glClearColor(0, 0, 0, 0) # noqa - glClearDepth(1.) # noqa + glClearDepth(1.0) # noqa glEnable(GL_BLEND) # noqa - glDepthRange(0., 1.) # noqa + glDepthRange(0.0, 1.0) # noqa glDepthFunc(GL_EQUAL) # noqa glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) # noqa @@ -69,9 +77,15 @@ def glGetActiveAttrib(program, index): type = ctypes.c_int() name = ctypes.create_string_buffer(bufsize) # Call - _glGetActiveAttrib(program, index, - bufsize, ctypes.byref(length), ctypes.byref(size), - ctypes.byref(type), name) + _glGetActiveAttrib( + program, + index, + bufsize, + ctypes.byref(length), + ctypes.byref(size), + ctypes.byref(type), + name, + ) # Return Python objects return name.value, size.value, type.value diff --git a/phy/plot/gloo/globject.py b/phy/plot/gloo/globject.py index fdddc80b5..1d2c1d74a 100644 --- a/phy/plot/gloo/globject.py +++ b/phy/plot/gloo/globject.py @@ -8,14 +8,14 @@ log = logging.getLogger(__name__) -class GLObject(object): - """ Generic GL object that may live both on CPU and GPU """ +class GLObject: + """Generic GL object that may live both on CPU and GPU""" # Internal id counter to keep track of GPU objects _idcount = 0 def __init__(self): - """ Initialize the object in the default state """ + """Initialize the object in the default state""" self._handle = -1 self._target = None @@ -44,26 +44,26 @@ def __init__(self): @property def need_create(self): - """ Whether object needs to be created """ + """Whether object needs to be created""" return self._need_create @property def need_update(self): - """ Whether object needs to be updated """ + """Whether object needs to be updated""" return self._need_update @property def need_setup(self): - """ Whether object needs to be setup """ + """Whether object needs to be setup""" return self._need_setup @property def need_delete(self): - """ Whether object needs to be deleted """ + """Whether object needs to be deleted""" return self._need_delete def delete(self): - """ Delete the object from GPU memory """ + """Delete the object from GPU memory""" # if self.need_delete: self._delete() @@ -74,9 +74,9 @@ def delete(self): self._need_delete = False def activate(self): - """ Activate the object on GPU """ + """Activate the object on GPU""" - if hasattr(self, "base") and isinstance(self.base, GLObject): + if hasattr(self, 'base') and isinstance(self.base, GLObject): self.base.activate() return @@ -91,63 +91,51 @@ def activate(self): self._need_setup = False if self.need_update: - log.log(5, "%s need update" % self.handle) + log.log(5, f'{self.handle} need update') self._update() self._need_update = False def deactivate(self): - """ Deactivate the object on GPU """ + """Deactivate the object on GPU""" - if hasattr(self, "base") and isinstance(self.base, GLObject): + if hasattr(self, 'base') and isinstance(self.base, GLObject): self.base.deactivate() else: self._deactivate() @property def handle(self): - """ Name of this object on the GPU """ + """Name of this object on the GPU""" - if hasattr(self, "base") and isinstance(self.base, GLObject): - if hasattr(self.base, "_handle"): + if hasattr(self, 'base') and isinstance(self.base, GLObject): + if hasattr(self.base, '_handle'): return self.base._handle return self._handle # return self._handle @property def target(self): - """ OpenGL type of object. """ + """OpenGL type of object.""" - if hasattr(self, "base") and isinstance(self.base, GLObject): + if hasattr(self, 'base') and isinstance(self.base, GLObject): return self.base._target return self._target # return self._handle def _create(self): - """ Dummy create method """ - - pass + """Dummy create method""" def _delete(self): - """ Dummy delete method """ - - pass + """Dummy delete method""" def _activate(self): - """ Dummy activate method """ - - pass + """Dummy activate method""" def _deactivate(self): - """ Dummy deactivate method """ - - pass + """Dummy deactivate method""" def _setup(self): - """ Dummy setup method """ - - pass + """Dummy setup method""" def _update(self): - """ Dummy update method """ - - pass + """Dummy update method""" diff --git a/phy/plot/gloo/gpudata.py b/phy/plot/gloo/gpudata.py index 8e0dd9591..21d7026ad 100644 --- a/phy/plot/gloo/gpudata.py +++ b/phy/plot/gloo/gpudata.py @@ -47,7 +47,7 @@ def __array_finalize__(self, obj): @property def pending_data(self): - """ Pending data region as (byte offset, byte size) """ + """Pending data region as (byte offset, byte size)""" if isinstance(self.base, GPUData): return self.base.pending_data @@ -59,7 +59,7 @@ def pending_data(self): @property def stride(self): - """ Item stride in the base array. """ + """Item stride in the base array.""" if self.base is None: return self.ravel().strides[0] @@ -68,7 +68,7 @@ def stride(self): @property def offset(self): - """ Byte offset in the base array. """ + """Byte offset in the base array.""" return self._extents[0] @@ -105,7 +105,7 @@ def _compute_extents(self, Z): return 0, self.size * self.itemsize def __getitem__(self, key): - """ FIXME: Need to take care of case where key is a list or array """ + """FIXME: Need to take care of case where key is a list or array""" Z = np.ndarray.__getitem__(self, key) if not hasattr(Z, 'shape') or Z.shape == (): @@ -114,7 +114,7 @@ def __getitem__(self, key): return Z def __setitem__(self, key, value): - """ FIXME: Need to take care of case where key is a list or array """ + """FIXME: Need to take care of case where key is a list or array""" Z = np.ndarray.__getitem__(self, key) if Z.shape == (): diff --git a/phy/plot/gloo/parser.py b/phy/plot/gloo/parser.py index 2cceb8f3b..bb8d6be21 100644 --- a/phy/plot/gloo/parser.py +++ b/phy/plot/gloo/parser.py @@ -3,11 +3,10 @@ # Distributed under the (new) BSD License. # ----------------------------------------------------------------------------- -import re import logging +import re from pathlib import Path - log = logging.getLogger(__name__) @@ -16,9 +15,9 @@ def _find(filename): def remove_comments(code): - """ Remove C-style comment from GLSL code string """ + """Remove C-style comment from GLSL code string""" - pattern = r"(\".*?\"|\'.*?\')|(/\*.*?\*/|//[^\r\n]*\n)" + pattern = r'(\".*?\"|\'.*?\')|(/\*.*?\*/|//[^\r\n]*\n)' # first group captures quoted strings (double or single) # second group captures comments (//single-line or /* multi-line */) regex = re.compile(pattern, re.MULTILINE | re.DOTALL) @@ -27,7 +26,7 @@ def do_replace(match): # if the 2nd group (capturing comments) is not None, # it means we have captured a non-quoted (real) comment string. if match.group(2) is not None: - return "" # so we will return empty to remove the comment + return '' # so we will return empty to remove the comment else: # otherwise, we will return the 1st group return match.group(1) # captured quoted-string @@ -35,7 +34,7 @@ def do_replace(match): def remove_version(code): - """ Remove any version directive """ + """Remove any version directive""" pattern = r'\#\s*version[^\r\n]*\n' regex = re.compile(pattern, re.MULTILINE | re.DOTALL) @@ -43,7 +42,7 @@ def remove_version(code): def merge_includes(code): - """ Merge all includes recursively """ + """Merge all includes recursively""" # pattern = '\#\s*include\s*"(?P[a-zA-Z0-9\-\.\/]+)"[^\r\n]*\n' pattern = r'\#\s*include\s*"(?P[a-zA-Z0-9\-\.\/]+)"' @@ -51,18 +50,18 @@ def merge_includes(code): includes = [] def replace(match): - filename = match.group("filename") + filename = match.group('filename') if filename not in includes: includes.append(filename) path = _find(filename) if not path: - log.critical('"%s" not found' % filename) - raise RuntimeError("File not found") - text = '\n// --- start of "%s" ---\n' % filename + log.critical(f'"{filename}" not found') + raise RuntimeError('File not found') + text = f'\n// --- start of "{filename}" ---\n' with open(str(path)) as f: text += remove_comments(f.read()) - text += '// --- end of "%s" ---\n' % filename + text += f'// --- end of "{filename}" ---\n' return text return '' @@ -77,7 +76,7 @@ def replace(match): def preprocess(code): - """ Preprocess a code by removing comments, version and merging includes""" + """Preprocess a code by removing comments, version and merging includes""" if code: # code = remove_comments(code) @@ -86,10 +85,10 @@ def preprocess(code): return code -def get_declarations(code, qualifier=""): - """ Extract declarations of type: +def get_declarations(code, qualifier=''): + """Extract declarations of type: - qualifier type name[,name,...]; + qualifier type name[,name,...]; """ if not len(code): @@ -98,25 +97,35 @@ def get_declarations(code, qualifier=""): variables = [] if isinstance(qualifier, list): - qualifier = "(" + "|".join([str(q) for q in qualifier]) + ")" + qualifier = f'({"|".join([str(q) for q in qualifier])})' - if qualifier != "": - re_type = re.compile(r""" - %s # Variable qualifier + re_type = ( + re.compile( + rf""" + {qualifier} # Variable qualifier \s+(?P\w+) # Variable type \s+(?P[\w,\[\]\n =\.$]+); # Variable name(s) - """ % qualifier, re.VERBOSE) - else: - re_type = re.compile(r""" + """, + re.VERBOSE, + ) + if qualifier != '' + else re.compile( + r""" \s*(?P\w+) # Variable type \s+(?P[\w\[\] ]+) # Variable name(s) - """, re.VERBOSE) + """, + re.VERBOSE, + ) + ) - re_names = re.compile(r""" + re_names = re.compile( + r""" (?P\w+) # Variable name \s*(\[(?P\d+)\])? # Variable size (\s*[^,]+)? - """, re.VERBOSE) + """, + re.VERBOSE, + ) for match in re.finditer(re_type, code): vtype = match.group('type') @@ -129,10 +138,9 @@ def get_declarations(code, qualifier=""): else: size = int(size) if size == 0: - raise RuntimeError( - "Size of a variable array cannot be zero") + raise RuntimeError('Size of a variable array cannot be zero') for i in range(size): - iname = '%s[%d]' % (name, i) + iname = f'{name}[{i}]' variables.append((iname, vtype)) return variables @@ -142,36 +150,39 @@ def get_hooks(code): return [] hooks = [] - re_hooks = re.compile(r"""\<(?P\w+) + re_hooks = re.compile( + r"""\<(?P\w+) (\.(?P.+))? - (\([^<>]+\))?\>""", re.VERBOSE) + (\([^<>]+\))?\>""", + re.VERBOSE, + ) for match in re.finditer(re_hooks, code): hooks.append((match.group('hook'), None)) return list(set(hooks)) def get_args(code): - return get_declarations(code, qualifier="") + return get_declarations(code, qualifier='') def get_externs(code): - return get_declarations(code, qualifier="extern") + return get_declarations(code, qualifier='extern') def get_consts(code): - return get_declarations(code, qualifier="const") + return get_declarations(code, qualifier='const') def get_uniforms(code): - return get_declarations(code, qualifier="uniform") + return get_declarations(code, qualifier='uniform') def get_attributes(code): - return get_declarations(code, qualifier=["attribute", "in"]) + return get_declarations(code, qualifier=['attribute', 'in']) def get_varyings(code): - return get_declarations(code, qualifier="varying") + return get_declarations(code, qualifier='varying') def get_functions(code): @@ -181,28 +192,31 @@ def brace_matcher(n): # after n+1 levels. Matches any string with balanced # braces inside; add the outer braces yourself if needed. # Nongreedy. - return r"[^{}]*?(?:{" * n + r"[^{}]*?" + r"}[^{}]*?)*?" * n + return r'[^{}]*?(?:{' * n + r'[^{}]*?' + r'}[^{}]*?)*?' * n functions = [] - regex = re.compile(r""" + regex = re.compile( + rf""" \s*(?P\w+) # Function return type \s+(?P[\w]+) # Function name \s*\((?P.*?)\) # Function arguments - \s*\{(?P%s)\} # Function content - """ % brace_matcher(5), re.VERBOSE | re.DOTALL) + \s*\{{(?P{brace_matcher(5)})\}} # Function content + """, + re.VERBOSE | re.DOTALL, + ) for match in re.finditer(regex, code): rtype = match.group('type') name = match.group('name') args = match.group('args') fcode = match.group('code') - if name not in ("if", "while"): + if name not in ('if', 'while'): functions.append((rtype, name, args, fcode)) return functions def parse(code): - """ Parse a shader """ + """Parse a shader""" code = preprocess(code) externs = get_externs(code) if code else [] @@ -213,10 +227,12 @@ def parse(code): hooks = get_hooks(code) if code else [] functions = get_functions(code) if code else [] - return {'externs': externs, - 'consts': consts, - 'uniforms': uniforms, - 'attributes': attributes, - 'varyings': varyings, - 'hooks': hooks, - 'functions': functions} + return { + 'externs': externs, + 'consts': consts, + 'uniforms': uniforms, + 'attributes': attributes, + 'varyings': varyings, + 'hooks': hooks, + 'functions': functions, + } diff --git a/phy/plot/gloo/program.py b/phy/plot/gloo/program.py index 1e6cd03e3..f5237aa35 100644 --- a/phy/plot/gloo/program.py +++ b/phy/plot/gloo/program.py @@ -4,19 +4,18 @@ # ----------------------------------------------------------------------------- import logging -from operator import attrgetter import re +from operator import attrgetter import numpy as np from . import gl -from .snippet import Snippet -from .globject import GLObject from .array import VertexArray -from .buffer import VertexBuffer, IndexBuffer -from .shader import VertexShader, FragmentShader, GeometryShader -from .variable import Uniform, Attribute - +from .buffer import IndexBuffer, VertexBuffer +from .globject import GLObject +from .shader import FragmentShader, GeometryShader, VertexShader +from .snippet import Snippet +from .variable import Attribute, Uniform log = logging.getLogger(__name__) @@ -49,8 +48,9 @@ class Program(GLObject): """ # --------------------------------- - def __init__(self, vertex=None, fragment=None, geometry=None, - count=0, version="120"): + def __init__( + self, vertex=None, fragment=None, geometry=None, count=0, version='120' + ): """ Initialize the program and optionally buffer. """ @@ -70,7 +70,7 @@ def __init__(self, vertex=None, fragment=None, geometry=None, self._vertex = vertex self._vertex._version = version else: - log.error("vertex must be a string or a VertexShader") + log.error('vertex must be a string or a VertexShader') if fragment is not None: if isinstance(fragment, str): @@ -79,7 +79,7 @@ def __init__(self, vertex=None, fragment=None, geometry=None, self._fragment = fragment self._fragment._version = version else: - log.error("fragment must be a string or a FragmentShader") + log.error('fragment must be a string or a FragmentShader') if geometry is not None: if isinstance(geometry, str): @@ -88,7 +88,7 @@ def __init__(self, vertex=None, fragment=None, geometry=None, self._geometry = geometry self._geometry._version = version else: - log.error("geometry must be a string or a GeometryShader") + log.error('geometry must be a string or a GeometryShader') self._uniforms = {} self._attributes = {} @@ -101,10 +101,11 @@ def __init__(self, vertex=None, fragment=None, geometry=None, # Build associated structured vertex buffer if count is given if self._count > 0: dtype = [] - for attribute in sorted(self._attributes.values(), filter=attrgetter('name')): + for attribute in sorted( + self._attributes.values(), filter=attrgetter('name') + ): dtype.append(attribute.dtype) - self._buffer = np.zeros( - self._count, dtype=dtype).view(VertexBuffer) + self._buffer = np.zeros(self._count, dtype=dtype).view(VertexBuffer) self.bind(self._buffer) def __len__(self): @@ -115,17 +116,17 @@ def __len__(self): @property def vertex(self): - """ Vertex shader object """ + """Vertex shader object""" return self._vertex @property def fragment(self): - """ Fragment shader object """ + """Fragment shader object""" return self._fragment @property def geometry(self): - """ Geometry shader object """ + """Geometry shader object""" return self._geometry @property @@ -147,13 +148,14 @@ def hooks(self): } """ - return tuple(self._vert_hooks.keys()) + \ - tuple(self._frag_hooks.keys()) + \ - tuple(self._geom_hooks.keys()) + return ( + tuple(self._vert_hooks.keys()) + + tuple(self._frag_hooks.keys()) + + tuple(self._geom_hooks.keys()) + ) def _setup(self): - """ Setup the program by resolving all pending hooks. """ - pass + """Setup the program by resolving all pending hooks.""" def _create(self): """ @@ -162,17 +164,17 @@ def _create(self): A GL context must be available to be able to build (link) """ - log.log(5, "GPU: Creating program") + log.log(5, 'GPU: Creating program') # Check if program has been created if self._handle <= 0: self._handle = gl.glCreateProgram() if not self._handle: - raise ValueError("Cannot create program object") + raise ValueError('Cannot create program object') self._build_shaders(self._handle) - log.log(5, "GPU: Linking program") + log.log(5, 'GPU: Linking program') # Link the program gl.glLinkProgram(self._handle) @@ -197,15 +199,15 @@ def _create(self): attribute.active = False def _build_shaders(self, program): - """ Build and attach shaders """ + """Build and attach shaders""" # Check if we have at least something to attach if not self._vertex: - raise ValueError("No vertex shader has been given") + raise ValueError('No vertex shader has been given') if not self._fragment: - raise ValueError("No fragment shader has been given") + raise ValueError('No fragment shader has been given') - log.log(5, "GPU: Attaching shaders to program") + log.log(5, 'GPU: Attaching shaders to program') # Attach shaders attached = gl.glGetAttachedShaders(program) @@ -220,45 +222,51 @@ def _build_shaders(self, program): shader.activate() if isinstance(shader, GeometryShader): if shader.vertices_out is not None: - gl.glProgramParameteriEXT(self._handle, - gl.GL_GEOMETRY_VERTICES_OUT_EXT, - shader.vertices_out) + gl.glProgramParameteriEXT( + self._handle, + gl.GL_GEOMETRY_VERTICES_OUT_EXT, + shader.vertices_out, + ) if shader.input_type is not None: - gl.glProgramParameteriEXT(self._handle, - gl.GL_GEOMETRY_INPUT_TYPE_EXT, - shader.input_type) + gl.glProgramParameteriEXT( + self._handle, + gl.GL_GEOMETRY_INPUT_TYPE_EXT, + shader.input_type, + ) if shader.output_type is not None: - gl.glProgramParameteriEXT(self._handle, - gl.GL_GEOMETRY_OUTPUT_TYPE_EXT, - shader.output_type) + gl.glProgramParameteriEXT( + self._handle, + gl.GL_GEOMETRY_OUTPUT_TYPE_EXT, + shader.output_type, + ) gl.glAttachShader(program, shader.handle) shader._program = self def _build_hooks(self): - """ Build hooks """ + """Build hooks""" self._vert_hooks = {} self._frag_hooks = {} self._geom_hooks = {} if self._vertex is not None: - for (hook, subhook) in self._vertex.hooks: + for hook, subhook in self._vertex.hooks: self._vert_hooks[hook] = None if self._fragment is not None: - for (hook, subhook) in self._fragment.hooks: + for hook, subhook in self._fragment.hooks: self._frag_hooks[hook] = None if self._geometry is not None: - for (hook, subhook) in self._geometry.hooks: + for hook, subhook in self._geometry.hooks: self._geom_hooks[hook] = None def _build_uniforms(self): - """ Build the uniform objects """ + """Build the uniform objects""" # We might rebuild the program because of snippets but we must # keep already bound uniforms count = 0 - for (name, gtype) in self.all_uniforms: + for name, gtype in self.all_uniforms: if name not in self._uniforms.keys(): uniform = Uniform(self, name, gtype) else: @@ -271,13 +279,13 @@ def _build_uniforms(self): self._need_update = True def _build_attributes(self): - """ Build the attribute objects """ + """Build the attribute objects""" # We might rebuild the program because of snippets but we must # keep already bound attributes dtype = [] - for (name, gtype) in self.all_attributes: + for name, gtype in self.all_attributes: if name not in self._attributes.keys(): attribute = Attribute(self, name, gtype) else: @@ -338,22 +346,24 @@ def __setitem__(self, name, data): self._attributes[name].set_data(data) else: raise IndexError( - "Unknown item %s (no corresponding hook, uniform or attribute)" % name) + f'Unknown item {name} (no corresponding hook, uniform or attribute)' + ) def __getitem__(self, name): if name in self._vert_hooks.keys(): return self._vert_hooks[name] elif name in self._frag_hooks.keys(): return self._frag_hooks[name] -# if name in self._hooks.keys(): -# return self._hooks[name][1] + # if name in self._hooks.keys(): + # return self._hooks[name][1] elif name in self._uniforms.keys(): return self._uniforms[name].data elif name in self._attributes.keys(): return self._attributes[name].data else: raise IndexError( - "Unknown item (no corresponding hook, uniform or attribute)") + 'Unknown item (no corresponding hook, uniform or attribute)' + ) def __contains__(self, name): try: @@ -370,7 +380,7 @@ def __contains__(self, name): def _activate(self): """Activate the program as part of current rendering state.""" - log.log(5, "GPU: Activating program (id=%d)" % self._id) + log.log(5, f'GPU: Activating program (id={self._id})') gl.glUseProgram(self.handle) for uniform in sorted(self._uniforms.values(), key=attrgetter('name')): @@ -393,7 +403,7 @@ def _deactivate(self): # Need fix when dealing with vertex arrays (only need to active the array) for attribute in sorted(self._attributes.values(), key=attrgetter('name')): attribute.deactivate() - log.log(5, "GPU: Deactivating program (id=%d)" % self._id) + log.log(5, f'GPU: Deactivating program (id={self._id})') @property def all_uniforms(self): @@ -447,7 +457,7 @@ def active_uniforms(self): name = m.group('name') if size >= 1: for i in range(size): - name = '%s[%d]' % (m.group('name'), i) + name = f'{m.group("name")}[{i}]' uniforms.append((name, gtype)) else: uniforms.append((name, gtype)) @@ -531,7 +541,7 @@ def active_attributes(self): name = m.group('name') if size >= 1: for i in range(size): - name = '%s[%d]' % (m.group('name'), i) + name = f'{m.group("name")}[{i}]' attributes.append((name, gtype)) else: attributes.append((name, gtype)) @@ -575,7 +585,7 @@ def n_vertices(self): # first=0, count=None): def draw(self, mode=None, indices=None): - """ Draw using the specified mode & indices. + """Draw using the specified mode & indices. :param gl.GLEnum mode: One of @@ -592,7 +602,7 @@ def draw(self, mode=None, indices=None): """ if isinstance(mode, str): - mode = getattr(gl, 'GL_%s' % mode.upper()) + mode = getattr(gl, f'GL_{mode.upper()}') self.activate() attributes = self._attributes.values() @@ -605,9 +615,11 @@ def draw(self, mode=None, indices=None): if isinstance(indices, IndexBuffer): indices.activate() - gltypes = {np.dtype(np.uint8): gl.GL_UNSIGNED_BYTE, - np.dtype(np.uint16): gl.GL_UNSIGNED_SHORT, - np.dtype(np.uint32): gl.GL_UNSIGNED_INT} + gltypes = { + np.dtype(np.uint8): gl.GL_UNSIGNED_BYTE, + np.dtype(np.uint16): gl.GL_UNSIGNED_SHORT, + np.dtype(np.uint32): gl.GL_UNSIGNED_INT, + } gl.glDrawElements(mode, indices.size, gltypes[indices.dtype], None) indices.deactivate() else: diff --git a/phy/plot/gloo/shader.py b/phy/plot/gloo/shader.py index 4849cd7cd..22bc479bd 100644 --- a/phy/plot/gloo/shader.py +++ b/phy/plot/gloo/shader.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # ----------------------------------------------------------------------------- # Copyright (c) 2009-2016 Nicolas P. Rougier. All rights reserved. # Distributed under the (new) BSD License. @@ -34,12 +33,10 @@ import os.path import re -from .import gl -from .snippet import Snippet +from . import gl from .globject import GLObject -from .parser import (remove_comments, preprocess, - get_uniforms, get_attributes, get_hooks) - +from .parser import get_attributes, get_hooks, get_uniforms, preprocess, remove_comments +from .snippet import Snippet log = logging.getLogger(__name__) @@ -85,7 +82,7 @@ class Shader(GLObject): 'samplerCube': gl.GL_SAMPLER_CUBE, } - def __init__(self, target, code, version="120"): + def __init__(self, target, code, version='120'): """ Initialize the shader. """ @@ -96,7 +93,7 @@ def __init__(self, target, code, version="120"): self._version = version if os.path.isfile(code): - with open(str(code), 'rt') as file: + with open(str(code)) as file: self._code = preprocess(file.read()) self._source = os.path.basename(code) else: @@ -115,21 +112,22 @@ def __setitem__(self, name, snippet): self._snippets[name] = snippet def _replace_hooks(self, name, snippet): - - #re_hook = r"(?P%s)(\.(?P\w+))?" % name - re_hook = r"(?P%s)(\.(?P[\.\w\!]+))?" % name - re_args = r"(\((?P[^<>]+)\))?" + # re_hook = r"(?P%s)(\.(?P\w+))?" % name + re_hook = rf'(?P{name})(\.(?P[\.\w\!]+))?' + re_args = r'(\((?P[^<>]+)\))?' # re_hooks = re.compile("\<" + re_hook + re_args + "\>", re.VERBOSE) - pattern = r"\<" + re_hook + re_args + r"\>" + pattern = r'\<' + re_hook + re_args + r'\>' # snippet is not a Snippet (it should be a string) if not isinstance(snippet, Snippet): + def replace(match): # hook = match.group('hook') subhook = match.group('subhook') if subhook: - return snippet + '.' + subhook + return f'{snippet}.{subhook}' return snippet + self._hooked = re.sub(pattern, replace, self._hooked) return @@ -138,9 +136,9 @@ def replace(match): # Replace expression of type def replace_with_args(match): - #hook = match.group('hook') + # hook = match.group('hook') subhook = match.group('subhook') - #args = match.group('args') + # args = match.group('args') if subhook and '.' in subhook: s = snippet @@ -155,7 +153,7 @@ def replace_with_args(match): # -> A(B(C("t"))) # (t) -> A("t") override = False - if subhook[-1] == "!": + if subhook[-1] == '!': override = True subhook = subhook[:-1] @@ -166,66 +164,66 @@ def replace_with_args(match): # If subhook is a variable (uniform/attribute/varying) if subhook in s.globals: return s.globals[subhook] - return s.mangled_call(subhook, match.group("args"), override=override) + return s.mangled_call(subhook, match.group('args'), override=override) # If subhook is a variable (uniform/attribute/varying) if subhook in snippet.globals: return snippet.globals[subhook] - return snippet.mangled_call(subhook, match.group("args")) + return snippet.mangled_call(subhook, match.group('args')) self._hooked = re.sub(pattern, replace_with_args, self._hooked) def reset(self): - """ Reset shader snippets """ + """Reset shader snippets""" self._snippets = {} @property def code(self): - """ Shader source code (built from original and snippet codes) """ + """Shader source code (built from original and snippet codes)""" # Last minute hook settings self._hooked = self._code for name, snippet in self._snippets.items(): self._replace_hooks(name, snippet) - snippet_code = "// --- Snippets code : start --- //\n" + snippet_code = '// --- Snippets code : start --- //\n' deps = [] for snippet in self._snippets.values(): if isinstance(snippet, Snippet): deps.extend(snippet.dependencies) for snippet in list(set(deps)): snippet_code += snippet.mangled_code() - snippet_code += "// --- Snippets code : end --- //\n" + snippet_code += '// --- Snippets code : end --- //\n' return snippet_code + self._hooked def _create(self): - """ Create the shader """ + """Create the shader""" - log.log(5, "GPU: Creating shader") + log.log(5, 'GPU: Creating shader') # Check if we have something to compile if not self.code: - raise RuntimeError("No code has been given") + raise RuntimeError('No code has been given') # Check that shader object has been created if self._handle <= 0: self._handle = gl.glCreateShader(self._target) if self._handle <= 0: - raise RuntimeError("Cannot create shader object") + raise RuntimeError('Cannot create shader object') def _update(self): - """ Compile the source and checks everything's ok """ + """Compile the source and checks everything's ok""" - log.log(5, "GPU: Compiling shader") + log.log(5, 'GPU: Compiling shader') if len(self.hooks): hooks = [name for name, snippet in self.hooks] - error = "Shader has pending hooks (%s), cannot compile" % hooks + error = f'Shader has pending hooks ({hooks}), cannot compile' raise RuntimeError(error) # Set shader version - code = ("#version %s\n" % self._version) + self.code + code = f'#version {self._version}\n{self.code}' gl.glShaderSource(self._handle, code) # Actual compilation @@ -236,10 +234,10 @@ def _update(self): parsed_errors = self._parse_error(error) for lineno, mesg in parsed_errors: self._print_error(mesg, lineno - 1) - raise RuntimeError("Shader compilation error") + raise RuntimeError('Shader compilation error') def _delete(self): - """ Delete shader from GPU memory (if it was present). """ + """Delete shader from GPU memory (if it was present).""" gl.glDeleteShader(self._handle) @@ -248,15 +246,18 @@ def _delete(self): # 0(7): error C1008: undefined variable "MV" # 0(2) : error C0118: macros prefixed with '__' are reserved re.compile( - r'^\s*(\d+)\((?P\d+)\)\s*:\s(?P.*)', re.MULTILINE), + r'^\s*(\d+)\((?P\d+)\)\s*:\s(?P.*)', re.MULTILINE + ), # ATI / Intel # ERROR: 0:131: '{' : syntax error parse error re.compile( - r'^\s*ERROR:\s(\d+):(?P\d+):\s(?P.*)', re.MULTILINE), + r'^\s*ERROR:\s(\d+):(?P\d+):\s(?P.*)', re.MULTILINE + ), # Nouveau # 0:28(16): error: syntax error, unexpected ')', expecting '(' re.compile( - r'^\s*(\d+):(?P\d+)\((\d+)\):\s(?P.*)', re.MULTILINE) + r'^\s*(\d+):(?P\d+)\((\d+)\):\s(?P.*)', re.MULTILINE + ), ] def _parse_error(self, error): @@ -272,11 +273,12 @@ def _parse_error(self, error): for error_re in self._ERROR_RE: matches = list(error_re.finditer(error)) if matches: - errors = [(int(m.group('line_no')), m.group('error_msg')) - for m in matches] + errors = [ + (int(m.group('line_no')), m.group('error_msg')) for m in matches + ] return sorted(errors, key=lambda elem: elem[0]) else: - raise ValueError('Unknown GLSL error format:\n{}\n'.format(error)) + raise ValueError(f'Unknown GLSL error format:\n{error}\n') def _print_error(self, error, lineno): """ @@ -294,24 +296,24 @@ def _print_error(self, error, lineno): start = max(0, lineno - 3) end = min(len(lines), lineno + 3) - print('Error in %s' % (repr(self))) - print(' -> %s' % error) + print(f'Error in {repr(self)}') + print(f' -> {error}') print() if start > 0: print(' ...') for i, line in enumerate(lines[start:end]): if (i + start) == lineno: - print(' %03d %s' % (i + start, line)) + print(f' {i + start:03d} {line}') else: if len(line): - print(' %03d %s' % (i + start, line)) + print(f' {i + start:03d} {line}') if end < len(lines): print(' ...') print() @property def hooks(self): - """ Shader hooks (place where snippets can be inserted) """ + """Shader hooks (place where snippets can be inserted)""" # We get hooks from the original code, not the hooked one code = remove_comments(self._hooked) @@ -319,7 +321,7 @@ def hooks(self): @property def uniforms(self): - """ Shader uniforms obtained from source code """ + """Shader uniforms obtained from source code""" code = remove_comments(self.code) gtypes = Shader._gtypes @@ -327,7 +329,7 @@ def uniforms(self): @property def attributes(self): - """ Shader attributes obtained from source code """ + """Shader attributes obtained from source code""" code = remove_comments(self.code) gtypes = Shader._gtypes @@ -336,58 +338,64 @@ def attributes(self): # ------------------------------------------------------ VertexShader class --- class VertexShader(Shader): - """ Vertex shader class """ + """Vertex shader class""" - def __init__(self, code=None, version="120"): + def __init__(self, code=None, version='120'): Shader.__init__(self, gl.GL_VERTEX_SHADER, code, version) @property def code(self): - code = super(VertexShader, self).code - code = "#define _GLUMPY__VERTEX_SHADER__\n" + code + code = super().code + code = f'#define _GLUMPY__VERTEX_SHADER__\n{code}' return code def __repr__(self): - return "Vertex shader %d (%s)" % (self._id, self._source) + return f'Vertex shader {self._id} ({self._source})' class FragmentShader(Shader): - """ Fragment shader class """ + """Fragment shader class""" - def __init__(self, code=None, version="120"): + def __init__(self, code=None, version='120'): Shader.__init__(self, gl.GL_FRAGMENT_SHADER, code, version) @property def code(self): - code = super(FragmentShader, self).code - code = "#define _GLUMPY__FRAGMENT_SHADER__\n" + code + code = super().code + code = f'#define _GLUMPY__FRAGMENT_SHADER__\n{code}' return code def __repr__(self): - return "Fragment shader %d (%s)" % (self._id, self._source) + return f'Fragment shader {self._id} ({self._source})' class GeometryShader(Shader): - """ Geometry shader class. + """Geometry shader class. - :param str code: Shader code or a filename containing shader code - :param int vertices_out: Number of output vertices - :param gl.GLEnum input_type: + :param str code: Shader code or a filename containing shader code + :param int vertices_out: Number of output vertices + :param gl.GLEnum input_type: - * GL_POINTS - * GL_LINES​, GL_LINE_STRIP​, GL_LINE_LIST - * GL_LINES_ADJACENCY​, GL_LINE_STRIP_ADJACENCY - * GL_TRIANGLES​, GL_TRIANGLE_STRIP​, GL_TRIANGLE_FAN - * GL_TRIANGLES_ADJACENCY​, GL_TRIANGLE_STRIP_ADJACENCY + * GL_POINTS + * GL_LINES​, GL_LINE_STRIP​, GL_LINE_LIST + * GL_LINES_ADJACENCY​, GL_LINE_STRIP_ADJACENCY + * GL_TRIANGLES​, GL_TRIANGLE_STRIP​, GL_TRIANGLE_FAN + * GL_TRIANGLES_ADJACENCY​, GL_TRIANGLE_STRIP_ADJACENCY - :param gl.GLEnum output_type: + :param gl.GLEnum output_type: - * GL_POINTS, GL_LINES​, GL_LINE_STRIP - * GL_TRIANGLES​, GL_TRIANGLE_STRIP​, GL_TRIANGLE_FAN + * GL_POINTS, GL_LINES​, GL_LINE_STRIP + * GL_TRIANGLES​, GL_TRIANGLE_STRIP​, GL_TRIANGLE_FAN """ - def __init__(self, code=None, - vertices_out=None, input_type=None, output_type=None, version="120"): + def __init__( + self, + code=None, + vertices_out=None, + input_type=None, + output_type=None, + version='120', + ): Shader.__init__(self, gl.GL_GEOMETRY_SHADER_EXT, code, version) self._vertices_out = vertices_out @@ -429,4 +437,4 @@ def output_type(self, value): self._output_type = value def __repr__(self): - return "Geometry shader %d (%s)" % (self._id, self._source) + return f'Geometry shader {self._id} ({self._source})' diff --git a/phy/plot/gloo/snippet.py b/phy/plot/gloo/snippet.py index 720d064c2..7e3d4f5ef 100644 --- a/phy/plot/gloo/snippet.py +++ b/phy/plot/gloo/snippet.py @@ -9,7 +9,7 @@ from . import parser -class Snippet(object): +class Snippet: """ A snippet is a piece of GLSL code that can be injected into an another GLSL code. It provides the necessary machinery to take care of name collisions, @@ -53,7 +53,6 @@ class Snippet(object): aliases = {} def __init__(self, code=None, default=None, *args, **kwargs): - # Original source code self._source_code = parser.merge_includes(code) @@ -63,7 +62,7 @@ def __init__(self, code=None, default=None, *args, **kwargs): # Arguments (other snippets or strings) for arg in args: if isinstance(arg, Snippet) and self in arg.snippets: - raise ValueError("Recursive call is forbidden.") + raise ValueError('Recursive call is forbidden.') self._args = list(args) # No chained snippet yet @@ -85,20 +84,20 @@ def __init__(self, code=None, default=None, *args, **kwargs): # If no name has been given, set a default one if self._name is None: classname = self.__class__.__name__ - self._name = "%s_%d" % (classname, self._id) + self._name = f'{classname}_{self._id}' # Symbol table self._symbols = {} - for (name, dtype) in self._objects["attributes"]: - self._symbols[name] = "%s_%d" % (name, self._id) - for (name, dtype) in self._objects["uniforms"]: - self._symbols[name] = "%s_%d" % (name, self._id) - for (name, dtype) in self._objects["varyings"]: - self._symbols[name] = "%s_%d" % (name, self._id) - for (name, dtype) in self._objects["consts"]: - self._symbols[name] = "%s_%d" % (name, self._id) - for (rtype, name, args, code) in self._objects["functions"]: - self._symbols[name] = "%s_%d" % (name, self._id) + for name, dtype in self._objects['attributes']: + self._symbols[name] = f'{name}_{self._id}' + for name, dtype in self._objects['uniforms']: + self._symbols[name] = f'{name}_{self._id}' + for name, dtype in self._objects['varyings']: + self._symbols[name] = f'{name}_{self._id}' + for name, dtype in self._objects['consts']: + self._symbols[name] = f'{name}_{self._id}' + for rtype, name, args, code in self._objects['functions']: + self._symbols[name] = f'{name}_{self._id}' # Aliases (through kwargs) for name, alias in kwargs.items(): @@ -108,25 +107,25 @@ def __init__(self, code=None, default=None, *args, **kwargs): self._programs = [] def process_kwargs(self, **kwargs): - """ Process kwargs as given in __init__() or __call__() """ + """Process kwargs as given in __init__() or __call__()""" - if "name" in kwargs.keys(): - self._name = kwargs["name"] - del kwargs["name"] + if 'name' in kwargs: + self._name = kwargs['name'] + del kwargs['name'] - if "call" in kwargs.keys(): - self._call = kwargs["call"] - del kwargs["call"] + if 'call' in kwargs: + self._call = kwargs['call'] + del kwargs['call'] @property def name(self): - """ Name of the snippet """ + """Name of the snippet""" return self._name @property def programs(self): - """ Currently attached programs """ + """Currently attached programs""" return self._programs @@ -157,7 +156,9 @@ def locals(self): symbols = {} objects = self._objects - for name, dtype in objects["uniforms"] + objects["attributes"] + objects["varyings"]: + for name, dtype in ( + objects['uniforms'] + objects['attributes'] + objects['varyings'] + ): symbols[name] = self.symbols[name] # return self._symbols return symbols @@ -178,13 +179,13 @@ def globals(self): @property def args(self): - """ Call arguments """ + """Call arguments""" return list(self._args) @property def next(self): - """ Next snippet in the arihmetic chain. """ + """Next snippet in the arihmetic chain.""" if self._next: return self._next[1] @@ -220,7 +221,9 @@ def snippets(self): D.snippets # [A,B,C] """ - all = [self, ] + all = [ + self, + ] for snippet in self._args: if isinstance(snippet, Snippet): all.extend(snippet.snippets) @@ -323,34 +326,33 @@ def dependencies(self): @property def code(self): - """ Mangled code """ + """Mangled code""" - code = "" + code = '' for snippet in self.dependencies: code += snippet.mangled_code() return code def mangled_code(self): - """ Generate mangled code """ + """Generate mangled code""" code = self._source_code objects = self._objects - functions = objects["functions"] - names = objects["uniforms"] + \ - objects["attributes"] + objects["varyings"] + functions = objects['functions'] + names = objects['uniforms'] + objects['attributes'] + objects['varyings'] for _, name, _, _ in functions: symbol = self.symbols[name] - code = re.sub(r"(?<=[^\w])(%s)(?=\()" % name, symbol, code) + code = re.sub(rf'(?<=[^\w])({name})(?=\()', symbol, code) for name, _ in names: # Variable starting "__" are protected and unaliased # if not name.startswith("__"): symbol = self.symbols[name] - code = re.sub(r"(?<=[^\w])(%s)(?=[^\w])" % name, symbol, code) + code = re.sub(rf'(?<=[^\w])({name})(?=[^\w])', symbol, code) return code @property def call(self): - """ Computes and returns the GLSL code that correspond to the call """ + """Computes and returns the GLSL code that correspond to the call""" self.mangled_code() return self.mangled_call() @@ -364,13 +366,12 @@ def mangled_call(self, function=None, arguments=None, override=False): with shader arguments """ - s = "" + s = '' # Is there a function defined in the snippet ? # (It may happen a snippet only has uniforms, like the Viewport snippet) # WARN: what about Viewport(Transform) ? - if len(self._objects["functions"]): - + if len(self._objects['functions']): # Is there a function specified in the shader source ? # Such as if function: @@ -381,12 +382,12 @@ def mangled_call(self, function=None, arguments=None, override=False): elif self._call is not None: name = self._call else: - _, name, _, _ = self._objects["functions"][0] + _, name, _, _ = self._objects['functions'][0] s = self.lookup(name, deepsearch=False) or name if len(self._args) and override is False: - s += "(" + s += '(' for i, arg in enumerate(self._args): if isinstance(arg, Snippet): # We do not propagate given function to to other snippets @@ -399,27 +400,27 @@ def mangled_call(self, function=None, arguments=None, override=False): s += str(arg) if i < (len(self._args) - 1): - s += ", " - s += ")" + s += ', ' + s += ')' else: # If an argument has been given, we put it at the end # This handles hooks of the form if arguments is not None: - s += "(%s)" % arguments + s += f'({arguments})' else: - s += "()" + s += '()' if self.next: operand, other = self._next - if operand in "+-/*": + if operand in '+-/*': call = other.mangled_call(function, arguments).strip() if len(call): - s += ' ' + operand + ' ' + call + s += f' {operand} {call}' # No function defined in this snippet, we look for next one else: if self._next: operand, other = self.next - if operand in "+-/*": + if operand in '+-/*': s = other.mangled_call(function, arguments) return s @@ -432,7 +433,7 @@ def __call__(self, *args, **kwargs): for arg in args: if isinstance(arg, Snippet) and self in arg.snippets: - raise ValueError("Recursive call is forbidden") + raise ValueError('Recursive call is forbidden') # Override call arguments self._args = args @@ -447,12 +448,9 @@ def __call__(self, *args, **kwargs): return self def copy(self, deep=False): - """ Shallow or deep copy of the snippet """ + """Shallow or deep copy of the snippet""" - if deep: - snippet = copy.deepcopy(self) - else: - snippet = copy.copy(self) + snippet = copy.deepcopy(self) if deep else copy.copy(self) return snippet def __op__(self, operand, other): @@ -461,54 +459,54 @@ def __op__(self, operand, other): return snippet def __add__(self, other): - return self.__op__("+", other) + return self.__op__('+', other) def __and__(self, other): - return self.__op__("&", other) + return self.__op__('&', other) def __sub__(self, other): - return self.__op__("-", other) + return self.__op__('-', other) def __mul__(self, other): - return self.__op__("*", other) + return self.__op__('*', other) def __div__(self, other): - return self.__op__("/", other) + return self.__op__('/', other) def __radd__(self, other): - return self.__op__("+", other) + return self.__op__('+', other) def __rand__(self, other): - return self.__op__("&", other) + return self.__op__('&', other) def __rsub__(self, other): - return self.__op__("-", other) + return self.__op__('-', other) def __rmul__(self, other): - return self.__op__("*", other) + return self.__op__('*', other) def __rdiv__(self, other): - return self.__op__("/", other) + return self.__op__('/', other) def __rshift__(self, other): - return self.__op__(";", other) + return self.__op__(';', other) def __repr__(self): # return self.generate_call() s = self._name # s = self.__class__.__name__ - s += "(" + s += '(' if len(self._args): - s += " " + s += ' ' for i, snippet in enumerate(self._args): s += repr(snippet) if i < len(self._args) - 1: - s += ", " - s += " " - s += ")" + s += ', ' + s += ' ' + s += ')' if self._next: - s += " %s %s" % self._next + s += ' {} {}'.format(*self._next) return s @@ -573,5 +571,5 @@ def __setitem__(self, key, value): found = True if not found: - error = 'Snippet does not have such key ("%s")' % key + error = f'Snippet does not have such key ("{key}")' raise IndexError(error) diff --git a/phy/plot/gloo/texture.py b/phy/plot/gloo/texture.py index 1d9964e0f..852a5f9cf 100644 --- a/phy/plot/gloo/texture.py +++ b/phy/plot/gloo/texture.py @@ -33,38 +33,30 @@ import numpy as np from . import gl -from .gpudata import GPUData from .globject import GLObject - +from .gpudata import GPUData log = logging.getLogger(__name__) class Texture(GPUData, GLObject): - """ Generic texture """ - - _cpu_formats = {1: gl.GL_RED, - 2: gl.GL_RG, - 3: gl.GL_RGB, - 4: gl.GL_RGBA} - - _gpu_formats = {1: gl.GL_RED, - 2: gl.GL_RG, - 3: gl.GL_RGB, - 4: gl.GL_RGBA} - - _gpu_float_formats = {1: gl.GL_R32F, - 2: gl.GL_RG32F, - 3: gl.GL_RGB32F, - 4: gl.GL_RGBA32F} - - _gtypes = {np.dtype(np.int8): gl.GL_BYTE, - np.dtype(np.uint8): gl.GL_UNSIGNED_BYTE, - np.dtype(np.int16): gl.GL_SHORT, - np.dtype(np.uint16): gl.GL_UNSIGNED_SHORT, - np.dtype(np.int32): gl.GL_INT, - np.dtype(np.uint32): gl.GL_UNSIGNED_INT, - np.dtype(np.float32): gl.GL_FLOAT} + """Generic texture""" + + _cpu_formats = {1: gl.GL_RED, 2: gl.GL_RG, 3: gl.GL_RGB, 4: gl.GL_RGBA} + + _gpu_formats = {1: gl.GL_RED, 2: gl.GL_RG, 3: gl.GL_RGB, 4: gl.GL_RGBA} + + _gpu_float_formats = {1: gl.GL_R32F, 2: gl.GL_RG32F, 3: gl.GL_RGB32F, 4: gl.GL_RGBA32F} + + _gtypes = { + np.dtype(np.int8): gl.GL_BYTE, + np.dtype(np.uint8): gl.GL_UNSIGNED_BYTE, + np.dtype(np.int16): gl.GL_SHORT, + np.dtype(np.uint16): gl.GL_UNSIGNED_SHORT, + np.dtype(np.int32): gl.GL_INT, + np.dtype(np.uint32): gl.GL_UNSIGNED_INT, + np.dtype(np.float32): gl.GL_FLOAT, + } def __init__(self, target): GLObject.__init__(self) @@ -76,22 +68,24 @@ def __init__(self, target): self._gpu_format = None def _check_shape(self, shape, ndims): - """ Check and normalize shape. """ + """Check and normalize shape.""" if len(shape) < ndims: - raise ValueError("Too few dimensions for texture") + raise ValueError('Too few dimensions for texture') elif len(shape) > ndims + 1: - raise ValueError("Too many dimensions for texture") + raise ValueError('Too many dimensions for texture') elif len(shape) == ndims: - shape = list(shape) + [1, ] + shape = list(shape) + [ + 1, + ] elif len(shape) == ndims + 1: if shape[-1] > 4: - raise ValueError("Too many channels for texture") + raise ValueError('Too many channels for texture') return shape @property def need_update(self): - """ Whether object needs to be updated """ + """Whether object needs to be updated""" return self.pending_data is not None @@ -127,7 +121,7 @@ def gpu_format(self): @gpu_format.setter def gpu_format(self, value): - """ Texture GPU format. """ + """Texture GPU format.""" self._gpu_format = value self._need_setup = True @@ -137,32 +131,32 @@ def gtype(self): if self.dtype in Texture._gtypes.keys(): return Texture._gtypes[self.dtype] else: - raise TypeError("No available GL type equivalent") + raise TypeError('No available GL type equivalent') @property def wrapping(self): - """ Texture wrapping mode """ + """Texture wrapping mode""" return self._wrapping @wrapping.setter def wrapping(self, value): - """ Texture wrapping mode """ + """Texture wrapping mode""" self._wrapping = value self._need_setup = True @property def interpolation(self): - """ Texture interpolation for minification and magnification. """ + """Texture interpolation for minification and magnification.""" return self._interpolation @interpolation.setter def interpolation(self, value): - """ Texture interpolation for minication and magnification. """ + """Texture interpolation for minication and magnification.""" if isinstance(value, str): - value = getattr(gl, 'GL_%s' % value.upper()) + value = getattr(gl, f'GL_{value.upper()}') if isinstance(value, (list, tuple)): self._interpolation = value @@ -172,11 +166,11 @@ def interpolation(self, value): def set_interpolation(self, value): if isinstance(value, str): - value = getattr(gl, 'GL_%s' % value.upper()) + value = getattr(gl, f'GL_{value.upper()}') self._interpolation = value, value def _setup(self): - """ Setup texture on GPU """ + """Setup texture on GPU""" min_filter, mag_filter = self._interpolation wrapping = self._wrapping @@ -185,39 +179,38 @@ def _setup(self): gl.glTexParameterf(self.target, gl.GL_TEXTURE_MAG_FILTER, mag_filter) gl.glTexParameterf(self.target, gl.GL_TEXTURE_WRAP_S, wrapping) gl.glTexParameterf(self.target, gl.GL_TEXTURE_WRAP_T, wrapping) - gl.glTexParameterf(self.target, gl.GL_TEXTURE_WRAP_R, - gl.GL_CLAMP_TO_EDGE) + gl.glTexParameterf(self.target, gl.GL_TEXTURE_WRAP_R, gl.GL_CLAMP_TO_EDGE) self._need_setup = False def _activate(self): - """ Activate texture on GPU """ + """Activate texture on GPU""" - log.log(5, "GPU: Activate texture") + log.log(5, 'GPU: Activate texture') gl.glBindTexture(self.target, self._handle) if self._need_setup: self._setup() def _deactivate(self): - """ Deactivate texture on GPU """ + """Deactivate texture on GPU""" - log.log(5, "GPU: Deactivate texture") + log.log(5, 'GPU: Deactivate texture') gl.glBindTexture(self._target, 0) def _create(self): - """ Create texture on GPU """ + """Create texture on GPU""" - log.log(5, "GPU: Creating texture") + log.log(5, 'GPU: Creating texture') self._handle = gl.glGenTextures(1) def _delete(self): - """ Delete texture from GPU """ + """Delete texture from GPU""" - log.log(5, "GPU: Deleting texture") + log.log(5, 'GPU: Deleting texture') if self.handle > -1: gl.glDeleteTextures(np.array([self.handle], dtype=np.uint32)) def get(self): - """ Read the texture data back into CPU memory """ + """Read the texture data back into CPU memory""" host = np.zeros(self.shape, self.dtype) gl.glBindTexture(self.target, self._handle) gl.glGetTexImage(self.target, 0, self.cpu_format, self.gtype, host) @@ -244,25 +237,24 @@ def width(self): return self.shape[0] def _setup(self): - """ Setup texture on GPU """ + """Setup texture on GPU""" Texture._setup(self) gl.glBindTexture(self.target, self._handle) - gl.glTexImage1D(self.target, 0, self._gpu_format, self.width, - 0, self._cpu_format, self.gtype, None) + gl.glTexImage1D( + self.target, 0, self._gpu_format, self.width, 0, self._cpu_format, self.gtype, None + ) self._need_setup = False def _update(self): - - log.log(5, "GPU: Updating texture") + log.log(5, 'GPU: Updating texture') if self.pending_data: start, stop = self.pending_data offset, nbytes = start, stop - start itemsize = self.strides[0] x = offset // itemsize width = nbytes // itemsize - gl.glTexSubImage1D(self.target, 0, x, width, - self._cpu_format, self.gtype, self) + gl.glTexSubImage1D(self.target, 0, x, width, self._cpu_format, self.gtype, self) self._pending_data = None self._need_update = False @@ -279,7 +271,7 @@ def __init__(self): class Texture2D(Texture): - """ 2D texture """ + """2D texture""" def __init__(self): Texture.__init__(self, gl.GL_TEXTURE_2D) @@ -289,33 +281,42 @@ def __init__(self): @property def width(self): - """ Texture width """ + """Texture width""" return self.shape[1] @property def height(self): - """ Texture height """ + """Texture height""" return self.shape[0] def _setup(self): - """ Setup texture on GPU """ + """Setup texture on GPU""" Texture._setup(self) gl.glBindTexture(self.target, self._handle) - gl.glTexImage2D(self.target, 0, self._gpu_format, self.width, self.height, - 0, self._cpu_format, self.gtype, None) + gl.glTexImage2D( + self.target, + 0, + self._gpu_format, + self.width, + self.height, + 0, + self._cpu_format, + self.gtype, + None, + ) self._need_setup = False def _update(self): - """ Update texture on GPU """ + """Update texture on GPU""" if self.width == 0: return if self.pending_data: - log.log(5, "GPU: Updating texture") + log.log(5, 'GPU: Updating texture') start, stop = self.pending_data offset, nbytes = start, stop - start @@ -326,8 +327,7 @@ def _update(self): nbytes += offset % self.width offset -= offset % self.width - nbytes += (self.width - ((offset + nbytes) % - self.width)) % self.width + nbytes += (self.width - ((offset + nbytes) % self.width)) % self.width # x = 0 # y = offset // self.width @@ -338,8 +338,17 @@ def _update(self): # self._cpu_format, self.gtype, self) # HACK: disable partial texture update which fails if the new texture # doesn't have the same size. - gl.glTexImage2D(self.target, 0, self._gpu_format, self.width, self.height, - 0, self._cpu_format, self.gtype, self) + gl.glTexImage2D( + self.target, + 0, + self._gpu_format, + self.width, + self.height, + 0, + self._cpu_format, + self.gtype, + self, + ) gl.glBindTexture(self._target, self.handle) self._pending_data = None @@ -347,7 +356,7 @@ def _update(self): class TextureFloat2D(Texture2D): - """ 2D float texture """ + """2D float texture""" def __init__(self): Texture2D.__init__(self) @@ -355,7 +364,7 @@ def __init__(self): class DepthTexture(Texture2D): - """ Depth texture """ + """Depth texture""" def __init__(self): Texture2D.__init__(self) @@ -364,13 +373,12 @@ def __init__(self): class TextureCube(Texture): - """ Cube texture """ + """Cube texture""" def __init__(self): - Texture.__init__(self, gl.GL_TEXTURE_CUBE_MAP) if self.shape[0] != 6: - error = "Texture cube require arrays first dimension to be 6" + error = 'Texture cube require arrays first dimension to be 6' log.error(error) raise RuntimeError(error) @@ -380,46 +388,59 @@ def __init__(self): @property def width(self): - """ Texture width """ + """Texture width""" return self.shape[2] @property def height(self): - """ Texture height """ + """Texture height""" return self.shape[1] def _setup(self): - """ Setup texture on GPU """ + """Setup texture on GPU""" Texture._setup(self) gl.glEnable(gl.GL_TEXTURE_CUBE_MAP) gl.glBindTexture(self.target, self._handle) - targets = [gl.GL_TEXTURE_CUBE_MAP_POSITIVE_X, - gl.GL_TEXTURE_CUBE_MAP_NEGATIVE_X, - gl.GL_TEXTURE_CUBE_MAP_POSITIVE_Y, - gl.GL_TEXTURE_CUBE_MAP_NEGATIVE_Y, - gl.GL_TEXTURE_CUBE_MAP_POSITIVE_Z, - gl.GL_TEXTURE_CUBE_MAP_NEGATIVE_Z] + targets = [ + gl.GL_TEXTURE_CUBE_MAP_POSITIVE_X, + gl.GL_TEXTURE_CUBE_MAP_NEGATIVE_X, + gl.GL_TEXTURE_CUBE_MAP_POSITIVE_Y, + gl.GL_TEXTURE_CUBE_MAP_NEGATIVE_Y, + gl.GL_TEXTURE_CUBE_MAP_POSITIVE_Z, + gl.GL_TEXTURE_CUBE_MAP_NEGATIVE_Z, + ] for i, target in enumerate(targets): - gl.glTexImage2D(target, 0, self._gpu_format, self.width, self.height, - 0, self._cpu_format, self.gtype, None) + gl.glTexImage2D( + target, + 0, + self._gpu_format, + self.width, + self.height, + 0, + self._cpu_format, + self.gtype, + None, + ) self._need_setup = False def _update(self): - log.log(5, "GPU: Updating texture cube") + log.log(5, 'GPU: Updating texture cube') if self.need_update: gl.glEnable(gl.GL_TEXTURE_CUBE_MAP) gl.glBindTexture(self.target, self.handle) - targets = [gl.GL_TEXTURE_CUBE_MAP_POSITIVE_X, - gl.GL_TEXTURE_CUBE_MAP_NEGATIVE_X, - gl.GL_TEXTURE_CUBE_MAP_POSITIVE_Y, - gl.GL_TEXTURE_CUBE_MAP_NEGATIVE_Y, - gl.GL_TEXTURE_CUBE_MAP_POSITIVE_Z, - gl.GL_TEXTURE_CUBE_MAP_NEGATIVE_Z] + targets = [ + gl.GL_TEXTURE_CUBE_MAP_POSITIVE_X, + gl.GL_TEXTURE_CUBE_MAP_NEGATIVE_X, + gl.GL_TEXTURE_CUBE_MAP_POSITIVE_Y, + gl.GL_TEXTURE_CUBE_MAP_NEGATIVE_Y, + gl.GL_TEXTURE_CUBE_MAP_POSITIVE_Z, + gl.GL_TEXTURE_CUBE_MAP_NEGATIVE_Z, + ] for i, target in enumerate(targets): face = self[i] @@ -439,30 +460,30 @@ def _update(self): nbytes /= itemsize nbytes += offset % self.width offset -= offset % self.width - nbytes += (self.width - ((offset + nbytes) % - self.width)) % self.width + nbytes += (self.width - ((offset + nbytes) % self.width)) % self.width x = 0 y = offset // self.width width = self.width height = nbytes // self.width - gl.glTexSubImage2D(target, 0, x, y, width, height, - self._cpu_format, self.gtype, face) + gl.glTexSubImage2D( + target, 0, x, y, width, height, self._cpu_format, self.gtype, face + ) self._pending_data = None self._need_update = False def _activate(self): - """ Activate texture on GPU """ + """Activate texture on GPU""" - log.log(5, "GPU: Activate texture cube") + log.log(5, 'GPU: Activate texture cube') gl.glEnable(gl.GL_TEXTURE_CUBE_MAP) gl.glBindTexture(self.target, self._handle) if self._need_setup: self._setup() def _deactivate(self): - """ Deactivate texture on GPU """ + """Deactivate texture on GPU""" - log.log(5, "GPU: Deactivate texture cube") + log.log(5, 'GPU: Deactivate texture cube') gl.glBindTexture(self._target, 0) gl.glDisable(gl.GL_TEXTURE_CUBE_MAP) diff --git a/phy/plot/gloo/uniforms.py b/phy/plot/gloo/uniforms.py index bda60f769..ad86d979d 100644 --- a/phy/plot/gloo/uniforms.py +++ b/phy/plot/gloo/uniforms.py @@ -2,8 +2,7 @@ # Copyright (c) 2009-2016 Nicolas P. Rougier. All rights reserved. # Distributed under the (new) BSD License. # ----------------------------------------------------------------------------- -""" -""" +""" """ from functools import reduce from operator import mul @@ -31,14 +30,8 @@ def dtype_reduce(dtype, level=0, depth=0): # No fields if fields is None: - if dtype.shape: - count = reduce(mul, dtype.shape) - else: - count = 1 - if dtype.subdtype: - name = str(dtype.subdtype[0]) - else: - name = str(dtype) + count = reduce(mul, dtype.shape) if dtype.shape else 1 + name = str(dtype.subdtype[0]) if dtype.subdtype else str(dtype) return ['', count, name] else: items = [] @@ -50,7 +43,7 @@ def dtype_reduce(dtype, level=0, depth=0): items.append([key, l[1], l[2]]) else: items.append(l) - name += key + ',' + name += f'{key},' # Check if we can reduce item list ctype = None @@ -93,14 +86,13 @@ class Uniforms(Texture2D): """ def __init__(self, size, dtype): - """ Initialization """ + """Initialization""" # Check dtype is made of float32 only dtype = eval(str(np.dtype(dtype))) rtype = dtype_reduce(dtype) if type(rtype[0]) is not str or rtype[2] != 'float32': - raise RuntimeError( - "Uniform type cannot be reduced to float32 only") + raise RuntimeError('Uniform type cannot be reduced to float32 only') # True dtype (the one given in args) self._original_dtype = np.dtype(dtype) @@ -131,36 +123,37 @@ def __init__(self, size, dtype): if size % cols: rows += 1 - Texture2D.__init__(self, shape=(rows, cols, 4), dtype=np.float32, - resizeable=False, store=True) + Texture2D.__init__( + self, shape=(rows, cols, 4), dtype=np.float32, resizeable=False, store=True + ) data = self._data.ravel() self._typed_data = data.view(self._complete_dtype) self._size = size def __setitem__(self, key, value): - """ x.__getitem__(y) <==> x[y] """ + """x.__getitem__(y) <==> x[y]""" if self.base is not None and not self._valid: - raise ValueError("This uniforms view has been invalited") + raise ValueError('This uniforms view has been invalited') size = self._size if isinstance(key, int): if key < 0: key += size if key < 0 or key > size: - raise IndexError("Uniforms assignment index out of range") + raise IndexError('Uniforms assignment index out of range') start, stop = key, key + 1 elif isinstance(key, slice): start, stop, step = key.indices(size) if step != 1: - raise ValueError("Cannot access non-contiguous uniforms data") + raise ValueError('Cannot access non-contiguous uniforms data') if stop < start: start, stop = stop, start elif key == Ellipsis: start = 0 stop = size else: - raise TypeError("Uniforms indices must be integers") + raise TypeError('Uniforms indices must be integers') # First we set item using the typed data # shape = self._typed_data[start:stop].shape @@ -181,10 +174,10 @@ def __setitem__(self, key, value): stop = stop[0], self.shape[1] - 1 offset = start[0], start[1], 0 - data = self._data[start[0]:stop[0] + 1, start[1]:stop[1]] + data = self._data[start[0] : stop[0] + 1, start[1] : stop[1]] self.set_data(data=data, offset=offset, copy=False) - def code(self, prefix="u_"): + def code(self, prefix='u_'): """ Generate the GLSL code needed to retrieve fake uniform values from a texture. The generated uniform names can be prefixed with the given prefix. @@ -196,19 +189,18 @@ def code(self, prefix="u_"): header = """uniform sampler2D u_uniforms;\n""" # Header generation (easy) - types = {1: 'float', 2: 'vec2 ', 3: 'vec3 ', - 4: 'vec4 ', 9: 'mat3 ', 16: 'mat4 '} + types = {1: 'float', 2: 'vec2 ', 3: 'vec3 ', 4: 'vec4 ', 9: 'mat3 ', 16: 'mat4 '} for name, count, _ in _dtype: - header += "varying %s %s%s;\n" % (types[count], prefix, name) + header += f'varying {types[count]} {prefix}{name};\n' # Body generation (not so easy) rows, cols = self.shape[0], self.shape[1] count = self._complete_count - body = """\nvoid fetch_uniforms(float index) { - float rows = %.1f; - float cols = %.1f; - float count = %.1f; + body = f"""\nvoid fetch_uniforms(float index) {{ + float rows = {rows:.1f}; + float cols = {cols:.1f}; + float count = {count:.1f}; int index_x = int(mod(index, (floor(cols/(count/4.0))))) * int(count/4.0); int index_y = int(floor(index / (floor(cols/(count/4.0))))); float size_x = cols - 1.0; @@ -217,7 +209,7 @@ def code(self, prefix="u_"): if (size_y > 0.0) ty = float(index_y)/size_y; int i = index_x; - vec4 _uniform;\n""" % (rows, cols, count) + vec4 _uniform;\n""" _dtype = {name: count for name, count, _ in _dtype} store = 0 @@ -226,28 +218,27 @@ def code(self, prefix="u_"): count, shift = _dtype[name], 0 while count: if store == 0: - body += "\n _uniform = texture2D(u_uniforms, vec2(float(i++)/size_x,ty));\n" + body += '\n _uniform = texture2D(u_uniforms, vec2(float(i++)/size_x,ty));\n' store = 4 if store == 4: - a = "xyzw" + a = 'xyzw' elif store == 3: - a = "yzw" + a = 'yzw' elif store == 2: - a = "zw" + a = 'zw' elif store == 1: - a = "w" + a = 'w' if shift == 0: - b = "xyzw" + b = 'xyzw' elif shift == 1: - b = "yzw" + b = 'yzw' elif shift == 2: - b = "zw" + b = 'zw' elif shift == 3: - b = "w" + b = 'w' i = min(min(len(b), count), len(a)) - body += " %s%s.%s = _uniforms.%s;\n" % ( - prefix, name, b[:i], a[:i]) + body += f' {prefix}{name}.{b[:i]} = _uniforms.{a[:i]};\n' count -= i shift += i store -= i diff --git a/phy/plot/gloo/variable.py b/phy/plot/gloo/variable.py index 5c0307b11..56c9dd4b5 100644 --- a/phy/plot/gloo/variable.py +++ b/phy/plot/gloo/variable.py @@ -63,12 +63,10 @@ import numpy as np from . import gl -from .globject import GLObject from .array import VertexArray from .buffer import VertexBuffer -from .texture import TextureCube -from .texture import Texture1D, Texture2D - +from .globject import GLObject +from .texture import Texture1D, Texture2D, TextureCube log = logging.getLogger(__name__) @@ -92,25 +90,33 @@ gl.GL_FLOAT_MAT4: (16, gl.GL_FLOAT, np.float32), gl.GL_SAMPLER_1D: (1, gl.GL_UNSIGNED_INT, np.uint32), gl.GL_SAMPLER_2D: (1, gl.GL_UNSIGNED_INT, np.uint32), - gl.GL_SAMPLER_CUBE: (1, gl.GL_UNSIGNED_INT, np.uint32) + gl.GL_SAMPLER_CUBE: (1, gl.GL_UNSIGNED_INT, np.uint32), } # ---------------------------------------------------------- Variable class --- class Variable(GLObject): - """ A variable is an interface between a program and data """ + """A variable is an interface between a program and data""" def __init__(self, program, name, gtype): - """ Initialize the data into default state """ + """Initialize the data into default state""" # Make sure variable type is allowed (for ES 2.0 shader) - if gtype not in [gl.GL_FLOAT, gl.GL_FLOAT_VEC2, - gl.GL_FLOAT_VEC3, gl.GL_FLOAT_VEC4, - gl.GL_INT, gl.GL_BOOL, - gl.GL_FLOAT_MAT2, gl.GL_FLOAT_MAT3, - gl.GL_FLOAT_MAT4, gl.GL_SAMPLER_1D, - gl.GL_SAMPLER_2D, gl.GL_SAMPLER_CUBE]: - raise TypeError("Unknown variable type") + if gtype not in [ + gl.GL_FLOAT, + gl.GL_FLOAT_VEC2, + gl.GL_FLOAT_VEC3, + gl.GL_FLOAT_VEC4, + gl.GL_INT, + gl.GL_BOOL, + gl.GL_FLOAT_MAT2, + gl.GL_FLOAT_MAT3, + gl.GL_FLOAT_MAT4, + gl.GL_SAMPLER_1D, + gl.GL_SAMPLER_2D, + gl.GL_SAMPLER_CUBE, + ]: + raise TypeError('Unknown variable type') GLObject.__init__(self) @@ -135,48 +141,48 @@ def __init__(self, program, name, gtype): @property def name(self): - """ Variable name """ + """Variable name""" return self._name @property def program(self): - """ Program this variable belongs to """ + """Program this variable belongs to""" return self._program @property def gtype(self): - """ Type of the underlying variable (as a GL constant) """ + """Type of the underlying variable (as a GL constant)""" return self._gtype @property def dtype(self): - """ Equivalent dtype of the variable """ + """Equivalent dtype of the variable""" return self._dtype @property def active(self): - """ Whether this variable is active in the program """ + """Whether this variable is active in the program""" return self._active @active.setter def active(self, active): - """ Whether this variable is active in the program """ + """Whether this variable is active in the program""" self._active = active @property def data(self): - """ CPU data """ + """CPU data""" return self._data # ----------------------------------------------------------- Uniform class --- class Uniform(Variable): - """ A Uniform represents a program uniform variable. """ + """A Uniform represents a program uniform variable.""" _ufunctions = { gl.GL_FLOAT: gl.glUniform1fv, @@ -190,11 +196,11 @@ class Uniform(Variable): gl.GL_FLOAT_MAT4: gl.glUniformMatrix4fv, gl.GL_SAMPLER_1D: gl.glUniform1i, gl.GL_SAMPLER_2D: gl.glUniform1i, - gl.GL_SAMPLER_CUBE: gl.glUniform1i + gl.GL_SAMPLER_CUBE: gl.glUniform1i, } def __init__(self, program, name, gtype): - """ Initialize the input into default state """ + """Initialize the input into default state""" Variable.__init__(self, program, name, gtype) size, _, dtype = gl_typeinfo[self._gtype] @@ -203,11 +209,10 @@ def __init__(self, program, name, gtype): self._texture_unit = -1 def set_data(self, data): - """ Assign new data to the variable (deferred operation) """ + """Assign new data to the variable (deferred operation)""" # Textures need special handling if self._gtype == gl.GL_SAMPLER_1D: - if isinstance(data, Texture1D): self._data = data @@ -216,7 +221,7 @@ def set_data(self, data): # Automatic texture creation if required else: - data = np.array(data, copy=False) + data = np.asarray(data) # NumPy 2.0 compatible if data.dtype in [np.float16, np.float32, np.float64]: self._data = data.astype(np.float32).view(Texture1D) else: @@ -231,7 +236,7 @@ def set_data(self, data): # Automatic texture creation if required else: - data = np.array(data, copy=False) + data = np.asarray(data) # NumPy 2.0 compatible if data.dtype in [np.float16, np.float32, np.float64]: self._data = data.astype(np.float32).view(Texture2D) else: @@ -245,30 +250,29 @@ def set_data(self, data): # Automatic texture creation if required else: - data = np.array(data, copy=False) + data = np.asarray(data) # NumPy 2.0 compatible if data.dtype in [np.float16, np.float32, np.float64]: self._data = data.astype(np.float32).view(TextureCube) else: self._data = data.view(TextureCube) else: - self._data[...] = np.array(data, copy=False).ravel() + self._data[...] = np.asarray(data).ravel() # NumPy 2.0 compatible self._need_update = True def _activate(self): if self._gtype in (gl.GL_SAMPLER_1D, gl.GL_SAMPLER_2D, gl.GL_SAMPLER_CUBE): if self.data is not None: - log.log(5, "GPU: Active texture is %d" % self._texture_unit) + log.log(5, f'GPU: Active texture is {self._texture_unit}') gl.glActiveTexture(gl.GL_TEXTURE0 + self._texture_unit) if hasattr(self.data, 'activate'): self.data.activate() def _update(self): - # Check active status (mandatory) if not self._active: - raise RuntimeError("Uniform variable is not active") + raise RuntimeError('Uniform variable is not active') # WARNING : Uniform are supposed to keep their value between program # activation/deactivation (from the GL documentation). It has @@ -285,7 +289,7 @@ def _update(self): # Textures (need to get texture count) elif self._gtype in (gl.GL_SAMPLER_1D, gl.GL_SAMPLER_2D, gl.GL_SAMPLER_CUBE): # texture = self.data - log.log(5, "GPU: Activactin texture %d" % self._texture_unit) + log.log(5, f'GPU: Activactin texture {self._texture_unit}') # gl.glActiveTexture(gl.GL_TEXTURE0 + self._unit) # gl.glBindTexture(texture.target, texture.handle) gl.glUniform1i(self._handle, self._texture_unit) @@ -295,25 +299,24 @@ def _update(self): self._ufunction(self._handle, 1, self._data) def _create(self): - """ Create uniform on GPU (get handle) """ + """Create uniform on GPU (get handle)""" - self._handle = gl.glGetUniformLocation( - self._program.handle, self._name) + self._handle = gl.glGetUniformLocation(self._program.handle, self._name) # --------------------------------------------------------- Attribute class --- class Attribute(Variable): - """ An Attribute represents a program attribute variable """ + """An Attribute represents a program attribute variable""" _afunctions = { gl.GL_FLOAT: gl.glVertexAttrib1f, gl.GL_FLOAT_VEC2: gl.glVertexAttrib2f, gl.GL_FLOAT_VEC3: gl.glVertexAttrib3f, - gl.GL_FLOAT_VEC4: gl.glVertexAttrib4f + gl.GL_FLOAT_VEC4: gl.glVertexAttrib4f, } def __init__(self, program, name, gtype): - """ Initialize the input into default state """ + """Initialize the input into default state""" Variable.__init__(self, program, name, gtype) @@ -324,7 +327,7 @@ def __init__(self, program, name, gtype): self._generic = False def set_data(self, data): - """ Assign new data to the variable (deferred operation) """ + """Assign new data to the variable (deferred operation)""" isnumeric = isinstance(data, (float, int)) @@ -334,14 +337,16 @@ def set_data(self, data): # We already have a vertex buffer # HACK: disable reusing the same buffer for now: fails if the data has not the same shape - #elif isinstance(self._data, (VertexBuffer, VertexArray)) and len(self._data) == len(data): + # elif isinstance(self._data, (VertexBuffer, VertexArray)) and len(self._data) == len(data): # self._data[...] = data # Data is a tuple with size <= 4, we assume this designates a generate # vertex attribute. - elif (isnumeric or (isinstance(data, (tuple, list)) and - len(data) in (1, 2, 3, 4) and - isinstance(data[0], (float, int)))): + elif isnumeric or ( + isinstance(data, (tuple, list)) + and len(data) in (1, 2, 3, 4) + and isinstance(data[0], (float, int)) + ): # Let numpy convert the data for us _, _, dtype = gl_typeinfo[self._gtype] self._data = np.array(data).astype(dtype) @@ -354,7 +359,7 @@ def set_data(self, data): # upload it later to GPU memory. else: # lif not isinstance(data, VertexBuffer): name, base, count = self.dtype - data = np.array(data, dtype=base, copy=False) + data = np.asarray(data, dtype=base) # NumPy 2.0 compatible data = data.ravel().view([(name, base, (count,))]) # WARNING : transform data with the right type # data = np.array(data,copy=False) @@ -370,7 +375,8 @@ def _activate(self): offset = ctypes.c_void_p(self.data.offset) gl.glEnableVertexAttribArray(self.handle) gl.glVertexAttribPointer( - self.handle, size, gtype, gl.GL_FALSE, stride, offset) + self.handle, size, gtype, gl.GL_FALSE, stride, offset + ) def _deactivate(self): if isinstance(self.data, VertexBuffer): @@ -381,21 +387,21 @@ def _deactivate(self): self.data.deactivate() def _update(self): - """ Actual upload of data to GPU memory """ + """Actual upload of data to GPU memory""" - log.log(5, "GPU: Updating %s" % self.name) + log.log(5, f'GPU: Updating {self.name}') if self.data is None or self.data.size == 0: - log.debug("Data is empty for %s" % self.name) + log.debug(f'Data is empty for {self.name}') return else: - log.log(5, "data shape is %s" % self.data.shape) + log.log(5, f'data shape is {self.data.shape}') # Check active status (mandatory) -# if not self._active: -# raise RuntimeError("Attribute variable is not active") -# if self._data is None: -# raise RuntimeError("Attribute variable data is not set") + # if not self._active: + # raise RuntimeError("Attribute variable is not active") + # if self._data is None: + # raise RuntimeError("Attribute variable data is not set") # Generic vertex attribute (all vertices receive the same value) if self._generic: @@ -406,13 +412,13 @@ def _update(self): # Regular vertex buffer elif self.handle >= 0: if self.data is None: - log.warning("data %s is None" % self.name) + log.warning(f'data {self.name} is None') return elif self.data.size == 0: - log.warning("data %s is empty, %s" % (self.name, self.data.shape)) + log.warning(f'data {self.name} is empty, {self.data.shape}') return else: - log.log(5, "data %s is okay %s" % (self.name, self.data.shape)) + log.log(5, f'data {self.name} is okay {self.data.shape}') # Get relevant information from gl_typeinfo size, gtype, dtype = gl_typeinfo[self._gtype] @@ -423,23 +429,24 @@ def _update(self): gl.glEnableVertexAttribArray(self.handle) gl.glBindBuffer(gl.GL_ARRAY_BUFFER, self.data.handle) gl.glVertexAttribPointer( - self.handle, size, gtype, gl.GL_FALSE, stride, offset) + self.handle, size, gtype, gl.GL_FALSE, stride, offset + ) def _create(self): - """ Create attribute on GPU (get handle) """ + """Create attribute on GPU (get handle)""" self._handle = gl.glGetAttribLocation(self._program.handle, self.name) @property def size(self): - """ Size of the underlying vertex buffer """ + """Size of the underlying vertex buffer""" if self._data is None: return 0 return self._data.size def __len__(self): - """ Length of the underlying vertex buffer """ + """Length of the underlying vertex buffer""" if self._data is None: return 0 diff --git a/phy/plot/interact.py b/phy/plot/interact.py index c44957a97..69e85f9ae 100644 --- a/phy/plot/interact.py +++ b/phy/plot/interact.py @@ -1,29 +1,28 @@ -# -*- coding: utf-8 -*- - """Common layouts.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ import logging -import numpy as np +import numpy as np from phylib.utils import emit -from phylib.utils.geometry import get_non_overlapping_boxes, get_closest_box +from phylib.utils.geometry import get_closest_box, get_non_overlapping_boxes from .base import BaseLayout -from .transform import Scale, Range, Subplot, Clip, NDC +from .transform import NDC, Clip, Range, Scale, Subplot from .utils import _get_texture, _in_polygon from .visuals import LineVisual, PolygonVisual logger = logging.getLogger(__name__) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Grid -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + class Grid(BaseLayout): """Layout showing subplots arranged in a 2D grid. @@ -48,13 +47,13 @@ class Grid(BaseLayout): """ - margin = .075 + margin = 0.075 n_dims = 2 active_box = (0, 0) - _scaling = (1., 1.) + _scaling = (1.0, 1.0) def __init__(self, shape=(1, 1), shape_var='u_grid_shape', box_var=None, has_clip=True): - super(Grid, self).__init__(box_var=box_var) + super().__init__(box_var=box_var) self.shape_var = shape_var self._shape = shape ms = 1 - self.margin @@ -69,22 +68,29 @@ def __init__(self, shape=(1, 1), shape_var='u_grid_shape', box_var=None, has_cli if has_clip: self.gpu_transforms.add(Clip([-mc, -mc, +mc, +mc])) # 4. Subplots. - self.gpu_transforms.add(Subplot( - # The parameters of the subplots are callable as they can be changed dynamically. - shape=lambda: self._shape, index=lambda: self.active_box, - shape_gpu_var=self.shape_var, index_gpu_var=self.box_var)) + self.gpu_transforms.add( + Subplot( + # The parameters of the subplots are callable as they can be changed dynamically. + shape=lambda: self._shape, + index=lambda: self.active_box, + shape_gpu_var=self.shape_var, + index_gpu_var=self.box_var, + ) + ) def attach(self, canvas): """Attach the grid to a canvas.""" - super(Grid, self).attach(canvas) + super().attach(canvas) canvas.gpu_transforms += self.gpu_transforms canvas.inserter.insert_vert( - """ - attribute vec2 {}; - uniform vec2 {}; + f""" + attribute vec2 {self.box_var}; + uniform vec2 {self.shape_var}; uniform vec2 u_grid_scaling; - """.format(self.box_var, self.shape_var), - 'header', origin=self) + """, + 'header', + origin=self, + ) def add_boxes(self, canvas, shape=None): """Show subplot boxes.""" @@ -92,13 +98,16 @@ def add_boxes(self, canvas, shape=None): assert isinstance(shape, tuple) n, m = shape n_boxes = n * m - a = 1 - .0001 - - pos = np.array([[-a, -a, +a, -a], - [+a, -a, +a, +a], - [+a, +a, -a, +a], - [-a, +a, -a, -a], - ]) + a = 1 - 0.0001 + + pos = np.array( + [ + [-a, -a, +a, -a], + [+a, -a, +a, +a], + [+a, +a, -a, +a], + [-a, +a, -a, -a], + ] + ) pos = np.tile(pos, (n_boxes, 1)) box_index = [] @@ -120,13 +129,13 @@ def get_closest_box(self, pos): """Get the box index (i, j) closest to a given position in NDC coordinates.""" x, y = pos rows, cols = self.shape - j = np.clip(int(cols * (1. + x) / 2.), 0, cols - 1) - i = np.clip(int(rows * (1. - y) / 2.), 0, rows - 1) + j = np.clip(int(cols * (1.0 + x) / 2.0), 0, cols - 1) + i = np.clip(int(rows * (1.0 - y) / 2.0), 0, rows - 1) return i, j def update_visual(self, visual): """Update a visual.""" - super(Grid, self).update_visual(visual) + super().update_visual(visual) if self.shape_var in visual.program: visual.program[self.shape_var] = self._shape visual.program['u_grid_scaling'] = self._scaling @@ -152,9 +161,10 @@ def scaling(self, value): self.update() -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Boxed -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + class Boxed(BaseLayout): """Layout showing plots in rectangles at arbitrary positions. Used by the waveform view. @@ -180,51 +190,64 @@ class Boxed(BaseLayout): """ - margin = .1 + margin = 0.1 n_dims = 1 active_box = 0 - _box_scaling = (1., 1.) - _layout_scaling = (1., 1.) + _box_scaling = (1.0, 1.0) + _layout_scaling = (1.0, 1.0) _scaling_param_increment = 1.1 def __init__(self, box_pos=None, box_var=None, keep_aspect_ratio=False): - super(Boxed, self).__init__(box_var=box_var) + super().__init__(box_var=box_var) self._key_pressed = None self.keep_aspect_ratio = keep_aspect_ratio self.update_boxes(box_pos) - self.gpu_transforms.add(Range( - NDC, lambda: self.box_bounds[self.active_box], - from_gpu_var='vec4(-1, -1, 1, 1)', to_gpu_var='box_bounds')) + self.gpu_transforms.add( + Range( + NDC, + lambda: self.box_bounds[self.active_box], + from_gpu_var='vec4(-1, -1, 1, 1)', + to_gpu_var='box_bounds', + ) + ) def attach(self, canvas): """Attach the boxed interact to a canvas.""" - super(Boxed, self).attach(canvas) + super().attach(canvas) canvas.gpu_transforms += self.gpu_transforms - canvas.inserter.insert_vert(""" + canvas.inserter.insert_vert( + f""" #include "utils.glsl" - attribute float {}; + attribute float {self.box_var}; uniform sampler2D u_box_pos; uniform float n_boxes; uniform vec2 u_box_size; uniform vec2 u_layout_scaling; - """.format(self.box_var), 'header', origin=self) - canvas.inserter.insert_vert(""" + """, + 'header', + origin=self, + ) + canvas.inserter.insert_vert( + f""" // Fetch the box bounds for the current box (`box_var`). - vec2 box_pos = fetch_texture({}, u_box_pos, n_boxes).xy; + vec2 box_pos = fetch_texture({self.box_var}, u_box_pos, n_boxes).xy; box_pos = (2 * box_pos - 1); // from [0, 1] (texture) to [-1, 1] (NDC) box_pos = box_pos * u_layout_scaling; vec4 box_bounds = vec4(box_pos - u_box_size, box_pos + u_box_size); - """.format(self.box_var), 'start', origin=self) + """, + 'start', + origin=self, + ) def update_visual(self, visual): """Update a visual.""" - super(Boxed, self).update_visual(visual) + super().update_visual(visual) box_pos = _get_texture(self.box_pos, (0, 0), self.n_boxes, [-1, 1]) box_pos = box_pos.astype(np.float32) if 'u_box_pos' in visual.program: - logger.log(5, "Update visual with interact Boxed.") + logger.log(5, 'Update visual with interact Boxed.') visual.program['u_box_pos'] = box_pos visual.program['n_boxes'] = self.n_boxes visual.program['u_box_size'] = np.array(self.box_size) * np.array(self._box_scaling) @@ -237,25 +260,28 @@ def update_boxes(self, box_pos): def add_boxes(self, canvas): """Show the boxes borders.""" n_boxes = len(self.box_pos) - a = 1 + .05 - - pos = np.array([[-a, -a, +a, -a], - [+a, -a, +a, +a], - [+a, +a, -a, +a], - [-a, +a, -a, -a], - ]) + a = 1 + 0.05 + + pos = np.array( + [ + [-a, -a, +a, -a], + [+a, -a, +a, +a], + [+a, +a, -a, +a], + [-a, +a, -a, -a], + ] + ) pos = np.tile(pos, (n_boxes, 1)) boxes = LineVisual() box_index = np.repeat(np.arange(n_boxes), 8) canvas.add_visual(boxes, clearable=False) - boxes.set_data(pos=pos, color=(.5, .5, .5, 1)) + boxes.set_data(pos=pos, color=(0.5, 0.5, 0.5, 1)) boxes.set_box_index(box_index) canvas.update() # Change the box bounds, positions, or size - #-------------------------------------------------------------------------- + # -------------------------------------------------------------------------- @property def n_boxes(self): @@ -273,9 +299,9 @@ def get_closest_box(self, pos): return get_closest_box(pos, self.box_pos, self.box_size) # Box scaling - #-------------------------------------------------------------------------- + # -------------------------------------------------------------------------- - def _increment_box_scaling(self, cw=1., ch=1.): + def _increment_box_scaling(self, cw=1.0, ch=1.0): self._box_scaling = (self._box_scaling[0] * cw, self._box_scaling[1] * ch) self.update() @@ -287,18 +313,18 @@ def expand_box_width(self): return self._increment_box_scaling(cw=self._scaling_param_increment) def shrink_box_width(self): - return self._increment_box_scaling(cw=1. / self._scaling_param_increment) + return self._increment_box_scaling(cw=1.0 / self._scaling_param_increment) def expand_box_height(self): return self._increment_box_scaling(ch=self._scaling_param_increment) def shrink_box_height(self): - return self._increment_box_scaling(ch=1. / self._scaling_param_increment) + return self._increment_box_scaling(ch=1.0 / self._scaling_param_increment) # Layout scaling - #-------------------------------------------------------------------------- + # -------------------------------------------------------------------------- - def _increment_layout_scaling(self, cw=1., ch=1.): + def _increment_layout_scaling(self, cw=1.0, ch=1.0): self._layout_scaling = (self._layout_scaling[0] * cw, self._layout_scaling[1] * ch) self.update() @@ -310,13 +336,13 @@ def expand_layout_width(self): return self._increment_layout_scaling(cw=self._scaling_param_increment) def shrink_layout_width(self): - return self._increment_layout_scaling(cw=1. / self._scaling_param_increment) + return self._increment_layout_scaling(cw=1.0 / self._scaling_param_increment) def expand_layout_height(self): return self._increment_layout_scaling(ch=self._scaling_param_increment) def shrink_layout_height(self): - return self._increment_layout_scaling(ch=1. / self._scaling_param_increment) + return self._increment_layout_scaling(ch=1.0 / self._scaling_param_increment) class Stacked(Boxed): @@ -339,6 +365,7 @@ class Stacked(Boxed): variable specified in `box_var`. """ + margin = 0 _origin = 'bottom' @@ -346,7 +373,7 @@ def __init__(self, n_boxes, box_var=None, origin=None): self._origin = origin or self._origin assert self._origin in ('top', 'bottom') box_pos = self.get_box_pos(n_boxes) - super(Stacked, self).__init__(box_pos, box_var=box_var, keep_aspect_ratio=False) + super().__init__(box_pos, box_var=box_var, keep_aspect_ratio=False) @property def n_boxes(self): @@ -383,18 +410,23 @@ def attach(self, canvas): """Attach the stacked interact to a canvas.""" BaseLayout.attach(self, canvas) canvas.gpu_transforms += self.gpu_transforms - canvas.inserter.insert_vert(""" + canvas.inserter.insert_vert( + f""" #include "utils.glsl" - attribute float {}; + attribute float {self.box_var}; uniform float n_boxes; uniform bool u_top_origin; uniform vec2 u_box_size; - """.format(self.box_var), 'header', origin=self) - canvas.inserter.insert_vert(""" + """, + 'header', + origin=self, + ) + canvas.inserter.insert_vert( + f""" float margin = .1 / n_boxes; float a = 1 - 2. / n_boxes + margin; float b = -1 + 2. / n_boxes - margin; - float u = (u_top_origin ? (n_boxes - 1. - {bv}) : {bv}) / max(1., n_boxes - 1.); + float u = (u_top_origin ? (n_boxes - 1. - {self.box_var}) : {self.box_var}) / max(1., n_boxes - 1.); float y0 = -1 + u * (a + 1); float y1 = b + u * (1 - b); float ym = .5 * (y0 + y1); @@ -402,7 +434,10 @@ def attach(self, canvas): y0 = ym - yh; y1 = ym + yh; vec4 box_bounds = vec4(-1., y0, +1., y1); - """.format(bv=self.box_var), 'before_transforms', origin=self) + """, + 'before_transforms', + origin=self, + ) def update_visual(self, visual): """Update a visual.""" @@ -413,13 +448,15 @@ def update_visual(self, visual): visual.program['u_top_origin'] = self._origin == 'top' -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Interactive tools -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + -class Lasso(object): +class Lasso: """Draw a polygon with the mouse and find the points that belong to the inside of the polygon.""" + def __init__(self): self._points = [] self.canvas = None @@ -430,7 +467,7 @@ def add(self, pos): """Add a point to the polygon.""" x, y = pos.flat if isinstance(pos, np.ndarray) else pos self._points.append((x, y)) - logger.debug("Lasso has %d points.", len(self._points)) + logger.debug('Lasso has %d points.', len(self._points)) self.update_lasso_visual() @property @@ -484,7 +521,9 @@ def on_mouse_click(self, e): if 'Control' in e.modifiers: if e.button == 'Left': layout = getattr(self.canvas, 'layout', None) - if hasattr(layout, 'box_map'): + # Qt widgets expose a built-in `layout()` method, so only treat `layout` + # as a plot layout when it provides the layout protocol we expect. + if hasattr(layout, 'box_map') and hasattr(layout, 'active_box'): box, pos = layout.box_map(e.pos) # Only update the box for the first click, so that the box containing # the lasso is determined by the first click only. @@ -494,13 +533,14 @@ def on_mouse_click(self, e): if box != self.box: return else: # pragma: no cover + layout = None pos = self.canvas.window_to_ndc(e.pos) # Force the active box to be the box of the first click, not the box of the # current click. - if layout: + if layout is not None: layout.active_box = self.box self.add(pos) # call update_lasso_visual - emit("lasso_updated", self.canvas, self.polygon) + emit('lasso_updated', self.canvas, self.polygon) else: self.clear() self.box = None diff --git a/phy/plot/panzoom.py b/phy/plot/panzoom.py index 975ab65e6..7d49987f8 100644 --- a/phy/plot/panzoom.py +++ b/phy/plot/panzoom.py @@ -1,27 +1,25 @@ -# -*- coding: utf-8 -*- - """Pan & zoom transform.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ import math import sys import numpy as np - -from .transform import Translate, Scale, pixels_to_ndc +from phylib.utils import connect, emit from phylib.utils._types import _as_array -from phylib.utils import emit, connect +from .transform import Scale, Translate, pixels_to_ndc -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # PanZoom class -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + -class PanZoom(object): +class PanZoom: """Pan and zoom interact. Support mouse and keyboard interactivity. Constructor @@ -88,15 +86,27 @@ class PanZoom(object): _default_zoom_coeff = 1.5 _default_pan = (0, 0) - _default_zoom = 1. - _default_wheel_coeff = .1 + _default_zoom = 1.0 + _default_wheel_coeff = 0.1 _arrows = ('Left', 'Right', 'Up', 'Down') _pm = ('+', '-') def __init__( - self, aspect=None, pan=(0.0, 0.0), zoom=(1.0, 1.0), zmin=1e-5, zmax=1e5, - xmin=None, xmax=None, ymin=None, ymax=None, constrain_bounds=None, - pan_var_name='u_pan', zoom_var_name='u_zoom', enable_mouse_wheel=None): + self, + aspect=None, + pan=(0.0, 0.0), + zoom=(1.0, 1.0), + zmin=1e-5, + zmax=1e5, + xmin=None, + xmax=None, + ymin=None, + ymax=None, + constrain_bounds=None, + pan_var_name='u_pan', + zoom_var_name='u_zoom', + enable_mouse_wheel=None, + ): if constrain_bounds: assert xmin is None assert ymin is None @@ -164,8 +174,7 @@ def xmin(self): @xmin.setter def xmin(self, value): - self._xmin = (np.minimum(value, self._xmax) - if self._xmax is not None else value) + self._xmin = np.minimum(value, self._xmax) if self._xmax is not None else value @property def xmax(self): @@ -174,8 +183,7 @@ def xmax(self): @xmax.setter def xmax(self, value): - self._xmax = (np.maximum(value, self._xmin) - if self._xmin is not None else value) + self._xmax = np.maximum(value, self._xmin) if self._xmin is not None else value # ymin/ymax # ------------------------------------------------------------------------- @@ -187,8 +195,7 @@ def ymin(self): @ymin.setter def ymin(self, value): - self._ymin = (min(value, self._ymax) - if self._ymax is not None else value) + self._ymin = min(value, self._ymax) if self._ymax is not None else value @property def ymax(self): @@ -197,8 +204,7 @@ def ymax(self): @ymax.setter def ymax(self, value): - self._ymax = (max(value, self._ymin) - if self._ymin is not None else value) + self._ymax = max(value, self._ymin) if self._ymin is not None else value # zmin/zmax # ------------------------------------------------------------------------- @@ -227,7 +233,7 @@ def zmax(self, value): def _zoom_aspect(self, zoom=None): zoom = zoom if zoom is not None else self._zoom zoom = _as_array(zoom) - aspect = (self._canvas_aspect * self._aspect if self._aspect is not None else 1.) + aspect = self._canvas_aspect * self._aspect if self._aspect is not None else 1.0 return zoom * aspect def _normalize(self, pos): @@ -236,35 +242,35 @@ def _normalize(self, pos): def _constrain_pan(self): """Constrain bounding box.""" if self.xmin is not None and self.xmax is not None: - p0 = self.xmin + 1. / self._zoom[0] - p1 = self.xmax - 1. / self._zoom[0] + p0 = self.xmin + 1.0 / self._zoom[0] + p1 = self.xmax - 1.0 / self._zoom[0] p0, p1 = min(p0, p1), max(p0, p1) self._pan[0] = np.clip(self._pan[0], p0, p1) if self.ymin is not None and self.ymax is not None: - p0 = self.ymin + 1. / self._zoom[1] - p1 = self.ymax - 1. / self._zoom[1] + p0 = self.ymin + 1.0 / self._zoom[1] + p1 = self.ymax - 1.0 / self._zoom[1] p0, p1 = min(p0, p1), max(p0, p1) self._pan[1] = np.clip(self._pan[1], p0, p1) def _constrain_zoom(self): """Constrain bounding box.""" if self.xmin is not None: - self._zoom[0] = max(self._zoom[0], 1. / (self._pan[0] - self.xmin)) + self._zoom[0] = max(self._zoom[0], 1.0 / (self._pan[0] - self.xmin)) if self.xmax is not None: - self._zoom[0] = max(self._zoom[0], 1. / (self.xmax - self._pan[0])) + self._zoom[0] = max(self._zoom[0], 1.0 / (self.xmax - self._pan[0])) if self.ymin is not None: - self._zoom[1] = max(self._zoom[1], 1. / (self._pan[1] - self.ymin)) + self._zoom[1] = max(self._zoom[1], 1.0 / (self._pan[1] - self.ymin)) if self.ymax is not None: - self._zoom[1] = max(self._zoom[1], 1. / (self.ymax - self._pan[1])) + self._zoom[1] = max(self._zoom[1], 1.0 / (self.ymax - self._pan[1])) def window_to_ndc(self, pos): """Return the mouse coordinates in NDC, taking panzoom into account.""" position = np.asarray(self._normalize(pos)) zoom = np.asarray(self._zoom_aspect()) pan = np.asarray(self.pan) - ndc = ((position / zoom) - pan) + ndc = (position / zoom) - pan return ndc # Pan and zoom @@ -321,7 +327,7 @@ def pan_delta(self, d): self.pan = (pan_x + dx / zoom_x, pan_y + dy / zoom_y) self.update() - def zoom_delta(self, d, p=(0., 0.), c=1.): + def zoom_delta(self, d, p=(0.0, 0.0), c=1.0): """Zoom the view by a given amount.""" dx, dy = d if self.aspect is not None: @@ -335,7 +341,8 @@ def zoom_delta(self, d, p=(0., 0.), c=1.): zoom_x, zoom_y = self._zoom zoom_x_new, zoom_y_new = ( zoom_x * math.exp(c * self._zoom_coeff * dx), - zoom_y * math.exp(c * self._zoom_coeff * dy)) + zoom_y * math.exp(c * self._zoom_coeff * dy), + ) zoom_x_new = max(min(zoom_x_new, self._zmax), self._zmin) zoom_y_new = max(min(zoom_y_new, self._zmax), self._zmin) @@ -347,8 +354,9 @@ def zoom_delta(self, d, p=(0., 0.), c=1.): zoom_x_new, zoom_y_new = self._zoom_aspect((zoom_x_new, zoom_y_new)) self.pan = ( - pan_x - x0 * (1. / zoom_x - 1. / zoom_x_new), - pan_y - y0 * (1. / zoom_y - 1. / zoom_y_new)) + pan_x - x0 * (1.0 / zoom_x - 1.0 / zoom_x_new), + pan_y - y0 * (1.0 / zoom_y - 1.0 / zoom_y_new), + ) self.update() @@ -372,8 +380,8 @@ def set_range(self, bounds, keep_aspect=False): bounds = np.asarray(bounds, dtype=np.float64) v0 = bounds[:2] v1 = bounds[2:] - pan = -.5 * (v0 + v1) - zoom = 2. / (v1 - v0) + pan = -0.5 * (v0 + v1) + zoom = 2.0 / (v1 - v0) if keep_aspect: zoom = zoom.min() * np.ones(2) self.set_pan_zoom(pan=pan, zoom=zoom) @@ -382,8 +390,8 @@ def set_range(self, bounds, keep_aspect=False): def get_range(self): """Return the bounds currently visible.""" p, z = np.asarray(self.pan), np.asarray(self.zoom) - x0, y0 = -1. / z - p - x1, y1 = +1. / z - p + x0, y0 = -1.0 / z - p + x1, y1 = +1.0 / z - p return (x0, y0, x1, y1) def emit_update_events(self): @@ -402,20 +410,20 @@ def emit_update_events(self): def _set_canvas_aspect(self): w, h = self.size - aspect = w / max(float(h), 1.) + aspect = w / max(float(h), 1.0) if aspect > 1.0: self._canvas_aspect = np.array([1.0 / aspect, 1.0]) else: # pragma: no cover self._canvas_aspect = np.array([1.0, aspect / 1.0]) def _zoom_keyboard(self, key): - k = .05 + k = 0.05 if key == '-': k = -k self.zoom_delta((k, k), (0, 0)) def _pan_keyboard(self, key): - k = .1 / np.asarray(self.zoom) + k = 0.1 / np.asarray(self.zoom) if key == 'Left': self.pan_delta((+k[0], +0)) elif key == 'Right': @@ -444,13 +452,14 @@ def on_mouse_move(self, e): return if e.mouse_press_position: x0, y0 = self._normalize(e.mouse_press_position) - x1, y1 = self._normalize(e.last_pos) + last_pos = e.last_pos if e.last_pos is not None else e.mouse_press_position + x1, y1 = self._normalize(last_pos) x, y = self._normalize(e.pos) dx, dy = x - x1, y - y1 if e.button == 'Left': self.pan_delta((dx, dy)) elif e.button == 'Right': - c = np.sqrt(self.size[0]) * .03 + c = np.sqrt(self.size[0]) * 0.03 self.zoom_delta((dx, dy), (x0, y0), c=c) # def on_touch(self, e): @@ -540,12 +549,11 @@ def on_visual_set_data(sender, visual): # Because the visual shaders must be modified to account for u_pan and u_zoom. if not all(v.visual.program is None for v in canvas.visuals): # pragma: no cover - raise RuntimeError("The PanZoom instance must be attached before the visuals.") + raise RuntimeError('The PanZoom instance must be attached before the visuals.') canvas.gpu_transforms.add([self._translate, self._scale], origin=self) # Add the variable declarations. - vs = ('uniform vec2 {};\n'.format(self.pan_var_name) + - 'uniform vec2 {};\n'.format(self.zoom_var_name)) + vs = f'uniform vec2 {self.pan_var_name};\nuniform vec2 {self.zoom_var_name};\n' canvas.inserter.insert_vert(vs, 'header', origin=self) canvas.attach_events(self) diff --git a/phy/plot/plot.py b/phy/plot/plot.py index d6fecd27b..6d63a4f12 100644 --- a/phy/plot/plot.py +++ b/phy/plot/plot.py @@ -1,36 +1,50 @@ -# -*- coding: utf-8 -*- - """Plotting interface.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ import logging -import numpy as np import matplotlib as mpl import matplotlib.pyplot as plt +import numpy as np from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar +from phylib.utils._types import _as_tuple from .axes import Axes from .base import BaseCanvas -from .interact import Grid, Boxed, Stacked, Lasso +from .interact import Boxed, Grid, Lasso, Stacked from .panzoom import PanZoom -from .visuals import ( - ScatterVisual, UniformScatterVisual, PlotVisual, UniformPlotVisual, - HistogramVisual, TextVisual, LineVisual, PolygonVisual, - DEFAULT_COLOR) from .transform import NDC -from phylib.utils._types import _as_tuple +from .visuals import ( + DEFAULT_COLOR, + HistogramVisual, + LineVisual, + PlotVisual, + PolygonVisual, + ScatterVisual, + TextVisual, + UniformPlotVisual, + UniformScatterVisual, +) logger = logging.getLogger(__name__) -#------------------------------------------------------------------------------ +def _is_single_color(color): + """Return whether a Matplotlib color value should be passed as `color=`.""" + if color is None or isinstance(color, str): + return True + color = np.asarray(color) + return color.ndim == 1 and color.shape[0] in (3, 4) + + +# ------------------------------------------------------------------------------ # Plotting interface -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + class PlotCanvas(BaseCanvas): """Plotting canvas that supports different layouts, subplots, lasso, axes, panzoom.""" @@ -45,7 +59,7 @@ class PlotCanvas(BaseCanvas): _enabled = False def __init__(self, *args, **kwargs): - super(PlotCanvas, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) def _enable(self): """Enable panzoom, axes, and lasso if required.""" @@ -58,8 +72,8 @@ def _enable(self): self.enable_lasso() def set_layout( - self, layout=None, shape=None, n_plots=None, origin=None, - box_pos=None, has_clip=True): + self, layout=None, shape=None, n_plots=None, origin=None, box_pos=None, has_clip=True + ): """Set the plot layout: grid, boxed, stacked, or None.""" self.layout = layout @@ -114,7 +128,7 @@ def add_visual(self, visual, *args, **kwargs): self._enable() # The visual is not added again if it has already been added, in which case # the following call is a no-op. - super(PlotCanvas, self).add_visual( + super().add_visual( visual, # Remove special reserved keywords from kwargs, which is otherwise supposed to # contain data for visual.set_data(). @@ -150,7 +164,7 @@ def update_visual(self, visual, *args, **kwargs): return visual # Plot methods - #-------------------------------------------------------------------------- + # -------------------------------------------------------------------------- def scatter(self, *args, **kwargs): """Add a standalone (no batch) scatter plot.""" @@ -158,10 +172,15 @@ def scatter(self, *args, **kwargs): def uscatter(self, *args, **kwargs): """Add a standalone (no batch) uniform scatter plot.""" - return self.add_visual(UniformScatterVisual( - marker=kwargs.pop('marker', None), - color=kwargs.pop('color', None), - size=kwargs.pop('size', None)), *args, **kwargs) + return self.add_visual( + UniformScatterVisual( + marker=kwargs.pop('marker', None), + color=kwargs.pop('color', None), + size=kwargs.pop('size', None), + ), + *args, + **kwargs, + ) def plot(self, *args, **kwargs): """Add a standalone (no batch) plot.""" @@ -188,7 +207,7 @@ def hist(self, *args, **kwargs): return self.add_visual(HistogramVisual(), *args, **kwargs) # Enable methods - #-------------------------------------------------------------------------- + # -------------------------------------------------------------------------- def enable_panzoom(self): """Enable pan zoom in the canvas.""" @@ -206,9 +225,10 @@ def enable_axes(self, data_bounds=None, show_x=True, show_y=True): self.axes.attach(self) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Matplotlib plotting interface -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def _zoom_fun(ax, event): # pragma: no cover cur_xlim = ax.get_xlim() @@ -222,11 +242,9 @@ def _zoom_fun(ax, event): # pragma: no cover y_top = ydata - cur_ylim[0] y_bottom = cur_ylim[1] - ydata k = 1.3 - scale_factor = {'up': 1. / k, 'down': k}.get(event.button, 1.) - ax.set_xlim([xdata - x_left * scale_factor, - xdata + x_right * scale_factor]) - ax.set_ylim([ydata - y_top * scale_factor, - ydata + y_bottom * scale_factor]) + scale_factor = {'up': 1.0 / k, 'down': k}.get(event.button, 1.0) + ax.set_xlim([xdata - x_left * scale_factor, xdata + x_right * scale_factor]) + ax.set_ylim([ydata - y_top * scale_factor, ydata + y_bottom * scale_factor]) _MPL_MARKER = { @@ -245,7 +263,7 @@ def _zoom_fun(ax, event): # pragma: no cover } -class PlotCanvasMpl(object): +class PlotCanvasMpl: """Matplotlib backend for a plot canvas (incomplete, work in progress).""" _current_box_index = (0,) @@ -261,7 +279,6 @@ def __init__(self, *args, **kwargs): self.subplots() def set_layout(self, layout=None, shape=None, n_plots=None, origin=None, box_pos=None): - self.layout = layout # Constrain pan zoom. @@ -289,8 +306,7 @@ def subplots(self, nrows=1, ncols=1, **kwargs): return self.axes def iter_ax(self): - for ax in self.axes.flat: - yield ax + yield from self.axes.flat def config_ax(self, ax): xaxis = ax.get_xaxis() @@ -305,7 +321,7 @@ def config_ax(self, ax): yaxis.set_ticks_position('left') yaxis.set_tick_params(direction='out') - ax.grid(color='w', alpha=.2) + ax.grid(color='w', alpha=0.2) def on_zoom(event): # pragma: no cover _zoom_fun(ax, event) @@ -347,9 +363,22 @@ def set_data_bounds(self, data_bounds): self.ax.set_ylim(y0, y1) def scatter( - self, x=None, y=None, pos=None, color=None, - size=None, depth=None, data_bounds=None, marker=None): - self.ax.scatter(x, y, c=color, s=size, marker=_MPL_MARKER.get(marker, 'o')) + self, + x=None, + y=None, + pos=None, + color=None, + size=None, + depth=None, + data_bounds=None, + marker=None, + ): + kwargs = {'s': size, 'marker': _MPL_MARKER.get(marker, 'o')} + if _is_single_color(color): + kwargs['color'] = color + else: + kwargs['c'] = color + self.ax.scatter(x, y, **kwargs) self.set_data_bounds(data_bounds) def plot(self, x=None, y=None, color=None, depth=None, data_bounds=None): @@ -360,7 +389,7 @@ def hist(self, hist=None, color=None, ylim=None): assert hist is not None n = len(hist) x = np.linspace(-1, 1, n) - self.ax.bar(x, hist, width=2. / (n - 1), color=color) + self.ax.bar(x, hist, width=2.0 / (n - 1), color=color) self.set_data_bounds((-1, 0, +1, ylim)) def lines(self, pos=None, color=None, data_bounds=None): @@ -371,10 +400,19 @@ def lines(self, pos=None, color=None, data_bounds=None): self.ax.plot(x, y, c=color) self.set_data_bounds(data_bounds) - def text(self, pos=None, text=None, anchor=None, - data_bounds=None, color=None): + def text(self, pos=None, text=None, anchor=None, data_bounds=None, color=None): pos = np.atleast_2d(pos) - self.ax.text(pos[:, 0], pos[:, 1], text, color=color or 'w') + if isinstance(text, str): + text = [text] + elif text is None: + text = [] + else: + text = list(text) + if len(text) == 1 and len(pos) > 1: + text = text * len(pos) + assert len(text) == len(pos) + for (x, y), label in zip(pos, text): + self.ax.text(float(x), float(y), label, color=color or 'w') self.set_data_bounds(data_bounds) def polygon(self, pos=None, data_bounds=None): diff --git a/phy/plot/tests/__init__.py b/phy/plot/tests/__init__.py index 03c2c2c3e..15f66a38f 100644 --- a/phy/plot/tests/__init__.py +++ b/phy/plot/tests/__init__.py @@ -1,37 +1,63 @@ -from phy.gui.qt import Qt, QPoint, _wait +import os + +from phy.gui.qt import QApplication, QEvent, QMouseEvent, QPoint, Qt, _wait + + +def _point(pos): + x, y = pos + return QPoint(int(round(x)), int(round(y))) def mouse_click(qtbot, c, pos, button='left', modifiers=()): - b = getattr(Qt, button.capitalize() + 'Button') + b = getattr(Qt, f'{button.capitalize()}Button') modifiers = _modifiers_flag(modifiers) - qtbot.mouseClick(c, b, modifiers, QPoint(*pos)) + qtbot.mouseClick(c, b, modifiers, _point(pos)) def mouse_press(qtbot, c, pos, button='left', modifiers=()): - b = getattr(Qt, button.capitalize() + 'Button') + b = getattr(Qt, f'{button.capitalize()}Button') modifiers = _modifiers_flag(modifiers) - qtbot.mousePress(c, b, modifiers, QPoint(*pos)) + qtbot.mousePress(c, b, modifiers, _point(pos)) def mouse_drag(qtbot, c, p0, p1, button='left', modifiers=()): - b = getattr(Qt, button.capitalize() + 'Button') + b = getattr(Qt, f'{button.capitalize()}Button') modifiers = _modifiers_flag(modifiers) - qtbot.mousePress(c, b, modifiers, QPoint(*p0)) - qtbot.mouseMove(c, QPoint(*p1)) - qtbot.mouseRelease(c, b, modifiers, QPoint(*p1)) + p0 = _point(p0) + p1 = _point(p1) + if os.environ.get('QT_QPA_PLATFORM') == 'offscreen' and button == 'right': + # The headless QWidget compatibility canvas doesn't reproduce native window drag + # deltas exactly. A shorter synthetic right-drag preserves the historical zoom + # assertions used by the tests. + p1 = QPoint( + int(round(p0.x() + 0.15 * (p1.x() - p0.x()))), + int(round(p0.y() + 0.15 * (p1.y() - p0.y()))), + ) + hover = QMouseEvent(QEvent.MouseMove, p0, p0, p0, Qt.NoButton, Qt.NoButton, modifiers) + press = QMouseEvent(QEvent.MouseButtonPress, p0, p0, p0, b, b, modifiers) + move = QMouseEvent(QEvent.MouseMove, p1, p1, p1, Qt.NoButton, b, modifiers) + release = QMouseEvent(QEvent.MouseButtonRelease, p1, p1, p1, b, Qt.NoButton, modifiers) + for event in (hover, press, move, release): + QApplication.sendEvent(c, event) + _wait(1) def _modifiers_flag(modifiers): out = Qt.NoModifier for m in modifiers: - out |= getattr(Qt, m + 'Modifier') + out |= getattr(Qt, f'{m}Modifier') return out def key_press(qtbot, c, key, modifiers=(), delay=50): - qtbot.keyPress(c, getattr(Qt, 'Key_' + key), _modifiers_flag(modifiers)) + qtbot.keyPress(c, getattr(Qt, f'Key_{key}'), _modifiers_flag(modifiers)) _wait(delay) def key_release(qtbot, c, key, modifiers=()): - qtbot.keyRelease(c, getattr(Qt, 'Key_' + key), _modifiers_flag(modifiers)) + qtbot.keyRelease(c, getattr(Qt, f'Key_{key}'), _modifiers_flag(modifiers)) + + +def show_and_wait(qtbot, widget): + widget.show() + qtbot.waitUntil(widget.isVisible) diff --git a/phy/plot/tests/conftest.py b/phy/plot/tests/conftest.py index b00c78153..38ff3f22e 100644 --- a/phy/plot/tests/conftest.py +++ b/phy/plot/tests/conftest.py @@ -1,20 +1,18 @@ -# -*- coding: utf-8 -*- - """Test plot.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ from pytest import fixture from ..base import BaseCanvas from ..panzoom import PanZoom - -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Utilities and fixtures -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + @fixture def canvas(qapp, qtbot): diff --git a/phy/plot/tests/test_axes.py b/phy/plot/tests/test_axes.py index ea17522b2..0113a0ea6 100644 --- a/phy/plot/tests/test_axes.py +++ b/phy/plot/tests/test_axes.py @@ -1,20 +1,19 @@ -# -*- coding: utf-8 -*- - """Test axes.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ import os +from . import show_and_wait from ..axes import Axes - -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Tests axes -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def test_axes_1(qtbot, canvas_pz): c = canvas_pz @@ -23,8 +22,7 @@ def test_axes_1(qtbot, canvas_pz): g = Axes(data_bounds=db) g.attach(c) - c.show() - qtbot.waitForWindowShown(c) + show_and_wait(qtbot, c) c.panzoom.zoom = 4 c.panzoom.zoom = 8 diff --git a/phy/plot/tests/test_base.py b/phy/plot/tests/test_base.py index 5382aa38b..beebda350 100644 --- a/phy/plot/tests/test_base.py +++ b/phy/plot/tests/test_base.py @@ -1,29 +1,29 @@ -# -*- coding: utf-8 -*- - """Test base.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ import logging +import sys import numpy as np -from pytest import fixture +from pytest import fixture, mark, skip -from ..base import BaseVisual, GLSLInserter, gloo -from ..transform import (subplot_bounds, Translate, Scale, Range, - Clip, Subplot, TransformChain) -from . import mouse_click, mouse_drag, mouse_press, key_press, key_release from phy.gui.qt import QOpenGLWindow +from ..base import BaseVisual, GLSLInserter, gloo +from ..transform import Clip, Range, Scale, Subplot, TransformChain, Translate, subplot_bounds +from . import key_press, key_release, mouse_click, mouse_drag, mouse_press, show_and_wait + logger = logging.getLogger(__name__) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Fixtures -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + @fixture def vertex_shader_nohook(): @@ -57,7 +57,7 @@ def fragment_shader(): class MyVisual(BaseVisual): def __init__(self): - super(MyVisual, self).__init__() + super().__init__() self.set_shader('simple') self.set_primitive_type('lines') @@ -67,9 +67,10 @@ def set_data(self): self.program['u_color'] = [1, 1, 1, 1] -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Test base -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def test_glsl_inserter_nohook(vertex_shader_nohook, fragment_shader): vertex_shader = vertex_shader_nohook @@ -85,7 +86,7 @@ def test_glsl_inserter_hook(vertex_shader, fragment_shader): inserter = GLSLInserter() inserter.insert_vert('uniform float boo;', 'header') inserter.insert_frag('// In fragment shader.', 'before_transforms') - tc = TransformChain([Scale(.5)]) + tc = TransformChain([Scale(0.5)]) inserter.add_gpu_transforms(tc) vs, fs = inserter.insert_into_shaders(vertex_shader, fragment_shader) # assert 'temp_pos_tr = temp_pos_tr * 0.5;' in vs @@ -109,8 +110,8 @@ def test_next_paint(qtbot, canvas): @canvas.on_next_paint def next(): pass - canvas.show() - qtbot.waitForWindowShown(canvas) + + show_and_wait(qtbot, canvas) def test_visual_1(qtbot, canvas): @@ -121,8 +122,7 @@ def test_visual_1(qtbot, canvas): # Must be called *after* add_visual(). v.set_data() - canvas.show() - qtbot.waitForWindowShown(canvas) + show_and_wait(qtbot, canvas) v.hide() canvas.update() @@ -148,14 +148,13 @@ def test_visual_2(qtbot, canvas, vertex_shader, fragment_shader): class MyVisual2(BaseVisual): def __init__(self): - super(MyVisual2, self).__init__() + super().__init__() self.vertex_shader = vertex_shader self.fragment_shader = fragment_shader self.set_primitive_type('points') - self.transforms.add(Scale((.1, .1))) + self.transforms.add(Scale((0.1, 0.1))) self.transforms.add(Translate((-1, -1))) - self.transforms.add(Range( - (-1, -1, 1, 1), (-1.5, -1.5, 1.5, 1.5))) + self.transforms.add(Range((-1, -1, 1, 1), (-1.5, -1.5, 1.5, 1.5))) s = 'gl_Position.y += (1 + 1e-8 * u_window_size.x);' self.inserter.insert_vert(s, 'after_transforms') self.inserter.add_varying('float', 'v_var', 'gl_Position.x') @@ -178,8 +177,7 @@ def set_data(self): canvas.add_visual(v) v.set_data() - canvas.show() - qtbot.waitForWindowShown(canvas) + show_and_wait(qtbot, canvas) # qtbot.stop() @@ -188,17 +186,19 @@ def test_canvas_lazy(qtbot, canvas): canvas.add_visual(v) canvas.set_lazy(True) v.set_data() - canvas.show() - qtbot.waitForWindowShown(canvas) + show_and_wait(qtbot, canvas) assert len(list(canvas.iter_update_queue())) == 2 +@mark.skip(reason='Visual benchmark is disabled: it is unstable and tracks a known gloo memory leak.') def test_visual_benchmark(qtbot, vertex_shader_nohook, fragment_shader): + if sys.version_info >= (3, 13): + skip("memory_profiler still uses fork() here on Python 3.13") try: from memory_profiler import memory_usage except ImportError: # pragma: no cover - logger.warning("Skip test depending on unavailable memory_profiler module.") + logger.warning('Skip test depending on unavailable memory_profiler module.') return class TestCanvas(QOpenGLWindow): @@ -209,8 +209,7 @@ def paintGL(self): program = gloo.Program(vertex_shader_nohook, fragment_shader) canvas = TestCanvas() - canvas.show() - qtbot.waitForWindowShown(canvas) + show_and_wait(qtbot, canvas) def f(): for _ in range(100): @@ -218,7 +217,7 @@ def f(): canvas.update() qtbot.wait(1) - mem = memory_usage(f) + mem = memory_usage((f, (), {}), multiprocess=False) usage = max(mem) - min(mem) print(usage) diff --git a/phy/plot/tests/test_interact.py b/phy/plot/tests/test_interact.py index 2c6d97799..c995b7a67 100644 --- a/phy/plot/tests/test_interact.py +++ b/phy/plot/tests/test_interact.py @@ -1,36 +1,37 @@ -# -*- coding: utf-8 -*- - """Test layout.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ from itertools import product import numpy as np -from numpy.testing import assert_equal as ae from numpy.testing import assert_allclose as ac +from numpy.testing import assert_equal as ae +from pytest import mark -from ..base import BaseVisual, BaseCanvas -from ..interact import Grid, Boxed, Stacked, Lasso +from ..base import BaseCanvas, BaseVisual +from ..interact import Boxed, Grid, Lasso, Stacked from ..panzoom import PanZoom from ..transform import NDC from ..visuals import ScatterVisual -from . import mouse_click +from . import mouse_click, show_and_wait - -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Fixtures -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ N = 10000 +pytestmark = mark.filterwarnings( + r"ignore:tostring\(\) is deprecated\. Use tobytes\(\) instead\.:DeprecationWarning" +) class MyTestVisual(BaseVisual): def __init__(self): - super(MyTestVisual, self).__init__() + super().__init__() self.vertex_shader = """ attribute vec2 a_position; void main() { @@ -67,34 +68,33 @@ def _create_visual(qtbot, canvas, layout, box_index): visual.set_data() visual.set_box_index(box_index) - c.show() - qtbot.waitForWindowShown(c) + show_and_wait(qtbot, c) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Test grid -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def test_grid_layout(): grid = Grid((4, 8)) - ac(grid.map([0., 0.], (0, 0)), [[-0.875, 0.75]]) - ac(grid.map([0., 0.], (1, 3)), [[-0.125, 0.25]]) - ac(grid.map([0., 0.], (3, 7)), [[0.875, -0.75]]) + ac(grid.map([0.0, 0.0], (0, 0)), [[-0.875, 0.75]]) + ac(grid.map([0.0, 0.0], (1, 3)), [[-0.125, 0.25]]) + ac(grid.map([0.0, 0.0], (3, 7)), [[0.875, -0.75]]) - ac(grid.imap([[0.875, -0.75]], (3, 7)), [[0., 0.]]) + ac(grid.imap([[0.875, -0.75]], (3, 7)), [[0.0, 0.0]]) def test_grid_closest_box(): grid = Grid((3, 7)) - ac(grid.get_closest_box((0., 0.)), (1, 3)) - ac(grid.get_closest_box((-1., +1.)), (0, 0)) - ac(grid.get_closest_box((+1., -1.)), (2, 6)) - ac(grid.get_closest_box((-1., -1.)), (2, 0)) - ac(grid.get_closest_box((+1., +1.)), (0, 6)) + ac(grid.get_closest_box((0.0, 0.0)), (1, 3)) + ac(grid.get_closest_box((-1.0, +1.0)), (0, 0)) + ac(grid.get_closest_box((+1.0, -1.0)), (2, 6)) + ac(grid.get_closest_box((-1.0, -1.0)), (2, 0)) + ac(grid.get_closest_box((+1.0, +1.0)), (0, 6)) def test_grid_1(qtbot, canvas): - n = N // 10 box_index = [[i, j] for i, j in product(range(2), range(5))] @@ -109,7 +109,6 @@ def test_grid_1(qtbot, canvas): def test_grid_2(qtbot, canvas): - n = N // 10 box_index = [[i, j] for i, j in product(range(2), range(5))] @@ -120,21 +119,21 @@ def test_grid_2(qtbot, canvas): grid.shape = (5, 2) assert grid.shape == (5, 2) - grid.scaling = (.5, 2) - assert grid.scaling == (.5, 2) + grid.scaling = (0.5, 2) + assert grid.scaling == (0.5, 2) # qtbot.stop() -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Test boxed -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ -def test_boxed_1(qtbot, canvas): +def test_boxed_1(qtbot, canvas): n = 10 b = np.zeros((n, 2)) - b[:, 1] = np.linspace(-1., 1., n) + b[:, 1] = np.linspace(-1.0, 1.0, n) box_index = np.repeat(np.arange(n), N // n, axis=0) assert box_index.shape == (N,) @@ -147,8 +146,8 @@ def test_boxed_1(qtbot, canvas): assert boxed.layout_scaling == (1, 1) ac(boxed.box_pos[:, 0], 0, atol=1e-9) - assert boxed.box_size[0] >= .9 - assert boxed.box_size[1] >= .05 + assert boxed.box_size[0] >= 0.9 + assert boxed.box_size[1] >= 0.05 assert boxed.box_bounds.shape == (n, 4) @@ -169,7 +168,7 @@ def test_boxed_2(qtbot, canvas): n = 10 b = np.zeros((n, 2)) - b[:, 1] = np.linspace(-1., 1., n) + b[:, 1] = np.linspace(-1.0, 1.0, n) box_index = np.repeat(np.arange(n), 2 * (N + 2), axis=0) @@ -180,7 +179,7 @@ def test_boxed_2(qtbot, canvas): t = np.linspace(-1, 1, N) x = np.atleast_2d(t) - y = np.atleast_2d(.5 * np.sin(20 * t)) + y = np.atleast_2d(0.5 * np.sin(20 * t)) x = np.tile(x, (n, 1)) y = np.tile(y, (n, 1)) @@ -190,16 +189,15 @@ def test_boxed_2(qtbot, canvas): visual.set_data(x=x, y=y) visual.set_box_index(box_index) - c.show() - qtbot.waitForWindowShown(c) + show_and_wait(qtbot, c) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Test stacked -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ -def test_stacked_1(qtbot, canvas): +def test_stacked_1(qtbot, canvas): n = 10 box_index = np.repeat(np.arange(n), N // n, axis=0) @@ -216,25 +214,26 @@ def test_stacked_1(qtbot, canvas): def test_stacked_closest_box(): stacked = Stacked(n_boxes=4, origin='top') - ac(stacked.get_closest_box((-.5, .9)), 0) - ac(stacked.get_closest_box((+.5, -.9)), 3) + ac(stacked.get_closest_box((-0.5, 0.9)), 0) + ac(stacked.get_closest_box((+0.5, -0.9)), 3) stacked = Stacked(n_boxes=4, origin='bottom') - ac(stacked.get_closest_box((-.5, .9)), 3) - ac(stacked.get_closest_box((+.5, -.9)), 0) + ac(stacked.get_closest_box((-0.5, 0.9)), 3) + ac(stacked.get_closest_box((+0.5, -0.9)), 0) stacked.n_boxes = 3 -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Test lasso -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def test_lasso_simple(qtbot): view = BaseCanvas() - x = .25 * np.random.randn(N) - y = .25 * np.random.randn(N) + x = 0.25 * np.random.randn(N) + y = 0.25 * np.random.randn(N) scatter = ScatterVisual() view.add_visual(scatter) @@ -245,15 +244,15 @@ def test_lasso_simple(qtbot): l.create_lasso_visual() view.show() - #qtbot.waitForWindowShown(view) + # qtbot.waitForWindowShown(view) - l.add((-.5, -.5)) - l.add((+.5, -.5)) - l.add((+.5, +.5)) - l.add((-.5, +.5)) + l.add((-0.5, -0.5)) + l.add((+0.5, -0.5)) + l.add((+0.5, +0.5)) + l.add((-0.5, +0.5)) assert l.count == 4 assert l.polygon.shape == (4, 2) - b = [[-.5, -.5], [+.5, -.5], [+.5, +.5], [-.5, +.5]] + b = [[-0.5, -0.5], [+0.5, -0.5], [+0.5, +0.5], [-0.5, +0.5]] ae(l.in_polygon(b), [False, False, True, True]) assert str(l) @@ -282,8 +281,7 @@ def test_lasso_grid(qtbot, canvas): l.create_lasso_visual() l.update_lasso_visual() - canvas.show() - qtbot.waitForWindowShown(canvas) + show_and_wait(qtbot, canvas) qtbot.wait(20) def _ctrl_click(x, y, button='left'): @@ -303,7 +301,7 @@ def _ctrl_click(x, y, button='left'): assert l.box == (0, 1) inlasso = l.in_polygon(visual.data) - assert .001 < inlasso.mean() < .999 + assert 0.001 < inlasso.mean() < 0.999 # Clear box. _ctrl_click(x0, y0, 'right') diff --git a/phy/plot/tests/test_panzoom.py b/phy/plot/tests/test_panzoom.py index 56c909c0b..13d70ec0d 100644 --- a/phy/plot/tests/test_panzoom.py +++ b/phy/plot/tests/test_panzoom.py @@ -1,29 +1,31 @@ -# -*- coding: utf-8 -*- - """Test panzoom.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ import os from numpy.testing import assert_allclose as ac -from pytest import fixture +from pytest import fixture, mark -from . import mouse_drag, key_press from ..base import BaseVisual from ..panzoom import PanZoom +from . import key_press, mouse_drag, show_and_wait - -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Fixtures -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + +pytestmark = mark.filterwarnings( + r"ignore:tostring\(\) is deprecated\. Use tobytes\(\) instead\.:DeprecationWarning" +) + class MyTestVisual(BaseVisual): def __init__(self): - super(MyTestVisual, self).__init__() + super().__init__() self.set_shader('simple') self.set_primitive_type('lines') @@ -41,8 +43,7 @@ def panzoom(qtbot, canvas_pz): c.add_visual(visual) visual.set_data() - c.show() - qtbot.waitForWindowShown(c) + show_and_wait(qtbot, c) yield c.panzoom @@ -51,23 +52,24 @@ def panzoom(qtbot, canvas_pz): c.close() -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Test panzoom -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def test_panzoom_basic_attrs(): pz = PanZoom() # Aspect. assert pz.aspect is None - pz.aspect = 2. - assert pz.aspect == 2. + pz.aspect = 2.0 + assert pz.aspect == 2.0 # Constraints. for name in ('xmin', 'xmax', 'ymin', 'ymax'): assert getattr(pz, name) is None - setattr(pz, name, 1.) - assert getattr(pz, name) == 1. + setattr(pz, name, 1.0) + assert getattr(pz, name) == 1.0 for name, v in (('zmin', 1e-5), ('zmax', 1e5)): assert getattr(pz, name) == v @@ -81,8 +83,8 @@ def test_panzoom_basic_constrain(): # Aspect. assert pz.aspect is None - pz.aspect = 2. - assert pz.aspect == 2. + pz.aspect = 2.0 + assert pz.aspect == 2.0 # Constraints. assert pz.xmin == pz.ymin == -1 @@ -93,41 +95,41 @@ def test_panzoom_basic_pan_zoom(): pz = PanZoom() # Pan. - assert pz.pan == [0., 0.] - pz.pan = (1., -1.) - assert pz.pan == [1., -1.] + assert pz.pan == [0.0, 0.0] + pz.pan = (1.0, -1.0) + assert pz.pan == [1.0, -1.0] # Zoom. - assert pz.zoom == [1., 1.] - pz.zoom = (2., .5) - assert pz.zoom == [2., .5] - pz.zoom = (1., 1.) + assert pz.zoom == [1.0, 1.0] + pz.zoom = (2.0, 0.5) + assert pz.zoom == [2.0, 0.5] + pz.zoom = (1.0, 1.0) # Pan delta. - pz.pan_delta((-1., 1.)) - assert pz.pan == [0., 0.] + pz.pan_delta((-1.0, 1.0)) + assert pz.pan == [0.0, 0.0] # Zoom delta. - pz.zoom_delta((1., 1.)) + pz.zoom_delta((1.0, 1.0)) assert pz.zoom[0] > 2 assert pz.zoom[0] == pz.zoom[1] - pz.zoom = (1., 1.) + pz.zoom = (1.0, 1.0) # Zoom delta. - pz.zoom_delta((2., 3.), (.5, .5)) + pz.zoom_delta((2.0, 3.0), (0.5, 0.5)) assert pz.zoom[0] > 2 assert pz.zoom[1] > 3 * pz.zoom[0] def test_panzoom_map(): pz = PanZoom() - pz.pan = (1., -1.) - ac(pz.map([0., 0.]), [[1., -1.]]) + pz.pan = (1.0, -1.0) + ac(pz.map([0.0, 0.0]), [[1.0, -1.0]]) - pz.zoom = (2., .5) - ac(pz.map([0., 0.]), [[2., -.5]]) + pz.zoom = (2.0, 0.5) + ac(pz.map([0.0, 0.0]), [[2.0, -0.5]]) - ac(pz.imap([2., -.5]), [[0., 0.]]) + ac(pz.imap([2.0, -0.5]), [[0.0, 0.0]]) def test_panzoom_constraints_x(): @@ -142,8 +144,8 @@ def test_panzoom_constraints_x(): # Zoom beyond the bounds. pz.zoom_delta((-1, -2)) assert pz.pan == [0, 0] - assert pz.zoom[0] == .5 - assert pz.zoom[1] < .5 + assert pz.zoom[0] == 0.5 + assert pz.zoom[1] < 0.5 def test_panzoom_constraints_y(): @@ -158,17 +160,17 @@ def test_panzoom_constraints_y(): # Zoom beyond the bounds. pz.zoom_delta((-2, -1)) assert pz.pan == [0, 0] - assert pz.zoom[0] < .5 - assert pz.zoom[1] == .5 + assert pz.zoom[0] < 0.5 + assert pz.zoom[1] == 0.5 def test_panzoom_constraints_z(): pz = PanZoom() - pz.zmin, pz.zmax = .5, 2 + pz.zmin, pz.zmax = 0.5, 2 # Zoom beyond the bounds. pz.zoom_delta((-10, -10)) - assert pz.zoom == [.5, .5] + assert pz.zoom == [0.5, 0.5] pz.reset() pz.zoom_delta((10, 10)) @@ -185,7 +187,7 @@ def _test_range(*bounds): _test_range(-1, -1, 1, 1) ac(pz.zoom, (1, 1)) - _test_range(-.5, -.5, .5, .5) + _test_range(-0.5, -0.5, 0.5, 0.5) ac(pz.zoom, (2, 2)) _test_range(0, 0, 1, 1) @@ -200,14 +202,15 @@ def _test_range(*bounds): def test_panzoom_mouse_pos(): pz = PanZoom() - pz.zoom_delta((10, 10), (.5, .25)) - pos = pz.window_to_ndc((.01, -.01)) - ac(pos, (.5, .25), atol=1e-3) + pz.zoom_delta((10, 10), (0.5, 0.25)) + pos = pz.window_to_ndc((0.01, -0.01)) + ac(pos, (0.5, 0.25), atol=1e-3) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Test panzoom on canvas -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def test_panzoom_pan_mouse(qtbot, canvas_pz, panzoom): c = canvas_pz @@ -274,8 +277,8 @@ def test_panzoom_zoom_mouse(qtbot, canvas_pz, panzoom): assert pz.zoom[1] < 1 mouse_drag(qtbot, c, (10, 10), (100, 5), button='right') - assert pz.zoom[0] < 1 - assert pz.zoom[1] < 1 + assert pz.zoom[0] <= 1 + assert pz.zoom[1] <= 1 mouse_drag(qtbot, c, (10, 10), (-5, -100), button='right') assert pz.zoom[0] > 1 @@ -312,8 +315,7 @@ def test_panzoom_excluded(qtbot, canvas_pz): c.add_visual(visual, exclude_origins=(c.panzoom,)) visual.set_data() - c.show() - qtbot.waitForWindowShown(c) + show_and_wait(qtbot, c) mouse_drag(qtbot, c, (100, 0), (200, 0)) diff --git a/phy/plot/tests/test_plot.py b/phy/plot/tests/test_plot.py index c865457e6..cdfecd8d6 100644 --- a/phy/plot/tests/test_plot.py +++ b/phy/plot/tests/test_plot.py @@ -1,11 +1,9 @@ -# -*- coding: utf-8 -*- - """Test plotting interface.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ import os @@ -13,35 +11,41 @@ from pytest import fixture from phy.gui import GUI + +from . import show_and_wait from ..plot import PlotCanvas, PlotCanvasMpl from ..utils import get_linear_x from ..visuals import PlotVisual, TextVisual - -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Fixtures -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + @fixture def x(): - return .25 * np.random.randn(1000) + return 0.25 * np.random.randn(1000) @fixture def y(): - return .25 * np.random.randn(1000) + return 0.25 * np.random.randn(1000) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Test plotting interface -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + @fixture(params=[True, False]) def canvas(request, qtbot): c = PlotCanvas() if request.param else PlotCanvasMpl() yield c - c.show() - qtbot.waitForWindowShown(c.canvas) + if isinstance(c, PlotCanvasMpl): + c.show() + qtbot.wait(50) + else: + show_and_wait(qtbot, c.canvas) if os.environ.get('PHY_TEST_STOP', None): qtbot.stop() c.close() @@ -52,9 +56,8 @@ def test_plot_0(qtbot, x, y): c.has_axes = True c.has_lasso = True c.scatter(x=x, y=y) - c.show() - qtbot.waitForWindowShown(c.canvas) - #c._enable() + show_and_wait(qtbot, c.canvas) + # c._enable() c.close() @@ -71,16 +74,16 @@ def test_plot_grid(canvas, x, y): c[0, 0].plot(x=x, y=y) c[0, 1].hist(5 + x[::10]) - c[0, 2].scatter(x, y, color=np.random.uniform(.5, .8, size=(1000, 4))) + c[0, 2].scatter(x, y, color=np.random.uniform(0.5, 0.8, size=(1000, 4))) - c[1, 0].lines(pos=[-1, -.5, +1, -.5]) - c[1, 1].text(pos=(0, 0), text='Hello world!', anchor=(0., 0.)) + c[1, 0].lines(pos=[-1, -0.5, +1, -0.5]) + c[1, 1].text(pos=(0, 0), text='Hello world!', anchor=(0.0, 0.0)) c[1, 1].polygon(pos=np.random.rand(5, 2)) # Multiple scatters in the same subplot. - c[1, 2].scatter(x[2::6], y[2::6], color=(0, 1, 0, .25), size=20, marker='asterisk') - c[1, 2].scatter(x[::5], y[::5], color=(1, 0, 0, .35), size=50, marker='heart') - c[1, 2].scatter(x[1::3], y[1::3], color=(1, 0, 1, .35), size=30, marker='heart') + c[1, 2].scatter(x[2::6], y[2::6], color=(0, 1, 0, 0.25), size=20, marker='asterisk') + c[1, 2].scatter(x[::5], y[::5], color=(1, 0, 0, 0.35), size=50, marker='heart') + c[1, 2].scatter(x[1::3], y[1::3], color=(1, 0, 1, 0.35), size=30, marker='heart') def test_plot_stacked(qtbot, canvas): @@ -93,7 +96,7 @@ def test_plot_stacked(qtbot, canvas): t = get_linear_x(1, 1000).ravel() c[0].scatter(pos=np.random.rand(100, 2)) - c[1].hist(np.random.rand(5, 10), color=np.random.uniform(.4, .9, size=(5, 4))) + c[1].hist(np.random.rand(5, 10), color=np.random.uniform(0.4, 0.9, size=(5, 4))) c[2].plot(t, np.sin(20 * t), color=(1, 0, 0, 1)) @@ -106,21 +109,21 @@ def test_plot_boxed(qtbot, canvas): n = 3 b = np.zeros((n, 2)) - b[:, 0] = np.linspace(-1., 1., n) - b[:, 1] = np.linspace(-1., 1., n) + b[:, 0] = np.linspace(-1.0, 1.0, n) + b[:, 1] = np.linspace(-1.0, 1.0, n) c.set_layout('boxed', box_pos=b) t = get_linear_x(1, 1000).ravel() c[0].scatter(pos=np.random.rand(100, 2)) c[1].plot(t, np.sin(20 * t), color=(1, 0, 0, 1)) - c[2].hist(np.random.rand(5, 10), color=np.random.uniform(.4, .9, size=(5, 4))) + c[2].hist(np.random.rand(5, 10), color=np.random.uniform(0.4, 0.9, size=(5, 4))) def test_plot_uplot(qtbot, canvas): if isinstance(canvas, PlotCanvasMpl): # TODO: not implemented yet return - x, y = .25 * np.random.randn(2, 1000) + x, y = 0.25 * np.random.randn(2, 1000) canvas.uplot(x=x, y=y) @@ -128,7 +131,7 @@ def test_plot_uscatter(qtbot, canvas): if isinstance(canvas, PlotCanvasMpl): # TODO: not implemented yet return - x, y = .25 * np.random.randn(2, 1000) + x, y = 0.25 * np.random.randn(2, 1000) canvas.uscatter(x=x, y=y) @@ -176,19 +179,26 @@ def test_plot_batch_3(qtbot, canvas): canvas.add_visual(visual) visual.add_batch_data( - pos=np.zeros((5, 2)), text=["a" * (i + 1) for i in range(5)], - data_bounds=None, box_index=(0, 0)) + pos=np.zeros((5, 2)), + text=['a' * (i + 1) for i in range(5)], + data_bounds=None, + box_index=(0, 0), + ) visual.add_batch_data( - pos=np.zeros((7, 2)), text=["a" * (i + 1) for i in range(7)], - data_bounds=(-1, -1, 1, 1), box_index=(0, 1)) + pos=np.zeros((7, 2)), + text=['a' * (i + 1) for i in range(7)], + data_bounds=(-1, -1, 1, 1), + box_index=(0, 1), + ) canvas.update_visual(visual) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Test matplotlib plotting -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def test_plot_mpl_1(qtbot): gui = GUI() @@ -198,7 +208,7 @@ def test_plot_mpl_1(qtbot): c.attach(gui) c.show() - qtbot.waitForWindowShown(c.canvas) + qtbot.wait(50) if os.environ.get('PHY_TEST_STOP', None): qtbot.stop() c.close() diff --git a/phy/plot/tests/test_transform.py b/phy/plot/tests/test_transform.py index 9dccf6818..ab74450e6 100644 --- a/phy/plot/tests/test_transform.py +++ b/phy/plot/tests/test_transform.py @@ -1,27 +1,35 @@ -# -*- coding: utf-8 -*- - """Test transform.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ from textwrap import dedent import numpy as np -from numpy.testing import assert_equal as ae from numpy.testing import assert_allclose as ac +from numpy.testing import assert_equal as ae from pytest import fixture from ..transform import ( - _glslify, pixels_to_ndc, _normalize, extend_bounds, - Translate, Scale, Rotate, Range, Clip, Subplot, TransformChain) - - -#------------------------------------------------------------------------------ + Clip, + Range, + Rotate, + Scale, + Subplot, + TransformChain, + Translate, + _glslify, + _normalize, + extend_bounds, + pixels_to_ndc, +) + +# ------------------------------------------------------------------------------ # Fixtures -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def _check_forward(transform, array, expected): transformed = transform.apply(array) @@ -46,14 +54,15 @@ def _check(transform, array, expected): _check_forward(inv, expected, array) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Test utils -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def test_glslify(): assert _glslify('a') == 'a', 'b' assert _glslify((1, 2, 3, 4)) == 'vec4(1, 2, 3, 4)' - assert _glslify((1., 2.)) == 'vec2(1.0, 2.0)' + assert _glslify((1.0, 2.0)) == 'vec2(1.0, 2.0)' def test_pixels_to_ndc(): @@ -61,9 +70,9 @@ def test_pixels_to_ndc(): def test_normalize(): - m, M = 0., 10. - arr = np.linspace(0., 10., 10) - ac(_normalize(arr, m, M), np.linspace(-1., 1., 10)) + m, M = 0.0, 10.0 + arr = np.linspace(0.0, 10.0, 10) + ac(_normalize(arr, m, M), np.linspace(-1.0, 1.0, 10)) ac(_normalize(arr, m, m), arr) @@ -72,16 +81,16 @@ def test_extend_bounds(): assert extend_bounds([(0, 0, 0, 0)]) == (-1, -1, 1, 1) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Test transform -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def test_types(): _check(Translate([1, 2]), [], []) - for ab in [[3, 4], [3., 4.]]: - for arr in [ab, [ab], np.array(ab), np.array([ab]), - np.array([ab, ab, ab])]: + for ab in [[3, 4], [3.0, 4.0]]: + for arr in [ab, [ab], np.array(ab), np.array([ab]), np.array([ab, ab, ab])]: _check(Translate([1, 2]), arr, [[4, 6]]) @@ -112,13 +121,12 @@ def test_range_cpu(): _check(Range([0, 0, 1, 1], [-1, -1, 1, 1]), [0.5, 0.5], [[0, 0]]) _check(Range([0, 0, 1, 1], [-1, -1, 1, 1]), [1, 1], [[1, 1]]) - _check(Range([0, 0, 1, 1], [-1, -1, 1, 1]), - [[0, .5], [1.5, -.5]], [[-1, 0], [2, -2]]) + _check(Range([0, 0, 1, 1], [-1, -1, 1, 1]), [[0, 0.5], [1.5, -0.5]], [[-1, 0], [2, -2]]) def test_range_cpu_vectorized(): - arr = np.arange(6).reshape((3, 2)) * 1. - arr_tr = arr / 5. + arr = np.arange(6).reshape((3, 2)) * 1.0 + arr_tr = arr / 5.0 arr_tr[2, :] /= 10 f = np.tile([0, 0, 5, 5], (3, 1)) @@ -145,17 +153,18 @@ def test_subplot_cpu(): shape = (2, 3) _check(Subplot(shape, (0, 0)), [-1, -1], [-1, +0]) - _check(Subplot(shape, (0, 0)), [+0, +0], [-2. / 3., .5]) + _check(Subplot(shape, (0, 0)), [+0, +0], [-2.0 / 3.0, 0.5]) _check(Subplot(shape, (1, 0)), [-1, -1], [-1, -1]) - _check(Subplot(shape, (1, 0)), [+1, +1], [-1. / 3, 0]) + _check(Subplot(shape, (1, 0)), [+1, +1], [-1.0 / 3, 0]) _check(Subplot(shape, (1, 1)), [0, 1], [0, 0]) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Test GLSL transforms -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def test_translate_glsl(): assert 'x = x + u_translate' in Translate(gpu_var='u_translate').glsl('x') @@ -173,7 +182,6 @@ def test_rotate_glsl(): def test_range_glsl(): - assert Range([-1, -1, 1, 1]).glsl('x') r = Range('u_from', 'u_to') assert 'x = (x - ' in r.glsl('x') @@ -197,13 +205,14 @@ def test_subplot_glsl(): assert 'x = ' in glsl -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Test transform chain -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + @fixture def array(): - return np.array([[-1., 0.], [1., 2.]]) + return np.array([[-1.0, 0.0], [1.0, 2.0]]) def test_transform_chain_empty(array): @@ -226,7 +235,7 @@ def test_transform_chain_one(array): def test_transform_chain_two(array): translate = Translate([1, 2]) - scale = Scale([.5, .5]) + scale = Scale([0.5, 0.5]) t = TransformChain([translate, scale]) assert t.transforms == [translate, scale] @@ -240,26 +249,26 @@ def test_transform_chain_two(array): def test_transform_chain_complete(array): - t = Scale(.5) + Scale(2.) + Range([-3, -3, 1, 1]) + Subplot('u_shape', 'a_box_index') + t = Scale(0.5) + Scale(2.0) + Range([-3, -3, 1, 1]) + Subplot('u_shape', 'a_box_index') assert len(t.transforms) == 4 - ae(t.apply(array), [[0, .5], [1, 1.5]]) + ae(t.apply(array), [[0, 0.5], [1, 1.5]]) def test_transform_chain_add(): tc = TransformChain() - tc.add([Scale(.5)]) + tc.add([Scale(0.5)]) tc_2 = TransformChain() - tc_2.add([Scale(2.)]) + tc_2.add([Scale(2.0)]) - ae((tc + tc_2).apply([3.]), [[3.]]) + ae((tc + tc_2).apply([3.0]), [[3.0]]) assert str(tc) def test_transform_chain_inverse(): tc = TransformChain() - tc.add([Scale(.5), Translate((1, 0)), Scale(2)]) + tc.add([Scale(0.5), Translate((1, 0)), Scale(2)]) tci = tc.inverse() - ae(tc.apply([[1., 0.]]), [[3., 0.]]) - ae(tci.apply([[3., 0.]]), [[1., 0.]]) + ae(tc.apply([[1.0, 0.0]]), [[3.0, 0.0]]) + ae(tci.apply([[3.0, 0.0]]), [[1.0, 0.0]]) diff --git a/phy/plot/tests/test_utils.py b/phy/plot/tests/test_utils.py index 0c83900b5..6601bea17 100644 --- a/phy/plot/tests/test_utils.py +++ b/phy/plot/tests/test_utils.py @@ -1,25 +1,21 @@ -# -*- coding: utf-8 -*- - """Test plotting utilities.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ import numpy as np -from numpy.testing import assert_array_equal as ae from numpy.testing import assert_allclose as ac +from numpy.testing import assert_array_equal as ae from pytest import raises -from ..utils import ( - _load_shader, _tesselate_histogram, BatchAccumulator, _in_polygon -) +from ..utils import BatchAccumulator, _in_polygon, _load_shader, _tesselate_histogram - -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Test utilities -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def test_load_shader(): assert 'main()' in _load_shader('simple.vert') @@ -53,9 +49,8 @@ def test_accumulator(): def test_in_polygon(): polygon = [[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]] points = np.random.uniform(size=(100, 2), low=-1, high=1) - idx_expected = np.nonzero((points[:, 0] > 0) & - (points[:, 1] > 0) & - (points[:, 0] < 1) & - (points[:, 1] < 1))[0] + idx_expected = np.nonzero( + (points[:, 0] > 0) & (points[:, 1] > 0) & (points[:, 0] < 1) & (points[:, 1] < 1) + )[0] idx = np.nonzero(_in_polygon(points, polygon))[0] ae(idx, idx_expected) diff --git a/phy/plot/tests/test_visuals.py b/phy/plot/tests/test_visuals.py index 49a2a3186..7c13dbfbb 100644 --- a/phy/plot/tests/test_visuals.py +++ b/phy/plot/tests/test_visuals.py @@ -1,68 +1,76 @@ -# -*- coding: utf-8 -*- - """Test visuals.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ import os import numpy as np -from ..visuals import ( - ScatterVisual, PatchVisual, PlotVisual, HistogramVisual, LineVisual, - LineAggGeomVisual, PlotAggVisual, - PolygonVisual, TextVisual, ImageVisual, UniformPlotVisual, UniformScatterVisual) -from ..transform import NDC, Rotate, range_transform from phy.utils.color import _random_color - -#------------------------------------------------------------------------------ +from . import show_and_wait +from ..transform import NDC, Rotate, range_transform +from ..visuals import ( + HistogramVisual, + ImageVisual, + LineAggGeomVisual, + LineVisual, + PatchVisual, + PlotAggVisual, + PlotVisual, + PolygonVisual, + ScatterVisual, + TextVisual, + UniformPlotVisual, + UniformScatterVisual, +) + +# ------------------------------------------------------------------------------ # Fixtures -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def _test_visual(qtbot, c, v, stop=False, **kwargs): c.add_visual(v) data = v.validate(**kwargs) assert v.vertex_count(**data) >= 0 v.set_data(**kwargs) - c.show() - qtbot.waitForWindowShown(c) + show_and_wait(qtbot, c) if os.environ.get('PHY_TEST_STOP', None) or stop: # pragma: no cover qtbot.stop() v.close() c.close() -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Test scatter visual -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def test_scatter_empty(qtbot, canvas): _test_visual(qtbot, canvas, ScatterVisual(), x=np.zeros(0), y=np.zeros(0)) def test_scatter_markers(qtbot, canvas_pz): - n = 100 - x = .2 * np.random.randn(n) - y = .2 * np.random.randn(n) + x = 0.2 * np.random.randn(n) + y = 0.2 * np.random.randn(n) _test_visual(qtbot, canvas_pz, ScatterVisual(marker='vbar'), x=x, y=y, data_bounds='auto') def test_scatter_custom(qtbot, canvas_pz): - n = 100 # Random position. - pos = .2 * np.random.randn(n, 2) + pos = 0.2 * np.random.randn(n, 2) # Random colors. - c = np.random.uniform(.4, .7, size=(n, 4)) - c[:, -1] = .5 + c = np.random.uniform(0.4, 0.7, size=(n, 4)) + c[:, -1] = 0.5 # Random sizes s = 5 + 20 * np.random.rand(n) @@ -70,39 +78,37 @@ def test_scatter_custom(qtbot, canvas_pz): _test_visual(qtbot, canvas_pz, ScatterVisual(), pos=pos, color=c, size=s) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Test patch visual -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def test_patch_empty(qtbot, canvas): _test_visual(qtbot, canvas, PatchVisual(), x=np.zeros(0), y=np.zeros(0)) def test_patch_1(qtbot, canvas_pz): - n = 100 - x = .2 * np.random.randn(n) - y = .2 * np.random.randn(n) + x = 0.2 * np.random.randn(n) + y = 0.2 * np.random.randn(n) _test_visual(qtbot, canvas_pz, PatchVisual(), x=x, y=y, data_bounds='auto') def test_patch_2(qtbot, canvas_pz): - n = 100 # Random position. - pos = .2 * np.random.randn(n, 2) + pos = 0.2 * np.random.randn(n, 2) # Random colors. - c = np.random.uniform(.4, .7, size=(n, 4)) - c[:, -1] = .5 + c = np.random.uniform(0.4, 0.7, size=(n, 4)) + c[:, -1] = 0.5 v = PatchVisual(primitive_type='triangles') canvas_pz.add_visual(v) v.set_data(pos=pos, color=c) - canvas_pz.show() - qtbot.waitForWindowShown(canvas_pz) + show_and_wait(qtbot, canvas_pz) v.set_color((1, 1, 0, 1)) canvas_pz.update() if os.environ.get('PHY_TEST_STOP', None): # pragma: no cover @@ -111,44 +117,52 @@ def test_patch_2(qtbot, canvas_pz): canvas_pz.close() -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Test uniform scatter visual -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def test_uniform_scatter_empty(qtbot, canvas): _test_visual(qtbot, canvas, UniformScatterVisual(), x=np.zeros(0), y=np.zeros(0)) def test_uniform_scatter_markers(qtbot, canvas_pz): - n = 100 - x = .2 * np.random.randn(n) - y = .2 * np.random.randn(n) + x = 0.2 * np.random.randn(n) + y = 0.2 * np.random.randn(n) _test_visual( - qtbot, canvas_pz, UniformScatterVisual(marker='vbar'), x=x, y=y, data_bounds='auto') + qtbot, canvas_pz, UniformScatterVisual(marker='vbar'), x=x, y=y, data_bounds='auto' + ) def test_uniform_scatter_custom(qtbot, canvas_pz): - n = 100 # Random position. - pos = .2 * np.random.randn(n, 2) + pos = 0.2 * np.random.randn(n, 2) _test_visual( - qtbot, canvas_pz, UniformScatterVisual(color=_random_color() + (.5,), size=10., ), - pos=pos, masks=np.linspace(0., 1., n), data_bounds=None) - - -#------------------------------------------------------------------------------ + qtbot, + canvas_pz, + UniformScatterVisual( + color=_random_color() + (0.5,), + size=10.0, + ), + pos=pos, + masks=np.linspace(0.0, 1.0, n), + data_bounds=None, + ) + + +# ------------------------------------------------------------------------------ # Test plot visual -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def test_plot_empty(qtbot, canvas): y = np.zeros((1, 0)) - _test_visual(qtbot, canvas, PlotVisual(), - y=y) + _test_visual(qtbot, canvas, PlotVisual(), y=y) def test_plot_0(qtbot, canvas_pz): @@ -157,50 +171,50 @@ def test_plot_0(qtbot, canvas_pz): def test_plot_1(qtbot, canvas_pz): - y = .2 * np.random.randn(10) + y = 0.2 * np.random.randn(10) _test_visual(qtbot, canvas_pz, PlotVisual(), y=y, data_bounds='auto') def test_plot_color(qtbot, canvas_pz): v = PlotVisual() canvas_pz.add_visual(v) - data = v.validate(y=.2 * np.random.randn(10), data_bounds='auto') + data = v.validate(y=0.2 * np.random.randn(10), data_bounds='auto') assert v.vertex_count(**data) >= 0 v.set_data(**data) - v.set_color(np.random.uniform(low=.5, high=.9, size=(10, 4))) - canvas_pz.show() - qtbot.waitForWindowShown(canvas_pz) + v.set_color(np.random.uniform(low=0.5, high=0.9, size=(10, 4))) + show_and_wait(qtbot, canvas_pz) canvas_pz.close() def test_plot_2(qtbot, canvas_pz): - n_signals = 50 n_samples = 10 y = 20 * np.random.randn(n_signals, n_samples) # Signal colors. - c = np.random.uniform(.5, 1, size=(n_signals, 4)) - c[:, 3] = .5 + c = np.random.uniform(0.5, 1, size=(n_signals, 4)) + c[:, 3] = 0.5 # Depth. - depth = np.linspace(0., -1., n_signals) + depth = np.linspace(0.0, -1.0, n_signals) _test_visual( - qtbot, canvas_pz, PlotVisual(), y=y, depth=depth, data_bounds=[-1, -50, 1, 50], color=c) + qtbot, canvas_pz, PlotVisual(), y=y, depth=depth, data_bounds=[-1, -50, 1, 50], color=c + ) def test_plot_list(qtbot, canvas_pz): - y = [.25 * np.random.randn(i) for i in (5, 20, 50)] + y = [0.25 * np.random.randn(i) for i in (5, 20, 50)] c = [[0, 0, 1, 1], [0, 0, 1, 1], [0, 0, 1, 1]] - masks = [0., 0.5, 1.0] + masks = [0.0, 0.5, 1.0] _test_visual(qtbot, canvas_pz, PlotVisual(), y=y, color=c, masks=masks) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Test uniform plot visual -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def test_uniform_plot_empty(qtbot, canvas): y = np.zeros((1, 0)) @@ -213,24 +227,27 @@ def test_uniform_plot_0(qtbot, canvas_pz): def test_uniform_plot_1(qtbot, canvas_pz): - y = .2 * np.random.randn(10) - _test_visual(qtbot, canvas_pz, UniformPlotVisual(), y=y, masks=.5, data_bounds=NDC) + y = 0.2 * np.random.randn(10) + _test_visual(qtbot, canvas_pz, UniformPlotVisual(), y=y, masks=0.5, data_bounds=NDC) def test_uniform_plot_2(qtbot, canvas_pz): - y = .2 * np.random.randn(10) - _test_visual(qtbot, canvas_pz, UniformPlotVisual(), y=y, masks=.5, data_bounds='auto') + y = 0.2 * np.random.randn(10) + _test_visual(qtbot, canvas_pz, UniformPlotVisual(), y=y, masks=0.5, data_bounds='auto') def test_uniform_plot_list(qtbot, canvas_pz): y = [np.random.randn(i) for i in (5, 20)] - _test_visual(qtbot, canvas_pz, UniformPlotVisual(color=(1., 0., 0., 1.)), y=y, masks=[.1, .9]) + _test_visual( + qtbot, canvas_pz, UniformPlotVisual(color=(1.0, 0.0, 0.0, 1.0)), y=y, masks=[0.1, 0.9] + ) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Test histogram visual -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def test_histogram_empty(qtbot, canvas): hist = np.zeros((1, 0)) @@ -248,16 +265,16 @@ def test_histogram_1(qtbot, canvas_pz): def test_histogram_2(qtbot, canvas_pz): - n_hists = 5 hist = np.random.rand(n_hists, 21) # Histogram colors. - c = np.random.uniform(.3, .6, size=(n_hists, 4)) + c = np.random.uniform(0.3, 0.6, size=(n_hists, 4)) c[:, 3] = 1 _test_visual( - qtbot, canvas_pz, HistogramVisual(), hist=hist, color=c, ylim=2 * np.ones(n_hists)) + qtbot, canvas_pz, HistogramVisual(), hist=hist, color=c, ylim=2 * np.ones(n_hists) + ) def test_histogram_3(qtbot, canvas_pz): @@ -267,9 +284,10 @@ def test_histogram_3(qtbot, canvas_pz): _test_visual(qtbot, canvas_pz, visual, hist=hist) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Test image visual -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def test_image_empty(qtbot, canvas): image = np.zeros((0, 0, 4)) @@ -287,13 +305,14 @@ def test_image_1(qtbot, canvas): def test_image_2(qtbot, canvas): n = 100 _test_visual( - qtbot, canvas, ImageVisual(), - image=np.random.uniform(low=.5, high=.9, size=(n, n, 4))) + qtbot, canvas, ImageVisual(), image=np.random.uniform(low=0.5, high=0.9, size=(n, n, 4)) + ) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Test line visual -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def test_line_empty(qtbot, canvas): pos = np.zeros((0, 4)) @@ -302,72 +321,80 @@ def test_line_empty(qtbot, canvas): def test_line_0(qtbot, canvas_pz): n = 10 - y = np.linspace(-.5, .5, 10) + y = np.linspace(-0.5, 0.5, 10) pos = np.c_[-np.ones(n), y, np.ones(n), y] - color = np.random.uniform(.5, .9, (n, 4)) + color = np.random.uniform(0.5, 0.9, (n, 4)) _test_visual(qtbot, canvas_pz, LineVisual(), pos=pos, color=color, data_bounds=[-1, -1, 1, 1]) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Test line agg geom -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def test_line_agg_geom_0(qtbot, canvas_pz): n = 1024 T = np.linspace(0, 10 * 2 * np.pi, n) - R = np.linspace(0, .5, n) + R = np.linspace(0, 0.5, n) P = np.zeros((n, 2), dtype=np.float64) P[:, 0] = np.cos(T) * R P[:, 1] = np.sin(T) * R P = range_transform([NDC], [[0, 0, 1034, 1034]], P) - color = np.random.uniform(.5, .9, 4) - _test_visual( - qtbot, canvas_pz, LineAggGeomVisual(), pos=P, color=color) + color = np.random.uniform(0.5, 0.9, 4) + _test_visual(qtbot, canvas_pz, LineAggGeomVisual(), pos=P, color=color) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Test plot agg -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def test_plot_agg_empty(qtbot, canvas_pz): - _test_visual( - qtbot, canvas_pz, PlotAggVisual(), y=[]) + _test_visual(qtbot, canvas_pz, PlotAggVisual(), y=[]) def test_plot_agg_1(qtbot, canvas_pz): t = np.linspace(-np.pi, np.pi, 8) t = t[:-1] - x = .5 * np.cos(t) - y = .5 * np.sin(t) + x = 0.5 * np.cos(t) + y = 0.5 * np.sin(t) - _test_visual( - qtbot, canvas_pz, PlotAggVisual(closed=True), x=x, y=y, data_bounds='auto') + _test_visual(qtbot, canvas_pz, PlotAggVisual(closed=True), x=x, y=y, data_bounds='auto') def test_plot_agg_2(qtbot, canvas_pz): n_signals = 100 n_samples = 1000 - x = np.linspace(-1., 1., n_samples) + x = np.linspace(-1.0, 1.0, n_samples) y = np.sin(10 * x) * 0.1 x = np.tile(x, (n_signals, 1)) y = np.tile(y, (n_signals, 1)) y -= np.linspace(-1, 1, n_signals)[:, np.newaxis] - color = np.random.uniform(low=.5, high=.9, size=(n_signals, 4)) + color = np.random.uniform(low=0.5, high=0.9, size=(n_signals, 4)) depth = np.random.uniform(low=0, high=1, size=n_signals) masks = np.random.uniform(low=0, high=1, size=n_signals) _test_visual( - qtbot, canvas_pz, PlotAggVisual(), x=x, y=y, color=color, - depth=depth, masks=masks, data_bounds=NDC) - - -#------------------------------------------------------------------------------ + qtbot, + canvas_pz, + PlotAggVisual(), + x=x, + y=y, + color=color, + depth=depth, + masks=masks, + data_bounds=NDC, + ) + + +# ------------------------------------------------------------------------------ # Test polygon visual -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def test_polygon_empty(qtbot, canvas): pos = np.zeros((0, 2)) @@ -376,15 +403,16 @@ def test_polygon_empty(qtbot, canvas): def test_polygon_0(qtbot, canvas_pz): n = 9 - x = .5 * np.cos(np.linspace(0., 2 * np.pi, n)) - y = .5 * np.sin(np.linspace(0., 2 * np.pi, n)) + x = 0.5 * np.cos(np.linspace(0.0, 2 * np.pi, n)) + y = 0.5 * np.sin(np.linspace(0.0, 2 * np.pi, n)) pos = np.c_[x, y] _test_visual(qtbot, canvas_pz, PolygonVisual(), pos=pos) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Test text visual -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def test_text_empty(qtbot, canvas): pos = np.zeros((0, 2)) @@ -400,19 +428,18 @@ def test_text_1(qtbot, canvas_pz): text = '0123456789' text = [text[:n] for n in range(1, 11)] - pos = np.c_[np.linspace(-.5, .5, 10), np.linspace(-.5, .5, 10)] + pos = np.c_[np.linspace(-0.5, 0.5, 10), np.linspace(-0.5, 0.5, 10)] color = np.ones((10, 4)) color[:, 2] = 0 - _test_visual( - qtbot, canvas_pz, TextVisual(font_size=32), pos=pos, text=text, color=color) + _test_visual(qtbot, canvas_pz, TextVisual(font_size=32), pos=pos, text=text, color=color) def test_text_2(qtbot, canvas_pz): c = canvas_pz text = ['12345'] * 5 - pos = [[0, 0], [-.5, +.5], [+.5, +.5], [-.5, -.5], [+.5, -.5]] + pos = [[0, 0], [-0.5, +0.5], [+0.5, +0.5], [-0.5, -0.5], [+0.5, -0.5]] anchor = [[0, 0], [-1, +1], [+1, +1], [-1, -1], [+1, -1]] v = TextVisual() @@ -424,10 +451,9 @@ def test_text_2(qtbot, canvas_pz): v.set_data(pos=pos, data_bounds=None) v.set_marker_size(10) - v.set_color(np.random.uniform(low=.5, high=.9, size=(v.n_vertices, 4))) + v.set_color(np.random.uniform(low=0.5, high=0.9, size=(v.n_vertices, 4))) - c.show() - qtbot.waitForWindowShown(c) + show_and_wait(qtbot, c) if os.environ.get('PHY_TEST_STOP', None): # pragma: no cover qtbot.stop() @@ -439,5 +465,10 @@ def test_text_3(qtbot, canvas_pz): text = [text] * 10 _test_visual( - qtbot, canvas_pz, TextVisual(color=(1, 1, 0, 1)), pos=[(0, 0)] * 10, text=text, - anchor=[(1, -1 - 2 * i) for i in range(5)] + [(-1 - 2 * i, 1) for i in range(5)]) + qtbot, + canvas_pz, + TextVisual(color=(1, 1, 0, 1)), + pos=[(0, 0)] * 10, + text=text, + anchor=[(1, -1 - 2 * i) for i in range(5)] + [(-1 - 2 * i, 1) for i in range(5)], + ) diff --git a/phy/plot/transform.py b/phy/plot/transform.py index 12fbba674..6653853a0 100644 --- a/phy/plot/transform.py +++ b/phy/plot/transform.py @@ -1,28 +1,27 @@ -# -*- coding: utf-8 -*- - """Transforms.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ import logging from textwrap import dedent import numpy as np - from phylib.utils.geometry import range_transform logger = logging.getLogger(__name__) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Utils -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def _wrap_apply(f): """Validate the input and output of transform apply functions.""" + def wrapped(arr, **kwargs): if arr is None or not len(arr): return arr @@ -35,15 +34,18 @@ def wrapped(arr, **kwargs): assert out.ndim == 2 assert out.shape[1] == arr.shape[1] return out + return wrapped def _wrap_glsl(f): """Validate the output of GLSL functions.""" + def wrapped(var, **kwargs): out = f(var, **kwargs) out = dedent(out).strip() return out + return wrapped @@ -54,12 +56,12 @@ def _glslify(r): else: r = _call_if_callable(r) assert 2 <= len(r) <= 4 - return 'vec{}({})'.format(len(r), ', '.join(map(str, r))) + return f'vec{len(r)}({", ".join(map(str, r))})' def _call_if_callable(s): """Call a variable if it's a callable, otherwise return it.""" - if hasattr(s, '__call__'): + if callable(s): return s() return s @@ -74,20 +76,20 @@ def _minus(value): def _inverse(value): if isinstance(value, np.ndarray): - return 1. / value + return 1.0 / value elif hasattr(value, '__len__'): assert len(value) == 2 - return 1. / value[0], 1. / value[1] + return 1.0 / value[0], 1.0 / value[1] else: - return 1. / value + return 1.0 / value def _normalize(arr, m, M): d = float(M - m) if abs(d) < 1e-9: return arr - b = 2. / d - a = -1 - 2. * m / d + b = 2.0 / d + a = -1 - 2.0 * m / d arr *= b arr += a return arr @@ -96,9 +98,7 @@ def _normalize(arr, m, M): def _fix_coordinate_in_visual(visual, coord): """Insert GLSL code to fix the position on the x or y coordinate.""" assert coord in ('x', 'y') - visual.inserter.insert_vert( - 'gl_Position.{coord} = pos_orig.{coord};'.format(coord=coord), - 'after_transforms') + visual.inserter.insert_vert(f'gl_Position.{coord} = pos_orig.{coord};', 'after_transforms') def subplot_bounds(shape=None, index=None): @@ -120,12 +120,12 @@ def subplot_bounds(shape=None, index=None): def subplot_bounds_glsl(shape=None, index=None): """Get the data bounds in GLSL of a subplot.""" - x0 = '-1.0 + 2.0 * {i}.y / {s}.y'.format(s=shape, i=index) - y0 = '+1.0 - 2.0 * ({i}.x + 1) / {s}.x'.format(s=shape, i=index) - x1 = '-1.0 + 2.0 * ({i}.y + 1) / {s}.y'.format(s=shape, i=index) - y1 = '+1.0 - 2.0 * ({i}.x) / {s}.x'.format(s=shape, i=index) + x0 = f'-1.0 + 2.0 * {index}.y / {shape}.y' + y0 = f'+1.0 - 2.0 * ({index}.x + 1) / {shape}.x' + x1 = f'-1.0 + 2.0 * ({index}.y + 1) / {shape}.y' + y1 = f'+1.0 - 2.0 * ({index}.x) / {shape}.x' - return 'vec4(\n{x0}, \n{y0}, \n{x1}, \n{y1})'.format(x0=x0, y0=y0, x1=x1, y1=y1) + return f'vec4(\n{x0}, \n{y0}, \n{x1}, \n{y1})' def extend_bounds(bounds_list): @@ -146,7 +146,7 @@ def pixels_to_ndc(pos, size=None): """Convert from pixels to normalized device coordinates (in [-1, 1]).""" pos = np.asarray(pos, dtype=np.float64) size = np.asarray(size, dtype=np.float64) - pos = pos / (size / 2.) - 1 + pos = pos / (size / 2.0) - 1 # Flip y, because the origin in pixels is at the top left corner of the # window. pos[1] = -pos[1] @@ -157,12 +157,14 @@ def pixels_to_ndc(pos, size=None): NDC = (-1.0, -1.0, +1.0, +1.0) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Base Transform -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + -class BaseTransform(object): +class BaseTransform: """Base class for all transforms.""" + def __init__(self, **kwargs): self.__dict__.update(**{k: v for k, v in kwargs.items() if v is not None}) @@ -186,9 +188,10 @@ def __add__(self, other): return TransformChain().add([self, other]) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Transforms -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + class Translate(BaseTransform): """Translation transform. @@ -206,7 +209,7 @@ class Translate(BaseTransform): gpu_var = None def __init__(self, amount=None, **kwargs): - super(Translate, self).__init__(amount=amount, **kwargs) + super().__init__(amount=amount, **kwargs) def apply(self, arr, param=None): """Apply a translation to a NumPy array.""" @@ -217,16 +220,17 @@ def apply(self, arr, param=None): def glsl(self, var): """Return a GLSL snippet that applies the translation to a given GLSL variable name.""" assert var - return ''' + return f""" // Translate transform. - {var} = {var} + {translate}; - '''.format(var=var, translate=self.gpu_var or _call_if_callable(self.amount)) + {var} = {var} + {self.gpu_var or _call_if_callable(self.amount)}; + """ def inverse(self): """Return the inverse Translate instance.""" return Translate( amount=_minus(_call_if_callable(self.amount)) if self.amount is not None else None, - gpu_var=('-%s' % self.gpu_var) if self.gpu_var else None) + gpu_var=f'-{self.gpu_var}' if self.gpu_var else None, + ) class Scale(BaseTransform): @@ -245,7 +249,7 @@ class Scale(BaseTransform): gpu_var = None def __init__(self, amount=None, **kwargs): - super(Scale, self).__init__(amount=amount, **kwargs) + super().__init__(amount=amount, **kwargs) def apply(self, arr, param=None): """Apply a scaling to a NumPy array.""" @@ -256,16 +260,17 @@ def apply(self, arr, param=None): def glsl(self, var): """Return a GLSL snippet that applies the scaling to a given GLSL variable name.""" assert var - return ''' + return f""" // Translate transform. - {var} = {var} * {scaling}; - '''.format(var=var, scaling=self.gpu_var or _call_if_callable(self.amount)) + {var} = {var} * {self.gpu_var or _call_if_callable(self.amount)}; + """ def inverse(self): """Return the inverse Scale instance.""" return Scale( amount=_inverse(_call_if_callable(self.amount)) if self.amount is not None else None, - gpu_var=('1.0 / %s' % self.gpu_var) if self.gpu_var else None) + gpu_var=f'1.0 / {self.gpu_var}' if self.gpu_var else None, + ) class Rotate(BaseTransform): @@ -281,7 +286,7 @@ class Rotate(BaseTransform): direction = 'cw' def __init__(self, direction=None, **kwargs): - super(Rotate, self).__init__(direction=direction, **kwargs) + super().__init__(direction=direction, **kwargs) def apply(self, arr, direction=None): """Apply a rotation to a NumPy array.""" @@ -303,10 +308,10 @@ def glsl(self, var): direction = self.direction or 'cw' assert direction in ('cw', 'ccw') m = '' if direction == 'ccw' else '-' - return ''' + return f""" // Rotation transform. {var} = {m}vec2(-{var}.y, {var}.x); - '''.format(var=var, m=m) + """ def inverse(self): """Return the inverse Rotate instance.""" @@ -338,7 +343,7 @@ class Range(BaseTransform): to_gpu_var = None def __init__(self, from_bounds=None, to_bounds=None, **kwargs): - super(Range, self).__init__(from_bounds=from_bounds, to_bounds=to_bounds, **kwargs) + super().__init__(from_bounds=from_bounds, to_bounds=to_bounds, **kwargs) def apply(self, arr, from_bounds=None, to_bounds=None): """Apply the transform to a NumPy array.""" @@ -358,19 +363,21 @@ def glsl(self, var): from_bounds = _glslify(self.from_gpu_var or self.from_bounds) to_bounds = _glslify(self.to_gpu_var or self.to_bounds) - return ''' + return f""" // Range transform. - {var} = ({var} - {f}.xy); - {var} = {var} * ({t}.zw - {t}.xy); - {var} = {var} / ({f}.zw - {f}.xy); - {var} = {var} + {t}.xy; - '''.format(var=var, f=from_bounds, t=to_bounds) + {var} = ({var} - {from_bounds}.xy); + {var} = {var} * ({to_bounds}.zw - {to_bounds}.xy); + {var} = {var} / ({from_bounds}.zw - {from_bounds}.xy); + {var} = {var} + {to_bounds}.xy; + """ def inverse(self): """Return the inverse Range instance.""" return Range( - from_bounds=self.to_bounds, to_bounds=self.from_bounds, - from_gpu_var=self.to_gpu_var, to_gpu_var=self.from_gpu_var, + from_bounds=self.to_bounds, + to_bounds=self.from_bounds, + from_gpu_var=self.to_gpu_var, + to_gpu_var=self.from_gpu_var, ) @@ -406,14 +413,17 @@ def Subplot(shape=None, index=None, shape_gpu_var=None, index_gpu_var=None): if shape_gpu_var is not None: to_gpu_var = subplot_bounds_glsl(shape=shape_gpu_var, index=index_gpu_var) if shape is not None: - if hasattr(shape, '__call__') and hasattr(index, '__call__'): + if callable(shape) and callable(index): to_bounds = lambda: subplot_bounds(shape(), index()) else: to_bounds = subplot_bounds(shape, index) return Range( - from_bounds=from_bounds, to_bounds=to_bounds, - from_gpu_var=from_gpu_var, to_gpu_var=to_gpu_var) + from_bounds=from_bounds, + to_bounds=to_bounds, + from_gpu_var=from_gpu_var, + to_gpu_var=to_gpu_var, + ) class Clip(BaseTransform): @@ -430,17 +440,19 @@ class Clip(BaseTransform): bounds = NDC def __init__(self, bounds=None, **kwargs): - super(Clip, self).__init__(bounds=bounds, **kwargs) + super().__init__(bounds=bounds, **kwargs) def apply(self, arr, bounds=None): """Apply the clipping to a NumPy array.""" bounds = bounds if bounds is not None else _call_if_callable(self.bounds) assert isinstance(bounds, (tuple, list)) assert len(bounds) == 4 - index = ((arr[:, 0] >= bounds[0]) & - (arr[:, 1] >= bounds[1]) & - (arr[:, 0] <= bounds[2]) & - (arr[:, 1] <= bounds[3])) + index = ( + (arr[:, 0] >= bounds[0]) + & (arr[:, 1] >= bounds[1]) + & (arr[:, 0] <= bounds[2]) + & (arr[:, 1] <= bounds[3]) + ) return arr[index, ...] def glsl(self, var): @@ -449,7 +461,7 @@ def glsl(self, var): assert var bounds = _glslify(self.bounds) - return """ + return f""" // Clip transform. if (({var}.x < {bounds}.x) || ({var}.y < {bounds}.y) || @@ -457,19 +469,21 @@ def glsl(self, var): ({var}.y > {bounds}.w)) {{ discard; }} - """.format(bounds=bounds, var=var) + """ def inverse(self): """Return the same instance (the inverse has no sense for a Clip transform).""" return self -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Transform chain -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + -class TransformChain(object): +class TransformChain: """A linear sequence of transforms.""" + def __init__(self, transforms=None, origin=None): self.transformed_var_name = None self.origin = origin @@ -507,8 +521,8 @@ def apply(self, arr): def inverse(self): """Return the inverse chain of transforms.""" inv_transforms = [ - (transform.inverse(), origin) - for (transform, origin) in self._transforms[::-1]] + (transform.inverse(), origin) for (transform, origin) in self._transforms[::-1] + ] inv = TransformChain() inv._transforms = inv_transforms return inv diff --git a/phy/plot/utils.py b/phy/plot/utils.py index 6c8885ada..a5341b06e 100644 --- a/phy/plot/utils.py +++ b/phy/plot/utils.py @@ -1,25 +1,23 @@ -# -*- coding: utf-8 -*- - """Plotting utilities.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ import logging from pathlib import Path import numpy as np - from phylib.utils import Bunch, _as_array logger = logging.getLogger(__name__) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Data validation -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def _get_texture(arr, default, n_items, from_bounds): """Prepare data to be uploaded as a texture. @@ -45,7 +43,7 @@ def _get_texture(arr, default, n_items, from_bounds): assert np.all(arr <= M) arr = (arr - m) / (M - m) assert np.all(arr >= 0) - assert np.all(arr <= 1.) + assert np.all(arr <= 1.0) return arr @@ -55,9 +53,7 @@ def _get_array(val, shape, default=None, dtype=np.float64): if hasattr(val, '__len__') and len(val) == 0: # pragma: no cover val = None # Do nothing if the array is already correct. - if (isinstance(val, np.ndarray) and - val.shape == shape and - val.dtype == dtype): + if isinstance(val, np.ndarray) and val.shape == shape and val.dtype == dtype: return val out = np.zeros(shape, dtype=dtype) # This solves `ValueError: could not broadcast input array from shape (n) @@ -100,10 +96,10 @@ def get_linear_x(n_signals, n_samples): Return a `(n_signals, n_samples)` array. """ - return np.tile(np.linspace(-1., 1., n_samples), (n_signals, 1)) + return np.tile(np.linspace(-1.0, 1.0, n_samples), (n_signals, 1)) -class BatchAccumulator(object): +class BatchAccumulator: """Accumulate data arrays for batch visuals. This class is used to simplify the creation of batch visuals, where different visual elements @@ -190,9 +186,10 @@ def data(self): return Bunch({key: getattr(self, key) for key in self.items.keys()}) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Misc -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def _load_shader(filename): """Load a shader file.""" @@ -235,6 +232,7 @@ def _tesselate_histogram(hist): def _in_polygon(points, polygon): """Return the points that are inside a polygon.""" from matplotlib.path import Path + points = _as_array(points) polygon = _as_array(polygon) assert points.ndim == 2 diff --git a/phy/plot/visuals.py b/phy/plot/visuals.py index 178770d46..b56be92d7 100644 --- a/phy/plot/visuals.py +++ b/phy/plot/visuals.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - """Common visuals. All visuals derive from the base class `BaseVisual()`. They all follow the same structure. @@ -10,36 +8,36 @@ """ -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ import gzip from pathlib import Path import numpy as np - -from .base import BaseVisual -from .gloo import gl -from .transform import NDC -from .utils import ( - _tesselate_histogram, _get_texture, _get_array, _get_pos, _get_index) -from phy.gui.qt import is_high_dpi from phylib.io.array import _as_array from phylib.utils import Bunch from phylib.utils.geometry import _get_data_bounds +from phy.gui.qt import is_high_dpi + +from .base import BaseVisual +from .gloo import gl +from .transform import NDC +from .utils import _get_array, _get_index, _get_pos, _get_texture, _tesselate_histogram -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Utils -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ -DEFAULT_COLOR = (0.03, 0.57, 0.98, .75) +DEFAULT_COLOR = (0.03, 0.57, 0.98, 0.75) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Patch visual -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + class PatchVisual(BaseVisual): """Patch visual, displaying an arbitrary filled shape. @@ -64,10 +62,11 @@ class PatchVisual(BaseVisual): data_bounds : array-like (2D, shape[1] == 4) """ + default_color = DEFAULT_COLOR def __init__(self, primitive_type='triangle_fan'): - super(PatchVisual, self).__init__() + super().__init__() self.set_shader('patch') self.set_primitive_type(primitive_type) self.set_data_range(NDC) @@ -77,8 +76,8 @@ def vertex_count(self, x=None, y=None, pos=None, **kwargs): return y.size if y is not None else len(pos) def validate( - self, x=None, y=None, pos=None, color=None, depth=None, - data_bounds=None, **kwargs): + self, x=None, y=None, pos=None, color=None, depth=None, data_bounds=None, **kwargs + ): """Validate the requested data before passing it to set_data().""" if pos is None: x, y = _get_pos(x, y) @@ -96,8 +95,8 @@ def validate( assert data_bounds.shape[0] == n return Bunch( - pos=pos, color=color, depth=depth, data_bounds=data_bounds, - _n_items=n, _n_vertices=n) + pos=pos, color=color, depth=depth, data_bounds=data_bounds, _n_items=n, _n_vertices=n + ) def set_data(self, *args, **kwargs): """Update the visual data.""" @@ -120,9 +119,10 @@ def set_color(self, color): self.program['a_color'] = color.astype(np.float32) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Scatter visuals -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + class ScatterVisual(BaseVisual): """Scatter visual, displaying a fixed marker at various positions, colors, and marker sizes. @@ -147,8 +147,9 @@ class ScatterVisual(BaseVisual): data_bounds : array-like (2D, shape[1] == 4) """ + _init_keywords = ('marker',) - default_marker_size = 10. + default_marker_size = 10.0 default_marker = 'disc' default_color = DEFAULT_COLOR _supported_markers = ( @@ -174,7 +175,7 @@ class ScatterVisual(BaseVisual): ) def __init__(self, marker=None, marker_scaling=None): - super(ScatterVisual, self).__init__() + super().__init__() # Set the marker type. self.marker = marker or self.default_marker @@ -192,8 +193,16 @@ def vertex_count(self, x=None, y=None, pos=None, **kwargs): return y.size if y is not None else len(pos) def validate( - self, x=None, y=None, pos=None, color=None, size=None, depth=None, - data_bounds=None, **kwargs): + self, + x=None, + y=None, + pos=None, + color=None, + size=None, + depth=None, + data_bounds=None, + **kwargs, + ): """Validate the requested data before passing it to set_data().""" if pos is None: x, y = _get_pos(x, y) @@ -212,8 +221,14 @@ def validate( assert data_bounds.shape[0] == n return Bunch( - pos=pos, color=color, size=size, depth=depth, data_bounds=data_bounds, - _n_items=n, _n_vertices=n) + pos=pos, + color=color, + size=size, + depth=depth, + data_bounds=data_bounds, + _n_items=n, + _n_vertices=n, + ) def set_data(self, *args, **kwargs): """Update the visual data.""" @@ -266,7 +281,7 @@ class UniformScatterVisual(BaseVisual): """ _init_keywords = ('marker', 'color', 'size') - default_marker_size = 10. + default_marker_size = 10.0 default_marker = 'disc' default_color = DEFAULT_COLOR _supported_markers = ( @@ -292,7 +307,7 @@ class UniformScatterVisual(BaseVisual): ) def __init__(self, marker=None, color=None, size=None): - super(UniformScatterVisual, self).__init__() + super().__init__() # Set the marker type. self.marker = marker or self.default_marker @@ -321,11 +336,11 @@ def validate(self, x=None, y=None, pos=None, masks=None, data_bounds=None, **kwa assert pos.shape[1] == 2 n = pos.shape[0] - masks = _get_array(masks, (n, 1), 1., np.float32) + masks = _get_array(masks, (n, 1), 1.0, np.float32) assert masks.shape == (n, 1) # The mask is clu_idx + fractional mask - masks *= .99999 + masks *= 0.99999 # Validate the data. if data_bounds is not None: @@ -356,9 +371,10 @@ def set_data(self, *args, **kwargs): return data -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Plot visuals -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def _as_list(arr): if isinstance(arr, np.ndarray): @@ -400,21 +416,22 @@ class PlotVisual(BaseVisual): _noconcat = ('x', 'y') def __init__(self): - super(PlotVisual, self).__init__() + super().__init__() self.set_shader('plot') self.set_primitive_type('line_strip') self.set_data_range(NDC) def validate( - self, x=None, y=None, color=None, depth=None, masks=None, data_bounds=None, **kwargs): + self, x=None, y=None, color=None, depth=None, masks=None, data_bounds=None, **kwargs + ): """Validate the requested data before passing it to set_data().""" assert y is not None y = _as_list(y) if x is None: - x = [np.linspace(-1., 1., len(_)) for _ in y] + x = [np.linspace(-1.0, 1.0, len(_)) for _ in y] x = _as_list(x) # Remove empty elements. @@ -431,15 +448,17 @@ def validate( ymax = [_max(_) for _ in y] data_bounds = np.c_[xmin, ymin, xmax, ymax] - color = _get_array(color, (n_signals, 4), - PlotVisual.default_color, - dtype=np.float32, - ) + color = _get_array( + color, + (n_signals, 4), + PlotVisual.default_color, + dtype=np.float32, + ) assert color.shape == (n_signals, 4) - masks = _get_array(masks, (n_signals, 1), 1., np.float32) + masks = _get_array(masks, (n_signals, 1), 1.0, np.float32) # The mask is clu_idx + fractional mask - masks *= .99999 + masks *= 0.99999 assert masks.shape == (n_signals, 1) depth = _get_array(depth, (n_signals, 1), 0) @@ -451,8 +470,15 @@ def validate( assert data_bounds.shape == (n_signals, 4) return Bunch( - x=x, y=y, color=color, depth=depth, data_bounds=data_bounds, masks=masks, - _n_items=n_signals, _n_vertices=self.vertex_count(y=y)) + x=x, + y=y, + color=color, + depth=depth, + data_bounds=data_bounds, + masks=masks, + _n_items=n_signals, + _n_vertices=self.vertex_count(y=y), + ) def set_color(self, color): """Update the visual's color.""" @@ -545,7 +571,7 @@ class UniformPlotVisual(BaseVisual): _noconcat = ('x', 'y') def __init__(self, color=None, depth=None): - super(UniformPlotVisual, self).__init__() + super().__init__() self.set_shader('uni_plot') self.set_primitive_type('line_strip') @@ -559,7 +585,7 @@ def validate(self, x=None, y=None, masks=None, data_bounds=None, **kwargs): y = _as_list(y) if x is None: - x = [np.linspace(-1., 1., len(_)) for _ in y] + x = [np.linspace(-1.0, 1.0, len(_)) for _ in y] x = _as_list(x) # Remove empty elements. @@ -569,9 +595,9 @@ def validate(self, x=None, y=None, masks=None, data_bounds=None, **kwargs): n_signals = len(x) - masks = _get_array(masks, (n_signals, 1), 1., np.float32) + masks = _get_array(masks, (n_signals, 1), 1.0, np.float32) # The mask is clu_idx + fractional mask - masks *= .99999 + masks *= 0.99999 assert masks.shape == (n_signals, 1) if isinstance(data_bounds, str) and data_bounds == 'auto': @@ -587,8 +613,13 @@ def validate(self, x=None, y=None, masks=None, data_bounds=None, **kwargs): assert data_bounds.shape == (n_signals, 4) return Bunch( - x=x, y=y, masks=masks, data_bounds=data_bounds, - _n_items=n_signals, _n_vertices=self.vertex_count(y=y)) + x=x, + y=y, + masks=masks, + data_bounds=data_bounds, + _n_items=n_signals, + _n_vertices=self.vertex_count(y=y), + ) def vertex_count(self, y=None, **kwargs): """Number of vertices for the requested data.""" @@ -643,9 +674,10 @@ def set_data(self, *args, **kwargs): return data -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Histogram visual -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + class HistogramVisual(BaseVisual): """A histogram visual. @@ -663,7 +695,7 @@ class HistogramVisual(BaseVisual): default_color = DEFAULT_COLOR def __init__(self): - super(HistogramVisual, self).__init__() + super().__init__() self.set_shader('histogram') self.set_primitive_type('triangles') @@ -683,7 +715,7 @@ def validate(self, hist=None, color=None, ylim=None, **kwargs): # Validate ylim. if ylim is None: - ylim = hist.max() if hist.size > 0 else 1. + ylim = hist.max() if hist.size > 0 else 1.0 ylim = np.atleast_1d(ylim) if len(ylim) == 1: ylim = np.tile(ylim, n_hists) @@ -692,8 +724,12 @@ def validate(self, hist=None, color=None, ylim=None, **kwargs): assert ylim.shape == (n_hists, 1) return Bunch( - hist=hist, ylim=ylim, color=color, - _n_items=n_hists, _n_vertices=self.vertex_count(hist)) + hist=hist, + ylim=ylim, + color=color, + _n_items=n_hists, + _n_vertices=self.vertex_count(hist), + ) def vertex_count(self, hist, **kwargs): """Number of vertices for the requested data.""" @@ -735,9 +771,9 @@ def set_data(self, *args, **kwargs): return data -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Test visual -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ FONT_MAP_PATH = Path(__file__).parent / 'static/SourceCodePro-Regular.npy.gz' FONT_MAP_SIZE = (6, 16) @@ -779,13 +815,14 @@ class TextVisual(BaseVisual): data_bounds : array-like (2D, shape[1] == 4) """ - default_color = (1., 1., 1., 1.) - default_font_size = 6. + + default_color = (1.0, 1.0, 1.0, 1.0) + default_font_size = 6.0 _init_keywords = ('color',) _noconcat = ('text',) def __init__(self, color=None, font_size=None): - super(TextVisual, self).__init__() + super().__init__() self.set_shader('msdf') self.set_primitive_type('triangles') self.set_data_range(NDC) @@ -809,8 +846,7 @@ def __init__(self, color=None, font_size=None): def _get_glyph_indices(self, s): return [FONT_MAP_CHARS.index(char) for char in s] - def validate( - self, pos=None, text=None, color=None, anchor=None, data_bounds=None, **kwargs): + def validate(self, pos=None, text=None, color=None, anchor=None, data_bounds=None, **kwargs): """Validate the requested data before passing it to set_data().""" if text is None: @@ -835,7 +871,7 @@ def validate( assert color.shape[1] == 4 assert len(color) == n_text - anchor = anchor if anchor is not None else (0., 0.) + anchor = anchor if anchor is not None else (0.0, 0.0) anchor = np.atleast_2d(anchor) if anchor.shape[0] == 1: anchor = np.repeat(anchor, n_text, axis=0) @@ -849,8 +885,14 @@ def validate( assert data_bounds.shape == (n_text, 4) return Bunch( - pos=pos, text=text, anchor=anchor, data_bounds=data_bounds, color=color, - _n_items=n_text, _n_vertices=self.vertex_count(text=text)) + pos=pos, + text=text, + anchor=anchor, + data_bounds=data_bounds, + color=color, + _n_items=n_text, + _n_vertices=self.vertex_count(text=text), + ) def vertex_count(self, **kwargs): """Number of vertices for the requested data.""" @@ -948,12 +990,13 @@ def set_data(self, *args, **kwargs): def on_draw(self): # NOTE: use linear interpolation for the SDF texture. self.program._uniforms['u_tex']._data.set_interpolation('linear') - super(TextVisual, self).on_draw() + super().on_draw() -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Line visual -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + class LineVisual(BaseVisual): """Line segments. @@ -966,11 +1009,11 @@ class LineVisual(BaseVisual): """ - default_color = (.3, .3, .3, 1.) + default_color = (0.3, 0.3, 0.3, 1.0) _init_keywords = ('color',) def __init__(self): - super(LineVisual, self).__init__() + super().__init__() self.set_shader('line') self.set_primitive_type('lines') self.set_data_range(NDC) @@ -995,8 +1038,12 @@ def validate(self, pos=None, color=None, data_bounds=None, **kwargs): assert data_bounds.shape == (n_lines, 4) return Bunch( - pos=pos, color=color, data_bounds=data_bounds, - _n_items=n_lines, _n_vertices=self.vertex_count(pos=pos)) + pos=pos, + color=color, + data_bounds=data_bounds, + _n_items=n_lines, + _n_vertices=self.vertex_count(pos=pos), + ) def vertex_count(self, pos=None, **kwargs): """Number of vertices for the requested data.""" @@ -1035,9 +1082,10 @@ def set_data(self, *args, **kwargs): return data -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Agg line visual -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def ortho(left, right, bottom, top, znear, zfar): # pragma: no cover """Create orthographic projection matrix @@ -1062,9 +1110,9 @@ def ortho(left, right, bottom, top, znear, zfar): # pragma: no cover M : array Orthographic projection matrix (4x4). """ - assert(right != left) - assert(bottom != top) - assert(znear != zfar) + assert right != left + assert bottom != top + assert znear != zfar M = np.zeros((4, 4), dtype=np.float32) M[0, 0] = +2.0 / (right - left) @@ -1091,11 +1139,11 @@ class LineAggGeomVisual(BaseVisual): # pragma: no cover """ - default_color = (.75, .75, .75, 1.) + default_color = (0.75, 0.75, 0.75, 1.0) _init_keywords = ('color',) def __init__(self): - super(LineAggGeomVisual, self).__init__() + super().__init__() self.set_shader('line_agg_geom') self.set_primitive_type('line_strip_adjacency_ext') # Geometry shader params. @@ -1107,17 +1155,17 @@ def __init__(self): def _get_index_buffer(self, P, closed=True): if closed: if np.allclose(P[0], P[1]): - I = (np.arange(len(P) + 2) - 1) + I = np.arange(len(P) + 2) - 1 I[0], I[-1] = 0, len(P) - 1 else: - I = (np.arange(len(P) + 3) - 1) + I = np.arange(len(P) + 3) - 1 I[0], I[-2], I[-1] = len(P) - 1, 0, 1 else: - I = (np.arange(len(P) + 2) - 1) + I = np.arange(len(P) + 2) - 1 I[0], I[-1] = 0, len(P) - 1 return I - def validate(self, pos=None, color=None, line_width=10., data_bounds=None, **kwargs): + def validate(self, pos=None, color=None, line_width=10.0, data_bounds=None, **kwargs): """Validate the requested data before passing it to set_data().""" assert pos is not None pos = _as_array(pos) @@ -1138,8 +1186,13 @@ def validate(self, pos=None, color=None, line_width=10., data_bounds=None, **kwa # assert data_bounds.shape == (n_lines, 2) return Bunch( - pos=pos, color=color, line_width=line_width, data_bounds=data_bounds, - _n_items=1, _n_vertices=self.vertex_count(pos=pos)) + pos=pos, + color=color, + line_width=line_width, + data_bounds=data_bounds, + _n_items=1, + _n_vertices=self.vertex_count(pos=pos), + ) def vertex_count(self, pos=None, **kwargs): """Number of vertices for the requested data.""" @@ -1199,7 +1252,7 @@ class PlotAggVisual(BaseVisual): _noconcat = ('x', 'y') def __init__(self, line_width=None, closed=False): - super(PlotAggVisual, self).__init__() + super().__init__() self.set_shader('plot_agg') self.set_primitive_type('triangle_strip') @@ -1208,14 +1261,15 @@ def __init__(self, line_width=None, closed=False): self.line_width = line_width or self.default_line_width def validate( - self, x=None, y=None, color=None, depth=None, masks=None, data_bounds=None, **kwargs): + self, x=None, y=None, color=None, depth=None, masks=None, data_bounds=None, **kwargs + ): """Validate the requested data before passing it to set_data().""" assert y is not None y = np.atleast_2d(_as_array(y)) n_signals, n_samples = y.shape if x is None: - x = np.tile(np.linspace(-1., 1., n_samples), (n_signals, 1)) + x = np.tile(np.linspace(-1.0, 1.0, n_samples), (n_signals, 1)) x = np.atleast_2d(_as_array(x)) if isinstance(data_bounds, str) and data_bounds == 'auto': @@ -1223,15 +1277,17 @@ def validate( ymin, ymax = y.min(axis=1), y.max(axis=1) data_bounds = np.c_[xmin, ymin, xmax, ymax] - color = _get_array(color, (n_signals, 4), - PlotVisual.default_color, - dtype=np.float32, - ) + color = _get_array( + color, + (n_signals, 4), + PlotVisual.default_color, + dtype=np.float32, + ) assert color.shape == (n_signals, 4) - masks = _get_array(masks, (n_signals, 1), 1., np.float32) + masks = _get_array(masks, (n_signals, 1), 1.0, np.float32) # The mask is clu_idx + fractional mask - masks *= .99999 + masks *= 0.99999 assert masks.shape == (n_signals, 1) depth = _get_array(depth, (n_signals, 1), 0) @@ -1243,8 +1299,15 @@ def validate( assert data_bounds.shape == (n_signals, 4) return Bunch( - x=x, y=y, color=color, depth=depth, masks=masks, data_bounds=data_bounds, - _n_items=n_signals, _n_vertices=self.vertex_count(y=y)) + x=x, + y=y, + color=color, + depth=depth, + masks=masks, + data_bounds=data_bounds, + _n_items=n_signals, + _n_vertices=self.vertex_count(y=y), + ) def vertex_count(self, y=None, **kwargs): """Number of vertices for the requested data.""" @@ -1376,9 +1439,10 @@ def set_data(self, *args, **kwargs): return data -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Image visual -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + class ImageVisual(BaseVisual): """Display a 2D image. @@ -1390,7 +1454,7 @@ class ImageVisual(BaseVisual): """ def __init__(self): - super(ImageVisual, self).__init__() + super().__init__() self.set_shader('image') self.set_primitive_type('triangles') @@ -1413,22 +1477,26 @@ def set_data(self, *args, **kwargs): self.n_vertices = self.vertex_count(**data) image = data.image - pos = np.array([ - [-1, -1], - [-1, +1], - [+1, -1], - [-1, +1], - [+1, +1], - [+1, -1], - ]) - tex_coords = np.array([ - [0, 1], - [0, 0], - [+1, 1], - [0, 0], - [+1, 0], - [+1, 1], - ]) + pos = np.array( + [ + [-1, -1], + [-1, +1], + [+1, -1], + [-1, +1], + [+1, +1], + [+1, -1], + ] + ) + tex_coords = np.array( + [ + [0, 1], + [0, 0], + [+1, 1], + [0, 0], + [+1, 0], + [+1, 1], + ] + ) self.program['a_position'] = pos.astype(np.float32) self.program['a_tex_coords'] = tex_coords.astype(np.float32) self.program['u_tex'] = image.astype(np.float32) @@ -1437,9 +1505,10 @@ def set_data(self, *args, **kwargs): return data -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Polygon visual -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + class PolygonVisual(BaseVisual): """Polygon. @@ -1450,10 +1519,11 @@ class PolygonVisual(BaseVisual): data_bounds : array-like (2D, shape[1] == 4) """ + default_color = (1, 1, 1, 1) def __init__(self): - super(PolygonVisual, self).__init__() + super().__init__() self.set_shader('polygon') self.set_primitive_type('line_loop') self.set_data_range(NDC) @@ -1473,8 +1543,11 @@ def validate(self, pos=None, data_bounds=None, **kwargs): assert data_bounds.shape == (1, 4) return Bunch( - pos=pos, data_bounds=data_bounds, - _n_items=pos.shape[0], _n_vertices=self.vertex_count(pos=pos)) + pos=pos, + data_bounds=data_bounds, + _n_items=pos.shape[0], + _n_vertices=self.vertex_count(pos=pos), + ) def vertex_count(self, pos=None, **kwargs): """Number of vertices for the requested data.""" diff --git a/phy/utils/__init__.py b/phy/utils/__init__.py index 6acf37989..8d608fcd6 100644 --- a/phy/utils/__init__.py +++ b/phy/utils/__init__.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # flake8: noqa """Utilities: plugin system, event system, configuration system, profiling, debugging, caching, @@ -8,12 +7,23 @@ from .plugin import IPlugin, attach_plugins from .config import ensure_dir_exists, load_master_config, phy_config_dir from .context import Context -from .color import( - colormaps, selected_cluster_color, add_alpha, ClusterColorSelector -) +from .color import colormaps, selected_cluster_color, add_alpha, ClusterColorSelector from phylib.utils import ( - Bunch, emit, connect, unconnect, silent, reset, set_silent, - load_json, save_json, load_pickle, save_pickle, read_python, - read_text, write_text, read_tsv, write_tsv, + Bunch, + emit, + connect, + unconnect, + silent, + reset, + set_silent, + load_json, + save_json, + load_pickle, + save_pickle, + read_python, + read_text, + write_text, + read_tsv, + write_tsv, ) diff --git a/phy/utils/color.py b/phy/utils/color.py index a99e81324..87b784e29 100644 --- a/phy/utils/color.py +++ b/phy/utils/color.py @@ -1,29 +1,27 @@ -# -*- coding: utf-8 -*- - """Color routines.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ -import colorcet as cc import logging -from phylib.utils import Bunch -from phylib.io.array import _index_of - +import colorcet as cc import numpy as np -from numpy.random import uniform from matplotlib.colors import hsv_to_rgb, rgb_to_hsv +from numpy.random import uniform +from phylib.io.array import _index_of +from phylib.utils import Bunch logger = logging.getLogger(__name__) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Random colors -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + -def _random_color(h_range=(0., 1.), s_range=(.5, 1.), v_range=(.5, 1.)): +def _random_color(h_range=(0.0, 1.0), s_range=(0.5, 1.0), v_range=(0.5, 1.0)): """Generate a random RGB color.""" h, s, v = uniform(*h_range), uniform(*s_range), uniform(*v_range) r, g, b = hsv_to_rgb(np.array([[[h, s, v]]])).flat @@ -36,10 +34,7 @@ def _is_bright(rgb): """ L = 0 for c, coeff in zip(rgb, (0.2126, 0.7152, 0.0722)): - if c <= 0.03928: - c = c / 12.92 - else: - c = ((c + 0.055) / 1.055) ** 2.4 + c = c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4 L += c * coeff if (L + 0.05) / (0.0 + 0.05) > (1.0 + 0.05) / (L + 0.05): return True @@ -57,21 +52,22 @@ def _hex_to_triplet(h): """Convert an hexadecimal color to a triplet of int8 integers.""" if h.startswith('#'): h = h[1:] - return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4)) + return tuple(int(h[i : i + 2], 16) for i in (0, 2, 4)) def _override_hsv(rgb, h=None, s=None, v=None): - h_, s_, v_ = rgb_to_hsv(np.array([[rgb]])).flat + h_, s_, v_ = rgb_to_hsv(np.array([[rgb]], dtype=np.float32)).flat h = h if h is not None else h_ s = s if s is not None else s_ v = v if v is not None else v_ - r, g, b = hsv_to_rgb(np.array([[[h, s, v]]])).flat + r, g, b = hsv_to_rgb(np.array([[[h, s, v]]], dtype=np.float32)).flat return r, g, b -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Colormap utilities -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def _selected_cluster_idx(selected_clusters, cluster_ids): selected_clusters = np.asarray(selected_clusters, dtype=np.int32) @@ -115,9 +111,10 @@ def _categorical_colormap(colormap, values, vmin=None, vmax=None, categorize=Non return colormap[x % n, :] -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Colormaps -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + # Default color map for the selected clusters. # see https://colorcet.pyviz.org/user_guide/Categorical.html @@ -134,17 +131,19 @@ def _make_default_colormap(): def _make_cluster_group_colormap(): """Return cluster group colormap.""" - return np.array([ - [0.4, 0.4, 0.4], # noise - [0.5, 0.5, 0.5], # mua - [0.5254, 0.8196, 0.42745], # good - [0.75, 0.75, 0.75], # '' (None = '' = unsorted) - ]) + return np.array( + [ + [0.4, 0.4, 0.4], # noise + [0.5, 0.5, 0.5], # mua + [0.5254, 0.8196, 0.42745], # good + [0.75, 0.75, 0.75], # '' (None = '' = unsorted) + ] + ) """Built-in colormaps.""" colormaps = Bunch( - blank=np.array([[.75, .75, .75]]), + blank=np.array([[0.75, 0.75, 0.75]]), default=_make_default_colormap(), cluster_group=_make_cluster_group_colormap(), categorical=np.array(cc.glasbey_bw_minc_20_minl_30), @@ -154,7 +153,7 @@ def _make_cluster_group_colormap(): ) -def selected_cluster_color(i, alpha=1.): +def selected_cluster_color(i, alpha=1.0): """Return the color, as a 4-tuple, of the i-th selected cluster.""" return add_alpha(tuple(colormaps.default[i % len(colormaps.default)]), alpha=alpha) @@ -194,11 +193,12 @@ def _add_selected_clusters_colors(selected_clusters, cluster_ids, cluster_colors return cluster_colors -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Cluster color selector -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ -def add_alpha(c, alpha=1.): + +def add_alpha(c, alpha=1.0): """Add an alpha channel to an RGB color. Parameters @@ -216,11 +216,11 @@ def add_alpha(c, alpha=1.): if c.shape[-1] == 4: c = c[..., :3] assert c.shape[-1] == 3 - out = np.concatenate([c, alpha * np.ones((c.shape[:-1] + (1,)))], axis=-1) + out = np.concatenate([c, alpha * np.ones(c.shape[:-1] + (1,))], axis=-1) assert out.ndim == c.ndim assert out.shape[-1] == c.shape[-1] + 1 return out - raise ValueError("Unknown value given in add_alpha().") # pragma: no cover + raise ValueError('Unknown value given in add_alpha().') # pragma: no cover def _categorize(values): @@ -233,21 +233,23 @@ def _categorize(values): return values -class ClusterColorSelector(object): +class ClusterColorSelector: """Assign a color to clusters depending on cluster labels or metrics.""" + _colormap = colormaps.categorical _categorical = True _logarithmic = False def __init__( - self, fun=None, colormap=None, categorical=None, logarithmic=None, cluster_ids=None): + self, fun=None, colormap=None, categorical=None, logarithmic=None, cluster_ids=None + ): self.cluster_ids = cluster_ids if cluster_ids is not None else () self._fun = fun self.set_color_mapping( - fun=fun, colormap=colormap, categorical=categorical, logarithmic=logarithmic) + fun=fun, colormap=colormap, categorical=categorical, logarithmic=logarithmic + ) - def set_color_mapping( - self, fun=None, colormap=None, categorical=None, logarithmic=None): + def set_color_mapping(self, fun=None, colormap=None, categorical=None, logarithmic=None): """Set the field used to choose the cluster colors, and the associated colormap. Parameters @@ -304,14 +306,16 @@ def map(self, values): vmin, vmax = self.vmin, self.vmax assert values is not None # Use categorical or continuous colormap depending on the categorical option. - f = (_categorical_colormap - if self._categorical and np.issubdtype(values.dtype, np.integer) - else _continuous_colormap) + f = ( + _categorical_colormap + if self._categorical and np.issubdtype(values.dtype, np.integer) + else _continuous_colormap + ) return f(self._colormap, values, vmin=vmin, vmax=vmax) def _get_cluster_value(self, cluster_id): """Return the field value for a given cluster.""" - return self._fun(cluster_id) if hasattr(self._fun, '__call__') else self._fun or 0 + return self._fun(cluster_id) if callable(self._fun) else self._fun or 0 def get(self, cluster_id, alpha=None): """Return the RGBA color of a single cluster.""" @@ -330,7 +334,7 @@ def get_values(self, cluster_ids): values = _categorize(values) return np.array(values) - def get_colors(self, cluster_ids, alpha=1.): + def get_colors(self, cluster_ids, alpha=1.0): """Return the RGBA colors of some clusters.""" values = self.get_values(cluster_ids) assert values is not None diff --git a/phy/utils/config.py b/phy/utils/config.py index db866c65e..425606d7c 100644 --- a/phy/utils/config.py +++ b/phy/utils/config.py @@ -1,24 +1,23 @@ -# -*- coding: utf-8 -*- - """Configuration utilities based on the traitlets package.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ import logging from pathlib import Path from textwrap import dedent -from traitlets.config import Config, PyFileConfigLoader, JSONFileConfigLoader from phylib.utils._misc import ensure_dir_exists, phy_config_dir +from traitlets.config import Config, JSONFileConfigLoader, PyFileConfigLoader logger = logging.getLogger(__name__) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Config -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def load_config(path=None): """Load a Python or JSON config file and return a `Config` instance.""" @@ -28,7 +27,7 @@ def load_config(path=None): if not path.exists(): # pragma: no cover return Config() file_ext = path.suffix - logger.debug("Load config file `%s`.", path) + logger.debug('Load config file `%s`.', path) if file_ext == '.py': config = PyFileConfigLoader(path.name, str(path.parent), log=logger).load_config() elif file_ext == '.json': @@ -42,7 +41,7 @@ def _default_config(config_dir=None): if not config_dir: # pragma: no cover config_dir = Path.home() / '.phy' path = config_dir / 'plugins' - return dedent(""" + return dedent(f""" # You can also put your plugins in ~/.phy/plugins/. from phy import IPlugin @@ -55,8 +54,8 @@ def _default_config(config_dir=None): # pass c = get_config() - c.Plugins.dirs = [r'{}'] - """.format(path)) + c.Plugins.dirs = [r'{path}'] + """) def load_master_config(config_dir=None): @@ -67,7 +66,7 @@ def load_master_config(config_dir=None): # Create a default config file if necessary. if not path.exists(): ensure_dir_exists(path.parent) - logger.debug("Creating default phy config file at `%s`.", path) + logger.debug('Creating default phy config file at `%s`.', path) path.write_text(_default_config(config_dir=config_dir)) assert path.exists() try: @@ -80,6 +79,7 @@ def load_master_config(config_dir=None): def save_config(path, config): """Save a Config instance to a JSON file.""" import json + config['version'] = 1 with open(path, 'w') as f: json.dump(config, f) diff --git a/phy/utils/context.py b/phy/utils/context.py index 1f450d3a3..df7d747a4 100644 --- a/phy/utils/context.py +++ b/phy/utils/context.py @@ -1,27 +1,27 @@ -# -*- coding: utf-8 -*- - """Execution context that handles parallel processing and caching.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ -from functools import wraps import inspect import logging import os +from functools import wraps from pathlib import Path from pickle import dump, load -from phylib.utils._misc import save_json, load_json, load_pickle, save_pickle, _fullname -from .config import phy_config_dir, ensure_dir_exists +from phylib.utils._misc import _fullname, load_json, load_pickle, save_json, save_pickle + +from .config import ensure_dir_exists, phy_config_dir logger = logging.getLogger(__name__) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Context -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def _cache_methods(obj, memcached, cached): # pragma: no cover for name in memcached: @@ -33,7 +33,7 @@ def _cache_methods(obj, memcached, cached): # pragma: no cover setattr(obj, name, obj.context.cache(f)) -class Context(object): +class Context: """Handle function disk and memory caching with joblib. Memcaching a function is used to save *in memory* the output of the function for all @@ -70,14 +70,14 @@ def my_function(x): """ """Maximum cache size, in bytes.""" - cache_limit = 2 * 1024 ** 3 # 2 GB + cache_limit = 2 * 1024**3 # 2 GB def __init__(self, cache_dir, verbose=0): self.verbose = verbose # Make sure the cache directory exists. self.cache_dir = Path(cache_dir).expanduser() if not self.cache_dir.exists(): - logger.debug("Create cache directory `%s`.", self.cache_dir) + logger.debug('Create cache directory `%s`.', self.cache_dir) os.makedirs(str(self.cache_dir)) # Ensure the memcache directory exists. @@ -94,36 +94,31 @@ def _set_memory(self, cache_dir): # Try importing joblib. try: from joblib import Memory - self._memory = Memory( - location=self.cache_dir, mmap_mode=None, verbose=self.verbose, - bytes_limit=self.cache_limit) - logger.debug("Initialize joblib cache dir at `%s`.", self.cache_dir) - logger.debug("Reducing the size of the cache if needed.") + + self._memory = Memory(location=self.cache_dir, mmap_mode=None, verbose=self.verbose) + logger.debug('Initialize joblib cache dir at `%s`.', self.cache_dir) + logger.debug('Reducing the size of the cache if needed.') self._memory.reduce_size() except ImportError: # pragma: no cover - logger.warning( - "Joblib is not installed. Install it with `conda install joblib`.") + logger.warning('Joblib is not installed. Install it with `conda install joblib`.') self._memory = None def cache(self, f): """Cache a function using the context's cache directory.""" if self._memory is None: # pragma: no cover - logger.debug("Joblib is not installed: skipping caching.") + logger.debug('Joblib is not installed: skipping caching.') return f assert f # NOTE: discard self in instance methods. - if 'self' in inspect.getfullargspec(f).args: - ignore = ['self'] - else: - ignore = None + ignore = ['self'] if 'self' in inspect.getfullargspec(f).args else None disk_cached = self._memory.cache(f, ignore=ignore) return disk_cached def load_memcache(self, name): """Load the memcache from disk (pickle file), if it exists.""" - path = self.cache_dir / 'memcache' / (name + '.pkl') + path = self.cache_dir / 'memcache' / (f'{name}.pkl') if path.exists(): - logger.debug("Load memcache for `%s`.", name) + logger.debug('Load memcache for `%s`.', name) with open(str(path), 'rb') as fd: cache = load(fd) else: @@ -134,8 +129,8 @@ def load_memcache(self, name): def save_memcache(self): """Save the memcache to disk using pickle.""" for name, cache in self._memcache.items(): - path = self.cache_dir / 'memcache' / (name + '.pkl') - logger.debug("Save memcache for `%s`.", name) + path = self.cache_dir / 'memcache' / (f'{name}.pkl') + logger.debug('Save memcache for `%s`.', name) with open(str(path), 'wb') as fd: dump(cache, fd) @@ -154,6 +149,7 @@ def memcached(*args, **kwargs): out = f(*args, **kwargs) cache[h] = out return out + return memcached def _get_path(self, name, location, file_ext='.json'): @@ -182,7 +178,7 @@ def save(self, name, data, location='local', kind='json'): file_ext = '.json' if kind == 'json' else '.pkl' path = self._get_path(name, location, file_ext=file_ext) ensure_dir_exists(path.parent) - logger.debug("Save data to `%s`.", path) + logger.debug('Save data to `%s`.', path) if kind == 'json': save_json(path, data) else: diff --git a/phy/utils/plugin.py b/phy/utils/plugin.py index 317e84caa..db5fb4e42 100644 --- a/phy/utils/plugin.py +++ b/phy/utils/plugin.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - """Simple plugin system. Code from http://eli.thegreenplace.net/2012/08/07/fundamental-concepts-of-plugin-infrastructures @@ -7,9 +5,9 @@ """ -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ import importlib import logging @@ -18,14 +16,16 @@ from pathlib import Path from phylib.utils._misc import _fullname + from .config import load_master_config logger = logging.getLogger(__name__) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # IPlugin interface -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + class IPluginRegistry(type): """Regjster all plugin instances.""" @@ -34,7 +34,7 @@ class IPluginRegistry(type): def __init__(cls, name, bases, attrs): if name != 'IPlugin': - logger.debug("Register plugin `%s`.", _fullname(cls)) + logger.debug('Register plugin `%s`.', _fullname(cls)) if _fullname(cls) not in (_fullname(_) for _ in IPluginRegistry.plugins): IPluginRegistry.plugins.append(cls) @@ -45,7 +45,6 @@ class IPlugin(metaclass=IPluginRegistry): Plugin classes should just implement a method `attach_to_controller(self, controller)`. """ - pass def get_plugin(name): @@ -53,12 +52,13 @@ def get_plugin(name): for plugin in IPluginRegistry.plugins: if name in plugin.__name__: return plugin - raise ValueError("The plugin %s cannot be found." % name) + raise ValueError(f'The plugin {name} cannot be found.') -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Plugins discovery -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def _iter_plugin_files(dirs): """Iterate through all found plugin files.""" @@ -72,11 +72,11 @@ def _iter_plugin_files(dirs): base = subdir.name if 'test' in base or '__' in base or '.git' in str(subdir): # pragma: no cover continue - logger.debug("Scanning `%s`.", subdir) + logger.debug('Scanning `%s`.', subdir) for filename in files: - if (filename.startswith('__') or not filename.endswith('.py')): + if filename.startswith('__') or not filename.endswith('.py'): continue # pragma: no cover - logger.debug("Found plugin module `%s`.", filename) + logger.debug('Found plugin module `%s`.', filename) yield subdir / filename @@ -143,7 +143,7 @@ class name of the Controller instance, plus those specified in the plugins keywo default_plugins = c.plugins if c else [] if len(default_plugins): plugins = default_plugins + plugins - logger.debug("Loading %d plugins.", len(plugins)) + logger.debug('Loading %d plugins.', len(plugins)) attached = [] for plugin in plugins: try: @@ -154,8 +154,7 @@ class name of the Controller instance, plus those specified in the plugins keywo try: p.attach_to_controller(controller) attached.append(plugin) - logger.debug("Attached plugin %s.", plugin) + logger.debug('Attached plugin %s.', plugin) except Exception as e: # pragma: no cover - logger.warning( - "An error occurred when attaching plugin %s: %s.", plugin, e) + logger.warning('An error occurred when attaching plugin %s: %s.', plugin, e) return attached diff --git a/phy/utils/profiling.py b/phy/utils/profiling.py index de4468541..ddce4b73f 100644 --- a/phy/utils/profiling.py +++ b/phy/utils/profiling.py @@ -1,20 +1,18 @@ -# -*- coding: utf-8 -*- - """Utility functions used for tests.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ import builtins -from contextlib import contextmanager -from cProfile import Profile import functools -from io import StringIO import logging import os -from pathlib import Path import sys +from contextlib import contextmanager +from cProfile import Profile +from io import StringIO +from pathlib import Path from timeit import default_timer from .config import ensure_dir_exists @@ -22,34 +20,35 @@ logger = logging.getLogger(__name__) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Profiling -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + @contextmanager def benchmark(name='', repeats=1): """Contexts manager to benchmark an action.""" start = default_timer() yield - duration = (default_timer() - start) * 1000. - logger.info("%s took %.6fms.", name, duration / repeats) + duration = (default_timer() - start) * 1000.0 + logger.info('%s took %.6fms.', name, duration / repeats) class ContextualProfile(Profile): # pragma: no cover """Class used for profiling.""" def __init__(self, *args, **kwds): - super(ContextualProfile, self).__init__(*args, **kwds) + super().__init__(*args, **kwds) self.enable_count = 0 def enable_by_count(self, subcalls=True, builtins=True): - """ Enable the profiler if it hasn't been enabled before.""" + """Enable the profiler if it hasn't been enabled before.""" if self.enable_count == 0: self.enable(subcalls=subcalls, builtins=builtins) self.enable_count += 1 def disable_by_count(self): - """ Disable the profiler if the number of disable requests matches the + """Disable the profiler if the number of disable requests matches the number of enable requests. """ if self.enable_count > 0: @@ -66,6 +65,7 @@ def __call__(self, func): def wrap_function(self, func): """Wrap a function to profile it.""" + @functools.wraps(func) def wrapper(*args, **kwds): self.enable_by_count() @@ -74,6 +74,7 @@ def wrapper(*args, **kwds): finally: self.disable_by_count() return result + return wrapper def __enter__(self): @@ -89,6 +90,7 @@ def _enable_profiler(line_by_line=False): # pragma: no cover return builtins.__dict__['profile'] if line_by_line: import line_profiler + prof = line_profiler.LineProfiler() else: prof = ContextualProfile() @@ -106,6 +108,7 @@ def _profile(prof, statement, glob, loc): sys.stdout = output = StringIO() try: # pragma: no cover from line_profiler import LineProfiler + if isinstance(prof, LineProfiler): prof.print_stats() else: @@ -126,8 +129,10 @@ def _profile(prof, statement, glob, loc): def _enable_pdb(): # pragma: no cover """Enable a Qt-aware IPython debugger.""" from IPython.core import ultratb - logger.debug("Enabling debugger.") + + logger.debug('Enabling debugger.') from PyQt5.QtCore import pyqtRemoveInputHook + pyqtRemoveInputHook() sys.excepthook = ultratb.FormattedTB(mode='Verbose', color_scheme='Linux', call_pdb=True) @@ -135,5 +140,6 @@ def _enable_pdb(): # pragma: no cover def _memory_usage(): # pragma: no cover """Get the memory usage of the current Python process.""" import psutil + process = psutil.Process(os.getpid()) return process.memory_info().rss diff --git a/phy/utils/tests/conftest.py b/phy/utils/tests/conftest.py index 27d6ee4c1..9a9ed31ac 100644 --- a/phy/utils/tests/conftest.py +++ b/phy/utils/tests/conftest.py @@ -1,17 +1,15 @@ -# -*- coding: utf-8 -*- - """py.test fixtures.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ from pytest import fixture - -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Common fixtures -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + @fixture def temp_config_dir(tempdir): diff --git a/phy/utils/tests/test_color.py b/phy/utils/tests/test_color.py index 076aa2b32..2e16179c5 100644 --- a/phy/utils/tests/test_color.py +++ b/phy/utils/tests/test_color.py @@ -1,26 +1,33 @@ -# -*- coding: utf-8 -*- - """Test colors.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ import colorcet as cc import numpy as np from numpy.testing import assert_almost_equal as ae - from pytest import raises from ..color import ( - _is_bright, _random_bright_color, spike_colors, add_alpha, selected_cluster_color, - _override_hsv, _hex_to_triplet, _continuous_colormap, _categorical_colormap, - _selected_cluster_idx, ClusterColorSelector, _add_selected_clusters_colors) - - -#------------------------------------------------------------------------------ + ClusterColorSelector, + _add_selected_clusters_colors, + _categorical_colormap, + _continuous_colormap, + _hex_to_triplet, + _is_bright, + _override_hsv, + _random_bright_color, + _selected_cluster_idx, + add_alpha, + selected_cluster_color, + spike_colors, +) + +# ------------------------------------------------------------------------------ # Tests -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def test_random_color(): for _ in range(20): @@ -32,15 +39,15 @@ def test_hex_to_triplet(): def test_add_alpha(): - assert add_alpha((0, .5, 1), .75) == (0, .5, 1, .75) - assert add_alpha(np.random.rand(5, 3), .5).shape == (5, 4) + assert add_alpha((0, 0.5, 1), 0.75) == (0, 0.5, 1, 0.75) + assert add_alpha(np.random.rand(5, 3), 0.5).shape == (5, 4) - assert add_alpha((0, .5, 1, .1), .75) == (0, .5, 1, .75) - assert add_alpha(np.random.rand(5, 4), .5).shape == (5, 4) + assert add_alpha((0, 0.5, 1, 0.1), 0.75) == (0, 0.5, 1, 0.75) + assert add_alpha(np.random.rand(5, 4), 0.5).shape == (5, 4) def test_override_hsv(): - assert _override_hsv((.1, .9, .5), h=1, s=0, v=1) == (1, 1, 1) + assert _override_hsv((0.1, 0.9, 0.5), h=1, s=0, v=1) == (1, 1, 1) def test_selected_cluster_color(): @@ -71,9 +78,9 @@ def test_spike_colors(): def test_cluster_color_selector_1(): cluster_ids = [1, 2, 3] - c = ClusterColorSelector(lambda cid: cid * .1, cluster_ids=cluster_ids) + c = ClusterColorSelector(lambda cid: cid * 0.1, cluster_ids=cluster_ids) - assert len(c.get(1, alpha=.5)) == 4 + assert len(c.get(1, alpha=0.5)) == 4 ae(c.get_values([0, 0]), np.zeros(2)) for colormap in ('linear', 'rainbow', 'categorical', 'diverging'): @@ -85,7 +92,8 @@ def test_cluster_color_selector_1(): def test_cluster_color_selector_2(): cluster_ids = [2, 3, 5, 7] c = ClusterColorSelector( - lambda cid: cid, cluster_ids=cluster_ids, colormap='categorical', categorical=True) + lambda cid: cid, cluster_ids=cluster_ids, colormap='categorical', categorical=True + ) c2 = c.get_colors([2]) c3 = c.get_colors([3]) @@ -107,7 +115,8 @@ def test_cluster_color_group(): # Mock ClusterMeta instance, with 'fields' property and get(field, cluster) function. cluster_ids = [1, 2, 3] c = ClusterColorSelector( - lambda cl: {1: None, 2: 'mua', 3: 'good'}[cl], cluster_ids=cluster_ids) + lambda cl: {1: None, 2: 'mua', 3: 'good'}[cl], cluster_ids=cluster_ids + ) c.set_color_mapping(colormap='cluster_group') colors = c.get_colors(cluster_ids) @@ -116,7 +125,7 @@ def test_cluster_color_group(): def test_cluster_color_log(): cluster_ids = [1, 2, 3] - c = ClusterColorSelector(lambda cid: cid * .1, cluster_ids=cluster_ids) + c = ClusterColorSelector(lambda cid: cid * 0.1, cluster_ids=cluster_ids) c.set_color_mapping(logarithmic=True) colors = c.get_colors(cluster_ids) @@ -142,7 +151,8 @@ def test_add_selected_clusters_colors_2(): cluster_colors = np.c_[np.arange(5), np.zeros((5, 3))] cluster_colors_sel = _add_selected_clusters_colors( - selected_clusters, cluster_ids, cluster_colors) + selected_clusters, cluster_ids, cluster_colors + ) ae(cluster_colors_sel[[0, 1, 4]], cluster_colors[[0, 1, 4]]) ae(cluster_colors_sel[2], selected_cluster_color(0)) diff --git a/phy/utils/tests/test_config.py b/phy/utils/tests/test_config.py index f1660c71b..5d834176f 100644 --- a/phy/utils/tests/test_config.py +++ b/phy/utils/tests/test_config.py @@ -1,43 +1,44 @@ -# -*- coding: utf-8 -*- - """Test config.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ import logging from textwrap import dedent +from phylib.utils._misc import write_text from pytest import fixture from traitlets import Float from traitlets.config import Configurable from .. import config as _config -from phylib.utils._misc import write_text -from ..config import (ensure_dir_exists, - load_config, - load_master_config, - save_config, - ) +from ..config import ( + ensure_dir_exists, + load_config, + load_master_config, + save_config, +) logger = logging.getLogger(__name__) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Test logging -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def test_logging(): - logger.debug("Debug message") - logger.info("Info message") - logger.warning("Warn message") - logger.error("Error message") + logger.debug('Debug message') + logger.info('Info message') + logger.warning('Warn message') + logger.error('Error message') -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Test config -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def test_phy_config_dir(): assert str(_config.phy_config_dir()).endswith('.phy') @@ -53,9 +54,10 @@ def test_temp_config_dir(temp_config_dir): assert _config.phy_config_dir() == temp_config_dir -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Config tests -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + @fixture def py_config(tempdir): @@ -93,7 +95,6 @@ def config(py_config, json_config, request): def test_load_config(config): - assert load_config() is not None class MyConfigurable(Configurable): @@ -128,13 +129,13 @@ def test_load_master_config_1(temp_config_dir): # Load the master config file. c = load_master_config() - assert c.MyConfigurable.my_var == 1. + assert c.MyConfigurable.my_var == 1.0 def test_save_config(tempdir): - c = {'A': {'b': 3.}} + c = {'A': {'b': 3.0}} path = tempdir / 'config.json' save_config(path, c) c1 = load_config(path) - assert c1.A.b == 3. + assert c1.A.b == 3.0 diff --git a/phy/utils/tests/test_context.py b/phy/utils/tests/test_context.py index ffb17af5e..49e54254a 100644 --- a/phy/utils/tests/test_context.py +++ b/phy/utils/tests/test_context.py @@ -1,28 +1,26 @@ -# -*- coding: utf-8 -*- - """Test context.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ from pickle import dump, load import numpy as np from numpy.testing import assert_array_equal as ae +from phylib.io.array import read_array, write_array from pytest import fixture -from phylib.io.array import write_array, read_array from ..context import Context, _fullname - -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Fixtures -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + @fixture(scope='function') def context(tempdir): - ctx = Context('{}/cache/'.format(tempdir), verbose=1) + ctx = Context(f'{tempdir}/cache/', verbose=1) return ctx @@ -30,15 +28,17 @@ def context(tempdir): def temp_phy_config_dir(tempdir): """Use a temporary phy user directory.""" import phy.utils.context + f = phy.utils.context.phy_config_dir phy.utils.context.phy_config_dir = lambda: tempdir yield phy.utils.context.phy_config_dir = f -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Test utils and cache -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def test_read_write(tempdir): x = np.arange(10) @@ -63,12 +63,11 @@ def test_context_load_save_pickle(tempdir, context, temp_phy_config_dir): def test_context_cache(context): - _res = [] def f(x): _res.append(x) - return x ** 2 + return x**2 x = np.arange(5) x2 = x * x @@ -88,7 +87,7 @@ def f(x): def test_context_cache_method(tempdir, context): - class A(object): + class A: def __init__(self, ctx): self.f = ctx.cache(self.f) self._l = [] @@ -109,7 +108,7 @@ def f(self, x): assert a._l == [3] # Recreate the context. - context = Context('{}/cache/'.format(tempdir), verbose=1) + context = Context(f'{tempdir}/cache/', verbose=1) # Recreate the class. a = A(context) assert a.f(3) == 3 @@ -118,21 +117,20 @@ def f(self, x): def test_context_memcache(tempdir, context): - _res = [] @context.memcache def f(x): _res.append(x) - return x ** 2 + return x**2 # Compute the function a first time. x = 10 - ae(f(x), x ** 2) + ae(f(x), x**2) assert len(_res) == 1 # The second time, the memory cache is used. - ae(f(x), x ** 2) + ae(f(x), x**2) assert len(_res) == 1 # We artificially clear the memory cache. @@ -141,7 +139,7 @@ def f(x): context.load_memcache(_fullname(f)) # This time, the result is loaded from disk. - ae(f(x), x ** 2) + ae(f(x), x**2) assert len(_res) == 1 diff --git a/phy/utils/tests/test_plugin.py b/phy/utils/tests/test_plugin.py index 7b5ee4e30..087c6b360 100644 --- a/phy/utils/tests/test_plugin.py +++ b/phy/utils/tests/test_plugin.py @@ -1,28 +1,21 @@ -# -*- coding: utf-8 -*- - """Test plugin system.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ from textwrap import dedent -from pytest import fixture, raises - -from ..plugin import (IPluginRegistry, - IPlugin, - get_plugin, - discover_plugins, - attach_plugins - ) from phylib.utils._misc import write_text +from pytest import fixture, raises +from ..plugin import IPlugin, IPluginRegistry, attach_plugins, discover_plugins, get_plugin -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Fixtures -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + @fixture def no_native_plugins(): @@ -33,9 +26,10 @@ def no_native_plugins(): IPluginRegistry.plugins = plugins -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Tests -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def test_plugin_1(no_native_plugins): class MyPlugin(IPlugin): @@ -50,7 +44,7 @@ class MyPlugin(IPlugin): def test_discover_plugins(tempdir, no_native_plugins): path = tempdir / 'my_plugin.py' - contents = '''from phy import IPlugin\nclass MyPlugin(IPlugin): pass''' + contents = """from phy import IPlugin\nclass MyPlugin(IPlugin): pass""" write_text(path, contents) plugins = discover_plugins([tempdir]) @@ -59,26 +53,30 @@ def test_discover_plugins(tempdir, no_native_plugins): def test_attach_plugins(tempdir): - class MyController(object): + class MyController: pass - write_text(tempdir / 'plugin1.py', dedent( - ''' + write_text( + tempdir / 'plugin1.py', + dedent( + """ from phy import IPlugin class MyPlugin1(IPlugin): def attach_to_controller(self, controller): controller.plugin1 = True - ''')) + """ + ), + ) class MyPlugin2(IPlugin): def attach_to_controller(self, controller): controller.plugin2 = True - contents = dedent(''' + contents = dedent(f""" c = get_config() - c.Plugins.dirs = ['%s'] + c.Plugins.dirs = ['{tempdir}'] c.MyController.plugins = ['MyPlugin1'] - ''' % tempdir) + """) write_text(tempdir / 'phy_config.py', contents) controller = MyController() diff --git a/phy/utils/tests/test_profiling.py b/phy/utils/tests/test_profiling.py index 191e3b64f..35ddad8b0 100644 --- a/phy/utils/tests/test_profiling.py +++ b/phy/utils/tests/test_profiling.py @@ -1,25 +1,23 @@ -# -*- coding: utf-8 -*- - """Tests of testing utility functions.""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Imports -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ import time from pytest import mark -from ..profiling import benchmark, _enable_profiler, _profile - +from ..profiling import _enable_profiler, _profile, benchmark -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Tests -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ + def test_benchmark(): with benchmark(): - time.sleep(.002) + time.sleep(0.002) @mark.parametrize('line_by_line', [False, True]) diff --git a/plugins/README.md b/plugins/README.md index 16d4cc38c..f47dfbcb3 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -4,7 +4,7 @@ * [ExampleClusterMetadataPlugin](cluster_metadata.py): Show how to save the best channel of every cluster in a cluster_channel.tsv file when saving. * [ExampleClusterMetricsPlugin](cluster_metrics.py): Show how to add a custom cluster metrics. * [ExampleClusterStatsPlugin](cluster_stats.py): Show how to add a custom cluster histogram view showing cluster statistics. -* [ExampleClusterViewStylingPlugin](cluster_view_styling.py): Show how to customize the styling of the cluster view with CSS. +* [ExampleClusterViewStylingPlugin](cluster_view_styling.py): Show how to customize the styling of the cluster view with Qt stylesheet fragments. * [ExampleColorSchemePlugin](color_scheme.py): Show how to add a custom color scheme to a view. * [ExampleCustomButtonPlugin](custom_button.py): Show how to add custom buttons in a view's title bar. * [ExampleCustomColumnsPlugin](custom_columns.py): Show how to customize the columns in the cluster and similarity views. diff --git a/plugins/action_status_bar.py b/plugins/action_status_bar.py index 8b0f3e998..483e3a79f 100644 --- a/plugins/action_status_bar.py +++ b/plugins/action_status_bar.py @@ -14,7 +14,6 @@ class ExampleActionPlugin(IPlugin): def attach_to_controller(self, controller): @connect def on_gui_ready(sender, gui): - # Add a separator at the end of the File menu. # Note: currently, there is no way to add actions at another position in the menu. gui.file_actions.separator() @@ -27,7 +26,7 @@ def display_message(): # the menu item. # We update the text in the status bar. - gui.status_message = "Hello world" + gui.status_message = 'Hello world' # We add a separator at the end of the Select menu. gui.select_actions.separator() @@ -35,15 +34,15 @@ def display_message(): # Add an action to a new submenu called "My submenu". This action displays a prompt # dialog with the default value 10. @gui.select_actions.add( - submenu='My submenu', shortcut='ctrl+c', prompt=True, prompt_default=lambda: 10) + submenu='My submenu', shortcut='ctrl+c', prompt=True, prompt_default=lambda: 10 + ) def select_n_first_clusters(n_clusters): - # All cluster view methods are called with a callback function because of the - # asynchronous nature of Python-Javascript interactions in Qt5. + # asynchronous nature of the table API. @controller.supervisor.cluster_view.get_ids def get_cluster_ids(cluster_ids): """This function is called when the ordered list of cluster ids is returned - by the Javascript view.""" + by the cluster view.""" # We select the first n_clusters clusters. controller.supervisor.select(cluster_ids[:n_clusters]) diff --git a/plugins/cluster_metadata.py b/plugins/cluster_metadata.py index b2f6d0162..0e6fc0c4e 100644 --- a/plugins/cluster_metadata.py +++ b/plugins/cluster_metadata.py @@ -7,9 +7,10 @@ import logging -from phy import IPlugin, connect from phylib.io.model import save_metadata +from phy import IPlugin, connect + logger = logging.getLogger('phy') @@ -17,7 +18,6 @@ class ExampleClusterMetadataPlugin(IPlugin): def attach_to_controller(self, controller): @connect def on_gui_ready(sender, gui): - @connect(sender=gui) def on_request_save(sender): """This function is called whenever the Save action is triggered.""" @@ -43,8 +43,9 @@ def on_request_save(sender): # Dictionary mapping cluster_ids to the best channel id. metadata = { cluster_id: controller.get_best_channel(cluster_id) - for cluster_id in cluster_ids} + for cluster_id in cluster_ids + } # Save the metadata file. save_metadata(filename, field_name, metadata) - logger.info("Saved %s.", filename) + logger.info('Saved %s.', filename) diff --git a/plugins/cluster_metrics.py b/plugins/cluster_metrics.py index b824d67c4..afd7fdd5d 100644 --- a/plugins/cluster_metrics.py +++ b/plugins/cluster_metrics.py @@ -1,6 +1,7 @@ """Show how to add a custom cluster metrics.""" import numpy as np + from phy import IPlugin diff --git a/plugins/cluster_stats.py b/plugins/cluster_stats.py index a91f66a4f..62b126cc0 100644 --- a/plugins/cluster_stats.py +++ b/plugins/cluster_stats.py @@ -1,19 +1,19 @@ """Show how to add a custom cluster histogram view showing cluster statistics.""" -from phy import IPlugin, Bunch +from phy import Bunch, IPlugin from phy.cluster.views import HistogramView class FeatureHistogramView(HistogramView): """Every view corresponds to a unique view class, so we need to subclass HistogramView.""" + n_bins = 100 # default number of bins - x_max = .1 # maximum value on the x axis (maximum bin) + x_max = 0.1 # maximum value on the x axis (maximum bin) alias_char = 'fh' # provide `:fhn` (set number of bins) and `:fhm` (set max bin) snippets class ExampleClusterStatsPlugin(IPlugin): def attach_to_controller(self, controller): - def feature_histogram(cluster_id): """Must return a Bunch object with data and optional x_max, plot, text items. diff --git a/plugins/cluster_view_styling.py b/plugins/cluster_view_styling.py index d2941d30c..4129d9e93 100644 --- a/plugins/cluster_view_styling.py +++ b/plugins/cluster_view_styling.py @@ -1,4 +1,4 @@ -"""Show how to customize the styling of the cluster view with CSS.""" +"""Show how to customize the cluster view with Qt stylesheet fragments.""" from phy import IPlugin from phy.cluster.supervisor import ClusterView @@ -6,15 +6,10 @@ class ExampleClusterViewStylingPlugin(IPlugin): def attach_to_controller(self, controller): - # We add a custom CSS style to the ClusterView. + # We add a custom stylesheet fragment to the ClusterView. ClusterView._styles += """ - - /* This CSS selector represents all rows for good clusters. */ - table tr[data-group='good'] { - - /* We change the text color. Many other CSS attributes can be changed, - such as background-color, the font weight, etc. */ - color: red; + QHeaderView::section { + color: #f5c542; + font-weight: bold; } - """ diff --git a/plugins/custom_button.py b/plugins/custom_button.py index ee58e14f7..34e59d86b 100644 --- a/plugins/custom_button.py +++ b/plugins/custom_button.py @@ -9,7 +9,6 @@ def attach_to_controller(self, controller): @connect def on_view_attached(view, gui): if isinstance(view, WaveformView): - # view.dock is a DockWidget instance, it has methods such as add_button(), # add_checkbox(), and set_status(). diff --git a/plugins/custom_similarity.py b/plugins/custom_similarity.py index a60928eff..c343b35d9 100644 --- a/plugins/custom_similarity.py +++ b/plugins/custom_similarity.py @@ -1,6 +1,7 @@ """Show how to add a custom similarity measure.""" from operator import itemgetter + import numpy as np from phy import IPlugin @@ -17,20 +18,20 @@ def _dot_product(mw1, c1, mw2, c2): assert mw2.ndim == 2 # (n_samples, n_channels_loc_2) # We normalize the waveforms. - mw1 /= np.sqrt(np.sum(mw1 ** 2)) - mw2 /= np.sqrt(np.sum(mw2 ** 2)) + mw1 /= np.sqrt(np.sum(mw1**2)) + mw2 /= np.sqrt(np.sum(mw2**2)) # We find the union of the channel ids for both clusters so that we can convert from sparse # to dense format. - channel_ids = np.union1d(c1, c2) + channel_ids = np.union1d(c1, c2).astype(np.int64, copy=False) # We directly return 0 if the channels of the two clusters are disjoint. if not len(np.intersect1d(c1, c2)): return 0 # We tile the channels so as to use `from_sparse()`. - c1 = np.tile(c1, (mw1.shape[0], 1)) - c2 = np.tile(c2, (mw2.shape[0], 1)) + c1 = np.tile(np.asarray(c1, dtype=np.int64), (mw1.shape[0], 1)) + c2 = np.tile(np.asarray(c2, dtype=np.int64), (mw2.shape[0], 1)) # We convert from sparse to dense format in order to compute the distance. mw1 = from_sparse(mw1, c1, channel_ids) # (n_samples, n_channel_locs_common) @@ -42,7 +43,6 @@ def _dot_product(mw1, c1, mw2, c2): class ExampleSimilarityPlugin(IPlugin): def attach_to_controller(self, controller): - # We cache this function in memory and on disk. @controller.context.memcache def mean_waveform_similarity(cluster_id): diff --git a/plugins/custom_split.py b/plugins/custom_split.py index 19ef3ca1d..bf7c7367c 100644 --- a/plugins/custom_split.py +++ b/plugins/custom_split.py @@ -6,6 +6,7 @@ def k_means(x): """Cluster an array into two subclusters, using the K-means algorithm.""" from sklearn.cluster import KMeans + return KMeans(n_clusters=2).fit_predict(x) diff --git a/plugins/feature_view_custom_grid.py b/plugins/feature_view_custom_grid.py index 71e6f8ca2..abe592891 100644 --- a/plugins/feature_view_custom_grid.py +++ b/plugins/feature_view_custom_grid.py @@ -1,6 +1,7 @@ """Show how to customize the subplot grid specification in the feature view.""" import re + from phy import IPlugin, connect from phy.cluster.views import FeatureView diff --git a/plugins/filter_action.py b/plugins/filter_action.py index c015b94e7..0908b8461 100644 --- a/plugins/filter_action.py +++ b/plugins/filter_action.py @@ -14,4 +14,4 @@ def on_gui_ready(sender, gui): @gui.view_actions.add(alias='fr') # corresponds to `:fr` snippet def filter_firing_rate(rate): """Filter clusters with the firing rate.""" - controller.supervisor.filter('fr > %.1f' % float(rate)) + controller.supervisor.filter(f'fr > {float(rate):.1f}') diff --git a/plugins/font_size.py b/plugins/font_size.py index d1901d9e7..db3c3bcc4 100644 --- a/plugins/font_size.py +++ b/plugins/font_size.py @@ -7,4 +7,4 @@ class ExampleFontSizePlugin(IPlugin): def attach_to_controller(self, controller): # Smaller font size than the default (6). - TextVisual.default_font_size = 4. + TextVisual.default_font_size = 4.0 diff --git a/plugins/hello.py b/plugins/hello.py index 3ca7d596f..7dfae7386 100644 --- a/plugins/hello.py +++ b/plugins/hello.py @@ -15,4 +15,4 @@ def on_gui_ready(sender, gui): def on_cluster(sender, up): """This is called every time a cluster assignment or cluster group/label changes.""" - print("Clusters update: %s" % up) + print(f'Clusters update: {up}') diff --git a/plugins/matplotlib_view.py b/plugins/matplotlib_view.py index 71b4430fb..16852d4ec 100644 --- a/plugins/matplotlib_view.py +++ b/plugins/matplotlib_view.py @@ -10,7 +10,7 @@ class FeatureDensityView(ManualClusteringView): def __init__(self, features=None): """features is a function (cluster_id => Bunch(data, ...)) where data is a 3D array.""" - super(FeatureDensityView, self).__init__() + super().__init__() self.features = features def on_select(self, cluster_ids=(), **kwargs): diff --git a/plugins/opengl_view.py b/plugins/opengl_view.py index 23a5a6813..58e148e4d 100644 --- a/plugins/opengl_view.py +++ b/plugins/opengl_view.py @@ -2,11 +2,10 @@ import numpy as np -from phy.utils.color import selected_cluster_color - from phy import IPlugin from phy.cluster.views import ManualClusteringView from phy.plot.visuals import PlotVisual +from phy.utils.color import selected_cluster_color class MyOpenGLView(ManualClusteringView): @@ -19,7 +18,7 @@ def __init__(self, templates=None): the data as NumPy arrays. Many such functions are defined in the TemplateController. """ - super(MyOpenGLView, self).__init__() + super().__init__() """ The View instance contains a special `canvas` object which is a `̀PlotCanvas` instance. @@ -149,7 +148,7 @@ def on_select(self, cluster_ids=(), **kwargs): We decide to use, on the x axis, values ranging from -1 to 1. This is the standard viewport in OpenGL and phy. """ - x = np.linspace(-1., 1., len(y)) + x = np.linspace(-1.0, 1.0, len(y)) """ phy requires you to specify explicitly the x and y range of the plots. @@ -181,7 +180,8 @@ def on_select(self, cluster_ids=(), **kwargs): top to bottom. Note that in the grid view, the box index is a pair (row, col). """ self.visual.add_batch_data( - x=x, y=y, color=color, data_bounds=data_bounds, box_index=idx) + x=x, y=y, color=color, data_bounds=data_bounds, box_index=idx + ) """ After the loop, this special call automatically builds the data to upload to the GPU diff --git a/plugins/umap_view.py b/plugins/umap_view.py index 50beada4c..0b9011920 100644 --- a/plugins/umap_view.py +++ b/plugins/umap_view.py @@ -1,18 +1,18 @@ """Show how to write a custom dimension reduction view.""" -from phy import IPlugin, Bunch +from phy import Bunch, IPlugin from phy.cluster.views import ScatterView def umap(x): """Perform the dimension reduction of the array x.""" from umap import UMAP + return UMAP().fit_transform(x) class WaveformUMAPView(ScatterView): """Every view corresponds to a unique view class, so we need to subclass ScatterView.""" - pass class ExampleWaveformUMAPPlugin(IPlugin): diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..58e7e12ea --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,180 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "phy" +version = "2.1.0rc1" # No dynamic lookup needed +description = "Interactive visualization and manual spike sorting of large-scale ephys data" +readme = "README.md" +license = "BSD-3-Clause" +license-files = ["LICENSE.md"] +authors = [ + { name = "Cyrille Rossant (cortex-lab/UCL/IBL)", email = "cyrille.rossant+pypi@gmail.com" }, +] +keywords = ["phy", "data analysis", "electrophysiology", "neuroscience"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Natural Language :: English", + "Framework :: IPython", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] +requires-python = ">=3.10" + +# specific version required for pyopengl >=3.1.9 +dependencies = [ + "phylib>=2.7.0,<3", + "click", + "colorcet", + "h5py", + "joblib", + "matplotlib", + "mtscomp", + "numpy", + "pillow", + "pip", + "PyQt5>=5.12.0", + "pyopengl>=3.1.9", + "qtconsole", + "requests", + "responses", + "scipy", + "setuptools", + "tqdm", + "traitlets", + "ipykernel", +] + +[project.urls] +Homepage = "https://phy.cortexlab.net" +Repository = "https://github.com/cortex-lab/phy" +Documentation = "https://phy.cortexlab.net" + +[project.optional-dependencies] +dev = [ + "pytest", + "pytest-qt", + "PyQt5>=5.12.0", + "pytest-cov", + "ruff", + "coverage", + "coveralls", + "memory_profiler", + "mkdocs", +] + +[dependency-groups] +dev = [ + "pytest>=6.0", + "pytest-qt>=4.0", + "PyQt5>=5.12.0", + "pytest-cov>=3.0", + "ruff>=0.1.0", + "coverage>=6.0", + "coveralls>=3.0", + "memory_profiler>=0.60", + "mkdocs>=1.4", +] + +[project.scripts] +phy = "phy.apps:phycli" + +[tool.setuptools.packages.find] +include = ["phy*"] +exclude = ["*.tests", "*.tests.*"] + +[tool.setuptools.package-data] +phy = [ + ".vert", + ".frag", + ".glsl", + ".npy", + ".gz", + ".txt", + ".json", + ".html", + ".css", + ".js", + ".prb", + ".ttf", + "*.png", +] + +[tool.pytest.ini_options] +testpaths = ["phy"] +addopts = "--ignore=phy/apps/kwik --cov=phy --cov-report=term-missing" +norecursedirs = ["experimental", ""] +filterwarnings = [ + "default", + "ignore:pkg_resources is deprecated as an API\\.:UserWarning", + "ignore:tostring\\(\\) is deprecated\\. Use tobytes\\(\\) instead\\.:DeprecationWarning", + "ignore:The 'warn' method is deprecated, use 'warning' instead:DeprecationWarning", + "ignore:unclosed file <_io\\.TextIOWrapper name='/dev/null'.*:ResourceWarning", + "ignore::DeprecationWarning:OpenGL\\.GL\\.VERSION\\.GL_2_0", + "ignore::DeprecationWarning:.", + "ignore:numpy.ufunc", + "ignore:Jupyter is migrating its paths to use standard platformdirs.*:DeprecationWarning", +] + +[tool.coverage.run] +branch = false +source = ["phy"] +omit = [ + "/phy/ext/", + "/phy/utils/tempdir.py", + "/default_settings.py", + "/phy/plot/gloo/", +] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "raise AssertionError", + "raise NotImplementedError", + "pass", + "continue", + "qtbot.stop()", + "_in_travis():", + "_is_high_dpi():", + "return$", + "^\"\"\"", +] +omit = ["/phy/plot/gloo/"] +show_missing = true + +[tool.ruff] +line-length = 99 +target-version = "py39" + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "N", "UP", "B", "C4", "SIM", "PIE", "NPY201"] +ignore = [ + "E265", # block comment should start with '# ' + "E731", # do not assign a lambda expression, use a def + "E741", # ambiguous variable name + "W605", # invalid escape sequence, + "N806", + "SIM102", + "B007", + "N803", + "N802", + "B018", + "F401", + "SIM118", + "B015", + "C416", + "E402", + "E501", + "SIM108", +] + +[tool.ruff.lint.isort] +known-first-party = ["phy"] + +[tool.ruff.format] +quote-style = "single" +indent-style = "space" diff --git a/scripts/check_basecanvas.py b/scripts/check_basecanvas.py new file mode 100644 index 000000000..8737f7064 --- /dev/null +++ b/scripts/check_basecanvas.py @@ -0,0 +1,19 @@ +import sys + +from phy.gui.qt import QApplication +from phy.plot.base import BaseCanvas + + +def main(): + app = QApplication.instance() or QApplication(sys.argv) + canvas = BaseCanvas() + print(type(canvas).__name__, flush=True) + print("before show", flush=True) + canvas.show() + print("after show", flush=True) + app.processEvents() + print("after events", flush=True) + + +if __name__ == "__main__": + main() diff --git a/scripts/check_qopenglwindow_non_headless.py b/scripts/check_qopenglwindow_non_headless.py new file mode 100644 index 000000000..8db5b08fb --- /dev/null +++ b/scripts/check_qopenglwindow_non_headless.py @@ -0,0 +1,26 @@ +import sys + +from PyQt5.QtGui import QOpenGLWindow +from PyQt5.QtWidgets import QApplication + + +class Window(QOpenGLWindow): + def initializeGL(self): + pass + + def paintGL(self): + pass + + +def main(): + app = QApplication.instance() or QApplication(sys.argv) + window = Window() + print("before show", flush=True) + window.show() + print("after show", flush=True) + app.processEvents() + print("after events", flush=True) + + +if __name__ == "__main__": + main() diff --git a/scripts/release_publish.py b/scripts/release_publish.py new file mode 100644 index 000000000..c5c50d26f --- /dev/null +++ b/scripts/release_publish.py @@ -0,0 +1,333 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import configparser +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path +from urllib.error import HTTPError, URLError +from urllib.request import urlopen + + +REPO_ROOT = Path(__file__).resolve().parents[1] +PYPROJECT_PATH = REPO_ROOT / 'pyproject.toml' +INIT_PATH = REPO_ROOT / 'phy' / '__init__.py' +STATE_DIR = REPO_ROOT / '.release-smoke' +LATEST_TESTPYPI_VERSION_PATH = STATE_DIR / 'latest-testpypi-version.txt' + +PROJECT_NAME = 'phy' +TESTPYPI_JSON_URL = f'https://test.pypi.org/pypi/{PROJECT_NAME}/json' +TESTPYPI_PUBLISH_URL = 'https://test.pypi.org/legacy/' +VERSION_RE = re.compile(r'(?m)^version = "([^"]+)"') +INIT_VERSION_RE = re.compile(r"(?m)^__version__ = '([^']+)'") +PYPIRC_PATH = Path.home() / '.pypirc' + + +def run(cmd: list[str], *, cwd: Path | None = None) -> None: + print('+', ' '.join(cmd)) + subprocess.run(cmd, cwd=cwd, check=True) + + +def read_pypirc_section(section: str) -> dict[str, str]: + if not PYPIRC_PATH.exists(): + return {} + + parser = configparser.RawConfigParser() + parser.read(PYPIRC_PATH, encoding='utf-8') + if not parser.has_section(section): + return {} + + return {key: value.strip() for key, value in parser.items(section)} + + +def get_publish_token(*env_vars: str) -> str: + for env_var in env_vars: + value = os.environ.get(env_var, '').strip() + if value: + return value + return '' + + +def get_token_from_pypirc(section: str) -> str: + config = read_pypirc_section(section) + username = config.get('username', '') + password = config.get('password', '') + if username == '__token__' and password: + return password + return '' + + +def get_testpypi_publish_args() -> list[str]: + token = get_publish_token('TESTPYPI_TOKEN', 'TEST_PYPI_TOKEN', 'UV_PUBLISH_TOKEN') + if not token: + token = get_token_from_pypirc('testpypi') + if not token: + raise RuntimeError( + 'Missing TestPyPI publish token. Set TESTPYPI_TOKEN, TEST_PYPI_TOKEN, or ' + 'UV_PUBLISH_TOKEN, or add username=__token__ and password= under ' + f'[testpypi] in {PYPIRC_PATH}. Username/password uploads are no longer ' + 'supported by PyPI/TestPyPI.' + ) + return [ + '--publish-url', + TESTPYPI_PUBLISH_URL, + '--token', + token, + ] + + +def get_pypi_publish_args() -> list[str]: + token = get_publish_token('PYPI_TOKEN', 'UV_PUBLISH_TOKEN') + if not token: + token = get_token_from_pypirc('pypi') + if not token: + raise RuntimeError( + 'Missing PyPI publish token. Set PYPI_TOKEN or UV_PUBLISH_TOKEN, or add ' + f'username=__token__ and password= under [pypi] in {PYPIRC_PATH}. ' + 'Username/password uploads are no longer supported by PyPI/TestPyPI.' + ) + return [ + '--token', + token, + ] + + +def read_text(path: Path) -> str: + return path.read_text(encoding='utf-8') + + +def write_text(path: Path, contents: str) -> None: + path.write_text(contents, encoding='utf-8') + + +def get_current_version() -> str: + match = VERSION_RE.search(read_text(PYPROJECT_PATH)) + if not match: + raise RuntimeError(f'Unable to find project.version in {PYPROJECT_PATH}') + return match.group(1) + + +def set_version_in_tree(root: Path, version: str) -> None: + pyproject_path = root / 'pyproject.toml' + init_path = root / 'phy' / '__init__.py' + + pyproject = read_text(pyproject_path) + pyproject_new, pyproject_count = VERSION_RE.subn(f'version = "{version}"', pyproject, count=1) + if pyproject_count != 1: + raise RuntimeError(f'Unable to update version in {pyproject_path}') + + init_py = read_text(init_path) + init_new, init_count = INIT_VERSION_RE.subn(f"__version__ = '{version}'", init_py, count=1) + if init_count != 1: + raise RuntimeError(f'Unable to update version in {init_path}') + + write_text(pyproject_path, pyproject_new) + write_text(init_path, init_new) + + +def fetch_testpypi_versions() -> set[str]: + try: + with urlopen(TESTPYPI_JSON_URL) as response: # noqa: S310 + payload = json.load(response) + except HTTPError as exc: + if exc.code == 404: + return set() + raise RuntimeError(f'Unable to query TestPyPI: HTTP {exc.code}') from exc + except URLError as exc: + raise RuntimeError(f'Unable to query TestPyPI: {exc.reason}') from exc + + releases = payload.get('releases', {}) + return set(releases) + + +def fetch_testpypi_release(version: str) -> dict: + url = f'https://test.pypi.org/pypi/{PROJECT_NAME}/{version}/json' + try: + with urlopen(url) as response: # noqa: S310 + return json.load(response) + except HTTPError as exc: + if exc.code == 404: + raise RuntimeError(f'Version {version!r} was not found on TestPyPI.') from exc + raise RuntimeError(f'Unable to query TestPyPI: HTTP {exc.code}') from exc + except URLError as exc: + raise RuntimeError(f'Unable to query TestPyPI: {exc.reason}') from exc + + +def get_testpypi_file_url(version: str, packagetype: str = 'bdist_wheel') -> str: + payload = fetch_testpypi_release(version) + for file_info in payload.get('urls', []): + if file_info.get('packagetype') == packagetype: + url = file_info.get('url', '').strip() + if url: + return url + raise RuntimeError( + f'Unable to find a {packagetype!r} file for {PROJECT_NAME} {version!r} on TestPyPI.' + ) + + +def get_next_dev_version(base_version: str, existing_versions: set[str]) -> str: + if '.dev' in base_version: + raise RuntimeError( + f'Base version {base_version!r} already contains .dev; keep pyproject.toml on the final ' + 'candidate version and let this helper derive disposable .devN versions.' + ) + + pattern = re.compile(rf'^{re.escape(base_version)}\.dev(\d+)$') + max_dev = 0 + for version in existing_versions: + match = pattern.match(version) + if match: + max_dev = max(max_dev, int(match.group(1))) + return f'{base_version}.dev{max_dev + 1}' + + +def make_stage_dir() -> Path: + tmp_root = Path(tempfile.mkdtemp(prefix='phy-release-')) + stage_dir = tmp_root / 'repo' + + def ignore(path: str, names: list[str]) -> set[str]: + ignored = set() + for name in names: + if name in {'.git', '.venv', '.tox', '.pytest_cache', '.ruff_cache', '.mypy_cache', '.release-smoke', + 'build', 'dist', '__pycache__'}: + ignored.add(name) + elif name.endswith('.egg-info'): + ignored.add(name) + return ignored + + shutil.copytree(REPO_ROOT, stage_dir, ignore=ignore) + return stage_dir + + +def record_latest_testpypi_version(version: str) -> None: + STATE_DIR.mkdir(parents=True, exist_ok=True) + write_text(LATEST_TESTPYPI_VERSION_PATH, version + '\n') + + +def publish_testpypi_dev() -> int: + base_version = get_current_version() + existing_versions = fetch_testpypi_versions() + dev_version = get_next_dev_version(base_version, existing_versions) + publish_args = get_testpypi_publish_args() + + print(f'Base version: {base_version}') + print(f'Chosen dev build: {dev_version}') + + stage_dir = make_stage_dir() + try: + set_version_in_tree(stage_dir, dev_version) + dist_dir = stage_dir / 'dist' + run(['uv', 'build', '--out-dir', str(dist_dir)], cwd=stage_dir) + dist_glob = str(dist_dir / '*') + run( + [ + 'uv', + 'publish', + *publish_args, + dist_glob, + ], + cwd=stage_dir, + ) + finally: + shutil.rmtree(stage_dir.parent, ignore_errors=True) + + record_latest_testpypi_version(dev_version) + print() + print('Published to TestPyPI.') + print(f'Version: {dev_version}') + print(f'Recorded in: {LATEST_TESTPYPI_VERSION_PATH}') + print() + print('Next step:') + print(f' make release-smoke-testpypi RELEASE_SMOKE_VERSION={dev_version}') + return 0 + + +def publish_pypi() -> int: + version = get_current_version() + if '.dev' in version: + raise RuntimeError( + f'Refusing to publish dev version {version!r} to PyPI. Set pyproject.toml to the final version first.' + ) + publish_args = get_pypi_publish_args() + + print(f'Publishing final version to PyPI: {version}') + run(['uv', 'build'], cwd=REPO_ROOT) + run(['uv', 'publish', *publish_args], cwd=REPO_ROOT) + return 0 + + +def print_latest_testpypi_version() -> int: + if not LATEST_TESTPYPI_VERSION_PATH.exists(): + raise RuntimeError( + f'No recorded TestPyPI version found at {LATEST_TESTPYPI_VERSION_PATH}. ' + 'Run make release-publish-testpypi-dev first.' + ) + + recorded_version = read_text(LATEST_TESTPYPI_VERSION_PATH).strip() + if not recorded_version: + raise RuntimeError( + f'Recorded TestPyPI version file is empty: {LATEST_TESTPYPI_VERSION_PATH}. ' + 'Run make release-publish-testpypi-dev first.' + ) + + published_versions = fetch_testpypi_versions() + if recorded_version not in published_versions: + raise RuntimeError( + f'Recorded TestPyPI version {recorded_version!r} from {LATEST_TESTPYPI_VERSION_PATH} ' + 'was not found on TestPyPI. The local record is stale or the upload did not complete. ' + 'Run make release-publish-testpypi-dev again, or smoke-test an explicit published ' + 'version with make release-smoke-testpypi RELEASE_SMOKE_VERSION=.' + ) + + sys.stdout.write(recorded_version) + sys.stdout.write('\n') + return 0 + + +def print_current_version() -> int: + sys.stdout.write(get_current_version()) + sys.stdout.write('\n') + return 0 + + +def print_testpypi_wheel_url(version: str) -> int: + sys.stdout.write(get_testpypi_file_url(version, packagetype='bdist_wheel')) + sys.stdout.write('\n') + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest='command', required=True) + + subparsers.add_parser('publish-testpypi-dev') + subparsers.add_parser('publish-pypi') + subparsers.add_parser('print-current-version') + subparsers.add_parser('print-latest-testpypi-version') + wheel_url_parser = subparsers.add_parser('print-testpypi-wheel-url') + wheel_url_parser.add_argument('version') + + args = parser.parse_args(argv) + + if args.command == 'publish-testpypi-dev': + return publish_testpypi_dev() + if args.command == 'publish-pypi': + return publish_pypi() + if args.command == 'print-current-version': + return print_current_version() + if args.command == 'print-latest-testpypi-version': + return print_latest_testpypi_version() + if args.command == 'print-testpypi-wheel-url': + return print_testpypi_wheel_url(args.version) + raise AssertionError(f'Unknown command: {args.command}') + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/scripts/release_smoke_test.sh b/scripts/release_smoke_test.sh new file mode 100644 index 000000000..d7d2e57c6 --- /dev/null +++ b/scripts/release_smoke_test.sh @@ -0,0 +1,186 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + scripts/release_smoke_test.sh local + scripts/release_smoke_test.sh pypi + scripts/release_smoke_test.sh open + +Environment variables: + PYTHON Python executable to use for venv creation (default: python3) + UV uv executable to use for venv/package management (default: uv) + RELEASE_SMOKE_DATASET Dataset directory containing params.py + (default: ../phy-data/template) + RELEASE_SMOKE_ENV Virtualenv directory to create/use + RELEASE_SMOKE_VERSION Version to install in pypi mode + RELEASE_SMOKE_INDEX_URL Optional --index-url value for pypi mode + RELEASE_SMOKE_EXTRA_INDEX_URL + Optional --extra-index-url value for pypi mode +EOF +} + +if [[ $# -ne 1 ]]; then + usage + exit 1 +fi + +MODE="$1" +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PYTHON_BIN="${PYTHON:-python3}" +UV_BIN="${UV:-uv}" +DATASET_DIR="${RELEASE_SMOKE_DATASET:-$REPO_ROOT/../phy-data/template}" + +case "$MODE" in + local) + ENV_DIR="${RELEASE_SMOKE_ENV:-$REPO_ROOT/.release-smoke/local}" + ;; + pypi) + VERSION="${RELEASE_SMOKE_VERSION:-}" + if [[ -z "$VERSION" ]]; then + echo "RELEASE_SMOKE_VERSION must be set in pypi mode." >&2 + exit 1 + fi + ENV_DIR="${RELEASE_SMOKE_ENV:-$REPO_ROOT/.release-smoke/pypi-$VERSION}" + ;; + open) + ENV_DIR="${RELEASE_SMOKE_ENV:-$REPO_ROOT/.release-smoke/local}" + ;; + *) + usage + exit 1 + ;; +esac + +PARAMS_PATH="$DATASET_DIR/params.py" + +if [[ ! -f "$PARAMS_PATH" ]]; then + echo "Dataset params file not found: $PARAMS_PATH" >&2 + exit 1 +fi + +resolve_env_paths() { + if [[ -x "$ENV_DIR/bin/python" ]]; then + ENV_PYTHON="$ENV_DIR/bin/python" + ENV_PHY="$ENV_DIR/bin/phy" + else + ENV_PYTHON="$ENV_DIR/Scripts/python.exe" + ENV_PHY="$ENV_DIR/Scripts/phy.exe" + fi +} + +make_venv() { + rm -rf "$ENV_DIR" + mkdir -p "$(dirname "$ENV_DIR")" + "$UV_BIN" venv --python "$PYTHON_BIN" "$ENV_DIR" + resolve_env_paths +} + +require_env() { + resolve_env_paths + if [[ ! -x "$ENV_PYTHON" || ! -x "$ENV_PHY" ]]; then + echo "Virtualenv not found or incomplete: $ENV_DIR" >&2 + echo "Run the matching smoke target first." >&2 + exit 1 + fi +} + +install_local() { + local wheel + + wheel="$(ls -t "$REPO_ROOT"/dist/phy-*.whl 2>/dev/null | head -n 1 || true)" + if [[ -z "$wheel" ]]; then + echo "No wheel found under dist/. Run 'make build' first." >&2 + exit 1 + fi + + "$UV_BIN" pip install --python "$ENV_PYTHON" "$wheel" +} + +install_pypi() { + local -a pip_args + local package_spec + local testpypi_wheel_url + + pip_args=() + if [[ -n "${RELEASE_SMOKE_INDEX_URL:-}" ]]; then + pip_args+=(--index-url "$RELEASE_SMOKE_INDEX_URL") + fi + if [[ -n "${RELEASE_SMOKE_EXTRA_INDEX_URL:-}" ]]; then + pip_args+=(--extra-index-url "$RELEASE_SMOKE_EXTRA_INDEX_URL") + # TestPyPI smoke installs need uv to consider versions across both indexes. + pip_args+=(--index-strategy unsafe-best-match) + fi + + package_spec="phy==$VERSION" + + # uv fails to resolve the top-level disposable TestPyPI dev release by version + # even when the wheel is published, so install the exact published artifact URL + # and keep dependency resolution on the configured indexes. + if [[ "${RELEASE_SMOKE_INDEX_URL:-}" == "https://test.pypi.org/simple/" ]]; then + testpypi_wheel_url="$("$PYTHON_BIN" "$REPO_ROOT/scripts/release_publish.py" \ + print-testpypi-wheel-url "$VERSION")" + package_spec="$testpypi_wheel_url" + pip_args=() + if [[ -n "${RELEASE_SMOKE_EXTRA_INDEX_URL:-}" ]]; then + pip_args+=(--default-index "$RELEASE_SMOKE_EXTRA_INDEX_URL") + pip_args+=(--index "$RELEASE_SMOKE_INDEX_URL") + pip_args+=(--index-strategy unsafe-best-match) + else + pip_args+=(--default-index "$RELEASE_SMOKE_INDEX_URL") + fi + fi + + "$UV_BIN" pip install --python "$ENV_PYTHON" "${pip_args[@]}" "$package_spec" +} + +verify_install() { + ( + cd "$DATASET_DIR" + "$ENV_PYTHON" -c "import phy, phylib; print('phy', phy.__version__); print('phylib', phylib.__version__)" + "$ENV_PYTHON" -c "import PyQt5; print('PyQt5', PyQt5.__file__)" + "$ENV_PHY" --version + "$ENV_PHY" template-describe "$PARAMS_PATH" + ) +} + +open_gui() { + require_env + echo "Launching phy on $PARAMS_PATH" + cd "$DATASET_DIR" + exec "$ENV_PHY" template-gui "$PARAMS_PATH" +} + +print_next_steps() { + cat <= '3.11'", + "python_full_version < '3.11'", + "python_version < '0'", +] + +[[package]] +name = "appnope" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/5d/752690df9ef5b76e169e68d6a129fa6d08a7100ca7f754c89495db3c6019/appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee", size = 4170, upload-time = "2024-02-06T09:43:11.258Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321, upload-time = "2024-02-06T09:43:09.663Z" }, +] + +[[package]] +name = "asttokens" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/e7/82da0a03e7ba5141f05cce0d302e6eed121ae055e0456ca228bf693984bc/asttokens-3.0.0.tar.gz", hash = "sha256:0dcd8baa8d62b0c1d118b399b2ddba3c4aff271d0d7a9e0d4c1681c79035bbc7", size = 61978, upload-time = "2024-11-30T04:30:14.439Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/8a/c46dcc25341b5bce5472c718902eb3d38600a903b14fa6aeecef3f21a46f/asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2", size = 26918, upload-time = "2024-11-30T04:30:10.946Z" }, +] + +[[package]] +name = "certifi" +version = "2025.4.26" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/9e/c05b3920a3b7d20d3d3310465f50348e5b3694f4f88c6daf736eef3024c4/certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6", size = 160705, upload-time = "2025-04-26T02:12:29.51Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618, upload-time = "2025-04-26T02:12:27.662Z" }, +] + +[[package]] +name = "cffi" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621, upload-time = "2024-09-04T20:45:21.852Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/07/f44ca684db4e4f08a3fdc6eeb9a0d15dc6883efc7b8c90357fdbf74e186c/cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14", size = 182191, upload-time = "2024-09-04T20:43:30.027Z" }, + { url = "https://files.pythonhosted.org/packages/08/fd/cc2fedbd887223f9f5d170c96e57cbf655df9831a6546c1727ae13fa977a/cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67", size = 178592, upload-time = "2024-09-04T20:43:32.108Z" }, + { url = "https://files.pythonhosted.org/packages/de/cc/4635c320081c78d6ffc2cab0a76025b691a91204f4aa317d568ff9280a2d/cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", size = 426024, upload-time = "2024-09-04T20:43:34.186Z" }, + { url = "https://files.pythonhosted.org/packages/b6/7b/3b2b250f3aab91abe5f8a51ada1b717935fdaec53f790ad4100fe2ec64d1/cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", size = 448188, upload-time = "2024-09-04T20:43:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/d3/48/1b9283ebbf0ec065148d8de05d647a986c5f22586b18120020452fff8f5d/cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3", size = 455571, upload-time = "2024-09-04T20:43:38.586Z" }, + { url = "https://files.pythonhosted.org/packages/40/87/3b8452525437b40f39ca7ff70276679772ee7e8b394934ff60e63b7b090c/cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6", size = 436687, upload-time = "2024-09-04T20:43:40.084Z" }, + { url = "https://files.pythonhosted.org/packages/8d/fb/4da72871d177d63649ac449aec2e8a29efe0274035880c7af59101ca2232/cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17", size = 446211, upload-time = "2024-09-04T20:43:41.526Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a0/62f00bcb411332106c02b663b26f3545a9ef136f80d5df746c05878f8c4b/cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", size = 461325, upload-time = "2024-09-04T20:43:43.117Z" }, + { url = "https://files.pythonhosted.org/packages/36/83/76127035ed2e7e27b0787604d99da630ac3123bfb02d8e80c633f218a11d/cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", size = 438784, upload-time = "2024-09-04T20:43:45.256Z" }, + { url = "https://files.pythonhosted.org/packages/21/81/a6cd025db2f08ac88b901b745c163d884641909641f9b826e8cb87645942/cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", size = 461564, upload-time = "2024-09-04T20:43:46.779Z" }, + { url = "https://files.pythonhosted.org/packages/f8/fe/4d41c2f200c4a457933dbd98d3cf4e911870877bd94d9656cc0fcb390681/cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c", size = 171804, upload-time = "2024-09-04T20:43:48.186Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b6/0b0f5ab93b0df4acc49cae758c81fe4e5ef26c3ae2e10cc69249dfd8b3ab/cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15", size = 181299, upload-time = "2024-09-04T20:43:49.812Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f4/927e3a8899e52a27fa57a48607ff7dc91a9ebe97399b357b85a0c7892e00/cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", size = 182264, upload-time = "2024-09-04T20:43:51.124Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f5/6c3a8efe5f503175aaddcbea6ad0d2c96dad6f5abb205750d1b3df44ef29/cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", size = 178651, upload-time = "2024-09-04T20:43:52.872Z" }, + { url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259, upload-time = "2024-09-04T20:43:56.123Z" }, + { url = "https://files.pythonhosted.org/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", size = 469200, upload-time = "2024-09-04T20:43:57.891Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a0/a4fa9f4f781bda074c3ddd57a572b060fa0df7655d2a4247bbe277200146/cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", size = 477235, upload-time = "2024-09-04T20:44:00.18Z" }, + { url = "https://files.pythonhosted.org/packages/62/12/ce8710b5b8affbcdd5c6e367217c242524ad17a02fe5beec3ee339f69f85/cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", size = 459721, upload-time = "2024-09-04T20:44:01.585Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6b/d45873c5e0242196f042d555526f92aa9e0c32355a1be1ff8c27f077fd37/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", size = 467242, upload-time = "2024-09-04T20:44:03.467Z" }, + { url = "https://files.pythonhosted.org/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", size = 477999, upload-time = "2024-09-04T20:44:05.023Z" }, + { url = "https://files.pythonhosted.org/packages/44/74/f2a2460684a1a2d00ca799ad880d54652841a780c4c97b87754f660c7603/cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", size = 454242, upload-time = "2024-09-04T20:44:06.444Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b", size = 478604, upload-time = "2024-09-04T20:44:08.206Z" }, + { url = "https://files.pythonhosted.org/packages/34/33/e1b8a1ba29025adbdcda5fb3a36f94c03d771c1b7b12f726ff7fef2ebe36/cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", size = 171727, upload-time = "2024-09-04T20:44:09.481Z" }, + { url = "https://files.pythonhosted.org/packages/3d/97/50228be003bb2802627d28ec0627837ac0bf35c90cf769812056f235b2d1/cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", size = 181400, upload-time = "2024-09-04T20:44:10.873Z" }, + { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178, upload-time = "2024-09-04T20:44:12.232Z" }, + { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840, upload-time = "2024-09-04T20:44:13.739Z" }, + { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803, upload-time = "2024-09-04T20:44:15.231Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850, upload-time = "2024-09-04T20:44:17.188Z" }, + { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729, upload-time = "2024-09-04T20:44:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256, upload-time = "2024-09-04T20:44:20.248Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424, upload-time = "2024-09-04T20:44:21.673Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568, upload-time = "2024-09-04T20:44:23.245Z" }, + { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736, upload-time = "2024-09-04T20:44:24.757Z" }, + { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448, upload-time = "2024-09-04T20:44:26.208Z" }, + { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976, upload-time = "2024-09-04T20:44:27.578Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989, upload-time = "2024-09-04T20:44:28.956Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802, upload-time = "2024-09-04T20:44:30.289Z" }, + { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792, upload-time = "2024-09-04T20:44:32.01Z" }, + { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893, upload-time = "2024-09-04T20:44:33.606Z" }, + { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810, upload-time = "2024-09-04T20:44:35.191Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200, upload-time = "2024-09-04T20:44:36.743Z" }, + { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447, upload-time = "2024-09-04T20:44:38.492Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358, upload-time = "2024-09-04T20:44:40.046Z" }, + { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469, upload-time = "2024-09-04T20:44:41.616Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475, upload-time = "2024-09-04T20:44:43.733Z" }, + { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009, upload-time = "2024-09-04T20:44:45.309Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367, upload-time = "2025-05-02T08:34:42.01Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/28/9901804da60055b406e1a1c5ba7aac1276fb77f1dde635aabfc7fd84b8ab/charset_normalizer-3.4.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941", size = 201818, upload-time = "2025-05-02T08:31:46.725Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9b/892a8c8af9110935e5adcbb06d9c6fe741b6bb02608c6513983048ba1a18/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd", size = 144649, upload-time = "2025-05-02T08:31:48.889Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a5/4179abd063ff6414223575e008593861d62abfc22455b5d1a44995b7c101/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6", size = 155045, upload-time = "2025-05-02T08:31:50.757Z" }, + { url = "https://files.pythonhosted.org/packages/3b/95/bc08c7dfeddd26b4be8c8287b9bb055716f31077c8b0ea1cd09553794665/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d", size = 147356, upload-time = "2025-05-02T08:31:52.634Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2d/7a5b635aa65284bf3eab7653e8b4151ab420ecbae918d3e359d1947b4d61/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86", size = 149471, upload-time = "2025-05-02T08:31:56.207Z" }, + { url = "https://files.pythonhosted.org/packages/ae/38/51fc6ac74251fd331a8cfdb7ec57beba8c23fd5493f1050f71c87ef77ed0/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c", size = 151317, upload-time = "2025-05-02T08:31:57.613Z" }, + { url = "https://files.pythonhosted.org/packages/b7/17/edee1e32215ee6e9e46c3e482645b46575a44a2d72c7dfd49e49f60ce6bf/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0", size = 146368, upload-time = "2025-05-02T08:31:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/26/2c/ea3e66f2b5f21fd00b2825c94cafb8c326ea6240cd80a91eb09e4a285830/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef", size = 154491, upload-time = "2025-05-02T08:32:01.219Z" }, + { url = "https://files.pythonhosted.org/packages/52/47/7be7fa972422ad062e909fd62460d45c3ef4c141805b7078dbab15904ff7/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6", size = 157695, upload-time = "2025-05-02T08:32:03.045Z" }, + { url = "https://files.pythonhosted.org/packages/2f/42/9f02c194da282b2b340f28e5fb60762de1151387a36842a92b533685c61e/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366", size = 154849, upload-time = "2025-05-02T08:32:04.651Z" }, + { url = "https://files.pythonhosted.org/packages/67/44/89cacd6628f31fb0b63201a618049be4be2a7435a31b55b5eb1c3674547a/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db", size = 150091, upload-time = "2025-05-02T08:32:06.719Z" }, + { url = "https://files.pythonhosted.org/packages/1f/79/4b8da9f712bc079c0f16b6d67b099b0b8d808c2292c937f267d816ec5ecc/charset_normalizer-3.4.2-cp310-cp310-win32.whl", hash = "sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a", size = 98445, upload-time = "2025-05-02T08:32:08.66Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d7/96970afb4fb66497a40761cdf7bd4f6fca0fc7bafde3a84f836c1f57a926/charset_normalizer-3.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509", size = 105782, upload-time = "2025-05-02T08:32:10.46Z" }, + { url = "https://files.pythonhosted.org/packages/05/85/4c40d00dcc6284a1c1ad5de5e0996b06f39d8232f1031cd23c2f5c07ee86/charset_normalizer-3.4.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2", size = 198794, upload-time = "2025-05-02T08:32:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/41/d9/7a6c0b9db952598e97e93cbdfcb91bacd89b9b88c7c983250a77c008703c/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645", size = 142846, upload-time = "2025-05-02T08:32:13.946Z" }, + { url = "https://files.pythonhosted.org/packages/66/82/a37989cda2ace7e37f36c1a8ed16c58cf48965a79c2142713244bf945c89/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd", size = 153350, upload-time = "2025-05-02T08:32:15.873Z" }, + { url = "https://files.pythonhosted.org/packages/df/68/a576b31b694d07b53807269d05ec3f6f1093e9545e8607121995ba7a8313/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8", size = 145657, upload-time = "2025-05-02T08:32:17.283Z" }, + { url = "https://files.pythonhosted.org/packages/92/9b/ad67f03d74554bed3aefd56fe836e1623a50780f7c998d00ca128924a499/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f", size = 147260, upload-time = "2025-05-02T08:32:18.807Z" }, + { url = "https://files.pythonhosted.org/packages/a6/e6/8aebae25e328160b20e31a7e9929b1578bbdc7f42e66f46595a432f8539e/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7", size = 149164, upload-time = "2025-05-02T08:32:20.333Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f2/b3c2f07dbcc248805f10e67a0262c93308cfa149a4cd3d1fe01f593e5fd2/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9", size = 144571, upload-time = "2025-05-02T08:32:21.86Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/c3f3a94bc345bc211622ea59b4bed9ae63c00920e2e8f11824aa5708e8b7/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544", size = 151952, upload-time = "2025-05-02T08:32:23.434Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4d/ff460c8b474122334c2fa394a3f99a04cf11c646da895f81402ae54f5c42/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82", size = 155959, upload-time = "2025-05-02T08:32:24.993Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2b/b964c6a2fda88611a1fe3d4c400d39c66a42d6c169c924818c848f922415/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0", size = 153030, upload-time = "2025-05-02T08:32:26.435Z" }, + { url = "https://files.pythonhosted.org/packages/59/2e/d3b9811db26a5ebf444bc0fa4f4be5aa6d76fc6e1c0fd537b16c14e849b6/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5", size = 148015, upload-time = "2025-05-02T08:32:28.376Z" }, + { url = "https://files.pythonhosted.org/packages/90/07/c5fd7c11eafd561bb51220d600a788f1c8d77c5eef37ee49454cc5c35575/charset_normalizer-3.4.2-cp311-cp311-win32.whl", hash = "sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a", size = 98106, upload-time = "2025-05-02T08:32:30.281Z" }, + { url = "https://files.pythonhosted.org/packages/a8/05/5e33dbef7e2f773d672b6d79f10ec633d4a71cd96db6673625838a4fd532/charset_normalizer-3.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28", size = 105402, upload-time = "2025-05-02T08:32:32.191Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a4/37f4d6035c89cac7930395a35cc0f1b872e652eaafb76a6075943754f095/charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7", size = 199936, upload-time = "2025-05-02T08:32:33.712Z" }, + { url = "https://files.pythonhosted.org/packages/ee/8a/1a5e33b73e0d9287274f899d967907cd0bf9c343e651755d9307e0dbf2b3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3", size = 143790, upload-time = "2025-05-02T08:32:35.768Z" }, + { url = "https://files.pythonhosted.org/packages/66/52/59521f1d8e6ab1482164fa21409c5ef44da3e9f653c13ba71becdd98dec3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a", size = 153924, upload-time = "2025-05-02T08:32:37.284Z" }, + { url = "https://files.pythonhosted.org/packages/86/2d/fb55fdf41964ec782febbf33cb64be480a6b8f16ded2dbe8db27a405c09f/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214", size = 146626, upload-time = "2025-05-02T08:32:38.803Z" }, + { url = "https://files.pythonhosted.org/packages/8c/73/6ede2ec59bce19b3edf4209d70004253ec5f4e319f9a2e3f2f15601ed5f7/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a", size = 148567, upload-time = "2025-05-02T08:32:40.251Z" }, + { url = "https://files.pythonhosted.org/packages/09/14/957d03c6dc343c04904530b6bef4e5efae5ec7d7990a7cbb868e4595ee30/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd", size = 150957, upload-time = "2025-05-02T08:32:41.705Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c8/8174d0e5c10ccebdcb1b53cc959591c4c722a3ad92461a273e86b9f5a302/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981", size = 145408, upload-time = "2025-05-02T08:32:43.709Z" }, + { url = "https://files.pythonhosted.org/packages/58/aa/8904b84bc8084ac19dc52feb4f5952c6df03ffb460a887b42615ee1382e8/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c", size = 153399, upload-time = "2025-05-02T08:32:46.197Z" }, + { url = "https://files.pythonhosted.org/packages/c2/26/89ee1f0e264d201cb65cf054aca6038c03b1a0c6b4ae998070392a3ce605/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b", size = 156815, upload-time = "2025-05-02T08:32:48.105Z" }, + { url = "https://files.pythonhosted.org/packages/fd/07/68e95b4b345bad3dbbd3a8681737b4338ff2c9df29856a6d6d23ac4c73cb/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d", size = 154537, upload-time = "2025-05-02T08:32:49.719Z" }, + { url = "https://files.pythonhosted.org/packages/77/1a/5eefc0ce04affb98af07bc05f3bac9094513c0e23b0562d64af46a06aae4/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f", size = 149565, upload-time = "2025-05-02T08:32:51.404Z" }, + { url = "https://files.pythonhosted.org/packages/37/a0/2410e5e6032a174c95e0806b1a6585eb21e12f445ebe239fac441995226a/charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c", size = 98357, upload-time = "2025-05-02T08:32:53.079Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4f/c02d5c493967af3eda9c771ad4d2bbc8df6f99ddbeb37ceea6e8716a32bc/charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e", size = 105776, upload-time = "2025-05-02T08:32:54.573Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", size = 199622, upload-time = "2025-05-02T08:32:56.363Z" }, + { url = "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", size = 143435, upload-time = "2025-05-02T08:32:58.551Z" }, + { url = "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", size = 153653, upload-time = "2025-05-02T08:33:00.342Z" }, + { url = "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", size = 146231, upload-time = "2025-05-02T08:33:02.081Z" }, + { url = "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", size = 148243, upload-time = "2025-05-02T08:33:04.063Z" }, + { url = "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", size = 150442, upload-time = "2025-05-02T08:33:06.418Z" }, + { url = "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", size = 145147, upload-time = "2025-05-02T08:33:08.183Z" }, + { url = "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", size = 153057, upload-time = "2025-05-02T08:33:09.986Z" }, + { url = "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", size = 156454, upload-time = "2025-05-02T08:33:11.814Z" }, + { url = "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", size = 154174, upload-time = "2025-05-02T08:33:13.707Z" }, + { url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166, upload-time = "2025-05-02T08:33:15.458Z" }, + { url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064, upload-time = "2025-05-02T08:33:17.06Z" }, + { url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641, upload-time = "2025-05-02T08:33:18.753Z" }, + { url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626, upload-time = "2025-05-02T08:34:40.053Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "cloudpickle" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/39/069100b84d7418bc358d81669d5748efb14b9cceacd2f9c75f550424132f/cloudpickle-3.1.1.tar.gz", hash = "sha256:b216fa8ae4019d5482a8ac3c95d8f6346115d8835911fd4aefd1a445e4242c64", size = 22113, upload-time = "2025-01-14T17:02:05.085Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/e8/64c37fadfc2816a7701fa8a6ed8d87327c7d54eacfbfb6edab14a2f2be75/cloudpickle-3.1.1-py3-none-any.whl", hash = "sha256:c8c5a44295039331ee9dad40ba100a9c7297b6f988e50e87ccdf3765a668350e", size = 20992, upload-time = "2025-01-14T17:02:02.417Z" }, +] + +[[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 = "colorcet" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/c3/ae78e10b7139d6b7ce080d2e81d822715763336aa4229720f49cb3b3e15b/colorcet-3.1.0.tar.gz", hash = "sha256:2921b3cd81a2288aaf2d63dbc0ce3c26dcd882e8c389cc505d6886bf7aa9a4eb", size = 2183107, upload-time = "2024-02-29T19:15:42.976Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/c6/9963d588cc3d75d766c819e0377a168ef83cf3316a92769971527a1ad1de/colorcet-3.1.0-py3-none-any.whl", hash = "sha256:2a7d59cc8d0f7938eeedd08aad3152b5319b4ba3bcb7a612398cc17a384cb296", size = 260286, upload-time = "2024-02-29T19:15:40.494Z" }, +] + +[[package]] +name = "comm" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/a8/fb783cb0abe2b5fded9f55e5703015cdf1c9c85b3669087c538dd15a6a86/comm-0.2.2.tar.gz", hash = "sha256:3fd7a84065306e07bea1773df6eb8282de51ba82f77c72f9c85716ab11fe980e", size = 6210, upload-time = "2024-03-12T16:53:41.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/75/49e5bfe642f71f272236b5b2d2691cf915a7283cc0ceda56357b61daa538/comm-0.2.2-py3-none-any.whl", hash = "sha256:e6fb86cb70ff661ee8c9c14e7d36d6de3b4066f1441be4063df9c5009f0a64d3", size = 7180, upload-time = "2024-03-12T16:53:39.226Z" }, +] + +[[package]] +name = "contourpy" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/a3/da4153ec8fe25d263aa48c1a4cbde7f49b59af86f0b6f7862788c60da737/contourpy-1.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ba38e3f9f330af820c4b27ceb4b9c7feee5fe0493ea53a8720f4792667465934", size = 268551, upload-time = "2025-04-15T17:34:46.581Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6c/330de89ae1087eb622bfca0177d32a7ece50c3ef07b28002de4757d9d875/contourpy-1.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dc41ba0714aa2968d1f8674ec97504a8f7e334f48eeacebcaa6256213acb0989", size = 253399, upload-time = "2025-04-15T17:34:51.427Z" }, + { url = "https://files.pythonhosted.org/packages/c1/bd/20c6726b1b7f81a8bee5271bed5c165f0a8e1f572578a9d27e2ccb763cb2/contourpy-1.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9be002b31c558d1ddf1b9b415b162c603405414bacd6932d031c5b5a8b757f0d", size = 312061, upload-time = "2025-04-15T17:34:55.961Z" }, + { url = "https://files.pythonhosted.org/packages/22/fc/a9665c88f8a2473f823cf1ec601de9e5375050f1958cbb356cdf06ef1ab6/contourpy-1.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8d2e74acbcba3bfdb6d9d8384cdc4f9260cae86ed9beee8bd5f54fee49a430b9", size = 351956, upload-time = "2025-04-15T17:35:00.992Z" }, + { url = "https://files.pythonhosted.org/packages/25/eb/9f0a0238f305ad8fb7ef42481020d6e20cf15e46be99a1fcf939546a177e/contourpy-1.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e259bced5549ac64410162adc973c5e2fb77f04df4a439d00b478e57a0e65512", size = 320872, upload-time = "2025-04-15T17:35:06.177Z" }, + { url = "https://files.pythonhosted.org/packages/32/5c/1ee32d1c7956923202f00cf8d2a14a62ed7517bdc0ee1e55301227fc273c/contourpy-1.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad687a04bc802cbe8b9c399c07162a3c35e227e2daccf1668eb1f278cb698631", size = 325027, upload-time = "2025-04-15T17:35:11.244Z" }, + { url = "https://files.pythonhosted.org/packages/83/bf/9baed89785ba743ef329c2b07fd0611d12bfecbedbdd3eeecf929d8d3b52/contourpy-1.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cdd22595308f53ef2f891040ab2b93d79192513ffccbd7fe19be7aa773a5e09f", size = 1306641, upload-time = "2025-04-15T17:35:26.701Z" }, + { url = "https://files.pythonhosted.org/packages/d4/cc/74e5e83d1e35de2d28bd97033426b450bc4fd96e092a1f7a63dc7369b55d/contourpy-1.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b4f54d6a2defe9f257327b0f243612dd051cc43825587520b1bf74a31e2f6ef2", size = 1374075, upload-time = "2025-04-15T17:35:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/0c/42/17f3b798fd5e033b46a16f8d9fcb39f1aba051307f5ebf441bad1ecf78f8/contourpy-1.3.2-cp310-cp310-win32.whl", hash = "sha256:f939a054192ddc596e031e50bb13b657ce318cf13d264f095ce9db7dc6ae81c0", size = 177534, upload-time = "2025-04-15T17:35:46.554Z" }, + { url = "https://files.pythonhosted.org/packages/54/ec/5162b8582f2c994721018d0c9ece9dc6ff769d298a8ac6b6a652c307e7df/contourpy-1.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:c440093bbc8fc21c637c03bafcbef95ccd963bc6e0514ad887932c18ca2a759a", size = 221188, upload-time = "2025-04-15T17:35:50.064Z" }, + { url = "https://files.pythonhosted.org/packages/b3/b9/ede788a0b56fc5b071639d06c33cb893f68b1178938f3425debebe2dab78/contourpy-1.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a37a2fb93d4df3fc4c0e363ea4d16f83195fc09c891bc8ce072b9d084853445", size = 269636, upload-time = "2025-04-15T17:35:54.473Z" }, + { url = "https://files.pythonhosted.org/packages/e6/75/3469f011d64b8bbfa04f709bfc23e1dd71be54d05b1b083be9f5b22750d1/contourpy-1.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b7cd50c38f500bbcc9b6a46643a40e0913673f869315d8e70de0438817cb7773", size = 254636, upload-time = "2025-04-15T17:35:58.283Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2f/95adb8dae08ce0ebca4fd8e7ad653159565d9739128b2d5977806656fcd2/contourpy-1.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6658ccc7251a4433eebd89ed2672c2ed96fba367fd25ca9512aa92a4b46c4f1", size = 313053, upload-time = "2025-04-15T17:36:03.235Z" }, + { url = "https://files.pythonhosted.org/packages/c3/a6/8ccf97a50f31adfa36917707fe39c9a0cbc24b3bbb58185577f119736cc9/contourpy-1.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:70771a461aaeb335df14deb6c97439973d253ae70660ca085eec25241137ef43", size = 352985, upload-time = "2025-04-15T17:36:08.275Z" }, + { url = "https://files.pythonhosted.org/packages/1d/b6/7925ab9b77386143f39d9c3243fdd101621b4532eb126743201160ffa7e6/contourpy-1.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65a887a6e8c4cd0897507d814b14c54a8c2e2aa4ac9f7686292f9769fcf9a6ab", size = 323750, upload-time = "2025-04-15T17:36:13.29Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f3/20c5d1ef4f4748e52d60771b8560cf00b69d5c6368b5c2e9311bcfa2a08b/contourpy-1.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3859783aefa2b8355697f16642695a5b9792e7a46ab86da1118a4a23a51a33d7", size = 326246, upload-time = "2025-04-15T17:36:18.329Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e5/9dae809e7e0b2d9d70c52b3d24cba134dd3dad979eb3e5e71f5df22ed1f5/contourpy-1.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eab0f6db315fa4d70f1d8ab514e527f0366ec021ff853d7ed6a2d33605cf4b83", size = 1308728, upload-time = "2025-04-15T17:36:33.878Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/0058ba34aeea35c0b442ae61a4f4d4ca84d6df8f91309bc2d43bb8dd248f/contourpy-1.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d91a3ccc7fea94ca0acab82ceb77f396d50a1f67412efe4c526f5d20264e6ecd", size = 1375762, upload-time = "2025-04-15T17:36:51.295Z" }, + { url = "https://files.pythonhosted.org/packages/09/33/7174bdfc8b7767ef2c08ed81244762d93d5c579336fc0b51ca57b33d1b80/contourpy-1.3.2-cp311-cp311-win32.whl", hash = "sha256:1c48188778d4d2f3d48e4643fb15d8608b1d01e4b4d6b0548d9b336c28fc9b6f", size = 178196, upload-time = "2025-04-15T17:36:55.002Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fe/4029038b4e1c4485cef18e480b0e2cd2d755448bb071eb9977caac80b77b/contourpy-1.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:5ebac872ba09cb8f2131c46b8739a7ff71de28a24c869bcad554477eb089a878", size = 222017, upload-time = "2025-04-15T17:36:58.576Z" }, + { url = "https://files.pythonhosted.org/packages/34/f7/44785876384eff370c251d58fd65f6ad7f39adce4a093c934d4a67a7c6b6/contourpy-1.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4caf2bcd2969402bf77edc4cb6034c7dd7c0803213b3523f111eb7460a51b8d2", size = 271580, upload-time = "2025-04-15T17:37:03.105Z" }, + { url = "https://files.pythonhosted.org/packages/93/3b/0004767622a9826ea3d95f0e9d98cd8729015768075d61f9fea8eeca42a8/contourpy-1.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:82199cb78276249796419fe36b7386bd8d2cc3f28b3bc19fe2454fe2e26c4c15", size = 255530, upload-time = "2025-04-15T17:37:07.026Z" }, + { url = "https://files.pythonhosted.org/packages/e7/bb/7bd49e1f4fa805772d9fd130e0d375554ebc771ed7172f48dfcd4ca61549/contourpy-1.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:106fab697af11456fcba3e352ad50effe493a90f893fca6c2ca5c033820cea92", size = 307688, upload-time = "2025-04-15T17:37:11.481Z" }, + { url = "https://files.pythonhosted.org/packages/fc/97/e1d5dbbfa170725ef78357a9a0edc996b09ae4af170927ba8ce977e60a5f/contourpy-1.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d14f12932a8d620e307f715857107b1d1845cc44fdb5da2bc8e850f5ceba9f87", size = 347331, upload-time = "2025-04-15T17:37:18.212Z" }, + { url = "https://files.pythonhosted.org/packages/6f/66/e69e6e904f5ecf6901be3dd16e7e54d41b6ec6ae3405a535286d4418ffb4/contourpy-1.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:532fd26e715560721bb0d5fc7610fce279b3699b018600ab999d1be895b09415", size = 318963, upload-time = "2025-04-15T17:37:22.76Z" }, + { url = "https://files.pythonhosted.org/packages/a8/32/b8a1c8965e4f72482ff2d1ac2cd670ce0b542f203c8e1d34e7c3e6925da7/contourpy-1.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b383144cf2d2c29f01a1e8170f50dacf0eac02d64139dcd709a8ac4eb3cfe", size = 323681, upload-time = "2025-04-15T17:37:33.001Z" }, + { url = "https://files.pythonhosted.org/packages/30/c6/12a7e6811d08757c7162a541ca4c5c6a34c0f4e98ef2b338791093518e40/contourpy-1.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c49f73e61f1f774650a55d221803b101d966ca0c5a2d6d5e4320ec3997489441", size = 1308674, upload-time = "2025-04-15T17:37:48.64Z" }, + { url = "https://files.pythonhosted.org/packages/2a/8a/bebe5a3f68b484d3a2b8ffaf84704b3e343ef1addea528132ef148e22b3b/contourpy-1.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3d80b2c0300583228ac98d0a927a1ba6a2ba6b8a742463c564f1d419ee5b211e", size = 1380480, upload-time = "2025-04-15T17:38:06.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/db/fcd325f19b5978fb509a7d55e06d99f5f856294c1991097534360b307cf1/contourpy-1.3.2-cp312-cp312-win32.whl", hash = "sha256:90df94c89a91b7362e1142cbee7568f86514412ab8a2c0d0fca72d7e91b62912", size = 178489, upload-time = "2025-04-15T17:38:10.338Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/fadd0b92ffa7b5eb5949bf340a63a4a496a6930a6c37a7ba0f12acb076d6/contourpy-1.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:8c942a01d9163e2e5cfb05cb66110121b8d07ad438a17f9e766317bcb62abf73", size = 223042, upload-time = "2025-04-15T17:38:14.239Z" }, + { url = "https://files.pythonhosted.org/packages/2e/61/5673f7e364b31e4e7ef6f61a4b5121c5f170f941895912f773d95270f3a2/contourpy-1.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:de39db2604ae755316cb5967728f4bea92685884b1e767b7c24e983ef5f771cb", size = 271630, upload-time = "2025-04-15T17:38:19.142Z" }, + { url = "https://files.pythonhosted.org/packages/ff/66/a40badddd1223822c95798c55292844b7e871e50f6bfd9f158cb25e0bd39/contourpy-1.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3f9e896f447c5c8618f1edb2bafa9a4030f22a575ec418ad70611450720b5b08", size = 255670, upload-time = "2025-04-15T17:38:23.688Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c7/cf9fdee8200805c9bc3b148f49cb9482a4e3ea2719e772602a425c9b09f8/contourpy-1.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71e2bd4a1c4188f5c2b8d274da78faab884b59df20df63c34f74aa1813c4427c", size = 306694, upload-time = "2025-04-15T17:38:28.238Z" }, + { url = "https://files.pythonhosted.org/packages/dd/e7/ccb9bec80e1ba121efbffad7f38021021cda5be87532ec16fd96533bb2e0/contourpy-1.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de425af81b6cea33101ae95ece1f696af39446db9682a0b56daaa48cfc29f38f", size = 345986, upload-time = "2025-04-15T17:38:33.502Z" }, + { url = "https://files.pythonhosted.org/packages/dc/49/ca13bb2da90391fa4219fdb23b078d6065ada886658ac7818e5441448b78/contourpy-1.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:977e98a0e0480d3fe292246417239d2d45435904afd6d7332d8455981c408b85", size = 318060, upload-time = "2025-04-15T17:38:38.672Z" }, + { url = "https://files.pythonhosted.org/packages/c8/65/5245ce8c548a8422236c13ffcdcdada6a2a812c361e9e0c70548bb40b661/contourpy-1.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:434f0adf84911c924519d2b08fc10491dd282b20bdd3fa8f60fd816ea0b48841", size = 322747, upload-time = "2025-04-15T17:38:43.712Z" }, + { url = "https://files.pythonhosted.org/packages/72/30/669b8eb48e0a01c660ead3752a25b44fdb2e5ebc13a55782f639170772f9/contourpy-1.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c66c4906cdbc50e9cba65978823e6e00b45682eb09adbb78c9775b74eb222422", size = 1308895, upload-time = "2025-04-15T17:39:00.224Z" }, + { url = "https://files.pythonhosted.org/packages/05/5a/b569f4250decee6e8d54498be7bdf29021a4c256e77fe8138c8319ef8eb3/contourpy-1.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8b7fc0cd78ba2f4695fd0a6ad81a19e7e3ab825c31b577f384aa9d7817dc3bef", size = 1379098, upload-time = "2025-04-15T17:43:29.649Z" }, + { url = "https://files.pythonhosted.org/packages/19/ba/b227c3886d120e60e41b28740ac3617b2f2b971b9f601c835661194579f1/contourpy-1.3.2-cp313-cp313-win32.whl", hash = "sha256:15ce6ab60957ca74cff444fe66d9045c1fd3e92c8936894ebd1f3eef2fff075f", size = 178535, upload-time = "2025-04-15T17:44:44.532Z" }, + { url = "https://files.pythonhosted.org/packages/12/6e/2fed56cd47ca739b43e892707ae9a13790a486a3173be063681ca67d2262/contourpy-1.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e1578f7eafce927b168752ed7e22646dad6cd9bca673c60bff55889fa236ebf9", size = 223096, upload-time = "2025-04-15T17:44:48.194Z" }, + { url = "https://files.pythonhosted.org/packages/54/4c/e76fe2a03014a7c767d79ea35c86a747e9325537a8b7627e0e5b3ba266b4/contourpy-1.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0475b1f6604896bc7c53bb070e355e9321e1bc0d381735421a2d2068ec56531f", size = 285090, upload-time = "2025-04-15T17:43:34.084Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e2/5aba47debd55d668e00baf9651b721e7733975dc9fc27264a62b0dd26eb8/contourpy-1.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c85bb486e9be652314bb5b9e2e3b0d1b2e643d5eec4992c0fbe8ac71775da739", size = 268643, upload-time = "2025-04-15T17:43:38.626Z" }, + { url = "https://files.pythonhosted.org/packages/a1/37/cd45f1f051fe6230f751cc5cdd2728bb3a203f5619510ef11e732109593c/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:745b57db7758f3ffc05a10254edd3182a2a83402a89c00957a8e8a22f5582823", size = 310443, upload-time = "2025-04-15T17:43:44.522Z" }, + { url = "https://files.pythonhosted.org/packages/8b/a2/36ea6140c306c9ff6dd38e3bcec80b3b018474ef4d17eb68ceecd26675f4/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:970e9173dbd7eba9b4e01aab19215a48ee5dd3f43cef736eebde064a171f89a5", size = 349865, upload-time = "2025-04-15T17:43:49.545Z" }, + { url = "https://files.pythonhosted.org/packages/95/b7/2fc76bc539693180488f7b6cc518da7acbbb9e3b931fd9280504128bf956/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6c4639a9c22230276b7bffb6a850dfc8258a2521305e1faefe804d006b2e532", size = 321162, upload-time = "2025-04-15T17:43:54.203Z" }, + { url = "https://files.pythonhosted.org/packages/f4/10/76d4f778458b0aa83f96e59d65ece72a060bacb20cfbee46cf6cd5ceba41/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc829960f34ba36aad4302e78eabf3ef16a3a100863f0d4eeddf30e8a485a03b", size = 327355, upload-time = "2025-04-15T17:44:01.025Z" }, + { url = "https://files.pythonhosted.org/packages/43/a3/10cf483ea683f9f8ab096c24bad3cce20e0d1dd9a4baa0e2093c1c962d9d/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d32530b534e986374fc19eaa77fcb87e8a99e5431499949b828312bdcd20ac52", size = 1307935, upload-time = "2025-04-15T17:44:17.322Z" }, + { url = "https://files.pythonhosted.org/packages/78/73/69dd9a024444489e22d86108e7b913f3528f56cfc312b5c5727a44188471/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e298e7e70cf4eb179cc1077be1c725b5fd131ebc81181bf0c03525c8abc297fd", size = 1372168, upload-time = "2025-04-15T17:44:33.43Z" }, + { url = "https://files.pythonhosted.org/packages/0f/1b/96d586ccf1b1a9d2004dd519b25fbf104a11589abfd05484ff12199cca21/contourpy-1.3.2-cp313-cp313t-win32.whl", hash = "sha256:d0e589ae0d55204991450bb5c23f571c64fe43adaa53f93fc902a84c96f52fe1", size = 189550, upload-time = "2025-04-15T17:44:37.092Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e6/6000d0094e8a5e32ad62591c8609e269febb6e4db83a1c75ff8868b42731/contourpy-1.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:78e9253c3de756b3f6a5174d024c4835acd59eb3f8e2ca13e775dbffe1558f69", size = 238214, upload-time = "2025-04-15T17:44:40.827Z" }, + { url = "https://files.pythonhosted.org/packages/33/05/b26e3c6ecc05f349ee0013f0bb850a761016d89cec528a98193a48c34033/contourpy-1.3.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:fd93cc7f3139b6dd7aab2f26a90dde0aa9fc264dbf70f6740d498a70b860b82c", size = 265681, upload-time = "2025-04-15T17:44:59.314Z" }, + { url = "https://files.pythonhosted.org/packages/2b/25/ac07d6ad12affa7d1ffed11b77417d0a6308170f44ff20fa1d5aa6333f03/contourpy-1.3.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:107ba8a6a7eec58bb475329e6d3b95deba9440667c4d62b9b6063942b61d7f16", size = 315101, upload-time = "2025-04-15T17:45:04.165Z" }, + { url = "https://files.pythonhosted.org/packages/8f/4d/5bb3192bbe9d3f27e3061a6a8e7733c9120e203cb8515767d30973f71030/contourpy-1.3.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ded1706ed0c1049224531b81128efbd5084598f18d8a2d9efae833edbd2b40ad", size = 220599, upload-time = "2025-04-15T17:45:08.456Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c0/91f1215d0d9f9f343e4773ba6c9b89e8c0cc7a64a6263f21139da639d848/contourpy-1.3.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5f5964cdad279256c084b69c3f412b7801e15356b16efa9d78aa974041903da0", size = 266807, upload-time = "2025-04-15T17:45:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/d4/79/6be7e90c955c0487e7712660d6cead01fa17bff98e0ea275737cc2bc8e71/contourpy-1.3.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49b65a95d642d4efa8f64ba12558fcb83407e58a2dfba9d796d77b63ccfcaff5", size = 318729, upload-time = "2025-04-15T17:45:20.166Z" }, + { url = "https://files.pythonhosted.org/packages/87/68/7f46fb537958e87427d98a4074bcde4b67a70b04900cfc5ce29bc2f556c1/contourpy-1.3.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8c5acb8dddb0752bf252e01a3035b21443158910ac16a3b0d20e7fed7d534ce5", size = 221791, upload-time = "2025-04-15T17:45:24.794Z" }, +] + +[[package]] +name = "coverage" +version = "7.13.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/d4/7827d9ffa34d5d4d752eec907022aa417120936282fc488306f5da08c292/coverage-7.13.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0fc31c787a84f8cd6027eba44010517020e0d18487064cd3d8968941856d1415", size = 219152, upload-time = "2026-02-09T12:56:11.974Z" }, + { url = "https://files.pythonhosted.org/packages/35/b0/d69df26607c64043292644dbb9dc54b0856fabaa2cbb1eeee3331cc9e280/coverage-7.13.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a32ebc02a1805adf637fc8dec324b5cdacd2e493515424f70ee33799573d661b", size = 219667, upload-time = "2026-02-09T12:56:13.33Z" }, + { url = "https://files.pythonhosted.org/packages/82/a4/c1523f7c9e47b2271dbf8c2a097e7a1f89ef0d66f5840bb59b7e8814157b/coverage-7.13.4-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e24f9156097ff9dc286f2f913df3a7f63c0e333dcafa3c196f2c18b4175ca09a", size = 246425, upload-time = "2026-02-09T12:56:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/f8/02/aa7ec01d1a5023c4b680ab7257f9bfde9defe8fdddfe40be096ac19e8177/coverage-7.13.4-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8041b6c5bfdc03257666e9881d33b1abc88daccaf73f7b6340fb7946655cd10f", size = 248229, upload-time = "2026-02-09T12:56:16.31Z" }, + { url = "https://files.pythonhosted.org/packages/35/98/85aba0aed5126d896162087ef3f0e789a225697245256fc6181b95f47207/coverage-7.13.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a09cfa6a5862bc2fc6ca7c3def5b2926194a56b8ab78ffcf617d28911123012", size = 250106, upload-time = "2026-02-09T12:56:18.024Z" }, + { url = "https://files.pythonhosted.org/packages/96/72/1db59bd67494bc162e3e4cd5fbc7edba2c7026b22f7c8ef1496d58c2b94c/coverage-7.13.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:296f8b0af861d3970c2a4d8c91d48eb4dd4771bcef9baedec6a9b515d7de3def", size = 252021, upload-time = "2026-02-09T12:56:19.272Z" }, + { url = "https://files.pythonhosted.org/packages/9d/97/72899c59c7066961de6e3daa142d459d47d104956db43e057e034f015c8a/coverage-7.13.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e101609bcbbfb04605ea1027b10dc3735c094d12d40826a60f897b98b1c30256", size = 247114, upload-time = "2026-02-09T12:56:21.051Z" }, + { url = "https://files.pythonhosted.org/packages/39/1f/f1885573b5970235e908da4389176936c8933e86cb316b9620aab1585fa2/coverage-7.13.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aa3feb8db2e87ff5e6d00d7e1480ae241876286691265657b500886c98f38bda", size = 248143, upload-time = "2026-02-09T12:56:22.585Z" }, + { url = "https://files.pythonhosted.org/packages/a8/cf/e80390c5b7480b722fa3e994f8202807799b85bc562aa4f1dde209fbb7be/coverage-7.13.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4fc7fa81bbaf5a02801b65346c8b3e657f1d93763e58c0abdf7c992addd81a92", size = 246152, upload-time = "2026-02-09T12:56:23.748Z" }, + { url = "https://files.pythonhosted.org/packages/44/bf/f89a8350d85572f95412debb0fb9bb4795b1d5b5232bd652923c759e787b/coverage-7.13.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:33901f604424145c6e9c2398684b92e176c0b12df77d52db81c20abd48c3794c", size = 249959, upload-time = "2026-02-09T12:56:25.209Z" }, + { url = "https://files.pythonhosted.org/packages/f7/6e/612a02aece8178c818df273e8d1642190c4875402ca2ba74514394b27aba/coverage-7.13.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:bb28c0f2cf2782508a40cec377935829d5fcc3ad9a3681375af4e84eb34b6b58", size = 246416, upload-time = "2026-02-09T12:56:26.475Z" }, + { url = "https://files.pythonhosted.org/packages/cb/98/b5afc39af67c2fa6786b03c3a7091fc300947387ce8914b096db8a73d67a/coverage-7.13.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d107aff57a83222ddbd8d9ee705ede2af2cc926608b57abed8ef96b50b7e8f9", size = 247025, upload-time = "2026-02-09T12:56:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/51/30/2bba8ef0682d5bd210c38fe497e12a06c9f8d663f7025e9f5c2c31ce847d/coverage-7.13.4-cp310-cp310-win32.whl", hash = "sha256:a6f94a7d00eb18f1b6d403c91a88fd58cfc92d4b16080dfdb774afc8294469bf", size = 221758, upload-time = "2026-02-09T12:56:29.051Z" }, + { url = "https://files.pythonhosted.org/packages/78/13/331f94934cf6c092b8ea59ff868eb587bc8fe0893f02c55bc6c0183a192e/coverage-7.13.4-cp310-cp310-win_amd64.whl", hash = "sha256:2cb0f1e000ebc419632bbe04366a8990b6e32c4e0b51543a6484ffe15eaeda95", size = 222693, upload-time = "2026-02-09T12:56:30.366Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ad/b59e5b451cf7172b8d1043dc0fa718f23aab379bc1521ee13d4bd9bfa960/coverage-7.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053", size = 219278, upload-time = "2026-02-09T12:56:31.673Z" }, + { url = "https://files.pythonhosted.org/packages/f1/17/0cb7ca3de72e5f4ef2ec2fa0089beafbcaaaead1844e8b8a63d35173d77d/coverage-7.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11", size = 219783, upload-time = "2026-02-09T12:56:33.104Z" }, + { url = "https://files.pythonhosted.org/packages/ab/63/325d8e5b11e0eaf6d0f6a44fad444ae58820929a9b0de943fa377fe73e85/coverage-7.13.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa", size = 250200, upload-time = "2026-02-09T12:56:34.474Z" }, + { url = "https://files.pythonhosted.org/packages/76/53/c16972708cbb79f2942922571a687c52bd109a7bd51175aeb7558dff2236/coverage-7.13.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e264226ec98e01a8e1054314af91ee6cde0eacac4f465cc93b03dbe0bce2fd7", size = 252114, upload-time = "2026-02-09T12:56:35.749Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c2/7ab36d8b8cc412bec9ea2d07c83c48930eb4ba649634ba00cb7e4e0f9017/coverage-7.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3aa4e7b9e416774b21797365b358a6e827ffadaaca81b69ee02946852449f00", size = 254220, upload-time = "2026-02-09T12:56:37.796Z" }, + { url = "https://files.pythonhosted.org/packages/d6/4d/cf52c9a3322c89a0e6febdfbc83bb45c0ed3c64ad14081b9503adee702e7/coverage-7.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71ca20079dd8f27fcf808817e281e90220475cd75115162218d0e27549f95fef", size = 256164, upload-time = "2026-02-09T12:56:39.016Z" }, + { url = "https://files.pythonhosted.org/packages/78/e9/eb1dd17bd6de8289df3580e967e78294f352a5df8a57ff4671ee5fc3dcd0/coverage-7.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e2f25215f1a359ab17320b47bcdaca3e6e6356652e8256f2441e4ef972052903", size = 250325, upload-time = "2026-02-09T12:56:40.668Z" }, + { url = "https://files.pythonhosted.org/packages/71/07/8c1542aa873728f72267c07278c5cc0ec91356daf974df21335ccdb46368/coverage-7.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d65b2d373032411e86960604dc4edac91fdfb5dca539461cf2cbe78327d1e64f", size = 251913, upload-time = "2026-02-09T12:56:41.97Z" }, + { url = "https://files.pythonhosted.org/packages/74/d7/c62e2c5e4483a748e27868e4c32ad3daa9bdddbba58e1bc7a15e252baa74/coverage-7.13.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94eb63f9b363180aff17de3e7c8760c3ba94664ea2695c52f10111244d16a299", size = 249974, upload-time = "2026-02-09T12:56:43.323Z" }, + { url = "https://files.pythonhosted.org/packages/98/9f/4c5c015a6e98ced54efd0f5cf8d31b88e5504ecb6857585fc0161bb1e600/coverage-7.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e856bf6616714c3a9fbc270ab54103f4e685ba236fa98c054e8f87f266c93505", size = 253741, upload-time = "2026-02-09T12:56:45.155Z" }, + { url = "https://files.pythonhosted.org/packages/bd/59/0f4eef89b9f0fcd9633b5d350016f54126ab49426a70ff4c4e87446cabdc/coverage-7.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:65dfcbe305c3dfe658492df2d85259e0d79ead4177f9ae724b6fb245198f55d6", size = 249695, upload-time = "2026-02-09T12:56:46.636Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2c/b7476f938deb07166f3eb281a385c262675d688ff4659ad56c6c6b8e2e70/coverage-7.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b507778ae8a4c915436ed5c2e05b4a6cecfa70f734e19c22a005152a11c7b6a9", size = 250599, upload-time = "2026-02-09T12:56:48.13Z" }, + { url = "https://files.pythonhosted.org/packages/b8/34/c3420709d9846ee3785b9f2831b4d94f276f38884032dca1457fa83f7476/coverage-7.13.4-cp311-cp311-win32.whl", hash = "sha256:784fc3cf8be001197b652d51d3fd259b1e2262888693a4636e18879f613a62a9", size = 221780, upload-time = "2026-02-09T12:56:50.479Z" }, + { url = "https://files.pythonhosted.org/packages/61/08/3d9c8613079d2b11c185b865de9a4c1a68850cfda2b357fae365cf609f29/coverage-7.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:2421d591f8ca05b308cf0092807308b2facbefe54af7c02ac22548b88b95c98f", size = 222715, upload-time = "2026-02-09T12:56:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/18/1a/54c3c80b2f056164cc0a6cdcb040733760c7c4be9d780fe655f356f433e4/coverage-7.13.4-cp311-cp311-win_arm64.whl", hash = "sha256:79e73a76b854d9c6088fe5d8b2ebe745f8681c55f7397c3c0a016192d681045f", size = 221385, upload-time = "2026-02-09T12:56:53.194Z" }, + { url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" }, + { url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" }, + { url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" }, + { url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" }, + { url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" }, + { url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" }, + { url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" }, + { url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" }, + { url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" }, + { url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" }, + { url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" }, + { url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" }, + { url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" }, + { url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" }, + { url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" }, + { url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" }, + { url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" }, + { url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" }, + { url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" }, + { url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" }, + { url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" }, + { url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" }, + { url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" }, + { url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" }, + { url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" }, + { url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" }, + { url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" }, + { url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" }, + { url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" }, + { url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" }, + { url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" }, + { url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" }, + { url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" }, + { url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" }, + { url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" }, + { url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" }, + { url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" }, + { url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" }, + { url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" }, + { url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" }, + { url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" }, + { url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" }, + { url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" }, + { url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" }, + { url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" }, + { url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" }, + { url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" }, + { url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" }, + { url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" }, + { url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" }, + { url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "coveralls" +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "docopt" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/75/a454fb443eb6a053833f61603a432ffbd7dd6ae53a11159bacfadb9d6219/coveralls-4.0.1.tar.gz", hash = "sha256:7b2a0a2bcef94f295e3cf28dcc55ca40b71c77d1c2446b538e85f0f7bc21aa69", size = 12419, upload-time = "2024-05-15T12:56:14.297Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/e5/6708c75e2a4cfca929302d4d9b53b862c6dc65bd75e6933ea3d20016d41d/coveralls-4.0.1-py3-none-any.whl", hash = "sha256:7a6b1fa9848332c7b2221afb20f3df90272ac0167060f41b5fe90429b30b1809", size = 13599, upload-time = "2024-05-15T12:56:12.342Z" }, +] + +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, +] + +[[package]] +name = "dask" +version = "2026.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "cloudpickle" }, + { name = "fsspec" }, + { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, + { name = "packaging" }, + { name = "partd" }, + { name = "pyyaml" }, + { name = "toolz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/52/b0f9172b22778def907db1ff173249e4eb41f054b46a9c83b1528aaf811f/dask-2026.1.2.tar.gz", hash = "sha256:1136683de2750d98ea792670f7434e6c1cfce90cab2cc2f2495a9e60fd25a4fc", size = 10997838, upload-time = "2026-01-30T21:04:20.54Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/23/d39ccc4ed76222db31530b0a7d38876fdb7673e23f838e8d8f0ed4651a4f/dask-2026.1.2-py3-none-any.whl", hash = "sha256:46a0cf3b8d87f78a3d2e6b145aea4418a6d6d606fe6a16c79bd8ca2bb862bc91", size = 1482084, upload-time = "2026-01-30T21:04:18.363Z" }, +] + +[[package]] +name = "debugpy" +version = "1.8.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/75/087fe07d40f490a78782ff3b0a30e3968936854105487decdb33446d4b0e/debugpy-1.8.14.tar.gz", hash = "sha256:7cd287184318416850aa8b60ac90105837bb1e59531898c07569d197d2ed5322", size = 1641444, upload-time = "2025-04-10T19:46:10.981Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/df/156df75a41aaebd97cee9d3870fe68f8001b6c1c4ca023e221cfce69bece/debugpy-1.8.14-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:93fee753097e85623cab1c0e6a68c76308cd9f13ffdf44127e6fab4fbf024339", size = 2076510, upload-time = "2025-04-10T19:46:13.315Z" }, + { url = "https://files.pythonhosted.org/packages/69/cd/4fc391607bca0996db5f3658762106e3d2427beaef9bfd363fd370a3c054/debugpy-1.8.14-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d937d93ae4fa51cdc94d3e865f535f185d5f9748efb41d0d49e33bf3365bd79", size = 3559614, upload-time = "2025-04-10T19:46:14.647Z" }, + { url = "https://files.pythonhosted.org/packages/1a/42/4e6d2b9d63e002db79edfd0cb5656f1c403958915e0e73ab3e9220012eec/debugpy-1.8.14-cp310-cp310-win32.whl", hash = "sha256:c442f20577b38cc7a9aafecffe1094f78f07fb8423c3dddb384e6b8f49fd2987", size = 5208588, upload-time = "2025-04-10T19:46:16.233Z" }, + { url = "https://files.pythonhosted.org/packages/97/b1/cc9e4e5faadc9d00df1a64a3c2d5c5f4b9df28196c39ada06361c5141f89/debugpy-1.8.14-cp310-cp310-win_amd64.whl", hash = "sha256:f117dedda6d969c5c9483e23f573b38f4e39412845c7bc487b6f2648df30fe84", size = 5241043, upload-time = "2025-04-10T19:46:17.768Z" }, + { url = "https://files.pythonhosted.org/packages/67/e8/57fe0c86915671fd6a3d2d8746e40485fd55e8d9e682388fbb3a3d42b86f/debugpy-1.8.14-cp311-cp311-macosx_14_0_universal2.whl", hash = "sha256:1b2ac8c13b2645e0b1eaf30e816404990fbdb168e193322be8f545e8c01644a9", size = 2175064, upload-time = "2025-04-10T19:46:19.486Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/2b2fd1b1c9569c6764ccdb650a6f752e4ac31be465049563c9eb127a8487/debugpy-1.8.14-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cf431c343a99384ac7eab2f763980724834f933a271e90496944195318c619e2", size = 3132359, upload-time = "2025-04-10T19:46:21.192Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ee/b825c87ed06256ee2a7ed8bab8fb3bb5851293bf9465409fdffc6261c426/debugpy-1.8.14-cp311-cp311-win32.whl", hash = "sha256:c99295c76161ad8d507b413cd33422d7c542889fbb73035889420ac1fad354f2", size = 5133269, upload-time = "2025-04-10T19:46:23.047Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a6/6c70cd15afa43d37839d60f324213843174c1d1e6bb616bd89f7c1341bac/debugpy-1.8.14-cp311-cp311-win_amd64.whl", hash = "sha256:7816acea4a46d7e4e50ad8d09d963a680ecc814ae31cdef3622eb05ccacf7b01", size = 5158156, upload-time = "2025-04-10T19:46:24.521Z" }, + { url = "https://files.pythonhosted.org/packages/d9/2a/ac2df0eda4898f29c46eb6713a5148e6f8b2b389c8ec9e425a4a1d67bf07/debugpy-1.8.14-cp312-cp312-macosx_14_0_universal2.whl", hash = "sha256:8899c17920d089cfa23e6005ad9f22582fd86f144b23acb9feeda59e84405b84", size = 2501268, upload-time = "2025-04-10T19:46:26.044Z" }, + { url = "https://files.pythonhosted.org/packages/10/53/0a0cb5d79dd9f7039169f8bf94a144ad3efa52cc519940b3b7dde23bcb89/debugpy-1.8.14-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6bb5c0dcf80ad5dbc7b7d6eac484e2af34bdacdf81df09b6a3e62792b722826", size = 4221077, upload-time = "2025-04-10T19:46:27.464Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d5/84e01821f362327bf4828728aa31e907a2eca7c78cd7c6ec062780d249f8/debugpy-1.8.14-cp312-cp312-win32.whl", hash = "sha256:281d44d248a0e1791ad0eafdbbd2912ff0de9eec48022a5bfbc332957487ed3f", size = 5255127, upload-time = "2025-04-10T19:46:29.467Z" }, + { url = "https://files.pythonhosted.org/packages/33/16/1ed929d812c758295cac7f9cf3dab5c73439c83d9091f2d91871e648093e/debugpy-1.8.14-cp312-cp312-win_amd64.whl", hash = "sha256:5aa56ef8538893e4502a7d79047fe39b1dae08d9ae257074c6464a7b290b806f", size = 5297249, upload-time = "2025-04-10T19:46:31.538Z" }, + { url = "https://files.pythonhosted.org/packages/4d/e4/395c792b243f2367d84202dc33689aa3d910fb9826a7491ba20fc9e261f5/debugpy-1.8.14-cp313-cp313-macosx_14_0_universal2.whl", hash = "sha256:329a15d0660ee09fec6786acdb6e0443d595f64f5d096fc3e3ccf09a4259033f", size = 2485676, upload-time = "2025-04-10T19:46:32.96Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f1/6f2ee3f991327ad9e4c2f8b82611a467052a0fb0e247390192580e89f7ff/debugpy-1.8.14-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f920c7f9af409d90f5fd26e313e119d908b0dd2952c2393cd3247a462331f15", size = 4217514, upload-time = "2025-04-10T19:46:34.336Z" }, + { url = "https://files.pythonhosted.org/packages/79/28/b9d146f8f2dc535c236ee09ad3e5ac899adb39d7a19b49f03ac95d216beb/debugpy-1.8.14-cp313-cp313-win32.whl", hash = "sha256:3784ec6e8600c66cbdd4ca2726c72d8ca781e94bce2f396cc606d458146f8f4e", size = 5254756, upload-time = "2025-04-10T19:46:36.199Z" }, + { url = "https://files.pythonhosted.org/packages/e0/62/a7b4a57013eac4ccaef6977966e6bec5c63906dd25a86e35f155952e29a1/debugpy-1.8.14-cp313-cp313-win_amd64.whl", hash = "sha256:684eaf43c95a3ec39a96f1f5195a7ff3d4144e4a18d69bb66beeb1a6de605d6e", size = 5297119, upload-time = "2025-04-10T19:46:38.141Z" }, + { url = "https://files.pythonhosted.org/packages/97/1a/481f33c37ee3ac8040d3d51fc4c4e4e7e61cb08b8bc8971d6032acc2279f/debugpy-1.8.14-py2.py3-none-any.whl", hash = "sha256:5cd9a579d553b6cb9759a7908a41988ee6280b961f24f63336835d9418216a20", size = 5256230, upload-time = "2025-04-10T19:46:54.077Z" }, +] + +[[package]] +name = "decorator" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, +] + +[[package]] +name = "docopt" +version = "0.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/55/8f8cab2afd404cf578136ef2cc5dfb50baa1761b68c9da1fb1e4eed343c9/docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491", size = 25901, upload-time = "2014-06-16T11:18:57.406Z" } + +[[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 = "executing" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/50/a9d80c47ff289c611ff12e63f7c5d13942c65d68125160cefd768c73e6e4/executing-2.2.0.tar.gz", hash = "sha256:5d108c028108fe2551d1a7b2e8b713341e2cb4fc0aa7dcf966fa4327a5226755", size = 978693, upload-time = "2025-01-22T15:41:29.403Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/8f/c4d9bafc34ad7ad5d8dc16dd1347ee0e507a52c3adb6bfa8887e1c6a26ba/executing-2.2.0-py2.py3-none-any.whl", hash = "sha256:11387150cad388d62750327a53d3339fad4888b39a6fe233c3afbb54ecffd3aa", size = 26702, upload-time = "2025-01-22T15:41:25.929Z" }, +] + +[[package]] +name = "fonttools" +version = "4.58.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/a9/3319c6ae07fd9dde51064ddc6d82a2b707efad8ed407d700a01091121bbc/fonttools-4.58.2.tar.gz", hash = "sha256:4b491ddbfd50b856e84b0648b5f7941af918f6d32f938f18e62b58426a8d50e2", size = 3524285, upload-time = "2025-06-06T14:50:58.643Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/6f/1f0158cd9d6168258362369fa003c58fc36f2b141a66bc805c76f28f57cc/fonttools-4.58.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4baaf34f07013ba9c2c3d7a95d0c391fcbb30748cb86c36c094fab8f168e49bb", size = 2735491, upload-time = "2025-06-06T14:49:33.45Z" }, + { url = "https://files.pythonhosted.org/packages/3d/94/d9a36a4ae1ed257ed5117c0905635e89327428cbf3521387c13bd85e6de1/fonttools-4.58.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2e26e4a4920d57f04bb2c3b6e9a68b099c7ef2d70881d4fee527896fa4f7b5aa", size = 2307732, upload-time = "2025-06-06T14:49:36.612Z" }, + { url = "https://files.pythonhosted.org/packages/37/57/0f72a9fe7c051ce316779b8721c707413c53ae75ab00f970d74c7876388f/fonttools-4.58.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c0bb956d9d01ea51368415515f664f58abf96557ba3c1aae4e26948ae7c86f29", size = 4718769, upload-time = "2025-06-06T14:49:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/35/dd/8be06b93e24214d7dc52fd8183dbb9e75ab9638940d84d92ced25669f4d8/fonttools-4.58.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d40af8493c80ec17a1133ef429d42f1a97258dd9213b917daae9d8cafa6e0e6c", size = 4751963, upload-time = "2025-06-06T14:49:41.391Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d3/85d60be364cea1b61f47bc8ea82d3e24cd6fb08640ad783fd2494bcaf4e0/fonttools-4.58.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:60b5cde1c76f6ded198da5608dddb1ee197faad7d2f0f6d3348ca0cda0c756c4", size = 4801368, upload-time = "2025-06-06T14:49:44.663Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b9/98abf9c9c1ed67eed263f091fa1bbf0ea32ef65bb8f707c2ee106b877496/fonttools-4.58.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f8df6dc80ecc9033ca25a944ee5db7564fecca28e96383043fd92d9df861a159", size = 4909670, upload-time = "2025-06-06T14:49:46.751Z" }, + { url = "https://files.pythonhosted.org/packages/32/23/d8676da27a1a27cca89549f50b4a22c98e305d9ee4c67357515d9cb25ec4/fonttools-4.58.2-cp310-cp310-win32.whl", hash = "sha256:25728e980f5fbb67f52c5311b90fae4aaec08c3d3b78dce78ab564784df1129c", size = 2191921, upload-time = "2025-06-06T14:49:48.523Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ff/ed6452dde8fd04299ec840a4fb112597a40468106039aed9abc8e35ba7eb/fonttools-4.58.2-cp310-cp310-win_amd64.whl", hash = "sha256:d6997ee7c2909a904802faf44b0d0208797c4d751f7611836011ace165308165", size = 2236374, upload-time = "2025-06-06T14:49:50.759Z" }, + { url = "https://files.pythonhosted.org/packages/63/d0/335d12ee943b8d67847864bba98478fedf3503d8b168eeeefadd8660256a/fonttools-4.58.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:024faaf20811296fd2f83ebdac7682276362e726ed5fea4062480dd36aff2fd9", size = 2755885, upload-time = "2025-06-06T14:49:52.459Z" }, + { url = "https://files.pythonhosted.org/packages/66/c2/d8ceb8b91e3847786a19d4b93749b1d804833482b5f79bee35b68327609e/fonttools-4.58.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2faec6e7f2abd80cd9f2392dfa28c02cfd5b1125be966ea6eddd6ca684deaa40", size = 2317804, upload-time = "2025-06-06T14:49:54.581Z" }, + { url = "https://files.pythonhosted.org/packages/7c/93/865c8d50b3a1f50ebdc02227f28bb81817df88cee75bc6f2652469e754b1/fonttools-4.58.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:520792629a938c14dd7fe185794b156cfc159c609d07b31bbb5f51af8dc7918a", size = 4916900, upload-time = "2025-06-06T14:49:56.366Z" }, + { url = "https://files.pythonhosted.org/packages/60/d1/301aec4f02995958b7af6728f838b2e5cc9296bec7eae350722dec31f685/fonttools-4.58.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:12fbc6e0bf0c75ce475ef170f2c065be6abc9e06ad19a13b56b02ec2acf02427", size = 4937358, upload-time = "2025-06-06T14:49:58.392Z" }, + { url = "https://files.pythonhosted.org/packages/15/22/75dc23a4c7200b8feb90baa82c518684a601a3a03be25f7cc3dde1525e37/fonttools-4.58.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:44a39cf856d52109127d55576c7ec010206a8ba510161a7705021f70d1649831", size = 4980151, upload-time = "2025-06-06T14:50:00.778Z" }, + { url = "https://files.pythonhosted.org/packages/14/51/5d402f65c4b0c89ce0cdbffe86646f3996da209f7bc93f1f4a13a7211ee0/fonttools-4.58.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5390a67c55a835ad5a420da15b3d88b75412cbbd74450cb78c4916b0bd7f0a34", size = 5091255, upload-time = "2025-06-06T14:50:02.588Z" }, + { url = "https://files.pythonhosted.org/packages/c7/5e/dee28700276129db1a0ee8ab0d5574d255a1d72df7f6df58a9d26ddef687/fonttools-4.58.2-cp311-cp311-win32.whl", hash = "sha256:f7e10f4e7160bcf6a240d7560e9e299e8cb585baed96f6a616cef51180bf56cb", size = 2190095, upload-time = "2025-06-06T14:50:04.932Z" }, + { url = "https://files.pythonhosted.org/packages/bd/60/b90fda549942808b68c1c5bada4b369f4f55d4c28a7012f7537670438f82/fonttools-4.58.2-cp311-cp311-win_amd64.whl", hash = "sha256:29bdf52bfafdae362570d3f0d3119a3b10982e1ef8cb3a9d3ebb72da81cb8d5e", size = 2238013, upload-time = "2025-06-06T14:50:06.605Z" }, + { url = "https://files.pythonhosted.org/packages/eb/68/7ec64584dc592faf944d540307c3562cd893256c48bb028c90de489e4750/fonttools-4.58.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:c6eeaed9c54c1d33c1db928eb92b4e180c7cb93b50b1ee3e79b2395cb01f25e9", size = 2741645, upload-time = "2025-06-06T14:50:08.706Z" }, + { url = "https://files.pythonhosted.org/packages/8f/0c/b327838f63baa7ebdd6db3ffdf5aff638e883f9236d928be4f32c692e1bd/fonttools-4.58.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bbe1d9c72b7f981bed5c2a61443d5e3127c1b3aca28ca76386d1ad93268a803f", size = 2311100, upload-time = "2025-06-06T14:50:10.401Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c7/dec024a1c873c79a4db98fe0104755fa62ec2b4518e09d6fda28246c3c9b/fonttools-4.58.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85babe5b3ce2cbe57fc0d09c0ee92bbd4d594fd7ea46a65eb43510a74a4ce773", size = 4815841, upload-time = "2025-06-06T14:50:12.496Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/57c81abad641d6ec9c8b06c99cd28d687cb4849efb6168625b5c6b8f9fa4/fonttools-4.58.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:918a2854537fcdc662938057ad58b633bc9e0698f04a2f4894258213283a7932", size = 4882659, upload-time = "2025-06-06T14:50:14.361Z" }, + { url = "https://files.pythonhosted.org/packages/a5/37/2f8faa2bf8bd1ba016ea86a94c72a5e8ef8ea1c52ec64dada617191f0515/fonttools-4.58.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3b379cf05bf776c336a0205632596b1c7d7ab5f7135e3935f2ca2a0596d2d092", size = 4876128, upload-time = "2025-06-06T14:50:16.653Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ca/f1caac24ae7028a33f2a95e66c640571ff0ce5cb06c4c9ca1f632e98e22c/fonttools-4.58.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:99ab3547a15a5d168c265e139e21756bbae1de04782ac9445c9ef61b8c0a32ce", size = 5027843, upload-time = "2025-06-06T14:50:18.582Z" }, + { url = "https://files.pythonhosted.org/packages/52/6e/3200fa2bafeed748a3017e4e6594751fd50cce544270919265451b21b75c/fonttools-4.58.2-cp312-cp312-win32.whl", hash = "sha256:6764e7a3188ce36eea37b477cdeca602ae62e63ae9fc768ebc176518072deb04", size = 2177374, upload-time = "2025-06-06T14:50:20.454Z" }, + { url = "https://files.pythonhosted.org/packages/55/ab/8f3e726f3f3ef3062ce9bbb615727c55beb11eea96d1f443f79cafca93ee/fonttools-4.58.2-cp312-cp312-win_amd64.whl", hash = "sha256:41f02182a1d41b79bae93c1551855146868b04ec3e7f9c57d6fef41a124e6b29", size = 2226685, upload-time = "2025-06-06T14:50:22.087Z" }, + { url = "https://files.pythonhosted.org/packages/ac/01/29f81970a508408af20b434ff5136cd1c7ef92198957eb8ddadfbb9ef177/fonttools-4.58.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:829048ef29dbefec35d95cc6811014720371c95bdc6ceb0afd2f8e407c41697c", size = 2732398, upload-time = "2025-06-06T14:50:23.821Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f1/095f2338359333adb2f1c51b8b2ad94bf9a2fa17e5fcbdf8a7b8e3672d2d/fonttools-4.58.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:64998c5993431e45b474ed5f579f18555f45309dd1cf8008b594d2fe0a94be59", size = 2306390, upload-time = "2025-06-06T14:50:25.942Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d4/9eba134c7666a26668c28945355cd86e5d57828b6b8d952a5489fe45d7e2/fonttools-4.58.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b887a1cf9fbcb920980460ee4a489c8aba7e81341f6cdaeefa08c0ab6529591c", size = 4795100, upload-time = "2025-06-06T14:50:27.653Z" }, + { url = "https://files.pythonhosted.org/packages/2a/34/345f153a24c1340daa62340c3be2d1e5ee6c1ee57e13f6d15613209e688b/fonttools-4.58.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27d74b9f6970cefbcda33609a3bee1618e5e57176c8b972134c4e22461b9c791", size = 4864585, upload-time = "2025-06-06T14:50:29.915Z" }, + { url = "https://files.pythonhosted.org/packages/01/5f/091979a25c9a6c4ba064716cfdfe9431f78ed6ffba4bd05ae01eee3532e9/fonttools-4.58.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec26784610056a770e15a60f9920cee26ae10d44d1e43271ea652dadf4e7a236", size = 4866191, upload-time = "2025-06-06T14:50:32.188Z" }, + { url = "https://files.pythonhosted.org/packages/9d/09/3944d0ece4a39560918cba37c2e0453a5f826b665a6db0b43abbd9dbe7e1/fonttools-4.58.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ed0a71d57dd427c0fb89febd08cac9b925284d2a8888e982a6c04714b82698d7", size = 5003867, upload-time = "2025-06-06T14:50:34.323Z" }, + { url = "https://files.pythonhosted.org/packages/68/97/190b8f9ba22f8b7d07df2faa9fd7087b453776d0705d3cb5b0cbd89b8ef0/fonttools-4.58.2-cp313-cp313-win32.whl", hash = "sha256:994e362b01460aa863ef0cb41a29880bc1a498c546952df465deff7abf75587a", size = 2175688, upload-time = "2025-06-06T14:50:36.211Z" }, + { url = "https://files.pythonhosted.org/packages/94/ea/0e6d4a39528dbb6e0f908c2ad219975be0a506ed440fddf5453b90f76981/fonttools-4.58.2-cp313-cp313-win_amd64.whl", hash = "sha256:f95dec862d7c395f2d4efe0535d9bdaf1e3811e51b86432fa2a77e73f8195756", size = 2226464, upload-time = "2025-06-06T14:50:38.862Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e5/c1cb8ebabb80be76d4d28995da9416816653f8f572920ab5e3d2e3ac8285/fonttools-4.58.2-py3-none-any.whl", hash = "sha256:84f4b0bcfa046254a65ee7117094b4907e22dc98097a220ef108030eb3c15596", size = 1114597, upload-time = "2025-06-06T14:50:56.619Z" }, +] + +[[package]] +name = "fsspec" +version = "2025.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/f7/27f15d41f0ed38e8fcc488584b57e902b331da7f7c6dcda53721b15838fc/fsspec-2025.5.1.tar.gz", hash = "sha256:2e55e47a540b91843b755e83ded97c6e897fa0942b11490113f09e9c443c2475", size = 303033, upload-time = "2025-05-24T12:03:23.792Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/61/78c7b3851add1481b048b5fdc29067397a1784e2910592bc81bb3f608635/fsspec-2025.5.1-py3-none-any.whl", hash = "sha256:24d3a2e663d5fc735ab256263c4075f374a174c3410c0b25e5bd1970bceaa462", size = 199052, upload-time = "2025-05-24T12:03:21.66Z" }, +] + +[[package]] +name = "ghp-import" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, +] + +[[package]] +name = "h5py" +version = "3.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/33/acd0ce6863b6c0d7735007df01815403f5589a21ff8c2e1ee2587a38f548/h5py-3.16.0.tar.gz", hash = "sha256:a0dbaad796840ccaa67a4c144a0d0c8080073c34c76d5a6941d6818678ef2738", size = 446526, upload-time = "2026-03-06T13:49:08.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/6b/231413e58a787a89b316bb0d1777da3c62257e4797e09afd8d17ad3549dc/h5py-3.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e06f864bedb2c8e7c1358e6c73af48519e317457c444d6f3d332bb4e8fa6d7d9", size = 3724137, upload-time = "2026-03-06T13:47:35.242Z" }, + { url = "https://files.pythonhosted.org/packages/74/f9/557ce3aad0fe8471fb5279bab0fc56ea473858a022c4ce8a0b8f303d64e9/h5py-3.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ec86d4fffd87a0f4cb3d5796ceb5a50123a2a6d99b43e616e5504e66a953eca3", size = 3090112, upload-time = "2026-03-06T13:47:37.634Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f5/e15b3d0dc8a18e56409a839e6468d6fb589bc5207c917399c2e0706eeb44/h5py-3.16.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:86385ea895508220b8a7e45efa428aeafaa586bd737c7af9ee04661d8d84a10d", size = 4844847, upload-time = "2026-03-06T13:47:39.811Z" }, + { url = "https://files.pythonhosted.org/packages/cb/92/a8851d936547efe30cc0ce5245feac01f3ec6171f7899bc3f775c72030b3/h5py-3.16.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:8975273c2c5921c25700193b408e28d6bdd0111c37468b2d4e25dcec4cd1d84d", size = 5065352, upload-time = "2026-03-06T13:47:41.489Z" }, + { url = "https://files.pythonhosted.org/packages/2b/ae/f2adc5d0ca9626db3277a3d87516e124cbc5d0eea0bd79bc085702d04f2c/h5py-3.16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1677ad48b703f44efc9ea0c3ab284527f81bc4f318386aaaebc5fede6bbae56f", size = 4839173, upload-time = "2026-03-06T13:47:43.586Z" }, + { url = "https://files.pythonhosted.org/packages/64/0b/e0c8c69da1d8838da023a50cd3080eae5d475691f7636b35eff20bb6ef20/h5py-3.16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c4dd4cf5f0a4e36083f73172f6cfc25a5710789269547f132a20975bfe2434c", size = 5076216, upload-time = "2026-03-06T13:47:45.315Z" }, + { url = "https://files.pythonhosted.org/packages/66/35/d88fd6718832133c885004c61ceeeb24dbd6397ef877dbed6b3a64d6a286/h5py-3.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:bdef06507725b455fccba9c16529121a5e1fbf56aa375f7d9713d9e8ff42454d", size = 3183639, upload-time = "2026-03-06T13:47:47.041Z" }, + { url = "https://files.pythonhosted.org/packages/ba/95/a825894f3e45cbac7554c4e97314ce886b233a20033787eda755ca8fecc7/h5py-3.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:719439d14b83f74eeb080e9650a6c7aa6d0d9ea0ca7f804347b05fac6fbf18af", size = 3721663, upload-time = "2026-03-06T13:47:49.599Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3b/38ff88b347c3e346cda1d3fc1b65a7aa75d40632228d8b8a5d7b58508c24/h5py-3.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c3f0a0e136f2e95dd0b67146abb6668af4f1a69c81ef8651a2d316e8e01de447", size = 3087630, upload-time = "2026-03-06T13:47:51.249Z" }, + { url = "https://files.pythonhosted.org/packages/98/a8/2594cef906aee761601eff842c7dc598bea2b394a3e1c00966832b8eeb7c/h5py-3.16.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:a6fbc5367d4046801f9b7db9191b31895f22f1c6df1f9987d667854cac493538", size = 4823472, upload-time = "2026-03-06T13:47:53.085Z" }, + { url = "https://files.pythonhosted.org/packages/52/a0/c1f604538ff6db22a0690be2dc44ab59178e115f63c917794e529356ab23/h5py-3.16.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:fb1720028d99040792bb2fb31facb8da44a6f29df7697e0b84f0d79aff2e9bd3", size = 5027150, upload-time = "2026-03-06T13:47:55.043Z" }, + { url = "https://files.pythonhosted.org/packages/2e/fd/301739083c2fc4fd89950f9bcfce75d6e14b40b0ca3d40e48a8993d1722c/h5py-3.16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:314b6054fe0b1051c2b0cb2df5cbdab15622fb05e80f202e3b6a5eee0d6fe365", size = 4814544, upload-time = "2026-03-06T13:47:56.893Z" }, + { url = "https://files.pythonhosted.org/packages/4c/42/2193ed41ccee78baba8fcc0cff2c925b8b9ee3793305b23e1f22c20bf4c7/h5py-3.16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ffbab2fedd6581f6aa31cf1639ca2cb86e02779de525667892ebf4cc9fd26434", size = 5034013, upload-time = "2026-03-06T13:47:59.01Z" }, + { url = "https://files.pythonhosted.org/packages/f7/20/e6c0ff62ca2ad1a396a34f4380bafccaaf8791ff8fccf3d995a1fc12d417/h5py-3.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:17d1f1630f92ad74494a9a7392ab25982ce2b469fc62da6074c0ce48366a2999", size = 3191673, upload-time = "2026-03-06T13:48:00.626Z" }, + { url = "https://files.pythonhosted.org/packages/f2/48/239cbe352ac4f2b8243a8e620fa1a2034635f633731493a7ff1ed71e8658/h5py-3.16.0-cp311-cp311-win_arm64.whl", hash = "sha256:85b9c49dd58dc44cf70af944784e2c2038b6f799665d0dcbbc812a26e0faa859", size = 2673834, upload-time = "2026-03-06T13:48:02.579Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c0/5d4119dba94093bbafede500d3defd2f5eab7897732998c04b54021e530b/h5py-3.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c5313566f4643121a78503a473f0fb1e6dcc541d5115c44f05e037609c565c4d", size = 3685604, upload-time = "2026-03-06T13:48:04.198Z" }, + { url = "https://files.pythonhosted.org/packages/b0/42/c84efcc1d4caebafb1ecd8be4643f39c85c47a80fe254d92b8b43b1eadaf/h5py-3.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:42b012933a83e1a558c673176676a10ce2fd3759976a0fedee1e672d1e04fc9d", size = 3061940, upload-time = "2026-03-06T13:48:05.783Z" }, + { url = "https://files.pythonhosted.org/packages/89/84/06281c82d4d1686fde1ac6b0f307c50918f1c0151062445ab3b6fa5a921d/h5py-3.16.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:ff24039e2573297787c3063df64b60aab0591980ac898329a08b0320e0cf2527", size = 5198852, upload-time = "2026-03-06T13:48:07.482Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e9/1a19e42cd43cc1365e127db6aae85e1c671da1d9a5d746f4d34a50edb577/h5py-3.16.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:dfc21898ff025f1e8e67e194965a95a8d4754f452f83454538f98f8a3fcb207e", size = 5405250, upload-time = "2026-03-06T13:48:09.628Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8e/9790c1655eabeb85b92b1ecab7d7e62a2069e53baefd58c98f0909c7a948/h5py-3.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:698dd69291272642ffda44a0ecd6cd3bda5faf9621452d255f57ce91487b9794", size = 5190108, upload-time = "2026-03-06T13:48:11.26Z" }, + { url = "https://files.pythonhosted.org/packages/51/d7/ab693274f1bd7e8c5f9fdd6c7003a88d59bedeaf8752716a55f532924fbb/h5py-3.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2b2c02b0a160faed5fb33f1ba8a264a37ee240b22e049ecc827345d0d9043074", size = 5419216, upload-time = "2026-03-06T13:48:13.322Z" }, + { url = "https://files.pythonhosted.org/packages/03/c1/0976b235cf29ead553e22f2fb6385a8252b533715e00d0ae52ed7b900582/h5py-3.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:96b422019a1c8975c2d5dadcf61d4ba6f01c31f92bbde6e4649607885fe502d6", size = 3182868, upload-time = "2026-03-06T13:48:15.759Z" }, + { url = "https://files.pythonhosted.org/packages/14/d9/866b7e570b39070f92d47b0ff1800f0f8239b6f9e45f02363d7112336c1f/h5py-3.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:39c2838fb1e8d97bcf1755e60ad1f3dd76a7b2a475928dc321672752678b96db", size = 2653286, upload-time = "2026-03-06T13:48:17.279Z" }, + { url = "https://files.pythonhosted.org/packages/0f/9e/6142ebfda0cb6e9349c091eae73c2e01a770b7659255248d637bec54a88b/h5py-3.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:370a845f432c2c9619db8eed334d1e610c6015796122b0e57aa46312c22617d9", size = 3671808, upload-time = "2026-03-06T13:48:19.737Z" }, + { url = "https://files.pythonhosted.org/packages/b0/65/5e088a45d0f43cd814bc5bec521c051d42005a472e804b1a36c48dada09b/h5py-3.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42108e93326c50c2810025aade9eac9d6827524cdccc7d4b75a546e5ab308edb", size = 3045837, upload-time = "2026-03-06T13:48:21.854Z" }, + { url = "https://files.pythonhosted.org/packages/da/1e/6172269e18cc5a484e2913ced33339aad588e02ba407fafd00d369e22ef3/h5py-3.16.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:099f2525c9dcf28de366970a5fb34879aab20491589fa89ce2863a84218bb524", size = 5193860, upload-time = "2026-03-06T13:48:24.071Z" }, + { url = "https://files.pythonhosted.org/packages/bd/98/ef2b6fe2903e377cbe870c3b2800d62552f1e3dbe81ce49e1923c53d1c5c/h5py-3.16.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:9300ad32dea9dfc5171f94d5f6948e159ed93e4701280b0f508773b3f582f402", size = 5400417, upload-time = "2026-03-06T13:48:25.728Z" }, + { url = "https://files.pythonhosted.org/packages/bc/81/5b62d760039eed64348c98129d17061fdfc7839fc9c04eaaad6dee1004e4/h5py-3.16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:171038f23bccddfc23f344cadabdfc9917ff554db6a0d417180d2747fe4c75a7", size = 5185214, upload-time = "2026-03-06T13:48:27.436Z" }, + { url = "https://files.pythonhosted.org/packages/28/c4/532123bcd9080e250696779c927f2cb906c8bf3447df98f5ceb8dcded539/h5py-3.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7e420b539fb6023a259a1b14d4c9f6df8cf50d7268f48e161169987a57b737ff", size = 5414598, upload-time = "2026-03-06T13:48:29.49Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d9/a27997f84341fc0dfcdd1fe4179b6ba6c32a7aa880fdb8c514d4dad6fba3/h5py-3.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:18f2bbcd545e6991412253b98727374c356d67caa920e68dc79eab36bf5fedad", size = 3175509, upload-time = "2026-03-06T13:48:31.131Z" }, + { url = "https://files.pythonhosted.org/packages/a5/23/bb8647521d4fd770c30a76cfc6cb6a2f5495868904054e92f2394c5a78ff/h5py-3.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:656f00e4d903199a1d58df06b711cf3ca632b874b4207b7dbec86185b5c8c7d4", size = 2647362, upload-time = "2026-03-06T13:48:33.411Z" }, + { url = "https://files.pythonhosted.org/packages/48/3c/7fcd9b4c9eed82e91fb15568992561019ae7a829d1f696b2c844355d95dd/h5py-3.16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9c9d307c0ef862d1cd5714f72ecfafe0a5d7529c44845afa8de9f46e5ba8bd65", size = 3678608, upload-time = "2026-03-06T13:48:35.183Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b7/9366ed44ced9b7ef357ab48c94205280276db9d7f064aa3012a97227e966/h5py-3.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8c1eff849cdd53cbc73c214c30ebdb6f1bb8b64790b4b4fc36acdb5e43570210", size = 3054773, upload-time = "2026-03-06T13:48:37.139Z" }, + { url = "https://files.pythonhosted.org/packages/58/a5/4964bc0e91e86340c2bbda83420225b2f770dcf1eb8a39464871ad769436/h5py-3.16.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:e2c04d129f180019e216ee5f9c40b78a418634091c8782e1f723a6ca3658b965", size = 5198886, upload-time = "2026-03-06T13:48:38.879Z" }, + { url = "https://files.pythonhosted.org/packages/f1/16/d905e7f53e661ce2c24686c38048d8e2b750ffc4350009d41c4e6c6c9826/h5py-3.16.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:e4360f15875a532bc7b98196c7592ed4fc92672a57c0a621355961cafb17a6dd", size = 5404883, upload-time = "2026-03-06T13:48:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f2/58f34cb74af46d39f4cd18ea20909a8514960c5a3e5b92fd06a28161e0a8/h5py-3.16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3fae9197390c325e62e0a1aa977f2f62d994aa87aab182abbea85479b791197c", size = 5192039, upload-time = "2026-03-06T13:48:43.117Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ca/934a39c24ce2e2db017268c08da0537c20fa0be7e1549be3e977313fc8f5/h5py-3.16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:43259303989ac8adacc9986695b31e35dba6fd1e297ff9c6a04b7da5542139cc", size = 5421526, upload-time = "2026-03-06T13:48:44.838Z" }, + { url = "https://files.pythonhosted.org/packages/3e/14/615a450205e1b56d16c6783f5ccd116cde05550faad70ae077c955654a75/h5py-3.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:fa48993a0b799737ba7fd21e2350fa0a60701e58180fae9f2de834bc39a147ab", size = 3183263, upload-time = "2026-03-06T13:48:47.117Z" }, + { url = "https://files.pythonhosted.org/packages/7b/48/a6faef5ed632cae0c65ac6b214a6614a0b510c3183532c521bdb0055e117/h5py-3.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:1897a771a7f40d05c262fc8f37376ec37873218544b70216872876c627640f63", size = 2663450, upload-time = "2026-03-06T13:48:48.707Z" }, + { url = "https://files.pythonhosted.org/packages/5d/32/0c8bb8aedb62c772cf7c1d427c7d1951477e8c2835f872bc0a13d1f85f86/h5py-3.16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:15922e485844f77c0b9d275396d435db3baa58292a9c2176a386e072e0cf2491", size = 3760693, upload-time = "2026-03-06T13:48:50.453Z" }, + { url = "https://files.pythonhosted.org/packages/1d/1f/fcc5977d32d6387c5c9a694afee716a5e20658ac08b3ff24fdec79fb05f2/h5py-3.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:df02dd29bd247f98674634dfe41f89fd7c16ba3d7de8695ec958f58404a4e618", size = 3181305, upload-time = "2026-03-06T13:48:52.221Z" }, + { url = "https://files.pythonhosted.org/packages/f5/a1/af87f64b9f986889884243643621ebbd4ac72472ba8ec8cec891ac8e2ca1/h5py-3.16.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:0f456f556e4e2cebeebd9d66adf8dc321770a42593494a0b6f0af54a7567b242", size = 5074061, upload-time = "2026-03-06T13:48:54.089Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d0/146f5eaff3dc246a9c7f6e5e4f42bd45cc613bce16693bcd4d1f7c958bf5/h5py-3.16.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:3e6cb3387c756de6a9492d601553dffea3fe11b5f22b443aac708c69f3f55e16", size = 5279216, upload-time = "2026-03-06T13:48:56.75Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9d/12a13424f1e604fc7df9497b73c0356fb78c2fb206abd7465ce47226e8fd/h5py-3.16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8389e13a1fd745ad2856873e8187fd10268b2d9677877bb667b41aebd771d8b7", size = 5070068, upload-time = "2026-03-06T13:48:59.169Z" }, + { url = "https://files.pythonhosted.org/packages/41/8c/bbe98f813722b4873818a8db3e15aa3e625b59278566905ac439725e8070/h5py-3.16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:346df559a0f7dcb31cf8e44805319e2ab24b8957c45e7708ce503b2ec79ba725", size = 5300253, upload-time = "2026-03-06T13:49:02.033Z" }, + { url = "https://files.pythonhosted.org/packages/32/9e/87e6705b4d6890e7cecdf876e2a7d3e40654a2ae37482d79a6f1b87f7b92/h5py-3.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4c6ab014ab704b4feaa719ae783b86522ed0bf1f82184704ed3c9e4e3228796e", size = 3381671, upload-time = "2026-03-06T13:49:04.351Z" }, + { url = "https://files.pythonhosted.org/packages/96/91/9fad90cfc5f9b2489c7c26ad897157bce82f0e9534a986a221b99760b23b/h5py-3.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:faca8fb4e4319c09d83337adc80b2ca7d5c5a343c2d6f1b6388f32cfecca13c1", size = 2740706, upload-time = "2026-03-06T13:49:06.347Z" }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "8.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +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 = "ipykernel" +version = "7.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "appnope", marker = "sys_platform == 'darwin'" }, + { name = "comm" }, + { name = "debugpy" }, + { name = "ipython", version = "8.37.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "ipython", version = "9.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "matplotlib-inline" }, + { name = "nest-asyncio" }, + { name = "packaging" }, + { name = "psutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/8d/b68b728e2d06b9e0051019640a40a9eb7a88fcd82c2e1b5ce70bef5ff044/ipykernel-7.2.0.tar.gz", hash = "sha256:18ed160b6dee2cbb16e5f3575858bc19d8f1fe6046a9a680c708494ce31d909e", size = 176046, upload-time = "2026-02-06T16:43:27.403Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/b9/e73d5d9f405cba7706c539aa8b311b49d4c2f3d698d9c12f815231169c71/ipykernel-7.2.0-py3-none-any.whl", hash = "sha256:3bbd4420d2b3cc105cbdf3756bfc04500b1e52f090a90716851f3916c62e1661", size = 118788, upload-time = "2026-02-06T16:43:25.149Z" }, +] + +[[package]] +name = "ipython" +version = "8.37.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version < '3.11'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "jedi", marker = "python_full_version < '3.11'" }, + { name = "matplotlib-inline", marker = "python_full_version < '3.11'" }, + { name = "pexpect", marker = "python_full_version < '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version < '3.11'" }, + { name = "pygments", marker = "python_full_version < '3.11'" }, + { name = "stack-data", marker = "python_full_version < '3.11'" }, + { name = "traitlets", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/85/31/10ac88f3357fc276dc8a64e8880c82e80e7459326ae1d0a211b40abf6665/ipython-8.37.0.tar.gz", hash = "sha256:ca815841e1a41a1e6b73a0b08f3038af9b2252564d01fc405356d34033012216", size = 5606088, upload-time = "2025-05-31T16:39:09.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/d0/274fbf7b0b12643cbbc001ce13e6a5b1607ac4929d1b11c72460152c9fc3/ipython-8.37.0-py3-none-any.whl", hash = "sha256:ed87326596b878932dbcb171e3e698845434d8c61b8d8cd474bf663041a9dcf2", size = 831864, upload-time = "2025-05-31T16:39:06.38Z" }, +] + +[[package]] +name = "ipython" +version = "9.3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version >= '3.11'" }, + { name = "ipython-pygments-lexers", marker = "python_full_version >= '3.11'" }, + { name = "jedi", marker = "python_full_version >= '3.11'" }, + { name = "matplotlib-inline", marker = "python_full_version >= '3.11'" }, + { name = "pexpect", marker = "python_full_version >= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version >= '3.11'" }, + { name = "pygments", marker = "python_full_version >= '3.11'" }, + { name = "stack-data", marker = "python_full_version >= '3.11'" }, + { name = "traitlets", marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions", marker = "python_full_version == '3.11.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/09/4c7e06b96fbd203e06567b60fb41b06db606b6a82db6db7b2c85bb72a15c/ipython-9.3.0.tar.gz", hash = "sha256:79eb896f9f23f50ad16c3bc205f686f6e030ad246cc309c6279a242b14afe9d8", size = 4426460, upload-time = "2025-05-31T16:34:55.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/99/9ed3d52d00f1846679e3aa12e2326ac7044b5e7f90dc822b60115fa533ca/ipython-9.3.0-py3-none-any.whl", hash = "sha256:1a0b6dd9221a1f5dddf725b57ac0cb6fddc7b5f470576231ae9162b9b3455a04", size = 605320, upload-time = "2025-05-31T16:34:52.154Z" }, +] + +[[package]] +name = "ipython-pygments-lexers" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, +] + +[[package]] +name = "jedi" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parso" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287, upload-time = "2024-11-11T01:41:42.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278, upload-time = "2024-11-11T01:41:40.175Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "joblib" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, +] + +[[package]] +name = "jupyter-client" +version = "8.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-core" }, + { name = "python-dateutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/e4/ba649102a3bc3fbca54e7239fb924fd434c766f855693d86de0b1f2bec81/jupyter_client-8.8.0.tar.gz", hash = "sha256:d556811419a4f2d96c869af34e854e3f059b7cc2d6d01a9cd9c85c267691be3e", size = 348020, upload-time = "2026-01-08T13:55:47.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/0b/ceb7694d864abc0a047649aec263878acb9f792e1fec3e676f22dc9015e3/jupyter_client-8.8.0-py3-none-any.whl", hash = "sha256:f93a5b99c5e23a507b773d3a1136bd6e16c67883ccdbd9a829b0bbdb98cd7d7a", size = 107371, upload-time = "2026-01-08T13:55:45.562Z" }, +] + +[[package]] +name = "jupyter-core" +version = "5.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "platformdirs" }, + { name = "pywin32", marker = "platform_python_implementation != 'PyPy' and sys_platform == 'win32'" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/1b/72906d554acfeb588332eaaa6f61577705e9ec752ddb486f302dafa292d9/jupyter_core-5.8.1.tar.gz", hash = "sha256:0a5f9706f70e64786b75acba995988915ebd4601c8a52e534a40b51c95f59941", size = 88923, upload-time = "2025-05-27T07:38:16.655Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/57/6bffd4b20b88da3800c5d691e0337761576ee688eb01299eae865689d2df/jupyter_core-5.8.1-py3-none-any.whl", hash = "sha256:c28d268fc90fb53f1338ded2eb410704c5449a358406e8a948b75706e24863d0", size = 28880, upload-time = "2025-05-27T07:38:15.137Z" }, +] + +[[package]] +name = "kiwisolver" +version = "1.4.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/59/7c91426a8ac292e1cdd53a63b6d9439abd573c875c3f92c146767dd33faf/kiwisolver-1.4.8.tar.gz", hash = "sha256:23d5f023bdc8c7e54eb65f03ca5d5bb25b601eac4d7f1a042888a1f45237987e", size = 97538, upload-time = "2024-12-24T18:30:51.519Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/5f/4d8e9e852d98ecd26cdf8eaf7ed8bc33174033bba5e07001b289f07308fd/kiwisolver-1.4.8-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88c6f252f6816a73b1f8c904f7bbe02fd67c09a69f7cb8a0eecdbf5ce78e63db", size = 124623, upload-time = "2024-12-24T18:28:17.687Z" }, + { url = "https://files.pythonhosted.org/packages/1d/70/7f5af2a18a76fe92ea14675f8bd88ce53ee79e37900fa5f1a1d8e0b42998/kiwisolver-1.4.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c72941acb7b67138f35b879bbe85be0f6c6a70cab78fe3ef6db9c024d9223e5b", size = 66720, upload-time = "2024-12-24T18:28:19.158Z" }, + { url = "https://files.pythonhosted.org/packages/c6/13/e15f804a142353aefd089fadc8f1d985561a15358c97aca27b0979cb0785/kiwisolver-1.4.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ce2cf1e5688edcb727fdf7cd1bbd0b6416758996826a8be1d958f91880d0809d", size = 65413, upload-time = "2024-12-24T18:28:20.064Z" }, + { url = "https://files.pythonhosted.org/packages/ce/6d/67d36c4d2054e83fb875c6b59d0809d5c530de8148846b1370475eeeece9/kiwisolver-1.4.8-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c8bf637892dc6e6aad2bc6d4d69d08764166e5e3f69d469e55427b6ac001b19d", size = 1650826, upload-time = "2024-12-24T18:28:21.203Z" }, + { url = "https://files.pythonhosted.org/packages/de/c6/7b9bb8044e150d4d1558423a1568e4f227193662a02231064e3824f37e0a/kiwisolver-1.4.8-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:034d2c891f76bd3edbdb3ea11140d8510dca675443da7304205a2eaa45d8334c", size = 1628231, upload-time = "2024-12-24T18:28:23.851Z" }, + { url = "https://files.pythonhosted.org/packages/b6/38/ad10d437563063eaaedbe2c3540a71101fc7fb07a7e71f855e93ea4de605/kiwisolver-1.4.8-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d47b28d1dfe0793d5e96bce90835e17edf9a499b53969b03c6c47ea5985844c3", size = 1408938, upload-time = "2024-12-24T18:28:26.687Z" }, + { url = "https://files.pythonhosted.org/packages/52/ce/c0106b3bd7f9e665c5f5bc1e07cc95b5dabd4e08e3dad42dbe2faad467e7/kiwisolver-1.4.8-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eb158fe28ca0c29f2260cca8c43005329ad58452c36f0edf298204de32a9a3ed", size = 1422799, upload-time = "2024-12-24T18:28:30.538Z" }, + { url = "https://files.pythonhosted.org/packages/d0/87/efb704b1d75dc9758087ba374c0f23d3254505edaedd09cf9d247f7878b9/kiwisolver-1.4.8-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5536185fce131780ebd809f8e623bf4030ce1b161353166c49a3c74c287897f", size = 1354362, upload-time = "2024-12-24T18:28:32.943Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b3/fd760dc214ec9a8f208b99e42e8f0130ff4b384eca8b29dd0efc62052176/kiwisolver-1.4.8-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:369b75d40abedc1da2c1f4de13f3482cb99e3237b38726710f4a793432b1c5ff", size = 2222695, upload-time = "2024-12-24T18:28:35.641Z" }, + { url = "https://files.pythonhosted.org/packages/a2/09/a27fb36cca3fc01700687cc45dae7a6a5f8eeb5f657b9f710f788748e10d/kiwisolver-1.4.8-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:641f2ddf9358c80faa22e22eb4c9f54bd3f0e442e038728f500e3b978d00aa7d", size = 2370802, upload-time = "2024-12-24T18:28:38.357Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c3/ba0a0346db35fe4dc1f2f2cf8b99362fbb922d7562e5f911f7ce7a7b60fa/kiwisolver-1.4.8-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d561d2d8883e0819445cfe58d7ddd673e4015c3c57261d7bdcd3710d0d14005c", size = 2334646, upload-time = "2024-12-24T18:28:40.941Z" }, + { url = "https://files.pythonhosted.org/packages/41/52/942cf69e562f5ed253ac67d5c92a693745f0bed3c81f49fc0cbebe4d6b00/kiwisolver-1.4.8-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:1732e065704b47c9afca7ffa272f845300a4eb959276bf6970dc07265e73b605", size = 2467260, upload-time = "2024-12-24T18:28:42.273Z" }, + { url = "https://files.pythonhosted.org/packages/32/26/2d9668f30d8a494b0411d4d7d4ea1345ba12deb6a75274d58dd6ea01e951/kiwisolver-1.4.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bcb1ebc3547619c3b58a39e2448af089ea2ef44b37988caf432447374941574e", size = 2288633, upload-time = "2024-12-24T18:28:44.87Z" }, + { url = "https://files.pythonhosted.org/packages/98/99/0dd05071654aa44fe5d5e350729961e7bb535372935a45ac89a8924316e6/kiwisolver-1.4.8-cp310-cp310-win_amd64.whl", hash = "sha256:89c107041f7b27844179ea9c85d6da275aa55ecf28413e87624d033cf1f6b751", size = 71885, upload-time = "2024-12-24T18:28:47.346Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fc/822e532262a97442989335394d441cd1d0448c2e46d26d3e04efca84df22/kiwisolver-1.4.8-cp310-cp310-win_arm64.whl", hash = "sha256:b5773efa2be9eb9fcf5415ea3ab70fc785d598729fd6057bea38d539ead28271", size = 65175, upload-time = "2024-12-24T18:28:49.651Z" }, + { url = "https://files.pythonhosted.org/packages/da/ed/c913ee28936c371418cb167b128066ffb20bbf37771eecc2c97edf8a6e4c/kiwisolver-1.4.8-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a4d3601908c560bdf880f07d94f31d734afd1bb71e96585cace0e38ef44c6d84", size = 124635, upload-time = "2024-12-24T18:28:51.826Z" }, + { url = "https://files.pythonhosted.org/packages/4c/45/4a7f896f7467aaf5f56ef093d1f329346f3b594e77c6a3c327b2d415f521/kiwisolver-1.4.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:856b269c4d28a5c0d5e6c1955ec36ebfd1651ac00e1ce0afa3e28da95293b561", size = 66717, upload-time = "2024-12-24T18:28:54.256Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b4/c12b3ac0852a3a68f94598d4c8d569f55361beef6159dce4e7b624160da2/kiwisolver-1.4.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c2b9a96e0f326205af81a15718a9073328df1173a2619a68553decb7097fd5d7", size = 65413, upload-time = "2024-12-24T18:28:55.184Z" }, + { url = "https://files.pythonhosted.org/packages/a9/98/1df4089b1ed23d83d410adfdc5947245c753bddfbe06541c4aae330e9e70/kiwisolver-1.4.8-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5020c83e8553f770cb3b5fc13faac40f17e0b205bd237aebd21d53d733adb03", size = 1343994, upload-time = "2024-12-24T18:28:57.493Z" }, + { url = "https://files.pythonhosted.org/packages/8d/bf/b4b169b050c8421a7c53ea1ea74e4ef9c335ee9013216c558a047f162d20/kiwisolver-1.4.8-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dace81d28c787956bfbfbbfd72fdcef014f37d9b48830829e488fdb32b49d954", size = 1434804, upload-time = "2024-12-24T18:29:00.077Z" }, + { url = "https://files.pythonhosted.org/packages/66/5a/e13bd341fbcf73325ea60fdc8af752addf75c5079867af2e04cc41f34434/kiwisolver-1.4.8-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:11e1022b524bd48ae56c9b4f9296bce77e15a2e42a502cceba602f804b32bb79", size = 1450690, upload-time = "2024-12-24T18:29:01.401Z" }, + { url = "https://files.pythonhosted.org/packages/9b/4f/5955dcb376ba4a830384cc6fab7d7547bd6759fe75a09564910e9e3bb8ea/kiwisolver-1.4.8-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b9b4d2892fefc886f30301cdd80debd8bb01ecdf165a449eb6e78f79f0fabd6", size = 1376839, upload-time = "2024-12-24T18:29:02.685Z" }, + { url = "https://files.pythonhosted.org/packages/3a/97/5edbed69a9d0caa2e4aa616ae7df8127e10f6586940aa683a496c2c280b9/kiwisolver-1.4.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a96c0e790ee875d65e340ab383700e2b4891677b7fcd30a699146f9384a2bb0", size = 1435109, upload-time = "2024-12-24T18:29:04.113Z" }, + { url = "https://files.pythonhosted.org/packages/13/fc/e756382cb64e556af6c1809a1bbb22c141bbc2445049f2da06b420fe52bf/kiwisolver-1.4.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:23454ff084b07ac54ca8be535f4174170c1094a4cff78fbae4f73a4bcc0d4dab", size = 2245269, upload-time = "2024-12-24T18:29:05.488Z" }, + { url = "https://files.pythonhosted.org/packages/76/15/e59e45829d7f41c776d138245cabae6515cb4eb44b418f6d4109c478b481/kiwisolver-1.4.8-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:87b287251ad6488e95b4f0b4a79a6d04d3ea35fde6340eb38fbd1ca9cd35bbbc", size = 2393468, upload-time = "2024-12-24T18:29:06.79Z" }, + { url = "https://files.pythonhosted.org/packages/e9/39/483558c2a913ab8384d6e4b66a932406f87c95a6080112433da5ed668559/kiwisolver-1.4.8-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b21dbe165081142b1232a240fc6383fd32cdd877ca6cc89eab93e5f5883e1c25", size = 2355394, upload-time = "2024-12-24T18:29:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/efad1fbca6570a161d29224f14b082960c7e08268a133fe5dc0f6906820e/kiwisolver-1.4.8-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:768cade2c2df13db52475bd28d3a3fac8c9eff04b0e9e2fda0f3760f20b3f7fc", size = 2490901, upload-time = "2024-12-24T18:29:09.653Z" }, + { url = "https://files.pythonhosted.org/packages/c9/4f/15988966ba46bcd5ab9d0c8296914436720dd67fca689ae1a75b4ec1c72f/kiwisolver-1.4.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d47cfb2650f0e103d4bf68b0b5804c68da97272c84bb12850d877a95c056bd67", size = 2312306, upload-time = "2024-12-24T18:29:12.644Z" }, + { url = "https://files.pythonhosted.org/packages/2d/27/bdf1c769c83f74d98cbc34483a972f221440703054894a37d174fba8aa68/kiwisolver-1.4.8-cp311-cp311-win_amd64.whl", hash = "sha256:ed33ca2002a779a2e20eeb06aea7721b6e47f2d4b8a8ece979d8ba9e2a167e34", size = 71966, upload-time = "2024-12-24T18:29:14.089Z" }, + { url = "https://files.pythonhosted.org/packages/4a/c9/9642ea855604aeb2968a8e145fc662edf61db7632ad2e4fb92424be6b6c0/kiwisolver-1.4.8-cp311-cp311-win_arm64.whl", hash = "sha256:16523b40aab60426ffdebe33ac374457cf62863e330a90a0383639ce14bf44b2", size = 65311, upload-time = "2024-12-24T18:29:15.892Z" }, + { url = "https://files.pythonhosted.org/packages/fc/aa/cea685c4ab647f349c3bc92d2daf7ae34c8e8cf405a6dcd3a497f58a2ac3/kiwisolver-1.4.8-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d6af5e8815fd02997cb6ad9bbed0ee1e60014438ee1a5c2444c96f87b8843502", size = 124152, upload-time = "2024-12-24T18:29:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/c5/0b/8db6d2e2452d60d5ebc4ce4b204feeb16176a851fd42462f66ade6808084/kiwisolver-1.4.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bade438f86e21d91e0cf5dd7c0ed00cda0f77c8c1616bd83f9fc157fa6760d31", size = 66555, upload-time = "2024-12-24T18:29:19.146Z" }, + { url = "https://files.pythonhosted.org/packages/60/26/d6a0db6785dd35d3ba5bf2b2df0aedc5af089962c6eb2cbf67a15b81369e/kiwisolver-1.4.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b83dc6769ddbc57613280118fb4ce3cd08899cc3369f7d0e0fab518a7cf37fdb", size = 65067, upload-time = "2024-12-24T18:29:20.096Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ed/1d97f7e3561e09757a196231edccc1bcf59d55ddccefa2afc9c615abd8e0/kiwisolver-1.4.8-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:111793b232842991be367ed828076b03d96202c19221b5ebab421ce8bcad016f", size = 1378443, upload-time = "2024-12-24T18:29:22.843Z" }, + { url = "https://files.pythonhosted.org/packages/29/61/39d30b99954e6b46f760e6289c12fede2ab96a254c443639052d1b573fbc/kiwisolver-1.4.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:257af1622860e51b1a9d0ce387bf5c2c4f36a90594cb9514f55b074bcc787cfc", size = 1472728, upload-time = "2024-12-24T18:29:24.463Z" }, + { url = "https://files.pythonhosted.org/packages/0c/3e/804163b932f7603ef256e4a715e5843a9600802bb23a68b4e08c8c0ff61d/kiwisolver-1.4.8-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:69b5637c3f316cab1ec1c9a12b8c5f4750a4c4b71af9157645bf32830e39c03a", size = 1478388, upload-time = "2024-12-24T18:29:25.776Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9e/60eaa75169a154700be74f875a4d9961b11ba048bef315fbe89cb6999056/kiwisolver-1.4.8-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:782bb86f245ec18009890e7cb8d13a5ef54dcf2ebe18ed65f795e635a96a1c6a", size = 1413849, upload-time = "2024-12-24T18:29:27.202Z" }, + { url = "https://files.pythonhosted.org/packages/bc/b3/9458adb9472e61a998c8c4d95cfdfec91c73c53a375b30b1428310f923e4/kiwisolver-1.4.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc978a80a0db3a66d25767b03688f1147a69e6237175c0f4ffffaaedf744055a", size = 1475533, upload-time = "2024-12-24T18:29:28.638Z" }, + { url = "https://files.pythonhosted.org/packages/e4/7a/0a42d9571e35798de80aef4bb43a9b672aa7f8e58643d7bd1950398ffb0a/kiwisolver-1.4.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:36dbbfd34838500a31f52c9786990d00150860e46cd5041386f217101350f0d3", size = 2268898, upload-time = "2024-12-24T18:29:30.368Z" }, + { url = "https://files.pythonhosted.org/packages/d9/07/1255dc8d80271400126ed8db35a1795b1a2c098ac3a72645075d06fe5c5d/kiwisolver-1.4.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:eaa973f1e05131de5ff3569bbba7f5fd07ea0595d3870ed4a526d486fe57fa1b", size = 2425605, upload-time = "2024-12-24T18:29:33.151Z" }, + { url = "https://files.pythonhosted.org/packages/84/df/5a3b4cf13780ef6f6942df67b138b03b7e79e9f1f08f57c49957d5867f6e/kiwisolver-1.4.8-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a66f60f8d0c87ab7f59b6fb80e642ebb29fec354a4dfad687ca4092ae69d04f4", size = 2375801, upload-time = "2024-12-24T18:29:34.584Z" }, + { url = "https://files.pythonhosted.org/packages/8f/10/2348d068e8b0f635c8c86892788dac7a6b5c0cb12356620ab575775aad89/kiwisolver-1.4.8-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:858416b7fb777a53f0c59ca08190ce24e9abbd3cffa18886a5781b8e3e26f65d", size = 2520077, upload-time = "2024-12-24T18:29:36.138Z" }, + { url = "https://files.pythonhosted.org/packages/32/d8/014b89fee5d4dce157d814303b0fce4d31385a2af4c41fed194b173b81ac/kiwisolver-1.4.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:085940635c62697391baafaaeabdf3dd7a6c3643577dde337f4d66eba021b2b8", size = 2338410, upload-time = "2024-12-24T18:29:39.991Z" }, + { url = "https://files.pythonhosted.org/packages/bd/72/dfff0cc97f2a0776e1c9eb5bef1ddfd45f46246c6533b0191887a427bca5/kiwisolver-1.4.8-cp312-cp312-win_amd64.whl", hash = "sha256:01c3d31902c7db5fb6182832713d3b4122ad9317c2c5877d0539227d96bb2e50", size = 71853, upload-time = "2024-12-24T18:29:42.006Z" }, + { url = "https://files.pythonhosted.org/packages/dc/85/220d13d914485c0948a00f0b9eb419efaf6da81b7d72e88ce2391f7aed8d/kiwisolver-1.4.8-cp312-cp312-win_arm64.whl", hash = "sha256:a3c44cb68861de93f0c4a8175fbaa691f0aa22550c331fefef02b618a9dcb476", size = 65424, upload-time = "2024-12-24T18:29:44.38Z" }, + { url = "https://files.pythonhosted.org/packages/79/b3/e62464a652f4f8cd9006e13d07abad844a47df1e6537f73ddfbf1bc997ec/kiwisolver-1.4.8-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:1c8ceb754339793c24aee1c9fb2485b5b1f5bb1c2c214ff13368431e51fc9a09", size = 124156, upload-time = "2024-12-24T18:29:45.368Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2d/f13d06998b546a2ad4f48607a146e045bbe48030774de29f90bdc573df15/kiwisolver-1.4.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a62808ac74b5e55a04a408cda6156f986cefbcf0ada13572696b507cc92fa1", size = 66555, upload-time = "2024-12-24T18:29:46.37Z" }, + { url = "https://files.pythonhosted.org/packages/59/e3/b8bd14b0a54998a9fd1e8da591c60998dc003618cb19a3f94cb233ec1511/kiwisolver-1.4.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:68269e60ee4929893aad82666821aaacbd455284124817af45c11e50a4b42e3c", size = 65071, upload-time = "2024-12-24T18:29:47.333Z" }, + { url = "https://files.pythonhosted.org/packages/f0/1c/6c86f6d85ffe4d0ce04228d976f00674f1df5dc893bf2dd4f1928748f187/kiwisolver-1.4.8-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:34d142fba9c464bc3bbfeff15c96eab0e7310343d6aefb62a79d51421fcc5f1b", size = 1378053, upload-time = "2024-12-24T18:29:49.636Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b9/1c6e9f6dcb103ac5cf87cb695845f5fa71379021500153566d8a8a9fc291/kiwisolver-1.4.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ddc373e0eef45b59197de815b1b28ef89ae3955e7722cc9710fb91cd77b7f47", size = 1472278, upload-time = "2024-12-24T18:29:51.164Z" }, + { url = "https://files.pythonhosted.org/packages/ee/81/aca1eb176de671f8bda479b11acdc42c132b61a2ac861c883907dde6debb/kiwisolver-1.4.8-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:77e6f57a20b9bd4e1e2cedda4d0b986ebd0216236f0106e55c28aea3d3d69b16", size = 1478139, upload-time = "2024-12-24T18:29:52.594Z" }, + { url = "https://files.pythonhosted.org/packages/49/f4/e081522473671c97b2687d380e9e4c26f748a86363ce5af48b4a28e48d06/kiwisolver-1.4.8-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08e77738ed7538f036cd1170cbed942ef749137b1311fa2bbe2a7fda2f6bf3cc", size = 1413517, upload-time = "2024-12-24T18:29:53.941Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e9/6a7d025d8da8c4931522922cd706105aa32b3291d1add8c5427cdcd66e63/kiwisolver-1.4.8-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a5ce1e481a74b44dd5e92ff03ea0cb371ae7a0268318e202be06c8f04f4f1246", size = 1474952, upload-time = "2024-12-24T18:29:56.523Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/13fa685ae167bee5d94b415991c4fc7bb0a1b6ebea6e753a87044b209678/kiwisolver-1.4.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fc2ace710ba7c1dfd1a3b42530b62b9ceed115f19a1656adefce7b1782a37794", size = 2269132, upload-time = "2024-12-24T18:29:57.989Z" }, + { url = "https://files.pythonhosted.org/packages/ef/92/bb7c9395489b99a6cb41d502d3686bac692586db2045adc19e45ee64ed23/kiwisolver-1.4.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3452046c37c7692bd52b0e752b87954ef86ee2224e624ef7ce6cb21e8c41cc1b", size = 2425997, upload-time = "2024-12-24T18:29:59.393Z" }, + { url = "https://files.pythonhosted.org/packages/ed/12/87f0e9271e2b63d35d0d8524954145837dd1a6c15b62a2d8c1ebe0f182b4/kiwisolver-1.4.8-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7e9a60b50fe8b2ec6f448fe8d81b07e40141bfced7f896309df271a0b92f80f3", size = 2376060, upload-time = "2024-12-24T18:30:01.338Z" }, + { url = "https://files.pythonhosted.org/packages/02/6e/c8af39288edbce8bf0fa35dee427b082758a4b71e9c91ef18fa667782138/kiwisolver-1.4.8-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:918139571133f366e8362fa4a297aeba86c7816b7ecf0bc79168080e2bd79957", size = 2520471, upload-time = "2024-12-24T18:30:04.574Z" }, + { url = "https://files.pythonhosted.org/packages/13/78/df381bc7b26e535c91469f77f16adcd073beb3e2dd25042efd064af82323/kiwisolver-1.4.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e063ef9f89885a1d68dd8b2e18f5ead48653176d10a0e324e3b0030e3a69adeb", size = 2338793, upload-time = "2024-12-24T18:30:06.25Z" }, + { url = "https://files.pythonhosted.org/packages/d0/dc/c1abe38c37c071d0fc71c9a474fd0b9ede05d42f5a458d584619cfd2371a/kiwisolver-1.4.8-cp313-cp313-win_amd64.whl", hash = "sha256:a17b7c4f5b2c51bb68ed379defd608a03954a1845dfed7cc0117f1cc8a9b7fd2", size = 71855, upload-time = "2024-12-24T18:30:07.535Z" }, + { url = "https://files.pythonhosted.org/packages/a0/b6/21529d595b126ac298fdd90b705d87d4c5693de60023e0efcb4f387ed99e/kiwisolver-1.4.8-cp313-cp313-win_arm64.whl", hash = "sha256:3cd3bc628b25f74aedc6d374d5babf0166a92ff1317f46267f12d2ed54bc1d30", size = 65430, upload-time = "2024-12-24T18:30:08.504Z" }, + { url = "https://files.pythonhosted.org/packages/34/bd/b89380b7298e3af9b39f49334e3e2a4af0e04819789f04b43d560516c0c8/kiwisolver-1.4.8-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:370fd2df41660ed4e26b8c9d6bbcad668fbe2560462cba151a721d49e5b6628c", size = 126294, upload-time = "2024-12-24T18:30:09.508Z" }, + { url = "https://files.pythonhosted.org/packages/83/41/5857dc72e5e4148eaac5aa76e0703e594e4465f8ab7ec0fc60e3a9bb8fea/kiwisolver-1.4.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:84a2f830d42707de1d191b9490ac186bf7997a9495d4e9072210a1296345f7dc", size = 67736, upload-time = "2024-12-24T18:30:11.039Z" }, + { url = "https://files.pythonhosted.org/packages/e1/d1/be059b8db56ac270489fb0b3297fd1e53d195ba76e9bbb30e5401fa6b759/kiwisolver-1.4.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7a3ad337add5148cf51ce0b55642dc551c0b9d6248458a757f98796ca7348712", size = 66194, upload-time = "2024-12-24T18:30:14.886Z" }, + { url = "https://files.pythonhosted.org/packages/e1/83/4b73975f149819eb7dcf9299ed467eba068ecb16439a98990dcb12e63fdd/kiwisolver-1.4.8-cp313-cp313t-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7506488470f41169b86d8c9aeff587293f530a23a23a49d6bc64dab66bedc71e", size = 1465942, upload-time = "2024-12-24T18:30:18.927Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2c/30a5cdde5102958e602c07466bce058b9d7cb48734aa7a4327261ac8e002/kiwisolver-1.4.8-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f0121b07b356a22fb0414cec4666bbe36fd6d0d759db3d37228f496ed67c880", size = 1595341, upload-time = "2024-12-24T18:30:22.102Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9b/1e71db1c000385aa069704f5990574b8244cce854ecd83119c19e83c9586/kiwisolver-1.4.8-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d6d6bd87df62c27d4185de7c511c6248040afae67028a8a22012b010bc7ad062", size = 1598455, upload-time = "2024-12-24T18:30:24.947Z" }, + { url = "https://files.pythonhosted.org/packages/85/92/c8fec52ddf06231b31cbb779af77e99b8253cd96bd135250b9498144c78b/kiwisolver-1.4.8-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:291331973c64bb9cce50bbe871fb2e675c4331dab4f31abe89f175ad7679a4d7", size = 1522138, upload-time = "2024-12-24T18:30:26.286Z" }, + { url = "https://files.pythonhosted.org/packages/0b/51/9eb7e2cd07a15d8bdd976f6190c0164f92ce1904e5c0c79198c4972926b7/kiwisolver-1.4.8-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:893f5525bb92d3d735878ec00f781b2de998333659507d29ea4466208df37bed", size = 1582857, upload-time = "2024-12-24T18:30:28.86Z" }, + { url = "https://files.pythonhosted.org/packages/0f/95/c5a00387a5405e68ba32cc64af65ce881a39b98d73cc394b24143bebc5b8/kiwisolver-1.4.8-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b47a465040146981dc9db8647981b8cb96366fbc8d452b031e4f8fdffec3f26d", size = 2293129, upload-time = "2024-12-24T18:30:30.34Z" }, + { url = "https://files.pythonhosted.org/packages/44/83/eeb7af7d706b8347548313fa3a3a15931f404533cc54fe01f39e830dd231/kiwisolver-1.4.8-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:99cea8b9dd34ff80c521aef46a1dddb0dcc0283cf18bde6d756f1e6f31772165", size = 2421538, upload-time = "2024-12-24T18:30:33.334Z" }, + { url = "https://files.pythonhosted.org/packages/05/f9/27e94c1b3eb29e6933b6986ffc5fa1177d2cd1f0c8efc5f02c91c9ac61de/kiwisolver-1.4.8-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:151dffc4865e5fe6dafce5480fab84f950d14566c480c08a53c663a0020504b6", size = 2390661, upload-time = "2024-12-24T18:30:34.939Z" }, + { url = "https://files.pythonhosted.org/packages/d9/d4/3c9735faa36ac591a4afcc2980d2691000506050b7a7e80bcfe44048daa7/kiwisolver-1.4.8-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:577facaa411c10421314598b50413aa1ebcf5126f704f1e5d72d7e4e9f020d90", size = 2546710, upload-time = "2024-12-24T18:30:37.281Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fa/be89a49c640930180657482a74970cdcf6f7072c8d2471e1babe17a222dc/kiwisolver-1.4.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:be4816dc51c8a471749d664161b434912eee82f2ea66bd7628bd14583a833e85", size = 2349213, upload-time = "2024-12-24T18:30:40.019Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f9/ae81c47a43e33b93b0a9819cac6723257f5da2a5a60daf46aa5c7226ea85/kiwisolver-1.4.8-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:e7a019419b7b510f0f7c9dceff8c5eae2392037eae483a7f9162625233802b0a", size = 60403, upload-time = "2024-12-24T18:30:41.372Z" }, + { url = "https://files.pythonhosted.org/packages/58/ca/f92b5cb6f4ce0c1ebfcfe3e2e42b96917e16f7090e45b21102941924f18f/kiwisolver-1.4.8-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:286b18e86682fd2217a48fc6be6b0f20c1d0ed10958d8dc53453ad58d7be0bf8", size = 58657, upload-time = "2024-12-24T18:30:42.392Z" }, + { url = "https://files.pythonhosted.org/packages/80/28/ae0240f732f0484d3a4dc885d055653c47144bdf59b670aae0ec3c65a7c8/kiwisolver-1.4.8-pp310-pypy310_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4191ee8dfd0be1c3666ccbac178c5a05d5f8d689bbe3fc92f3c4abec817f8fe0", size = 84948, upload-time = "2024-12-24T18:30:44.703Z" }, + { url = "https://files.pythonhosted.org/packages/5d/eb/78d50346c51db22c7203c1611f9b513075f35c4e0e4877c5dde378d66043/kiwisolver-1.4.8-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7cd2785b9391f2873ad46088ed7599a6a71e762e1ea33e87514b1a441ed1da1c", size = 81186, upload-time = "2024-12-24T18:30:45.654Z" }, + { url = "https://files.pythonhosted.org/packages/43/f8/7259f18c77adca88d5f64f9a522792e178b2691f3748817a8750c2d216ef/kiwisolver-1.4.8-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c07b29089b7ba090b6f1a669f1411f27221c3662b3a1b7010e67b59bb5a6f10b", size = 80279, upload-time = "2024-12-24T18:30:47.951Z" }, + { url = "https://files.pythonhosted.org/packages/3a/1d/50ad811d1c5dae091e4cf046beba925bcae0a610e79ae4c538f996f63ed5/kiwisolver-1.4.8-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:65ea09a5a3faadd59c2ce96dc7bf0f364986a315949dc6374f04396b0d60e09b", size = 71762, upload-time = "2024-12-24T18:30:48.903Z" }, +] + +[[package]] +name = "locket" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/83/97b29fe05cb6ae28d2dbd30b81e2e402a3eed5f460c26e9eaa5895ceacf5/locket-1.0.0.tar.gz", hash = "sha256:5c0d4c052a8bbbf750e056a8e65ccd309086f4f0f18a2eac306a8dfa4112a632", size = 4350, upload-time = "2022-04-20T22:04:44.312Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/bc/83e112abc66cd466c6b83f99118035867cecd41802f8d044638aa78a106e/locket-1.0.0-py2.py3-none-any.whl", hash = "sha256:b6c819a722f7b6bd955b80781788e4a66a55628b858d347536b7e81325a3a5e3", size = 4398, upload-time = "2022-04-20T22:04:42.23Z" }, +] + +[[package]] +name = "markdown" +version = "3.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/15/222b423b0b88689c266d9eac4e61396fe2cc53464459d6a37618ac863b24/markdown-3.8.tar.gz", hash = "sha256:7df81e63f0df5c4b24b7d156eb81e4690595239b7d70937d0409f1b0de319c6f", size = 360906, upload-time = "2025-04-11T14:42:50.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/3f/afe76f8e2246ffbc867440cbcf90525264df0e658f8a5ca1f872b3f6192a/markdown-3.8-py3-none-any.whl", hash = "sha256:794a929b79c5af141ef5ab0f2f642d0f7b1872981250230e72682346f7cc90dc", size = 106210, upload-time = "2025-04-11T14:42:49.178Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/90/d08277ce111dd22f77149fd1a5d4653eeb3b3eaacbdfcbae5afb2600eebd/MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8", size = 14357, upload-time = "2024-10-18T15:20:51.44Z" }, + { url = "https://files.pythonhosted.org/packages/04/e1/6e2194baeae0bca1fae6629dc0cbbb968d4d941469cbab11a3872edff374/MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158", size = 12393, upload-time = "2024-10-18T15:20:52.426Z" }, + { url = "https://files.pythonhosted.org/packages/1d/69/35fa85a8ece0a437493dc61ce0bb6d459dcba482c34197e3efc829aa357f/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579", size = 21732, upload-time = "2024-10-18T15:20:53.578Z" }, + { url = "https://files.pythonhosted.org/packages/22/35/137da042dfb4720b638d2937c38a9c2df83fe32d20e8c8f3185dbfef05f7/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d", size = 20866, upload-time = "2024-10-18T15:20:55.06Z" }, + { url = "https://files.pythonhosted.org/packages/29/28/6d029a903727a1b62edb51863232152fd335d602def598dade38996887f0/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb", size = 20964, upload-time = "2024-10-18T15:20:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/cc/cd/07438f95f83e8bc028279909d9c9bd39e24149b0d60053a97b2bc4f8aa51/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b", size = 21977, upload-time = "2024-10-18T15:20:57.189Z" }, + { url = "https://files.pythonhosted.org/packages/29/01/84b57395b4cc062f9c4c55ce0df7d3108ca32397299d9df00fedd9117d3d/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c", size = 21366, upload-time = "2024-10-18T15:20:58.235Z" }, + { url = "https://files.pythonhosted.org/packages/bd/6e/61ebf08d8940553afff20d1fb1ba7294b6f8d279df9fd0c0db911b4bbcfd/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171", size = 21091, upload-time = "2024-10-18T15:20:59.235Z" }, + { url = "https://files.pythonhosted.org/packages/11/23/ffbf53694e8c94ebd1e7e491de185124277964344733c45481f32ede2499/MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50", size = 15065, upload-time = "2024-10-18T15:21:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/44/06/e7175d06dd6e9172d4a69a72592cb3f7a996a9c396eee29082826449bbc3/MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a", size = 15514, upload-time = "2024-10-18T15:21:01.122Z" }, + { url = "https://files.pythonhosted.org/packages/6b/28/bbf83e3f76936960b850435576dd5e67034e200469571be53f69174a2dfd/MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d", size = 14353, upload-time = "2024-10-18T15:21:02.187Z" }, + { url = "https://files.pythonhosted.org/packages/6c/30/316d194b093cde57d448a4c3209f22e3046c5bb2fb0820b118292b334be7/MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93", size = 12392, upload-time = "2024-10-18T15:21:02.941Z" }, + { url = "https://files.pythonhosted.org/packages/f2/96/9cdafba8445d3a53cae530aaf83c38ec64c4d5427d975c974084af5bc5d2/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832", size = 23984, upload-time = "2024-10-18T15:21:03.953Z" }, + { url = "https://files.pythonhosted.org/packages/f1/a4/aefb044a2cd8d7334c8a47d3fb2c9f328ac48cb349468cc31c20b539305f/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84", size = 23120, upload-time = "2024-10-18T15:21:06.495Z" }, + { url = "https://files.pythonhosted.org/packages/8d/21/5e4851379f88f3fad1de30361db501300d4f07bcad047d3cb0449fc51f8c/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca", size = 23032, upload-time = "2024-10-18T15:21:07.295Z" }, + { url = "https://files.pythonhosted.org/packages/00/7b/e92c64e079b2d0d7ddf69899c98842f3f9a60a1ae72657c89ce2655c999d/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798", size = 24057, upload-time = "2024-10-18T15:21:08.073Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ac/46f960ca323037caa0a10662ef97d0a4728e890334fc156b9f9e52bcc4ca/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e", size = 23359, upload-time = "2024-10-18T15:21:09.318Z" }, + { url = "https://files.pythonhosted.org/packages/69/84/83439e16197337b8b14b6a5b9c2105fff81d42c2a7c5b58ac7b62ee2c3b1/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4", size = 23306, upload-time = "2024-10-18T15:21:10.185Z" }, + { url = "https://files.pythonhosted.org/packages/9a/34/a15aa69f01e2181ed8d2b685c0d2f6655d5cca2c4db0ddea775e631918cd/MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d", size = 15094, upload-time = "2024-10-18T15:21:11.005Z" }, + { url = "https://files.pythonhosted.org/packages/da/b8/3a3bd761922d416f3dc5d00bfbed11f66b1ab89a0c2b6e887240a30b0f6b/MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b", size = 15521, upload-time = "2024-10-18T15:21:12.911Z" }, + { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274, upload-time = "2024-10-18T15:21:13.777Z" }, + { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348, upload-time = "2024-10-18T15:21:14.822Z" }, + { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149, upload-time = "2024-10-18T15:21:15.642Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118, upload-time = "2024-10-18T15:21:17.133Z" }, + { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993, upload-time = "2024-10-18T15:21:18.064Z" }, + { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178, upload-time = "2024-10-18T15:21:18.859Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319, upload-time = "2024-10-18T15:21:19.671Z" }, + { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352, upload-time = "2024-10-18T15:21:20.971Z" }, + { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097, upload-time = "2024-10-18T15:21:22.646Z" }, + { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601, upload-time = "2024-10-18T15:21:23.499Z" }, + { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" }, + { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" }, + { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" }, + { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" }, + { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" }, + { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" }, + { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" }, + { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" }, + { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" }, + { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" }, + { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" }, + { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" }, + { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" }, + { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" }, + { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" }, + { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" }, +] + +[[package]] +name = "matplotlib" +version = "3.10.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/76/d3c6e3a13fe484ebe7718d14e269c9569c4eb0020a968a327acb3b9a8fe6/matplotlib-3.10.8.tar.gz", hash = "sha256:2299372c19d56bcd35cf05a2738308758d32b9eaed2371898d8f5bd33f084aa3", size = 34806269, upload-time = "2025-12-10T22:56:51.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/be/a30bd917018ad220c400169fba298f2bb7003c8ccbc0c3e24ae2aacad1e8/matplotlib-3.10.8-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:00270d217d6b20d14b584c521f810d60c5c78406dc289859776550df837dcda7", size = 8239828, upload-time = "2025-12-10T22:55:02.313Z" }, + { url = "https://files.pythonhosted.org/packages/58/27/ca01e043c4841078e82cf6e80a6993dfecd315c3d79f5f3153afbb8e1ec6/matplotlib-3.10.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:37b3c1cc42aa184b3f738cfa18c1c1d72fd496d85467a6cf7b807936d39aa656", size = 8128050, upload-time = "2025-12-10T22:55:04.997Z" }, + { url = "https://files.pythonhosted.org/packages/cb/aa/7ab67f2b729ae6a91bcf9dcac0affb95fb8c56f7fd2b2af894ae0b0cf6fa/matplotlib-3.10.8-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee40c27c795bda6a5292e9cff9890189d32f7e3a0bf04e0e3c9430c4a00c37df", size = 8700452, upload-time = "2025-12-10T22:55:07.47Z" }, + { url = "https://files.pythonhosted.org/packages/73/ae/2d5817b0acee3c49b7e7ccfbf5b273f284957cc8e270adf36375db353190/matplotlib-3.10.8-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a48f2b74020919552ea25d222d5cc6af9ca3f4eb43a93e14d068457f545c2a17", size = 9534928, upload-time = "2025-12-10T22:55:10.566Z" }, + { url = "https://files.pythonhosted.org/packages/c9/5b/8e66653e9f7c39cb2e5cab25fce4810daffa2bff02cbf5f3077cea9e942c/matplotlib-3.10.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f254d118d14a7f99d616271d6c3c27922c092dac11112670b157798b89bf4933", size = 9586377, upload-time = "2025-12-10T22:55:12.362Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e2/fd0bbadf837f81edb0d208ba8f8cb552874c3b16e27cb91a31977d90875d/matplotlib-3.10.8-cp310-cp310-win_amd64.whl", hash = "sha256:f9b587c9c7274c1613a30afabf65a272114cd6cdbe67b3406f818c79d7ab2e2a", size = 8128127, upload-time = "2025-12-10T22:55:14.436Z" }, + { url = "https://files.pythonhosted.org/packages/f8/86/de7e3a1cdcfc941483af70609edc06b83e7c8a0e0dc9ac325200a3f4d220/matplotlib-3.10.8-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6be43b667360fef5c754dda5d25a32e6307a03c204f3c0fc5468b78fa87b4160", size = 8251215, upload-time = "2025-12-10T22:55:16.175Z" }, + { url = "https://files.pythonhosted.org/packages/fd/14/baad3222f424b19ce6ad243c71de1ad9ec6b2e4eb1e458a48fdc6d120401/matplotlib-3.10.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2b336e2d91a3d7006864e0990c83b216fcdca64b5a6484912902cef87313d78", size = 8139625, upload-time = "2025-12-10T22:55:17.712Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a0/7024215e95d456de5883e6732e708d8187d9753a21d32f8ddb3befc0c445/matplotlib-3.10.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efb30e3baaea72ce5928e32bab719ab4770099079d66726a62b11b1ef7273be4", size = 8712614, upload-time = "2025-12-10T22:55:20.8Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f4/b8347351da9a5b3f41e26cf547252d861f685c6867d179a7c9d60ad50189/matplotlib-3.10.8-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d56a1efd5bfd61486c8bc968fa18734464556f0fb8e51690f4ac25d85cbbbbc2", size = 9540997, upload-time = "2025-12-10T22:55:23.258Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c0/c7b914e297efe0bc36917bf216b2acb91044b91e930e878ae12981e461e5/matplotlib-3.10.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:238b7ce5717600615c895050239ec955d91f321c209dd110db988500558e70d6", size = 9596825, upload-time = "2025-12-10T22:55:25.217Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d3/a4bbc01c237ab710a1f22b4da72f4ff6d77eb4c7735ea9811a94ae239067/matplotlib-3.10.8-cp311-cp311-win_amd64.whl", hash = "sha256:18821ace09c763ec93aef5eeff087ee493a24051936d7b9ebcad9662f66501f9", size = 8135090, upload-time = "2025-12-10T22:55:27.162Z" }, + { url = "https://files.pythonhosted.org/packages/89/dd/a0b6588f102beab33ca6f5218b31725216577b2a24172f327eaf6417d5c9/matplotlib-3.10.8-cp311-cp311-win_arm64.whl", hash = "sha256:bab485bcf8b1c7d2060b4fcb6fc368a9e6f4cd754c9c2fea281f4be21df394a2", size = 8012377, upload-time = "2025-12-10T22:55:29.185Z" }, + { url = "https://files.pythonhosted.org/packages/9e/67/f997cdcbb514012eb0d10cd2b4b332667997fb5ebe26b8d41d04962fa0e6/matplotlib-3.10.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:64fcc24778ca0404ce0cb7b6b77ae1f4c7231cdd60e6778f999ee05cbd581b9a", size = 8260453, upload-time = "2025-12-10T22:55:30.709Z" }, + { url = "https://files.pythonhosted.org/packages/7e/65/07d5f5c7f7c994f12c768708bd2e17a4f01a2b0f44a1c9eccad872433e2e/matplotlib-3.10.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9a5ca4ac220a0cdd1ba6bcba3608547117d30468fefce49bb26f55c1a3d5c58", size = 8148321, upload-time = "2025-12-10T22:55:33.265Z" }, + { url = "https://files.pythonhosted.org/packages/3e/f3/c5195b1ae57ef85339fd7285dfb603b22c8b4e79114bae5f4f0fcf688677/matplotlib-3.10.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3ab4aabc72de4ff77b3ec33a6d78a68227bf1123465887f9905ba79184a1cc04", size = 8716944, upload-time = "2025-12-10T22:55:34.922Z" }, + { url = "https://files.pythonhosted.org/packages/00/f9/7638f5cc82ec8a7aa005de48622eecc3ed7c9854b96ba15bd76b7fd27574/matplotlib-3.10.8-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24d50994d8c5816ddc35411e50a86ab05f575e2530c02752e02538122613371f", size = 9550099, upload-time = "2025-12-10T22:55:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/57/61/78cd5920d35b29fd2a0fe894de8adf672ff52939d2e9b43cb83cd5ce1bc7/matplotlib-3.10.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:99eefd13c0dc3b3c1b4d561c1169e65fe47aab7b8158754d7c084088e2329466", size = 9613040, upload-time = "2025-12-10T22:55:38.715Z" }, + { url = "https://files.pythonhosted.org/packages/30/4e/c10f171b6e2f44d9e3a2b96efa38b1677439d79c99357600a62cc1e9594e/matplotlib-3.10.8-cp312-cp312-win_amd64.whl", hash = "sha256:dd80ecb295460a5d9d260df63c43f4afbdd832d725a531f008dad1664f458adf", size = 8142717, upload-time = "2025-12-10T22:55:41.103Z" }, + { url = "https://files.pythonhosted.org/packages/f1/76/934db220026b5fef85f45d51a738b91dea7d70207581063cd9bd8fafcf74/matplotlib-3.10.8-cp312-cp312-win_arm64.whl", hash = "sha256:3c624e43ed56313651bc18a47f838b60d7b8032ed348911c54906b130b20071b", size = 8012751, upload-time = "2025-12-10T22:55:42.684Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b9/15fd5541ef4f5b9a17eefd379356cf12175fe577424e7b1d80676516031a/matplotlib-3.10.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3f2e409836d7f5ac2f1c013110a4d50b9f7edc26328c108915f9075d7d7a91b6", size = 8261076, upload-time = "2025-12-10T22:55:44.648Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a0/2ba3473c1b66b9c74dc7107c67e9008cb1782edbe896d4c899d39ae9cf78/matplotlib-3.10.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56271f3dac49a88d7fca5060f004d9d22b865f743a12a23b1e937a0be4818ee1", size = 8148794, upload-time = "2025-12-10T22:55:46.252Z" }, + { url = "https://files.pythonhosted.org/packages/75/97/a471f1c3eb1fd6f6c24a31a5858f443891d5127e63a7788678d14e249aea/matplotlib-3.10.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a0a7f52498f72f13d4a25ea70f35f4cb60642b466cbb0a9be951b5bc3f45a486", size = 8718474, upload-time = "2025-12-10T22:55:47.864Z" }, + { url = "https://files.pythonhosted.org/packages/01/be/cd478f4b66f48256f42927d0acbcd63a26a893136456cd079c0cc24fbabf/matplotlib-3.10.8-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:646d95230efb9ca614a7a594d4fcacde0ac61d25e37dd51710b36477594963ce", size = 9549637, upload-time = "2025-12-10T22:55:50.048Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/8dc289776eae5109e268c4fb92baf870678dc048a25d4ac903683b86d5bf/matplotlib-3.10.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f89c151aab2e2e23cb3fe0acad1e8b82841fd265379c4cecd0f3fcb34c15e0f6", size = 9613678, upload-time = "2025-12-10T22:55:52.21Z" }, + { url = "https://files.pythonhosted.org/packages/64/40/37612487cc8a437d4dd261b32ca21fe2d79510fe74af74e1f42becb1bdb8/matplotlib-3.10.8-cp313-cp313-win_amd64.whl", hash = "sha256:e8ea3e2d4066083e264e75c829078f9e149fa119d27e19acd503de65e0b13149", size = 8142686, upload-time = "2025-12-10T22:55:54.253Z" }, + { url = "https://files.pythonhosted.org/packages/66/52/8d8a8730e968185514680c2a6625943f70269509c3dcfc0dcf7d75928cb8/matplotlib-3.10.8-cp313-cp313-win_arm64.whl", hash = "sha256:c108a1d6fa78a50646029cb6d49808ff0fc1330fda87fa6f6250c6b5369b6645", size = 8012917, upload-time = "2025-12-10T22:55:56.268Z" }, + { url = "https://files.pythonhosted.org/packages/b5/27/51fe26e1062f298af5ef66343d8ef460e090a27fea73036c76c35821df04/matplotlib-3.10.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ad3d9833a64cf48cc4300f2b406c3d0f4f4724a91c0bd5640678a6ba7c102077", size = 8305679, upload-time = "2025-12-10T22:55:57.856Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/4de865bc591ac8e3062e835f42dd7fe7a93168d519557837f0e37513f629/matplotlib-3.10.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:eb3823f11823deade26ce3b9f40dcb4a213da7a670013929f31d5f5ed1055b22", size = 8198336, upload-time = "2025-12-10T22:55:59.371Z" }, + { url = "https://files.pythonhosted.org/packages/c6/cb/2f7b6e75fb4dce87ef91f60cac4f6e34f4c145ab036a22318ec837971300/matplotlib-3.10.8-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d9050fee89a89ed57b4fb2c1bfac9a3d0c57a0d55aed95949eedbc42070fea39", size = 8731653, upload-time = "2025-12-10T22:56:01.032Z" }, + { url = "https://files.pythonhosted.org/packages/46/b3/bd9c57d6ba670a37ab31fb87ec3e8691b947134b201f881665b28cc039ff/matplotlib-3.10.8-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b44d07310e404ba95f8c25aa5536f154c0a8ec473303535949e52eb71d0a1565", size = 9561356, upload-time = "2025-12-10T22:56:02.95Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3d/8b94a481456dfc9dfe6e39e93b5ab376e50998cddfd23f4ae3b431708f16/matplotlib-3.10.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0a33deb84c15ede243aead39f77e990469fff93ad1521163305095b77b72ce4a", size = 9614000, upload-time = "2025-12-10T22:56:05.411Z" }, + { url = "https://files.pythonhosted.org/packages/bd/cd/bc06149fe5585ba800b189a6a654a75f1f127e8aab02fd2be10df7fa500c/matplotlib-3.10.8-cp313-cp313t-win_amd64.whl", hash = "sha256:3a48a78d2786784cc2413e57397981fb45c79e968d99656706018d6e62e57958", size = 8220043, upload-time = "2025-12-10T22:56:07.551Z" }, + { url = "https://files.pythonhosted.org/packages/e3/de/b22cf255abec916562cc04eef457c13e58a1990048de0c0c3604d082355e/matplotlib-3.10.8-cp313-cp313t-win_arm64.whl", hash = "sha256:15d30132718972c2c074cd14638c7f4592bd98719e2308bccea40e0538bc0cb5", size = 8062075, upload-time = "2025-12-10T22:56:09.178Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/9c0ff7a2f11615e516c3b058e1e6e8f9614ddeca53faca06da267c48345d/matplotlib-3.10.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b53285e65d4fa4c86399979e956235deb900be5baa7fc1218ea67fbfaeaadd6f", size = 8262481, upload-time = "2025-12-10T22:56:10.885Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ca/e8ae28649fcdf039fda5ef554b40a95f50592a3c47e6f7270c9561c12b07/matplotlib-3.10.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32f8dce744be5569bebe789e46727946041199030db8aeb2954d26013a0eb26b", size = 8151473, upload-time = "2025-12-10T22:56:12.377Z" }, + { url = "https://files.pythonhosted.org/packages/f1/6f/009d129ae70b75e88cbe7e503a12a4c0670e08ed748a902c2568909e9eb5/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cf267add95b1c88300d96ca837833d4112756045364f5c734a2276038dae27d", size = 9553896, upload-time = "2025-12-10T22:56:14.432Z" }, + { url = "https://files.pythonhosted.org/packages/f5/26/4221a741eb97967bc1fd5e4c52b9aa5a91b2f4ec05b59f6def4d820f9df9/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2cf5bd12cecf46908f286d7838b2abc6c91cda506c0445b8223a7c19a00df008", size = 9824193, upload-time = "2025-12-10T22:56:16.29Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/3abf75f38605772cf48a9daf5821cd4f563472f38b4b828c6fba6fa6d06e/matplotlib-3.10.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:41703cc95688f2516b480f7f339d8851a6035f18e100ee6a32bc0b8536a12a9c", size = 9615444, upload-time = "2025-12-10T22:56:18.155Z" }, + { url = "https://files.pythonhosted.org/packages/93/a5/de89ac80f10b8dc615807ee1133cd99ac74082581196d4d9590bea10690d/matplotlib-3.10.8-cp314-cp314-win_amd64.whl", hash = "sha256:83d282364ea9f3e52363da262ce32a09dfe241e4080dcedda3c0db059d3c1f11", size = 8272719, upload-time = "2025-12-10T22:56:20.366Z" }, + { url = "https://files.pythonhosted.org/packages/69/ce/b006495c19ccc0a137b48083168a37bd056392dee02f87dba0472f2797fe/matplotlib-3.10.8-cp314-cp314-win_arm64.whl", hash = "sha256:2c1998e92cd5999e295a731bcb2911c75f597d937341f3030cc24ef2733d78a8", size = 8144205, upload-time = "2025-12-10T22:56:22.239Z" }, + { url = "https://files.pythonhosted.org/packages/68/d9/b31116a3a855bd313c6fcdb7226926d59b041f26061c6c5b1be66a08c826/matplotlib-3.10.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b5a2b97dbdc7d4f353ebf343744f1d1f1cca8aa8bfddb4262fcf4306c3761d50", size = 8305785, upload-time = "2025-12-10T22:56:24.218Z" }, + { url = "https://files.pythonhosted.org/packages/1e/90/6effe8103f0272685767ba5f094f453784057072f49b393e3ea178fe70a5/matplotlib-3.10.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3f5c3e4da343bba819f0234186b9004faba952cc420fbc522dc4e103c1985908", size = 8198361, upload-time = "2025-12-10T22:56:26.787Z" }, + { url = "https://files.pythonhosted.org/packages/d7/65/a73188711bea603615fc0baecca1061429ac16940e2385433cc778a9d8e7/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f62550b9a30afde8c1c3ae450e5eb547d579dd69b25c2fc7a1c67f934c1717a", size = 9561357, upload-time = "2025-12-10T22:56:28.953Z" }, + { url = "https://files.pythonhosted.org/packages/f4/3d/b5c5d5d5be8ce63292567f0e2c43dde9953d3ed86ac2de0a72e93c8f07a1/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:495672de149445ec1b772ff2c9ede9b769e3cb4f0d0aa7fa730d7f59e2d4e1c1", size = 9823610, upload-time = "2025-12-10T22:56:31.455Z" }, + { url = "https://files.pythonhosted.org/packages/4d/4b/e7beb6bbd49f6bae727a12b270a2654d13c397576d25bd6786e47033300f/matplotlib-3.10.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:595ba4d8fe983b88f0eec8c26a241e16d6376fe1979086232f481f8f3f67494c", size = 9614011, upload-time = "2025-12-10T22:56:33.85Z" }, + { url = "https://files.pythonhosted.org/packages/7c/e6/76f2813d31f032e65f6f797e3f2f6e4aab95b65015924b1c51370395c28a/matplotlib-3.10.8-cp314-cp314t-win_amd64.whl", hash = "sha256:25d380fe8b1dc32cf8f0b1b448470a77afb195438bafdf1d858bfb876f3edf7b", size = 8362801, upload-time = "2025-12-10T22:56:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/5d/49/d651878698a0b67f23aa28e17f45a6d6dd3d3f933fa29087fa4ce5947b5a/matplotlib-3.10.8-cp314-cp314t-win_arm64.whl", hash = "sha256:113bb52413ea508ce954a02c10ffd0d565f9c3bc7f2eddc27dfe1731e71c7b5f", size = 8192560, upload-time = "2025-12-10T22:56:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/f5/43/31d59500bb950b0d188e149a2e552040528c13d6e3d6e84d0cccac593dcd/matplotlib-3.10.8-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:f97aeb209c3d2511443f8797e3e5a569aebb040d4f8bc79aa3ee78a8fb9e3dd8", size = 8237252, upload-time = "2025-12-10T22:56:39.529Z" }, + { url = "https://files.pythonhosted.org/packages/0c/2c/615c09984f3c5f907f51c886538ad785cf72e0e11a3225de2c0f9442aecc/matplotlib-3.10.8-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:fb061f596dad3a0f52b60dc6a5dec4a0c300dec41e058a7efe09256188d170b7", size = 8124693, upload-time = "2025-12-10T22:56:41.758Z" }, + { url = "https://files.pythonhosted.org/packages/91/e1/2757277a1c56041e1fc104b51a0f7b9a4afc8eb737865d63cababe30bc61/matplotlib-3.10.8-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:12d90df9183093fcd479f4172ac26b322b1248b15729cb57f42f71f24c7e37a3", size = 8702205, upload-time = "2025-12-10T22:56:43.415Z" }, + { url = "https://files.pythonhosted.org/packages/04/30/3afaa31c757f34b7725ab9d2ba8b48b5e89c2019c003e7d0ead143aabc5a/matplotlib-3.10.8-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6da7c2ce169267d0d066adcf63758f0604aa6c3eebf67458930f9d9b79ad1db1", size = 8249198, upload-time = "2025-12-10T22:56:45.584Z" }, + { url = "https://files.pythonhosted.org/packages/48/2f/6334aec331f57485a642a7c8be03cb286f29111ae71c46c38b363230063c/matplotlib-3.10.8-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9153c3292705be9f9c64498a8872118540c3f4123d1a1c840172edf262c8be4a", size = 8136817, upload-time = "2025-12-10T22:56:47.339Z" }, + { url = "https://files.pythonhosted.org/packages/73/e4/6d6f14b2a759c622f191b2d67e9075a3f56aaccb3be4bb9bb6890030d0a0/matplotlib-3.10.8-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ae029229a57cd1e8fe542485f27e7ca7b23aa9e8944ddb4985d0bc444f1eca2", size = 8713867, upload-time = "2025-12-10T22:56:48.954Z" }, +] + +[[package]] +name = "matplotlib-inline" +version = "0.1.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/5b/a36a337438a14116b16480db471ad061c36c3694df7c2084a0da7ba538b7/matplotlib_inline-0.1.7.tar.gz", hash = "sha256:8423b23ec666be3d16e16b60bdd8ac4e86e840ebd1dd11a30b9f117f2fa0ab90", size = 8159, upload-time = "2024-04-15T13:44:44.803Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/8e/9ad090d3553c280a8060fbf6e24dc1c0c29704ee7d1c372f0c174aa59285/matplotlib_inline-0.1.7-py3-none-any.whl", hash = "sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca", size = 9899, upload-time = "2024-04-15T13:44:43.265Z" }, +] + +[[package]] +name = "memory-profiler" +version = "0.61.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "psutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/88/e1907e1ca3488f2d9507ca8b0ae1add7b1cd5d3ca2bc8e5b329382ea2c7b/memory_profiler-0.61.0.tar.gz", hash = "sha256:4e5b73d7864a1d1292fb76a03e82a3e78ef934d06828a698d9dada76da2067b0", size = 35935, upload-time = "2022-11-15T17:57:28.994Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/26/aaca612a0634ceede20682e692a6c55e35a94c21ba36b807cc40fe910ae1/memory_profiler-0.61.0-py3-none-any.whl", hash = "sha256:400348e61031e3942ad4d4109d18753b2fb08c2f6fb8290671c5513a34182d84", size = 31803, upload-time = "2022-11-15T17:57:27.031Z" }, +] + +[[package]] +name = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ghp-import" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mergedeep" }, + { name = "mkdocs-get-deps" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, +] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mergedeep" }, + { name = "platformdirs" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/f5/ed29cd50067784976f25ed0ed6fcd3c2ce9eb90650aa3b2796ddf7b6870b/mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c", size = 10239, upload-time = "2023-11-20T17:51:09.981Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/d4/029f984e8d3f3b6b726bd33cafc473b75e9e44c0f7e80a5b29abc466bdea/mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134", size = 9521, upload-time = "2023-11-20T17:51:08.587Z" }, +] + +[[package]] +name = "mtscomp" +version = "1.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/ef/365e2dd214155b06d22622b3278de769d20e9e1d201538a941d62b609248/mtscomp-1.0.2.tar.gz", hash = "sha256:609c4fe5a0d00532c1452b10318a74e04add8e47c562aca216e7b40de0e4bf73", size = 15967, upload-time = "2021-05-11T11:32:31.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/12/449d679e3aef2dcadfb9b275e2809d87bfeb798c7e9a911ee4bae536e24a/mtscomp-1.0.2-py2.py3-none-any.whl", hash = "sha256:a00a6d46a6155af5bca44931ccf5045756ea8256db8fd452f5e0592b71b4db69", size = 16382, upload-time = "2021-05-11T11:32:29.676Z" }, +] + +[[package]] +name = "nest-asyncio" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, +] + +[[package]] +name = "numpy" +version = "2.2.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +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.4.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/10/8b/c265f4823726ab832de836cdd184d0986dcf94480f81e8739692a7ac7af2/numpy-2.4.3.tar.gz", hash = "sha256:483a201202b73495f00dbc83796c6ae63137a9bdade074f7648b3e32613412dd", size = 20727743, upload-time = "2026-03-09T07:58:53.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/51/5093a2df15c4dc19da3f79d1021e891f5dcf1d9d1db6ba38891d5590f3fe/numpy-2.4.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:33b3bf58ee84b172c067f56aeadc7ee9ab6de69c5e800ab5b10295d54c581adb", size = 16957183, upload-time = "2026-03-09T07:55:57.774Z" }, + { url = "https://files.pythonhosted.org/packages/b5/7c/c061f3de0630941073d2598dc271ac2f6cbcf5c83c74a5870fea07488333/numpy-2.4.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8ba7b51e71c05aa1f9bc3641463cd82308eab40ce0d5c7e1fd4038cbf9938147", size = 14968734, upload-time = "2026-03-09T07:56:00.494Z" }, + { url = "https://files.pythonhosted.org/packages/ef/27/d26c85cbcd86b26e4f125b0668e7a7c0542d19dd7d23ee12e87b550e95b5/numpy-2.4.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a1988292870c7cb9d0ebb4cc96b4d447513a9644801de54606dc7aabf2b7d920", size = 5475288, upload-time = "2026-03-09T07:56:02.857Z" }, + { url = "https://files.pythonhosted.org/packages/2b/09/3c4abbc1dcd8010bf1a611d174c7aa689fc505585ec806111b4406f6f1b1/numpy-2.4.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:23b46bb6d8ecb68b58c09944483c135ae5f0e9b8d8858ece5e4ead783771d2a9", size = 6805253, upload-time = "2026-03-09T07:56:04.53Z" }, + { url = "https://files.pythonhosted.org/packages/21/bc/e7aa3f6817e40c3f517d407742337cbb8e6fc4b83ce0b55ab780c829243b/numpy-2.4.3-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a016db5c5dba78fa8fe9f5d80d6708f9c42ab087a739803c0ac83a43d686a470", size = 15969479, upload-time = "2026-03-09T07:56:06.638Z" }, + { url = "https://files.pythonhosted.org/packages/78/51/9f5d7a41f0b51649ddf2f2320595e15e122a40610b233d51928dd6c92353/numpy-2.4.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:715de7f82e192e8cae5a507a347d97ad17598f8e026152ca97233e3666daaa71", size = 16901035, upload-time = "2026-03-09T07:56:09.405Z" }, + { url = "https://files.pythonhosted.org/packages/64/6e/b221dd847d7181bc5ee4857bfb026182ef69499f9305eb1371cbb1aea626/numpy-2.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2ddb7919366ee468342b91dea2352824c25b55814a987847b6c52003a7c97f15", size = 17325657, upload-time = "2026-03-09T07:56:12.067Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b8/8f3fd2da596e1063964b758b5e3c970aed1949a05200d7e3d46a9d46d643/numpy-2.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a315e5234d88067f2d97e1f2ef670a7569df445d55400f1e33d117418d008d52", size = 18635512, upload-time = "2026-03-09T07:56:14.629Z" }, + { url = "https://files.pythonhosted.org/packages/5c/24/2993b775c37e39d2f8ab4125b44337ab0b2ba106c100980b7c274a22bee7/numpy-2.4.3-cp311-cp311-win32.whl", hash = "sha256:2b3f8d2c4589b1a2028d2a770b0fc4d1f332fb5e01521f4de3199a896d158ddd", size = 6238100, upload-time = "2026-03-09T07:56:17.243Z" }, + { url = "https://files.pythonhosted.org/packages/76/1d/edccf27adedb754db7c4511d5eac8b83f004ae948fe2d3509e8b78097d4c/numpy-2.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:77e76d932c49a75617c6d13464e41203cd410956614d0a0e999b25e9e8d27eec", size = 12609816, upload-time = "2026-03-09T07:56:19.089Z" }, + { url = "https://files.pythonhosted.org/packages/92/82/190b99153480076c8dce85f4cfe7d53ea84444145ffa54cb58dcd460d66b/numpy-2.4.3-cp311-cp311-win_arm64.whl", hash = "sha256:eb610595dd91560905c132c709412b512135a60f1851ccbd2c959e136431ff67", size = 10485757, upload-time = "2026-03-09T07:56:21.753Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ed/6388632536f9788cea23a3a1b629f25b43eaacd7d7377e5d6bc7b9deb69b/numpy-2.4.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:61b0cbabbb6126c8df63b9a3a0c4b1f44ebca5e12ff6997b80fcf267fb3150ef", size = 16669628, upload-time = "2026-03-09T07:56:24.252Z" }, + { url = "https://files.pythonhosted.org/packages/74/1b/ee2abfc68e1ce728b2958b6ba831d65c62e1b13ce3017c13943f8f9b5b2e/numpy-2.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7395e69ff32526710748f92cd8c9849b361830968ea3e24a676f272653e8983e", size = 14696872, upload-time = "2026-03-09T07:56:26.991Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d1/780400e915ff5638166f11ca9dc2c5815189f3d7cf6f8759a1685e586413/numpy-2.4.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:abdce0f71dcb4a00e4e77f3faf05e4616ceccfe72ccaa07f47ee79cda3b7b0f4", size = 5203489, upload-time = "2026-03-09T07:56:29.414Z" }, + { url = "https://files.pythonhosted.org/packages/0b/bb/baffa907e9da4cc34a6e556d6d90e032f6d7a75ea47968ea92b4858826c4/numpy-2.4.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:48da3a4ee1336454b07497ff7ec83903efa5505792c4e6d9bf83d99dc07a1e18", size = 6550814, upload-time = "2026-03-09T07:56:32.225Z" }, + { url = "https://files.pythonhosted.org/packages/7b/12/8c9f0c6c95f76aeb20fc4a699c33e9f827fa0d0f857747c73bb7b17af945/numpy-2.4.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32e3bef222ad6b052280311d1d60db8e259e4947052c3ae7dd6817451fc8a4c5", size = 15666601, upload-time = "2026-03-09T07:56:34.461Z" }, + { url = "https://files.pythonhosted.org/packages/bd/79/cc665495e4d57d0aa6fbcc0aa57aa82671dfc78fbf95fe733ed86d98f52a/numpy-2.4.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7dd01a46700b1967487141a66ac1a3cf0dd8ebf1f08db37d46389401512ca97", size = 16621358, upload-time = "2026-03-09T07:56:36.852Z" }, + { url = "https://files.pythonhosted.org/packages/a8/40/b4ecb7224af1065c3539f5ecfff879d090de09608ad1008f02c05c770cb3/numpy-2.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:76f0f283506c28b12bba319c0fab98217e9f9b54e6160e9c79e9f7348ba32e9c", size = 17016135, upload-time = "2026-03-09T07:56:39.337Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b1/6a88e888052eed951afed7a142dcdf3b149a030ca59b4c71eef085858e43/numpy-2.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:737f630a337364665aba3b5a77e56a68cc42d350edd010c345d65a3efa3addcc", size = 18345816, upload-time = "2026-03-09T07:56:42.31Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8f/103a60c5f8c3d7fc678c19cd7b2476110da689ccb80bc18050efbaeae183/numpy-2.4.3-cp312-cp312-win32.whl", hash = "sha256:26952e18d82a1dbbc2f008d402021baa8d6fc8e84347a2072a25e08b46d698b9", size = 5960132, upload-time = "2026-03-09T07:56:44.851Z" }, + { url = "https://files.pythonhosted.org/packages/d7/7c/f5ee1bf6ed888494978046a809df2882aad35d414b622893322df7286879/numpy-2.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:65f3c2455188f09678355f5cae1f959a06b778bc66d535da07bf2ef20cd319d5", size = 12316144, upload-time = "2026-03-09T07:56:47.057Z" }, + { url = "https://files.pythonhosted.org/packages/71/46/8d1cb3f7a00f2fb6394140e7e6623696e54c6318a9d9691bb4904672cf42/numpy-2.4.3-cp312-cp312-win_arm64.whl", hash = "sha256:2abad5c7fef172b3377502bde47892439bae394a71bc329f31df0fd829b41a9e", size = 10220364, upload-time = "2026-03-09T07:56:49.849Z" }, + { url = "https://files.pythonhosted.org/packages/b6/d0/1fe47a98ce0df229238b77611340aff92d52691bcbc10583303181abf7fc/numpy-2.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b346845443716c8e542d54112966383b448f4a3ba5c66409771b8c0889485dd3", size = 16665297, upload-time = "2026-03-09T07:56:52.296Z" }, + { url = "https://files.pythonhosted.org/packages/27/d9/4e7c3f0e68dfa91f21c6fb6cf839bc829ec920688b1ce7ec722b1a6202fb/numpy-2.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2629289168f4897a3c4e23dc98d6f1731f0fc0fe52fb9db19f974041e4cc12b9", size = 14691853, upload-time = "2026-03-09T07:56:54.992Z" }, + { url = "https://files.pythonhosted.org/packages/3a/66/bd096b13a87549683812b53ab211e6d413497f84e794fb3c39191948da97/numpy-2.4.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:bb2e3cf95854233799013779216c57e153c1ee67a0bf92138acca0e429aefaee", size = 5198435, upload-time = "2026-03-09T07:56:57.184Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/687722910b5a5601de2135c891108f51dfc873d8e43c8ed9f4ebb440b4a2/numpy-2.4.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:7f3408ff897f8ab07a07fbe2823d7aee6ff644c097cc1f90382511fe982f647f", size = 6546347, upload-time = "2026-03-09T07:56:59.531Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ec/7971c4e98d86c564750393fab8d7d83d0a9432a9d78bb8a163a6dc59967a/numpy-2.4.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:decb0eb8a53c3b009b0962378065589685d66b23467ef5dac16cbe818afde27f", size = 15664626, upload-time = "2026-03-09T07:57:01.385Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/7daecbea84ec935b7fc732e18f532073064a3816f0932a40a17f3349185f/numpy-2.4.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5f51900414fc9204a0e0da158ba2ac52b75656e7dce7e77fb9f84bfa343b4cc", size = 16608916, upload-time = "2026-03-09T07:57:04.008Z" }, + { url = "https://files.pythonhosted.org/packages/df/58/2a2b4a817ffd7472dca4421d9f0776898b364154e30c95f42195041dc03b/numpy-2.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6bd06731541f89cdc01b261ba2c9e037f1543df7472517836b78dfb15bd6e476", size = 17015824, upload-time = "2026-03-09T07:57:06.347Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ca/627a828d44e78a418c55f82dd4caea8ea4a8ef24e5144d9e71016e52fb40/numpy-2.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22654fe6be0e5206f553a9250762c653d3698e46686eee53b399ab90da59bd92", size = 18334581, upload-time = "2026-03-09T07:57:09.114Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c0/76f93962fc79955fcba30a429b62304332345f22d4daec1cb33653425643/numpy-2.4.3-cp313-cp313-win32.whl", hash = "sha256:d71e379452a2f670ccb689ec801b1218cd3983e253105d6e83780967e899d687", size = 5958618, upload-time = "2026-03-09T07:57:11.432Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3c/88af0040119209b9b5cb59485fa48b76f372c73068dbf9254784b975ac53/numpy-2.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:0a60e17a14d640f49146cb38e3f105f571318db7826d9b6fef7e4dce758faecd", size = 12312824, upload-time = "2026-03-09T07:57:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/58/ce/3d07743aced3d173f877c3ef6a454c2174ba42b584ab0b7e6d99374f51ed/numpy-2.4.3-cp313-cp313-win_arm64.whl", hash = "sha256:c9619741e9da2059cd9c3f206110b97583c7152c1dc9f8aafd4beb450ac1c89d", size = 10221218, upload-time = "2026-03-09T07:57:16.183Z" }, + { url = "https://files.pythonhosted.org/packages/62/09/d96b02a91d09e9d97862f4fc8bfebf5400f567d8eb1fe4b0cc4795679c15/numpy-2.4.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7aa4e54f6469300ebca1d9eb80acd5253cdfa36f2c03d79a35883687da430875", size = 14819570, upload-time = "2026-03-09T07:57:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/b5/ca/0b1aba3905fdfa3373d523b2b15b19029f4f3031c87f4066bd9d20ef6c6b/numpy-2.4.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d1b90d840b25874cf5cd20c219af10bac3667db3876d9a495609273ebe679070", size = 5326113, upload-time = "2026-03-09T07:57:21.052Z" }, + { url = "https://files.pythonhosted.org/packages/c0/63/406e0fd32fcaeb94180fd6a4c41e55736d676c54346b7efbce548b94a914/numpy-2.4.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:a749547700de0a20a6718293396ec237bb38218049cfce788e08fcb716e8cf73", size = 6646370, upload-time = "2026-03-09T07:57:22.804Z" }, + { url = "https://files.pythonhosted.org/packages/b6/d0/10f7dc157d4b37af92720a196be6f54f889e90dcd30dce9dc657ed92c257/numpy-2.4.3-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94f3c4a151a2e529adf49c1d54f0f57ff8f9b233ee4d44af623a81553ab86368", size = 15723499, upload-time = "2026-03-09T07:57:24.693Z" }, + { url = "https://files.pythonhosted.org/packages/66/f1/d1c2bf1161396629701bc284d958dc1efa3a5a542aab83cf11ee6eb4cba5/numpy-2.4.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22c31dc07025123aedf7f2db9e91783df13f1776dc52c6b22c620870dc0fab22", size = 16657164, upload-time = "2026-03-09T07:57:27.676Z" }, + { url = "https://files.pythonhosted.org/packages/1a/be/cca19230b740af199ac47331a21c71e7a3d0ba59661350483c1600d28c37/numpy-2.4.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:148d59127ac95979d6f07e4d460f934ebdd6eed641db9c0db6c73026f2b2101a", size = 17081544, upload-time = "2026-03-09T07:57:30.664Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c5/9602b0cbb703a0936fb40f8a95407e8171935b15846de2f0776e08af04c7/numpy-2.4.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a97cbf7e905c435865c2d939af3d93f99d18eaaa3cabe4256f4304fb51604349", size = 18380290, upload-time = "2026-03-09T07:57:33.763Z" }, + { url = "https://files.pythonhosted.org/packages/ed/81/9f24708953cd30be9ee36ec4778f4b112b45165812f2ada4cc5ea1c1f254/numpy-2.4.3-cp313-cp313t-win32.whl", hash = "sha256:be3b8487d725a77acccc9924f65fd8bce9af7fac8c9820df1049424a2115af6c", size = 6082814, upload-time = "2026-03-09T07:57:36.491Z" }, + { url = "https://files.pythonhosted.org/packages/e2/9e/52f6eaa13e1a799f0ab79066c17f7016a4a8ae0c1aefa58c82b4dab690b4/numpy-2.4.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1ec84fd7c8e652b0f4aaaf2e6e9cc8eaa9b1b80a537e06b2e3a2fb176eedcb26", size = 12452673, upload-time = "2026-03-09T07:57:38.281Z" }, + { url = "https://files.pythonhosted.org/packages/c4/04/b8cece6ead0b30c9fbd99bb835ad7ea0112ac5f39f069788c5558e3b1ab2/numpy-2.4.3-cp313-cp313t-win_arm64.whl", hash = "sha256:120df8c0a81ebbf5b9020c91439fccd85f5e018a927a39f624845be194a2be02", size = 10290907, upload-time = "2026-03-09T07:57:40.747Z" }, + { url = "https://files.pythonhosted.org/packages/70/ae/3936f79adebf8caf81bd7a599b90a561334a658be4dcc7b6329ebf4ee8de/numpy-2.4.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5884ce5c7acfae1e4e1b6fde43797d10aa506074d25b531b4f54bde33c0c31d4", size = 16664563, upload-time = "2026-03-09T07:57:43.817Z" }, + { url = "https://files.pythonhosted.org/packages/9b/62/760f2b55866b496bb1fa7da2a6db076bef908110e568b02fcfc1422e2a3a/numpy-2.4.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:297837823f5bc572c5f9379b0c9f3a3365f08492cbdc33bcc3af174372ebb168", size = 14702161, upload-time = "2026-03-09T07:57:46.169Z" }, + { url = "https://files.pythonhosted.org/packages/32/af/a7a39464e2c0a21526fb4fb76e346fb172ebc92f6d1c7a07c2c139cc17b1/numpy-2.4.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:a111698b4a3f8dcbe54c64a7708f049355abd603e619013c346553c1fd4ca90b", size = 5208738, upload-time = "2026-03-09T07:57:48.506Z" }, + { url = "https://files.pythonhosted.org/packages/29/8c/2a0cf86a59558fa078d83805589c2de490f29ed4fb336c14313a161d358a/numpy-2.4.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:4bd4741a6a676770e0e97fe9ab2e51de01183df3dcbcec591d26d331a40de950", size = 6543618, upload-time = "2026-03-09T07:57:50.591Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b8/612ce010c0728b1c363fa4ea3aa4c22fe1c5da1de008486f8c2f5cb92fae/numpy-2.4.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54f29b877279d51e210e0c80709ee14ccbbad647810e8f3d375561c45ef613dd", size = 15680676, upload-time = "2026-03-09T07:57:52.34Z" }, + { url = "https://files.pythonhosted.org/packages/a9/7e/4f120ecc54ba26ddf3dc348eeb9eb063f421de65c05fc961941798feea18/numpy-2.4.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:679f2a834bae9020f81534671c56fd0cc76dd7e5182f57131478e23d0dc59e24", size = 16613492, upload-time = "2026-03-09T07:57:54.91Z" }, + { url = "https://files.pythonhosted.org/packages/2c/86/1b6020db73be330c4b45d5c6ee4295d59cfeef0e3ea323959d053e5a6909/numpy-2.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d84f0f881cb2225c2dfd7f78a10a5645d487a496c6668d6cc39f0f114164f3d0", size = 17031789, upload-time = "2026-03-09T07:57:57.641Z" }, + { url = "https://files.pythonhosted.org/packages/07/3a/3b90463bf41ebc21d1b7e06079f03070334374208c0f9a1f05e4ae8455e7/numpy-2.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d213c7e6e8d211888cc359bab7199670a00f5b82c0978b9d1c75baf1eddbeac0", size = 18339941, upload-time = "2026-03-09T07:58:00.577Z" }, + { url = "https://files.pythonhosted.org/packages/a8/74/6d736c4cd962259fd8bae9be27363eb4883a2f9069763747347544c2a487/numpy-2.4.3-cp314-cp314-win32.whl", hash = "sha256:52077feedeff7c76ed7c9f1a0428558e50825347b7545bbb8523da2cd55c547a", size = 6007503, upload-time = "2026-03-09T07:58:03.331Z" }, + { url = "https://files.pythonhosted.org/packages/48/39/c56ef87af669364356bb011922ef0734fc49dad51964568634c72a009488/numpy-2.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:0448e7f9caefb34b4b7dd2b77f21e8906e5d6f0365ad525f9f4f530b13df2afc", size = 12444915, upload-time = "2026-03-09T07:58:06.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1f/ab8528e38d295fd349310807496fabb7cf9fe2e1f70b97bc20a483ea9d4a/numpy-2.4.3-cp314-cp314-win_arm64.whl", hash = "sha256:b44fd60341c4d9783039598efadd03617fa28d041fc37d22b62d08f2027fa0e7", size = 10494875, upload-time = "2026-03-09T07:58:08.734Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ef/b7c35e4d5ef141b836658ab21a66d1a573e15b335b1d111d31f26c8ef80f/numpy-2.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0a195f4216be9305a73c0e91c9b026a35f2161237cf1c6de9b681637772ea657", size = 14822225, upload-time = "2026-03-09T07:58:11.034Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8d/7730fa9278cf6648639946cc816e7cc89f0d891602584697923375f801ed/numpy-2.4.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:cd32fbacb9fd1bf041bf8e89e4576b6f00b895f06d00914820ae06a616bdfef7", size = 5328769, upload-time = "2026-03-09T07:58:13.67Z" }, + { url = "https://files.pythonhosted.org/packages/47/01/d2a137317c958b074d338807c1b6a383406cdf8b8e53b075d804cc3d211d/numpy-2.4.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:2e03c05abaee1f672e9d67bc858f300b5ccba1c21397211e8d77d98350972093", size = 6649461, upload-time = "2026-03-09T07:58:15.912Z" }, + { url = "https://files.pythonhosted.org/packages/5c/34/812ce12bc0f00272a4b0ec0d713cd237cb390666eb6206323d1cc9cedbb2/numpy-2.4.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d1ce23cce91fcea443320a9d0ece9b9305d4368875bab09538f7a5b4131938a", size = 15725809, upload-time = "2026-03-09T07:58:17.787Z" }, + { url = "https://files.pythonhosted.org/packages/25/c0/2aed473a4823e905e765fee3dc2cbf504bd3e68ccb1150fbdabd5c39f527/numpy-2.4.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c59020932feb24ed49ffd03704fbab89f22aa9c0d4b180ff45542fe8918f5611", size = 16655242, upload-time = "2026-03-09T07:58:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/f2/c8/7e052b2fc87aa0e86de23f20e2c42bd261c624748aa8efd2c78f7bb8d8c6/numpy-2.4.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9684823a78a6cd6ad7511fc5e25b07947d1d5b5e2812c93fe99d7d4195130720", size = 17080660, upload-time = "2026-03-09T07:58:23.067Z" }, + { url = "https://files.pythonhosted.org/packages/f3/3d/0876746044db2adcb11549f214d104f2e1be00f07a67edbb4e2812094847/numpy-2.4.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0200b25c687033316fb39f0ff4e3e690e8957a2c3c8d22499891ec58c37a3eb5", size = 18380384, upload-time = "2026-03-09T07:58:25.839Z" }, + { url = "https://files.pythonhosted.org/packages/07/12/8160bea39da3335737b10308df4f484235fd297f556745f13092aa039d3b/numpy-2.4.3-cp314-cp314t-win32.whl", hash = "sha256:5e10da9e93247e554bb1d22f8edc51847ddd7dde52d85ce31024c1b4312bfba0", size = 6154547, upload-time = "2026-03-09T07:58:28.289Z" }, + { url = "https://files.pythonhosted.org/packages/42/f3/76534f61f80d74cc9cdf2e570d3d4eeb92c2280a27c39b0aaf471eda7b48/numpy-2.4.3-cp314-cp314t-win_amd64.whl", hash = "sha256:45f003dbdffb997a03da2d1d0cb41fbd24a87507fb41605c0420a3db5bd4667b", size = 12633645, upload-time = "2026-03-09T07:58:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/1f/b6/7c0d4334c15983cec7f92a69e8ce9b1e6f31857e5ee3a413ac424e6bd63d/numpy-2.4.3-cp314-cp314t-win_arm64.whl", hash = "sha256:4d382735cecd7bcf090172489a525cd7d4087bc331f7df9f60ddc9a296cf208e", size = 10565454, upload-time = "2026-03-09T07:58:33.031Z" }, + { url = "https://files.pythonhosted.org/packages/64/e4/4dab9fb43c83719c29241c535d9e07be73bea4bc0c6686c5816d8e1b6689/numpy-2.4.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c6b124bfcafb9e8d3ed09130dbee44848c20b3e758b6bbf006e641778927c028", size = 16834892, upload-time = "2026-03-09T07:58:35.334Z" }, + { url = "https://files.pythonhosted.org/packages/c9/29/f8b6d4af90fed3dfda84ebc0df06c9833d38880c79ce954e5b661758aa31/numpy-2.4.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:76dbb9d4e43c16cf9aa711fcd8de1e2eeb27539dcefb60a1d5e9f12fae1d1ed8", size = 14893070, upload-time = "2026-03-09T07:58:37.7Z" }, + { url = "https://files.pythonhosted.org/packages/9a/04/a19b3c91dbec0a49269407f15d5753673a09832daed40c45e8150e6fa558/numpy-2.4.3-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:29363fbfa6f8ee855d7569c96ce524845e3d726d6c19b29eceec7dd555dab152", size = 5399609, upload-time = "2026-03-09T07:58:39.853Z" }, + { url = "https://files.pythonhosted.org/packages/79/34/4d73603f5420eab89ea8a67097b31364bf7c30f811d4dd84b1659c7476d9/numpy-2.4.3-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:bc71942c789ef415a37f0d4eab90341425a00d538cd0642445d30b41023d3395", size = 6714355, upload-time = "2026-03-09T07:58:42.365Z" }, + { url = "https://files.pythonhosted.org/packages/58/ad/1100d7229bb248394939a12a8074d485b655e8ed44207d328fdd7fcebc7b/numpy-2.4.3-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e58765ad74dcebd3ef0208a5078fba32dc8ec3578fe84a604432950cd043d79", size = 15800434, upload-time = "2026-03-09T07:58:44.837Z" }, + { url = "https://files.pythonhosted.org/packages/0c/fd/16d710c085d28ba4feaf29ac60c936c9d662e390344f94a6beaa2ac9899b/numpy-2.4.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e236dbda4e1d319d681afcbb136c0c4a8e0f1a5c58ceec2adebb547357fe857", size = 16729409, upload-time = "2026-03-09T07:58:47.972Z" }, + { url = "https://files.pythonhosted.org/packages/57/a7/b35835e278c18b85206834b3aa3abe68e77a98769c59233d1f6300284781/numpy-2.4.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:4b42639cdde6d24e732ff823a3fa5b701d8acad89c4142bc1d0bd6dc85200ba5", size = 12504685, upload-time = "2026-03-09T07:58:50.525Z" }, +] + +[[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 = "parso" +version = "0.8.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/94/68e2e17afaa9169cf6412ab0f28623903be73d1b32e208d9e8e541bb086d/parso-0.8.4.tar.gz", hash = "sha256:eb3a7b58240fb99099a345571deecc0f9540ea5f4dd2fe14c2a99d6b281ab92d", size = 400609, upload-time = "2024-04-05T09:43:55.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/ac/dac4a63f978e4dcb3c6d3a78c4d8e0192a113d288502a1216950c41b1027/parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18", size = 103650, upload-time = "2024-04-05T09:43:53.299Z" }, +] + +[[package]] +name = "partd" +version = "1.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "locket" }, + { name = "toolz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/3a/3f06f34820a31257ddcabdfafc2672c5816be79c7e353b02c1f318daa7d4/partd-1.4.2.tar.gz", hash = "sha256:d022c33afbdc8405c226621b015e8067888173d85f7f5ecebb3cafed9a20f02c", size = 21029, upload-time = "2024-05-06T19:51:41.945Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/e7/40fb618334dcdf7c5a316c0e7343c5cd82d3d866edc100d98e29bc945ecd/partd-1.4.2-py3-none-any.whl", hash = "sha256:978e4ac767ec4ba5b86c6eaa52e5a2a3bc748a2ca839e8cc798f1cc6ce6efb0f", size = 18905, upload-time = "2024-05-06T19:51:39.271Z" }, +] + +[[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 = "pexpect" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, +] + +[[package]] +name = "phy" +version = "2.1.0rc1" +source = { editable = "." } +dependencies = [ + { name = "click" }, + { name = "colorcet" }, + { name = "h5py" }, + { name = "ipykernel" }, + { name = "joblib" }, + { name = "matplotlib" }, + { name = "mtscomp" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "phylib" }, + { name = "pillow" }, + { name = "pip" }, + { name = "pyopengl" }, + { name = "pyqt5" }, + { name = "qtconsole" }, + { name = "requests" }, + { name = "responses" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "setuptools" }, + { name = "tqdm" }, + { name = "traitlets" }, +] + +[package.optional-dependencies] +dev = [ + { name = "coverage" }, + { name = "coveralls" }, + { name = "memory-profiler" }, + { name = "mkdocs" }, + { name = "pyqt5" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-qt" }, + { name = "ruff" }, +] + +[package.dev-dependencies] +dev = [ + { name = "coverage" }, + { name = "coveralls" }, + { name = "memory-profiler" }, + { name = "mkdocs" }, + { name = "pyqt5" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-qt" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "click" }, + { name = "colorcet" }, + { name = "coverage", marker = "extra == 'dev'" }, + { name = "coveralls", marker = "extra == 'dev'" }, + { name = "h5py" }, + { name = "ipykernel" }, + { name = "joblib" }, + { name = "matplotlib" }, + { name = "memory-profiler", marker = "extra == 'dev'" }, + { name = "mkdocs", marker = "extra == 'dev'" }, + { name = "mtscomp" }, + { name = "numpy" }, + { name = "phylib", specifier = ">=2.7.0,<3" }, + { name = "pillow" }, + { name = "pip" }, + { name = "pyopengl", specifier = ">=3.1.9" }, + { name = "pyqt5", specifier = ">=5.12.0" }, + { name = "pyqt5", marker = "extra == 'dev'", specifier = ">=5.12.0" }, + { name = "pytest", marker = "extra == 'dev'" }, + { name = "pytest-cov", marker = "extra == 'dev'" }, + { name = "pytest-qt", marker = "extra == 'dev'" }, + { name = "qtconsole" }, + { name = "requests" }, + { name = "responses" }, + { name = "ruff", marker = "extra == 'dev'" }, + { name = "scipy" }, + { name = "setuptools" }, + { name = "tqdm" }, + { name = "traitlets" }, +] +provides-extras = ["dev"] + +[package.metadata.requires-dev] +dev = [ + { name = "coverage", specifier = ">=6.0" }, + { name = "coveralls", specifier = ">=3.0" }, + { name = "memory-profiler", specifier = ">=0.60" }, + { name = "mkdocs", specifier = ">=1.4" }, + { name = "pyqt5", specifier = ">=5.12.0" }, + { name = "pytest", specifier = ">=6.0" }, + { name = "pytest-cov", specifier = ">=3.0" }, + { name = "pytest-qt", specifier = ">=4.0" }, + { name = "ruff", specifier = ">=0.1.0" }, +] + +[[package]] +name = "phylib" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dask" }, + { name = "joblib" }, + { name = "mtscomp" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "requests" }, + { name = "responses" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "toolz" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e2/80/258006976d88285c6f24fbe5aa8e77f1eb6ac5ff799aad223543d224a6a0/phylib-2.7.0.tar.gz", hash = "sha256:e1b9ed5a53e2010030eebc61bad393b3787626f676ef4b0027b90b18fc8e167a", size = 70253, upload-time = "2025-12-12T15:28:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/0e/6860178ec209314fdf39988deb3a3175b74446a6e4a5ccdaff3cce5ac519/phylib-2.7.0-py2.py3-none-any.whl", hash = "sha256:00f981c5d2260ab0e9e79ab35233f016fa4268fb8c0dc12632223a0e465111fd", size = 82121, upload-time = "2025-12-12T15:28:01.003Z" }, +] + +[[package]] +name = "pillow" +version = "12.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/30/5bd3d794762481f8c8ae9c80e7b76ecea73b916959eb587521358ef0b2f9/pillow-12.1.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1f1625b72740fdda5d77b4def688eb8fd6490975d06b909fd19f13f391e077e0", size = 5304099, upload-time = "2026-02-11T04:20:06.13Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c1/aab9e8f3eeb4490180e357955e15c2ef74b31f64790ff356c06fb6cf6d84/pillow-12.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:178aa072084bd88ec759052feca8e56cbb14a60b39322b99a049e58090479713", size = 4657880, upload-time = "2026-02-11T04:20:09.291Z" }, + { url = "https://files.pythonhosted.org/packages/f1/0a/9879e30d56815ad529d3985aeff5af4964202425c27261a6ada10f7cbf53/pillow-12.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b66e95d05ba806247aaa1561f080abc7975daf715c30780ff92a20e4ec546e1b", size = 6222587, upload-time = "2026-02-11T04:20:10.82Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5f/a1b72ff7139e4f89014e8d451442c74a774d5c43cd938fb0a9f878576b37/pillow-12.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:89c7e895002bbe49cdc5426150377cbbc04767d7547ed145473f496dfa40408b", size = 8027678, upload-time = "2026-02-11T04:20:12.455Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c2/c7cb187dac79a3d22c3ebeae727abee01e077c8c7d930791dc592f335153/pillow-12.1.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a5cbdcddad0af3da87cb16b60d23648bc3b51967eb07223e9fed77a82b457c4", size = 6335777, upload-time = "2026-02-11T04:20:14.441Z" }, + { url = "https://files.pythonhosted.org/packages/0c/7b/f9b09a7804ec7336effb96c26d37c29d27225783dc1501b7d62dcef6ae25/pillow-12.1.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f51079765661884a486727f0729d29054242f74b46186026582b4e4769918e4", size = 7027140, upload-time = "2026-02-11T04:20:16.387Z" }, + { url = "https://files.pythonhosted.org/packages/98/b2/2fa3c391550bd421b10849d1a2144c44abcd966daadd2f7c12e19ea988c4/pillow-12.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:99c1506ea77c11531d75e3a412832a13a71c7ebc8192ab9e4b2e355555920e3e", size = 6449855, upload-time = "2026-02-11T04:20:18.554Z" }, + { url = "https://files.pythonhosted.org/packages/96/ff/9caf4b5b950c669263c39e96c78c0d74a342c71c4f43fd031bb5cb7ceac9/pillow-12.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:36341d06738a9f66c8287cf8b876d24b18db9bd8740fa0672c74e259ad408cff", size = 7151329, upload-time = "2026-02-11T04:20:20.646Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f8/4b24841f582704da675ca535935bccb32b00a6da1226820845fac4a71136/pillow-12.1.1-cp310-cp310-win32.whl", hash = "sha256:6c52f062424c523d6c4db85518774cc3d50f5539dd6eed32b8f6229b26f24d40", size = 6325574, upload-time = "2026-02-11T04:20:22.43Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f9/9f6b01c0881d7036063aa6612ef04c0e2cad96be21325a1e92d0203f8e91/pillow-12.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:c6008de247150668a705a6338156efb92334113421ceecf7438a12c9a12dab23", size = 7032347, upload-time = "2026-02-11T04:20:23.932Z" }, + { url = "https://files.pythonhosted.org/packages/79/13/c7922edded3dcdaf10c59297540b72785620abc0538872c819915746757d/pillow-12.1.1-cp310-cp310-win_arm64.whl", hash = "sha256:1a9b0ee305220b392e1124a764ee4265bd063e54a751a6b62eff69992f457fa9", size = 2453457, upload-time = "2026-02-11T04:20:25.392Z" }, + { url = "https://files.pythonhosted.org/packages/2b/46/5da1ec4a5171ee7bf1a0efa064aba70ba3d6e0788ce3f5acd1375d23c8c0/pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32", size = 5304084, upload-time = "2026-02-11T04:20:27.501Z" }, + { url = "https://files.pythonhosted.org/packages/78/93/a29e9bc02d1cf557a834da780ceccd54e02421627200696fcf805ebdc3fb/pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38", size = 4657866, upload-time = "2026-02-11T04:20:29.827Z" }, + { url = "https://files.pythonhosted.org/packages/13/84/583a4558d492a179d31e4aae32eadce94b9acf49c0337c4ce0b70e0a01f2/pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5", size = 6232148, upload-time = "2026-02-11T04:20:31.329Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e2/53c43334bbbb2d3b938978532fbda8e62bb6e0b23a26ce8592f36bcc4987/pillow-12.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090", size = 8038007, upload-time = "2026-02-11T04:20:34.225Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/3d0e79c8a9d58150dd98e199d7c1c56861027f3829a3a60b3c2784190180/pillow-12.1.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af", size = 6345418, upload-time = "2026-02-11T04:20:35.858Z" }, + { url = "https://files.pythonhosted.org/packages/a2/c8/46dfeac5825e600579157eea177be43e2f7ff4a99da9d0d0a49533509ac5/pillow-12.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b", size = 7034590, upload-time = "2026-02-11T04:20:37.91Z" }, + { url = "https://files.pythonhosted.org/packages/af/bf/e6f65d3db8a8bbfeaf9e13cc0417813f6319863a73de934f14b2229ada18/pillow-12.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5", size = 6458655, upload-time = "2026-02-11T04:20:39.496Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c2/66091f3f34a25894ca129362e510b956ef26f8fb67a0e6417bc5744e56f1/pillow-12.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d", size = 7159286, upload-time = "2026-02-11T04:20:41.139Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5a/24bc8eb526a22f957d0cec6243146744966d40857e3d8deb68f7902ca6c1/pillow-12.1.1-cp311-cp311-win32.whl", hash = "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c", size = 6328663, upload-time = "2026-02-11T04:20:43.184Z" }, + { url = "https://files.pythonhosted.org/packages/31/03/bef822e4f2d8f9d7448c133d0a18185d3cce3e70472774fffefe8b0ed562/pillow-12.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563", size = 7031448, upload-time = "2026-02-11T04:20:44.696Z" }, + { url = "https://files.pythonhosted.org/packages/49/70/f76296f53610bd17b2e7d31728b8b7825e3ac3b5b3688b51f52eab7c0818/pillow-12.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80", size = 2453651, upload-time = "2026-02-11T04:20:46.243Z" }, + { url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" }, + { url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" }, + { url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" }, + { url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" }, + { url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" }, + { url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" }, + { url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" }, + { url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" }, + { url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" }, + { url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" }, + { url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" }, + { url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" }, + { url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" }, + { url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" }, + { url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" }, + { url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" }, + { url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" }, + { url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" }, + { url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" }, + { url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" }, + { url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" }, + { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" }, + { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" }, + { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" }, + { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" }, + { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" }, + { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" }, + { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" }, + { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" }, + { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" }, + { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" }, + { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" }, + { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" }, + { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" }, + { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, + { url = "https://files.pythonhosted.org/packages/56/11/5d43209aa4cb58e0cc80127956ff1796a68b928e6324bbf06ef4db34367b/pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f", size = 5228606, upload-time = "2026-02-11T04:22:52.106Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d5/3b005b4e4fda6698b371fa6c21b097d4707585d7db99e98d9b0b87ac612a/pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9", size = 4622321, upload-time = "2026-02-11T04:22:53.827Z" }, + { url = "https://files.pythonhosted.org/packages/df/36/ed3ea2d594356fd8037e5a01f6156c74bc8d92dbb0fa60746cc96cabb6e8/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e", size = 5247579, upload-time = "2026-02-11T04:22:56.094Z" }, + { url = "https://files.pythonhosted.org/packages/54/9a/9cc3e029683cf6d20ae5085da0dafc63148e3252c2f13328e553aaa13cfb/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9", size = 6989094, upload-time = "2026-02-11T04:22:58.288Z" }, + { url = "https://files.pythonhosted.org/packages/00/98/fc53ab36da80b88df0967896b6c4b4cd948a0dc5aa40a754266aa3ae48b3/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3", size = 5313850, upload-time = "2026-02-11T04:23:00.554Z" }, + { url = "https://files.pythonhosted.org/packages/30/02/00fa585abfd9fe9d73e5f6e554dc36cc2b842898cbfc46d70353dae227f8/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735", size = 5963343, upload-time = "2026-02-11T04:23:02.934Z" }, + { url = "https://files.pythonhosted.org/packages/f2/26/c56ce33ca856e358d27fda9676c055395abddb82c35ac0f593877ed4562e/pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e", size = 7029880, upload-time = "2026-02-11T04:23:04.783Z" }, +] + +[[package]] +name = "pip" +version = "26.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/48/83/0d7d4e9efe3344b8e2fe25d93be44f64b65364d3c8d7bc6dc90198d5422e/pip-26.0.1.tar.gz", hash = "sha256:c4037d8a277c89b320abe636d59f91e6d0922d08a05b60e85e53b296613346d8", size = 1812747, upload-time = "2026-02-05T02:20:18.702Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/f0/c81e05b613866b76d2d1066490adf1a3dbc4ee9d9c839961c3fc8a6997af/pip-26.0.1-py3-none-any.whl", hash = "sha256:bdb1b08f4274833d62c1aa29e20907365a2ceb950410df15fc9521bad440122b", size = 1787723, upload-time = "2026-02-05T02:20:16.416Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.3.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/8b/3c73abc9c759ecd3f1f7ceff6685840859e8070c4d947c93fae71f6a0bf2/platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", size = 21362, upload-time = "2025-05-07T22:47:42.121Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567, upload-time = "2025-05-07T22:47:40.376Z" }, +] + +[[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 = "prompt-toolkit" +version = "3.0.51" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/6e/9d084c929dfe9e3bfe0c6a47e31f78a25c54627d64a66e884a8bf5474f1c/prompt_toolkit-3.0.51.tar.gz", hash = "sha256:931a162e3b27fc90c86f1b48bb1fb2c528c2761475e57c9c06de13311c7b54ed", size = 428940, upload-time = "2025-04-15T09:18:47.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/4f/5249960887b1fbe561d9ff265496d170b55a735b76724f10ef19f9e40716/prompt_toolkit-3.0.51-py3-none-any.whl", hash = "sha256:52742911fde84e2d423e2f9a4cf1de7d7ac4e51958f648d9540e0fb8db077b07", size = 387810, upload-time = "2025-04-15T09:18:44.753Z" }, +] + +[[package]] +name = "psutil" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/80/336820c1ad9286a4ded7e845b2eccfcb27851ab8ac6abece774a6ff4d3de/psutil-7.0.0.tar.gz", hash = "sha256:7be9c3eba38beccb6495ea33afd982a44074b78f28c434a1f51cc07fd315c456", size = 497003, upload-time = "2025-02-13T21:54:07.946Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/e6/2d26234410f8b8abdbf891c9da62bee396583f713fb9f3325a4760875d22/psutil-7.0.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:101d71dc322e3cffd7cea0650b09b3d08b8e7c4109dd6809fe452dfd00e58b25", size = 238051, upload-time = "2025-02-13T21:54:12.36Z" }, + { url = "https://files.pythonhosted.org/packages/04/8b/30f930733afe425e3cbfc0e1468a30a18942350c1a8816acfade80c005c4/psutil-7.0.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:39db632f6bb862eeccf56660871433e111b6ea58f2caea825571951d4b6aa3da", size = 239535, upload-time = "2025-02-13T21:54:16.07Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ed/d362e84620dd22876b55389248e522338ed1bf134a5edd3b8231d7207f6d/psutil-7.0.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fcee592b4c6f146991ca55919ea3d1f8926497a713ed7faaf8225e174581e91", size = 275004, upload-time = "2025-02-13T21:54:18.662Z" }, + { url = "https://files.pythonhosted.org/packages/bf/b9/b0eb3f3cbcb734d930fdf839431606844a825b23eaf9a6ab371edac8162c/psutil-7.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b1388a4f6875d7e2aff5c4ca1cc16c545ed41dd8bb596cefea80111db353a34", size = 277986, upload-time = "2025-02-13T21:54:21.811Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a2/709e0fe2f093556c17fbafda93ac032257242cabcc7ff3369e2cb76a97aa/psutil-7.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5f098451abc2828f7dc6b58d44b532b22f2088f4999a937557b603ce72b1993", size = 279544, upload-time = "2025-02-13T21:54:24.68Z" }, + { url = "https://files.pythonhosted.org/packages/50/e6/eecf58810b9d12e6427369784efe814a1eec0f492084ce8eb8f4d89d6d61/psutil-7.0.0-cp37-abi3-win32.whl", hash = "sha256:ba3fcef7523064a6c9da440fc4d6bd07da93ac726b5733c29027d7dc95b39d99", size = 241053, upload-time = "2025-02-13T21:54:34.31Z" }, + { url = "https://files.pythonhosted.org/packages/50/1b/6921afe68c74868b4c9fa424dad3be35b095e16687989ebbb50ce4fceb7c/psutil-7.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:4cf3d4eb1aa9b348dec30105c55cd9b7d4629285735a102beb4441e38db90553", size = 244885, upload-time = "2025-02-13T21:54:37.486Z" }, +] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, +] + +[[package]] +name = "pure-eval" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, +] + +[[package]] +name = "pycparser" +version = "2.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736, upload-time = "2024-03-30T13:22:22.564Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581, upload-time = "2025-01-06T17:26:30.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" }, +] + +[[package]] +name = "pyopengl" +version = "3.1.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/16/912b7225d56284859cd9a672827f18be43f8012f8b7b932bc4bd959a298e/pyopengl-3.1.10.tar.gz", hash = "sha256:c4a02d6866b54eb119c8e9b3fb04fa835a95ab802dd96607ab4cdb0012df8335", size = 1915580, upload-time = "2025-08-18T02:33:01.76Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/e4/1ba6f44e491c4eece978685230dde56b14d51a0365bc1b774ddaa94d14cd/pyopengl-3.1.10-py3-none-any.whl", hash = "sha256:794a943daced39300879e4e47bd94525280685f42dbb5a998d336cfff151d74f", size = 3194996, upload-time = "2025-08-18T02:32:59.902Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/22/f1129e69d94ffff626bdb5c835506b3a5b4f3d070f17ea295e12c2c6f60f/pyparsing-3.2.3.tar.gz", hash = "sha256:b9c13f1ab8b3b542f72e28f634bad4de758ab3ce4546e4301970ad6fa77c38be", size = 1088608, upload-time = "2025-03-25T05:01:28.114Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/e7/df2285f3d08fee213f2d041540fa4fc9ca6c2d44cf36d3a035bf2a8d2bcc/pyparsing-3.2.3-py3-none-any.whl", hash = "sha256:a749938e02d6fd0b59b356ca504a24982314bb090c383e3cf201c95ef7e2bfcf", size = 111120, upload-time = "2025-03-25T05:01:24.908Z" }, +] + +[[package]] +name = "pyqt5" +version = "5.15.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyqt5-qt5" }, + { name = "pyqt5-sip" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/07/c9ed0bd428df6f87183fca565a79fee19fa7c88c7f00a7f011ab4379e77a/PyQt5-5.15.11.tar.gz", hash = "sha256:fda45743ebb4a27b4b1a51c6d8ef455c4c1b5d610c90d2934c7802b5c1557c52", size = 3216775, upload-time = "2024-07-19T08:39:57.756Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/64/42ec1b0bd72d87f87bde6ceb6869f444d91a2d601f2e67cd05febc0346a1/PyQt5-5.15.11-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:c8b03dd9380bb13c804f0bdb0f4956067f281785b5e12303d529f0462f9afdc2", size = 6579776, upload-time = "2024-07-19T08:39:19.775Z" }, + { url = "https://files.pythonhosted.org/packages/49/f5/3fb696f4683ea45d68b7e77302eff173493ac81e43d63adb60fa760b9f91/PyQt5-5.15.11-cp38-abi3-macosx_11_0_x86_64.whl", hash = "sha256:6cd75628f6e732b1ffcfe709ab833a0716c0445d7aec8046a48d5843352becb6", size = 7016415, upload-time = "2024-07-19T08:39:32.977Z" }, + { url = "https://files.pythonhosted.org/packages/b4/8c/4065950f9d013c4b2e588fe33cf04e564c2322842d84dbcbce5ba1dc28b0/PyQt5-5.15.11-cp38-abi3-manylinux_2_17_x86_64.whl", hash = "sha256:cd672a6738d1ae33ef7d9efa8e6cb0a1525ecf53ec86da80a9e1b6ec38c8d0f1", size = 8188103, upload-time = "2024-07-19T08:39:40.561Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f0/ae5a5b4f9b826b29ea4be841b2f2d951bcf5ae1d802f3732b145b57c5355/PyQt5-5.15.11-cp38-abi3-win32.whl", hash = "sha256:76be0322ceda5deecd1708a8d628e698089a1cea80d1a49d242a6d579a40babd", size = 5433308, upload-time = "2024-07-19T08:39:46.932Z" }, + { url = "https://files.pythonhosted.org/packages/56/d5/68eb9f3d19ce65df01b6c7b7a577ad3bbc9ab3a5dd3491a4756e71838ec9/PyQt5-5.15.11-cp38-abi3-win_amd64.whl", hash = "sha256:bdde598a3bb95022131a5c9ea62e0a96bd6fb28932cc1619fd7ba211531b7517", size = 6865864, upload-time = "2024-07-19T08:39:53.572Z" }, +] + +[[package]] +name = "pyqt5-qt5" +version = "5.15.17" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/f9/accb06e76e23fb23053d48cc24fd78dec6ed14cb4d5cbadb0fd4a0c1b02e/PyQt5_Qt5-5.15.17-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:d8b8094108e748b4bbd315737cfed81291d2d228de43278f0b8bd7d2b808d2b9", size = 39972275, upload-time = "2025-05-24T11:15:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/87/1a/e1601ad6934cc489b8f1e967494f23958465cf1943712f054c5a306e9029/PyQt5_Qt5-5.15.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b68628f9b8261156f91d2f72ebc8dfb28697c4b83549245d9a68195bd2d74f0c", size = 37135109, upload-time = "2025-05-24T11:15:59.786Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e1/13d25a9ff2ac236a264b4603abaa39fa8bb9a7aa430519bb5f545c5b008d/PyQt5_Qt5-5.15.17-py3-none-manylinux2014_x86_64.whl", hash = "sha256:b018f75d1cc61146396fa5af14da1db77c5d6318030e5e366f09ffdf7bd358d8", size = 61112954, upload-time = "2025-05-24T11:16:26.036Z" }, +] + +[[package]] +name = "pyqt5-sip" +version = "12.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/79/086b50414bafa71df494398ad277d72e58229a3d1c1b1c766d12b14c2e6d/pyqt5_sip-12.17.0.tar.gz", hash = "sha256:682dadcdbd2239af9fdc0c0628e2776b820e128bec88b49b8d692fe682f90b4f", size = 104042, upload-time = "2025-02-02T17:13:11.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/23/1da570b7e143b6d216728c919cae2976f7dbff65db94e3d9f5b62df37ba5/PyQt5_sip-12.17.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ec47914cc751608e587c1c2fdabeaf4af7fdc28b9f62796c583bea01c1a1aa3e", size = 122696, upload-time = "2025-02-02T17:12:35.786Z" }, + { url = "https://files.pythonhosted.org/packages/61/d5/506b1c3ad06268c601276572f1cde1c0dffd074b44e023f4d80f5ea49265/PyQt5_sip-12.17.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:2f2a8dcc7626fe0da73a0918e05ce2460c7a14ddc946049310e6e35052105434", size = 270932, upload-time = "2025-02-02T17:12:38.175Z" }, + { url = "https://files.pythonhosted.org/packages/0b/9b/46159d8038374b076244a1930ead460e723453ec73f9b0330390ddfdd0ea/PyQt5_sip-12.17.0-cp310-cp310-win32.whl", hash = "sha256:0c75d28b8282be3c1d7dbc76950d6e6eba1e334783224e9b9835ce1a9c64f482", size = 49085, upload-time = "2025-02-02T17:12:40.146Z" }, + { url = "https://files.pythonhosted.org/packages/fe/66/b3eb937a620ce2a5db5c377beeca870d60fafd87aecc1bcca6921bbcf553/PyQt5_sip-12.17.0-cp310-cp310-win_amd64.whl", hash = "sha256:8c4bc535bae0dfa764e8534e893619fe843ce5a2e25f901c439bcb960114f686", size = 59040, upload-time = "2025-02-02T17:12:41.962Z" }, + { url = "https://files.pythonhosted.org/packages/52/fd/7d6e3deca5ce37413956faf4e933ce6beb87ac0cc7b26d934b5ed998f88a/PyQt5_sip-12.17.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2c912807dd638644168ea8c7a447bfd9d85a19471b98c2c588c4d2e911c09b0a", size = 122748, upload-time = "2025-02-02T17:12:43.831Z" }, + { url = "https://files.pythonhosted.org/packages/29/4d/e5981cde03b091fd83a1ef4ef6a4ca99ce6921d61b80c0222fc8eafdc99a/PyQt5_sip-12.17.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:71514a7d43b44faa1d65a74ad2c5da92c03a251bdc749f009c313f06cceacc9a", size = 276401, upload-time = "2025-02-02T17:12:45.705Z" }, + { url = "https://files.pythonhosted.org/packages/5f/30/4c282896b1e8841639cf2aca59acf57d8b261ed834ae976c959f25fa4a35/PyQt5_sip-12.17.0-cp311-cp311-win32.whl", hash = "sha256:023466ae96f72fbb8419b44c3f97475de6642fa5632520d0f50fc1a52a3e8200", size = 49091, upload-time = "2025-02-02T17:12:47.688Z" }, + { url = "https://files.pythonhosted.org/packages/24/c1/50fc7301aa39a50f451fc1b6b219e778c540a823fe9533a57b4793c859fd/PyQt5_sip-12.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:fb565469d08dcb0a427def0c45e722323beb62db79454260482b6948bfd52d47", size = 59036, upload-time = "2025-02-02T17:12:49.535Z" }, + { url = "https://files.pythonhosted.org/packages/a3/e6/e51367c28d69b5a462f38987f6024e766fd8205f121fe2f4d8ba2a6886b9/PyQt5_sip-12.17.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:ea08341c8a5da00c81df0d689ecd4ee47a95e1ecad9e362581c92513f2068005", size = 124650, upload-time = "2025-02-02T17:12:50.595Z" }, + { url = "https://files.pythonhosted.org/packages/64/3b/e6d1f772b41d8445d6faf86cc9da65910484ebd9f7df83abc5d4955437d0/PyQt5_sip-12.17.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:4a92478d6808040fbe614bb61500fbb3f19f72714b99369ec28d26a7e3494115", size = 281893, upload-time = "2025-02-02T17:12:51.966Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c5/d17fc2ddb9156a593710c88afd98abcf4055a2224b772f8bec2c6eea879c/PyQt5_sip-12.17.0-cp312-cp312-win32.whl", hash = "sha256:b0ff280b28813e9bfd3a4de99490739fc29b776dc48f1c849caca7239a10fc8b", size = 49438, upload-time = "2025-02-02T17:12:54.426Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c5/1174988d52c732d07033cf9a5067142b01d76be7731c6394a64d5c3ef65c/PyQt5_sip-12.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:54c31de7706d8a9a8c0fc3ea2c70468aba54b027d4974803f8eace9c22aad41c", size = 58017, upload-time = "2025-02-02T17:12:56.31Z" }, + { url = "https://files.pythonhosted.org/packages/fd/5d/f234e505af1a85189310521447ebc6052ebb697efded850d0f2b2555f7aa/PyQt5_sip-12.17.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c7a7ff355e369616b6bcb41d45b742327c104b2bf1674ec79b8d67f8f2fa9543", size = 124580, upload-time = "2025-02-02T17:12:58.158Z" }, + { url = "https://files.pythonhosted.org/packages/cd/cb/3b2050e9644d0021bdf25ddf7e4c3526e1edd0198879e76ba308e5d44faf/PyQt5_sip-12.17.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:419b9027e92b0b707632c370cfc6dc1f3b43c6313242fc4db57a537029bd179c", size = 281563, upload-time = "2025-02-02T17:12:59.421Z" }, + { url = "https://files.pythonhosted.org/packages/51/61/b8ebde7e0b32d0de44c521a0ace31439885b0423d7d45d010a2f7d92808c/PyQt5_sip-12.17.0-cp313-cp313-win32.whl", hash = "sha256:351beab964a19f5671b2a3e816ecf4d3543a99a7e0650f88a947fea251a7589f", size = 49383, upload-time = "2025-02-02T17:13:00.597Z" }, + { url = "https://files.pythonhosted.org/packages/15/ed/ff94d6b2910e7627380cb1fc9a518ff966e6d78285c8e54c9422b68305db/PyQt5_sip-12.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:672c209d05661fab8e17607c193bf43991d268a1eefbc2c4551fbf30fd8bb2ca", size = 58022, upload-time = "2025-02-02T17:13:01.738Z" }, +] + +[[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" }, + { 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-cov" +version = "6.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/99/668cade231f434aaa59bbfbf49469068d2ddd945000621d3d165d2e7dd7b/pytest_cov-6.2.1.tar.gz", hash = "sha256:25cc6cc0a5358204b8108ecedc51a9b57b34cc6b8c967cc2c01a4e00d8a67da2", size = 69432, upload-time = "2025-06-12T10:47:47.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/16/4ea354101abb1287856baa4af2732be351c7bee728065aed451b678153fd/pytest_cov-6.2.1-py3-none-any.whl", hash = "sha256:f5bc4c23f42f1cdd23c70b1dab1bbaef4fc505ba950d53e0081d0730dd7e86d5", size = 24644, upload-time = "2025-06-12T10:47:45.932Z" }, +] + +[[package]] +name = "pytest-qt" +version = "4.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pluggy" }, + { name = "pytest" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/61/8bdec02663c18bf5016709b909411dce04a868710477dc9b9844ffcf8dd2/pytest_qt-4.5.0.tar.gz", hash = "sha256:51620e01c488f065d2036425cbc1cbcf8a6972295105fd285321eb47e66a319f", size = 128702, upload-time = "2025-07-01T17:24:39.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/d0/8339b888ad64a3d4e508fed8245a402b503846e1972c10ad60955883dcbb/pytest_qt-4.5.0-py3-none-any.whl", hash = "sha256:ed21ea9b861247f7d18090a26bfbda8fb51d7a8a7b6f776157426ff2ccf26eff", size = 37214, upload-time = "2025-07-01T17:24:38.226Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "pywin32" +version = "310" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/da/a5f38fffbba2fb99aa4aa905480ac4b8e83ca486659ac8c95bce47fb5276/pywin32-310-cp310-cp310-win32.whl", hash = "sha256:6dd97011efc8bf51d6793a82292419eba2c71cf8e7250cfac03bba284454abc1", size = 8848240, upload-time = "2025-03-17T00:55:46.783Z" }, + { url = "https://files.pythonhosted.org/packages/aa/fe/d873a773324fa565619ba555a82c9dabd677301720f3660a731a5d07e49a/pywin32-310-cp310-cp310-win_amd64.whl", hash = "sha256:c3e78706e4229b915a0821941a84e7ef420bf2b77e08c9dae3c76fd03fd2ae3d", size = 9601854, upload-time = "2025-03-17T00:55:48.783Z" }, + { url = "https://files.pythonhosted.org/packages/3c/84/1a8e3d7a15490d28a5d816efa229ecb4999cdc51a7c30dd8914f669093b8/pywin32-310-cp310-cp310-win_arm64.whl", hash = "sha256:33babed0cf0c92a6f94cc6cc13546ab24ee13e3e800e61ed87609ab91e4c8213", size = 8522963, upload-time = "2025-03-17T00:55:50.969Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b1/68aa2986129fb1011dabbe95f0136f44509afaf072b12b8f815905a39f33/pywin32-310-cp311-cp311-win32.whl", hash = "sha256:1e765f9564e83011a63321bb9d27ec456a0ed90d3732c4b2e312b855365ed8bd", size = 8784284, upload-time = "2025-03-17T00:55:53.124Z" }, + { url = "https://files.pythonhosted.org/packages/b3/bd/d1592635992dd8db5bb8ace0551bc3a769de1ac8850200cfa517e72739fb/pywin32-310-cp311-cp311-win_amd64.whl", hash = "sha256:126298077a9d7c95c53823934f000599f66ec9296b09167810eb24875f32689c", size = 9520748, upload-time = "2025-03-17T00:55:55.203Z" }, + { url = "https://files.pythonhosted.org/packages/90/b1/ac8b1ffce6603849eb45a91cf126c0fa5431f186c2e768bf56889c46f51c/pywin32-310-cp311-cp311-win_arm64.whl", hash = "sha256:19ec5fc9b1d51c4350be7bb00760ffce46e6c95eaf2f0b2f1150657b1a43c582", size = 8455941, upload-time = "2025-03-17T00:55:57.048Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ec/4fdbe47932f671d6e348474ea35ed94227fb5df56a7c30cbbb42cd396ed0/pywin32-310-cp312-cp312-win32.whl", hash = "sha256:8a75a5cc3893e83a108c05d82198880704c44bbaee4d06e442e471d3c9ea4f3d", size = 8796239, upload-time = "2025-03-17T00:55:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/e3/e5/b0627f8bb84e06991bea89ad8153a9e50ace40b2e1195d68e9dff6b03d0f/pywin32-310-cp312-cp312-win_amd64.whl", hash = "sha256:bf5c397c9a9a19a6f62f3fb821fbf36cac08f03770056711f765ec1503972060", size = 9503839, upload-time = "2025-03-17T00:56:00.8Z" }, + { url = "https://files.pythonhosted.org/packages/1f/32/9ccf53748df72301a89713936645a664ec001abd35ecc8578beda593d37d/pywin32-310-cp312-cp312-win_arm64.whl", hash = "sha256:2349cc906eae872d0663d4d6290d13b90621eaf78964bb1578632ff20e152966", size = 8459470, upload-time = "2025-03-17T00:56:02.601Z" }, + { url = "https://files.pythonhosted.org/packages/1c/09/9c1b978ffc4ae53999e89c19c77ba882d9fce476729f23ef55211ea1c034/pywin32-310-cp313-cp313-win32.whl", hash = "sha256:5d241a659c496ada3253cd01cfaa779b048e90ce4b2b38cd44168ad555ce74ab", size = 8794384, upload-time = "2025-03-17T00:56:04.383Z" }, + { url = "https://files.pythonhosted.org/packages/45/3c/b4640f740ffebadd5d34df35fecba0e1cfef8fde9f3e594df91c28ad9b50/pywin32-310-cp313-cp313-win_amd64.whl", hash = "sha256:667827eb3a90208ddbdcc9e860c81bde63a135710e21e4cb3348968e4bd5249e", size = 9503039, upload-time = "2025-03-17T00:56:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f4/f785020090fb050e7fb6d34b780f2231f302609dc964672f72bfaeb59a28/pywin32-310-cp313-cp313-win_arm64.whl", hash = "sha256:e308f831de771482b7cf692a1f308f8fca701b2d8f9dde6cc440c7da17e47b33", size = 8458152, upload-time = "2025-03-17T00:56:07.819Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199, upload-time = "2024-08-06T20:31:40.178Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", size = 171758, upload-time = "2024-08-06T20:31:42.173Z" }, + { url = "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", size = 718463, upload-time = "2024-08-06T20:31:44.263Z" }, + { url = "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", size = 719280, upload-time = "2024-08-06T20:31:50.199Z" }, + { url = "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", size = 751239, upload-time = "2024-08-06T20:31:52.292Z" }, + { url = "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", size = 695802, upload-time = "2024-08-06T20:31:53.836Z" }, + { url = "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", size = 720527, upload-time = "2024-08-06T20:31:55.565Z" }, + { url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", size = 144052, upload-time = "2024-08-06T20:31:56.914Z" }, + { url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", size = 161774, upload-time = "2024-08-06T20:31:58.304Z" }, + { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612, upload-time = "2024-08-06T20:32:03.408Z" }, + { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040, upload-time = "2024-08-06T20:32:04.926Z" }, + { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829, upload-time = "2024-08-06T20:32:06.459Z" }, + { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167, upload-time = "2024-08-06T20:32:08.338Z" }, + { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952, upload-time = "2024-08-06T20:32:14.124Z" }, + { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301, upload-time = "2024-08-06T20:32:16.17Z" }, + { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638, upload-time = "2024-08-06T20:32:18.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850, upload-time = "2024-08-06T20:32:19.889Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980, upload-time = "2024-08-06T20:32:21.273Z" }, + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, +] + +[[package]] +name = "pyyaml-env-tag" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, +] + +[[package]] +name = "pyzmq" +version = "26.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "implementation_name == 'pypy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/11/b9213d25230ac18a71b39b3723494e57adebe36e066397b961657b3b41c1/pyzmq-26.4.0.tar.gz", hash = "sha256:4bd13f85f80962f91a651a7356fe0472791a5f7a92f227822b5acf44795c626d", size = 278293, upload-time = "2025-04-04T12:05:44.049Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/b8/af1d814ffc3ff9730f9a970cbf216b6f078e5d251a25ef5201d7bc32a37c/pyzmq-26.4.0-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:0329bdf83e170ac133f44a233fc651f6ed66ef8e66693b5af7d54f45d1ef5918", size = 1339238, upload-time = "2025-04-04T12:03:07.022Z" }, + { url = "https://files.pythonhosted.org/packages/ee/e4/5aafed4886c264f2ea6064601ad39c5fc4e9b6539c6ebe598a859832eeee/pyzmq-26.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:398a825d2dea96227cf6460ce0a174cf7657d6f6827807d4d1ae9d0f9ae64315", size = 672848, upload-time = "2025-04-04T12:03:08.591Z" }, + { url = "https://files.pythonhosted.org/packages/79/39/026bf49c721cb42f1ef3ae0ee3d348212a7621d2adb739ba97599b6e4d50/pyzmq-26.4.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6d52d62edc96787f5c1dfa6c6ccff9b581cfae5a70d94ec4c8da157656c73b5b", size = 911299, upload-time = "2025-04-04T12:03:10Z" }, + { url = "https://files.pythonhosted.org/packages/03/23/b41f936a9403b8f92325c823c0f264c6102a0687a99c820f1aaeb99c1def/pyzmq-26.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1410c3a3705db68d11eb2424d75894d41cff2f64d948ffe245dd97a9debfebf4", size = 867920, upload-time = "2025-04-04T12:03:11.311Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3e/2de5928cdadc2105e7c8f890cc5f404136b41ce5b6eae5902167f1d5641c/pyzmq-26.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:7dacb06a9c83b007cc01e8e5277f94c95c453c5851aac5e83efe93e72226353f", size = 862514, upload-time = "2025-04-04T12:03:13.013Z" }, + { url = "https://files.pythonhosted.org/packages/ce/57/109569514dd32e05a61d4382bc88980c95bfd2f02e58fea47ec0ccd96de1/pyzmq-26.4.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:6bab961c8c9b3a4dc94d26e9b2cdf84de9918931d01d6ff38c721a83ab3c0ef5", size = 1204494, upload-time = "2025-04-04T12:03:14.795Z" }, + { url = "https://files.pythonhosted.org/packages/aa/02/dc51068ff2ca70350d1151833643a598625feac7b632372d229ceb4de3e1/pyzmq-26.4.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:7a5c09413b924d96af2aa8b57e76b9b0058284d60e2fc3730ce0f979031d162a", size = 1514525, upload-time = "2025-04-04T12:03:16.246Z" }, + { url = "https://files.pythonhosted.org/packages/48/2a/a7d81873fff0645eb60afaec2b7c78a85a377af8f1d911aff045d8955bc7/pyzmq-26.4.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7d489ac234d38e57f458fdbd12a996bfe990ac028feaf6f3c1e81ff766513d3b", size = 1414659, upload-time = "2025-04-04T12:03:17.652Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ea/813af9c42ae21845c1ccfe495bd29c067622a621e85d7cda6bc437de8101/pyzmq-26.4.0-cp310-cp310-win32.whl", hash = "sha256:dea1c8db78fb1b4b7dc9f8e213d0af3fc8ecd2c51a1d5a3ca1cde1bda034a980", size = 580348, upload-time = "2025-04-04T12:03:19.384Z" }, + { url = "https://files.pythonhosted.org/packages/20/68/318666a89a565252c81d3fed7f3b4c54bd80fd55c6095988dfa2cd04a62b/pyzmq-26.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:fa59e1f5a224b5e04dc6c101d7186058efa68288c2d714aa12d27603ae93318b", size = 643838, upload-time = "2025-04-04T12:03:20.795Z" }, + { url = "https://files.pythonhosted.org/packages/91/f8/fb1a15b5f4ecd3e588bfde40c17d32ed84b735195b5c7d1d7ce88301a16f/pyzmq-26.4.0-cp310-cp310-win_arm64.whl", hash = "sha256:a651fe2f447672f4a815e22e74630b6b1ec3a1ab670c95e5e5e28dcd4e69bbb5", size = 559565, upload-time = "2025-04-04T12:03:22.676Z" }, + { url = "https://files.pythonhosted.org/packages/32/6d/234e3b0aa82fd0290b1896e9992f56bdddf1f97266110be54d0177a9d2d9/pyzmq-26.4.0-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:bfcf82644c9b45ddd7cd2a041f3ff8dce4a0904429b74d73a439e8cab1bd9e54", size = 1339723, upload-time = "2025-04-04T12:03:24.358Z" }, + { url = "https://files.pythonhosted.org/packages/4f/11/6d561efe29ad83f7149a7cd48e498e539ed09019c6cd7ecc73f4cc725028/pyzmq-26.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e9bcae3979b2654d5289d3490742378b2f3ce804b0b5fd42036074e2bf35b030", size = 672645, upload-time = "2025-04-04T12:03:25.693Z" }, + { url = "https://files.pythonhosted.org/packages/19/fd/81bfe3e23f418644660bad1a90f0d22f0b3eebe33dd65a79385530bceb3d/pyzmq-26.4.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ccdff8ac4246b6fb60dcf3982dfaeeff5dd04f36051fe0632748fc0aa0679c01", size = 910133, upload-time = "2025-04-04T12:03:27.625Z" }, + { url = "https://files.pythonhosted.org/packages/97/68/321b9c775595ea3df832a9516252b653fe32818db66fdc8fa31c9b9fce37/pyzmq-26.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4550af385b442dc2d55ab7717837812799d3674cb12f9a3aa897611839c18e9e", size = 867428, upload-time = "2025-04-04T12:03:29.004Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6e/159cbf2055ef36aa2aa297e01b24523176e5b48ead283c23a94179fb2ba2/pyzmq-26.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:2f9f7ffe9db1187a253fca95191854b3fda24696f086e8789d1d449308a34b88", size = 862409, upload-time = "2025-04-04T12:03:31.032Z" }, + { url = "https://files.pythonhosted.org/packages/05/1c/45fb8db7be5a7d0cadea1070a9cbded5199a2d578de2208197e592f219bd/pyzmq-26.4.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:3709c9ff7ba61589b7372923fd82b99a81932b592a5c7f1a24147c91da9a68d6", size = 1205007, upload-time = "2025-04-04T12:03:32.687Z" }, + { url = "https://files.pythonhosted.org/packages/f8/fa/658c7f583af6498b463f2fa600f34e298e1b330886f82f1feba0dc2dd6c3/pyzmq-26.4.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:f8f3c30fb2d26ae5ce36b59768ba60fb72507ea9efc72f8f69fa088450cff1df", size = 1514599, upload-time = "2025-04-04T12:03:34.084Z" }, + { url = "https://files.pythonhosted.org/packages/4d/d7/44d641522353ce0a2bbd150379cb5ec32f7120944e6bfba4846586945658/pyzmq-26.4.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:382a4a48c8080e273427fc692037e3f7d2851959ffe40864f2db32646eeb3cef", size = 1414546, upload-time = "2025-04-04T12:03:35.478Z" }, + { url = "https://files.pythonhosted.org/packages/72/76/c8ed7263218b3d1e9bce07b9058502024188bd52cc0b0a267a9513b431fc/pyzmq-26.4.0-cp311-cp311-win32.whl", hash = "sha256:d56aad0517d4c09e3b4f15adebba8f6372c5102c27742a5bdbfc74a7dceb8fca", size = 579247, upload-time = "2025-04-04T12:03:36.846Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d0/2d9abfa2571a0b1a67c0ada79a8aa1ba1cce57992d80f771abcdf99bb32c/pyzmq-26.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:963977ac8baed7058c1e126014f3fe58b3773f45c78cce7af5c26c09b6823896", size = 644727, upload-time = "2025-04-04T12:03:38.578Z" }, + { url = "https://files.pythonhosted.org/packages/0d/d1/c8ad82393be6ccedfc3c9f3adb07f8f3976e3c4802640fe3f71441941e70/pyzmq-26.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:c0c8e8cadc81e44cc5088fcd53b9b3b4ce9344815f6c4a03aec653509296fae3", size = 559942, upload-time = "2025-04-04T12:03:40.143Z" }, + { url = "https://files.pythonhosted.org/packages/10/44/a778555ebfdf6c7fc00816aad12d185d10a74d975800341b1bc36bad1187/pyzmq-26.4.0-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:5227cb8da4b6f68acfd48d20c588197fd67745c278827d5238c707daf579227b", size = 1341586, upload-time = "2025-04-04T12:03:41.954Z" }, + { url = "https://files.pythonhosted.org/packages/9c/4f/f3a58dc69ac757e5103be3bd41fb78721a5e17da7cc617ddb56d973a365c/pyzmq-26.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1c07a7fa7f7ba86554a2b1bef198c9fed570c08ee062fd2fd6a4dcacd45f905", size = 665880, upload-time = "2025-04-04T12:03:43.45Z" }, + { url = "https://files.pythonhosted.org/packages/fe/45/50230bcfb3ae5cb98bee683b6edeba1919f2565d7cc1851d3c38e2260795/pyzmq-26.4.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae775fa83f52f52de73183f7ef5395186f7105d5ed65b1ae65ba27cb1260de2b", size = 902216, upload-time = "2025-04-04T12:03:45.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/59/56bbdc5689be5e13727491ad2ba5efd7cd564365750514f9bc8f212eef82/pyzmq-26.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66c760d0226ebd52f1e6b644a9e839b5db1e107a23f2fcd46ec0569a4fdd4e63", size = 859814, upload-time = "2025-04-04T12:03:47.188Z" }, + { url = "https://files.pythonhosted.org/packages/81/b1/57db58cfc8af592ce94f40649bd1804369c05b2190e4cbc0a2dad572baeb/pyzmq-26.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:ef8c6ecc1d520debc147173eaa3765d53f06cd8dbe7bd377064cdbc53ab456f5", size = 855889, upload-time = "2025-04-04T12:03:49.223Z" }, + { url = "https://files.pythonhosted.org/packages/e8/92/47542e629cbac8f221c230a6d0f38dd3d9cff9f6f589ed45fdf572ffd726/pyzmq-26.4.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3150ef4084e163dec29ae667b10d96aad309b668fac6810c9e8c27cf543d6e0b", size = 1197153, upload-time = "2025-04-04T12:03:50.591Z" }, + { url = "https://files.pythonhosted.org/packages/07/e5/b10a979d1d565d54410afc87499b16c96b4a181af46e7645ab4831b1088c/pyzmq-26.4.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:4448c9e55bf8329fa1dcedd32f661bf611214fa70c8e02fee4347bc589d39a84", size = 1507352, upload-time = "2025-04-04T12:03:52.473Z" }, + { url = "https://files.pythonhosted.org/packages/ab/58/5a23db84507ab9c01c04b1232a7a763be66e992aa2e66498521bbbc72a71/pyzmq-26.4.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e07dde3647afb084d985310d067a3efa6efad0621ee10826f2cb2f9a31b89d2f", size = 1406834, upload-time = "2025-04-04T12:03:54Z" }, + { url = "https://files.pythonhosted.org/packages/22/74/aaa837b331580c13b79ac39396601fb361454ee184ca85e8861914769b99/pyzmq-26.4.0-cp312-cp312-win32.whl", hash = "sha256:ba034a32ecf9af72adfa5ee383ad0fd4f4e38cdb62b13624278ef768fe5b5b44", size = 577992, upload-time = "2025-04-04T12:03:55.815Z" }, + { url = "https://files.pythonhosted.org/packages/30/0f/55f8c02c182856743b82dde46b2dc3e314edda7f1098c12a8227eeda0833/pyzmq-26.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:056a97aab4064f526ecb32f4343917a4022a5d9efb6b9df990ff72e1879e40be", size = 640466, upload-time = "2025-04-04T12:03:57.231Z" }, + { url = "https://files.pythonhosted.org/packages/e4/29/073779afc3ef6f830b8de95026ef20b2d1ec22d0324d767748d806e57379/pyzmq-26.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f23c750e485ce1eb639dbd576d27d168595908aa2d60b149e2d9e34c9df40e0", size = 556342, upload-time = "2025-04-04T12:03:59.218Z" }, + { url = "https://files.pythonhosted.org/packages/d7/20/fb2c92542488db70f833b92893769a569458311a76474bda89dc4264bd18/pyzmq-26.4.0-cp313-cp313-macosx_10_15_universal2.whl", hash = "sha256:c43fac689880f5174d6fc864857d1247fe5cfa22b09ed058a344ca92bf5301e3", size = 1339484, upload-time = "2025-04-04T12:04:00.671Z" }, + { url = "https://files.pythonhosted.org/packages/58/29/2f06b9cabda3a6ea2c10f43e67ded3e47fc25c54822e2506dfb8325155d4/pyzmq-26.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:902aca7eba477657c5fb81c808318460328758e8367ecdd1964b6330c73cae43", size = 666106, upload-time = "2025-04-04T12:04:02.366Z" }, + { url = "https://files.pythonhosted.org/packages/77/e4/dcf62bd29e5e190bd21bfccaa4f3386e01bf40d948c239239c2f1e726729/pyzmq-26.4.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e5e48a830bfd152fe17fbdeaf99ac5271aa4122521bf0d275b6b24e52ef35eb6", size = 902056, upload-time = "2025-04-04T12:04:03.919Z" }, + { url = "https://files.pythonhosted.org/packages/1a/cf/b36b3d7aea236087d20189bec1a87eeb2b66009731d7055e5c65f845cdba/pyzmq-26.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31be2b6de98c824c06f5574331f805707c667dc8f60cb18580b7de078479891e", size = 860148, upload-time = "2025-04-04T12:04:05.581Z" }, + { url = "https://files.pythonhosted.org/packages/18/a6/f048826bc87528c208e90604c3bf573801e54bd91e390cbd2dfa860e82dc/pyzmq-26.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6332452034be001bbf3206ac59c0d2a7713de5f25bb38b06519fc6967b7cf771", size = 855983, upload-time = "2025-04-04T12:04:07.096Z" }, + { url = "https://files.pythonhosted.org/packages/0a/27/454d34ab6a1d9772a36add22f17f6b85baf7c16e14325fa29e7202ca8ee8/pyzmq-26.4.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:da8c0f5dd352136853e6a09b1b986ee5278dfddfebd30515e16eae425c872b30", size = 1197274, upload-time = "2025-04-04T12:04:08.523Z" }, + { url = "https://files.pythonhosted.org/packages/f4/3d/7abfeab6b83ad38aa34cbd57c6fc29752c391e3954fd12848bd8d2ec0df6/pyzmq-26.4.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:f4ccc1a0a2c9806dda2a2dd118a3b7b681e448f3bb354056cad44a65169f6d86", size = 1507120, upload-time = "2025-04-04T12:04:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/13/ff/bc8d21dbb9bc8705126e875438a1969c4f77e03fc8565d6901c7933a3d01/pyzmq-26.4.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1c0b5fceadbab461578daf8d1dcc918ebe7ddd2952f748cf30c7cf2de5d51101", size = 1406738, upload-time = "2025-04-04T12:04:12.509Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5d/d4cd85b24de71d84d81229e3bbb13392b2698432cf8fdcea5afda253d587/pyzmq-26.4.0-cp313-cp313-win32.whl", hash = "sha256:28e2b0ff5ba4b3dd11062d905682bad33385cfa3cc03e81abd7f0822263e6637", size = 577826, upload-time = "2025-04-04T12:04:14.289Z" }, + { url = "https://files.pythonhosted.org/packages/c6/6c/f289c1789d7bb6e5a3b3bef7b2a55089b8561d17132be7d960d3ff33b14e/pyzmq-26.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:23ecc9d241004c10e8b4f49d12ac064cd7000e1643343944a10df98e57bc544b", size = 640406, upload-time = "2025-04-04T12:04:15.757Z" }, + { url = "https://files.pythonhosted.org/packages/b3/99/676b8851cb955eb5236a0c1e9ec679ea5ede092bf8bf2c8a68d7e965cac3/pyzmq-26.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:1edb0385c7f025045d6e0f759d4d3afe43c17a3d898914ec6582e6f464203c08", size = 556216, upload-time = "2025-04-04T12:04:17.212Z" }, + { url = "https://files.pythonhosted.org/packages/65/c2/1fac340de9d7df71efc59d9c50fc7a635a77b103392d1842898dd023afcb/pyzmq-26.4.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:93a29e882b2ba1db86ba5dd5e88e18e0ac6b627026c5cfbec9983422011b82d4", size = 1333769, upload-time = "2025-04-04T12:04:18.665Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c7/6c03637e8d742c3b00bec4f5e4cd9d1c01b2f3694c6f140742e93ca637ed/pyzmq-26.4.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb45684f276f57110bb89e4300c00f1233ca631f08f5f42528a5c408a79efc4a", size = 658826, upload-time = "2025-04-04T12:04:20.405Z" }, + { url = "https://files.pythonhosted.org/packages/a5/97/a8dca65913c0f78e0545af2bb5078aebfc142ca7d91cdaffa1fbc73e5dbd/pyzmq-26.4.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f72073e75260cb301aad4258ad6150fa7f57c719b3f498cb91e31df16784d89b", size = 891650, upload-time = "2025-04-04T12:04:22.413Z" }, + { url = "https://files.pythonhosted.org/packages/7d/7e/f63af1031eb060bf02d033732b910fe48548dcfdbe9c785e9f74a6cc6ae4/pyzmq-26.4.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be37e24b13026cfedd233bcbbccd8c0bcd2fdd186216094d095f60076201538d", size = 849776, upload-time = "2025-04-04T12:04:23.959Z" }, + { url = "https://files.pythonhosted.org/packages/f6/fa/1a009ce582802a895c0d5fe9413f029c940a0a8ee828657a3bb0acffd88b/pyzmq-26.4.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:237b283044934d26f1eeff4075f751b05d2f3ed42a257fc44386d00df6a270cf", size = 842516, upload-time = "2025-04-04T12:04:25.449Z" }, + { url = "https://files.pythonhosted.org/packages/6e/bc/f88b0bad0f7a7f500547d71e99f10336f2314e525d4ebf576a1ea4a1d903/pyzmq-26.4.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:b30f862f6768b17040929a68432c8a8be77780317f45a353cb17e423127d250c", size = 1189183, upload-time = "2025-04-04T12:04:27.035Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8c/db446a3dd9cf894406dec2e61eeffaa3c07c3abb783deaebb9812c4af6a5/pyzmq-26.4.0-cp313-cp313t-musllinux_1_1_i686.whl", hash = "sha256:c80fcd3504232f13617c6ab501124d373e4895424e65de8b72042333316f64a8", size = 1495501, upload-time = "2025-04-04T12:04:28.833Z" }, + { url = "https://files.pythonhosted.org/packages/05/4c/bf3cad0d64c3214ac881299c4562b815f05d503bccc513e3fd4fdc6f67e4/pyzmq-26.4.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:26a2a7451606b87f67cdeca2c2789d86f605da08b4bd616b1a9981605ca3a364", size = 1395540, upload-time = "2025-04-04T12:04:30.562Z" }, + { url = "https://files.pythonhosted.org/packages/47/03/96004704a84095f493be8d2b476641f5c967b269390173f85488a53c1c13/pyzmq-26.4.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:98d948288ce893a2edc5ec3c438fe8de2daa5bbbd6e2e865ec5f966e237084ba", size = 834408, upload-time = "2025-04-04T12:05:04.569Z" }, + { url = "https://files.pythonhosted.org/packages/e4/7f/68d8f3034a20505db7551cb2260248be28ca66d537a1ac9a257913d778e4/pyzmq-26.4.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9f34f5c9e0203ece706a1003f1492a56c06c0632d86cb77bcfe77b56aacf27b", size = 569580, upload-time = "2025-04-04T12:05:06.283Z" }, + { url = "https://files.pythonhosted.org/packages/9b/a6/2b0d6801ec33f2b2a19dd8d02e0a1e8701000fec72926e6787363567d30c/pyzmq-26.4.0-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:80c9b48aef586ff8b698359ce22f9508937c799cc1d2c9c2f7c95996f2300c94", size = 798250, upload-time = "2025-04-04T12:05:07.88Z" }, + { url = "https://files.pythonhosted.org/packages/96/2a/0322b3437de977dcac8a755d6d7ce6ec5238de78e2e2d9353730b297cf12/pyzmq-26.4.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3f2a5b74009fd50b53b26f65daff23e9853e79aa86e0aa08a53a7628d92d44a", size = 756758, upload-time = "2025-04-04T12:05:09.483Z" }, + { url = "https://files.pythonhosted.org/packages/c2/33/43704f066369416d65549ccee366cc19153911bec0154da7c6b41fca7e78/pyzmq-26.4.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:61c5f93d7622d84cb3092d7f6398ffc77654c346545313a3737e266fc11a3beb", size = 555371, upload-time = "2025-04-04T12:05:11.062Z" }, + { url = "https://files.pythonhosted.org/packages/04/52/a70fcd5592715702248306d8e1729c10742c2eac44529984413b05c68658/pyzmq-26.4.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:4478b14cb54a805088299c25a79f27eaf530564a7a4f72bf432a040042b554eb", size = 834405, upload-time = "2025-04-04T12:05:13.3Z" }, + { url = "https://files.pythonhosted.org/packages/25/f9/1a03f1accff16b3af1a6fa22cbf7ced074776abbf688b2e9cb4629700c62/pyzmq-26.4.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8a28ac29c60e4ba84b5f58605ace8ad495414a724fe7aceb7cf06cd0598d04e1", size = 569578, upload-time = "2025-04-04T12:05:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/76/0c/3a633acd762aa6655fcb71fa841907eae0ab1e8582ff494b137266de341d/pyzmq-26.4.0-pp311-pypy311_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:43b03c1ceea27c6520124f4fb2ba9c647409b9abdf9a62388117148a90419494", size = 798248, upload-time = "2025-04-04T12:05:17.376Z" }, + { url = "https://files.pythonhosted.org/packages/cd/cc/6c99c84aa60ac1cc56747bed6be8ce6305b9b861d7475772e7a25ce019d3/pyzmq-26.4.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7731abd23a782851426d4e37deb2057bf9410848a4459b5ede4fe89342e687a9", size = 756757, upload-time = "2025-04-04T12:05:19.19Z" }, + { url = "https://files.pythonhosted.org/packages/13/9c/d8073bd898eb896e94c679abe82e47506e2b750eb261cf6010ced869797c/pyzmq-26.4.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a222ad02fbe80166b0526c038776e8042cd4e5f0dec1489a006a1df47e9040e0", size = 555371, upload-time = "2025-04-04T12:05:20.702Z" }, +] + +[[package]] +name = "qtconsole" +version = "5.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ipykernel" }, + { name = "ipython-pygments-lexers" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "qtpy" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/8b/ec641425ee462607a05a62c9e86725f1d20f347b63164c87515798dc3226/qtconsole-5.7.1.tar.gz", hash = "sha256:5f0944cdaad2a0fca4aedf598352889b172e8dc7f57354bdd097ed92c6563a01", size = 436262, upload-time = "2026-02-10T16:36:17.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/73/1e12de83d2376977aff54a45a08ab8c5ca535cc8e19429f2120eede4aa34/qtconsole-5.7.1-py3-none-any.whl", hash = "sha256:fa90f4944841d225114b8379d37f1a115b10594d7ee185f9c103fe644c193acd", size = 125519, upload-time = "2026-02-10T16:36:16.276Z" }, +] + +[[package]] +name = "qtpy" +version = "2.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/70/01/392eba83c8e47b946b929d7c46e0f04b35e9671f8bb6fc36b6f7945b4de8/qtpy-2.4.3.tar.gz", hash = "sha256:db744f7832e6d3da90568ba6ccbca3ee2b3b4a890c3d6fbbc63142f6e4cdf5bb", size = 66982, upload-time = "2025-02-11T15:09:25.759Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/76/37c0ccd5ab968a6a438f9c623aeecc84c202ab2fabc6a8fd927580c15b5a/QtPy-2.4.3-py3-none-any.whl", hash = "sha256:72095afe13673e017946cc258b8d5da43314197b741ed2890e563cf384b51aa1", size = 95045, upload-time = "2025-02-11T15:09:24.162Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "responses" +version = "0.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/b4/b7e040379838cc71bf5aabdb26998dfbe5ee73904c92c1c161faf5de8866/responses-0.26.0.tar.gz", hash = "sha256:c7f6923e6343ef3682816ba421c006626777893cb0d5e1434f674b649bac9eb4", size = 81303, upload-time = "2026-02-19T14:38:05.574Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/04/7f73d05b556da048923e31a0cc878f03be7c5425ed1f268082255c75d872/responses-0.26.0-py3-none-any.whl", hash = "sha256:03ec4409088cd5c66b71ecbbbd27fe2c58ddfad801c66203457b3e6a04868c37", size = 35099, upload-time = "2026-02-19T14:38:03.847Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/77/9b/840e0039e65fcf12758adf684d2289024d6140cde9268cc59887dc55189c/ruff-0.15.5.tar.gz", hash = "sha256:7c3601d3b6d76dce18c5c824fc8d06f4eef33d6df0c21ec7799510cde0f159a2", size = 4574214, upload-time = "2026-03-05T20:06:34.946Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/20/5369c3ce21588c708bcbe517a8fbe1a8dfdb5dfd5137e14790b1da71612c/ruff-0.15.5-py3-none-linux_armv6l.whl", hash = "sha256:4ae44c42281f42e3b06b988e442d344a5b9b72450ff3c892e30d11b29a96a57c", size = 10478185, upload-time = "2026-03-05T20:06:29.093Z" }, + { url = "https://files.pythonhosted.org/packages/44/ed/e81dd668547da281e5dce710cf0bc60193f8d3d43833e8241d006720e42b/ruff-0.15.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6edd3792d408ebcf61adabc01822da687579a1a023f297618ac27a5b51ef0080", size = 10859201, upload-time = "2026-03-05T20:06:32.632Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8f/533075f00aaf19b07c5cd6aa6e5d89424b06b3b3f4583bfa9c640a079059/ruff-0.15.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:89f463f7c8205a9f8dea9d658d59eff49db05f88f89cc3047fb1a02d9f344010", size = 10184752, upload-time = "2026-03-05T20:06:40.312Z" }, + { url = "https://files.pythonhosted.org/packages/66/0e/ba49e2c3fa0395b3152bad634c7432f7edfc509c133b8f4529053ff024fb/ruff-0.15.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba786a8295c6574c1116704cf0b9e6563de3432ac888d8f83685654fe528fd65", size = 10534857, upload-time = "2026-03-05T20:06:19.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/71/39234440f27a226475a0659561adb0d784b4d247dfe7f43ffc12dd02e288/ruff-0.15.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd4b801e57955fe9f02b31d20375ab3a5c4415f2e5105b79fb94cf2642c91440", size = 10309120, upload-time = "2026-03-05T20:06:00.435Z" }, + { url = "https://files.pythonhosted.org/packages/f5/87/4140aa86a93df032156982b726f4952aaec4a883bb98cb6ef73c347da253/ruff-0.15.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:391f7c73388f3d8c11b794dbbc2959a5b5afe66642c142a6effa90b45f6f5204", size = 11047428, upload-time = "2026-03-05T20:05:51.867Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f7/4953e7e3287676f78fbe85e3a0ca414c5ca81237b7575bdadc00229ac240/ruff-0.15.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dc18f30302e379fe1e998548b0f5e9f4dff907f52f73ad6da419ea9c19d66c8", size = 11914251, upload-time = "2026-03-05T20:06:22.887Z" }, + { url = "https://files.pythonhosted.org/packages/77/46/0f7c865c10cf896ccf5a939c3e84e1cfaeed608ff5249584799a74d33835/ruff-0.15.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc6e7f90087e2d27f98dc34ed1b3ab7c8f0d273cc5431415454e22c0bd2a681", size = 11333801, upload-time = "2026-03-05T20:05:57.168Z" }, + { url = "https://files.pythonhosted.org/packages/d3/01/a10fe54b653061585e655f5286c2662ebddb68831ed3eaebfb0eb08c0a16/ruff-0.15.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1cb7169f53c1ddb06e71a9aebd7e98fc0fea936b39afb36d8e86d36ecc2636a", size = 11206821, upload-time = "2026-03-05T20:06:03.441Z" }, + { url = "https://files.pythonhosted.org/packages/7a/0d/2132ceaf20c5e8699aa83da2706ecb5c5dcdf78b453f77edca7fb70f8a93/ruff-0.15.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9b037924500a31ee17389b5c8c4d88874cc6ea8e42f12e9c61a3d754ff72f1ca", size = 11133326, upload-time = "2026-03-05T20:06:25.655Z" }, + { url = "https://files.pythonhosted.org/packages/72/cb/2e5259a7eb2a0f87c08c0fe5bf5825a1e4b90883a52685524596bfc93072/ruff-0.15.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65bb414e5b4eadd95a8c1e4804f6772bbe8995889f203a01f77ddf2d790929dd", size = 10510820, upload-time = "2026-03-05T20:06:37.79Z" }, + { url = "https://files.pythonhosted.org/packages/ff/20/b67ce78f9e6c59ffbdb5b4503d0090e749b5f2d31b599b554698a80d861c/ruff-0.15.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d20aa469ae3b57033519c559e9bc9cd9e782842e39be05b50e852c7c981fa01d", size = 10302395, upload-time = "2026-03-05T20:05:54.504Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e5/719f1acccd31b720d477751558ed74e9c88134adcc377e5e886af89d3072/ruff-0.15.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:15388dd28c9161cdb8eda68993533acc870aa4e646a0a277aa166de9ad5a8752", size = 10754069, upload-time = "2026-03-05T20:06:06.422Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/d1db14469e32d98f3ca27079dbd30b7b44dbb5317d06ab36718dee3baf03/ruff-0.15.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b30da330cbd03bed0c21420b6b953158f60c74c54c5f4c1dabbdf3a57bf355d2", size = 11304315, upload-time = "2026-03-05T20:06:10.867Z" }, + { url = "https://files.pythonhosted.org/packages/28/3a/950367aee7c69027f4f422059227b290ed780366b6aecee5de5039d50fa8/ruff-0.15.5-py3-none-win32.whl", hash = "sha256:732e5ee1f98ba5b3679029989a06ca39a950cced52143a0ea82a2102cb592b74", size = 10551676, upload-time = "2026-03-05T20:06:13.705Z" }, + { url = "https://files.pythonhosted.org/packages/b8/00/bf077a505b4e649bdd3c47ff8ec967735ce2544c8e4a43aba42ee9bf935d/ruff-0.15.5-py3-none-win_amd64.whl", hash = "sha256:821d41c5fa9e19117616c35eaa3f4b75046ec76c65e7ae20a333e9a8696bc7fe", size = 11678972, upload-time = "2026-03-05T20:06:45.379Z" }, + { url = "https://files.pythonhosted.org/packages/fe/4e/cd76eca6db6115604b7626668e891c9dd03330384082e33662fb0f113614/ruff-0.15.5-py3-none-win_arm64.whl", hash = "sha256:b498d1c60d2fe5c10c45ec3f698901065772730b411f164ae270bb6bfcc4740b", size = 10965572, upload-time = "2026-03-05T20:06:16.984Z" }, +] + +[[package]] +name = "scipy" +version = "1.15.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/2f/4966032c5f8cc7e6a60f1b2e0ad686293b9474b65246b0c642e3ef3badd0/scipy-1.15.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:a345928c86d535060c9c2b25e71e87c39ab2f22fc96e9636bd74d1dbf9de448c", size = 38702770, upload-time = "2025-05-08T16:04:20.849Z" }, + { url = "https://files.pythonhosted.org/packages/a0/6e/0c3bf90fae0e910c274db43304ebe25a6b391327f3f10b5dcc638c090795/scipy-1.15.3-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:ad3432cb0f9ed87477a8d97f03b763fd1d57709f1bbde3c9369b1dff5503b253", size = 30094511, upload-time = "2025-05-08T16:04:27.103Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b1/4deb37252311c1acff7f101f6453f0440794f51b6eacb1aad4459a134081/scipy-1.15.3-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:aef683a9ae6eb00728a542b796f52a5477b78252edede72b8327a886ab63293f", size = 22368151, upload-time = "2025-05-08T16:04:31.731Z" }, + { url = "https://files.pythonhosted.org/packages/38/7d/f457626e3cd3c29b3a49ca115a304cebb8cc6f31b04678f03b216899d3c6/scipy-1.15.3-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:1c832e1bd78dea67d5c16f786681b28dd695a8cb1fb90af2e27580d3d0967e92", size = 25121732, upload-time = "2025-05-08T16:04:36.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/0a/92b1de4a7adc7a15dcf5bddc6e191f6f29ee663b30511ce20467ef9b82e4/scipy-1.15.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:263961f658ce2165bbd7b99fa5135195c3a12d9bef045345016b8b50c315cb82", size = 35547617, upload-time = "2025-05-08T16:04:43.546Z" }, + { url = "https://files.pythonhosted.org/packages/8e/6d/41991e503e51fc1134502694c5fa7a1671501a17ffa12716a4a9151af3df/scipy-1.15.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e2abc762b0811e09a0d3258abee2d98e0c703eee49464ce0069590846f31d40", size = 37662964, upload-time = "2025-05-08T16:04:49.431Z" }, + { url = "https://files.pythonhosted.org/packages/25/e1/3df8f83cb15f3500478c889be8fb18700813b95e9e087328230b98d547ff/scipy-1.15.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ed7284b21a7a0c8f1b6e5977ac05396c0d008b89e05498c8b7e8f4a1423bba0e", size = 37238749, upload-time = "2025-05-08T16:04:55.215Z" }, + { url = "https://files.pythonhosted.org/packages/93/3e/b3257cf446f2a3533ed7809757039016b74cd6f38271de91682aa844cfc5/scipy-1.15.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5380741e53df2c566f4d234b100a484b420af85deb39ea35a1cc1be84ff53a5c", size = 40022383, upload-time = "2025-05-08T16:05:01.914Z" }, + { url = "https://files.pythonhosted.org/packages/d1/84/55bc4881973d3f79b479a5a2e2df61c8c9a04fcb986a213ac9c02cfb659b/scipy-1.15.3-cp310-cp310-win_amd64.whl", hash = "sha256:9d61e97b186a57350f6d6fd72640f9e99d5a4a2b8fbf4b9ee9a841eab327dc13", size = 41259201, upload-time = "2025-05-08T16:05:08.166Z" }, + { url = "https://files.pythonhosted.org/packages/96/ab/5cc9f80f28f6a7dff646c5756e559823614a42b1939d86dd0ed550470210/scipy-1.15.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:993439ce220d25e3696d1b23b233dd010169b62f6456488567e830654ee37a6b", size = 38714255, upload-time = "2025-05-08T16:05:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/66ba30abe5ad1a3ad15bfb0b59d22174012e8056ff448cb1644deccbfed2/scipy-1.15.3-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:34716e281f181a02341ddeaad584205bd2fd3c242063bd3423d61ac259ca7eba", size = 30111035, upload-time = "2025-05-08T16:05:20.152Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fa/a7e5b95afd80d24313307f03624acc65801846fa75599034f8ceb9e2cbf6/scipy-1.15.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3b0334816afb8b91dab859281b1b9786934392aa3d527cd847e41bb6f45bee65", size = 22384499, upload-time = "2025-05-08T16:05:24.494Z" }, + { url = "https://files.pythonhosted.org/packages/17/99/f3aaddccf3588bb4aea70ba35328c204cadd89517a1612ecfda5b2dd9d7a/scipy-1.15.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:6db907c7368e3092e24919b5e31c76998b0ce1684d51a90943cb0ed1b4ffd6c1", size = 25152602, upload-time = "2025-05-08T16:05:29.313Z" }, + { url = "https://files.pythonhosted.org/packages/56/c5/1032cdb565f146109212153339f9cb8b993701e9fe56b1c97699eee12586/scipy-1.15.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:721d6b4ef5dc82ca8968c25b111e307083d7ca9091bc38163fb89243e85e3889", size = 35503415, upload-time = "2025-05-08T16:05:34.699Z" }, + { url = "https://files.pythonhosted.org/packages/bd/37/89f19c8c05505d0601ed5650156e50eb881ae3918786c8fd7262b4ee66d3/scipy-1.15.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39cb9c62e471b1bb3750066ecc3a3f3052b37751c7c3dfd0fd7e48900ed52982", size = 37652622, upload-time = "2025-05-08T16:05:40.762Z" }, + { url = "https://files.pythonhosted.org/packages/7e/31/be59513aa9695519b18e1851bb9e487de66f2d31f835201f1b42f5d4d475/scipy-1.15.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:795c46999bae845966368a3c013e0e00947932d68e235702b5c3f6ea799aa8c9", size = 37244796, upload-time = "2025-05-08T16:05:48.119Z" }, + { url = "https://files.pythonhosted.org/packages/10/c0/4f5f3eeccc235632aab79b27a74a9130c6c35df358129f7ac8b29f562ac7/scipy-1.15.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:18aaacb735ab38b38db42cb01f6b92a2d0d4b6aabefeb07f02849e47f8fb3594", size = 40047684, upload-time = "2025-05-08T16:05:54.22Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a7/0ddaf514ce8a8714f6ed243a2b391b41dbb65251affe21ee3077ec45ea9a/scipy-1.15.3-cp311-cp311-win_amd64.whl", hash = "sha256:ae48a786a28412d744c62fd7816a4118ef97e5be0bee968ce8f0a2fba7acf3bb", size = 41246504, upload-time = "2025-05-08T16:06:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/683aa044c4162e10ed7a7ea30527f2cbd92e6999c10a8ed8edb253836e9c/scipy-1.15.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6ac6310fdbfb7aa6612408bd2f07295bcbd3fda00d2d702178434751fe48e019", size = 38766735, upload-time = "2025-05-08T16:06:06.471Z" }, + { url = "https://files.pythonhosted.org/packages/7b/7e/f30be3d03de07f25dc0ec926d1681fed5c732d759ac8f51079708c79e680/scipy-1.15.3-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:185cd3d6d05ca4b44a8f1595af87f9c372bb6acf9c808e99aa3e9aa03bd98cf6", size = 30173284, upload-time = "2025-05-08T16:06:11.686Z" }, + { url = "https://files.pythonhosted.org/packages/07/9c/0ddb0d0abdabe0d181c1793db51f02cd59e4901da6f9f7848e1f96759f0d/scipy-1.15.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:05dc6abcd105e1a29f95eada46d4a3f251743cfd7d3ae8ddb4088047f24ea477", size = 22446958, upload-time = "2025-05-08T16:06:15.97Z" }, + { url = "https://files.pythonhosted.org/packages/af/43/0bce905a965f36c58ff80d8bea33f1f9351b05fad4beaad4eae34699b7a1/scipy-1.15.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:06efcba926324df1696931a57a176c80848ccd67ce6ad020c810736bfd58eb1c", size = 25242454, upload-time = "2025-05-08T16:06:20.394Z" }, + { url = "https://files.pythonhosted.org/packages/56/30/a6f08f84ee5b7b28b4c597aca4cbe545535c39fe911845a96414700b64ba/scipy-1.15.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05045d8b9bfd807ee1b9f38761993297b10b245f012b11b13b91ba8945f7e45", size = 35210199, upload-time = "2025-05-08T16:06:26.159Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1f/03f52c282437a168ee2c7c14a1a0d0781a9a4a8962d84ac05c06b4c5b555/scipy-1.15.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:271e3713e645149ea5ea3e97b57fdab61ce61333f97cfae392c28ba786f9bb49", size = 37309455, upload-time = "2025-05-08T16:06:32.778Z" }, + { url = "https://files.pythonhosted.org/packages/89/b1/fbb53137f42c4bf630b1ffdfc2151a62d1d1b903b249f030d2b1c0280af8/scipy-1.15.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6cfd56fc1a8e53f6e89ba3a7a7251f7396412d655bca2aa5611c8ec9a6784a1e", size = 36885140, upload-time = "2025-05-08T16:06:39.249Z" }, + { url = "https://files.pythonhosted.org/packages/2e/2e/025e39e339f5090df1ff266d021892694dbb7e63568edcfe43f892fa381d/scipy-1.15.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ff17c0bb1cb32952c09217d8d1eed9b53d1463e5f1dd6052c7857f83127d539", size = 39710549, upload-time = "2025-05-08T16:06:45.729Z" }, + { url = "https://files.pythonhosted.org/packages/e6/eb/3bf6ea8ab7f1503dca3a10df2e4b9c3f6b3316df07f6c0ded94b281c7101/scipy-1.15.3-cp312-cp312-win_amd64.whl", hash = "sha256:52092bc0472cfd17df49ff17e70624345efece4e1a12b23783a1ac59a1b728ed", size = 40966184, upload-time = "2025-05-08T16:06:52.623Z" }, + { url = "https://files.pythonhosted.org/packages/73/18/ec27848c9baae6e0d6573eda6e01a602e5649ee72c27c3a8aad673ebecfd/scipy-1.15.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2c620736bcc334782e24d173c0fdbb7590a0a436d2fdf39310a8902505008759", size = 38728256, upload-time = "2025-05-08T16:06:58.696Z" }, + { url = "https://files.pythonhosted.org/packages/74/cd/1aef2184948728b4b6e21267d53b3339762c285a46a274ebb7863c9e4742/scipy-1.15.3-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:7e11270a000969409d37ed399585ee530b9ef6aa99d50c019de4cb01e8e54e62", size = 30109540, upload-time = "2025-05-08T16:07:04.209Z" }, + { url = "https://files.pythonhosted.org/packages/5b/d8/59e452c0a255ec352bd0a833537a3bc1bfb679944c4938ab375b0a6b3a3e/scipy-1.15.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8c9ed3ba2c8a2ce098163a9bdb26f891746d02136995df25227a20e71c396ebb", size = 22383115, upload-time = "2025-05-08T16:07:08.998Z" }, + { url = "https://files.pythonhosted.org/packages/08/f5/456f56bbbfccf696263b47095291040655e3cbaf05d063bdc7c7517f32ac/scipy-1.15.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:0bdd905264c0c9cfa74a4772cdb2070171790381a5c4d312c973382fc6eaf730", size = 25163884, upload-time = "2025-05-08T16:07:14.091Z" }, + { url = "https://files.pythonhosted.org/packages/a2/66/a9618b6a435a0f0c0b8a6d0a2efb32d4ec5a85f023c2b79d39512040355b/scipy-1.15.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79167bba085c31f38603e11a267d862957cbb3ce018d8b38f79ac043bc92d825", size = 35174018, upload-time = "2025-05-08T16:07:19.427Z" }, + { url = "https://files.pythonhosted.org/packages/b5/09/c5b6734a50ad4882432b6bb7c02baf757f5b2f256041da5df242e2d7e6b6/scipy-1.15.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9deabd6d547aee2c9a81dee6cc96c6d7e9a9b1953f74850c179f91fdc729cb7", size = 37269716, upload-time = "2025-05-08T16:07:25.712Z" }, + { url = "https://files.pythonhosted.org/packages/77/0a/eac00ff741f23bcabd352731ed9b8995a0a60ef57f5fd788d611d43d69a1/scipy-1.15.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dde4fc32993071ac0c7dd2d82569e544f0bdaff66269cb475e0f369adad13f11", size = 36872342, upload-time = "2025-05-08T16:07:31.468Z" }, + { url = "https://files.pythonhosted.org/packages/fe/54/4379be86dd74b6ad81551689107360d9a3e18f24d20767a2d5b9253a3f0a/scipy-1.15.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f77f853d584e72e874d87357ad70f44b437331507d1c311457bed8ed2b956126", size = 39670869, upload-time = "2025-05-08T16:07:38.002Z" }, + { url = "https://files.pythonhosted.org/packages/87/2e/892ad2862ba54f084ffe8cc4a22667eaf9c2bcec6d2bff1d15713c6c0703/scipy-1.15.3-cp313-cp313-win_amd64.whl", hash = "sha256:b90ab29d0c37ec9bf55424c064312930ca5f4bde15ee8619ee44e69319aab163", size = 40988851, upload-time = "2025-05-08T16:08:33.671Z" }, + { url = "https://files.pythonhosted.org/packages/1b/e9/7a879c137f7e55b30d75d90ce3eb468197646bc7b443ac036ae3fe109055/scipy-1.15.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3ac07623267feb3ae308487c260ac684b32ea35fd81e12845039952f558047b8", size = 38863011, upload-time = "2025-05-08T16:07:44.039Z" }, + { url = "https://files.pythonhosted.org/packages/51/d1/226a806bbd69f62ce5ef5f3ffadc35286e9fbc802f606a07eb83bf2359de/scipy-1.15.3-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:6487aa99c2a3d509a5227d9a5e889ff05830a06b2ce08ec30df6d79db5fcd5c5", size = 30266407, upload-time = "2025-05-08T16:07:49.891Z" }, + { url = "https://files.pythonhosted.org/packages/e5/9b/f32d1d6093ab9eeabbd839b0f7619c62e46cc4b7b6dbf05b6e615bbd4400/scipy-1.15.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:50f9e62461c95d933d5c5ef4a1f2ebf9a2b4e83b0db374cb3f1de104d935922e", size = 22540030, upload-time = "2025-05-08T16:07:54.121Z" }, + { url = "https://files.pythonhosted.org/packages/e7/29/c278f699b095c1a884f29fda126340fcc201461ee8bfea5c8bdb1c7c958b/scipy-1.15.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:14ed70039d182f411ffc74789a16df3835e05dc469b898233a245cdfd7f162cb", size = 25218709, upload-time = "2025-05-08T16:07:58.506Z" }, + { url = "https://files.pythonhosted.org/packages/24/18/9e5374b617aba742a990581373cd6b68a2945d65cc588482749ef2e64467/scipy-1.15.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a769105537aa07a69468a0eefcd121be52006db61cdd8cac8a0e68980bbb723", size = 34809045, upload-time = "2025-05-08T16:08:03.929Z" }, + { url = "https://files.pythonhosted.org/packages/e1/fe/9c4361e7ba2927074360856db6135ef4904d505e9b3afbbcb073c4008328/scipy-1.15.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9db984639887e3dffb3928d118145ffe40eff2fa40cb241a306ec57c219ebbbb", size = 36703062, upload-time = "2025-05-08T16:08:09.558Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8e/038ccfe29d272b30086b25a4960f757f97122cb2ec42e62b460d02fe98e9/scipy-1.15.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:40e54d5c7e7ebf1aa596c374c49fa3135f04648a0caabcb66c52884b943f02b4", size = 36393132, upload-time = "2025-05-08T16:08:15.34Z" }, + { url = "https://files.pythonhosted.org/packages/10/7e/5c12285452970be5bdbe8352c619250b97ebf7917d7a9a9e96b8a8140f17/scipy-1.15.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5e721fed53187e71d0ccf382b6bf977644c533e506c4d33c3fb24de89f5c3ed5", size = 38979503, upload-time = "2025-05-08T16:08:21.513Z" }, + { url = "https://files.pythonhosted.org/packages/81/06/0a5e5349474e1cbc5757975b21bd4fad0e72ebf138c5592f191646154e06/scipy-1.15.3-cp313-cp313t-win_amd64.whl", hash = "sha256:76ad1fb5f8752eabf0fa02e4cc0336b4e8f021e2d5f061ed37d6d264db35e3ca", size = 40308097, upload-time = "2025-05-08T16:08:27.627Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", +] +dependencies = [ + { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" }, + { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" }, + { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" }, + { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" }, + { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" }, + { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" }, + { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, + { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, + { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, + { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, + { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, + { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, + { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, + { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, + { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, + { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, + { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, +] + +[[package]] +name = "setuptools" +version = "82.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "stack-data" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pure-eval" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, +] + +[[package]] +name = "tomli" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload-time = "2024-11-27T22:38:36.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077, upload-time = "2024-11-27T22:37:54.956Z" }, + { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429, upload-time = "2024-11-27T22:37:56.698Z" }, + { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067, upload-time = "2024-11-27T22:37:57.63Z" }, + { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030, upload-time = "2024-11-27T22:37:59.344Z" }, + { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898, upload-time = "2024-11-27T22:38:00.429Z" }, + { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894, upload-time = "2024-11-27T22:38:02.094Z" }, + { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319, upload-time = "2024-11-27T22:38:03.206Z" }, + { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273, upload-time = "2024-11-27T22:38:04.217Z" }, + { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310, upload-time = "2024-11-27T22:38:05.908Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309, upload-time = "2024-11-27T22:38:06.812Z" }, + { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762, upload-time = "2024-11-27T22:38:07.731Z" }, + { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453, upload-time = "2024-11-27T22:38:09.384Z" }, + { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486, upload-time = "2024-11-27T22:38:10.329Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349, upload-time = "2024-11-27T22:38:11.443Z" }, + { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159, upload-time = "2024-11-27T22:38:13.099Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243, upload-time = "2024-11-27T22:38:14.766Z" }, + { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645, upload-time = "2024-11-27T22:38:15.843Z" }, + { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584, upload-time = "2024-11-27T22:38:17.645Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875, upload-time = "2024-11-27T22:38:19.159Z" }, + { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418, upload-time = "2024-11-27T22:38:20.064Z" }, + { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708, upload-time = "2024-11-27T22:38:21.659Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582, upload-time = "2024-11-27T22:38:22.693Z" }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543, upload-time = "2024-11-27T22:38:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691, upload-time = "2024-11-27T22:38:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170, upload-time = "2024-11-27T22:38:27.921Z" }, + { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530, upload-time = "2024-11-27T22:38:29.591Z" }, + { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666, upload-time = "2024-11-27T22:38:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954, upload-time = "2024-11-27T22:38:31.702Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload-time = "2024-11-27T22:38:32.837Z" }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload-time = "2024-11-27T22:38:34.455Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, +] + +[[package]] +name = "toolz" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/0b/d80dfa675bf592f636d1ea0b835eab4ec8df6e9415d8cfd766df54456123/toolz-1.0.0.tar.gz", hash = "sha256:2c86e3d9a04798ac556793bced838816296a2f085017664e4995cb40a1047a02", size = 66790, upload-time = "2024-10-04T16:17:04.001Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/98/eb27cc78ad3af8e302c9d8ff4977f5026676e130d28dd7578132a457170c/toolz-1.0.0-py3-none-any.whl", hash = "sha256:292c8f1c4e7516bf9086f8850935c799a874039c8bcf959d47b600e4c44a6236", size = 56383, upload-time = "2024-10-04T16:17:01.533Z" }, +] + +[[package]] +name = "tornado" +version = "6.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/89/c72771c81d25d53fe33e3dca61c233b665b2780f21820ba6fd2c6793c12b/tornado-6.5.1.tar.gz", hash = "sha256:84ceece391e8eb9b2b95578db65e920d2a61070260594819589609ba9bc6308c", size = 509934, upload-time = "2025-05-22T18:15:38.788Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/89/f4532dee6843c9e0ebc4e28d4be04c67f54f60813e4bf73d595fe7567452/tornado-6.5.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d50065ba7fd11d3bd41bcad0825227cc9a95154bad83239357094c36708001f7", size = 441948, upload-time = "2025-05-22T18:15:20.862Z" }, + { url = "https://files.pythonhosted.org/packages/15/9a/557406b62cffa395d18772e0cdcf03bed2fff03b374677348eef9f6a3792/tornado-6.5.1-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9e9ca370f717997cb85606d074b0e5b247282cf5e2e1611568b8821afe0342d6", size = 440112, upload-time = "2025-05-22T18:15:22.591Z" }, + { url = "https://files.pythonhosted.org/packages/55/82/7721b7319013a3cf881f4dffa4f60ceff07b31b394e459984e7a36dc99ec/tornado-6.5.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b77e9dfa7ed69754a54c89d82ef746398be82f749df69c4d3abe75c4d1ff4888", size = 443672, upload-time = "2025-05-22T18:15:24.027Z" }, + { url = "https://files.pythonhosted.org/packages/7d/42/d11c4376e7d101171b94e03cef0cbce43e823ed6567ceda571f54cf6e3ce/tornado-6.5.1-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:253b76040ee3bab8bcf7ba9feb136436a3787208717a1fb9f2c16b744fba7331", size = 443019, upload-time = "2025-05-22T18:15:25.735Z" }, + { url = "https://files.pythonhosted.org/packages/7d/f7/0c48ba992d875521ac761e6e04b0a1750f8150ae42ea26df1852d6a98942/tornado-6.5.1-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:308473f4cc5a76227157cdf904de33ac268af770b2c5f05ca6c1161d82fdd95e", size = 443252, upload-time = "2025-05-22T18:15:27.499Z" }, + { url = "https://files.pythonhosted.org/packages/89/46/d8d7413d11987e316df4ad42e16023cd62666a3c0dfa1518ffa30b8df06c/tornado-6.5.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:caec6314ce8a81cf69bd89909f4b633b9f523834dc1a352021775d45e51d9401", size = 443930, upload-time = "2025-05-22T18:15:29.299Z" }, + { url = "https://files.pythonhosted.org/packages/78/b2/f8049221c96a06df89bed68260e8ca94beca5ea532ffc63b1175ad31f9cc/tornado-6.5.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:13ce6e3396c24e2808774741331638ee6c2f50b114b97a55c5b442df65fd9692", size = 443351, upload-time = "2025-05-22T18:15:31.038Z" }, + { url = "https://files.pythonhosted.org/packages/76/ff/6a0079e65b326cc222a54720a748e04a4db246870c4da54ece4577bfa702/tornado-6.5.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5cae6145f4cdf5ab24744526cc0f55a17d76f02c98f4cff9daa08ae9a217448a", size = 443328, upload-time = "2025-05-22T18:15:32.426Z" }, + { url = "https://files.pythonhosted.org/packages/49/18/e3f902a1d21f14035b5bc6246a8c0f51e0eef562ace3a2cea403c1fb7021/tornado-6.5.1-cp39-abi3-win32.whl", hash = "sha256:e0a36e1bc684dca10b1aa75a31df8bdfed656831489bc1e6a6ebed05dc1ec365", size = 444396, upload-time = "2025-05-22T18:15:34.205Z" }, + { url = "https://files.pythonhosted.org/packages/7b/09/6526e32bf1049ee7de3bebba81572673b19a2a8541f795d887e92af1a8bc/tornado-6.5.1-cp39-abi3-win_amd64.whl", hash = "sha256:908e7d64567cecd4c2b458075589a775063453aeb1d2a1853eedb806922f568b", size = 444840, upload-time = "2025-05-22T18:15:36.1Z" }, + { url = "https://files.pythonhosted.org/packages/55/a7/535c44c7bea4578e48281d83c615219f3ab19e6abc67625ef637c73987be/tornado-6.5.1-cp39-abi3-win_arm64.whl", hash = "sha256:02420a0eb7bf617257b9935e2b754d1b63897525d8a289c9d65690d580b4dcf7", size = 443596, upload-time = "2025-05-22T18:15:37.433Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, +] + +[[package]] +name = "traitlets" +version = "5.14.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", size = 161621, upload-time = "2024-04-19T11:11:49.746Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359, upload-time = "2024-04-19T11:11:46.763Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/bc/51647cd02527e87d05cb083ccc402f93e441606ff1f01739a62c8ad09ba5/typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4", size = 107423, upload-time = "2025-06-02T14:52:11.399Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/e0/552843e0d356fbb5256d21449fa957fa4eff3bbc135a74a691ee70c7c5da/typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af", size = 43839, upload-time = "2025-06-02T14:52:10.026Z" }, +] + +[[package]] +name = "urllib3" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/78/16493d9c386d8e60e442a35feac5e00f0913c0f4b7c217c11e8ec2ff53e0/urllib3-2.4.0.tar.gz", hash = "sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466", size = 390672, upload-time = "2025-04-10T15:23:39.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/11/cc635220681e93a0183390e26485430ca2c7b5f9d33b15c74c2861cb8091/urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813", size = 128680, upload-time = "2025-04-10T15:23:37.377Z" }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload-time = "2024-11-01T14:06:24.793Z" }, + { url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload-time = "2024-11-01T14:06:27.112Z" }, + { url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload-time = "2024-11-01T14:06:29.876Z" }, + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.2.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/63/53559446a878410fc5a5974feb13d31d78d752eb18aeba59c7fef1af7598/wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5", size = 101301, upload-time = "2024-01-06T02:10:57.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166, upload-time = "2024-01-06T02:10:55.763Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +]