diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..45c383a
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,15 @@
+# To get started with Dependabot version updates, you'll need to specify which
+# package ecosystems to update and where the package manifests are located.
+# Please see the documentation for all configuration options:
+# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
+
+version: 2
+updates:
+ - package-ecosystem: "pip" # See documentation for possible values
+ directory: "/" # Location of package manifests
+ schedule:
+ interval: "monthly"
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "monthly"
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
new file mode 100644
index 0000000..50b2ad9
--- /dev/null
+++ b/.github/workflows/publish.yml
@@ -0,0 +1,47 @@
+name: Build and Publish Package
+
+on:
+ pull_request:
+ branches:
+ - main
+ types:
+ - closed
+
+jobs:
+ publish-package:
+ if: ${{ github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'release/v') }}
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Check out repo main branch
+ uses: actions/checkout@v4
+ with:
+ ref: main
+ - name: Set up Python 3.10
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.10"
+ - name: Install Poetry
+ uses: snok/install-poetry@v1
+ with:
+ version: 1.6.1
+ - name: Configure poetry
+ run: poetry config --no-interaction pypi-token.pypi ${{ secrets.FASTDATATABLE_PYPI_TOKEN }}
+ - name: Get project Version
+ id: project_version
+ run: echo "project_version=$(poetry version --short)" >> $GITHUB_OUTPUT
+ - name: Build package
+ run: poetry build --no-interaction
+ - name: Publish package to PyPI
+ run: poetry publish --no-interaction
+ - name: Create a Github Release
+ uses: softprops/action-gh-release@v2
+ with:
+ tag_name: v${{ steps.project_version.outputs.project_version }}
+ target_commitish: main
+ token: ${{ secrets.FASTDATATABLE_RELEASE_TOKEN }}
+ body_path: CHANGELOG.md
+ files: |
+ LICENSE
+ dist/*textual_fastdatatable*.whl
+ dist/*textual_fastdatatable*.tar.gz
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..c41a60d
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,58 @@
+name: Create Release Branch
+
+on:
+ workflow_dispatch:
+ inputs:
+ newVersion:
+ description: A version number for this release (e.g., "0.1.0")
+ required: true
+
+jobs:
+ prepare-release:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ pull-requests: write
+
+ steps:
+ - name: Check out repo main branch
+ uses: actions/checkout@v4
+ with:
+ ref: main
+ - name: Set up Python 3.10
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.10"
+ - name: Install Poetry
+ uses: snok/install-poetry@v1
+ with:
+ version: 1.6.1
+ - name: Create release branch
+ run: |
+ git checkout -b release/v${{ github.event.inputs.newVersion }}
+ git push --set-upstream origin release/v${{ github.event.inputs.newVersion }}
+ - name: Bump version
+ run: poetry version ${{ github.event.inputs.newVersion }} --no-interaction
+ - name: Ensure package can be built
+ run: poetry build --no-interaction
+ - name: Update CHANGELOG
+ uses: thomaseizinger/keep-a-changelog-new-release@v3
+ with:
+ version: ${{ github.event.inputs.newVersion }}
+ - name: Commit Changes
+ uses: stefanzweifel/git-auto-commit-action@v5
+ with:
+ commit_message: Bumps version to ${{ github.event.inputs.newVersion }}
+ - name: Create pull request into main
+ uses: thomaseizinger/create-pull-request@1.3.1
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ with:
+ head: release/v${{ github.event.inputs.newVersion }}
+ base: main
+ title: v${{ github.event.inputs.newVersion }}
+ body: >
+ This PR was automatically generated. It bumps the version number
+ in pyproject.toml and updates CHANGELOG.md. You may have to close
+ this PR and reopen it to get the required checks to run.
+
diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml
new file mode 100644
index 0000000..00e49c8
--- /dev/null
+++ b/.github/workflows/static.yml
@@ -0,0 +1,53 @@
+name: "Perform Static Analysis"
+
+on:
+ pull_request:
+
+# will cancel previous workflows triggered by the same event and for the same ref for PRs or same SHA otherwise
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event_name }}-${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.ref || github.sha }}
+ cancel-in-progress: true
+
+jobs:
+ static:
+ name: Static Analysis - 3.11
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check out Repo
+ uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+ - name: Set up Python 3.11
+ uses: actions/setup-python@v5
+ id: setup-python
+ with:
+ python-version: "3.11"
+ - name: Load cached Poetry installation
+ id: cached-poetry-install
+ uses: actions/cache@v4
+ with:
+ path: ~/.local
+ key: poetry-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-0
+ - name: Install Poetry
+ if: steps.cached-poetry-install.outputs.cache-hit != 'true'
+ uses: snok/install-poetry@v1
+ with:
+ version: 1.4.2
+ virtualenvs-create: true
+ virtualenvs-in-project: true
+ installer-parallel: true
+ - name: Load cached venv
+ id: cached-poetry-dependencies
+ uses: actions/cache@v4
+ with:
+ path: .venv
+ key: static-venv-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/poetry.lock') }}
+ - name: Install python dependencies
+ if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
+ run: poetry install --sync --no-interaction --without dev
+ - name: Run analysis
+ run: |
+ source .venv/bin/activate
+ ruff format --check
+ ruff check .
+ mypy
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..14e1421
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,88 @@
+name: Test
+
+on:
+ push:
+ branches: [ main ]
+ pull_request:
+
+# will cancel previous workflows triggered by the same event and for the same ref for PRs or same SHA otherwise
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event_name }}-${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.ref || github.sha }}
+ cancel-in-progress: true
+
+defaults:
+ run:
+ shell: bash
+
+jobs:
+ test-windows:
+ name: Windows - 3.10
+ runs-on: Windows-latest
+ steps:
+ - name: Check out Repo
+ uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+ - name: Set up Python 3.10
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.10"
+ - name: Install Poetry
+ uses: snok/install-poetry@v1
+ with:
+ version: 1.4.2
+ - name: Install python dependencies
+ run: poetry install --sync --no-interaction --only main,test
+ - name: Run tests
+ run: poetry run pytest
+
+ test:
+ name: ${{ matrix.os }} - ${{ matrix.py }}
+ runs-on: ${{ matrix.os }}-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ os:
+ - ubuntu
+ - MacOs
+ py:
+ - "3.11"
+ - "3.10"
+ - "3.9"
+ steps:
+ - name: Check out Repo
+ uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+ - name: Set up Python ${{ matrix.py }}
+ id: setup-python
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.py }}
+ - name: Load cached Poetry installation
+ id: cached-poetry-install
+ uses: actions/cache@v4
+ with:
+ path: ~/.local
+ key: poetry-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/poetry.lock') }}
+ - name: Install Poetry
+ if: steps.cached-poetry-install.outputs.cache-hit != 'true'
+ uses: snok/install-poetry@v1
+ with:
+ version: 1.4.2
+ virtualenvs-create: true
+ virtualenvs-in-project: true
+ installer-parallel: true
+ - name: Load cached venv
+ id: cached-poetry-dependencies
+ uses: actions/cache@v4
+ with:
+ path: .venv
+ key: venv-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/poetry.lock') }}
+ - name: Install python dependencies
+ if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
+ run: poetry install --sync --no-interaction --only main,test
+ - name: Run tests
+ run: |
+ source .venv/bin/activate
+ pytest
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..8b72cfb
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,167 @@
+.vscode
+snapshot_report.html
+profile*.html
+results.md
+Pipfile
+.python-version
+
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+# Usually these files are written by a python script from a template
+# before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py,cover
+.hypothesis/
+.pytest_cache/
+cover/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+.pybuilder/
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+# For a library or package, you might want to ignore these files since the code is
+# intended to run in multiple environments; otherwise, check them in:
+# .python-version
+
+# pipenv
+# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+# However, in case of collaboration, if having platform-specific dependencies or dependencies
+# having no cross-platform support, pipenv may install dependencies that don't work, or not
+# install all needed dependencies.
+#Pipfile.lock
+
+# poetry
+# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
+# This is especially recommended for binary packages to ensure reproducibility, and is more
+# commonly ignored for libraries.
+# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
+#poetry.lock
+
+# pdm
+# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
+#pdm.lock
+# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
+# in version control.
+# https://pdm.fming.dev/#use-with-ide
+.pdm.toml
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule
+celerybeat.pid
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# Cython debug symbols
+cython_debug/
+
+# PyCharm
+# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
+# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
+# and can be added to the global gitignore or merged into this file. For a more nuclear
+# option (not recommended) you can uncomment the following to ignore the entire idea folder.
+#.idea/
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..e513be4
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,28 @@
+repos:
+ - repo: https://github.com/charliermarsh/ruff-pre-commit
+ rev: v0.5.1
+ hooks:
+ - id: ruff-format
+ - id: ruff
+ args: [ --fix, --exit-non-zero-on-fix ]
+ - repo: https://github.com/pre-commit/mirrors-mypy
+ rev: v1.11.0
+ hooks:
+ - id: mypy
+ additional_dependencies:
+ - textual>=0.72.0
+ - pytest
+ - pyarrow-stubs
+ - pandas-stubs
+ - polars
+ exclude: "tests/snapshot_tests/"
+ args:
+ - "--disallow-untyped-calls"
+ - "--disallow-untyped-defs"
+ - "--disallow-incomplete-defs"
+ - "--strict-optional"
+ - "--warn-return-any"
+ - "--warn-no-return"
+ - "--warn-redundant-casts"
+ - "--no-warn-unused-ignores"
+ - "--allow-untyped-decorators"
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..e69de29
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..02024ec
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,181 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+## [Unreleased]
+
+## [0.12.0] - 2025-02-06
+
+- Catch overflow errors when casting arrow temporal types to python date and datetimes, and substitue date.max/min and datetime.max/min, instead of None.
+- Format date.max/min and datetime.max/min with an infinity symbol (`∞`) when rendering cells with those values.
+
+## [0.11.0] - 2024-12-19
+
+- Drops support for Python 3.8
+- Adds support for Python 3.13
+
+## [0.10.0] - 2024-10-31
+
+- Adds an optional parameter to DataTable to disable rendering of string data as Rich Markup.
+- Fixes a bug where None could be casted to a string and displayed as "None" ([tconbeer/harlequin#658](https://github.com/tconbeer/harlequin/issues/658))
+
+## [0.9.0] - 2024-07-23
+
+- Adds a PolarsBackend implementation of DataTableBackend. You must have `polars` installed to use the PolarsBackend. You can install it using the `polars` extra for this package.
+- Fixes a crash from the ArrowBackend when attempting to instantiate negative datetimes after a timezone conversion.
+
+## [0.8.0] - 2024-07-10
+
+- Fixes a crash when cell contents contained bad Rich Markdown ([tconbeer/harlequin#569](https://github.com/tconbeer/harlequin/issues/569)).
+- Improves the appearance of data tooltips.
+
+## [0.7.1] - 2024-02-09
+
+- Adds a `backend.source_data` property to exposue the underlying Arrow table, before slicing.
+
+## [0.7.0] - 2024-02-07
+
+### Breaking Changes
+
+- Removes the NumpyBackend ([#78](https://github.com/tconbeer/textual-fastdatatable/issues/78)).
+
+### Features
+
+- Values are now formatted based on their type. Numbers have separators based on the locale, and numbers, dates/times/etc., and bools are right-aligned ([#70](https://github.com/tconbeer/textual-fastdatatable/issues/70)).
+
+### Bug Fixes
+
+- Fixes bug that caused either a crash or an empty table from initializing a table `from_records` or `from_pydict` with mixed (widening or narrowing) types in one column.
+
+## [0.6.3] - 2024-01-09
+
+### Bug Fixes
+
+- Widens acceptable types for create_backend to accept a sequence of any iterable, not just iterables that are instances of typing.Iterable.
+
+## [0.6.2] - 2024-01-08
+
+### Bug Fixes
+
+- Adds the tzdata package as a dependency for Windows installs, since Windows does not ship with a built-in tzdata database.
+
+## [0.6.1] - 2024-01-05
+
+### Bug Fixes
+
+- Fixes the behavior of tab and shift+tab to cycle to the next/prev row if at the end/start of a row or table.
+- Fixes a crash from pressing ctrl+c when the cursor type is column.
+
+## [0.6.0] - 2024-01-05
+
+### Features
+
+- Adds keybindings for navigating the cursor in the data table. ctrl+right/left/up/down/home/end (with shift variants), tab, shift+tab, ctrl+a now all do roughly what they do in Excel (if the cursor type is `range`).
+
+## [0.5.1] - 2024-01-05
+
+### Bug Fixes
+
+- Adds a dependency on pytz for Python <3.9 for timezone support.
+- Fixes a bug where Arrow crashes while casting timestamptz to string ([tconbeer/harlequin#382](https://github.com/tconbeer/harlequin/issues/382)).
+
+### Performance
+
+- Vectorizes fallback string casting for datatypes unsupported by `pc.cast` ([#8](https://github.com/tconbeer/textual-fastdatatable/issues/8))
+
+## [0.5.0] - 2023-12-21
+
+### Features
+
+- Adds a `range` cursor type that will highlight a range of selected cells, like Excel.
+- ctrl+c now posts a `SelectionCopied` message, with a values attribute that conttains a list of tuples of values from the data table.
+- Adds a `max_column_content_width` parameter to DataTable. If set, DataTable will truncate values longer than the width, but show the full value in a tooltip on hover.
+
+## [0.4.1] - 2023-12-14
+
+- Fixes a crash caused by calling `create_backend` with an empty sequence.
+
+## [0.4.0] - 2023-11-14
+
+### Breaking API Changes
+
+- When calling `create_backend` with a sequence of iterables, the default behavior now assumes the data does not contain headers. You can restore the old behavior with `create_backend(has_headers=True)`.
+- When calling `DataTable(data=...)` with a sequence of iterables, the first row is treated as a header only if `column_labels` is not provided.
+
+## [0.3.0] - 2023-11-11
+
+### Features
+
+- The DataTable now accepts a `max_rows` kwarg; if provided, backends will only store the first `max_rows` and the DataTable will only present `max_rows`. The original row count of the data source is available as DataTable().source_row_count ([tconbeer/harlequin#281](https://github.com/tconbeer/harlequin/issues/281)).
+
+### API Changes
+
+- Backends must now accept a `max_rows` kwarg on initialization.
+
+## [0.2.1] - 2023-11-10
+
+### Bug Fixes
+
+- Tables with the ArrowBackend no longer display incorrect output when column labels are duplicated ([#26](https://github.com/tconbeer/textual-fastdatatable/issues/26)).
+
+## [0.2.0] - 2023-11-08
+
+### Features
+
+- Adds a `null_rep: str` argument when initializing the data table; this string will be used to replace missing data.
+- Adds a `NumpyBackend` that uses Numpy Record Arrays; this backend is marginally slower than the `ArrowBackend` in most scenarios ([#23](https://github.com/tconbeer/textual-fastdatatable/issues/23)).
+
+### Bug Fixes
+
+- Fixes a crash when using `ArrowBackend.from_records(has_header=False)`.
+
+### Performance
+
+- Drastically improves performance for tables that are much wider than the viewport ([#12](https://github.com/tconbeer/textual-fastdatatable/issues/12)).
+
+### Benchmarks
+
+- Improves benchmarks to exclude data load times, disable garbage collection, and include more information about first paint and scroll performance.
+
+## [0.1.4] - 2023-11-06
+
+- Fixes a crash when computing the widths of columns with no rows ([#19](https://github.com/tconbeer/textual-fastdatatable/issues/19)).
+
+## [0.1.3] - 2023-10-09
+
+- Fixes a crash when creating a column from a null or complex type.
+
+## [0.1.2] - 2023-10-02
+
+## [0.1.1] - 2023-09-29
+
+- Fixes a crash when rows were added to an empty table.
+
+## [0.1.0] - 2023-09-29
+
+- Initial release. Adds DataTable and ArrowBackend, which is 1000x faster for datasets of 500k records or more.
+
+[unreleased]: https://github.com/tconbeer/textual-fastdatatable/compare/0.12.0...HEAD
+[0.12.0]: https://github.com/tconbeer/textual-fastdatatable/compare/0.11.0...0.12.0
+[0.11.0]: https://github.com/tconbeer/textual-fastdatatable/compare/0.10.0...0.11.0
+[0.10.0]: https://github.com/tconbeer/textual-fastdatatable/compare/0.9.0...0.10.0
+[0.9.0]: https://github.com/tconbeer/textual-fastdatatable/compare/0.8.0...0.9.0
+[0.8.0]: https://github.com/tconbeer/textual-fastdatatable/compare/0.7.1...0.8.0
+[0.7.1]: https://github.com/tconbeer/textual-fastdatatable/compare/0.7.0...0.7.1
+[0.7.0]: https://github.com/tconbeer/textual-fastdatatable/compare/0.6.3...0.7.0
+[0.6.3]: https://github.com/tconbeer/textual-fastdatatable/compare/0.6.2...0.6.3
+[0.6.2]: https://github.com/tconbeer/textual-fastdatatable/compare/0.6.1...0.6.2
+[0.6.1]: https://github.com/tconbeer/textual-fastdatatable/compare/0.6.0...0.6.1
+[0.6.0]: https://github.com/tconbeer/textual-fastdatatable/compare/0.5.1...0.6.0
+[0.5.1]: https://github.com/tconbeer/textual-fastdatatable/compare/0.5.0...0.5.1
+[0.5.0]: https://github.com/tconbeer/textual-fastdatatable/compare/0.4.1...0.5.0
+[0.4.1]: https://github.com/tconbeer/textual-fastdatatable/compare/0.4.0...0.4.1
+[0.4.0]: https://github.com/tconbeer/textual-fastdatatable/compare/0.3.0...0.4.0
+[0.3.0]: https://github.com/tconbeer/textual-fastdatatable/compare/0.2.1...0.3.0
+[0.2.1]: https://github.com/tconbeer/textual-fastdatatable/compare/0.2.0...0.2.1
+[0.2.0]: https://github.com/tconbeer/textual-fastdatatable/compare/0.1.4...0.2.0
+[0.1.4]: https://github.com/tconbeer/textual-fastdatatable/compare/0.1.3...0.1.4
+[0.1.3]: https://github.com/tconbeer/textual-fastdatatable/compare/0.1.2...0.1.3
+[0.1.2]: https://github.com/tconbeer/textual-fastdatatable/compare/0.1.1...0.1.2
+[0.1.1]: https://github.com/tconbeer/textual-fastdatatable/compare/0.1.0...0.1.1
+[0.1.0]: https://github.com/tconbeer/textual-fastdatatable/compare/4b9f99175d34f693dd0d4198c39d72f89caf6479...0.1.0
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..04ec0c8
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023 Ted Conbeer
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..b8be4eb
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,24 @@
+.PHONY: check
+check:
+ ruff format .
+ pytest
+ ruff check . --fix
+ mypy
+
+.PHONY: lint
+lint:
+ ruff format .
+ ruff check . --fix
+ mypy
+
+.PHONY: serve
+serve:
+ textual run --dev -c python -m textual_fastdatatable
+
+.PHONY: profile
+profile:
+ pyinstrument -r html -o profile.html "src/scripts/run_arrow_wide.py"
+
+.PHONY: benchmark
+benchmark:
+ python src/scripts/benchmark.py > /dev/null
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..925f84f
--- /dev/null
+++ b/README.md
@@ -0,0 +1,131 @@
+# textual-fastdatatable
+A performance-focused reimplementation of Textual's DataTable widget, with a pluggable data storage backend.
+
+Textual's built-in DataTable widget is beautiful and powerful, but it can be slow to load large datasets.
+
+Here are some benchmarks on my relatively weak laptop. For each benchmark, we initialize a Textual App that
+loads a dataset from a parquet file and mounts a data table; it then scrolls around the table
+(10 pagedowns and 15 right arrows).
+
+For the built-in table and the others marked "from Records", the data is loaded into memory before the timer
+is started; for the "Arrow from Parquet" back-end, the timer is started immediately.
+
+The times in each column represent the time to the first paint of the table, and the time after scrolling
+is completed (we wait until the table is fully rendered after each scroll):
+
+Records | Built-In DataTable | FastDataTable (Arrow from Parquet) | FastDataTable (Arrow from Records) | FastDataTable (Numpy from Records)
+--------|--------|--------|--------|--------
+lap_times_100.parquet | 0.019s / 1.716s | 0.012s / 1.724s | 0.011s / 1.700s | 0.011s / 1.688s
+lap_times_1000.parquet | 0.103s / 1.931s | 0.011s / 1.859s | 0.011s / 1.799s | 0.015s / 1.848s
+lap_times_10000.parquet | 0.977s / 2.824s | 0.013s / 1.834s | 0.016s / 1.812s | 0.078s / 1.869s
+lap_times_100000.parquet | 11.773s / 13.770s | 0.025s / 1.790s | 0.156s / 1.824s | 0.567s / 2.347s
+lap_times_538121.parquet | 62.960s / 65.760s | 0.077s / 1.803s | 0.379s / 2.234s | 3.324s / 5.031s
+wide_10000.parquet | 5.110s / 10.539s | 0.024s / 3.373s | 0.042s / 3.278s | 0.369s / 3.461s
+wide_100000.parquet | 51.144s / 56.604s | 0.054s / 3.294s | 0.429s / 3.642s | 3.628s / 6.732s
+
+
+**NB:** FastDataTable currently does not support rows with a height of more than one line. See below for
+more limitations, relative to the built-in DataTable.
+
+## Installation
+
+```bash
+pip install textual-fastdatatable
+```
+
+## Usage
+
+If you already have data in Apache Arrow or another common table format:
+
+```py
+from textual_fastdatatable import DataTable
+data_table = DataTable(data = my_data)
+```
+
+The currently supported types are:
+
+```py
+AutoBackendType = Union[
+ pa.Table,
+ pa.RecordBatch,
+ Path, # to parquet only
+ str, # path to parquet only
+ Sequence[Iterable[Any]],
+ Mapping[str, Sequence[Any]],
+]
+```
+
+To override the column labels and widths supplied by the backend:
+```py
+from textual_fastdatatable import DataTable
+data_table = DataTable(data = my_data, column_labels=["Supports", "[red]Console[/]", "Markup!"], column_widths=[10, 5, None])
+```
+
+You can also pass in a `backend` manually (if you want more control or want to plug in your own).
+
+```py
+from textual_fastdatatable import ArrowBackend, DataTable, create_backend
+backend = create_backend(my_data)
+backend = ArrowBackend(my_arrow_table)
+# from python dictionary in the form key: col_values
+backend = ArrowBackend.from_pydict(
+ {
+ "col one": [1, 2, 3 ,4],
+ "col two": ["a", "b", "c", "d"],
+ }
+)
+# from a list of tuples or another sequence of iterables
+backend = ArrowBackend.from_records(
+ [
+ ("col one", "col two"),
+ (1, "a"),
+ (2, "b"),
+ (3, "c"),
+ (4, "d"),
+ ]
+)
+# from a path to a Parquet file:
+backend = ArrowBackend.from_parquet("path/to/file.parquet")
+```
+
+## Limitations and Caveats
+
+The `DataTable` does not currently support rows with a height of more than one line. Only the first line of each row will be displayed.
+
+The `DataTable` does not currently support row labels.
+
+The `ArrowBackend` is optimized to be fast for large, immutable datasets. Mutating the data,
+especially adding or removing rows, may be slow.
+
+The `ArrowBackend` cannot be initialized without data, however, the DataTable can (either with or without `column_labels`).
+
+The `ArrowBackend` cannot store arbitrary Python objects or Rich Renderables as values. It may widen types to strings unnecessarily.
+
+## Additional Features
+
+### Copying Data from the Table
+
+`ctrl+c` will post a SelectionCopied message with a list of tuples of the values selected by the cursor. To use, initialize with `cursor_type=range` from an app that does NOT inherit bindings.
+
+```py
+from textual.app import App, ComposeResult
+
+from textual_fastdatatable import ArrowBackend, DataTable
+
+
+class TableApp(App, inherit_bindings=False):
+ BINDINGS = [("ctrl+q", "quit", "Quit")]
+
+ def compose(self) -> ComposeResult:
+ backend = ArrowBackend.from_parquet("./tests/data/lap_times_538121.parquet")
+ yield DataTable(backend=backend, cursor_type="range")
+
+
+if __name__ == "__main__":
+ app = TableApp()
+ app.run()
+```
+
+### Truncating long values
+
+The `DataTable` will automatically calculate column widths; if you set a `max_column_content_width` at initialization, it will truncate any long values at that width; the full value will be visible on hover in a tooltip (and the full value will always be copied to the clipboard).
diff --git a/poetry.lock b/poetry.lock
new file mode 100644
index 0000000..e93d8e0
--- /dev/null
+++ b/poetry.lock
@@ -0,0 +1,1769 @@
+# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand.
+
+[[package]]
+name = "aiohappyeyeballs"
+version = "2.4.4"
+description = "Happy Eyeballs for asyncio"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "aiohappyeyeballs-2.4.4-py3-none-any.whl", hash = "sha256:a980909d50efcd44795c4afeca523296716d50cd756ddca6af8c65b996e27de8"},
+ {file = "aiohappyeyeballs-2.4.4.tar.gz", hash = "sha256:5fdd7d87889c63183afc18ce9271f9b0a7d32c2303e394468dd45d514a757745"},
+]
+
+[[package]]
+name = "aiohttp"
+version = "3.11.9"
+description = "Async http client/server framework (asyncio)"
+optional = false
+python-versions = ">=3.9"
+files = [
+ {file = "aiohttp-3.11.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0411777249f25d11bd2964a230b3ffafcbed6cd65d0f2b132bc2b8f5b8c347c7"},
+ {file = "aiohttp-3.11.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:499368eb904566fbdf1a3836a1532000ef1308f34a1bcbf36e6351904cced771"},
+ {file = "aiohttp-3.11.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0b5a5009b0159a8f707879dc102b139466d8ec6db05103ec1520394fdd8ea02c"},
+ {file = "aiohttp-3.11.9-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:176f8bb8931da0613bb0ed16326d01330066bb1e172dd97e1e02b1c27383277b"},
+ {file = "aiohttp-3.11.9-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6435a66957cdba1a0b16f368bde03ce9c79c57306b39510da6ae5312a1a5b2c1"},
+ {file = "aiohttp-3.11.9-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:202f40fb686e5f93908eee0c75d1e6fbe50a43e9bd4909bf3bf4a56b560ca180"},
+ {file = "aiohttp-3.11.9-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39625703540feb50b6b7f938b3856d1f4886d2e585d88274e62b1bd273fae09b"},
+ {file = "aiohttp-3.11.9-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c6beeac698671baa558e82fa160be9761cf0eb25861943f4689ecf9000f8ebd0"},
+ {file = "aiohttp-3.11.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:96726839a42429318017e67a42cca75d4f0d5248a809b3cc2e125445edd7d50d"},
+ {file = "aiohttp-3.11.9-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3f5461c77649358610fb9694e790956b4238ac5d9e697a17f63619c096469afe"},
+ {file = "aiohttp-3.11.9-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4313f3bc901255b22f01663eeeae167468264fdae0d32c25fc631d5d6e15b502"},
+ {file = "aiohttp-3.11.9-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:d6e274661c74195708fc4380a4ef64298926c5a50bb10fbae3d01627d7a075b7"},
+ {file = "aiohttp-3.11.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:db2914de2559809fdbcf3e48f41b17a493b58cb7988d3e211f6b63126c55fe82"},
+ {file = "aiohttp-3.11.9-cp310-cp310-win32.whl", hash = "sha256:27935716f8d62c1c73010428db310fd10136002cfc6d52b0ba7bdfa752d26066"},
+ {file = "aiohttp-3.11.9-cp310-cp310-win_amd64.whl", hash = "sha256:afbe85b50ade42ddff5669947afde9e8a610e64d2c80be046d67ec4368e555fa"},
+ {file = "aiohttp-3.11.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:afcda759a69c6a8be3aae764ec6733155aa4a5ad9aad4f398b52ba4037942fe3"},
+ {file = "aiohttp-3.11.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c5bba6b83fde4ca233cfda04cbd4685ab88696b0c8eaf76f7148969eab5e248a"},
+ {file = "aiohttp-3.11.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:442356e8924fe1a121f8c87866b0ecdc785757fd28924b17c20493961b3d6697"},
+ {file = "aiohttp-3.11.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f737fef6e117856400afee4f17774cdea392b28ecf058833f5eca368a18cf1bf"},
+ {file = "aiohttp-3.11.9-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ea142255d4901b03f89cb6a94411ecec117786a76fc9ab043af8f51dd50b5313"},
+ {file = "aiohttp-3.11.9-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6e1e9e447856e9b7b3d38e1316ae9a8c92e7536ef48373de758ea055edfd5db5"},
+ {file = "aiohttp-3.11.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7f6173302f8a329ca5d1ee592af9e628d3ade87816e9958dcf7cdae2841def7"},
+ {file = "aiohttp-3.11.9-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7c6147c6306f537cff59409609508a1d2eff81199f0302dd456bb9e7ea50c39"},
+ {file = "aiohttp-3.11.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e9d036a9a41fc78e8a3f10a86c2fc1098fca8fab8715ba9eb999ce4788d35df0"},
+ {file = "aiohttp-3.11.9-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:2ac9fd83096df36728da8e2f4488ac3b5602238f602706606f3702f07a13a409"},
+ {file = "aiohttp-3.11.9-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d3108f0ad5c6b6d78eec5273219a5bbd884b4aacec17883ceefaac988850ce6e"},
+ {file = "aiohttp-3.11.9-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:96bbec47beb131bbf4bae05d8ef99ad9e5738f12717cfbbf16648b78b0232e87"},
+ {file = "aiohttp-3.11.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:fc726c3fa8f606d07bd2b500e5dc4c0fd664c59be7788a16b9e34352c50b6b6b"},
+ {file = "aiohttp-3.11.9-cp311-cp311-win32.whl", hash = "sha256:5720ebbc7a1b46c33a42d489d25d36c64c419f52159485e55589fbec648ea49a"},
+ {file = "aiohttp-3.11.9-cp311-cp311-win_amd64.whl", hash = "sha256:17af09d963fa1acd7e4c280e9354aeafd9e3d47eaa4a6bfbd2171ad7da49f0c5"},
+ {file = "aiohttp-3.11.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:c1f2d7fd583fc79c240094b3e7237d88493814d4b300d013a42726c35a734bc9"},
+ {file = "aiohttp-3.11.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4b8a1b6c7a68c73191f2ebd3bf66f7ce02f9c374e309bdb68ba886bbbf1b938"},
+ {file = "aiohttp-3.11.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd3f711f4c99da0091ced41dccdc1bcf8be0281dc314d6d9c6b6cf5df66f37a9"},
+ {file = "aiohttp-3.11.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:44cb1a1326a0264480a789e6100dc3e07122eb8cd1ad6b784a3d47d13ed1d89c"},
+ {file = "aiohttp-3.11.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7a7ddf981a0b953ade1c2379052d47ccda2f58ab678fca0671c7c7ca2f67aac2"},
+ {file = "aiohttp-3.11.9-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6ffa45cc55b18d4ac1396d1ddb029f139b1d3480f1594130e62bceadf2e1a838"},
+ {file = "aiohttp-3.11.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cca505829cdab58c2495ff418c96092d225a1bbd486f79017f6de915580d3c44"},
+ {file = "aiohttp-3.11.9-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44d323aa80a867cb6db6bebb4bbec677c6478e38128847f2c6b0f70eae984d72"},
+ {file = "aiohttp-3.11.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b2fab23003c4bb2249729a7290a76c1dda38c438300fdf97d4e42bf78b19c810"},
+ {file = "aiohttp-3.11.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:be0c7c98e38a1e3ad7a6ff64af8b6d6db34bf5a41b1478e24c3c74d9e7f8ed42"},
+ {file = "aiohttp-3.11.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5cc5e0d069c56645446c45a4b5010d4b33ac6c5ebfd369a791b5f097e46a3c08"},
+ {file = "aiohttp-3.11.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9bcf97b971289be69638d8b1b616f7e557e1342debc7fc86cf89d3f08960e411"},
+ {file = "aiohttp-3.11.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c7333e7239415076d1418dbfb7fa4df48f3a5b00f8fdf854fca549080455bc14"},
+ {file = "aiohttp-3.11.9-cp312-cp312-win32.whl", hash = "sha256:9384b07cfd3045b37b05ed002d1c255db02fb96506ad65f0f9b776b762a7572e"},
+ {file = "aiohttp-3.11.9-cp312-cp312-win_amd64.whl", hash = "sha256:f5252ba8b43906f206048fa569debf2cd0da0316e8d5b4d25abe53307f573941"},
+ {file = "aiohttp-3.11.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:282e0a7ddd36ebc411f156aeaa0491e8fe7f030e2a95da532cf0c84b0b70bc66"},
+ {file = "aiohttp-3.11.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ebd3e6b0c7d4954cca59d241970011f8d3327633d555051c430bd09ff49dc494"},
+ {file = "aiohttp-3.11.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30f9f89ae625d412043f12ca3771b2ccec227cc93b93bb1f994db6e1af40a7d3"},
+ {file = "aiohttp-3.11.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a3b5b2c012d70c63d9d13c57ed1603709a4d9d7d473e4a9dfece0e4ea3d5f51"},
+ {file = "aiohttp-3.11.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6ef1550bb5f55f71b97a6a395286db07f7f2c01c8890e613556df9a51da91e8d"},
+ {file = "aiohttp-3.11.9-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:317251b9c9a2f1a9ff9cd093775b34c6861d1d7df9439ce3d32a88c275c995cd"},
+ {file = "aiohttp-3.11.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21cbe97839b009826a61b143d3ca4964c8590d7aed33d6118125e5b71691ca46"},
+ {file = "aiohttp-3.11.9-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:618b18c3a2360ac940a5503da14fa4f880c5b9bc315ec20a830357bcc62e6bae"},
+ {file = "aiohttp-3.11.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a0cf4d814689e58f57ecd5d8c523e6538417ca2e72ff52c007c64065cef50fb2"},
+ {file = "aiohttp-3.11.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:15c4e489942d987d5dac0ba39e5772dcbed4cc9ae3710d1025d5ba95e4a5349c"},
+ {file = "aiohttp-3.11.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:ec8df0ff5a911c6d21957a9182402aad7bf060eaeffd77c9ea1c16aecab5adbf"},
+ {file = "aiohttp-3.11.9-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ed95d66745f53e129e935ad726167d3a6cb18c5d33df3165974d54742c373868"},
+ {file = "aiohttp-3.11.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:647ec5bee7e4ec9f1034ab48173b5fa970d9a991e565549b965e93331f1328fe"},
+ {file = "aiohttp-3.11.9-cp313-cp313-win32.whl", hash = "sha256:ef2c9499b7bd1e24e473dc1a85de55d72fd084eea3d8bdeec7ee0720decb54fa"},
+ {file = "aiohttp-3.11.9-cp313-cp313-win_amd64.whl", hash = "sha256:84de955314aa5e8d469b00b14d6d714b008087a0222b0f743e7ffac34ef56aff"},
+ {file = "aiohttp-3.11.9-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e738aabff3586091221044b7a584865ddc4d6120346d12e28e788307cd731043"},
+ {file = "aiohttp-3.11.9-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:28f29bce89c3b401a53d6fd4bee401ee943083bf2bdc12ef297c1d63155070b0"},
+ {file = "aiohttp-3.11.9-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:31de2f10f63f96cc19e04bd2df9549559beadd0b2ee2da24a17e7ed877ca8c60"},
+ {file = "aiohttp-3.11.9-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f31cebd8c27a36af6c7346055ac564946e562080ee1a838da724585c67474f"},
+ {file = "aiohttp-3.11.9-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0bcb7f6976dc0b6b56efde13294862adf68dd48854111b422a336fa729a82ea6"},
+ {file = "aiohttp-3.11.9-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a8b13b9950d8b2f8f58b6e5842c4b842b5887e2c32e3f4644d6642f1659a530"},
+ {file = "aiohttp-3.11.9-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9c23e62f3545c2216100603614f9e019e41b9403c47dd85b8e7e5015bf1bde0"},
+ {file = "aiohttp-3.11.9-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ec656680fc53a13f849c71afd0c84a55c536206d524cbc831cde80abbe80489e"},
+ {file = "aiohttp-3.11.9-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:36df00e0541f264ce42d62280281541a47474dfda500bc5b7f24f70a7f87be7a"},
+ {file = "aiohttp-3.11.9-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:8dcfd14c712aa9dd18049280bfb2f95700ff6a8bde645e09f17c3ed3f05a0130"},
+ {file = "aiohttp-3.11.9-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:14624d96f0d69cf451deed3173079a68c322279be6030208b045ab77e1e8d550"},
+ {file = "aiohttp-3.11.9-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4b01d9cfcb616eeb6d40f02e66bebfe7b06d9f2ef81641fdd50b8dd981166e0b"},
+ {file = "aiohttp-3.11.9-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:928f92f80e2e8d6567b87d3316c1fd9860ccfe36e87a9a7f5237d4cda8baa1ba"},
+ {file = "aiohttp-3.11.9-cp39-cp39-win32.whl", hash = "sha256:c8a02f74ae419e3955af60f570d83187423e42e672a6433c5e292f1d23619269"},
+ {file = "aiohttp-3.11.9-cp39-cp39-win_amd64.whl", hash = "sha256:0a97d657f6cf8782a830bb476c13f7d777cfcab8428ac49dde15c22babceb361"},
+ {file = "aiohttp-3.11.9.tar.gz", hash = "sha256:a9266644064779840feec0e34f10a89b3ff1d2d6b751fe90017abcad1864fa7c"},
+]
+
+[package.dependencies]
+aiohappyeyeballs = ">=2.3.0"
+aiosignal = ">=1.1.2"
+async-timeout = {version = ">=4.0,<6.0", markers = "python_version < \"3.11\""}
+attrs = ">=17.3.0"
+frozenlist = ">=1.1.1"
+multidict = ">=4.5,<7.0"
+propcache = ">=0.2.0"
+yarl = ">=1.17.0,<2.0"
+
+[package.extras]
+speedups = ["Brotli", "aiodns (>=3.2.0)", "brotlicffi"]
+
+[[package]]
+name = "aiohttp-jinja2"
+version = "1.6"
+description = "jinja2 template renderer for aiohttp.web (http server for asyncio)"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "aiohttp-jinja2-1.6.tar.gz", hash = "sha256:a3a7ff5264e5bca52e8ae547bbfd0761b72495230d438d05b6c0915be619b0e2"},
+ {file = "aiohttp_jinja2-1.6-py3-none-any.whl", hash = "sha256:0df405ee6ad1b58e5a068a105407dc7dcc1704544c559f1938babde954f945c7"},
+]
+
+[package.dependencies]
+aiohttp = ">=3.9.0"
+jinja2 = ">=3.0.0"
+
+[[package]]
+name = "aiosignal"
+version = "1.3.1"
+description = "aiosignal: a list of registered asynchronous callbacks"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "aiosignal-1.3.1-py3-none-any.whl", hash = "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17"},
+ {file = "aiosignal-1.3.1.tar.gz", hash = "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc"},
+]
+
+[package.dependencies]
+frozenlist = ">=1.1.0"
+
+[[package]]
+name = "async-timeout"
+version = "5.0.1"
+description = "Timeout context manager for asyncio programs"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c"},
+ {file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"},
+]
+
+[[package]]
+name = "attrs"
+version = "24.2.0"
+description = "Classes Without Boilerplate"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2"},
+ {file = "attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346"},
+]
+
+[package.extras]
+benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"]
+cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"]
+dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"]
+docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"]
+tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"]
+tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"]
+
+[[package]]
+name = "cfgv"
+version = "3.4.0"
+description = "Validate configuration and produce human readable error messages."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"},
+ {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"},
+]
+
+[[package]]
+name = "click"
+version = "8.1.7"
+description = "Composable command line interface toolkit"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"},
+ {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"},
+]
+
+[package.dependencies]
+colorama = {version = "*", markers = "platform_system == \"Windows\""}
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+description = "Cross-platform colored terminal text."
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
+files = [
+ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
+ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
+]
+
+[[package]]
+name = "distlib"
+version = "0.3.9"
+description = "Distribution utilities"
+optional = false
+python-versions = "*"
+files = [
+ {file = "distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87"},
+ {file = "distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403"},
+]
+
+[[package]]
+name = "exceptiongroup"
+version = "1.2.2"
+description = "Backport of PEP 654 (exception groups)"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"},
+ {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"},
+]
+
+[package.extras]
+test = ["pytest (>=6)"]
+
+[[package]]
+name = "filelock"
+version = "3.16.1"
+description = "A platform independent file lock."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0"},
+ {file = "filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435"},
+]
+
+[package.extras]
+docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4.1)"]
+testing = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "diff-cover (>=9.2)", "pytest (>=8.3.3)", "pytest-asyncio (>=0.24)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.26.4)"]
+typing = ["typing-extensions (>=4.12.2)"]
+
+[[package]]
+name = "frozenlist"
+version = "1.5.0"
+description = "A list-like structure which implements collections.abc.MutableSequence"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "frozenlist-1.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5b6a66c18b5b9dd261ca98dffcb826a525334b2f29e7caa54e182255c5f6a65a"},
+ {file = "frozenlist-1.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d1b3eb7b05ea246510b43a7e53ed1653e55c2121019a97e60cad7efb881a97bb"},
+ {file = "frozenlist-1.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:15538c0cbf0e4fa11d1e3a71f823524b0c46299aed6e10ebb4c2089abd8c3bec"},
+ {file = "frozenlist-1.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e79225373c317ff1e35f210dd5f1344ff31066ba8067c307ab60254cd3a78ad5"},
+ {file = "frozenlist-1.5.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9272fa73ca71266702c4c3e2d4a28553ea03418e591e377a03b8e3659d94fa76"},
+ {file = "frozenlist-1.5.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:498524025a5b8ba81695761d78c8dd7382ac0b052f34e66939c42df860b8ff17"},
+ {file = "frozenlist-1.5.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:92b5278ed9d50fe610185ecd23c55d8b307d75ca18e94c0e7de328089ac5dcba"},
+ {file = "frozenlist-1.5.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f3c8c1dacd037df16e85227bac13cca58c30da836c6f936ba1df0c05d046d8d"},
+ {file = "frozenlist-1.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f2ac49a9bedb996086057b75bf93538240538c6d9b38e57c82d51f75a73409d2"},
+ {file = "frozenlist-1.5.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e66cc454f97053b79c2ab09c17fbe3c825ea6b4de20baf1be28919460dd7877f"},
+ {file = "frozenlist-1.5.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:5a3ba5f9a0dfed20337d3e966dc359784c9f96503674c2faf015f7fe8e96798c"},
+ {file = "frozenlist-1.5.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6321899477db90bdeb9299ac3627a6a53c7399c8cd58d25da094007402b039ab"},
+ {file = "frozenlist-1.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:76e4753701248476e6286f2ef492af900ea67d9706a0155335a40ea21bf3b2f5"},
+ {file = "frozenlist-1.5.0-cp310-cp310-win32.whl", hash = "sha256:977701c081c0241d0955c9586ffdd9ce44f7a7795df39b9151cd9a6fd0ce4cfb"},
+ {file = "frozenlist-1.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:189f03b53e64144f90990d29a27ec4f7997d91ed3d01b51fa39d2dbe77540fd4"},
+ {file = "frozenlist-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:fd74520371c3c4175142d02a976aee0b4cb4a7cc912a60586ffd8d5929979b30"},
+ {file = "frozenlist-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2f3f7a0fbc219fb4455264cae4d9f01ad41ae6ee8524500f381de64ffaa077d5"},
+ {file = "frozenlist-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f47c9c9028f55a04ac254346e92977bf0f166c483c74b4232bee19a6697e4778"},
+ {file = "frozenlist-1.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0996c66760924da6e88922756d99b47512a71cfd45215f3570bf1e0b694c206a"},
+ {file = "frozenlist-1.5.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2fe128eb4edeabe11896cb6af88fca5346059f6c8d807e3b910069f39157869"},
+ {file = "frozenlist-1.5.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a8ea951bbb6cacd492e3948b8da8c502a3f814f5d20935aae74b5df2b19cf3d"},
+ {file = "frozenlist-1.5.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:de537c11e4aa01d37db0d403b57bd6f0546e71a82347a97c6a9f0dcc532b3a45"},
+ {file = "frozenlist-1.5.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c2623347b933fcb9095841f1cc5d4ff0b278addd743e0e966cb3d460278840d"},
+ {file = "frozenlist-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cee6798eaf8b1416ef6909b06f7dc04b60755206bddc599f52232606e18179d3"},
+ {file = "frozenlist-1.5.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f5f9da7f5dbc00a604fe74aa02ae7c98bcede8a3b8b9666f9f86fc13993bc71a"},
+ {file = "frozenlist-1.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:90646abbc7a5d5c7c19461d2e3eeb76eb0b204919e6ece342feb6032c9325ae9"},
+ {file = "frozenlist-1.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bdac3c7d9b705d253b2ce370fde941836a5f8b3c5c2b8fd70940a3ea3af7f4f2"},
+ {file = "frozenlist-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03d33c2ddbc1816237a67f66336616416e2bbb6beb306e5f890f2eb22b959cdf"},
+ {file = "frozenlist-1.5.0-cp311-cp311-win32.whl", hash = "sha256:237f6b23ee0f44066219dae14c70ae38a63f0440ce6750f868ee08775073f942"},
+ {file = "frozenlist-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:0cc974cc93d32c42e7b0f6cf242a6bd941c57c61b618e78b6c0a96cb72788c1d"},
+ {file = "frozenlist-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:31115ba75889723431aa9a4e77d5f398f5cf976eea3bdf61749731f62d4a4a21"},
+ {file = "frozenlist-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7437601c4d89d070eac8323f121fcf25f88674627505334654fd027b091db09d"},
+ {file = "frozenlist-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7948140d9f8ece1745be806f2bfdf390127cf1a763b925c4a805c603df5e697e"},
+ {file = "frozenlist-1.5.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:feeb64bc9bcc6b45c6311c9e9b99406660a9c05ca8a5b30d14a78555088b0b3a"},
+ {file = "frozenlist-1.5.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:683173d371daad49cffb8309779e886e59c2f369430ad28fe715f66d08d4ab1a"},
+ {file = "frozenlist-1.5.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7d57d8f702221405a9d9b40f9da8ac2e4a1a8b5285aac6100f3393675f0a85ee"},
+ {file = "frozenlist-1.5.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30c72000fbcc35b129cb09956836c7d7abf78ab5416595e4857d1cae8d6251a6"},
+ {file = "frozenlist-1.5.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:000a77d6034fbad9b6bb880f7ec073027908f1b40254b5d6f26210d2dab1240e"},
+ {file = "frozenlist-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5d7f5a50342475962eb18b740f3beecc685a15b52c91f7d975257e13e029eca9"},
+ {file = "frozenlist-1.5.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:87f724d055eb4785d9be84e9ebf0f24e392ddfad00b3fe036e43f489fafc9039"},
+ {file = "frozenlist-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:6e9080bb2fb195a046e5177f10d9d82b8a204c0736a97a153c2466127de87784"},
+ {file = "frozenlist-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9b93d7aaa36c966fa42efcaf716e6b3900438632a626fb09c049f6a2f09fc631"},
+ {file = "frozenlist-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:52ef692a4bc60a6dd57f507429636c2af8b6046db8b31b18dac02cbc8f507f7f"},
+ {file = "frozenlist-1.5.0-cp312-cp312-win32.whl", hash = "sha256:29d94c256679247b33a3dc96cce0f93cbc69c23bf75ff715919332fdbb6a32b8"},
+ {file = "frozenlist-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:8969190d709e7c48ea386db202d708eb94bdb29207a1f269bab1196ce0dcca1f"},
+ {file = "frozenlist-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7a1a048f9215c90973402e26c01d1cff8a209e1f1b53f72b95c13db61b00f953"},
+ {file = "frozenlist-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dd47a5181ce5fcb463b5d9e17ecfdb02b678cca31280639255ce9d0e5aa67af0"},
+ {file = "frozenlist-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1431d60b36d15cda188ea222033eec8e0eab488f39a272461f2e6d9e1a8e63c2"},
+ {file = "frozenlist-1.5.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6482a5851f5d72767fbd0e507e80737f9c8646ae7fd303def99bfe813f76cf7f"},
+ {file = "frozenlist-1.5.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:44c49271a937625619e862baacbd037a7ef86dd1ee215afc298a417ff3270608"},
+ {file = "frozenlist-1.5.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:12f78f98c2f1c2429d42e6a485f433722b0061d5c0b0139efa64f396efb5886b"},
+ {file = "frozenlist-1.5.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ce3aa154c452d2467487765e3adc730a8c153af77ad84096bc19ce19a2400840"},
+ {file = "frozenlist-1.5.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b7dc0c4338e6b8b091e8faf0db3168a37101943e687f373dce00959583f7439"},
+ {file = "frozenlist-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:45e0896250900b5aa25180f9aec243e84e92ac84bd4a74d9ad4138ef3f5c97de"},
+ {file = "frozenlist-1.5.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:561eb1c9579d495fddb6da8959fd2a1fca2c6d060d4113f5844b433fc02f2641"},
+ {file = "frozenlist-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:df6e2f325bfee1f49f81aaac97d2aa757c7646534a06f8f577ce184afe2f0a9e"},
+ {file = "frozenlist-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:140228863501b44b809fb39ec56b5d4071f4d0aa6d216c19cbb08b8c5a7eadb9"},
+ {file = "frozenlist-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7707a25d6a77f5d27ea7dc7d1fc608aa0a478193823f88511ef5e6b8a48f9d03"},
+ {file = "frozenlist-1.5.0-cp313-cp313-win32.whl", hash = "sha256:31a9ac2b38ab9b5a8933b693db4939764ad3f299fcaa931a3e605bc3460e693c"},
+ {file = "frozenlist-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:11aabdd62b8b9c4b84081a3c246506d1cddd2dd93ff0ad53ede5defec7886b28"},
+ {file = "frozenlist-1.5.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:dd94994fc91a6177bfaafd7d9fd951bc8689b0a98168aa26b5f543868548d3ca"},
+ {file = "frozenlist-1.5.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2d0da8bbec082bf6bf18345b180958775363588678f64998c2b7609e34719b10"},
+ {file = "frozenlist-1.5.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:73f2e31ea8dd7df61a359b731716018c2be196e5bb3b74ddba107f694fbd7604"},
+ {file = "frozenlist-1.5.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:828afae9f17e6de596825cf4228ff28fbdf6065974e5ac1410cecc22f699d2b3"},
+ {file = "frozenlist-1.5.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1577515d35ed5649d52ab4319db757bb881ce3b2b796d7283e6634d99ace307"},
+ {file = "frozenlist-1.5.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2150cc6305a2c2ab33299453e2968611dacb970d2283a14955923062c8d00b10"},
+ {file = "frozenlist-1.5.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a72b7a6e3cd2725eff67cd64c8f13335ee18fc3c7befc05aed043d24c7b9ccb9"},
+ {file = "frozenlist-1.5.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c16d2fa63e0800723139137d667e1056bee1a1cf7965153d2d104b62855e9b99"},
+ {file = "frozenlist-1.5.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:17dcc32fc7bda7ce5875435003220a457bcfa34ab7924a49a1c19f55b6ee185c"},
+ {file = "frozenlist-1.5.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:97160e245ea33d8609cd2b8fd997c850b56db147a304a262abc2b3be021a9171"},
+ {file = "frozenlist-1.5.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:f1e6540b7fa044eee0bb5111ada694cf3dc15f2b0347ca125ee9ca984d5e9e6e"},
+ {file = "frozenlist-1.5.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:91d6c171862df0a6c61479d9724f22efb6109111017c87567cfeb7b5d1449fdf"},
+ {file = "frozenlist-1.5.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:c1fac3e2ace2eb1052e9f7c7db480818371134410e1f5c55d65e8f3ac6d1407e"},
+ {file = "frozenlist-1.5.0-cp38-cp38-win32.whl", hash = "sha256:b97f7b575ab4a8af9b7bc1d2ef7f29d3afee2226bd03ca3875c16451ad5a7723"},
+ {file = "frozenlist-1.5.0-cp38-cp38-win_amd64.whl", hash = "sha256:374ca2dabdccad8e2a76d40b1d037f5bd16824933bf7bcea3e59c891fd4a0923"},
+ {file = "frozenlist-1.5.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:9bbcdfaf4af7ce002694a4e10a0159d5a8d20056a12b05b45cea944a4953f972"},
+ {file = "frozenlist-1.5.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1893f948bf6681733aaccf36c5232c231e3b5166d607c5fa77773611df6dc336"},
+ {file = "frozenlist-1.5.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2b5e23253bb709ef57a8e95e6ae48daa9ac5f265637529e4ce6b003a37b2621f"},
+ {file = "frozenlist-1.5.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f253985bb515ecd89629db13cb58d702035ecd8cfbca7d7a7e29a0e6d39af5f"},
+ {file = "frozenlist-1.5.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:04a5c6babd5e8fb7d3c871dc8b321166b80e41b637c31a995ed844a6139942b6"},
+ {file = "frozenlist-1.5.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a9fe0f1c29ba24ba6ff6abf688cb0b7cf1efab6b6aa6adc55441773c252f7411"},
+ {file = "frozenlist-1.5.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:226d72559fa19babe2ccd920273e767c96a49b9d3d38badd7c91a0fdeda8ea08"},
+ {file = "frozenlist-1.5.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15b731db116ab3aedec558573c1a5eec78822b32292fe4f2f0345b7f697745c2"},
+ {file = "frozenlist-1.5.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:366d8f93e3edfe5a918c874702f78faac300209a4d5bf38352b2c1bdc07a766d"},
+ {file = "frozenlist-1.5.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1b96af8c582b94d381a1c1f51ffaedeb77c821c690ea5f01da3d70a487dd0a9b"},
+ {file = "frozenlist-1.5.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:c03eff4a41bd4e38415cbed054bbaff4a075b093e2394b6915dca34a40d1e38b"},
+ {file = "frozenlist-1.5.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:50cf5e7ee9b98f22bdecbabf3800ae78ddcc26e4a435515fc72d97903e8488e0"},
+ {file = "frozenlist-1.5.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1e76bfbc72353269c44e0bc2cfe171900fbf7f722ad74c9a7b638052afe6a00c"},
+ {file = "frozenlist-1.5.0-cp39-cp39-win32.whl", hash = "sha256:666534d15ba8f0fda3f53969117383d5dc021266b3c1a42c9ec4855e4b58b9d3"},
+ {file = "frozenlist-1.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:5c28f4b5dbef8a0d8aad0d4de24d1e9e981728628afaf4ea0792f5d0939372f0"},
+ {file = "frozenlist-1.5.0-py3-none-any.whl", hash = "sha256:d994863bba198a4a518b467bb971c56e1db3f180a25c6cf7bb1949c267f748c3"},
+ {file = "frozenlist-1.5.0.tar.gz", hash = "sha256:81d5af29e61b9c8348e876d442253723928dce6433e0e76cd925cd83f1b4b817"},
+]
+
+[[package]]
+name = "identify"
+version = "2.6.3"
+description = "File identification library for Python"
+optional = false
+python-versions = ">=3.9"
+files = [
+ {file = "identify-2.6.3-py2.py3-none-any.whl", hash = "sha256:9edba65473324c2ea9684b1f944fe3191db3345e50b6d04571d10ed164f8d7bd"},
+ {file = "identify-2.6.3.tar.gz", hash = "sha256:62f5dae9b5fef52c84cc188514e9ea4f3f636b1d8799ab5ebc475471f9e47a02"},
+]
+
+[package.extras]
+license = ["ukkonen"]
+
+[[package]]
+name = "idna"
+version = "3.10"
+description = "Internationalized Domain Names in Applications (IDNA)"
+optional = false
+python-versions = ">=3.6"
+files = [
+ {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"},
+ {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"},
+]
+
+[package.extras]
+all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
+
+[[package]]
+name = "iniconfig"
+version = "2.0.0"
+description = "brain-dead simple config-ini parsing"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"},
+ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
+]
+
+[[package]]
+name = "jinja2"
+version = "3.1.4"
+description = "A very fast and expressive template engine."
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"},
+ {file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"},
+]
+
+[package.dependencies]
+MarkupSafe = ">=2.0"
+
+[package.extras]
+i18n = ["Babel (>=2.7)"]
+
+[[package]]
+name = "linkify-it-py"
+version = "2.0.3"
+description = "Links recognition library with FULL unicode support."
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "linkify-it-py-2.0.3.tar.gz", hash = "sha256:68cda27e162e9215c17d786649d1da0021a451bdc436ef9e0fa0ba5234b9b048"},
+ {file = "linkify_it_py-2.0.3-py3-none-any.whl", hash = "sha256:6bcbc417b0ac14323382aef5c5192c0075bf8a9d6b41820a2b66371eac6b6d79"},
+]
+
+[package.dependencies]
+uc-micro-py = "*"
+
+[package.extras]
+benchmark = ["pytest", "pytest-benchmark"]
+dev = ["black", "flake8", "isort", "pre-commit", "pyproject-flake8"]
+doc = ["myst-parser", "sphinx", "sphinx-book-theme"]
+test = ["coverage", "pytest", "pytest-cov"]
+
+[[package]]
+name = "markdown-it-py"
+version = "3.0.0"
+description = "Python port of markdown-it. Markdown parsing, done right!"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"},
+ {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"},
+]
+
+[package.dependencies]
+linkify-it-py = {version = ">=1,<3", optional = true, markers = "extra == \"linkify\""}
+mdit-py-plugins = {version = "*", optional = true, markers = "extra == \"plugins\""}
+mdurl = ">=0.1,<1.0"
+
+[package.extras]
+benchmarking = ["psutil", "pytest", "pytest-benchmark"]
+code-style = ["pre-commit (>=3.0,<4.0)"]
+compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"]
+linkify = ["linkify-it-py (>=1,<3)"]
+plugins = ["mdit-py-plugins"]
+profiling = ["gprof2dot"]
+rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"]
+testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"]
+
+[[package]]
+name = "markupsafe"
+version = "3.0.2"
+description = "Safely add untrusted strings to HTML/XML markup."
+optional = false
+python-versions = ">=3.9"
+files = [
+ {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"},
+ {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"},
+ {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579"},
+ {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d"},
+ {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb"},
+ {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b"},
+ {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c"},
+ {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171"},
+ {file = "MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50"},
+ {file = "MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a"},
+ {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d"},
+ {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93"},
+ {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832"},
+ {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84"},
+ {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca"},
+ {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798"},
+ {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e"},
+ {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4"},
+ {file = "MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d"},
+ {file = "MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b"},
+ {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf"},
+ {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225"},
+ {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028"},
+ {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8"},
+ {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c"},
+ {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557"},
+ {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22"},
+ {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48"},
+ {file = "MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30"},
+ {file = "MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f"},
+ {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a"},
+ {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff"},
+ {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13"},
+ {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144"},
+ {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29"},
+ {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0"},
+ {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0"},
+ {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178"},
+ {file = "MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f"},
+ {file = "MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a"},
+ {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"},
+]
+
+[[package]]
+name = "mdit-py-plugins"
+version = "0.4.2"
+description = "Collection of plugins for markdown-it-py"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "mdit_py_plugins-0.4.2-py3-none-any.whl", hash = "sha256:0c673c3f889399a33b95e88d2f0d111b4447bdfea7f237dab2d488f459835636"},
+ {file = "mdit_py_plugins-0.4.2.tar.gz", hash = "sha256:5f2cd1fdb606ddf152d37ec30e46101a60512bc0e5fa1a7002c36647b09e26b5"},
+]
+
+[package.dependencies]
+markdown-it-py = ">=1.0.0,<4.0.0"
+
+[package.extras]
+code-style = ["pre-commit"]
+rtd = ["myst-parser", "sphinx-book-theme"]
+testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"]
+
+[[package]]
+name = "mdurl"
+version = "0.1.2"
+description = "Markdown URL utilities"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"},
+ {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"},
+]
+
+[[package]]
+name = "msgpack"
+version = "1.1.0"
+description = "MessagePack serializer"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "msgpack-1.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7ad442d527a7e358a469faf43fda45aaf4ac3249c8310a82f0ccff9164e5dccd"},
+ {file = "msgpack-1.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:74bed8f63f8f14d75eec75cf3d04ad581da6b914001b474a5d3cd3372c8cc27d"},
+ {file = "msgpack-1.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:914571a2a5b4e7606997e169f64ce53a8b1e06f2cf2c3a7273aa106236d43dd5"},
+ {file = "msgpack-1.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c921af52214dcbb75e6bdf6a661b23c3e6417f00c603dd2070bccb5c3ef499f5"},
+ {file = "msgpack-1.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d8ce0b22b890be5d252de90d0e0d119f363012027cf256185fc3d474c44b1b9e"},
+ {file = "msgpack-1.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:73322a6cc57fcee3c0c57c4463d828e9428275fb85a27aa2aa1a92fdc42afd7b"},
+ {file = "msgpack-1.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e1f3c3d21f7cf67bcf2da8e494d30a75e4cf60041d98b3f79875afb5b96f3a3f"},
+ {file = "msgpack-1.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:64fc9068d701233effd61b19efb1485587560b66fe57b3e50d29c5d78e7fef68"},
+ {file = "msgpack-1.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:42f754515e0f683f9c79210a5d1cad631ec3d06cea5172214d2176a42e67e19b"},
+ {file = "msgpack-1.1.0-cp310-cp310-win32.whl", hash = "sha256:3df7e6b05571b3814361e8464f9304c42d2196808e0119f55d0d3e62cd5ea044"},
+ {file = "msgpack-1.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:685ec345eefc757a7c8af44a3032734a739f8c45d1b0ac45efc5d8977aa4720f"},
+ {file = "msgpack-1.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3d364a55082fb2a7416f6c63ae383fbd903adb5a6cf78c5b96cc6316dc1cedc7"},
+ {file = "msgpack-1.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:79ec007767b9b56860e0372085f8504db5d06bd6a327a335449508bbee9648fa"},
+ {file = "msgpack-1.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6ad622bf7756d5a497d5b6836e7fc3752e2dd6f4c648e24b1803f6048596f701"},
+ {file = "msgpack-1.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e59bca908d9ca0de3dc8684f21ebf9a690fe47b6be93236eb40b99af28b6ea6"},
+ {file = "msgpack-1.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e1da8f11a3dd397f0a32c76165cf0c4eb95b31013a94f6ecc0b280c05c91b59"},
+ {file = "msgpack-1.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:452aff037287acb1d70a804ffd022b21fa2bb7c46bee884dbc864cc9024128a0"},
+ {file = "msgpack-1.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8da4bf6d54ceed70e8861f833f83ce0814a2b72102e890cbdfe4b34764cdd66e"},
+ {file = "msgpack-1.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:41c991beebf175faf352fb940bf2af9ad1fb77fd25f38d9142053914947cdbf6"},
+ {file = "msgpack-1.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a52a1f3a5af7ba1c9ace055b659189f6c669cf3657095b50f9602af3a3ba0fe5"},
+ {file = "msgpack-1.1.0-cp311-cp311-win32.whl", hash = "sha256:58638690ebd0a06427c5fe1a227bb6b8b9fdc2bd07701bec13c2335c82131a88"},
+ {file = "msgpack-1.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fd2906780f25c8ed5d7b323379f6138524ba793428db5d0e9d226d3fa6aa1788"},
+ {file = "msgpack-1.1.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:d46cf9e3705ea9485687aa4001a76e44748b609d260af21c4ceea7f2212a501d"},
+ {file = "msgpack-1.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5dbad74103df937e1325cc4bfeaf57713be0b4f15e1c2da43ccdd836393e2ea2"},
+ {file = "msgpack-1.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:58dfc47f8b102da61e8949708b3eafc3504509a5728f8b4ddef84bd9e16ad420"},
+ {file = "msgpack-1.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4676e5be1b472909b2ee6356ff425ebedf5142427842aa06b4dfd5117d1ca8a2"},
+ {file = "msgpack-1.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17fb65dd0bec285907f68b15734a993ad3fc94332b5bb21b0435846228de1f39"},
+ {file = "msgpack-1.1.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a51abd48c6d8ac89e0cfd4fe177c61481aca2d5e7ba42044fd218cfd8ea9899f"},
+ {file = "msgpack-1.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2137773500afa5494a61b1208619e3871f75f27b03bcfca7b3a7023284140247"},
+ {file = "msgpack-1.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:398b713459fea610861c8a7b62a6fec1882759f308ae0795b5413ff6a160cf3c"},
+ {file = "msgpack-1.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:06f5fd2f6bb2a7914922d935d3b8bb4a7fff3a9a91cfce6d06c13bc42bec975b"},
+ {file = "msgpack-1.1.0-cp312-cp312-win32.whl", hash = "sha256:ad33e8400e4ec17ba782f7b9cf868977d867ed784a1f5f2ab46e7ba53b6e1e1b"},
+ {file = "msgpack-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:115a7af8ee9e8cddc10f87636767857e7e3717b7a2e97379dc2054712693e90f"},
+ {file = "msgpack-1.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:071603e2f0771c45ad9bc65719291c568d4edf120b44eb36324dcb02a13bfddf"},
+ {file = "msgpack-1.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0f92a83b84e7c0749e3f12821949d79485971f087604178026085f60ce109330"},
+ {file = "msgpack-1.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4a1964df7b81285d00a84da4e70cb1383f2e665e0f1f2a7027e683956d04b734"},
+ {file = "msgpack-1.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:59caf6a4ed0d164055ccff8fe31eddc0ebc07cf7326a2aaa0dbf7a4001cd823e"},
+ {file = "msgpack-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0907e1a7119b337971a689153665764adc34e89175f9a34793307d9def08e6ca"},
+ {file = "msgpack-1.1.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:65553c9b6da8166e819a6aa90ad15288599b340f91d18f60b2061f402b9a4915"},
+ {file = "msgpack-1.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7a946a8992941fea80ed4beae6bff74ffd7ee129a90b4dd5cf9c476a30e9708d"},
+ {file = "msgpack-1.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4b51405e36e075193bc051315dbf29168d6141ae2500ba8cd80a522964e31434"},
+ {file = "msgpack-1.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4c01941fd2ff87c2a934ee6055bda4ed353a7846b8d4f341c428109e9fcde8c"},
+ {file = "msgpack-1.1.0-cp313-cp313-win32.whl", hash = "sha256:7c9a35ce2c2573bada929e0b7b3576de647b0defbd25f5139dcdaba0ae35a4cc"},
+ {file = "msgpack-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:bce7d9e614a04d0883af0b3d4d501171fbfca038f12c77fa838d9f198147a23f"},
+ {file = "msgpack-1.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c40ffa9a15d74e05ba1fe2681ea33b9caffd886675412612d93ab17b58ea2fec"},
+ {file = "msgpack-1.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f1ba6136e650898082d9d5a5217d5906d1e138024f836ff48691784bbe1adf96"},
+ {file = "msgpack-1.1.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e0856a2b7e8dcb874be44fea031d22e5b3a19121be92a1e098f46068a11b0870"},
+ {file = "msgpack-1.1.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:471e27a5787a2e3f974ba023f9e265a8c7cfd373632247deb225617e3100a3c7"},
+ {file = "msgpack-1.1.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:646afc8102935a388ffc3914b336d22d1c2d6209c773f3eb5dd4d6d3b6f8c1cb"},
+ {file = "msgpack-1.1.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:13599f8829cfbe0158f6456374e9eea9f44eee08076291771d8ae93eda56607f"},
+ {file = "msgpack-1.1.0-cp38-cp38-win32.whl", hash = "sha256:8a84efb768fb968381e525eeeb3d92857e4985aacc39f3c47ffd00eb4509315b"},
+ {file = "msgpack-1.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:879a7b7b0ad82481c52d3c7eb99bf6f0645dbdec5134a4bddbd16f3506947feb"},
+ {file = "msgpack-1.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:53258eeb7a80fc46f62fd59c876957a2d0e15e6449a9e71842b6d24419d88ca1"},
+ {file = "msgpack-1.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7e7b853bbc44fb03fbdba34feb4bd414322180135e2cb5164f20ce1c9795ee48"},
+ {file = "msgpack-1.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f3e9b4936df53b970513eac1758f3882c88658a220b58dcc1e39606dccaaf01c"},
+ {file = "msgpack-1.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46c34e99110762a76e3911fc923222472c9d681f1094096ac4102c18319e6468"},
+ {file = "msgpack-1.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a706d1e74dd3dea05cb54580d9bd8b2880e9264856ce5068027eed09680aa74"},
+ {file = "msgpack-1.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:534480ee5690ab3cbed89d4c8971a5c631b69a8c0883ecfea96c19118510c846"},
+ {file = "msgpack-1.1.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:8cf9e8c3a2153934a23ac160cc4cba0ec035f6867c8013cc6077a79823370346"},
+ {file = "msgpack-1.1.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3180065ec2abbe13a4ad37688b61b99d7f9e012a535b930e0e683ad6bc30155b"},
+ {file = "msgpack-1.1.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c5a91481a3cc573ac8c0d9aace09345d989dc4a0202b7fcb312c88c26d4e71a8"},
+ {file = "msgpack-1.1.0-cp39-cp39-win32.whl", hash = "sha256:f80bc7d47f76089633763f952e67f8214cb7b3ee6bfa489b3cb6a84cfac114cd"},
+ {file = "msgpack-1.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:4d1b7ff2d6146e16e8bd665ac726a89c74163ef8cd39fa8c1087d4e52d3a2325"},
+ {file = "msgpack-1.1.0.tar.gz", hash = "sha256:dd432ccc2c72b914e4cb77afce64aab761c1137cc698be3984eee260bcb2896e"},
+]
+
+[[package]]
+name = "multidict"
+version = "6.1.0"
+description = "multidict implementation"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "multidict-6.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3380252550e372e8511d49481bd836264c009adb826b23fefcc5dd3c69692f60"},
+ {file = "multidict-6.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:99f826cbf970077383d7de805c0681799491cb939c25450b9b5b3ced03ca99f1"},
+ {file = "multidict-6.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a114d03b938376557927ab23f1e950827c3b893ccb94b62fd95d430fd0e5cf53"},
+ {file = "multidict-6.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1c416351ee6271b2f49b56ad7f308072f6f44b37118d69c2cad94f3fa8a40d5"},
+ {file = "multidict-6.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6b5d83030255983181005e6cfbac1617ce9746b219bc2aad52201ad121226581"},
+ {file = "multidict-6.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e97b5e938051226dc025ec80980c285b053ffb1e25a3db2a3aa3bc046bf7f56"},
+ {file = "multidict-6.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d618649d4e70ac6efcbba75be98b26ef5078faad23592f9b51ca492953012429"},
+ {file = "multidict-6.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:10524ebd769727ac77ef2278390fb0068d83f3acb7773792a5080f2b0abf7748"},
+ {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ff3827aef427c89a25cc96ded1759271a93603aba9fb977a6d264648ebf989db"},
+ {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:06809f4f0f7ab7ea2cabf9caca7d79c22c0758b58a71f9d32943ae13c7ace056"},
+ {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f179dee3b863ab1c59580ff60f9d99f632f34ccb38bf67a33ec6b3ecadd0fd76"},
+ {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:aaed8b0562be4a0876ee3b6946f6869b7bcdb571a5d1496683505944e268b160"},
+ {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3c8b88a2ccf5493b6c8da9076fb151ba106960a2df90c2633f342f120751a9e7"},
+ {file = "multidict-6.1.0-cp310-cp310-win32.whl", hash = "sha256:4a9cb68166a34117d6646c0023c7b759bf197bee5ad4272f420a0141d7eb03a0"},
+ {file = "multidict-6.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:20b9b5fbe0b88d0bdef2012ef7dee867f874b72528cf1d08f1d59b0e3850129d"},
+ {file = "multidict-6.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3efe2c2cb5763f2f1b275ad2bf7a287d3f7ebbef35648a9726e3b69284a4f3d6"},
+ {file = "multidict-6.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c7053d3b0353a8b9de430a4f4b4268ac9a4fb3481af37dfe49825bf45ca24156"},
+ {file = "multidict-6.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:27e5fc84ccef8dfaabb09d82b7d179c7cf1a3fbc8a966f8274fcb4ab2eb4cadb"},
+ {file = "multidict-6.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e2b90b43e696f25c62656389d32236e049568b39320e2735d51f08fd362761b"},
+ {file = "multidict-6.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d83a047959d38a7ff552ff94be767b7fd79b831ad1cd9920662db05fec24fe72"},
+ {file = "multidict-6.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d1a9dd711d0877a1ece3d2e4fea11a8e75741ca21954c919406b44e7cf971304"},
+ {file = "multidict-6.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec2abea24d98246b94913b76a125e855eb5c434f7c46546046372fe60f666351"},
+ {file = "multidict-6.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4867cafcbc6585e4b678876c489b9273b13e9fff9f6d6d66add5e15d11d926cb"},
+ {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5b48204e8d955c47c55b72779802b219a39acc3ee3d0116d5080c388970b76e3"},
+ {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d8fff389528cad1618fb4b26b95550327495462cd745d879a8c7c2115248e399"},
+ {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a7a9541cd308eed5e30318430a9c74d2132e9a8cb46b901326272d780bf2d423"},
+ {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:da1758c76f50c39a2efd5e9859ce7d776317eb1dd34317c8152ac9251fc574a3"},
+ {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c943a53e9186688b45b323602298ab727d8865d8c9ee0b17f8d62d14b56f0753"},
+ {file = "multidict-6.1.0-cp311-cp311-win32.whl", hash = "sha256:90f8717cb649eea3504091e640a1b8568faad18bd4b9fcd692853a04475a4b80"},
+ {file = "multidict-6.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:82176036e65644a6cc5bd619f65f6f19781e8ec2e5330f51aa9ada7504cc1926"},
+ {file = "multidict-6.1.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:b04772ed465fa3cc947db808fa306d79b43e896beb677a56fb2347ca1a49c1fa"},
+ {file = "multidict-6.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6180c0ae073bddeb5a97a38c03f30c233e0a4d39cd86166251617d1bbd0af436"},
+ {file = "multidict-6.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:071120490b47aa997cca00666923a83f02c7fbb44f71cf7f136df753f7fa8761"},
+ {file = "multidict-6.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50b3a2710631848991d0bf7de077502e8994c804bb805aeb2925a981de58ec2e"},
+ {file = "multidict-6.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b58c621844d55e71c1b7f7c498ce5aa6985d743a1a59034c57a905b3f153c1ef"},
+ {file = "multidict-6.1.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55b6d90641869892caa9ca42ff913f7ff1c5ece06474fbd32fb2cf6834726c95"},
+ {file = "multidict-6.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b820514bfc0b98a30e3d85462084779900347e4d49267f747ff54060cc33925"},
+ {file = "multidict-6.1.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:10a9b09aba0c5b48c53761b7c720aaaf7cf236d5fe394cd399c7ba662d5f9966"},
+ {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e16bf3e5fc9f44632affb159d30a437bfe286ce9e02754759be5536b169b305"},
+ {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:76f364861c3bfc98cbbcbd402d83454ed9e01a5224bb3a28bf70002a230f73e2"},
+ {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:820c661588bd01a0aa62a1283f20d2be4281b086f80dad9e955e690c75fb54a2"},
+ {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:0e5f362e895bc5b9e67fe6e4ded2492d8124bdf817827f33c5b46c2fe3ffaca6"},
+ {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ec660d19bbc671e3a6443325f07263be452c453ac9e512f5eb935e7d4ac28b3"},
+ {file = "multidict-6.1.0-cp312-cp312-win32.whl", hash = "sha256:58130ecf8f7b8112cdb841486404f1282b9c86ccb30d3519faf301b2e5659133"},
+ {file = "multidict-6.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:188215fc0aafb8e03341995e7c4797860181562380f81ed0a87ff455b70bf1f1"},
+ {file = "multidict-6.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d569388c381b24671589335a3be6e1d45546c2988c2ebe30fdcada8457a31008"},
+ {file = "multidict-6.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:052e10d2d37810b99cc170b785945421141bf7bb7d2f8799d431e7db229c385f"},
+ {file = "multidict-6.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f90c822a402cb865e396a504f9fc8173ef34212a342d92e362ca498cad308e28"},
+ {file = "multidict-6.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b225d95519a5bf73860323e633a664b0d85ad3d5bede6d30d95b35d4dfe8805b"},
+ {file = "multidict-6.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:23bfd518810af7de1116313ebd9092cb9aa629beb12f6ed631ad53356ed6b86c"},
+ {file = "multidict-6.1.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c09fcfdccdd0b57867577b719c69e347a436b86cd83747f179dbf0cc0d4c1f3"},
+ {file = "multidict-6.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf6bea52ec97e95560af5ae576bdac3aa3aae0b6758c6efa115236d9e07dae44"},
+ {file = "multidict-6.1.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57feec87371dbb3520da6192213c7d6fc892d5589a93db548331954de8248fd2"},
+ {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0c3f390dc53279cbc8ba976e5f8035eab997829066756d811616b652b00a23a3"},
+ {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:59bfeae4b25ec05b34f1956eaa1cb38032282cd4dfabc5056d0a1ec4d696d3aa"},
+ {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b2f59caeaf7632cc633b5cf6fc449372b83bbdf0da4ae04d5be36118e46cc0aa"},
+ {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:37bb93b2178e02b7b618893990941900fd25b6b9ac0fa49931a40aecdf083fe4"},
+ {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4e9f48f58c2c523d5a06faea47866cd35b32655c46b443f163d08c6d0ddb17d6"},
+ {file = "multidict-6.1.0-cp313-cp313-win32.whl", hash = "sha256:3a37ffb35399029b45c6cc33640a92bef403c9fd388acce75cdc88f58bd19a81"},
+ {file = "multidict-6.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:e9aa71e15d9d9beaad2c6b9319edcdc0a49a43ef5c0a4c8265ca9ee7d6c67774"},
+ {file = "multidict-6.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:db7457bac39421addd0c8449933ac32d8042aae84a14911a757ae6ca3eef1392"},
+ {file = "multidict-6.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d094ddec350a2fb899fec68d8353c78233debde9b7d8b4beeafa70825f1c281a"},
+ {file = "multidict-6.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5845c1fd4866bb5dd3125d89b90e57ed3138241540897de748cdf19de8a2fca2"},
+ {file = "multidict-6.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9079dfc6a70abe341f521f78405b8949f96db48da98aeb43f9907f342f627cdc"},
+ {file = "multidict-6.1.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3914f5aaa0f36d5d60e8ece6a308ee1c9784cd75ec8151062614657a114c4478"},
+ {file = "multidict-6.1.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c08be4f460903e5a9d0f76818db3250f12e9c344e79314d1d570fc69d7f4eae4"},
+ {file = "multidict-6.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d093be959277cb7dee84b801eb1af388b6ad3ca6a6b6bf1ed7585895789d027d"},
+ {file = "multidict-6.1.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3702ea6872c5a2a4eeefa6ffd36b042e9773f05b1f37ae3ef7264b1163c2dcf6"},
+ {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:2090f6a85cafc5b2db085124d752757c9d251548cedabe9bd31afe6363e0aff2"},
+ {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:f67f217af4b1ff66c68a87318012de788dd95fcfeb24cc889011f4e1c7454dfd"},
+ {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:189f652a87e876098bbc67b4da1049afb5f5dfbaa310dd67c594b01c10388db6"},
+ {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:6bb5992037f7a9eff7991ebe4273ea7f51f1c1c511e6a2ce511d0e7bdb754492"},
+ {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:ac10f4c2b9e770c4e393876e35a7046879d195cd123b4f116d299d442b335bcd"},
+ {file = "multidict-6.1.0-cp38-cp38-win32.whl", hash = "sha256:e27bbb6d14416713a8bd7aaa1313c0fc8d44ee48d74497a0ff4c3a1b6ccb5167"},
+ {file = "multidict-6.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:22f3105d4fb15c8f57ff3959a58fcab6ce36814486500cd7485651230ad4d4ef"},
+ {file = "multidict-6.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:4e18b656c5e844539d506a0a06432274d7bd52a7487e6828c63a63d69185626c"},
+ {file = "multidict-6.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a185f876e69897a6f3325c3f19f26a297fa058c5e456bfcff8015e9a27e83ae1"},
+ {file = "multidict-6.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ab7c4ceb38d91570a650dba194e1ca87c2b543488fe9309b4212694174fd539c"},
+ {file = "multidict-6.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e617fb6b0b6953fffd762669610c1c4ffd05632c138d61ac7e14ad187870669c"},
+ {file = "multidict-6.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16e5f4bf4e603eb1fdd5d8180f1a25f30056f22e55ce51fb3d6ad4ab29f7d96f"},
+ {file = "multidict-6.1.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f4c035da3f544b1882bac24115f3e2e8760f10a0107614fc9839fd232200b875"},
+ {file = "multidict-6.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:957cf8e4b6e123a9eea554fa7ebc85674674b713551de587eb318a2df3e00255"},
+ {file = "multidict-6.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:483a6aea59cb89904e1ceabd2b47368b5600fb7de78a6e4a2c2987b2d256cf30"},
+ {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:87701f25a2352e5bf7454caa64757642734da9f6b11384c1f9d1a8e699758057"},
+ {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:682b987361e5fd7a139ed565e30d81fd81e9629acc7d925a205366877d8c8657"},
+ {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ce2186a7df133a9c895dea3331ddc5ddad42cdd0d1ea2f0a51e5d161e4762f28"},
+ {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:9f636b730f7e8cb19feb87094949ba54ee5357440b9658b2a32a5ce4bce53972"},
+ {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:73eae06aa53af2ea5270cc066dcaf02cc60d2994bbb2c4ef5764949257d10f43"},
+ {file = "multidict-6.1.0-cp39-cp39-win32.whl", hash = "sha256:1ca0083e80e791cffc6efce7660ad24af66c8d4079d2a750b29001b53ff59ada"},
+ {file = "multidict-6.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:aa466da5b15ccea564bdab9c89175c762bc12825f4659c11227f515cee76fa4a"},
+ {file = "multidict-6.1.0-py3-none-any.whl", hash = "sha256:48e171e52d1c4d33888e529b999e5900356b9ae588c2f09a52dcefb158b27506"},
+ {file = "multidict-6.1.0.tar.gz", hash = "sha256:22ae2ebf9b0c69d206c003e2f6a914ea33f0a932d4aa16f236afc049d9958f4a"},
+]
+
+[package.dependencies]
+typing-extensions = {version = ">=4.1.0", markers = "python_version < \"3.11\""}
+
+[[package]]
+name = "mypy"
+version = "1.13.0"
+description = "Optional static typing for Python"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "mypy-1.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6607e0f1dd1fb7f0aca14d936d13fd19eba5e17e1cd2a14f808fa5f8f6d8f60a"},
+ {file = "mypy-1.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8a21be69bd26fa81b1f80a61ee7ab05b076c674d9b18fb56239d72e21d9f4c80"},
+ {file = "mypy-1.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b2353a44d2179846a096e25691d54d59904559f4232519d420d64da6828a3a7"},
+ {file = "mypy-1.13.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0730d1c6a2739d4511dc4253f8274cdd140c55c32dfb0a4cf8b7a43f40abfa6f"},
+ {file = "mypy-1.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:c5fc54dbb712ff5e5a0fca797e6e0aa25726c7e72c6a5850cfd2adbc1eb0a372"},
+ {file = "mypy-1.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:581665e6f3a8a9078f28d5502f4c334c0c8d802ef55ea0e7276a6e409bc0d82d"},
+ {file = "mypy-1.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3ddb5b9bf82e05cc9a627e84707b528e5c7caaa1c55c69e175abb15a761cec2d"},
+ {file = "mypy-1.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20c7ee0bc0d5a9595c46f38beb04201f2620065a93755704e141fcac9f59db2b"},
+ {file = "mypy-1.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3790ded76f0b34bc9c8ba4def8f919dd6a46db0f5a6610fb994fe8efdd447f73"},
+ {file = "mypy-1.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51f869f4b6b538229c1d1bcc1dd7d119817206e2bc54e8e374b3dfa202defcca"},
+ {file = "mypy-1.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5c7051a3461ae84dfb5dd15eff5094640c61c5f22257c8b766794e6dd85e72d5"},
+ {file = "mypy-1.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:39bb21c69a5d6342f4ce526e4584bc5c197fd20a60d14a8624d8743fffb9472e"},
+ {file = "mypy-1.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:164f28cb9d6367439031f4c81e84d3ccaa1e19232d9d05d37cb0bd880d3f93c2"},
+ {file = "mypy-1.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a4c1bfcdbce96ff5d96fc9b08e3831acb30dc44ab02671eca5953eadad07d6d0"},
+ {file = "mypy-1.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0affb3a79a256b4183ba09811e3577c5163ed06685e4d4b46429a271ba174d2"},
+ {file = "mypy-1.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a7b44178c9760ce1a43f544e595d35ed61ac2c3de306599fa59b38a6048e1aa7"},
+ {file = "mypy-1.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d5092efb8516d08440e36626f0153b5006d4088c1d663d88bf79625af3d1d62"},
+ {file = "mypy-1.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2904956dac40ced10931ac967ae63c5089bd498542194b436eb097a9f77bc8"},
+ {file = "mypy-1.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:7bfd8836970d33c2105562650656b6846149374dc8ed77d98424b40b09340ba7"},
+ {file = "mypy-1.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9f73dba9ec77acb86457a8fc04b5239822df0c14a082564737833d2963677dbc"},
+ {file = "mypy-1.13.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:100fac22ce82925f676a734af0db922ecfea991e1d7ec0ceb1e115ebe501301a"},
+ {file = "mypy-1.13.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7bcb0bb7f42a978bb323a7c88f1081d1b5dee77ca86f4100735a6f541299d8fb"},
+ {file = "mypy-1.13.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bde31fc887c213e223bbfc34328070996061b0833b0a4cfec53745ed61f3519b"},
+ {file = "mypy-1.13.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:07de989f89786f62b937851295ed62e51774722e5444a27cecca993fc3f9cd74"},
+ {file = "mypy-1.13.0-cp38-cp38-win_amd64.whl", hash = "sha256:4bde84334fbe19bad704b3f5b78c4abd35ff1026f8ba72b29de70dda0916beb6"},
+ {file = "mypy-1.13.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0246bcb1b5de7f08f2826451abd947bf656945209b140d16ed317f65a17dc7dc"},
+ {file = "mypy-1.13.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7f5b7deae912cf8b77e990b9280f170381fdfbddf61b4ef80927edd813163732"},
+ {file = "mypy-1.13.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7029881ec6ffb8bc233a4fa364736789582c738217b133f1b55967115288a2bc"},
+ {file = "mypy-1.13.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:3e38b980e5681f28f033f3be86b099a247b13c491f14bb8b1e1e134d23bb599d"},
+ {file = "mypy-1.13.0-cp39-cp39-win_amd64.whl", hash = "sha256:a6789be98a2017c912ae6ccb77ea553bbaf13d27605d2ca20a76dfbced631b24"},
+ {file = "mypy-1.13.0-py3-none-any.whl", hash = "sha256:9c250883f9fd81d212e0952c92dbfcc96fc237f4b7c92f56ac81fd48460b3e5a"},
+ {file = "mypy-1.13.0.tar.gz", hash = "sha256:0291a61b6fbf3e6673e3405cfcc0e7650bebc7939659fdca2702958038bd835e"},
+]
+
+[package.dependencies]
+mypy-extensions = ">=1.0.0"
+tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
+typing-extensions = ">=4.6.0"
+
+[package.extras]
+dmypy = ["psutil (>=4.0)"]
+faster-cache = ["orjson"]
+install-types = ["pip"]
+mypyc = ["setuptools (>=50)"]
+reports = ["lxml"]
+
+[[package]]
+name = "mypy-extensions"
+version = "1.0.0"
+description = "Type system extensions for programs checked with the mypy type checker."
+optional = false
+python-versions = ">=3.5"
+files = [
+ {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"},
+ {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"},
+]
+
+[[package]]
+name = "nodeenv"
+version = "1.9.1"
+description = "Node.js virtual environment builder"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
+files = [
+ {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"},
+ {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"},
+]
+
+[[package]]
+name = "numpy"
+version = "1.26.4"
+description = "Fundamental package for array computing in Python"
+optional = false
+python-versions = ">=3.9"
+files = [
+ {file = "numpy-1.26.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9ff0f4f29c51e2803569d7a51c2304de5554655a60c5d776e35b4a41413830d0"},
+ {file = "numpy-1.26.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e4ee3380d6de9c9ec04745830fd9e2eccb3e6cf790d39d7b98ffd19b0dd754a"},
+ {file = "numpy-1.26.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d209d8969599b27ad20994c8e41936ee0964e6da07478d6c35016bc386b66ad4"},
+ {file = "numpy-1.26.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffa75af20b44f8dba823498024771d5ac50620e6915abac414251bd971b4529f"},
+ {file = "numpy-1.26.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:62b8e4b1e28009ef2846b4c7852046736bab361f7aeadeb6a5b89ebec3c7055a"},
+ {file = "numpy-1.26.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a4abb4f9001ad2858e7ac189089c42178fcce737e4169dc61321660f1a96c7d2"},
+ {file = "numpy-1.26.4-cp310-cp310-win32.whl", hash = "sha256:bfe25acf8b437eb2a8b2d49d443800a5f18508cd811fea3181723922a8a82b07"},
+ {file = "numpy-1.26.4-cp310-cp310-win_amd64.whl", hash = "sha256:b97fe8060236edf3662adfc2c633f56a08ae30560c56310562cb4f95500022d5"},
+ {file = "numpy-1.26.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c66707fabe114439db9068ee468c26bbdf909cac0fb58686a42a24de1760c71"},
+ {file = "numpy-1.26.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:edd8b5fe47dab091176d21bb6de568acdd906d1887a4584a15a9a96a1dca06ef"},
+ {file = "numpy-1.26.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ab55401287bfec946ced39700c053796e7cc0e3acbef09993a9ad2adba6ca6e"},
+ {file = "numpy-1.26.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:666dbfb6ec68962c033a450943ded891bed2d54e6755e35e5835d63f4f6931d5"},
+ {file = "numpy-1.26.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:96ff0b2ad353d8f990b63294c8986f1ec3cb19d749234014f4e7eb0112ceba5a"},
+ {file = "numpy-1.26.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:60dedbb91afcbfdc9bc0b1f3f402804070deed7392c23eb7a7f07fa857868e8a"},
+ {file = "numpy-1.26.4-cp311-cp311-win32.whl", hash = "sha256:1af303d6b2210eb850fcf03064d364652b7120803a0b872f5211f5234b399f20"},
+ {file = "numpy-1.26.4-cp311-cp311-win_amd64.whl", hash = "sha256:cd25bcecc4974d09257ffcd1f098ee778f7834c3ad767fe5db785be9a4aa9cb2"},
+ {file = "numpy-1.26.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b3ce300f3644fb06443ee2222c2201dd3a89ea6040541412b8fa189341847218"},
+ {file = "numpy-1.26.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:03a8c78d01d9781b28a6989f6fa1bb2c4f2d51201cf99d3dd875df6fbd96b23b"},
+ {file = "numpy-1.26.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9fad7dcb1aac3c7f0584a5a8133e3a43eeb2fe127f47e3632d43d677c66c102b"},
+ {file = "numpy-1.26.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:675d61ffbfa78604709862923189bad94014bef562cc35cf61d3a07bba02a7ed"},
+ {file = "numpy-1.26.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab47dbe5cc8210f55aa58e4805fe224dac469cde56b9f731a4c098b91917159a"},
+ {file = "numpy-1.26.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1dda2e7b4ec9dd512f84935c5f126c8bd8b9f2fc001e9f54af255e8c5f16b0e0"},
+ {file = "numpy-1.26.4-cp312-cp312-win32.whl", hash = "sha256:50193e430acfc1346175fcbdaa28ffec49947a06918b7b92130744e81e640110"},
+ {file = "numpy-1.26.4-cp312-cp312-win_amd64.whl", hash = "sha256:08beddf13648eb95f8d867350f6a018a4be2e5ad54c8d8caed89ebca558b2818"},
+ {file = "numpy-1.26.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7349ab0fa0c429c82442a27a9673fc802ffdb7c7775fad780226cb234965e53c"},
+ {file = "numpy-1.26.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:52b8b60467cd7dd1e9ed082188b4e6bb35aa5cdd01777621a1658910745b90be"},
+ {file = "numpy-1.26.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5241e0a80d808d70546c697135da2c613f30e28251ff8307eb72ba696945764"},
+ {file = "numpy-1.26.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f870204a840a60da0b12273ef34f7051e98c3b5961b61b0c2c1be6dfd64fbcd3"},
+ {file = "numpy-1.26.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:679b0076f67ecc0138fd2ede3a8fd196dddc2ad3254069bcb9faf9a79b1cebcd"},
+ {file = "numpy-1.26.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:47711010ad8555514b434df65f7d7b076bb8261df1ca9bb78f53d3b2db02e95c"},
+ {file = "numpy-1.26.4-cp39-cp39-win32.whl", hash = "sha256:a354325ee03388678242a4d7ebcd08b5c727033fcff3b2f536aea978e15ee9e6"},
+ {file = "numpy-1.26.4-cp39-cp39-win_amd64.whl", hash = "sha256:3373d5d70a5fe74a2c1bb6d2cfd9609ecf686d47a2d7b1d37a8f3b6bf6003aea"},
+ {file = "numpy-1.26.4-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:afedb719a9dcfc7eaf2287b839d8198e06dcd4cb5d276a3df279231138e83d30"},
+ {file = "numpy-1.26.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95a7476c59002f2f6c590b9b7b998306fba6a5aa646b1e22ddfeaf8f78c3a29c"},
+ {file = "numpy-1.26.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7e50d0a0cc3189f9cb0aeb3a6a6af18c16f59f004b866cd2be1c14b36134a4a0"},
+ {file = "numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010"},
+]
+
+[[package]]
+name = "packaging"
+version = "24.2"
+description = "Core utilities for Python packages"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"},
+ {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"},
+]
+
+[[package]]
+name = "pandas"
+version = "2.2.3"
+description = "Powerful data structures for data analysis, time series, and statistics"
+optional = false
+python-versions = ">=3.9"
+files = [
+ {file = "pandas-2.2.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1948ddde24197a0f7add2bdc4ca83bf2b1ef84a1bc8ccffd95eda17fd836ecb5"},
+ {file = "pandas-2.2.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:381175499d3802cde0eabbaf6324cce0c4f5d52ca6f8c377c29ad442f50f6348"},
+ {file = "pandas-2.2.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d9c45366def9a3dd85a6454c0e7908f2b3b8e9c138f5dc38fed7ce720d8453ed"},
+ {file = "pandas-2.2.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86976a1c5b25ae3f8ccae3a5306e443569ee3c3faf444dfd0f41cda24667ad57"},
+ {file = "pandas-2.2.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b8661b0238a69d7aafe156b7fa86c44b881387509653fdf857bebc5e4008ad42"},
+ {file = "pandas-2.2.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:37e0aced3e8f539eccf2e099f65cdb9c8aa85109b0be6e93e2baff94264bdc6f"},
+ {file = "pandas-2.2.3-cp310-cp310-win_amd64.whl", hash = "sha256:56534ce0746a58afaf7942ba4863e0ef81c9c50d3f0ae93e9497d6a41a057645"},
+ {file = "pandas-2.2.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66108071e1b935240e74525006034333f98bcdb87ea116de573a6a0dccb6c039"},
+ {file = "pandas-2.2.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7c2875855b0ff77b2a64a0365e24455d9990730d6431b9e0ee18ad8acee13dbd"},
+ {file = "pandas-2.2.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd8d0c3be0515c12fed0bdbae072551c8b54b7192c7b1fda0ba56059a0179698"},
+ {file = "pandas-2.2.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c124333816c3a9b03fbeef3a9f230ba9a737e9e5bb4060aa2107a86cc0a497fc"},
+ {file = "pandas-2.2.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:63cc132e40a2e084cf01adf0775b15ac515ba905d7dcca47e9a251819c575ef3"},
+ {file = "pandas-2.2.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:29401dbfa9ad77319367d36940cd8a0b3a11aba16063e39632d98b0e931ddf32"},
+ {file = "pandas-2.2.3-cp311-cp311-win_amd64.whl", hash = "sha256:3fc6873a41186404dad67245896a6e440baacc92f5b716ccd1bc9ed2995ab2c5"},
+ {file = "pandas-2.2.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b1d432e8d08679a40e2a6d8b2f9770a5c21793a6f9f47fdd52c5ce1948a5a8a9"},
+ {file = "pandas-2.2.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a5a1595fe639f5988ba6a8e5bc9649af3baf26df3998a0abe56c02609392e0a4"},
+ {file = "pandas-2.2.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5de54125a92bb4d1c051c0659e6fcb75256bf799a732a87184e5ea503965bce3"},
+ {file = "pandas-2.2.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fffb8ae78d8af97f849404f21411c95062db1496aeb3e56f146f0355c9989319"},
+ {file = "pandas-2.2.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dfcb5ee8d4d50c06a51c2fffa6cff6272098ad6540aed1a76d15fb9318194d8"},
+ {file = "pandas-2.2.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:062309c1b9ea12a50e8ce661145c6aab431b1e99530d3cd60640e255778bd43a"},
+ {file = "pandas-2.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:59ef3764d0fe818125a5097d2ae867ca3fa64df032331b7e0917cf5d7bf66b13"},
+ {file = "pandas-2.2.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f00d1345d84d8c86a63e476bb4955e46458b304b9575dcf71102b5c705320015"},
+ {file = "pandas-2.2.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3508d914817e153ad359d7e069d752cdd736a247c322d932eb89e6bc84217f28"},
+ {file = "pandas-2.2.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22a9d949bfc9a502d320aa04e5d02feab689d61da4e7764b62c30b991c42c5f0"},
+ {file = "pandas-2.2.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3a255b2c19987fbbe62a9dfd6cff7ff2aa9ccab3fc75218fd4b7530f01efa24"},
+ {file = "pandas-2.2.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:800250ecdadb6d9c78eae4990da62743b857b470883fa27f652db8bdde7f6659"},
+ {file = "pandas-2.2.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6374c452ff3ec675a8f46fd9ab25c4ad0ba590b71cf0656f8b6daa5202bca3fb"},
+ {file = "pandas-2.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:61c5ad4043f791b61dd4752191d9f07f0ae412515d59ba8f005832a532f8736d"},
+ {file = "pandas-2.2.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3b71f27954685ee685317063bf13c7709a7ba74fc996b84fc6821c59b0f06468"},
+ {file = "pandas-2.2.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:38cf8125c40dae9d5acc10fa66af8ea6fdf760b2714ee482ca691fc66e6fcb18"},
+ {file = "pandas-2.2.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ba96630bc17c875161df3818780af30e43be9b166ce51c9a18c1feae342906c2"},
+ {file = "pandas-2.2.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db71525a1538b30142094edb9adc10be3f3e176748cd7acc2240c2f2e5aa3a4"},
+ {file = "pandas-2.2.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:15c0e1e02e93116177d29ff83e8b1619c93ddc9c49083f237d4312337a61165d"},
+ {file = "pandas-2.2.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ad5b65698ab28ed8d7f18790a0dc58005c7629f227be9ecc1072aa74c0c1d43a"},
+ {file = "pandas-2.2.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:bc6b93f9b966093cb0fd62ff1a7e4c09e6d546ad7c1de191767baffc57628f39"},
+ {file = "pandas-2.2.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5dbca4c1acd72e8eeef4753eeca07de9b1db4f398669d5994086f788a5d7cc30"},
+ {file = "pandas-2.2.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8cd6d7cc958a3910f934ea8dbdf17b2364827bb4dafc38ce6eef6bb3d65ff09c"},
+ {file = "pandas-2.2.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99df71520d25fade9db7c1076ac94eb994f4d2673ef2aa2e86ee039b6746d20c"},
+ {file = "pandas-2.2.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:31d0ced62d4ea3e231a9f228366919a5ea0b07440d9d4dac345376fd8e1477ea"},
+ {file = "pandas-2.2.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7eee9e7cea6adf3e3d24e304ac6b8300646e2a5d1cd3a3c2abed9101b0846761"},
+ {file = "pandas-2.2.3-cp39-cp39-win_amd64.whl", hash = "sha256:4850ba03528b6dd51d6c5d273c46f183f39a9baf3f0143e566b89450965b105e"},
+ {file = "pandas-2.2.3.tar.gz", hash = "sha256:4f18ba62b61d7e192368b84517265a99b4d7ee8912f8708660fb4a366cc82667"},
+]
+
+[package.dependencies]
+numpy = [
+ {version = ">=1.22.4", markers = "python_version < \"3.11\""},
+ {version = ">=1.23.2", markers = "python_version == \"3.11\""},
+ {version = ">=1.26.0", markers = "python_version >= \"3.12\""},
+]
+python-dateutil = ">=2.8.2"
+pytz = ">=2020.1"
+tzdata = ">=2022.7"
+
+[package.extras]
+all = ["PyQt5 (>=5.15.9)", "SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "adbc-driver-sqlite (>=0.8.0)", "beautifulsoup4 (>=4.11.2)", "bottleneck (>=1.3.6)", "dataframe-api-compat (>=0.1.7)", "fastparquet (>=2022.12.0)", "fsspec (>=2022.11.0)", "gcsfs (>=2022.11.0)", "html5lib (>=1.1)", "hypothesis (>=6.46.1)", "jinja2 (>=3.1.2)", "lxml (>=4.9.2)", "matplotlib (>=3.6.3)", "numba (>=0.56.4)", "numexpr (>=2.8.4)", "odfpy (>=1.4.1)", "openpyxl (>=3.1.0)", "pandas-gbq (>=0.19.0)", "psycopg2 (>=2.9.6)", "pyarrow (>=10.0.1)", "pymysql (>=1.0.2)", "pyreadstat (>=1.2.0)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)", "python-calamine (>=0.1.7)", "pyxlsb (>=1.0.10)", "qtpy (>=2.3.0)", "s3fs (>=2022.11.0)", "scipy (>=1.10.0)", "tables (>=3.8.0)", "tabulate (>=0.9.0)", "xarray (>=2022.12.0)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.5)", "zstandard (>=0.19.0)"]
+aws = ["s3fs (>=2022.11.0)"]
+clipboard = ["PyQt5 (>=5.15.9)", "qtpy (>=2.3.0)"]
+compression = ["zstandard (>=0.19.0)"]
+computation = ["scipy (>=1.10.0)", "xarray (>=2022.12.0)"]
+consortium-standard = ["dataframe-api-compat (>=0.1.7)"]
+excel = ["odfpy (>=1.4.1)", "openpyxl (>=3.1.0)", "python-calamine (>=0.1.7)", "pyxlsb (>=1.0.10)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.5)"]
+feather = ["pyarrow (>=10.0.1)"]
+fss = ["fsspec (>=2022.11.0)"]
+gcp = ["gcsfs (>=2022.11.0)", "pandas-gbq (>=0.19.0)"]
+hdf5 = ["tables (>=3.8.0)"]
+html = ["beautifulsoup4 (>=4.11.2)", "html5lib (>=1.1)", "lxml (>=4.9.2)"]
+mysql = ["SQLAlchemy (>=2.0.0)", "pymysql (>=1.0.2)"]
+output-formatting = ["jinja2 (>=3.1.2)", "tabulate (>=0.9.0)"]
+parquet = ["pyarrow (>=10.0.1)"]
+performance = ["bottleneck (>=1.3.6)", "numba (>=0.56.4)", "numexpr (>=2.8.4)"]
+plot = ["matplotlib (>=3.6.3)"]
+postgresql = ["SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "psycopg2 (>=2.9.6)"]
+pyarrow = ["pyarrow (>=10.0.1)"]
+spss = ["pyreadstat (>=1.2.0)"]
+sql-other = ["SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "adbc-driver-sqlite (>=0.8.0)"]
+test = ["hypothesis (>=6.46.1)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)"]
+xml = ["lxml (>=4.9.2)"]
+
+[[package]]
+name = "pandas-stubs"
+version = "2.2.2.240807"
+description = "Type annotations for pandas"
+optional = false
+python-versions = ">=3.9"
+files = [
+ {file = "pandas_stubs-2.2.2.240807-py3-none-any.whl", hash = "sha256:893919ad82be4275f0d07bb47a95d08bae580d3fdea308a7acfcb3f02e76186e"},
+ {file = "pandas_stubs-2.2.2.240807.tar.gz", hash = "sha256:64a559725a57a449f46225fbafc422520b7410bff9252b661a225b5559192a93"},
+]
+
+[package.dependencies]
+numpy = ">=1.23.5"
+types-pytz = ">=2022.1.1"
+
+[[package]]
+name = "platformdirs"
+version = "4.3.6"
+description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"},
+ {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"},
+]
+
+[package.extras]
+docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"]
+test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"]
+type = ["mypy (>=1.11.2)"]
+
+[[package]]
+name = "pluggy"
+version = "1.5.0"
+description = "plugin and hook calling mechanisms for python"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"},
+ {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"},
+]
+
+[package.extras]
+dev = ["pre-commit", "tox"]
+testing = ["pytest", "pytest-benchmark"]
+
+[[package]]
+name = "polars"
+version = "1.16.0"
+description = "Blazingly fast DataFrame library"
+optional = false
+python-versions = ">=3.9"
+files = [
+ {file = "polars-1.16.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:072f5ff3b5fe05797c59890de0e464b34ede75a9735e7d7221622fa3a0616d8e"},
+ {file = "polars-1.16.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ebaf7a1ea114b042fa9f1cd17d49436279eb30545dd74361a2f5e3febeb867cd"},
+ {file = "polars-1.16.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e626d21dcd2566e1442dac414fe177bc70ebfc2f16620d59d778b1b774361018"},
+ {file = "polars-1.16.0-cp39-abi3-manylinux_2_24_aarch64.whl", hash = "sha256:53debcce55f68731ee2c7d6c787afdee26860ed6576f1ffa0cb9111b57f82857"},
+ {file = "polars-1.16.0-cp39-abi3-win_amd64.whl", hash = "sha256:17efcb550c42d51034ff79702612b9184d8eac0d500de1dd7fb98490459276d3"},
+ {file = "polars-1.16.0.tar.gz", hash = "sha256:dd99808b833872babe02434a809fd45c1cffe66a3d57123cdc5e447c7753d328"},
+]
+
+[package.extras]
+adbc = ["adbc-driver-manager[dbapi]", "adbc-driver-sqlite[dbapi]"]
+all = ["polars[async,cloudpickle,database,deltalake,excel,fsspec,graph,iceberg,numpy,pandas,plot,pyarrow,pydantic,style,timezone]"]
+async = ["gevent"]
+calamine = ["fastexcel (>=0.9)"]
+cloudpickle = ["cloudpickle"]
+connectorx = ["connectorx (>=0.3.2)"]
+database = ["nest-asyncio", "polars[adbc,connectorx,sqlalchemy]"]
+deltalake = ["deltalake (>=0.15.0)"]
+excel = ["polars[calamine,openpyxl,xlsx2csv,xlsxwriter]"]
+fsspec = ["fsspec"]
+gpu = ["cudf-polars-cu12"]
+graph = ["matplotlib"]
+iceberg = ["pyiceberg (>=0.5.0)"]
+numpy = ["numpy (>=1.16.0)"]
+openpyxl = ["openpyxl (>=3.0.0)"]
+pandas = ["pandas", "polars[pyarrow]"]
+plot = ["altair (>=5.4.0)"]
+pyarrow = ["pyarrow (>=7.0.0)"]
+pydantic = ["pydantic"]
+sqlalchemy = ["polars[pandas]", "sqlalchemy"]
+style = ["great-tables (>=0.8.0)"]
+timezone = ["backports-zoneinfo", "tzdata"]
+xlsx2csv = ["xlsx2csv (>=0.8.0)"]
+xlsxwriter = ["xlsxwriter"]
+
+[[package]]
+name = "pre-commit"
+version = "3.8.0"
+description = "A framework for managing and maintaining multi-language pre-commit hooks."
+optional = false
+python-versions = ">=3.9"
+files = [
+ {file = "pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f"},
+ {file = "pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af"},
+]
+
+[package.dependencies]
+cfgv = ">=2.0.0"
+identify = ">=1.0.0"
+nodeenv = ">=0.11.1"
+pyyaml = ">=5.1"
+virtualenv = ">=20.10.0"
+
+[[package]]
+name = "propcache"
+version = "0.2.1"
+description = "Accelerated property cache"
+optional = false
+python-versions = ">=3.9"
+files = [
+ {file = "propcache-0.2.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6b3f39a85d671436ee3d12c017f8fdea38509e4f25b28eb25877293c98c243f6"},
+ {file = "propcache-0.2.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:39d51fbe4285d5db5d92a929e3e21536ea3dd43732c5b177c7ef03f918dff9f2"},
+ {file = "propcache-0.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6445804cf4ec763dc70de65a3b0d9954e868609e83850a47ca4f0cb64bd79fea"},
+ {file = "propcache-0.2.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9479aa06a793c5aeba49ce5c5692ffb51fcd9a7016e017d555d5e2b0045d212"},
+ {file = "propcache-0.2.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d9631c5e8b5b3a0fda99cb0d29c18133bca1e18aea9effe55adb3da1adef80d3"},
+ {file = "propcache-0.2.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3156628250f46a0895f1f36e1d4fbe062a1af8718ec3ebeb746f1d23f0c5dc4d"},
+ {file = "propcache-0.2.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b6fb63ae352e13748289f04f37868099e69dba4c2b3e271c46061e82c745634"},
+ {file = "propcache-0.2.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:887d9b0a65404929641a9fabb6452b07fe4572b269d901d622d8a34a4e9043b2"},
+ {file = "propcache-0.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a96dc1fa45bd8c407a0af03b2d5218392729e1822b0c32e62c5bf7eeb5fb3958"},
+ {file = "propcache-0.2.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:a7e65eb5c003a303b94aa2c3852ef130230ec79e349632d030e9571b87c4698c"},
+ {file = "propcache-0.2.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:999779addc413181912e984b942fbcc951be1f5b3663cd80b2687758f434c583"},
+ {file = "propcache-0.2.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:19a0f89a7bb9d8048d9c4370c9c543c396e894c76be5525f5e1ad287f1750ddf"},
+ {file = "propcache-0.2.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:1ac2f5fe02fa75f56e1ad473f1175e11f475606ec9bd0be2e78e4734ad575034"},
+ {file = "propcache-0.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:574faa3b79e8ebac7cb1d7930f51184ba1ccf69adfdec53a12f319a06030a68b"},
+ {file = "propcache-0.2.1-cp310-cp310-win32.whl", hash = "sha256:03ff9d3f665769b2a85e6157ac8b439644f2d7fd17615a82fa55739bc97863f4"},
+ {file = "propcache-0.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:2d3af2e79991102678f53e0dbf4c35de99b6b8b58f29a27ca0325816364caaba"},
+ {file = "propcache-0.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ffc3cca89bb438fb9c95c13fc874012f7b9466b89328c3c8b1aa93cdcfadd16"},
+ {file = "propcache-0.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f174bbd484294ed9fdf09437f889f95807e5f229d5d93588d34e92106fbf6717"},
+ {file = "propcache-0.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:70693319e0b8fd35dd863e3e29513875eb15c51945bf32519ef52927ca883bc3"},
+ {file = "propcache-0.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b480c6a4e1138e1aa137c0079b9b6305ec6dcc1098a8ca5196283e8a49df95a9"},
+ {file = "propcache-0.2.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d27b84d5880f6d8aa9ae3edb253c59d9f6642ffbb2c889b78b60361eed449787"},
+ {file = "propcache-0.2.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:857112b22acd417c40fa4595db2fe28ab900c8c5fe4670c7989b1c0230955465"},
+ {file = "propcache-0.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cf6c4150f8c0e32d241436526f3c3f9cbd34429492abddbada2ffcff506c51af"},
+ {file = "propcache-0.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66d4cfda1d8ed687daa4bc0274fcfd5267873db9a5bc0418c2da19273040eeb7"},
+ {file = "propcache-0.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2f992c07c0fca81655066705beae35fc95a2fa7366467366db627d9f2ee097f"},
+ {file = "propcache-0.2.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:4a571d97dbe66ef38e472703067021b1467025ec85707d57e78711c085984e54"},
+ {file = "propcache-0.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:bb6178c241278d5fe853b3de743087be7f5f4c6f7d6d22a3b524d323eecec505"},
+ {file = "propcache-0.2.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ad1af54a62ffe39cf34db1aa6ed1a1873bd548f6401db39d8e7cd060b9211f82"},
+ {file = "propcache-0.2.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e7048abd75fe40712005bcfc06bb44b9dfcd8e101dda2ecf2f5aa46115ad07ca"},
+ {file = "propcache-0.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:160291c60081f23ee43d44b08a7e5fb76681221a8e10b3139618c5a9a291b84e"},
+ {file = "propcache-0.2.1-cp311-cp311-win32.whl", hash = "sha256:819ce3b883b7576ca28da3861c7e1a88afd08cc8c96908e08a3f4dd64a228034"},
+ {file = "propcache-0.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:edc9fc7051e3350643ad929df55c451899bb9ae6d24998a949d2e4c87fb596d3"},
+ {file = "propcache-0.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:081a430aa8d5e8876c6909b67bd2d937bfd531b0382d3fdedb82612c618bc41a"},
+ {file = "propcache-0.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d2ccec9ac47cf4e04897619c0e0c1a48c54a71bdf045117d3a26f80d38ab1fb0"},
+ {file = "propcache-0.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:14d86fe14b7e04fa306e0c43cdbeebe6b2c2156a0c9ce56b815faacc193e320d"},
+ {file = "propcache-0.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:049324ee97bb67285b49632132db351b41e77833678432be52bdd0289c0e05e4"},
+ {file = "propcache-0.2.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1cd9a1d071158de1cc1c71a26014dcdfa7dd3d5f4f88c298c7f90ad6f27bb46d"},
+ {file = "propcache-0.2.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98110aa363f1bb4c073e8dcfaefd3a5cea0f0834c2aab23dda657e4dab2f53b5"},
+ {file = "propcache-0.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:647894f5ae99c4cf6bb82a1bb3a796f6e06af3caa3d32e26d2350d0e3e3faf24"},
+ {file = "propcache-0.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfd3223c15bebe26518d58ccf9a39b93948d3dcb3e57a20480dfdd315356baff"},
+ {file = "propcache-0.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d71264a80f3fcf512eb4f18f59423fe82d6e346ee97b90625f283df56aee103f"},
+ {file = "propcache-0.2.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e73091191e4280403bde6c9a52a6999d69cdfde498f1fdf629105247599b57ec"},
+ {file = "propcache-0.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3935bfa5fede35fb202c4b569bb9c042f337ca4ff7bd540a0aa5e37131659348"},
+ {file = "propcache-0.2.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f508b0491767bb1f2b87fdfacaba5f7eddc2f867740ec69ece6d1946d29029a6"},
+ {file = "propcache-0.2.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1672137af7c46662a1c2be1e8dc78cb6d224319aaa40271c9257d886be4363a6"},
+ {file = "propcache-0.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b74c261802d3d2b85c9df2dfb2fa81b6f90deeef63c2db9f0e029a3cac50b518"},
+ {file = "propcache-0.2.1-cp312-cp312-win32.whl", hash = "sha256:d09c333d36c1409d56a9d29b3a1b800a42c76a57a5a8907eacdbce3f18768246"},
+ {file = "propcache-0.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:c214999039d4f2a5b2073ac506bba279945233da8c786e490d411dfc30f855c1"},
+ {file = "propcache-0.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aca405706e0b0a44cc6bfd41fbe89919a6a56999157f6de7e182a990c36e37bc"},
+ {file = "propcache-0.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:12d1083f001ace206fe34b6bdc2cb94be66d57a850866f0b908972f90996b3e9"},
+ {file = "propcache-0.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d93f3307ad32a27bda2e88ec81134b823c240aa3abb55821a8da553eed8d9439"},
+ {file = "propcache-0.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba278acf14471d36316159c94a802933d10b6a1e117b8554fe0d0d9b75c9d536"},
+ {file = "propcache-0.2.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4e6281aedfca15301c41f74d7005e6e3f4ca143584ba696ac69df4f02f40d629"},
+ {file = "propcache-0.2.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5b750a8e5a1262434fb1517ddf64b5de58327f1adc3524a5e44c2ca43305eb0b"},
+ {file = "propcache-0.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf72af5e0fb40e9babf594308911436c8efde3cb5e75b6f206c34ad18be5c052"},
+ {file = "propcache-0.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2d0a12018b04f4cb820781ec0dffb5f7c7c1d2a5cd22bff7fb055a2cb19ebce"},
+ {file = "propcache-0.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e800776a79a5aabdb17dcc2346a7d66d0777e942e4cd251defeb084762ecd17d"},
+ {file = "propcache-0.2.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:4160d9283bd382fa6c0c2b5e017acc95bc183570cd70968b9202ad6d8fc48dce"},
+ {file = "propcache-0.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:30b43e74f1359353341a7adb783c8f1b1c676367b011709f466f42fda2045e95"},
+ {file = "propcache-0.2.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:58791550b27d5488b1bb52bc96328456095d96206a250d28d874fafe11b3dfaf"},
+ {file = "propcache-0.2.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:0f022d381747f0dfe27e99d928e31bc51a18b65bb9e481ae0af1380a6725dd1f"},
+ {file = "propcache-0.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:297878dc9d0a334358f9b608b56d02e72899f3b8499fc6044133f0d319e2ec30"},
+ {file = "propcache-0.2.1-cp313-cp313-win32.whl", hash = "sha256:ddfab44e4489bd79bda09d84c430677fc7f0a4939a73d2bba3073036f487a0a6"},
+ {file = "propcache-0.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:556fc6c10989f19a179e4321e5d678db8eb2924131e64652a51fe83e4c3db0e1"},
+ {file = "propcache-0.2.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:6a9a8c34fb7bb609419a211e59da8887eeca40d300b5ea8e56af98f6fbbb1541"},
+ {file = "propcache-0.2.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ae1aa1cd222c6d205853b3013c69cd04515f9d6ab6de4b0603e2e1c33221303e"},
+ {file = "propcache-0.2.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:accb6150ce61c9c4b7738d45550806aa2b71c7668c6942f17b0ac182b6142fd4"},
+ {file = "propcache-0.2.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5eee736daafa7af6d0a2dc15cc75e05c64f37fc37bafef2e00d77c14171c2097"},
+ {file = "propcache-0.2.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7a31fc1e1bd362874863fdeed71aed92d348f5336fd84f2197ba40c59f061bd"},
+ {file = "propcache-0.2.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cba4cfa1052819d16699e1d55d18c92b6e094d4517c41dd231a8b9f87b6fa681"},
+ {file = "propcache-0.2.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f089118d584e859c62b3da0892b88a83d611c2033ac410e929cb6754eec0ed16"},
+ {file = "propcache-0.2.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:781e65134efaf88feb447e8c97a51772aa75e48b794352f94cb7ea717dedda0d"},
+ {file = "propcache-0.2.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:31f5af773530fd3c658b32b6bdc2d0838543de70eb9a2156c03e410f7b0d3aae"},
+ {file = "propcache-0.2.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:a7a078f5d37bee6690959c813977da5291b24286e7b962e62a94cec31aa5188b"},
+ {file = "propcache-0.2.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cea7daf9fc7ae6687cf1e2c049752f19f146fdc37c2cc376e7d0032cf4f25347"},
+ {file = "propcache-0.2.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:8b3489ff1ed1e8315674d0775dc7d2195fb13ca17b3808721b54dbe9fd020faf"},
+ {file = "propcache-0.2.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:9403db39be1393618dd80c746cb22ccda168efce239c73af13c3763ef56ffc04"},
+ {file = "propcache-0.2.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5d97151bc92d2b2578ff7ce779cdb9174337390a535953cbb9452fb65164c587"},
+ {file = "propcache-0.2.1-cp39-cp39-win32.whl", hash = "sha256:9caac6b54914bdf41bcc91e7eb9147d331d29235a7c967c150ef5df6464fd1bb"},
+ {file = "propcache-0.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:92fc4500fcb33899b05ba73276dfb684a20d31caa567b7cb5252d48f896a91b1"},
+ {file = "propcache-0.2.1-py3-none-any.whl", hash = "sha256:52277518d6aae65536e9cea52d4e7fd2f7a66f4aa2d30ed3f2fcea620ace3c54"},
+ {file = "propcache-0.2.1.tar.gz", hash = "sha256:3f77ce728b19cb537714499928fe800c3dda29e8d9428778fc7c186da4c09a64"},
+]
+
+[[package]]
+name = "pyarrow"
+version = "18.1.0"
+description = "Python library for Apache Arrow"
+optional = false
+python-versions = ">=3.9"
+files = [
+ {file = "pyarrow-18.1.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:e21488d5cfd3d8b500b3238a6c4b075efabc18f0f6d80b29239737ebd69caa6c"},
+ {file = "pyarrow-18.1.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:b516dad76f258a702f7ca0250885fc93d1fa5ac13ad51258e39d402bd9e2e1e4"},
+ {file = "pyarrow-18.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f443122c8e31f4c9199cb23dca29ab9427cef990f283f80fe15b8e124bcc49b"},
+ {file = "pyarrow-18.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0a03da7f2758645d17b7b4f83c8bffeae5bbb7f974523fe901f36288d2eab71"},
+ {file = "pyarrow-18.1.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:ba17845efe3aa358ec266cf9cc2800fa73038211fb27968bfa88acd09261a470"},
+ {file = "pyarrow-18.1.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:3c35813c11a059056a22a3bef520461310f2f7eea5c8a11ef9de7062a23f8d56"},
+ {file = "pyarrow-18.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:9736ba3c85129d72aefa21b4f3bd715bc4190fe4426715abfff90481e7d00812"},
+ {file = "pyarrow-18.1.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:eaeabf638408de2772ce3d7793b2668d4bb93807deed1725413b70e3156a7854"},
+ {file = "pyarrow-18.1.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:3b2e2239339c538f3464308fd345113f886ad031ef8266c6f004d49769bb074c"},
+ {file = "pyarrow-18.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f39a2e0ed32a0970e4e46c262753417a60c43a3246972cfc2d3eb85aedd01b21"},
+ {file = "pyarrow-18.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e31e9417ba9c42627574bdbfeada7217ad8a4cbbe45b9d6bdd4b62abbca4c6f6"},
+ {file = "pyarrow-18.1.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:01c034b576ce0eef554f7c3d8c341714954be9b3f5d5bc7117006b85fcf302fe"},
+ {file = "pyarrow-18.1.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:f266a2c0fc31995a06ebd30bcfdb7f615d7278035ec5b1cd71c48d56daaf30b0"},
+ {file = "pyarrow-18.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:d4f13eee18433f99adefaeb7e01d83b59f73360c231d4782d9ddfaf1c3fbde0a"},
+ {file = "pyarrow-18.1.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:9f3a76670b263dc41d0ae877f09124ab96ce10e4e48f3e3e4257273cee61ad0d"},
+ {file = "pyarrow-18.1.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:da31fbca07c435be88a0c321402c4e31a2ba61593ec7473630769de8346b54ee"},
+ {file = "pyarrow-18.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:543ad8459bc438efc46d29a759e1079436290bd583141384c6f7a1068ed6f992"},
+ {file = "pyarrow-18.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0743e503c55be0fdb5c08e7d44853da27f19dc854531c0570f9f394ec9671d54"},
+ {file = "pyarrow-18.1.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:d4b3d2a34780645bed6414e22dda55a92e0fcd1b8a637fba86800ad737057e33"},
+ {file = "pyarrow-18.1.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:c52f81aa6f6575058d8e2c782bf79d4f9fdc89887f16825ec3a66607a5dd8e30"},
+ {file = "pyarrow-18.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:0ad4892617e1a6c7a551cfc827e072a633eaff758fa09f21c4ee548c30bcaf99"},
+ {file = "pyarrow-18.1.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:84e314d22231357d473eabec709d0ba285fa706a72377f9cc8e1cb3c8013813b"},
+ {file = "pyarrow-18.1.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:f591704ac05dfd0477bb8f8e0bd4b5dc52c1cadf50503858dce3a15db6e46ff2"},
+ {file = "pyarrow-18.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acb7564204d3c40babf93a05624fc6a8ec1ab1def295c363afc40b0c9e66c191"},
+ {file = "pyarrow-18.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:74de649d1d2ccb778f7c3afff6085bd5092aed4c23df9feeb45dd6b16f3811aa"},
+ {file = "pyarrow-18.1.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:f96bd502cb11abb08efea6dab09c003305161cb6c9eafd432e35e76e7fa9b90c"},
+ {file = "pyarrow-18.1.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:36ac22d7782554754a3b50201b607d553a8d71b78cdf03b33c1125be4b52397c"},
+ {file = "pyarrow-18.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:25dbacab8c5952df0ca6ca0af28f50d45bd31c1ff6fcf79e2d120b4a65ee7181"},
+ {file = "pyarrow-18.1.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:6a276190309aba7bc9d5bd2933230458b3521a4317acfefe69a354f2fe59f2bc"},
+ {file = "pyarrow-18.1.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:ad514dbfcffe30124ce655d72771ae070f30bf850b48bc4d9d3b25993ee0e386"},
+ {file = "pyarrow-18.1.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aebc13a11ed3032d8dd6e7171eb6e86d40d67a5639d96c35142bd568b9299324"},
+ {file = "pyarrow-18.1.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d6cf5c05f3cee251d80e98726b5c7cc9f21bab9e9783673bac58e6dfab57ecc8"},
+ {file = "pyarrow-18.1.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:11b676cd410cf162d3f6a70b43fb9e1e40affbc542a1e9ed3681895f2962d3d9"},
+ {file = "pyarrow-18.1.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:b76130d835261b38f14fc41fdfb39ad8d672afb84c447126b84d5472244cfaba"},
+ {file = "pyarrow-18.1.0-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:0b331e477e40f07238adc7ba7469c36b908f07c89b95dd4bd3a0ec84a3d1e21e"},
+ {file = "pyarrow-18.1.0-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:2c4dd0c9010a25ba03e198fe743b1cc03cd33c08190afff371749c52ccbbaf76"},
+ {file = "pyarrow-18.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f97b31b4c4e21ff58c6f330235ff893cc81e23da081b1a4b1c982075e0ed4e9"},
+ {file = "pyarrow-18.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a4813cb8ecf1809871fd2d64a8eff740a1bd3691bbe55f01a3cf6c5ec869754"},
+ {file = "pyarrow-18.1.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:05a5636ec3eb5cc2a36c6edb534a38ef57b2ab127292a716d00eabb887835f1e"},
+ {file = "pyarrow-18.1.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:73eeed32e724ea3568bb06161cad5fa7751e45bc2228e33dcb10c614044165c7"},
+ {file = "pyarrow-18.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:a1880dd6772b685e803011a6b43a230c23b566859a6e0c9a276c1e0faf4f4052"},
+ {file = "pyarrow-18.1.0.tar.gz", hash = "sha256:9386d3ca9c145b5539a1cfc75df07757dff870168c959b473a0bccbc3abc8c73"},
+]
+
+[package.extras]
+test = ["cffi", "hypothesis", "pandas", "pytest", "pytz"]
+
+[[package]]
+name = "pygments"
+version = "2.18.0"
+description = "Pygments is a syntax highlighting package written in Python."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"},
+ {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"},
+]
+
+[package.extras]
+windows-terminal = ["colorama (>=0.4.6)"]
+
+[[package]]
+name = "pyinstrument"
+version = "5.0.0"
+description = "Call stack profiler for Python. Shows you why your code is slow!"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "pyinstrument-5.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6a83cf18f5594e1b1899b12b46df7aabca556eef895846ccdaaa3a46a37d1274"},
+ {file = "pyinstrument-5.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1cc236313272d0222261be8e2b2a08e42d7ccbe54db9059babf4d77040da1880"},
+ {file = "pyinstrument-5.0.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6dd685d68a31f3715ca61f82c37c1c2f8b75f45646bd9840e04681d91862bd85"},
+ {file = "pyinstrument-5.0.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4cecd0f6558f13fba74a9f036b2b168956206e9525dcb84c6add2d73ab61dc22"},
+ {file = "pyinstrument-5.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40a8485c2e41082a20822001a6651667bb5327f6f5f6759987198593e45bb376"},
+ {file = "pyinstrument-5.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a6294b7111348765ba4c311fc91821ed8b59c6690c4dab23aa7165a67da9e972"},
+ {file = "pyinstrument-5.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:a164f3dae5c7db2faa501639659d64034cde8db62a4d6744712593a369bc8629"},
+ {file = "pyinstrument-5.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6f6bac8a434407de6f2ebddbcdecdb19b324c9315cbb8b8c2352714f7ced8181"},
+ {file = "pyinstrument-5.0.0-cp310-cp310-win32.whl", hash = "sha256:7e8dc887e535f5c5e5a2a64a0729496f11ddcef0c23b0a555d5ab6fa19759445"},
+ {file = "pyinstrument-5.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:0c337190a1818841732643ba93065411591df526bc9de44b97ba8f56b581d2ef"},
+ {file = "pyinstrument-5.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c9052f548ec5ccecc50676fbf1a1d0b60bdbd3cd67630c5253099af049d1f0ad"},
+ {file = "pyinstrument-5.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:197d25487f52da3f8ec26d46db7202bc5d703cc73c1503371166417eb7cea14e"},
+ {file = "pyinstrument-5.0.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a072d928dc16a32e0f3d1e51726f4472a69d66d838ee1d1bf248737fd70b9415"},
+ {file = "pyinstrument-5.0.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c2c7ae2c984879a645fce583bf3053b7e57495f60c1e158bb71ad7dfced1fbf1"},
+ {file = "pyinstrument-5.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8284bf8847629c9a5054702b9306eab3ab14c2474959e01e606369ffbcf938bc"},
+ {file = "pyinstrument-5.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4fd94cc725efb1dd41ae8e20a5f06a6a5363dec959e8a9dacbac3f4d12d28f03"},
+ {file = "pyinstrument-5.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e0fdb9fe6f9c694940410dcc82e23a3fe2928114328efd35047fc0bb8a6c959f"},
+ {file = "pyinstrument-5.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9ffe938e63173ceb8ce7b6b309ce26c9d44d16f53c0162d89d6e706eb9e69802"},
+ {file = "pyinstrument-5.0.0-cp311-cp311-win32.whl", hash = "sha256:80d2a248516f372a89e0fe9ddf4a9d6388a4c6481b6ebd3dfe01b3cd028c0275"},
+ {file = "pyinstrument-5.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:7ccf4267aff62de0e1d976e8f5da25dcb69737ae86e38d3cfffa24877837e7d1"},
+ {file = "pyinstrument-5.0.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:dec3529a5351ea160baeef1ef2a6e28b1a7a7b3fb5e9863fae8de6da73d0f69a"},
+ {file = "pyinstrument-5.0.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5a39e3ef84c56183f8274dfd584b8c2fae4783c6204f880513e70ab2440b9137"},
+ {file = "pyinstrument-5.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3938f063ee065e05826628dadf1fb32c7d26b22df4a945c22f7fe25ea1ba6a2"},
+ {file = "pyinstrument-5.0.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f18990cc16b2e23b54738aa2f222863e1d36daaaec8f67b1613ddfa41f5b24db"},
+ {file = "pyinstrument-5.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3731412b5bfdcef8014518f145140c69384793e218863a33a39ccfe5fb42045"},
+ {file = "pyinstrument-5.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:02b2eaf38460b14eea646d6bb7f373eb5bb5691d13f788e80bdcb3a4eaa2519e"},
+ {file = "pyinstrument-5.0.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e57db06590f13657b2bce8c4d9cf8e9e2bd90bb729bcbbe421c531ba67ad7add"},
+ {file = "pyinstrument-5.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ddaa3001c1b798ec9bf1266ef476bbc0834b74d547d531f5ed99e7d05ac5d81b"},
+ {file = "pyinstrument-5.0.0-cp312-cp312-win32.whl", hash = "sha256:b69ff982acf5ef2f4e0f32ce9b4b598f256faf88438f233ea3a72f1042707e5b"},
+ {file = "pyinstrument-5.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:0bf4ef061d60befe72366ce0ed4c75dee5be089644de38f9936d2df0bcf44af0"},
+ {file = "pyinstrument-5.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:79a54def2d4aa83a4ed37c6cffc5494ae5de140f0453169eb4f7c744cc249d3a"},
+ {file = "pyinstrument-5.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9538f746f166a40c8802ebe5c3e905d50f3faa189869cd71c083b8a639e574bb"},
+ {file = "pyinstrument-5.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2bbab65cae1483ad8a18429511d1eac9e3efec9f7961f2fd1bf90e1e2d69ef15"},
+ {file = "pyinstrument-5.0.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4351ad041d208c597e296a0e9c2e6e21cc96804608bcafa40cfa168f3c2b8f79"},
+ {file = "pyinstrument-5.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ceee5252f4580abec29bcc5c965453c217b0d387c412a5ffb8afdcda4e648feb"},
+ {file = "pyinstrument-5.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b3050a4e7033103a13cfff9802680e2070a9173e1a258fa3f15a80b4eb9ee278"},
+ {file = "pyinstrument-5.0.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3b1f44a34da7810938df615fb7cbc43cd879b42ca6b5cd72e655aee92149d012"},
+ {file = "pyinstrument-5.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fde075196c8a3b2be191b8da05b92ff909c78d308f82df56d01a8cfdd6da07b9"},
+ {file = "pyinstrument-5.0.0-cp313-cp313-win32.whl", hash = "sha256:1a9b62a8b54e05e7723eb8b9595fadc43559b73290c87b3b1cb2dc5944559790"},
+ {file = "pyinstrument-5.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:2478d2c55f77ad8e281e67b0dfe7c2176304bb824c307e86e11890f5e68d7feb"},
+ {file = "pyinstrument-5.0.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:c2e3b4283f85232fd5818e2153e6798bceb39a8c3ccfaa22fae08faf554740b7"},
+ {file = "pyinstrument-5.0.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:fb1139d2822abff1cbf1c81c018341f573b7afa23a94ce74888a0f6f47828cbc"},
+ {file = "pyinstrument-5.0.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c971566d86ba46a7233d3f5b0d85d7ee4c9863f541f5d8f796c3947ebe17f68"},
+ {file = "pyinstrument-5.0.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:429376235960179d6ab9b97e7871090059d39de160b4e3b2723672f30e8eea8e"},
+ {file = "pyinstrument-5.0.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8599b4b0630c776b30fc3c4f7476d5e3814ee7fe42d99131644fe3c00b40fdf1"},
+ {file = "pyinstrument-5.0.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:a8bc688afa2a5368042a7cb56866d5a28fdff8f37a282f7be79b17cae042841b"},
+ {file = "pyinstrument-5.0.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:5d34c06e2276d1f549a540bccb063688ea3d876e6df7c391205f1c8b4b96d5c8"},
+ {file = "pyinstrument-5.0.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:4d3b2ec6e028731dbb2ba8cf06f19030162789e6696bca990a09519881ad42fb"},
+ {file = "pyinstrument-5.0.0-cp38-cp38-win32.whl", hash = "sha256:5ed6f5873a7526ec5915e45d956d044334ef302653cf63649e48c41561aaa285"},
+ {file = "pyinstrument-5.0.0-cp38-cp38-win_amd64.whl", hash = "sha256:9e87d65bae7d0f5ef50908e35d67d43b7cc566909995cc99e91721bb49b4ea06"},
+ {file = "pyinstrument-5.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bd953163616bc29c2ccb1e4c0e48ccdd11e0a97fc849da26bc362bba372019ba"},
+ {file = "pyinstrument-5.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8d2a7279ed9b6d7cdae247bc2e57095a32f35dfe32182c334ab0ac3eb02e0eac"},
+ {file = "pyinstrument-5.0.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68001dfcb8a37b624a1c3de5d2ee7d634f63eac7a6dd1357b7370a5cdbdcf567"},
+ {file = "pyinstrument-5.0.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d5c4c3cc6410ad5afe0e352a7fb09fb1ab85eb5676ec5ec8522123759d9cc68f"},
+ {file = "pyinstrument-5.0.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d87ddab66b1b3525ad3abc49a88aaa51efcaf83578e9d2a702c03a1cea39f28"},
+ {file = "pyinstrument-5.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:03182ffaa9c91687cbaba80dc0c5a47015c5ea170fe642f632d88e885cf07356"},
+ {file = "pyinstrument-5.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:39b60417c9c12eed04e1886644e92aa0b281d72e5d0b097b16253cade43110f7"},
+ {file = "pyinstrument-5.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7bb389b6d1573361bd1367b296133c5c69184e35fc18db22e29e8cdf56f158f9"},
+ {file = "pyinstrument-5.0.0-cp39-cp39-win32.whl", hash = "sha256:ae69478815edb3c63e7ebf82e1e13e38c3fb2bab833b1c013643c3475b1b8cf5"},
+ {file = "pyinstrument-5.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:83caeb4150c0334e9e290c0f9bb164ff6bdc199065ecb62016268e8a88589a51"},
+ {file = "pyinstrument-5.0.0.tar.gz", hash = "sha256:144f98eb3086667ece461f66324bf1cc1ee0475b399ab3f9ded8449cc76b7c90"},
+]
+
+[package.extras]
+bin = ["click", "nox"]
+docs = ["furo (==2024.7.18)", "myst-parser (==3.0.1)", "sphinx (==7.4.7)", "sphinx-autobuild (==2024.4.16)", "sphinxcontrib-programoutput (==0.17)"]
+examples = ["django", "litestar", "numpy"]
+test = ["cffi (>=1.17.0)", "flaky", "greenlet (>=3)", "ipython", "pytest", "pytest-asyncio (==0.23.8)", "trio"]
+types = ["typing-extensions"]
+
+[[package]]
+name = "pytest"
+version = "7.4.4"
+description = "pytest: simple powerful testing with Python"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"},
+ {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"},
+]
+
+[package.dependencies]
+colorama = {version = "*", markers = "sys_platform == \"win32\""}
+exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""}
+iniconfig = "*"
+packaging = "*"
+pluggy = ">=0.12,<2.0"
+tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""}
+
+[package.extras]
+testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
+
+[[package]]
+name = "pytest-asyncio"
+version = "0.23.8"
+description = "Pytest support for asyncio"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "pytest_asyncio-0.23.8-py3-none-any.whl", hash = "sha256:50265d892689a5faefb84df80819d1ecef566eb3549cf915dfb33569359d1ce2"},
+ {file = "pytest_asyncio-0.23.8.tar.gz", hash = "sha256:759b10b33a6dc61cce40a8bd5205e302978bbbcc00e279a8b61d9a6a3c82e4d3"},
+]
+
+[package.dependencies]
+pytest = ">=7.0.0,<9"
+
+[package.extras]
+docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"]
+testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"]
+
+[[package]]
+name = "pytest-textual-snapshot"
+version = "0.4.0"
+description = "Snapshot testing for Textual apps"
+optional = false
+python-versions = ">=3.6,<4.0"
+files = [
+ {file = "pytest_textual_snapshot-0.4.0-py3-none-any.whl", hash = "sha256:879cc5de29cdd31cfe1b6daeb1dc5e42682abebcf4f88e7e3375bd5200683fc0"},
+ {file = "pytest_textual_snapshot-0.4.0.tar.gz", hash = "sha256:63782e053928a925d88ff7359dd640f2900e23bc708b3007f8b388e65f2527cb"},
+]
+
+[package.dependencies]
+jinja2 = ">=3.0.0"
+pytest = ">=7.0.0"
+rich = ">=12.0.0"
+syrupy = ">=3.0.0"
+textual = ">=0.28.0"
+
+[[package]]
+name = "python-dateutil"
+version = "2.9.0.post0"
+description = "Extensions to the standard Python datetime module"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
+files = [
+ {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"},
+ {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"},
+]
+
+[package.dependencies]
+six = ">=1.5"
+
+[[package]]
+name = "pytz"
+version = "2024.2"
+description = "World timezone definitions, modern and historical"
+optional = false
+python-versions = "*"
+files = [
+ {file = "pytz-2024.2-py2.py3-none-any.whl", hash = "sha256:31c7c1817eb7fae7ca4b8c7ee50c72f93aa2dd863de768e1ef4245d426aa0725"},
+ {file = "pytz-2024.2.tar.gz", hash = "sha256:2aa355083c50a0f93fa581709deac0c9ad65cca8a9e9beac660adcbd493c798a"},
+]
+
+[[package]]
+name = "pyyaml"
+version = "6.0.2"
+description = "YAML parser and emitter for Python"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"},
+ {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"},
+ {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"},
+ {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"},
+ {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"},
+ {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"},
+ {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"},
+ {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"},
+ {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"},
+ {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"},
+ {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"},
+ {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"},
+ {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"},
+ {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"},
+ {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"},
+ {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"},
+ {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"},
+ {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"},
+ {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"},
+ {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"},
+ {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"},
+ {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"},
+ {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"},
+ {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"},
+ {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"},
+ {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"},
+ {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"},
+ {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"},
+ {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"},
+ {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"},
+ {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"},
+ {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"},
+ {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"},
+ {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"},
+ {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"},
+ {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"},
+ {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"},
+ {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"},
+ {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"},
+ {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"},
+ {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"},
+ {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"},
+ {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"},
+ {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"},
+ {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"},
+ {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"},
+ {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"},
+ {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"},
+ {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"},
+ {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"},
+ {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"},
+ {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"},
+ {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"},
+]
+
+[[package]]
+name = "rich"
+version = "13.9.4"
+description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
+optional = false
+python-versions = ">=3.8.0"
+files = [
+ {file = "rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90"},
+ {file = "rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098"},
+]
+
+[package.dependencies]
+markdown-it-py = ">=2.2.0"
+pygments = ">=2.13.0,<3.0.0"
+typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.11\""}
+
+[package.extras]
+jupyter = ["ipywidgets (>=7.5.1,<9)"]
+
+[[package]]
+name = "ruff"
+version = "0.5.7"
+description = "An extremely fast Python linter and code formatter, written in Rust."
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "ruff-0.5.7-py3-none-linux_armv6l.whl", hash = "sha256:548992d342fc404ee2e15a242cdbea4f8e39a52f2e7752d0e4cbe88d2d2f416a"},
+ {file = "ruff-0.5.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:00cc8872331055ee017c4f1071a8a31ca0809ccc0657da1d154a1d2abac5c0be"},
+ {file = "ruff-0.5.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eaf3d86a1fdac1aec8a3417a63587d93f906c678bb9ed0b796da7b59c1114a1e"},
+ {file = "ruff-0.5.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a01c34400097b06cf8a6e61b35d6d456d5bd1ae6961542de18ec81eaf33b4cb8"},
+ {file = "ruff-0.5.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fcc8054f1a717e2213500edaddcf1dbb0abad40d98e1bd9d0ad364f75c763eea"},
+ {file = "ruff-0.5.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7f70284e73f36558ef51602254451e50dd6cc479f8b6f8413a95fcb5db4a55fc"},
+ {file = "ruff-0.5.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:a78ad870ae3c460394fc95437d43deb5c04b5c29297815a2a1de028903f19692"},
+ {file = "ruff-0.5.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9ccd078c66a8e419475174bfe60a69adb36ce04f8d4e91b006f1329d5cd44bcf"},
+ {file = "ruff-0.5.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7e31c9bad4ebf8fdb77b59cae75814440731060a09a0e0077d559a556453acbb"},
+ {file = "ruff-0.5.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d796327eed8e168164346b769dd9a27a70e0298d667b4ecee6877ce8095ec8e"},
+ {file = "ruff-0.5.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4a09ea2c3f7778cc635e7f6edf57d566a8ee8f485f3c4454db7771efb692c499"},
+ {file = "ruff-0.5.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a36d8dcf55b3a3bc353270d544fb170d75d2dff41eba5df57b4e0b67a95bb64e"},
+ {file = "ruff-0.5.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9369c218f789eefbd1b8d82a8cf25017b523ac47d96b2f531eba73770971c9e5"},
+ {file = "ruff-0.5.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b88ca3db7eb377eb24fb7c82840546fb7acef75af4a74bd36e9ceb37a890257e"},
+ {file = "ruff-0.5.7-py3-none-win32.whl", hash = "sha256:33d61fc0e902198a3e55719f4be6b375b28f860b09c281e4bdbf783c0566576a"},
+ {file = "ruff-0.5.7-py3-none-win_amd64.whl", hash = "sha256:083bbcbe6fadb93cd86709037acc510f86eed5a314203079df174c40bbbca6b3"},
+ {file = "ruff-0.5.7-py3-none-win_arm64.whl", hash = "sha256:2dca26154ff9571995107221d0aeaad0e75a77b5a682d6236cf89a58c70b76f4"},
+ {file = "ruff-0.5.7.tar.gz", hash = "sha256:8dfc0a458797f5d9fb622dd0efc52d796f23f0a1493a9527f4e49a550ae9a7e5"},
+]
+
+[[package]]
+name = "six"
+version = "1.17.0"
+description = "Python 2 and 3 compatibility utilities"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
+files = [
+ {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"},
+ {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"},
+]
+
+[[package]]
+name = "syrupy"
+version = "4.8.0"
+description = "Pytest Snapshot Test Utility"
+optional = false
+python-versions = ">=3.8.1"
+files = [
+ {file = "syrupy-4.8.0-py3-none-any.whl", hash = "sha256:544f4ec6306f4b1c460fdab48fd60b2c7fe54a6c0a8243aeea15f9ad9c638c3f"},
+ {file = "syrupy-4.8.0.tar.gz", hash = "sha256:648f0e9303aaa8387c8365d7314784c09a6bab0a407455c6a01d6a4f5c6a8ede"},
+]
+
+[package.dependencies]
+pytest = ">=7.0.0,<9.0.0"
+
+[[package]]
+name = "textual"
+version = "0.89.1"
+description = "Modern Text User Interface framework"
+optional = false
+python-versions = "<4.0.0,>=3.8.1"
+files = [
+ {file = "textual-0.89.1-py3-none-any.whl", hash = "sha256:0a5d214df6e951b4a2c421e13d0b608482882471c1e34ea74a3631adede8054f"},
+ {file = "textual-0.89.1.tar.gz", hash = "sha256:66befe80e2bca5a8c876cd8ceeaf01752267b6b1dc1d0f73071f1f1e15d90cc8"},
+]
+
+[package.dependencies]
+markdown-it-py = {version = ">=2.1.0", extras = ["linkify", "plugins"]}
+platformdirs = ">=3.6.0,<5"
+rich = ">=13.3.3"
+typing-extensions = ">=4.4.0,<5.0.0"
+
+[package.extras]
+syntax = ["tree-sitter (>=0.23.0)", "tree-sitter-bash (>=0.23.0)", "tree-sitter-css (>=0.23.0)", "tree-sitter-go (>=0.23.0)", "tree-sitter-html (>=0.23.0)", "tree-sitter-java (>=0.23.0)", "tree-sitter-javascript (>=0.23.0)", "tree-sitter-json (>=0.24.0)", "tree-sitter-markdown (>=0.3.0)", "tree-sitter-python (>=0.23.0)", "tree-sitter-regex (>=0.24.0)", "tree-sitter-rust (>=0.23.0)", "tree-sitter-sql (>=0.3.0)", "tree-sitter-toml (>=0.6.0)", "tree-sitter-xml (>=0.7.0)", "tree-sitter-yaml (>=0.6.0)"]
+
+[[package]]
+name = "textual-dev"
+version = "1.7.0"
+description = "Development tools for working with Textual"
+optional = false
+python-versions = "<4.0.0,>=3.8.1"
+files = [
+ {file = "textual_dev-1.7.0-py3-none-any.whl", hash = "sha256:a93a846aeb6a06edb7808504d9c301565f7f4bf2e7046d56583ed755af356c8d"},
+ {file = "textual_dev-1.7.0.tar.gz", hash = "sha256:bf1a50eaaff4cd6a863535dd53f06dbbd62617c371604f66f56de3908220ccd5"},
+]
+
+[package.dependencies]
+aiohttp = ">=3.8.1"
+click = ">=8.1.2"
+msgpack = ">=1.0.3"
+textual = ">=0.86.2"
+textual_serve = ">=1.0.3"
+typing-extensions = ">=4.4.0,<5.0.0"
+
+[[package]]
+name = "textual-serve"
+version = "1.1.1"
+description = "Turn your Textual TUIs in to web applications"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "textual_serve-1.1.1-py3-none-any.whl", hash = "sha256:568782f1c0e60e3f7039d9121e1cb5c2f4ca1aaf6d6bd7aeb833d5763a534cb2"},
+ {file = "textual_serve-1.1.1.tar.gz", hash = "sha256:71c662472c462e5e368defc660ee6e8eae3bfda88ca40c050c55474686eb0c54"},
+]
+
+[package.dependencies]
+aiohttp = ">=3.9.5"
+aiohttp-jinja2 = ">=1.6"
+jinja2 = ">=3.1.4"
+rich = "*"
+textual = ">=0.66.0"
+
+[[package]]
+name = "tomli"
+version = "2.2.1"
+description = "A lil' TOML parser"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"},
+ {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"},
+ {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"},
+ {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"},
+ {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"},
+ {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"},
+ {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"},
+ {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"},
+ {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"},
+ {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"},
+ {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"},
+ {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"},
+ {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"},
+ {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"},
+ {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"},
+ {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"},
+ {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"},
+ {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"},
+ {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"},
+ {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"},
+ {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"},
+ {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"},
+ {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"},
+ {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"},
+ {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"},
+ {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"},
+ {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"},
+ {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"},
+ {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"},
+ {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"},
+ {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"},
+ {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"},
+]
+
+[[package]]
+name = "types-pytz"
+version = "2024.2.0.20241003"
+description = "Typing stubs for pytz"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "types-pytz-2024.2.0.20241003.tar.gz", hash = "sha256:575dc38f385a922a212bac00a7d6d2e16e141132a3c955078f4a4fd13ed6cb44"},
+ {file = "types_pytz-2024.2.0.20241003-py3-none-any.whl", hash = "sha256:3e22df1336c0c6ad1d29163c8fda82736909eb977281cb823c57f8bae07118b7"},
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.12.2"
+description = "Backported and Experimental Type Hints for Python 3.8+"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"},
+ {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"},
+]
+
+[[package]]
+name = "tzdata"
+version = "2024.2"
+description = "Provider of IANA time zone data"
+optional = false
+python-versions = ">=2"
+files = [
+ {file = "tzdata-2024.2-py2.py3-none-any.whl", hash = "sha256:a48093786cdcde33cad18c2555e8532f34422074448fbc874186f0abd79565cd"},
+ {file = "tzdata-2024.2.tar.gz", hash = "sha256:7d85cc416e9382e69095b7bdf4afd9e3880418a2413feec7069d533d6b4e31cc"},
+]
+
+[[package]]
+name = "uc-micro-py"
+version = "1.0.3"
+description = "Micro subset of unicode data files for linkify-it-py projects."
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "uc-micro-py-1.0.3.tar.gz", hash = "sha256:d321b92cff673ec58027c04015fcaa8bb1e005478643ff4a500882eaab88c48a"},
+ {file = "uc_micro_py-1.0.3-py3-none-any.whl", hash = "sha256:db1dffff340817673d7b466ec86114a9dc0e9d4d9b5ba229d9d60e5c12600cd5"},
+]
+
+[package.extras]
+test = ["coverage", "pytest", "pytest-cov"]
+
+[[package]]
+name = "virtualenv"
+version = "20.28.0"
+description = "Virtual Python Environment builder"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "virtualenv-20.28.0-py3-none-any.whl", hash = "sha256:23eae1b4516ecd610481eda647f3a7c09aea295055337331bb4e6892ecce47b0"},
+ {file = "virtualenv-20.28.0.tar.gz", hash = "sha256:2c9c3262bb8e7b87ea801d715fae4495e6032450c71d2309be9550e7364049aa"},
+]
+
+[package.dependencies]
+distlib = ">=0.3.7,<1"
+filelock = ">=3.12.2,<4"
+platformdirs = ">=3.9.1,<5"
+
+[package.extras]
+docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"]
+test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"]
+
+[[package]]
+name = "yarl"
+version = "1.18.3"
+description = "Yet another URL library"
+optional = false
+python-versions = ">=3.9"
+files = [
+ {file = "yarl-1.18.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7df647e8edd71f000a5208fe6ff8c382a1de8edfbccdbbfe649d263de07d8c34"},
+ {file = "yarl-1.18.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c69697d3adff5aa4f874b19c0e4ed65180ceed6318ec856ebc423aa5850d84f7"},
+ {file = "yarl-1.18.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:602d98f2c2d929f8e697ed274fbadc09902c4025c5a9963bf4e9edfc3ab6f7ed"},
+ {file = "yarl-1.18.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c654d5207c78e0bd6d749f6dae1dcbbfde3403ad3a4b11f3c5544d9906969dde"},
+ {file = "yarl-1.18.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5094d9206c64181d0f6e76ebd8fb2f8fe274950a63890ee9e0ebfd58bf9d787b"},
+ {file = "yarl-1.18.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35098b24e0327fc4ebdc8ffe336cee0a87a700c24ffed13161af80124b7dc8e5"},
+ {file = "yarl-1.18.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3236da9272872443f81fedc389bace88408f64f89f75d1bdb2256069a8730ccc"},
+ {file = "yarl-1.18.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e2c08cc9b16f4f4bc522771d96734c7901e7ebef70c6c5c35dd0f10845270bcd"},
+ {file = "yarl-1.18.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:80316a8bd5109320d38eef8833ccf5f89608c9107d02d2a7f985f98ed6876990"},
+ {file = "yarl-1.18.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c1e1cc06da1491e6734f0ea1e6294ce00792193c463350626571c287c9a704db"},
+ {file = "yarl-1.18.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fea09ca13323376a2fdfb353a5fa2e59f90cd18d7ca4eaa1fd31f0a8b4f91e62"},
+ {file = "yarl-1.18.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e3b9fd71836999aad54084906f8663dffcd2a7fb5cdafd6c37713b2e72be1760"},
+ {file = "yarl-1.18.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:757e81cae69244257d125ff31663249b3013b5dc0a8520d73694aed497fb195b"},
+ {file = "yarl-1.18.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b1771de9944d875f1b98a745bc547e684b863abf8f8287da8466cf470ef52690"},
+ {file = "yarl-1.18.3-cp310-cp310-win32.whl", hash = "sha256:8874027a53e3aea659a6d62751800cf6e63314c160fd607489ba5c2edd753cf6"},
+ {file = "yarl-1.18.3-cp310-cp310-win_amd64.whl", hash = "sha256:93b2e109287f93db79210f86deb6b9bbb81ac32fc97236b16f7433db7fc437d8"},
+ {file = "yarl-1.18.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8503ad47387b8ebd39cbbbdf0bf113e17330ffd339ba1144074da24c545f0069"},
+ {file = "yarl-1.18.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:02ddb6756f8f4517a2d5e99d8b2f272488e18dd0bfbc802f31c16c6c20f22193"},
+ {file = "yarl-1.18.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:67a283dd2882ac98cc6318384f565bffc751ab564605959df4752d42483ad889"},
+ {file = "yarl-1.18.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d980e0325b6eddc81331d3f4551e2a333999fb176fd153e075c6d1c2530aa8a8"},
+ {file = "yarl-1.18.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b643562c12680b01e17239be267bc306bbc6aac1f34f6444d1bded0c5ce438ca"},
+ {file = "yarl-1.18.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c017a3b6df3a1bd45b9fa49a0f54005e53fbcad16633870104b66fa1a30a29d8"},
+ {file = "yarl-1.18.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75674776d96d7b851b6498f17824ba17849d790a44d282929c42dbb77d4f17ae"},
+ {file = "yarl-1.18.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ccaa3a4b521b780a7e771cc336a2dba389a0861592bbce09a476190bb0c8b4b3"},
+ {file = "yarl-1.18.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2d06d3005e668744e11ed80812e61efd77d70bb7f03e33c1598c301eea20efbb"},
+ {file = "yarl-1.18.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:9d41beda9dc97ca9ab0b9888cb71f7539124bc05df02c0cff6e5acc5a19dcc6e"},
+ {file = "yarl-1.18.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ba23302c0c61a9999784e73809427c9dbedd79f66a13d84ad1b1943802eaaf59"},
+ {file = "yarl-1.18.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6748dbf9bfa5ba1afcc7556b71cda0d7ce5f24768043a02a58846e4a443d808d"},
+ {file = "yarl-1.18.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0b0cad37311123211dc91eadcb322ef4d4a66008d3e1bdc404808992260e1a0e"},
+ {file = "yarl-1.18.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0fb2171a4486bb075316ee754c6d8382ea6eb8b399d4ec62fde2b591f879778a"},
+ {file = "yarl-1.18.3-cp311-cp311-win32.whl", hash = "sha256:61b1a825a13bef4a5f10b1885245377d3cd0bf87cba068e1d9a88c2ae36880e1"},
+ {file = "yarl-1.18.3-cp311-cp311-win_amd64.whl", hash = "sha256:b9d60031cf568c627d028239693fd718025719c02c9f55df0a53e587aab951b5"},
+ {file = "yarl-1.18.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1dd4bdd05407ced96fed3d7f25dbbf88d2ffb045a0db60dbc247f5b3c5c25d50"},
+ {file = "yarl-1.18.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7c33dd1931a95e5d9a772d0ac5e44cac8957eaf58e3c8da8c1414de7dd27c576"},
+ {file = "yarl-1.18.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:25b411eddcfd56a2f0cd6a384e9f4f7aa3efee14b188de13048c25b5e91f1640"},
+ {file = "yarl-1.18.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:436c4fc0a4d66b2badc6c5fc5ef4e47bb10e4fd9bf0c79524ac719a01f3607c2"},
+ {file = "yarl-1.18.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e35ef8683211db69ffe129a25d5634319a677570ab6b2eba4afa860f54eeaf75"},
+ {file = "yarl-1.18.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:84b2deecba4a3f1a398df819151eb72d29bfeb3b69abb145a00ddc8d30094512"},
+ {file = "yarl-1.18.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00e5a1fea0fd4f5bfa7440a47eff01d9822a65b4488f7cff83155a0f31a2ecba"},
+ {file = "yarl-1.18.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d0e883008013c0e4aef84dcfe2a0b172c4d23c2669412cf5b3371003941f72bb"},
+ {file = "yarl-1.18.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5a3f356548e34a70b0172d8890006c37be92995f62d95a07b4a42e90fba54272"},
+ {file = "yarl-1.18.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ccd17349166b1bee6e529b4add61727d3f55edb7babbe4069b5764c9587a8cc6"},
+ {file = "yarl-1.18.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b958ddd075ddba5b09bb0be8a6d9906d2ce933aee81100db289badbeb966f54e"},
+ {file = "yarl-1.18.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c7d79f7d9aabd6011004e33b22bc13056a3e3fb54794d138af57f5ee9d9032cb"},
+ {file = "yarl-1.18.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4891ed92157e5430874dad17b15eb1fda57627710756c27422200c52d8a4e393"},
+ {file = "yarl-1.18.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ce1af883b94304f493698b00d0f006d56aea98aeb49d75ec7d98cd4a777e9285"},
+ {file = "yarl-1.18.3-cp312-cp312-win32.whl", hash = "sha256:f91c4803173928a25e1a55b943c81f55b8872f0018be83e3ad4938adffb77dd2"},
+ {file = "yarl-1.18.3-cp312-cp312-win_amd64.whl", hash = "sha256:7e2ee16578af3b52ac2f334c3b1f92262f47e02cc6193c598502bd46f5cd1477"},
+ {file = "yarl-1.18.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:90adb47ad432332d4f0bc28f83a5963f426ce9a1a8809f5e584e704b82685dcb"},
+ {file = "yarl-1.18.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:913829534200eb0f789d45349e55203a091f45c37a2674678744ae52fae23efa"},
+ {file = "yarl-1.18.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ef9f7768395923c3039055c14334ba4d926f3baf7b776c923c93d80195624782"},
+ {file = "yarl-1.18.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88a19f62ff30117e706ebc9090b8ecc79aeb77d0b1f5ec10d2d27a12bc9f66d0"},
+ {file = "yarl-1.18.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e17c9361d46a4d5addf777c6dd5eab0715a7684c2f11b88c67ac37edfba6c482"},
+ {file = "yarl-1.18.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a74a13a4c857a84a845505fd2d68e54826a2cd01935a96efb1e9d86c728e186"},
+ {file = "yarl-1.18.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:41f7ce59d6ee7741af71d82020346af364949314ed3d87553763a2df1829cc58"},
+ {file = "yarl-1.18.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f52a265001d830bc425f82ca9eabda94a64a4d753b07d623a9f2863fde532b53"},
+ {file = "yarl-1.18.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:82123d0c954dc58db301f5021a01854a85bf1f3bb7d12ae0c01afc414a882ca2"},
+ {file = "yarl-1.18.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2ec9bbba33b2d00999af4631a3397d1fd78290c48e2a3e52d8dd72db3a067ac8"},
+ {file = "yarl-1.18.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:fbd6748e8ab9b41171bb95c6142faf068f5ef1511935a0aa07025438dd9a9bc1"},
+ {file = "yarl-1.18.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:877d209b6aebeb5b16c42cbb377f5f94d9e556626b1bfff66d7b0d115be88d0a"},
+ {file = "yarl-1.18.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b464c4ab4bfcb41e3bfd3f1c26600d038376c2de3297760dfe064d2cb7ea8e10"},
+ {file = "yarl-1.18.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8d39d351e7faf01483cc7ff7c0213c412e38e5a340238826be7e0e4da450fdc8"},
+ {file = "yarl-1.18.3-cp313-cp313-win32.whl", hash = "sha256:61ee62ead9b68b9123ec24bc866cbef297dd266175d53296e2db5e7f797f902d"},
+ {file = "yarl-1.18.3-cp313-cp313-win_amd64.whl", hash = "sha256:578e281c393af575879990861823ef19d66e2b1d0098414855dd367e234f5b3c"},
+ {file = "yarl-1.18.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:61e5e68cb65ac8f547f6b5ef933f510134a6bf31bb178be428994b0cb46c2a04"},
+ {file = "yarl-1.18.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fe57328fbc1bfd0bd0514470ac692630f3901c0ee39052ae47acd1d90a436719"},
+ {file = "yarl-1.18.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a440a2a624683108a1b454705ecd7afc1c3438a08e890a1513d468671d90a04e"},
+ {file = "yarl-1.18.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09c7907c8548bcd6ab860e5f513e727c53b4a714f459b084f6580b49fa1b9cee"},
+ {file = "yarl-1.18.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b4f6450109834af88cb4cc5ecddfc5380ebb9c228695afc11915a0bf82116789"},
+ {file = "yarl-1.18.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a9ca04806f3be0ac6d558fffc2fdf8fcef767e0489d2684a21912cc4ed0cd1b8"},
+ {file = "yarl-1.18.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77a6e85b90a7641d2e07184df5557132a337f136250caafc9ccaa4a2a998ca2c"},
+ {file = "yarl-1.18.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6333c5a377c8e2f5fae35e7b8f145c617b02c939d04110c76f29ee3676b5f9a5"},
+ {file = "yarl-1.18.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0b3c92fa08759dbf12b3a59579a4096ba9af8dd344d9a813fc7f5070d86bbab1"},
+ {file = "yarl-1.18.3-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:4ac515b860c36becb81bb84b667466885096b5fc85596948548b667da3bf9f24"},
+ {file = "yarl-1.18.3-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:045b8482ce9483ada4f3f23b3774f4e1bf4f23a2d5c912ed5170f68efb053318"},
+ {file = "yarl-1.18.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:a4bb030cf46a434ec0225bddbebd4b89e6471814ca851abb8696170adb163985"},
+ {file = "yarl-1.18.3-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:54d6921f07555713b9300bee9c50fb46e57e2e639027089b1d795ecd9f7fa910"},
+ {file = "yarl-1.18.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1d407181cfa6e70077df3377938c08012d18893f9f20e92f7d2f314a437c30b1"},
+ {file = "yarl-1.18.3-cp39-cp39-win32.whl", hash = "sha256:ac36703a585e0929b032fbaab0707b75dc12703766d0b53486eabd5139ebadd5"},
+ {file = "yarl-1.18.3-cp39-cp39-win_amd64.whl", hash = "sha256:ba87babd629f8af77f557b61e49e7c7cac36f22f871156b91e10a6e9d4f829e9"},
+ {file = "yarl-1.18.3-py3-none-any.whl", hash = "sha256:b57f4f58099328dfb26c6a771d09fb20dbbae81d20cfb66141251ea063bd101b"},
+ {file = "yarl-1.18.3.tar.gz", hash = "sha256:ac1801c45cbf77b6c99242eeff4fffb5e4e73a800b5c4ad4fc0be5def634d2e1"},
+]
+
+[package.dependencies]
+idna = ">=2.0"
+multidict = ">=4.0"
+propcache = ">=0.2.0"
+
+[extras]
+polars = ["polars"]
+
+[metadata]
+lock-version = "2.0"
+python-versions = ">=3.9,<3.14"
+content-hash = "d7644efc2e218d280ff6b6678c36e7b2b23df09a420bf14d886ef37cc5505fed"
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..b95600c
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,81 @@
+[tool.poetry]
+name = "textual-fastdatatable"
+version = "0.12.0"
+description = "A performance-focused reimplementation of Textual's DataTable widget, with a pluggable data storage backend."
+authors = ["Ted Conbeer "]
+license = "MIT"
+readme = "README.md"
+packages = [
+ { include = "textual_fastdatatable", from = "src" },
+]
+
+[build-system]
+requires = ["poetry-core"]
+build-backend = "poetry.core.masonry.api"
+
+
+[tool.poetry.dependencies]
+python = ">=3.9,<3.14"
+textual = ">=0.89.1"
+pyarrow = ">=16.1.0"
+polars = { version = ">=0.20.0", optional = true }
+tzdata = { version = ">=2023", markers = "sys_platform == 'win32'" } # arrow timestamptz support on windows
+
+[tool.poetry.extras]
+polars = ["polars"]
+
+[tool.poetry.group.dev.dependencies]
+pre-commit = "^3.3.1"
+textual-dev = "^1.0.1"
+pandas = "^2.1.1"
+numpy = "^1"
+pyinstrument = "^5"
+
+[tool.poetry.group.static.dependencies]
+ruff = "^0.5"
+mypy = "^1.10.0"
+pandas-stubs = "^2.1.1"
+
+[tool.poetry.group.test.dependencies]
+pytest = "^7.3.1"
+pytest-asyncio = ">=0.21,<0.24"
+pytest-textual-snapshot = ">=0.4.0"
+polars = ">=0.20.0"
+
+
+[tool.ruff]
+target-version = "py39"
+
+[tool.ruff.lint]
+select = ["A", "B", "E", "F", "I"]
+
+[tool.mypy]
+python_version = "3.9"
+files = [
+ "src/**/*.py",
+ "tests/unit_tests/**/*.py"
+]
+mypy_path = "src:stubs"
+
+show_column_numbers = true
+
+# show error messages from unrelated files
+follow_imports = "normal"
+
+# be strict
+disallow_untyped_calls = true
+disallow_untyped_defs = true
+check_untyped_defs = true
+disallow_untyped_decorators = true
+disallow_incomplete_defs = true
+disallow_subclassing_any = true
+strict_optional = true
+
+warn_return_any = true
+warn_no_return = true
+warn_redundant_casts = true
+warn_unused_ignores = true
+warn_unused_configs = true
+
+no_implicit_reexport = true
+strict_equality = true
diff --git a/src/scripts/benchmark.py b/src/scripts/benchmark.py
new file mode 100644
index 0000000..014513e
--- /dev/null
+++ b/src/scripts/benchmark.py
@@ -0,0 +1,157 @@
+from __future__ import annotations
+
+import gc
+from pathlib import Path
+from time import perf_counter
+
+import pandas as pd
+import polars as pl
+from textual.app import App, ComposeResult
+from textual.driver import Driver
+from textual.pilot import Pilot
+from textual.types import CSSPathType
+from textual.widgets import DataTable as BuiltinDataTable
+from textual_fastdatatable import ArrowBackend
+from textual_fastdatatable import DataTable as FastDataTable
+from textual_fastdatatable.backend import PolarsBackend
+
+BENCHMARK_DATA = Path(__file__).parent.parent.parent / "tests" / "data"
+
+
+async def scroller(pilot: Pilot) -> None:
+ first_paint = perf_counter() - pilot.app.start # type: ignore
+ for _ in range(5):
+ await pilot.press("pagedown")
+ for _ in range(15):
+ await pilot.press("right")
+ for _ in range(5):
+ await pilot.press("pagedown")
+ elapsed = perf_counter() - pilot.app.start # type: ignore
+ pilot.app.exit(result=(first_paint, elapsed))
+
+
+class BuiltinApp(App):
+ TITLE = "Built-In DataTable"
+
+ def __init__(
+ self,
+ data_path: Path,
+ driver_class: type[Driver] | None = None,
+ css_path: CSSPathType | None = None,
+ watch_css: bool = False,
+ ):
+ super().__init__(driver_class, css_path, watch_css)
+ self.data_path = data_path
+
+ def compose(self) -> ComposeResult:
+ df = pd.read_parquet(self.data_path)
+ rows = [tuple(row) for row in df.itertuples(index=False)]
+ self.start = perf_counter()
+ table: BuiltinDataTable = BuiltinDataTable()
+ table.add_columns(*[str(col) for col in df.columns])
+ for row in rows:
+ table.add_row(*row, height=1, label=None)
+ yield table
+
+
+class ArrowBackendApp(App):
+ TITLE = "FastDataTable (Arrow from Parquet)"
+
+ def __init__(
+ self,
+ data_path: Path,
+ driver_class: type[Driver] | None = None,
+ css_path: CSSPathType | None = None,
+ watch_css: bool = False,
+ ):
+ super().__init__(driver_class, css_path, watch_css)
+ self.data_path = data_path
+
+ def compose(self) -> ComposeResult:
+ self.start = perf_counter()
+ yield FastDataTable(data=self.data_path)
+
+
+class ArrowBackendAppFromRecords(App):
+ TITLE = "FastDataTable (Arrow from Records)"
+
+ def __init__(
+ self,
+ data_path: Path,
+ driver_class: type[Driver] | None = None,
+ css_path: CSSPathType | None = None,
+ watch_css: bool = False,
+ ):
+ super().__init__(driver_class, css_path, watch_css)
+ self.data_path = data_path
+
+ def compose(self) -> ComposeResult:
+ df = pd.read_parquet(self.data_path)
+ rows = [tuple(row) for row in df.itertuples(index=False)]
+ self.start = perf_counter()
+ backend = ArrowBackend.from_records(rows, has_header=False)
+ table = FastDataTable(
+ backend=backend, column_labels=[str(col) for col in df.columns]
+ )
+ yield table
+
+
+class PolarsBackendApp(App):
+ TITLE = "FastDataTable (Polars from Parquet)"
+
+ def __init__(
+ self,
+ data_path: Path,
+ driver_class: type[Driver] | None = None,
+ css_path: CSSPathType | None = None,
+ watch_css: bool = False,
+ ):
+ super().__init__(driver_class, css_path, watch_css)
+ self.data_path = data_path
+
+ def compose(self) -> ComposeResult:
+ self.start = perf_counter()
+ yield FastDataTable(
+ data=PolarsBackend.from_dataframe(pl.read_parquet(self.data_path))
+ )
+
+
+if __name__ == "__main__":
+ app_defs = [
+ BuiltinApp,
+ ArrowBackendApp,
+ ArrowBackendAppFromRecords,
+ PolarsBackendApp,
+ ]
+ bench = [
+ (f"lap_times_{n}.parquet", 3 if n <= 10000 else 1)
+ for n in [100, 1000, 10000, 100000, 538121]
+ ]
+ bench.extend([(f"wide_{n}.parquet", 1) for n in [10000, 100000]])
+ with open("results.md", "w") as f:
+ print(
+ "Records |",
+ " | ".join([a.TITLE for a in app_defs]), # type: ignore
+ sep="",
+ file=f,
+ )
+ print("--------|", "|".join(["--------" for _ in app_defs]), sep="", file=f)
+ for p, tries in bench:
+ first_paint: list[list[float]] = [list() for _ in app_defs]
+ elapsed: list[list[float]] = [list() for _ in app_defs]
+ for i, app_cls in enumerate(app_defs):
+ for _ in range(tries):
+ app = app_cls(BENCHMARK_DATA / p)
+ gc.disable()
+ fp, el = app.run(headless=True, auto_pilot=scroller) # type: ignore
+ gc.collect()
+ first_paint[i].append(fp)
+ elapsed[i].append(el)
+ gc.enable()
+ avg_first_paint = [sum(app_times) / tries for app_times in first_paint]
+ avg_elapsed = [sum(app_times) / tries for app_times in elapsed]
+ formatted = [
+ f"{fp:7,.3f}s / {el:7,.3f}s"
+ for fp, el in zip(avg_first_paint, avg_elapsed)
+ ]
+ print(f"{p} | {' | '.join(formatted)}", file=f)
diff --git a/src/scripts/run_arrow_wide.py b/src/scripts/run_arrow_wide.py
new file mode 100644
index 0000000..2938fcc
--- /dev/null
+++ b/src/scripts/run_arrow_wide.py
@@ -0,0 +1,32 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+from textual.app import App, ComposeResult
+from textual.driver import Driver
+from textual.types import CSSPathType
+from textual_fastdatatable import DataTable
+
+BENCHMARK_DATA = Path(__file__).parent.parent.parent / "tests" / "data"
+
+
+class ArrowBackendApp(App):
+ TITLE = "FastDataTable (Arrow)"
+
+ def __init__(
+ self,
+ data_path: Path,
+ driver_class: type[Driver] | None = None,
+ css_path: CSSPathType | None = None,
+ watch_css: bool = False,
+ ):
+ super().__init__(driver_class, css_path, watch_css)
+ self.data_path = data_path
+
+ def compose(self) -> ComposeResult:
+ yield DataTable(data=self.data_path)
+
+
+if __name__ == "__main__":
+ app = ArrowBackendApp(data_path=BENCHMARK_DATA / "wide_100000.parquet")
+ app.run()
diff --git a/src/scripts/run_builtin_wide.py b/src/scripts/run_builtin_wide.py
new file mode 100644
index 0000000..2e5311f
--- /dev/null
+++ b/src/scripts/run_builtin_wide.py
@@ -0,0 +1,39 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pandas as pd
+from textual.app import App, ComposeResult
+from textual.driver import Driver
+from textual.types import CSSPathType
+from textual.widgets import DataTable
+
+BENCHMARK_DATA = Path(__file__).parent.parent.parent / "tests" / "data"
+
+
+class BuiltinApp(App):
+ TITLE = "Built-In DataTable"
+
+ def __init__(
+ self,
+ data_path: Path,
+ driver_class: type[Driver] | None = None,
+ css_path: CSSPathType | None = None,
+ watch_css: bool = False,
+ ):
+ super().__init__(driver_class, css_path, watch_css)
+ self.data_path = data_path
+
+ def compose(self) -> ComposeResult:
+ df = pd.read_parquet(self.data_path)
+ rows = [tuple(row) for row in df.itertuples(index=False)]
+ table: DataTable = DataTable()
+ table.add_columns(*[str(col) for col in df.columns])
+ for row in rows:
+ table.add_row(*row, height=1, label=None)
+ yield table
+
+
+if __name__ == "__main__":
+ app = BuiltinApp(data_path=BENCHMARK_DATA / "wide_10000.parquet")
+ app.run()
diff --git a/src/textual_fastdatatable/__init__.py b/src/textual_fastdatatable/__init__.py
new file mode 100644
index 0000000..44dfe00
--- /dev/null
+++ b/src/textual_fastdatatable/__init__.py
@@ -0,0 +1,13 @@
+from textual_fastdatatable.backend import (
+ ArrowBackend,
+ DataTableBackend,
+ create_backend,
+)
+from textual_fastdatatable.data_table import DataTable
+
+__all__ = [
+ "DataTable",
+ "ArrowBackend",
+ "DataTableBackend",
+ "create_backend",
+]
diff --git a/src/textual_fastdatatable/__main__.py b/src/textual_fastdatatable/__main__.py
new file mode 100644
index 0000000..c3f6788
--- /dev/null
+++ b/src/textual_fastdatatable/__main__.py
@@ -0,0 +1,19 @@
+from textual.app import App, ComposeResult
+
+from textual_fastdatatable import ArrowBackend, DataTable
+
+
+class TableApp(App, inherit_bindings=False):
+ BINDINGS = [("ctrl+q", "quit", "Quit"), ("ctrl+d", "quit", "Quit")]
+
+ def compose(self) -> ComposeResult:
+ backend = ArrowBackend.from_parquet("./tests/data/wide_100000.parquet")
+ yield DataTable(backend=backend, cursor_type="range", fixed_columns=2)
+
+
+if __name__ == "__main__":
+ import locale
+
+ locale.setlocale(locale.LC_ALL, "")
+ app = TableApp()
+ app.run()
diff --git a/src/textual_fastdatatable/backend.py b/src/textual_fastdatatable/backend.py
new file mode 100644
index 0000000..ed667f1
--- /dev/null
+++ b/src/textual_fastdatatable/backend.py
@@ -0,0 +1,706 @@
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+from contextlib import suppress
+from datetime import date, datetime
+from pathlib import Path
+from typing import (
+ Any,
+ Dict,
+ Generic,
+ Iterable,
+ Literal,
+ Mapping,
+ Sequence,
+ TypeVar,
+)
+
+import pyarrow as pa
+import pyarrow.compute as pc
+import pyarrow.lib as pal
+import pyarrow.parquet as pq
+import pyarrow.types as pt
+from rich.console import Console
+
+from textual_fastdatatable.formatter import measure_width
+
+AutoBackendType = Any
+
+try:
+ import polars as pl
+ import polars.datatypes as pld
+except ImportError:
+ _HAS_POLARS = False
+else:
+ _HAS_POLARS = True
+
+
+def create_backend(
+ data: "AutoBackendType",
+ max_rows: int | None = None,
+ has_header: bool = False,
+) -> DataTableBackend:
+ if isinstance(data, pa.Table):
+ return ArrowBackend(data, max_rows=max_rows)
+ if isinstance(data, pa.RecordBatch):
+ return ArrowBackend.from_batches(data, max_rows=max_rows)
+ if _HAS_POLARS and isinstance(data, pl.DataFrame):
+ return PolarsBackend.from_dataframe(data, max_rows=max_rows)
+
+ if isinstance(data, Path) or isinstance(data, str):
+ data = Path(data)
+ if data.suffix in [".pqt", ".parquet"]:
+ return ArrowBackend.from_parquet(data, max_rows=max_rows)
+ if _HAS_POLARS:
+ return PolarsBackend.from_file_path(
+ data, max_rows=max_rows, has_header=has_header
+ )
+ if isinstance(data, Sequence) and not data:
+ return ArrowBackend(pa.table([]), max_rows=max_rows)
+ if isinstance(data, Sequence) and _is_iterable(data[0]):
+ return ArrowBackend.from_records(data, max_rows=max_rows, has_header=has_header)
+
+ if (
+ isinstance(data, Mapping)
+ and isinstance(next(iter(data.keys())), str)
+ and isinstance(next(iter(data.values())), Sequence)
+ ):
+ return ArrowBackend.from_pydict(data, max_rows=max_rows)
+
+ raise TypeError(
+ f"Cannot automatically create backend for data of type: {type(data)}. "
+ f"Data must be of type: Union[pa.Table, pa.RecordBatch, Path, str, "
+ "Sequence[Iterable[Any]], Mapping[str, Sequence[Any]], pl.DataFrame",
+ )
+
+
+def _is_iterable(item: Any) -> bool:
+ try:
+ iter(item)
+ except TypeError:
+ return False
+ else:
+ return True
+
+
+_TableTypeT = TypeVar("_TableTypeT")
+
+
+class DataTableBackend(ABC, Generic[_TableTypeT]):
+ data: _TableTypeT
+
+ @abstractmethod
+ def __init__(self, data: _TableTypeT, max_rows: int | None = None) -> None:
+ pass
+
+ @classmethod
+ @abstractmethod
+ def from_pydict(
+ cls, data: Mapping[str, Sequence[Any]], max_rows: int | None = None
+ ) -> "DataTableBackend":
+ pass
+
+ @property
+ @abstractmethod
+ def source_data(self) -> _TableTypeT:
+ """
+ Return the source data as an Arrow table
+ """
+ pass
+
+ @property
+ @abstractmethod
+ def source_row_count(self) -> int:
+ """
+ The number of rows in the source data, before filtering down to max_rows
+ """
+ pass
+
+ @property
+ @abstractmethod
+ def row_count(self) -> int:
+ """
+ The number of rows in backend's retained data, after filtering down to max_rows
+ """
+ pass
+
+ @property
+ def column_count(self) -> int:
+ return len(self.columns)
+
+ @property
+ @abstractmethod
+ def columns(self) -> Sequence[str]:
+ """
+ A list of column labels
+ """
+ pass
+
+ @property
+ @abstractmethod
+ def column_content_widths(self) -> Sequence[int]:
+ """
+ A list of integers corresponding to the widest utf8 string length
+ of any data in each column.
+ """
+ pass
+
+ @abstractmethod
+ def get_row_at(self, index: int) -> Sequence[Any]:
+ pass
+
+ @abstractmethod
+ def get_column_at(self, index: int) -> Sequence[Any]:
+ pass
+
+ @abstractmethod
+ def get_cell_at(self, row_index: int, column_index: int) -> Any:
+ pass
+
+ @abstractmethod
+ def append_column(self, label: str, default: Any | None = None) -> int:
+ """
+ Returns column index
+ """
+
+ @abstractmethod
+ def append_rows(self, records: Iterable[Iterable[Any]]) -> list[int]:
+ """
+ Returns new row indicies
+ """
+ pass
+
+ @abstractmethod
+ def drop_row(self, row_index: int) -> None:
+ pass
+
+ @abstractmethod
+ def update_cell(self, row_index: int, column_index: int, value: Any) -> None:
+ """
+ Raises IndexError if bad indicies
+ """
+
+ @abstractmethod
+ def sort(
+ self, by: list[tuple[str, Literal["ascending", "descending"]]] | str
+ ) -> None:
+ """
+ by: str sorts table by the data in the column with that name (asc).
+ by: list[tuple] sorts the table by the named column(s) with the directions
+ indicated.
+ """
+
+
+class ArrowBackend(DataTableBackend[pa.Table]):
+ def __init__(self, data: pa.Table, max_rows: int | None = None) -> None:
+ self._source_data = data
+
+ # Arrow allows duplicate field names, but a table's to_pylist() and
+ # to_pydict() methods will drop duplicate-named fields!
+ field_names: list[str] = []
+ renamed = False
+ for field in data.column_names:
+ n = 0
+ while field in field_names:
+ field = f"{field}{n}"
+ renamed = True
+ n += 1
+ field_names.append(field)
+ if renamed:
+ data = data.rename_columns(field_names)
+
+ self._source_row_count = data.num_rows
+ if max_rows is not None and max_rows < self._source_row_count:
+ self.data = data.slice(offset=0, length=max_rows)
+ else:
+ self.data = data
+ self._console = Console()
+ self._column_content_widths: list[int] = []
+
+ @staticmethod
+ def _pydict_from_records(
+ records: Sequence[Iterable[Any]], has_header: bool = False
+ ) -> dict[str, list[Any]]:
+ headers = (
+ records[0]
+ if has_header
+ else [f"f{i}" for i in range(len(list(records[0])))]
+ )
+ data = list(map(list, records[1:] if has_header else records))
+ pydict = {header: [row[i] for row in data] for i, header in enumerate(headers)}
+ return pydict
+
+ @staticmethod
+ def _handle_overflow(scalar: pa.Scalar) -> Any | None:
+ """
+ PyArrow may throw an OverflowError when casting arrow types
+ to python types; in some cases we can catch these and
+ present a sensible value in the data table; otherwise
+ we return None.
+ """
+ if pt.is_date32(scalar.type):
+ if scalar.value > 0: # type: ignore[attr-defined]
+ return date.max
+ elif scalar.value <= 0: # type: ignore[attr-defined]
+ return date.min
+ elif pt.is_date64(scalar.type):
+ if scalar.value > 0: # type: ignore[attr-defined]
+ return date.max
+ elif scalar.value <= 0: # type: ignore[attr-defined]
+ return date.min
+ elif pt.is_timestamp(scalar.type):
+ if scalar.value > 0: # type: ignore[attr-defined]
+ return datetime.max
+ elif scalar.value <= 0: # type: ignore[attr-defined]
+ return datetime.min
+
+ return None
+
+ @classmethod
+ def from_batches(
+ cls, data: pa.RecordBatch, max_rows: int | None = None
+ ) -> "ArrowBackend":
+ tbl = pa.Table.from_batches([data])
+ return cls(tbl, max_rows=max_rows)
+
+ @classmethod
+ def from_parquet(
+ cls, path: Path | str, max_rows: int | None = None
+ ) -> "ArrowBackend":
+ tbl = pq.read_table(str(path))
+ return cls(tbl, max_rows=max_rows)
+
+ @classmethod
+ def from_pydict(
+ cls, data: Mapping[str, Sequence[Any]], max_rows: int | None = None
+ ) -> "ArrowBackend":
+ try:
+ tbl = pa.Table.from_pydict(dict(data))
+ except (pal.ArrowInvalid, pal.ArrowTypeError):
+ # one or more fields has mixed types, like int and
+ # string. Cast all to string for safety
+ new_data = {
+ k: [str(val) if val is not None else None for val in v]
+ for k, v in data.items()
+ }
+ tbl = pa.Table.from_pydict(new_data)
+ return cls(tbl, max_rows=max_rows)
+
+ @classmethod
+ def from_records(
+ cls,
+ records: Sequence[Iterable[Any]],
+ has_header: bool = False,
+ max_rows: int | None = None,
+ ) -> "ArrowBackend":
+ pydict = cls._pydict_from_records(records, has_header)
+ return cls.from_pydict(pydict, max_rows=max_rows)
+
+ @property
+ def source_data(self) -> pa.Table:
+ return self._source_data
+
+ @property
+ def source_row_count(self) -> int:
+ return self._source_row_count
+
+ @property
+ def row_count(self) -> int:
+ return self.data.num_rows
+
+ @property
+ def column_count(self) -> int:
+ return self.data.num_columns
+
+ @property
+ def columns(self) -> Sequence[str]:
+ return self.data.column_names
+
+ @property
+ def column_content_widths(self) -> list[int]:
+ if not self._column_content_widths:
+ measurements = [self._measure(arr) for arr in self.data.columns]
+ # pc.max returns None for each column without rows; we need to return 0
+ # instead.
+ self._column_content_widths = [cw or 0 for cw in measurements]
+
+ return self._column_content_widths
+
+ def get_row_at(self, index: int) -> Sequence[Any]:
+ try:
+ row: Dict[str, Any] = self.data.slice(index, length=1).to_pylist()[0]
+ except OverflowError:
+ return [
+ self._handle_overflow(self.data[i][index])
+ for i in range(len(self.columns))
+ ]
+ else:
+ return list(row.values())
+
+ def get_column_at(self, column_index: int) -> list[Any]:
+ try:
+ values = self.data[column_index].to_pylist()
+ except OverflowError:
+ # TODO: consider registering a scalar UDF here for parallel processing
+ return [self._handle_overflow(scalar) for scalar in self.data[column_index]]
+ else:
+ return values
+
+ def get_cell_at(self, row_index: int, column_index: int) -> Any:
+ scalar = self.data[column_index][row_index]
+ try:
+ value = scalar.as_py()
+ except OverflowError:
+ value = self._handle_overflow(scalar)
+ return value
+
+ def append_column(self, label: str, default: Any | None = None) -> int:
+ """
+ Returns column index
+ """
+ if default is None:
+ arr: pa.Array = pa.nulls(self.row_count)
+ else:
+ arr = pa.nulls(self.row_count, type=pa.string())
+ arr = arr.fill_null(str(default))
+
+ self.data = self.data.append_column(label, arr)
+ if self._column_content_widths:
+ self._column_content_widths.append(measure_width(default, self._console))
+ return self.data.num_columns - 1
+
+ def append_rows(self, records: Iterable[Iterable[Any]]) -> list[int]:
+ rows = list(records)
+ indicies = list(range(self.row_count, self.row_count + len(rows)))
+ records_with_headers = [self.data.column_names, *rows]
+ pydict = self._pydict_from_records(records_with_headers, has_header=True)
+ old_rows = self.data.to_batches()
+ new_rows = pa.RecordBatch.from_pydict(
+ pydict,
+ schema=self.data.schema,
+ )
+ self.data = pa.Table.from_batches([*old_rows, new_rows])
+ self._reset_content_widths()
+ return indicies
+
+ def drop_row(self, row_index: int) -> None:
+ if row_index < 0 or row_index >= self.row_count:
+ raise IndexError(f"Can't drop row {row_index} of {self.row_count}")
+ above = self.data.slice(0, row_index).to_batches()
+ below = self.data.slice(row_index + 1).to_batches()
+ self.data = pa.Table.from_batches([*above, *below])
+ self._reset_content_widths()
+ pass
+
+ def update_cell(self, row_index: int, column_index: int, value: Any) -> None:
+ column = self.data.column(column_index)
+ pycolumn = self.get_column_at(column_index=column_index)
+ pycolumn[row_index] = value
+ new_type = pa.string() if pt.is_null(column.type) else column.type
+ self.data = self.data.set_column(
+ column_index,
+ self.data.column_names[column_index],
+ pa.array(pycolumn, type=new_type),
+ )
+ if self._column_content_widths:
+ self._column_content_widths[column_index] = max(
+ measure_width(value, self._console),
+ self._column_content_widths[column_index],
+ )
+
+ def sort(
+ self, by: list[tuple[str, Literal["ascending", "descending"]]] | str
+ ) -> None:
+ """
+ by: str sorts table by the data in the column with that name (asc).
+ by: list[tuple] sorts the table by the named column(s) with the directions
+ indicated.
+ """
+ self.data = self.data.sort_by(by)
+
+ def _reset_content_widths(self) -> None:
+ self._column_content_widths = []
+
+ def _measure(self, arr: pa._PandasConvertible) -> int:
+ # with some types we can measure the width more efficiently
+ if pt.is_boolean(arr.type):
+ return 7
+ elif pt.is_null(arr.type):
+ return 0
+ elif (
+ pt.is_integer(arr.type)
+ or pt.is_floating(arr.type)
+ or pt.is_decimal(arr.type)
+ ):
+ try:
+ col_max = pc.max(arr.fill_null(0)).as_py()
+ except OverflowError:
+ col_max = 9223372036854775807
+ try:
+ col_min = pc.min(arr.fill_null(0)).as_py()
+ except OverflowError:
+ col_min = -9223372036854775807
+ return max([measure_width(el, self._console) for el in [col_max, col_min]])
+ elif pt.is_temporal(arr.type):
+ try:
+ value = arr.drop_null()[0].as_py()
+ except OverflowError:
+ return 26 # need space for the infinity sign and a space
+ except IndexError:
+ return 24
+ else:
+ # valid temporal types all have the same width for their type
+ return measure_width(value, self._console)
+
+ # for everything else, we need to compute it
+ # First, cast the data to strings
+ try:
+ arr = arr.cast(
+ pa.string(),
+ safe=False,
+ )
+ except (pal.ArrowNotImplementedError, pal.ArrowInvalid):
+ # some types can't be casted to strings natively by arrow, but they
+ # can be casted to strings by python. The arrow way is faster, but
+ # if it fails, register a python udf and try again
+ def py_str(_ctx: Any, arr: pa.Array) -> str | pa.Array | pa.ChunkedArray:
+ return pa.array([str(el) for el in arr], type=pa.string())
+
+ udf_name = f"tfdt_pystr_{arr.type}"
+ with suppress(pal.ArrowKeyError): # already registered
+ pc.register_scalar_function(
+ py_str,
+ function_name=udf_name,
+ function_doc={"summary": "str", "description": "built-in str"},
+ in_types={"arr": arr.type},
+ out_type=pa.string(),
+ )
+
+ arr = pc.call_function(udf_name, [arr])
+
+ # next, try to measure the UTF-encoded string length of each cell,
+ # then take the max
+ try:
+ width: int = pc.max(pc.utf8_length(arr.fill_null("")).fill_null(0)).as_py()
+ except OverflowError:
+ width = 10
+ return width
+
+
+if _HAS_POLARS:
+
+ class PolarsBackend(DataTableBackend[pl.DataFrame]):
+ @classmethod
+ def from_file_path(
+ cls, path: Path, max_rows: int | None = None, has_header: bool = True
+ ) -> "PolarsBackend":
+ if path.suffix in [".arrow", ".feather"]:
+ tbl = pl.read_ipc(path)
+ elif path.suffix == ".arrows":
+ tbl = pl.read_ipc_stream(path)
+ elif path.suffix == ".json":
+ tbl = pl.read_json(path)
+ elif path.suffix == ".csv":
+ tbl = pl.read_csv(path, has_header=has_header)
+ else:
+ raise TypeError(
+ f"Dont know how to load file type {path.suffix} for {path}"
+ )
+ return cls(tbl, max_rows=max_rows)
+
+ @classmethod
+ def from_pydict(
+ cls, pydict: Mapping[str, Sequence[Any]], max_rows: int | None = None
+ ) -> "PolarsBackend":
+ return cls(pl.from_dict(pydict), max_rows=max_rows)
+
+ @classmethod
+ def from_dataframe(
+ cls, frame: pl.DataFrame, max_rows: int | None = None
+ ) -> "PolarsBackend":
+ return cls(frame, max_rows=max_rows)
+
+ def __init__(self, data: pl.DataFrame, max_rows: int | None = None) -> None:
+ self._source_data = data
+
+ # Arrow allows duplicate field names, but a table's to_pylist() and
+ # to_pydict() methods will drop duplicate-named fields!
+ field_names: list[str] = []
+ for field in data.columns:
+ n = 0
+ while field in field_names:
+ field = f"{field}{n}"
+ n += 1
+ field_names.append(field)
+ data.columns = field_names
+
+ self._source_row_count = len(data)
+ if max_rows is not None and max_rows < self._source_row_count:
+ self.data = data.slice(offset=0, length=max_rows)
+ else:
+ self.data = data
+ self._console = Console()
+ self._column_content_widths: list[int] = []
+
+ @property
+ def source_data(self) -> pl.DataFrame:
+ return self._source_data
+
+ @property
+ def source_row_count(self) -> int:
+ return self._source_row_count
+
+ @property
+ def row_count(self) -> int:
+ return len(self.data)
+
+ @property
+ def column_count(self) -> int:
+ return len(self.data.columns)
+
+ @property
+ def columns(self) -> Sequence[str]:
+ return self.data.columns
+
+ def get_row_at(self, index: int) -> Sequence[Any]:
+ if index < 0 or index >= len(self.data):
+ raise IndexError(
+ f"Cannot get row={index} in table with {len(self.data)} rows "
+ f"and {len(self.data.columns)} cols"
+ )
+ return list(self.data.slice(index, length=1).to_dicts()[0].values())
+
+ def get_column_at(self, column_index: int) -> Sequence[Any]:
+ if column_index < 0 or column_index >= len(self.data.columns):
+ raise IndexError(
+ f"Cannot get column={column_index} in table with {len(self.data)} "
+ f"rows and {len(self.data.columns)} cols."
+ )
+ return list(self.data.to_series(column_index))
+
+ def get_cell_at(self, row_index: int, column_index: int) -> Any:
+ if (
+ row_index >= len(self.data)
+ or row_index < 0
+ or column_index < 0
+ or column_index >= len(self.data.columns)
+ ):
+ raise IndexError(
+ f"Cannot get cell at row={row_index} col={column_index} in table "
+ f"with {len(self.data)} rows and {len(self.data.columns)} cols"
+ )
+ return self.data.to_series(column_index)[row_index]
+
+ def drop_row(self, row_index: int) -> None:
+ if row_index < 0 or row_index >= self.row_count:
+ raise IndexError(f"Can't drop row {row_index} of {self.row_count}")
+ above = self.data.slice(0, row_index)
+ below = self.data.slice(row_index + 1)
+ self.data = pl.concat([above, below])
+ self._reset_content_widths()
+
+ def append_rows(self, records: Iterable[Iterable[Any]]) -> list[int]:
+ rows_to_add = pl.from_dicts(
+ [dict(zip(self.data.columns, row)) for row in records]
+ )
+ indicies = list(range(self.row_count, self.row_count + len(rows_to_add)))
+ self.data = pl.concat([self.data, rows_to_add])
+ self._reset_content_widths()
+ return indicies
+
+ def append_column(self, label: str, default: Any | None = None) -> int:
+ """
+ Returns column index
+ """
+ self.data = self.data.with_columns(
+ pl.Series([default])
+ .extend_constant(default, self.row_count - 1)
+ .alias(label)
+ )
+ if self._column_content_widths:
+ self._column_content_widths.append(
+ measure_width(default, self._console)
+ )
+ return len(self.data.columns) - 1
+
+ def _reset_content_widths(self) -> None:
+ self._column_content_widths = []
+
+ def update_cell(self, row_index: int, column_index: int, value: Any) -> None:
+ if row_index >= len(self.data) or column_index >= len(self.data.columns):
+ raise IndexError(
+ f"Cannot update cell at row={row_index} col={column_index} in "
+ f"table with {len(self.data)} rows and "
+ f"{len(self.data.columns)} cols"
+ )
+ col_name = self.data.columns[column_index]
+ self.data = self.data.with_columns(
+ self.data.to_series(column_index)
+ .scatter(row_index, value)
+ .alias(col_name)
+ )
+ if self._column_content_widths:
+ self._column_content_widths[column_index] = max(
+ measure_width(value, self._console),
+ self._column_content_widths[column_index],
+ )
+
+ @property
+ def column_content_widths(self) -> list[int]:
+ if not self._column_content_widths:
+ measurements = [
+ self._measure(self.data[arr]) for arr in self.data.columns
+ ]
+ # pc.max returns None for each column without rows; we need to return 0
+ # instead.
+ self._column_content_widths = [cw or 0 for cw in measurements]
+
+ return self._column_content_widths
+
+ def _measure(self, arr: pl.Series) -> int:
+ # with some types we can measure the width more efficiently
+ dtype = arr.dtype
+ if dtype == pld.Categorical():
+ return self._measure(arr.cat.get_categories())
+
+ if dtype.is_decimal() or dtype.is_float() or dtype.is_integer():
+ col_max = arr.max()
+ col_min = arr.min()
+ return max(
+ [measure_width(el, self._console) for el in [col_max, col_min]]
+ )
+ if dtype.is_temporal():
+ try:
+ value = arr.drop_nulls()[0]
+ except IndexError:
+ return 0
+ else:
+ return measure_width(value, self._console)
+ if dtype.is_(pld.Boolean()):
+ return 7
+
+ # for everything else, we need to compute it
+
+ arr = arr.cast(
+ pl.Utf8(),
+ strict=False,
+ )
+ width = arr.fill_null("").str.len_chars().max()
+ assert isinstance(width, int)
+ return width
+
+ def sort(
+ self, by: list[tuple[str, Literal["ascending", "descending"]]] | str
+ ) -> None:
+ """
+ by: str sorts table by the data in the column with that name (asc).
+ by: list[tuple] sorts the table by the named column(s) with the directions
+ indicated.
+ """
+ if isinstance(by, str):
+ cols = [by]
+ typs = [False]
+ else:
+ cols = [x for x, _ in by]
+ typs = [x == "descending" for _, x in by]
+ self.data = self.data.sort(cols, descending=typs)
diff --git a/src/textual_fastdatatable/column.py b/src/textual_fastdatatable/column.py
new file mode 100644
index 0000000..24d9d97
--- /dev/null
+++ b/src/textual_fastdatatable/column.py
@@ -0,0 +1,47 @@
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+
+from rich.text import Text
+
+CELL_X_PADDING = 2
+
+SNAKE_ID_PROG = re.compile(r"(\b|_)id\b", flags=re.IGNORECASE)
+CAMEL_ID_PROG = re.compile(r"[a-z]I[dD]\b")
+
+
+@dataclass
+class Column:
+ """Metadata for a column in the DataTable."""
+
+ label: Text
+ width: int = 0
+ content_width: int = 0
+ auto_width: bool = False
+ max_content_width: int | None = None
+
+ def __post_init__(self) -> None:
+ self._is_id: bool | None = None
+
+ @property
+ def render_width(self) -> int:
+ """Width in cells, required to render a column."""
+ # +2 is to account for space padding either side of the cell
+ if self.auto_width and self.max_content_width is not None:
+ return (
+ min(max(len(self.label), self.content_width), self.max_content_width)
+ + CELL_X_PADDING
+ )
+ elif self.auto_width:
+ return max(len(self.label), self.content_width) + CELL_X_PADDING
+ else:
+ return self.width + CELL_X_PADDING
+
+ @property
+ def is_id(self) -> bool:
+ if self._is_id is None:
+ snake_id = SNAKE_ID_PROG.search(str(self.label)) is not None
+ camel_id = CAMEL_ID_PROG.search(str(self.label)) is not None
+ self._is_id = snake_id or camel_id
+ return self._is_id
diff --git a/src/textual_fastdatatable/data_table.py b/src/textual_fastdatatable/data_table.py
new file mode 100644
index 0000000..e582c32
--- /dev/null
+++ b/src/textual_fastdatatable/data_table.py
@@ -0,0 +1,2808 @@
+# Forked from Textual; the original code comes with the following License:
+
+# MIT License
+
+# Copyright (c) 2021 Will McGugan
+
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from __future__ import annotations
+
+import functools
+from datetime import date, datetime, time, timedelta
+from decimal import Decimal
+from itertools import chain, zip_longest
+from typing import Any, ClassVar, Iterable, NamedTuple, Tuple, Union
+
+import rich.repr
+from rich.console import RenderableType
+from rich.padding import Padding
+from rich.pretty import Pretty
+from rich.segment import Segment
+from rich.style import Style
+from rich.text import Text, TextType
+from textual import events, on
+from textual._segment_tools import line_crop
+from textual._two_way_dict import TwoWayDict
+from textual._types import SegmentLines
+from textual.binding import Binding, BindingType
+from textual.cache import LRUCache
+from textual.color import Color
+from textual.coordinate import Coordinate
+from textual.geometry import Region, Size, Spacing, clamp
+from textual.message import Message
+from textual.reactive import Reactive
+from textual.render import measure
+from textual.renderables.styled import Styled
+from textual.scroll_view import ScrollView
+from textual.strip import Strip
+from textual.widget import PseudoClasses
+from typing_extensions import Literal, Self
+
+from textual_fastdatatable.backend import DataTableBackend, create_backend
+from textual_fastdatatable.column import Column
+from textual_fastdatatable.formatter import cell_formatter, measure_width
+
+CursorType = Literal["cell", "range", "row", "column", "none"]
+"""The valid types of cursors for
+[`DataTable.cursor_type`][textual.widgets.DataTable.cursor_type]."""
+TooltipCacheKey = Tuple[int, int, int]
+CellCacheKey = Tuple[int, int, Style, bool, bool, bool, bool, int, PseudoClasses]
+LineCacheKey = Tuple[
+ int,
+ int,
+ int,
+ int,
+ Coordinate,
+ Coordinate,
+ Union[Coordinate, None],
+ Style,
+ CursorType,
+ bool,
+ int,
+ PseudoClasses,
+]
+RowCacheKey = Tuple[
+ int,
+ int,
+ int,
+ int,
+ Style,
+ Coordinate,
+ Coordinate,
+ Union[Coordinate, None],
+ CursorType,
+ bool,
+ bool,
+ int,
+ PseudoClasses,
+]
+CellType = RenderableType
+
+
+class CellDoesNotExist(Exception):
+ """The cell key/index was invalid.
+
+ Raised when the coordinates or cell key provided does not exist
+ in the DataTable (e.g. out of bounds index, invalid key)"""
+
+
+class RowDoesNotExist(Exception):
+ """Raised when the row index or row key provided does not exist
+ in the DataTable (e.g. out of bounds index, invalid key)"""
+
+
+class ColumnDoesNotExist(Exception):
+ """Raised when the column index or column key provided does not exist
+ in the DataTable (e.g. out of bounds index, invalid key)"""
+
+
+class RowRenderables(NamedTuple):
+ """Container for a row, which contains an optional label and some data cells."""
+
+ label: RenderableType | None
+ cells: list[RenderableType]
+
+
+class DataTable(ScrollView, can_focus=True):
+ """A tabular widget that contains data."""
+
+ BINDINGS: ClassVar[list[BindingType]] = [
+ Binding("enter", "select_cursor", "Select", show=False),
+ Binding("up", "cursor_up", "Cursor Up", show=False),
+ Binding("down", "cursor_down", "Cursor Down", show=False),
+ Binding("ctrl+up", "scroll_home", "Home", show=False),
+ Binding("ctrl+down", "scroll_end", "End", show=False),
+ Binding("right", "cursor_right", "Cursor Right", show=False),
+ Binding("left", "cursor_left", "Cursor Left", show=False),
+ Binding("tab", "cursor_next", "Cursor Next", show=False),
+ Binding("shift+tab", "cursor_prev", "Cursor Prev", show=False),
+ Binding("ctrl+right", "cursor_row_end", "Cursor Right", show=False),
+ Binding("ctrl+left", "cursor_row_start", "Cursor Left", show=False),
+ Binding("pageup", "page_up", "Page Up", show=False),
+ Binding("pagedown", "page_down", "Page Down", show=False),
+ Binding("home", "scroll_home", "Home", show=False),
+ Binding("end", "scroll_end", "End", show=False),
+ Binding("ctrl+home", "cursor_table_start", "Home", show=False),
+ Binding("ctrl+end", "cursor_table_end", "End", show=False),
+ Binding("shift+up", "cursor_up(True)", "Cursor Up", show=False),
+ Binding("shift+down", "cursor_down(True)", "Cursor Down", show=False),
+ Binding("shift+right", "cursor_right(True)", "Cursor Right", show=False),
+ Binding("shift+left", "cursor_left(True)", "Cursor Left", show=False),
+ Binding("shift+pageup", "page_up(True)", "Page Up", show=False),
+ Binding("shift+pagedown", "page_down(True)", "Page Down", show=False),
+ Binding("shift+home", "scroll_home(True)", "Home", show=False),
+ Binding("shift+end", "scroll_end(True)", "End", show=False),
+ Binding("ctrl+shift+up", "scroll_home(True)", "Home", show=False),
+ Binding("ctrl+shift+down", "scroll_end(True)", "End", show=False),
+ Binding("ctrl+shift+right", "cursor_row_end(True)", "Cursor Right", show=False),
+ Binding("ctrl+shift+left", "cursor_row_start(True)", "Cursor Left", show=False),
+ Binding("ctrl+shift+home", "cursor_table_start(True)", "Home", show=False),
+ Binding("ctrl+shift+end", "cursor_table_end(True)", "End", show=False),
+ Binding("ctrl+a", "select_all", "Select All", show=False),
+ Binding("ctrl+c", "copy_selection", "Copy", show=False),
+ ]
+ """
+ | Key(s) | Description |
+ | :- | :- |
+ | enter | Select cells under the cursor. |
+ | up | Move the cursor up. |
+ | down | Move the cursor down. |
+ | right | Move the cursor right. |
+ | left | Move the cursor left. |
+ """
+
+ COMPONENT_CLASSES: ClassVar[set[str]] = {
+ "datatable--cursor",
+ "datatable--selectrange",
+ "datatable--hover",
+ "datatable--fixed",
+ "datatable--fixed-cursor",
+ "datatable--header",
+ "datatable--header-cursor",
+ "datatable--header-selectrange",
+ "datatable--header-hover",
+ "datatable--odd-row",
+ "datatable--even-row",
+ }
+ """
+ | Class | Description |
+ | :- | :- |
+ | `datatable--cursor` | Target the cursor. |
+ | `datatable--selectrange` | Target the cells in the selection range (ex cursor). |
+ | `datatable--hover` | Target the cells under the hover cursor. |
+ | `datatable--fixed` | Target fixed columns and fixed rows. |
+ | `datatable--fixed-cursor` | Target highlighted and fixed columns or header. |
+ | `datatable--header` | Target the header of the data table. |
+ | `datatable--header-cursor` | Target header cells highlighted by the cursor. |
+ | `datatable--header-selectrange` | Target header cells highlighted by the range. |
+ | `datatable--header-hover` | Target hovered header or row label cells. |
+ | `datatable--even-row` | Target even rows (row indices start at 0). |
+ | `datatable--odd-row` | Target odd rows (row indices start at 0). |
+ """
+
+ DEFAULT_CSS = """
+ DataTable:dark {
+ background: initial;
+ }
+ DataTable {
+ background: $surface ;
+ color: $text;
+ height: auto;
+ max-height: 100vh;
+ }
+ DataTable > .datatable--header {
+ text-style: bold;
+ background: $primary;
+ color: $text;
+ }
+ DataTable > .datatable--fixed {
+ background: $primary 50%;
+ color: $text;
+ }
+
+ DataTable > .datatable--odd-row {
+
+ }
+
+ DataTable > .datatable--even-row {
+ background: $primary 10%;
+ }
+
+ DataTable > .datatable--cursor {
+ background: $secondary;
+ color: $text;
+ }
+
+ DataTable > .datatable--selectrange {
+ background: $secondary 50%;
+ color: $text;
+ }
+
+ DataTable > .datatable--fixed-cursor {
+ background: $secondary 92%;
+ color: $text;
+ }
+
+ DataTable > .datatable--header-cursor {
+ background: $secondary-darken-1;
+ color: $text;
+ }
+
+ DataTable > .datatable--header-cursor {
+ background: $secondary 50%;
+ color: $text;
+ }
+
+ DataTable > .datatable--header-hover {
+ background: $secondary 30%;
+ }
+
+ DataTable:dark > .datatable--even-row {
+ background: $primary 15%;
+ }
+
+ DataTable > .datatable--hover {
+ background: $secondary 20%;
+ }
+ """
+
+ show_header = Reactive(True)
+ show_row_labels = Reactive(True)
+ fixed_rows = Reactive(0)
+ fixed_columns = Reactive(0)
+ zebra_stripes = Reactive(False)
+ header_height = Reactive(1)
+ show_cursor = Reactive(True)
+ cursor_type: Reactive[CursorType] = Reactive[CursorType]("cell")
+ """The type of the cursor of the `DataTable`."""
+
+ cursor_coordinate: Reactive[Coordinate] = Reactive(
+ Coordinate(0, 0), repaint=False, always_update=True
+ )
+ """Current cursor [`Coordinate`][textual.coordinate.Coordinate].
+
+ This can be set programmatically or changed via the method
+ [`move_cursor`][textual.widgets.DataTable.move_cursor].
+ """
+ selection_anchor_coordinate: Reactive[Coordinate | None] = Reactive(
+ None, repaint=False, always_update=True
+ )
+ hover_coordinate: Reactive[Coordinate] = Reactive(
+ Coordinate(0, 0), repaint=False, always_update=True
+ )
+ """The coordinate of the `DataTable` that is being hovered."""
+
+ class DataLoadError(Message):
+ def __init__(self, error: Exception) -> None:
+ super().__init__()
+ self.error = error
+
+ class CellHighlighted(Message):
+ """Posted when the cursor moves to highlight a new cell.
+
+ This is only relevant when the `cursor_type` is `"cell"`.
+ It's also posted when the cell cursor is
+ re-enabled (by setting `show_cursor=True`), and when the cursor type is
+ changed to `"cell"`. Can be handled using `on_data_table_cell_highlighted` in
+ a subclass of `DataTable` or in a parent widget in the DOM.
+ """
+
+ def __init__(
+ self,
+ data_table: DataTable,
+ value: CellType,
+ coordinate: Coordinate,
+ ) -> None:
+ self.data_table = data_table
+ """The data table."""
+ self.value: CellType = value
+ """The value in the highlighted cell."""
+ self.coordinate: Coordinate = coordinate
+ """The coordinate of the highlighted cell."""
+ super().__init__()
+
+ def __rich_repr__(self) -> rich.repr.Result:
+ yield "value", self.value
+ yield "coordinate", self.coordinate
+
+ @property
+ def control(self) -> DataTable:
+ """Alias for the data table."""
+ return self.data_table
+
+ class CellSelected(Message):
+ """Posted by the `DataTable` widget when a cell is selected.
+
+ This is only relevant when the `cursor_type` is `"cell"`. Can be handled using
+ `on_data_table_cell_selected` in a subclass of `DataTable` or in a parent
+ widget in the DOM.
+ """
+
+ def __init__(
+ self,
+ data_table: DataTable,
+ value: CellType,
+ coordinate: Coordinate,
+ ) -> None:
+ self.data_table = data_table
+ """The data table."""
+ self.value: CellType = value
+ """The value in the cell that was selected."""
+ self.coordinate: Coordinate = coordinate
+ """The coordinate of the cell that was selected."""
+ super().__init__()
+
+ def __rich_repr__(self) -> rich.repr.Result:
+ yield "value", self.value
+ yield "coordinate", self.coordinate
+
+ @property
+ def control(self) -> DataTable:
+ """Alias for the data table."""
+ return self.data_table
+
+ class RowHighlighted(Message):
+ """Posted when a row is highlighted.
+
+ This message is only posted when the
+ `cursor_type` is set to `"row"`. Can be handled using
+ `on_data_table_row_highlighted` in a subclass of `DataTable` or in a parent
+ widget in the DOM.
+ """
+
+ def __init__(self, data_table: DataTable, cursor_row: int) -> None:
+ self.data_table = data_table
+ """The data table."""
+ self.cursor_row: int = cursor_row
+ """The y-coordinate of the cursor that highlighted the row."""
+ super().__init__()
+
+ def __rich_repr__(self) -> rich.repr.Result:
+ yield "cursor_row", self.cursor_row
+
+ @property
+ def control(self) -> DataTable:
+ """Alias for the data table."""
+ return self.data_table
+
+ class RowSelected(Message):
+ """Posted when a row is selected.
+
+ This message is only posted when the
+ `cursor_type` is set to `"row"`. Can be handled using
+ `on_data_table_row_selected` in a subclass of `DataTable` or in a parent
+ widget in the DOM.
+ """
+
+ def __init__(self, data_table: DataTable, cursor_row: int) -> None:
+ self.data_table = data_table
+ """The data table."""
+ self.cursor_row: int = cursor_row
+ """The y-coordinate of the cursor that made the selection."""
+ super().__init__()
+
+ def __rich_repr__(self) -> rich.repr.Result:
+ yield "cursor_row", self.cursor_row
+
+ @property
+ def control(self) -> DataTable:
+ """Alias for the data table."""
+ return self.data_table
+
+ class ColumnHighlighted(Message):
+ """Posted when a column is highlighted.
+
+ This message is only posted when the
+ `cursor_type` is set to `"column"`. Can be handled using
+ `on_data_table_column_highlighted` in a subclass of `DataTable` or in a parent
+ widget in the DOM.
+ """
+
+ def __init__(self, data_table: DataTable, cursor_column: int) -> None:
+ self.data_table = data_table
+ """The data table."""
+ self.cursor_column: int = cursor_column
+ """The x-coordinate of the column that was highlighted."""
+ super().__init__()
+
+ def __rich_repr__(self) -> rich.repr.Result:
+ yield "cursor_column", self.cursor_column
+
+ @property
+ def control(self) -> DataTable:
+ """Alias for the data table."""
+ return self.data_table
+
+ class ColumnSelected(Message):
+ """Posted when a column is selected.
+
+ This message is only posted when the
+ `cursor_type` is set to `"column"`. Can be handled using
+ `on_data_table_column_selected` in a subclass of `DataTable` or in a parent
+ widget in the DOM.
+ """
+
+ def __init__(self, data_table: DataTable, cursor_column: int) -> None:
+ self.data_table = data_table
+ """The data table."""
+ self.cursor_column: int = cursor_column
+ """The x-coordinate of the column that was selected."""
+ super().__init__()
+
+ def __rich_repr__(self) -> rich.repr.Result:
+ yield "cursor_column", self.cursor_column
+
+ @property
+ def control(self) -> DataTable:
+ """Alias for the data table."""
+ return self.data_table
+
+ class HeaderSelected(Message):
+ """Posted when a column header/label is clicked."""
+
+ def __init__(
+ self,
+ data_table: DataTable,
+ column_index: int,
+ label: Text,
+ ):
+ self.data_table = data_table
+ """The data table."""
+ self.column_index = column_index
+ """The index for the column."""
+ self.label = label
+ """The text of the label."""
+ super().__init__()
+
+ def __rich_repr__(self) -> rich.repr.Result:
+ yield "column_index", self.column_index
+ yield "label", self.label.plain
+
+ @property
+ def control(self) -> DataTable:
+ """Alias for the data table."""
+ return self.data_table
+
+ class RowLabelSelected(Message):
+ """Posted when a row label is clicked."""
+
+ def __init__(
+ self,
+ data_table: DataTable,
+ row_index: int,
+ label: Text,
+ ):
+ self.data_table = data_table
+ """The data table."""
+ self.row_index = row_index
+ """The index for the column."""
+ self.label = label
+ """The text of the label."""
+ super().__init__()
+
+ def __rich_repr__(self) -> rich.repr.Result:
+ yield "row_index", self.row_index
+ yield "label", self.label.plain
+
+ @property
+ def control(self) -> DataTable:
+ """Alias for the data table."""
+ return self.data_table
+
+ class SelectionCopied(Message):
+ """Posted when the user presses ctrl+c."""
+
+ def __init__(
+ self,
+ data_table: DataTable,
+ values: list[tuple[Any, ...]],
+ ):
+ self.data_table = data_table
+ """The data table."""
+ self.values = values
+ """The values of the selected cells."""
+ super().__init__()
+
+ def __rich_repr__(self) -> rich.repr.Result:
+ yield "values", self.values
+
+ @property
+ def control(self) -> DataTable:
+ """Alias for the data table."""
+ return self.data_table
+
+ def __init__(
+ self,
+ *,
+ backend: DataTableBackend | None = None,
+ data: Any | None = None,
+ column_labels: list[str | Text] | None = None,
+ column_widths: list[int | None] | None = None,
+ max_column_content_width: int | None = None,
+ show_header: bool = True,
+ show_row_labels: bool = True,
+ max_rows: int | None = None,
+ fixed_rows: int = 0,
+ fixed_columns: int = 0,
+ zebra_stripes: bool = False,
+ header_height: int = 1,
+ show_cursor: bool = True,
+ cursor_foreground_priority: Literal["renderable", "css"] = "css",
+ cursor_background_priority: Literal["renderable", "css"] = "renderable",
+ cursor_type: CursorType = "cell",
+ name: str | None = None,
+ id: str | None = None, # noqa: A002
+ classes: str | None = None,
+ disabled: bool = False,
+ null_rep: str = "",
+ render_markup: bool = True,
+ ) -> None:
+ super().__init__(name=name, id=id, classes=classes, disabled=disabled)
+ try:
+ self.backend: DataTableBackend | None = (
+ backend
+ if backend is not None
+ else create_backend(
+ data,
+ max_rows=max_rows,
+ has_header=(column_labels is None),
+ )
+ )
+ except (TypeError, OSError) as e:
+ self.backend = None
+ if data is not None:
+ self.post_message(self.DataLoadError(e))
+
+ self._column_labels: list[str | Text] | None = (
+ list(column_labels) if column_labels is not None else None
+ )
+ self._column_widths: list[int | None] | None = (
+ list(column_widths) if column_widths is not None else None
+ )
+ self.max_column_content_width: int | None = max_column_content_width
+ self._ordered_columns: None | list[Column] = None
+
+ self._row_render_cache: LRUCache[
+ RowCacheKey, tuple[SegmentLines, SegmentLines]
+ ] = LRUCache(1000)
+ """For each row (a row can have a height of multiple lines), we maintain a
+ cache of the fixed and scrollable lines within that row to minimise how often
+ we need to re-render it. """
+ self._cell_render_cache: LRUCache[CellCacheKey, SegmentLines] = LRUCache(10000)
+ """Cache for individual cells."""
+ self._line_cache: LRUCache[LineCacheKey, Strip] = LRUCache(1000)
+ """Cache for lines within rows."""
+ self._tooltip_cache: LRUCache[TooltipCacheKey, RenderableType | None] = (
+ LRUCache(1000)
+ )
+ """Cache for values for the tooltip"""
+ # self._offset_cache: LRUCache[int, list[tuple[RowKey, int]]] = LRUCache(1)
+ """Cached y_offset - key is update_count - see y_offsets property for more
+ information """
+ # self._ordered_row_cache: LRUCache[tuple[int, int], list[Row]] = LRUCache(1)
+ """Caches row ordering - key is (num_rows, update_count)."""
+
+ self._pseudo_class_state = PseudoClasses(False, False, False)
+ """The pseudo-class state is used as part of cache keys to ensure that,
+ for example, when we lose focus on the DataTable, rules which apply to :focus
+ are invalidated and we prevent lingering styles."""
+
+ self._require_update_dimensions: bool = True
+ """Set to re-calculate dimensions on idle."""
+ # TODO: support mutable data
+ # self._new_rows: set[RowKey] = set()
+ """Tracking newly added rows to be used in calculation of dimensions on idle."""
+ # self._updated_cells: set[CellKey] = set()
+ """Track which cells were updated, so that we can refresh them once on idle."""
+
+ self._show_hover_cursor = False
+ """Used to hide the mouse hover cursor when the user uses the keyboard."""
+ self._update_count = 0
+ """Number of update (INCLUDING SORT) operations so far.
+ Used for cache invalidation."""
+ self._header_row_index = -1
+ """The header is a special row - not part of the data. Retrieve via this key."""
+ self._label_column_index = -1
+ """The column containing row labels is not part of the data.
+ This key identifies it."""
+ self._labelled_row_exists = False
+ """Whether or not the user has supplied any rows with labels."""
+ self._label_column = Column(Text(), auto_width=True)
+ """The largest content width out of all row labels in the table."""
+
+ self.show_header = show_header
+ """Show/hide the header row (the row of column labels)."""
+ self.show_row_labels = show_row_labels
+ """Show/hide the column containing the labels of rows."""
+ self.header_height = header_height
+ """The height of the header row (the row of column labels)."""
+ self.fixed_rows = fixed_rows
+ """The number of rows to fix (prevented from scrolling)."""
+ self.fixed_columns = fixed_columns
+ """The number of columns to fix (prevented from scrolling)."""
+ self.zebra_stripes = zebra_stripes
+ """Apply zebra effect on row backgrounds (light, dark, light, dark, ...)."""
+ self.show_cursor = show_cursor
+ """Show/hide both the keyboard and hover cursor."""
+ self.cursor_foreground_priority = cursor_foreground_priority
+ """Should we prioritize the cursor component class CSS foreground or the
+ renderable foreground in the event where a cell contains a renderable with a
+ foreground color."""
+ self.cursor_background_priority = cursor_background_priority
+ """Should we prioritize the cursor component class CSS background or the
+ renderable background in the event where a cell contains a renderable with a
+ background color."""
+ self.cursor_type = cursor_type
+ """The type of cursor of the `DataTable`."""
+ self.null_rep = Text.from_markup(null_rep)
+ """The string used to represent missing data (None or null)"""
+ self.render_markup = render_markup
+ """If true, render string data as Rich markup."""
+
+ @property
+ def hover_row(self) -> int:
+ """The index of the row that the mouse cursor is currently hovering above."""
+ return self.hover_coordinate.row
+
+ @property
+ def hover_column(self) -> int:
+ """The index of the column that the mouse cursor is currently hovering
+ above."""
+ return self.hover_coordinate.column
+
+ @property
+ def cursor_row(self) -> int:
+ """The index of the row that the DataTable cursor is currently on."""
+ return self.cursor_coordinate.row
+
+ @property
+ def cursor_column(self) -> int:
+ """The index of the column that the DataTable cursor is currently on."""
+ return self.cursor_coordinate.column
+
+ @property
+ def selection_anchor_row(self) -> int | None:
+ """The index of the row that the DataTable select range starts on."""
+ if self.selection_anchor_coordinate is None:
+ return None
+ return self.selection_anchor_coordinate.row
+
+ @property
+ def selection_anchor_column(self) -> int | None:
+ """The index of the column that the DataTable select range starts on."""
+ if self.selection_anchor_coordinate is None:
+ return None
+ return self.selection_anchor_coordinate.column
+
+ @property
+ def source_row_count(self) -> int:
+ """The number of rows in the data source used to create the table."""
+ if self.backend is None:
+ return 0
+ else:
+ return self.backend.source_row_count
+
+ @property
+ def row_count(self) -> int:
+ """The number of rows currently present in the DataTable."""
+ if self.backend is None:
+ return 0
+ else:
+ return self.backend.row_count
+
+ @property
+ def _total_row_height(self) -> int:
+ """
+ The total height of all rows within the DataTable, NOT including the header.
+ """
+ # TODO: support rows with height > 1
+ return self.row_count
+
+ @property
+ def column_count(self) -> int:
+ if self.backend is not None:
+ return self.backend.column_count
+ elif self._column_labels is not None:
+ return len(self._column_labels)
+ else:
+ return 0
+
+ def update_cell(
+ self,
+ row_index: int,
+ column_index: int,
+ value: CellType,
+ *,
+ update_width: bool = False,
+ ) -> None:
+ """Update the cell identified by the specified row key and column key.
+
+ Args:
+ row_key: The key identifying the row.
+ column_key: The key identifying the column.
+ value: The new value to put inside the cell.
+ update_width: Whether to resize the column width to accommodate
+ for the new cell content.
+
+ Raises:
+ CellDoesNotExist: When the supplied `row_key` and `column_key`
+ cannot be found in the table.
+ """
+ if self.backend is None:
+ raise CellDoesNotExist("No data in the table")
+ try:
+ self.backend.update_cell(row_index, column_index, value)
+ except IndexError:
+ raise CellDoesNotExist(
+ f"No cell exists for row_key={row_index}, column_key={column_index}."
+ ) from None
+ self._update_count += 1
+
+ # Recalculate widths if necessary
+ if update_width:
+ self._require_update_dimensions = True
+
+ self.refresh()
+
+ def update_cell_at(
+ self, coordinate: Coordinate, value: CellType, *, update_width: bool = False
+ ) -> None:
+ """Update the content inside the cell currently occupying the given coordinate.
+
+ Args:
+ coordinate: The coordinate to update the cell at.
+ value: The new value to place inside the cell.
+ update_width: Whether to resize the column width to accommodate
+ for the new cell content.
+ """
+ raise NotImplementedError("No updates allowed.")
+ if not self.is_valid_coordinate(coordinate):
+ raise CellDoesNotExist(f"Coordinate {coordinate!r} is invalid.")
+
+ row_key, column_key = self.coordinate_to_cell_key(coordinate)
+ self.update_cell(row_key, column_key, value, update_width=update_width)
+
+ def get_cell_at(self, coordinate: Coordinate) -> Any:
+ """Get the value from the cell occupying the given coordinate.
+
+ Args:
+ coordinate: The coordinate to retrieve the value from.
+
+ Returns:
+ The value of the cell at the coordinate.
+
+ Raises:
+ CellDoesNotExist: If there is no cell with the given coordinate.
+ """
+ if self.backend is None:
+ raise CellDoesNotExist("No data in the table")
+ try:
+ return self.backend.get_cell_at(coordinate.row, coordinate.column)
+ except IndexError as e:
+ raise CellDoesNotExist(f"No cell exists at coordinate {coordinate}.") from e
+
+ # def get_row(self, row_key: RowKey | str) -> list[CellType]:
+ # """Get the values from the row identified by the given row key.
+
+ # Args:
+ # row_key: The key of the row.
+
+ # Returns:
+ # A list of the values contained within the row.
+
+ # Raises:
+ # RowDoesNotExist: When there is no row corresponding to the key.
+ # """
+ # raise NotImplementedError("Use get_row_at instead.")
+ # if row_key not in self._row_locations:
+ # raise RowDoesNotExist(f"Row key {row_key!r} is not valid.")
+ # cell_mapping: dict[ColumnKey, CellType] = self._data.get(row_key, {})
+ # ordered_row: list[CellType] = [
+ # cell_mapping[column.key] for column in self.ordered_columns
+ # ]
+ # return ordered_row
+
+ def get_row_at(self, row_index: int) -> list[Any]:
+ """Get the values from the cells in a row at a given index. This will
+ return the values from a row based on the rows _current position_ in
+ the table.
+
+ Args:
+ row_index: The index of the row.
+
+ Returns:
+ A list of the values contained in the row.
+
+ Raises:
+ RowDoesNotExist: If there is no row with the given index.
+ """
+ if self.backend is None or not self.is_valid_row_index(row_index):
+ raise RowDoesNotExist(f"Row index {row_index!r} is not valid.")
+ return list(self.backend.get_row_at(row_index))
+
+ # def get_column(self, column_key: ColumnKey | str) -> Iterable[CellType]:
+ # """Get the values from the column identified by the given column key.
+
+ # Args:
+ # column_key: The key of the column.
+
+ # Returns:
+ # A generator which yields the cells in the column.
+
+ # Raises:
+ # ColumnDoesNotExist: If there is no column corresponding to the key.
+ # """
+ # raise NotImplementedError("Use get_column_at instead.")
+ # if column_key not in self._column_locations:
+ # raise ColumnDoesNotExist(f"Column key {column_key!r} is not valid.")
+
+ # data = self._data
+ # for row_metadata in self.ordered_rows:
+ # row_key = row_metadata.key
+ # yield data[row_key][column_key]
+
+ def get_column_at(self, column_index: int) -> Iterable[Any]:
+ """Get the values from the column at a given index.
+
+ Args:
+ column_index: The index of the column.
+
+ Returns:
+ A generator which yields the cells in the column.
+
+ Raises:
+ ColumnDoesNotExist: If there is no column with the given index.
+ """
+ if self.backend is None or not self.is_valid_column_index(column_index):
+ raise ColumnDoesNotExist(f"Column index {column_index!r} is not valid.")
+
+ yield from self.backend.get_column_at(column_index)
+
+ def _clear_caches(self) -> None:
+ self._row_render_cache.clear()
+ self._cell_render_cache.clear()
+ self._line_cache.clear()
+ self._styles_cache.clear()
+ self._tooltip_cache.clear()
+ # self._offset_cache.clear()
+ # self._ordered_row_cache.clear()
+ self._get_styles_to_render_cell.cache_clear()
+
+ def get_row_height(self, row_index: int) -> int:
+ """Given a row key, return the height of that row in terminal cells.
+
+ Args:
+ row_key: The key of the row.
+
+ Returns:
+ The height of the row, measured in terminal character cells.
+ """
+ return 1
+ # TODO: support variable height rows.
+ # if row_key is self._header_row_key:
+ # return self.header_height
+ # return self.rows[row_key].height
+
+ def notify_style_update(self) -> None:
+ self._clear_caches()
+ self.refresh()
+
+ def _on_resize(self, _: events.Resize) -> None:
+ self._update_count += 1
+
+ def watch_show_cursor(self, show_cursor: bool) -> None:
+ self._clear_caches()
+ if show_cursor and self.cursor_type != "none":
+ # When we re-enable the cursor, apply highlighting and
+ # post the appropriate [Row|Column|Cell]Highlighted event.
+ self._scroll_cursor_into_view(animate=False)
+ if self.cursor_type == "cell":
+ self._highlight_coordinate(self.cursor_coordinate)
+ elif self.cursor_type == "range":
+ self._highlight_range(
+ self.cursor_coordinate, self.selection_anchor_coordinate
+ )
+ elif self.cursor_type == "row":
+ self._highlight_row(self.cursor_row)
+ elif self.cursor_type == "column":
+ self._highlight_column(self.cursor_column)
+
+ def watch_show_header(self, show: bool) -> None:
+ width, height = self.virtual_size
+ height_change = self.header_height if show else -self.header_height
+ self.virtual_size = Size(width, height + height_change)
+ self._scroll_cursor_into_view()
+ self._clear_caches()
+
+ def watch_show_row_labels(self, show: bool) -> None:
+ width, height = self.virtual_size
+ column_width = self._label_column.render_width
+ width_change = column_width if show else -column_width
+ self.virtual_size = Size(width + width_change, height)
+ self._scroll_cursor_into_view()
+ self._clear_caches()
+
+ def watch_fixed_rows(self) -> None:
+ self._clear_caches()
+
+ def watch_fixed_columns(self) -> None:
+ self._clear_caches()
+
+ def watch_zebra_stripes(self) -> None:
+ self._clear_caches()
+
+ def watch_hover_coordinate(self, old: Coordinate, value: Coordinate) -> None:
+ self.refresh_coordinate(old)
+ self.refresh_coordinate(value)
+
+ def watch_cursor_coordinate(
+ self, old_coordinate: Coordinate, new_coordinate: Coordinate
+ ) -> None:
+ if old_coordinate != new_coordinate:
+ # Refresh the old and the new cell, and post the appropriate
+ # message to tell users of the newly highlighted row/cell/column.
+ if self.cursor_type == "cell":
+ self.refresh_coordinate(old_coordinate)
+ self._highlight_coordinate(new_coordinate)
+ elif self.cursor_type == "range":
+ self.refresh_range(old_coordinate, self.selection_anchor_coordinate)
+ self._highlight_range(new_coordinate, self.selection_anchor_coordinate)
+ elif self.cursor_type == "row":
+ self.refresh_row(old_coordinate.row)
+ self._highlight_row(new_coordinate.row)
+ elif self.cursor_type == "column":
+ self.refresh_column(old_coordinate.column)
+ self._highlight_column(new_coordinate.column)
+ # If the coordinate was changed via `move_cursor`, give priority to its
+ # scrolling because it may be animated.
+ self.call_next(self._scroll_cursor_into_view)
+
+ def watch_selection_anchor_coordinate(
+ self, old_coordinate: Coordinate | None, new_coordinate: Coordinate | None
+ ) -> None:
+ if self.cursor_type == "range" and old_coordinate != new_coordinate:
+ # Refresh the old and the new cell, and post the appropriate
+ # message to tell users of the newly highlighted row/cell/column.
+ self.refresh_range(self.cursor_coordinate, old_coordinate)
+ self._highlight_range(self.cursor_coordinate, new_coordinate)
+ # If the coordinate was changed via `move_cursor`, give priority to its
+ # scrolling because it may be animated.
+ self.call_next(self._scroll_cursor_into_view)
+
+ def move_cursor(
+ self,
+ *,
+ row: int | None = None,
+ column: int | None = None,
+ animate: bool = False,
+ select: bool = False,
+ ) -> None:
+ """Move the cursor to the given position.
+
+ Example:
+ ```py
+ datatable = app.query_one(DataTable)
+ datatable.move_cursor(row=4, column=6)
+ # datatable.cursor_coordinate == Coordinate(4, 6)
+ datatable.move_cursor(row=3)
+ # datatable.cursor_coordinate == Coordinate(3, 6)
+ ```
+
+ Args:
+ row: The new row to move the cursor to.
+ column: The new column to move the cursor to.
+ animate: Whether to animate the change of coordinates.
+ """
+ if select and self.selection_anchor_coordinate is None:
+ self.selection_anchor_coordinate = self.cursor_coordinate
+ elif not select:
+ self.selection_anchor_coordinate = None
+
+ cursor_row, cursor_column = self.cursor_coordinate
+ if row is not None:
+ cursor_row = row
+ if column is not None:
+ cursor_column = column
+ destination = Coordinate(cursor_row, cursor_column)
+ self.cursor_coordinate = destination
+ self._scroll_cursor_into_view(animate=animate)
+
+ def _highlight_coordinate(self, coordinate: Coordinate) -> None:
+ """Apply highlighting to the cell at the coordinate, and post event."""
+ self.refresh_coordinate(coordinate)
+ try:
+ cell_value = self.get_cell_at(coordinate)
+ except CellDoesNotExist:
+ # The cell may not exist e.g. when the table is cleared.
+ # In that case, there's nothing for us to do here.
+ return
+ else:
+ self.post_message(
+ DataTable.CellHighlighted(self, cell_value, coordinate=coordinate)
+ )
+
+ def _highlight_range(
+ self,
+ cursor_coordinate: Coordinate,
+ selection_anchor_coordinate: Coordinate | None,
+ ) -> None:
+ """Apply highlighting to the range at the coordinate, and post event."""
+ self.refresh_range(cursor_coordinate, selection_anchor_coordinate)
+ # TODO: make an event for this.
+ try:
+ cell_value = self.get_cell_at(cursor_coordinate)
+ except CellDoesNotExist:
+ # The cell may not exist e.g. when the table is cleared.
+ # In that case, there's nothing for us to do here.
+ return
+ else:
+ self.post_message(
+ DataTable.CellHighlighted(
+ self, cell_value, coordinate=cursor_coordinate
+ )
+ )
+
+ def _highlight_row(self, row_index: int) -> None:
+ """Apply highlighting to the row at the given index, and post event."""
+ self.refresh_row(row_index)
+ if self.is_valid_row_index(row_index):
+ self.post_message(DataTable.RowHighlighted(self, row_index))
+
+ def _highlight_column(self, column_index: int) -> None:
+ """Apply highlighting to the column at the given index, and post event."""
+ self.refresh_column(column_index)
+ if self.is_valid_column_index(column_index):
+ self.post_message(DataTable.ColumnHighlighted(self, column_index))
+
+ def validate_cursor_coordinate(self, value: Coordinate) -> Coordinate:
+ return self._clamp_cursor_coordinate(value)
+
+ def validate_selection_anchor_coordinate(
+ self, value: Coordinate | None
+ ) -> Coordinate | None:
+ if value is None:
+ return None
+ return self._clamp_cursor_coordinate(value)
+
+ def _clamp_cursor_coordinate(self, coordinate: Coordinate) -> Coordinate:
+ """Clamp a coordinate such that it falls within the boundaries of the table."""
+ row, column = coordinate
+ row = clamp(row, 0, self.row_count - 1)
+ column = clamp(column, 0, self.column_count - 1)
+ return Coordinate(row, column)
+
+ def watch_cursor_type(self, old: str, new: str) -> None:
+ self._set_hover_cursor(False)
+ if self.show_cursor:
+ self._highlight_cursor()
+
+ # Refresh cells that were previously impacted by the cursor
+ # but may no longer be.
+ if old == "cell":
+ self.refresh_coordinate(self.cursor_coordinate)
+ elif old == "range":
+ self.refresh_range(self.cursor_coordinate, self.selection_anchor_coordinate)
+ self.selection_anchor_coordinate = None
+ elif old == "row":
+ row_index, _ = self.cursor_coordinate
+ self.refresh_row(row_index)
+ elif old == "column":
+ _, column_index = self.cursor_coordinate
+ self.refresh_column(column_index)
+
+ self._scroll_cursor_into_view()
+
+ def _highlight_cursor(self) -> None:
+ """Applies the appropriate highlighting and raises the appropriate
+ [Row|Column|Cell]Highlighted event for the given cursor coordinate
+ and cursor type."""
+ row_index, column_index = self.cursor_coordinate
+ cursor_type = self.cursor_type
+ # Apply the highlighting to the newly relevant cells
+ if cursor_type == "cell":
+ self._highlight_coordinate(self.cursor_coordinate)
+ elif cursor_type == "range":
+ self._highlight_range(
+ self.cursor_coordinate, self.selection_anchor_coordinate
+ )
+ elif cursor_type == "row":
+ self._highlight_row(row_index)
+ elif cursor_type == "column":
+ self._highlight_column(column_index)
+
+ @property
+ def _row_label_column_width(self) -> int:
+ """The render width of the column containing row labels"""
+ return self._label_column.render_width if self._should_render_row_labels else 0
+
+ def _update_dimensions(self, new_rows: Iterable[int]) -> None:
+ """Called to recalculate the virtual (scrollable) size.
+
+ This recomputes column widths and then checks if any of the new rows need
+ to have their height computed.
+
+ Args:
+ new_rows: The new rows that will affect the `DataTable` dimensions.
+ """
+ console = self.app.console
+ auto_height_rows: list[tuple[int, int, list[RenderableType]]] = []
+ for row_index in new_rows:
+ # The row could have been removed before on_idle was called, so we
+ # need to be quite defensive here and don't assume that the row exists.
+ if not self.is_valid_row_index(row_index):
+ continue
+
+ # TODO: support row labels
+ # row = self.rows.get(row_key)
+ # assert row is not None
+
+ # if row.label is not None:
+ # self._labelled_row_exists = True
+
+ row_label, cells_in_row = self._get_row_renderables(row_index)
+ label_content_width = measure(console, row_label, 1) if row_label else 0
+ self._label_column.content_width = max(
+ self._label_column.content_width, label_content_width
+ )
+
+ for column, renderable in zip(self.ordered_columns, cells_in_row):
+ content_width = measure(console, renderable, 1)
+ column.content_width = max(column.content_width, content_width)
+
+ # TODO: support row HEIGHT > 1
+ # if row.auto_height:
+ # auto_height_rows.append((row_index, row, cells_in_row))
+
+ # If there are rows that need to have their height computed, render them
+ # correctly so that we can cache this rendering for later.
+ if auto_height_rows:
+ raise NotImplementedError("todo: support auto-height rows.")
+ render_cell = self._render_cell # This method renders & caches.
+ should_highlight = self._should_highlight
+ cursor_type = self.cursor_type
+ cursor_location = self.cursor_coordinate
+ # TODO: handle range selection
+ hover_location = self.hover_coordinate
+ base_style = self.rich_style
+ fixed_style = self.get_component_styles(
+ "datatable--fixed"
+ ).rich_style + Style.from_meta({"fixed": True})
+ ordered_columns = self.ordered_columns
+ fixed_columns = self.fixed_columns
+
+ for row_index, row, _ in auto_height_rows:
+ height = 0
+ row_style = self._get_row_style(row_index, base_style)
+
+ # As we go through the cells, save their rendering, height, and
+ # column width. After we compute the height of the row, go over
+ # the cells
+ # that were rendered with the wrong height and append the missing
+ # padding.
+ rendered_cells: list[tuple[SegmentLines, int, int]] = []
+ for column_index, column in enumerate(ordered_columns):
+ style = fixed_style if column_index < fixed_columns else row_style
+ cell_location = Coordinate(row_index, column_index)
+ rendered_cell = render_cell(
+ row_index,
+ column_index,
+ style,
+ column.render_width,
+ cursor=should_highlight(
+ cursor_location, cell_location, cursor_type
+ ),
+ hover=should_highlight(
+ hover_location, cell_location, cursor_type
+ ),
+ # TODO: handle range selection
+ )
+ cell_height = len(rendered_cell)
+ rendered_cells.append(
+ (rendered_cell, cell_height, column.render_width)
+ )
+ height = max(height, cell_height)
+
+ row.height = height
+ # Do surgery on the cache for cells that were rendered with the
+ # incorrect height during the first pass.
+ for cell_renderable, cell_height, column_width in rendered_cells:
+ if cell_height < height:
+ first_line_space_style = cell_renderable[0][0].style
+ cell_renderable.extend(
+ [
+ [Segment(" " * column_width, first_line_space_style)]
+ for _ in range(height - cell_height)
+ ]
+ )
+
+ data_cells_width = sum(column.render_width for column in self.ordered_columns)
+ total_width = data_cells_width + self._row_label_column_width
+ header_height = self.header_height if self.show_header else 0
+ self.virtual_size = Size(
+ total_width,
+ self._total_row_height + header_height,
+ )
+
+ def _get_cell_region(self, coordinate: Coordinate) -> Region:
+ """Get the region of the cell at the given spatial coordinate."""
+ if not self.is_valid_coordinate(coordinate):
+ return Region(0, 0, 0, 0)
+
+ row_index, column_index = coordinate
+
+ # The x-coordinate of a cell is the sum of widths of the data cells to the left
+ # plus the width of the render width of the longest row label.
+ x = (
+ sum(column.render_width for column in self.ordered_columns[:column_index])
+ # TODO: support row labels
+ # + self._row_label_column_width
+ )
+ width = self.ordered_columns[column_index].render_width
+ height = 1 # row.height
+ # TODO: support multiple heights
+ # y = sum(ordered_row.height for ordered_row in self.ordered_rows[:row_index])
+ y = row_index
+ if self.show_header:
+ y += self.header_height
+ cell_region = Region(x, y, width, height)
+ return cell_region
+
+ def _get_range_region(self, coordinate: Coordinate, anchor: Coordinate) -> Region:
+ """
+ Get the region of the range defined by the box with corners at anchor
+ and coordinate.
+ """
+ if not self.is_valid_coordinate(coordinate) or not self.is_valid_coordinate(
+ anchor
+ ):
+ return Region(0, 0, 0, 0)
+
+ min_row, max_row, min_col, max_col = self._order_bounding_coords(
+ coordinate, anchor
+ )
+
+ x = (
+ sum(column.render_width for column in self.ordered_columns[:min_col])
+ # TODO: support row labels
+ # + self._row_label_column_width
+ )
+ width = sum(
+ column.render_width
+ for column in self.ordered_columns[min_col : max_col + 1]
+ )
+ y = min_row
+ if self.show_header:
+ y += self.header_height
+
+ # TODO: support variable-height rows
+ height = 1 * (max_row - min_row + 1)
+ return Region(x=x, y=y, width=width, height=height)
+
+ def _get_row_region(self, row_index: int) -> Region:
+ """Get the region of the row at the given index."""
+ if not self.is_valid_row_index(row_index):
+ return Region(0, 0, 0, 0)
+
+ row_width = (
+ sum(column.render_width for column in self.ordered_columns)
+ # TODO: support row labels
+ # + self._row_label_column_width
+ )
+ # TODO: support multiple heights
+ # y = sum(ordered_row.height for ordered_row in self.ordered_rows[:row_index])
+ y = row_index
+ if self.show_header:
+ y += self.header_height
+ row_region = Region(0, y, row_width, 1) # row.height)
+ return row_region
+
+ def _get_column_region(self, column_index: int) -> Region:
+ """Get the region of the column at the given index."""
+ if not self.is_valid_column_index(column_index):
+ return Region(0, 0, 0, 0)
+
+ x = (
+ sum(column.render_width for column in self.ordered_columns[:column_index])
+ + self._row_label_column_width
+ )
+ width = self.ordered_columns[column_index].render_width
+ header_height = self.header_height if self.show_header else 0
+ height = self._total_row_height + header_height
+ full_column_region = Region(x, 0, width, height)
+ return full_column_region
+
+ def clear(self, columns: bool = False) -> Self:
+ """Clear the table.
+
+ Args:
+ columns: Also clear the columns.
+
+ Returns:
+ The `DataTable` instance.
+ """
+ # TODO: make Backend optional and reactive?
+ raise NotImplementedError("Unmount this table and mount a new one instead.")
+ self._clear_caches()
+ self._y_offsets.clear()
+ self._data.clear()
+ self.rows.clear()
+ self._row_locations = TwoWayDict({})
+ if columns:
+ self.columns.clear()
+ self._column_locations = TwoWayDict({})
+ self._require_update_dimensions = True
+ self.cursor_coordinate = Coordinate(0, 0)
+ self.selection_anchor_coordinate = None
+ self.hover_coordinate = Coordinate(0, 0)
+ self._label_column = Column(self._label_column_key, Text(), auto_width=True)
+ self._labelled_row_exists = False
+ self.refresh()
+ self.scroll_x = 0
+ self.scroll_y = 0
+ self.scroll_target_x = 0
+ self.scroll_target_y = 0
+ return self
+
+ def add_column(
+ self,
+ label: TextType,
+ *,
+ width: int | None = None,
+ default: CellType | None = None,
+ ) -> int:
+ """Add a column to the table.
+
+ Args:
+ label: A str or Text object containing the label (shown top of column).
+ width: Width of the column in cells or None to fit content.
+ key: A key which uniquely identifies this column.
+ If None, it will be generated for you.
+ default: The value to insert into pre-existing rows.
+
+ Returns:
+ Uniquely identifies this column. Can be used to retrieve this column
+ regardless of its current location in the DataTable (it could have moved
+ after being added due to sorting/insertion/deletion of other columns).
+ """
+ label = Text.from_markup(label) if isinstance(label, str) else label
+ label_width = measure(self.app.console, label, 1)
+ if width is None:
+ col = Column(
+ label,
+ label_width,
+ content_width=label_width,
+ auto_width=True,
+ )
+ else:
+ col = Column(
+ label,
+ width,
+ content_width=label_width,
+ )
+ if self._ordered_columns is not None:
+ self._ordered_columns.append(col)
+ elif self._column_labels is not None:
+ self._column_labels.append(label)
+
+ if self._column_widths is not None:
+ self._column_widths.append(width)
+
+ # Update backend to account for the new column.
+ if self.backend is not None:
+ column_index = self.backend.append_column(str(label), default=default)
+ elif self._column_labels is not None:
+ column_index = len(self._column_labels)
+ else:
+ column_index = 0
+
+ self._require_update_dimensions = True
+ self.check_idle()
+
+ return column_index
+
+ def add_row(
+ self,
+ *cells: CellType,
+ height: int | None = 1,
+ label: TextType | None = None,
+ ) -> int:
+ """Add a row at the bottom of the DataTable.
+
+ Args:
+ *cells: Positional arguments should contain cell data.
+ height: The height of a row (in lines). Use `None` to auto-detect the
+ optimal height.
+ key: A key which uniquely identifies this row. If None, it will be
+ generated for you and returned.
+ label: The label for the row. Will be displayed to the left if supplied.
+
+ Returns:
+ Unique identifier for this row. Can be used to retrieve this row regardless
+ of its current location in the DataTable (it could have moved after
+ being added due to sorting or insertion/deletion of other rows).
+ """
+ if label is not None:
+ raise NotImplementedError("todo: support labeled rows")
+ elif height != 1:
+ raise NotImplementedError("todo: support auto-height rows")
+
+ [index] = self.add_rows([cells])
+ return index
+
+ def add_columns(self, *labels: TextType) -> list[int]:
+ """Add a number of columns.
+
+ Args:
+ *labels: Column headers.
+
+ Returns:
+ A list of the keys for the columns that were added. See
+ the `add_column` method docstring for more information on how
+ these keys are used.
+ """
+ column_indexes = []
+ for label in labels:
+ column_index = self.add_column(label, width=None)
+ column_indexes.append(column_index)
+ return column_indexes
+
+ def add_rows(self, rows: Iterable[Iterable[Any]]) -> list[int]:
+ """Add a number of rows at the bottom of the DataTable.
+
+ Args:
+ rows: Iterable of rows. A row is an iterable of cells.
+
+ Returns:
+ A list of the keys for the rows that were added. See
+ the `add_row` method docstring for more information on how
+ these keys are used.
+ """
+ if self.backend is None:
+ self.backend = create_backend(list(rows))
+ indicies = list(range(self.row_count))
+ else:
+ indicies = self.backend.append_rows(rows)
+ self._require_update_dimensions = True
+ self.cursor_coordinate = self.cursor_coordinate
+
+ # If a position has opened for the cursor to appear, where it previously
+ # could not (e.g. when there's no data in the table), then a highlighted
+ # event is posted, since there's now a highlighted cell when there wasn't
+ # before.
+ cell_now_available = self.row_count == 1 and len(self.ordered_columns) > 0
+ visible_cursor = self.show_cursor and self.cursor_type != "none"
+ if cell_now_available and visible_cursor:
+ self._highlight_cursor()
+
+ self._update_count += 1
+ self.check_idle()
+ return indicies
+
+ def remove_row(self, row_index: int) -> None:
+ """Remove a row (identified by a key) from the DataTable.
+
+ Args:
+ row_key: The key identifying the row to remove.
+
+ Raises:
+ RowDoesNotExist: If the row key does not exist.
+ """
+ if self.backend is None:
+ raise RowDoesNotExist("No data in the table")
+ self.backend.drop_row(row_index)
+
+ self.cursor_coordinate = self.cursor_coordinate
+ self.selection_anchor_coordinate = self.selection_anchor_coordinate
+ self.hover_coordinate = self.hover_coordinate
+
+ self._update_count += 1
+ self._require_update_dimensions = True
+ self.refresh(layout=True)
+ self.check_idle()
+
+ # def remove_column(self, column_index: int) -> None:
+ # """Remove a column (identified by a key) from the DataTable.
+
+ # Args:
+ # column_key: The key identifying the column to remove.
+
+ # Raises:
+ # ColumnDoesNotExist: If the column key does not exist.
+ # """
+ # raise NotImplementedError("No updates allowed.")
+ # if column_key not in self._column_locations:
+ # raise ColumnDoesNotExist(f"Column key {column_key!r} is not valid.")
+
+ # self._require_update_dimensions = True
+ # self.check_idle()
+
+ # index_to_delete = self._column_locations.get(column_key)
+ # new_column_locations = TwoWayDict({})
+ # for column_location_key in self._column_locations:
+ # column_index = self._column_locations.get(column_location_key)
+ # if column_index > index_to_delete:
+ # new_column_locations[column_location_key] = column_index - 1
+ # elif column_index < index_to_delete:
+ # new_column_locations[column_location_key] = column_index
+
+ # self._column_locations = new_column_locations
+
+ # del self.columns[column_key]
+ # for row in self._data:
+ # del self._data[row][column_key]
+
+ # self.cursor_coordinate = self.cursor_coordinate
+ # self.selection_anchor_coordinate = self.selection_anchor_coordinate
+ # self.hover_coordinate = self.hover_coordinate
+
+ # self._update_count += 1
+ # self.refresh(layout=True)
+
+ async def _on_idle(self, _: events.Idle) -> None:
+ """Runs when the message pump is empty.
+
+ We use this for some expensive calculations like re-computing dimensions of the
+ whole DataTable and re-computing column widths after some cells
+ have been updated. This is more efficient in the case of high
+ frequency updates, ensuring we only do expensive computations once."""
+ # TODO: allow updates
+ pass
+ # if self._updated_cells:
+ # Cell contents have already been updated at this point.
+ # Now we only need to worry about measuring column widths.
+ # updated_columns = self._updated_cells.copy()
+ # self._updated_cells.clear()
+ # self._update_column_widths(updated_columns)
+
+ if self._require_update_dimensions:
+ # Add the new rows *before* updating the column widths, since
+ # cells in a new row may influence the final width of a column.
+ # Only then can we compute optimal height of rows with "auto" height.
+ self._require_update_dimensions = False
+ # new_rows = self._new_rows.copy()
+ new_rows: list[int] = []
+ # self._new_rows.clear()
+ self._update_dimensions(new_rows)
+
+ def refresh_coordinate(self, coordinate: Coordinate) -> Self:
+ """Refresh the cell at a coordinate.
+
+ Args:
+ coordinate: The coordinate to refresh.
+
+ Returns:
+ The `DataTable` instance.
+ """
+ if not self.is_valid_coordinate(coordinate):
+ return self
+ region = self._get_cell_region(coordinate)
+ self._refresh_region(region)
+ return self
+
+ def refresh_range(self, coordinate: Coordinate, anchor: Coordinate | None) -> Self:
+ """Refresh the cell at a coordinate.
+
+ Args:
+ coordinate: The coordinate to refresh.
+
+ Returns:
+ The `DataTable` instance.
+ """
+ if anchor is None:
+ return self.refresh_coordinate(coordinate)
+ if not self.is_valid_coordinate(coordinate):
+ return self
+ region = self._get_range_region(coordinate, anchor)
+ self._refresh_region(region)
+ return self
+
+ def refresh_row(self, row_index: int) -> Self:
+ """Refresh the row at the given index.
+
+ Args:
+ row_index: The index of the row to refresh.
+
+ Returns:
+ The `DataTable` instance.
+ """
+ if not self.is_valid_row_index(row_index):
+ return self
+
+ region = self._get_row_region(row_index)
+ self._refresh_region(region)
+ return self
+
+ def refresh_column(self, column_index: int) -> Self:
+ """Refresh the column at the given index.
+
+ Args:
+ column_index: The index of the column to refresh.
+
+ Returns:
+ The `DataTable` instance.
+ """
+ if not self.is_valid_column_index(column_index):
+ return self
+
+ region = self._get_column_region(column_index)
+ self._refresh_region(region)
+ return self
+
+ def _refresh_region(self, region: Region) -> Self:
+ """Refresh a region of the DataTable, if it's visible within the window.
+
+ This method will translate the region to account for scrolling.
+
+ Returns:
+ The `DataTable` instance.
+ """
+ if not self.window_region.overlaps(region):
+ return self
+ region = region.translate(-self.scroll_offset)
+ self.refresh(region)
+ return self
+
+ def is_valid_row_index(self, row_index: int) -> bool:
+ """Return a boolean indicating whether the row_index is within table bounds.
+
+ Args:
+ row_index: The row index to check.
+
+ Returns:
+ True if the row index is within the bounds of the table.
+ """
+ return 0 <= row_index < self.row_count
+
+ def is_valid_column_index(self, column_index: int) -> bool:
+ """Return a boolean indicating whether the column_index is within table bounds.
+
+ Args:
+ column_index: The column index to check.
+
+ Returns:
+ True if the column index is within the bounds of the table.
+ """
+ return 0 <= column_index < self.column_count
+
+ def is_valid_coordinate(self, coordinate: Coordinate) -> bool:
+ """Return a boolean indicating whether the given coordinate is valid.
+
+ Args:
+ coordinate: The coordinate to validate.
+
+ Returns:
+ True if the coordinate is within the bounds of the table.
+ """
+ row_index, column_index = coordinate
+ return self.is_valid_row_index(row_index) and self.is_valid_column_index(
+ column_index
+ )
+
+ @property
+ def ordered_columns(self) -> list[Column]:
+ """The list of Columns in the DataTable, ordered as they appear on screen."""
+ if self._column_labels is not None:
+ labels = self._column_labels
+ elif self.backend is not None:
+ labels = list(self.backend.columns)
+ else:
+ labels = []
+
+ if self._column_widths is not None:
+ widths = self._column_widths
+ else:
+ widths = [0] * len(labels)
+
+ if self.backend is not None:
+ column_content_widths = self.backend.column_content_widths
+ else:
+ column_content_widths = [0] * len(labels)
+
+ if self._ordered_columns is None:
+ self._ordered_columns = [
+ Column(
+ label=Text.from_markup(label) if isinstance(label, str) else label,
+ width=width if width is not None else 0,
+ content_width=content_width,
+ auto_width=True if width is None or width == 0 else False,
+ max_content_width=self.max_column_content_width,
+ )
+ for label, width, content_width in zip(
+ labels, widths, column_content_widths
+ )
+ ]
+ return self._ordered_columns
+
+ # @property
+ # def ordered_rows(self) -> list[Row]:
+ # """The list of Rows in the DataTable, ordered as they appear on screen."""
+ # raise NotImplementedError("Unused and unwise.")
+ # num_rows = self.row_count
+ # update_count = self._update_count
+ # cache_key = (num_rows, update_count)
+ # if cache_key in self._ordered_row_cache:
+ # ordered_rows = self._ordered_row_cache[cache_key]
+ # else:
+ # row_indices = range(num_rows)
+ # ordered_rows = []
+ # for row_index in row_indices:
+ # row_key = self._row_locations.get_key(row_index)
+ # row = self.rows[row_key]
+ # ordered_rows.append(row)
+ # self._ordered_row_cache[cache_key] = ordered_rows
+ # return ordered_rows
+
+ @property
+ def _should_render_row_labels(self) -> bool:
+ """Whether row labels should be rendered or not."""
+ return self._labelled_row_exists and self.show_row_labels
+
+ def _get_row_renderables(self, row_index: int) -> RowRenderables:
+ """Get renderables for the row currently at the given row index. The renderables
+ returned here have already been passed through the default_cell_formatter.
+
+ Args:
+ row_index: Index of the row.
+
+ Returns:
+ A RowRenderables containing the optional label and the rendered cells.
+ """
+ ordered_columns = self.ordered_columns
+ if row_index == -1:
+ header_row: list[RenderableType] = [
+ # TODO: make this pluggable so we can override the native labels
+ column.label
+ for column in ordered_columns
+ ]
+ # This is the cell where header and row labels intersect
+ return RowRenderables(None, header_row)
+
+ ordered_row = self.get_row_at(row_index)
+ empty = self.null_rep
+
+ formatted_row_cells = [
+ cell_formatter(
+ datum, null_rep=empty, col=col, render_markup=self.render_markup
+ )
+ for datum, col in zip_longest(ordered_row, self.ordered_columns)
+ ]
+ label = None
+ if self._should_render_row_labels:
+ raise NotImplementedError("Todo: support row labels")
+ # row_metadata = self.rows.get(self._row_locations.get_key(row_index))
+ # label = (
+ # default_cell_formatter(row_metadata.label)
+ # if row_metadata.label
+ # else None
+ # )
+ return RowRenderables(label, formatted_row_cells)
+
+ def _get_cell_renderable(
+ self, row_index: int, column_index: int
+ ) -> RenderableType | Text:
+ """Get renderables for the cell currently at the given row index,
+ column index tuple. The renderable
+ returned here has already been passed through the default_cell_formatter.
+
+ Args:
+ row_index: Index of the row.
+ column_index: Index of the column.
+
+ Returns:
+ A RenderableType (or Text) containing the the rendered cell.
+ """
+ if row_index == -1:
+ return self.ordered_columns[column_index].label
+
+ datum = self.get_cell_at(Coordinate(row=row_index, column=column_index))
+ return cell_formatter(
+ datum,
+ null_rep=self.null_rep,
+ col=self.ordered_columns[column_index],
+ render_markup=self.render_markup,
+ )
+
+ def _render_cell(
+ self,
+ row_index: int,
+ column_index: int,
+ base_style: Style,
+ width: int,
+ cursor: bool = False,
+ hover: bool = False,
+ in_range: bool = False,
+ ) -> SegmentLines:
+ """Render the given cell.
+
+ Args:
+ row_index: Index of the row.
+ column_index: Index of the column.
+ base_style: Style to apply.
+ width: Width of the cell.
+ cursor: Is this cell affected by cursor highlighting?
+ hover: Is this cell affected by hover cursor highlighting?
+ in_range: is this cell affected by selection range highlighting?
+
+ Returns:
+ A list of segments per line.
+ """
+ is_header_cell = row_index == -1
+ is_row_label_cell = column_index == -1
+
+ is_fixed_style_cell = (
+ not is_header_cell
+ and not is_row_label_cell
+ and (row_index < self.fixed_rows or column_index < self.fixed_columns)
+ )
+
+ cell_cache_key: CellCacheKey = (
+ row_index,
+ column_index,
+ base_style,
+ cursor,
+ hover,
+ in_range,
+ self._show_hover_cursor,
+ self._update_count,
+ self._pseudo_class_state,
+ )
+
+ if cell_cache_key not in self._cell_render_cache:
+ base_style += Style.from_meta({"row": row_index, "column": column_index})
+
+ if is_row_label_cell:
+ row_label, _ = self._get_row_renderables(row_index)
+ cell = row_label if row_label is not None else ""
+ else:
+ cell = self._get_cell_renderable(
+ row_index=row_index, column_index=column_index
+ )
+
+ component_style, post_style = self._get_styles_to_render_cell(
+ is_header_cell,
+ is_row_label_cell,
+ is_fixed_style_cell,
+ hover,
+ cursor,
+ in_range,
+ self.show_cursor,
+ self._show_hover_cursor,
+ self.cursor_type == "range",
+ self.cursor_foreground_priority == "css",
+ self.cursor_background_priority == "css",
+ )
+
+ if is_header_cell:
+ options = self.app.console.options.update_dimensions(
+ width, self.header_height
+ )
+ else:
+ # TODO: support rows with height > 1
+ # row = self.rows[row_key]
+ # If an auto-height row hasn't had its height calculated, we don't fix
+ # the value for `height` so that we can measure the height of the cell.
+ # if row.auto_height and row.height == 0:
+ # options = self.app.console.options.update_width(width)
+ # else:
+ options = self.app.console.options.update_dimensions(width, 1)
+ if self.max_column_content_width is not None:
+ options.overflow = "ellipsis"
+ options.no_wrap = True
+ lines = self.app.console.render_lines(
+ Styled(
+ Padding(cell, (0, 1)),
+ pre_style=base_style + component_style,
+ post_style=post_style,
+ ),
+ options,
+ )
+
+ self._cell_render_cache[cell_cache_key] = lines
+
+ return self._cell_render_cache[cell_cache_key]
+
+ @functools.lru_cache(maxsize=32) # noqa B019
+ def _get_styles_to_render_cell(
+ self,
+ is_header_cell: bool,
+ is_row_label_cell: bool,
+ is_fixed_style_cell: bool,
+ hover: bool,
+ cursor: bool,
+ in_range: bool,
+ show_cursor: bool,
+ show_hover_cursor: bool,
+ show_range_highlight: bool,
+ has_css_foreground_priority: bool,
+ has_css_background_priority: bool,
+ ) -> tuple[Style, Style]:
+ """Auxiliary method to compute styles used to render a given cell.
+
+ Args:
+ is_header_cell: Is this a cell from a header?
+ is_row_label_cell: Is this the label of any given row?
+ is_fixed_style_cell: Should this cell be styled like a fixed cell?
+ hover: Does this cell have the hover pseudo class?
+ cursor: Is this cell covered by the cursor?
+ in_range: Is this cell in the selection range?
+ show_cursor: Do we want to show the cursor in the data table?
+ show_hover_cursor: Do we want to show the mouse hover when using
+ the keyboard to move the cursor?
+ has_css_foreground_priority: `self.cursor_foreground_priority == "css"`?
+ has_css_background_priority: `self.cursor_background_priority == "css"`?
+ """
+ get_component = self.get_component_rich_style
+ component_style = Style()
+
+ if hover and show_cursor and show_hover_cursor:
+ component_style += get_component("datatable--hover")
+ if is_header_cell or is_row_label_cell:
+ # Apply subtle variation in style for the header/label (blue
+ # background by default) rows and columns affected by the cursor, to
+ # ensure we can still differentiate between the labels and the data.
+ component_style += get_component("datatable--header-hover")
+
+ if cursor and show_cursor:
+ cursor_style = get_component("datatable--cursor")
+ component_style += cursor_style
+ if is_header_cell or is_row_label_cell:
+ component_style += get_component("datatable--header-cursor")
+ elif is_fixed_style_cell:
+ component_style += get_component("datatable--fixed-cursor")
+ elif in_range and show_range_highlight and show_cursor:
+ cursor_style = get_component("datatable--selectrange")
+ component_style += cursor_style
+ if is_header_cell or is_row_label_cell:
+ component_style += get_component("datatable--header-selectrange")
+
+ post_foreground = (
+ Style.from_color(color=component_style.color)
+ if has_css_foreground_priority
+ else Style.null()
+ )
+ post_background = (
+ Style.from_color(bgcolor=component_style.bgcolor)
+ if has_css_background_priority
+ else Style.null()
+ )
+
+ return component_style, post_foreground + post_background
+
+ def _render_line_in_row(
+ self,
+ row_index: int,
+ line_no: int,
+ x1: int,
+ x2: int,
+ base_style: Style,
+ cursor_location: Coordinate,
+ hover_location: Coordinate,
+ selection_anchor_location: Coordinate | None,
+ ) -> tuple[SegmentLines, SegmentLines, int, int]:
+ """Render a single line from a row in the DataTable.
+
+ Args:
+ row_key: The identifying key for this row.
+ line_no: Line number (y-coordinate) within row. 0 is the first strip of
+ cells in the row, line_no=1 is the next line in the row, and so on...
+ x1: X start crop.
+ x2: X end crop (exclusive).
+ base_style: Base style of row.
+ cursor_location: The location of the cursor in the DataTable.
+ hover_location: The location of the hover cursor in the DataTable.
+ selection_anchor_location: The location of the selection anchor, or None.
+
+ Returns:
+ Lines for fixed cells, and Lines for scrollable cells, and the x-offset
+ for the first cell in scrollable cells.
+ """
+ cursor_type = self.cursor_type
+ show_cursor = self.show_cursor
+
+ # determine which columns are visible (col2 inclusive)
+ fixed_width = sum(
+ [col.render_width for col in self.ordered_columns[: self.fixed_columns]]
+ )
+ col_widths = [
+ col.render_width for col in self.ordered_columns[self.fixed_columns :]
+ ]
+ cumulative_width = 0
+ hidden_width = 0
+ visible_width = self.size.width - fixed_width
+ col1 = self.fixed_columns
+ col2 = None
+ for i, w in enumerate(col_widths, start=self.fixed_columns):
+ if cumulative_width < x1:
+ col1 = i
+ hidden_width = cumulative_width
+ if col2 is None and cumulative_width - x1 > visible_width:
+ col2 = i
+ break
+ cumulative_width += w
+ else:
+ col2 = len(self.ordered_columns) - 1
+
+ cache_key = (
+ row_index,
+ line_no,
+ col1,
+ col2,
+ base_style,
+ cursor_location,
+ hover_location,
+ selection_anchor_location,
+ cursor_type,
+ show_cursor,
+ self._show_hover_cursor,
+ self._update_count,
+ self._pseudo_class_state,
+ )
+
+ if cache_key in self._row_render_cache:
+ cache_contents = self._row_render_cache[cache_key]
+ return cache_contents[0], cache_contents[1], hidden_width, fixed_width
+
+ should_highlight = self._should_highlight
+ should_highlight_range = self._should_highlight_range
+ render_cell = self._render_cell
+ header_style = self.get_component_styles("datatable--header").rich_style
+
+ if not self.is_valid_row_index(row_index):
+ row_index = -1
+
+ # If the row has a label, add it to fixed_row here with correct style.
+ fixed_row = []
+
+ if self._labelled_row_exists and self.show_row_labels:
+ # The width of the row label is updated again on idle
+ cell_location = Coordinate(row_index, -1)
+ label_cell_lines = render_cell(
+ row_index,
+ -1,
+ header_style,
+ width=self._row_label_column_width,
+ cursor=should_highlight(cursor_location, cell_location, cursor_type),
+ hover=should_highlight(hover_location, cell_location, cursor_type),
+ in_range=should_highlight_range(
+ cursor_location,
+ selection_anchor_location,
+ cell_location,
+ cursor_type,
+ ),
+ )[line_no]
+ fixed_row.append(label_cell_lines)
+
+ if self.fixed_columns:
+ if row_index == self._header_row_index:
+ fixed_style = header_style # We use the header style either way.
+ else:
+ fixed_style = self.get_component_styles("datatable--fixed").rich_style
+ fixed_style += Style.from_meta({"fixed": True})
+ for column_index, column in enumerate(
+ self.ordered_columns[: self.fixed_columns]
+ ):
+ cell_location = Coordinate(row_index, column_index)
+ fixed_cell_lines = render_cell(
+ row_index,
+ column_index,
+ fixed_style,
+ column.render_width,
+ cursor=should_highlight(
+ cursor_location, cell_location, cursor_type
+ ),
+ hover=should_highlight(hover_location, cell_location, cursor_type),
+ in_range=should_highlight_range(
+ cursor_location,
+ selection_anchor_location,
+ cell_location,
+ cursor_type,
+ ),
+ )[line_no]
+ fixed_row.append(fixed_cell_lines)
+
+ row_style = self._get_row_style(row_index, base_style)
+
+ scrollable_row = []
+ visible_columns = self.ordered_columns[col1 : col2 + 1]
+ for column_index, column in enumerate(visible_columns, start=col1):
+ cell_location = Coordinate(row_index, column_index)
+ cell_lines = render_cell(
+ row_index,
+ column_index,
+ row_style,
+ column.render_width,
+ cursor=should_highlight(cursor_location, cell_location, cursor_type),
+ hover=should_highlight(hover_location, cell_location, cursor_type),
+ in_range=should_highlight_range(
+ cursor_location,
+ selection_anchor_location,
+ cell_location,
+ cursor_type,
+ ),
+ )[line_no]
+ scrollable_row.append(cell_lines)
+
+ # Extending the styling out horizontally to fill the container
+ widget_width = self.size.width
+ table_width = (
+ sum(col_widths[self.fixed_columns :]) + self._row_label_column_width
+ )
+ remaining_space = max(0, widget_width - table_width)
+ background_color = self.background_colors[1]
+ faded_color = Color.from_rich_color(row_style.bgcolor).blend(
+ background_color, factor=0.25
+ )
+ faded_style = Style.from_color(
+ color=row_style.color, bgcolor=faded_color.rich_color
+ )
+ scrollable_row.append([Segment(" " * remaining_space, faded_style)])
+
+ self._row_render_cache[cache_key] = (fixed_row, scrollable_row)
+ return (fixed_row, scrollable_row, hidden_width, fixed_width)
+
+ def _get_offsets(self, y: int) -> tuple[int, int]:
+ """Get row key and line offset for a given line.
+
+ Args:
+ y: Y coordinate relative to DataTable top.
+
+ Returns:
+ Row key and line (y) offset within cell.
+ """
+ raise NotImplementedError("todo: support heights > 1")
+ header_height = self.header_height
+ y_offsets = self._y_offsets
+ if self.show_header:
+ if y < header_height:
+ return self._header_row_key, y
+ y -= header_height
+ if y > len(y_offsets):
+ raise LookupError("Y coord {y!r} is greater than total height")
+
+ return y_offsets[y]
+
+ def _render_line(self, y: int, x1: int, x2: int, base_style: Style) -> Strip:
+ """Render a (possibly cropped) line in to a Strip (a list of segments
+ representing a horizontal line).
+
+ Args:
+ y: Y coordinate of line
+ x1: X start crop.
+ x2: X end crop (exclusive).
+ base_style: Style to apply to line.
+
+ Returns:
+ The Strip which represents this cropped line.
+ """
+
+ width = self.size.width
+
+ # todo: support rows with height > 1
+ # try:
+ # row_key, y_offset_in_row = self._get_offsets(y)
+ # except LookupError:
+ # return Strip.blank(width, base_style)
+ row_index = y - 1
+ if not self.is_valid_row_index(row_index) and row_index != -1:
+ return Strip.blank(width, base_style)
+
+ cache_key = (
+ y,
+ x1,
+ x2,
+ width,
+ self.cursor_coordinate,
+ self.hover_coordinate,
+ self.selection_anchor_coordinate,
+ base_style,
+ self.cursor_type,
+ self._show_hover_cursor,
+ self._update_count,
+ self._pseudo_class_state,
+ )
+ if cache_key in self._line_cache:
+ return self._line_cache[cache_key]
+
+ fixed, scrollable, hidden_width, fixed_width = self._render_line_in_row(
+ row_index,
+ 0,
+ x1,
+ x2,
+ base_style,
+ cursor_location=self.cursor_coordinate,
+ hover_location=self.hover_coordinate,
+ selection_anchor_location=self.selection_anchor_coordinate,
+ )
+
+ fixed_line: list[Segment] = list(chain.from_iterable(fixed)) if fixed else []
+ scrollable_line: list[Segment] = list(chain.from_iterable(scrollable))
+
+ segments = fixed_line + line_crop(
+ scrollable_line, x1 - hidden_width, x2 - hidden_width, width - fixed_width
+ )
+ strip = Strip(segments).adjust_cell_length(width, base_style).simplify()
+
+ self._line_cache[cache_key] = strip
+ return strip
+
+ def render_lines(self, crop: Region) -> list[Strip]:
+ self._pseudo_class_state = self.get_pseudo_class_state()
+ return super().render_lines(crop)
+
+ def render_line(self, y: int) -> Strip:
+ width, _ = self.size
+ scroll_x, scroll_y = self.scroll_offset
+
+ fixed_rows_height = self.fixed_rows
+ # sum(
+ # self.get_row_height(row_key) for row_key in fixed_row_keys
+ # )
+ if self.show_header:
+ fixed_rows_height += self.header_height
+
+ if y >= fixed_rows_height:
+ y += scroll_y
+
+ return self._render_line(y, scroll_x, scroll_x + width, self.rich_style)
+
+ def _should_highlight(
+ self,
+ cursor: Coordinate,
+ target_cell: Coordinate,
+ type_of_cursor: CursorType,
+ ) -> bool:
+ """Determine if the given cell should be highlighted because of the cursor.
+
+ This auxiliary method takes the cursor position and type into account when
+ determining whether the cell should be highlighted.
+
+ Args:
+ cursor: The current position of the cursor.
+ target_cell: The cell we're checking for the need to highlight.
+ type_of_cursor: The type of cursor that is currently active.
+
+ Returns:
+ Whether or not the given cell should be highlighted.
+ """
+ if type_of_cursor in ("cell", "range"):
+ return cursor == target_cell
+ elif type_of_cursor == "row":
+ cursor_row, _ = cursor
+ cell_row, _ = target_cell
+ return cursor_row == cell_row
+ elif type_of_cursor == "column":
+ _, cursor_column = cursor
+ _, cell_column = target_cell
+ return cursor_column == cell_column
+ else:
+ return False
+
+ def _should_highlight_range(
+ self,
+ cursor: Coordinate,
+ selection_anchor: Coordinate | None,
+ target_cell: Coordinate,
+ type_of_cursor: CursorType,
+ ) -> bool:
+ if type_of_cursor != "range":
+ return False
+ elif selection_anchor is None:
+ return False
+ min_row, max_row, min_col, max_col = self._order_bounding_coords(
+ cursor, selection_anchor
+ )
+ if (min_row <= target_cell.row <= max_row) and (
+ min_col <= target_cell.column <= max_col
+ ):
+ return True
+ else:
+ return False
+
+ def _get_row_style(self, row_index: int, base_style: Style) -> Style:
+ """Gets the Style that should be applied to the row at the given index.
+
+ Args:
+ row_index: The index of the row to style.
+ base_style: The base style to use by default.
+
+ Returns:
+ The appropriate style.
+ """
+
+ if row_index == -1:
+ row_style = self.get_component_styles("datatable--header").rich_style
+ elif row_index < self.fixed_rows:
+ row_style = self.get_component_styles("datatable--fixed").rich_style
+ else:
+ if self.zebra_stripes:
+ component_row_style = (
+ "datatable--odd-row" if row_index % 2 else "datatable--even-row"
+ )
+ row_style = self.get_component_styles(component_row_style).rich_style
+ else:
+ row_style = base_style
+ return row_style
+
+ def _on_leave(self, _: events.Leave) -> None:
+ self._set_hover_cursor(False)
+ self.tooltip = None
+
+ def _get_fixed_offset(self) -> Spacing:
+ """Calculate the "fixed offset", that is the space to the top and left
+ that is occupied by fixed rows and columns respectively. Fixed rows and columns
+ are rows and columns that do not participate in scrolling."""
+ top = self.header_height if self.show_header else 0
+ # TODO: Support row heights > 1
+ # top += sum(row.height for row in self.ordered_rows[: self.fixed_rows])
+ top += self.fixed_rows
+ left = (
+ sum(
+ column.render_width
+ for column in self.ordered_columns[: self.fixed_columns]
+ )
+ + self._row_label_column_width
+ )
+ return Spacing(top, 0, 0, left)
+
+ def sort(
+ self,
+ by: list[tuple[str, Literal["ascending", "descending"]]] | str,
+ ) -> Self:
+ """Sort the rows in the `DataTable` by one or more column keys.
+
+ Args:
+ by: str sorts table by the data in the column with that name (asc).
+ by: list[tuple] sorts the table by the named column(s) with the
+ directions indicated.
+
+ Returns:
+ The `DataTable` instance.
+ """
+ if self.backend is None:
+ return self
+ self.backend.sort(by=by)
+ self._update_count += 1
+ self.refresh()
+ return self
+
+ def _scroll_cursor_into_view(self, animate: bool = False) -> None:
+ """When the cursor is at a boundary of the DataTable and moves out
+ of view, this method handles scrolling to ensure it remains visible."""
+ fixed_offset = self._get_fixed_offset()
+ top, _, _, left = fixed_offset
+
+ if self.cursor_type == "row":
+ x, y, width, height = self._get_row_region(self.cursor_row)
+ region = Region(int(self.scroll_x) + left, y, width - left, height)
+ elif self.cursor_type == "column":
+ x, y, width, height = self._get_column_region(self.cursor_column)
+ region = Region(x, int(self.scroll_y) + top, width, height - top)
+ else:
+ region = self._get_cell_region(self.cursor_coordinate)
+
+ self.scroll_to_region(region, animate=animate, spacing=fixed_offset)
+
+ def _set_hover_cursor(self, active: bool) -> None:
+ """Set whether the hover cursor (the faint cursor you see when you
+ hover the mouse cursor over a cell) is visible or not. Typically,
+ when you interact with the keyboard, you want to switch the hover
+ cursor off.
+
+ Args:
+ active: Display the hover cursor.
+ """
+ self._show_hover_cursor = active
+ cursor_type = self.cursor_type
+ if cursor_type == "column":
+ self.refresh_column(self.hover_column)
+ elif cursor_type == "row":
+ self.refresh_row(self.hover_row)
+ elif cursor_type in ("cell", "range"):
+ self.refresh_coordinate(self.hover_coordinate)
+
+ @on(events.MouseDown)
+ async def move_cursor_to_mouse_down(self, event: events.MouseDown) -> None:
+ self._set_hover_cursor(True)
+ meta = event.style.meta
+ if not meta:
+ return
+
+ row_index = meta["row"]
+ column_index = meta["column"]
+ is_header_click = self.show_header and row_index == -1
+ is_row_label_click = self.show_row_labels and column_index == -1
+ if is_header_click:
+ # Header clicks work even if cursor is off, and doesn't move the cursor.
+ column = self.ordered_columns[column_index]
+ message = DataTable.HeaderSelected(self, column_index, label=column.label)
+ self.post_message(message)
+ elif is_row_label_click:
+ # TODO: support row labels.
+ # row = self.ordered_rows[row_index]
+ row_message = DataTable.RowLabelSelected(
+ self,
+ row_index,
+ label=Text(), # label=row.label
+ )
+ self.post_message(row_message)
+ elif self.show_cursor and self.cursor_type != "none":
+ # Only post selection events if there is a visible row/col/cell cursor.
+ self.selection_anchor_coordinate = None
+ self.cursor_coordinate = Coordinate(row_index, column_index)
+ self._scroll_cursor_into_view(animate=True)
+ event.stop()
+
+ @on(events.MouseUp)
+ async def move_cursor_to_mouse_up(self, event: events.MouseUp) -> None:
+ self._set_hover_cursor(True)
+ meta = event.style.meta
+ if not meta:
+ return
+
+ row_index = meta["row"]
+ column_index = meta["column"]
+ click_coordinate = Coordinate(row_index, column_index)
+ is_header_click = self.show_header and row_index == -1
+ # is_row_label_click = self.show_row_labels and column_index == -1
+ if is_header_click:
+ # don't move the cursor
+ pass
+ # elif is_row_label_click:
+ # # TODO: support row labels.
+ # # row = self.ordered_rows[row_index]
+ # row_message = DataTable.RowLabelSelected(
+ # self, row_index, label=Text() # label=row.label
+ # )
+ # self.post_message(row_message)
+ elif (
+ self.show_cursor
+ and self.cursor_type == "range"
+ and click_coordinate != self.cursor_coordinate
+ ):
+ # Only post selection events if there is a visible row/col/cell cursor.
+ if self.selection_anchor_coordinate is None:
+ self.selection_anchor_coordinate = self.cursor_coordinate
+ self.cursor_coordinate = click_coordinate
+ self._post_selected_message()
+ self._scroll_cursor_into_view(animate=True)
+ event.stop()
+
+ @on(events.MouseMove)
+ def set_hover_or_cursor_from_mouse(self, event: events.MouseMove) -> None:
+ """If the hover cursor is visible, display it by extracting the row
+ and column metadata from the segments present in the cells."""
+ self.tooltip = None
+ meta = event.style.meta
+ if not meta:
+ self._set_hover_cursor(False)
+ return
+ else:
+ self._set_hover_cursor(True)
+
+ mouse_coordinate = Coordinate(meta["row"], meta["column"])
+ if event.button == 1 and self.cursor_type == "range": # left click and drag
+ if self.selection_anchor_coordinate is None:
+ self.selection_anchor_coordinate = self.cursor_coordinate
+ self.cursor_coordinate = mouse_coordinate
+ self._scroll_cursor_into_view(animate=True)
+ event.stop()
+ elif self.show_cursor and self.cursor_type != "none":
+ try:
+ self.hover_coordinate = mouse_coordinate
+ except KeyError:
+ pass
+
+ self._set_tooltip_from_cell_at(mouse_coordinate)
+
+ def _set_tooltip_from_cell_at(self, coordinate: Coordinate) -> None:
+ # TODO: support row labels
+ cache_key = (coordinate.row, coordinate.column, self._update_count)
+ column = self.ordered_columns[coordinate.column]
+ if cache_key not in self._tooltip_cache:
+ if coordinate.row == -1: # hover over header
+ raw_value = column.label
+ else:
+ raw_value = self.get_cell_at(coordinate)
+ if raw_value is None:
+ raw_value = self.null_rep
+ measured_width = measure_width(raw_value, self.app.console)
+ if (
+ self.max_column_content_width is not None
+ and measured_width > self.max_column_content_width
+ ) or (measured_width > column.render_width):
+ if isinstance(raw_value, Text):
+ self._tooltip_cache[cache_key] = raw_value
+ elif isinstance(
+ raw_value,
+ (str, float, Decimal, int, datetime, time, date, timedelta),
+ ):
+ self._tooltip_cache[cache_key] = cell_formatter(
+ raw_value, null_rep=self.null_rep, col=column
+ )
+ else:
+ self._tooltip_cache[cache_key] = Pretty(raw_value)
+ else:
+ self._tooltip_cache[cache_key] = None
+ self.tooltip = self._tooltip_cache[cache_key]
+
+ def _set_selection_anchor(self, select: bool) -> None:
+ if (
+ select
+ and self.cursor_type == "range"
+ and self.selection_anchor_coordinate is None
+ ):
+ self.selection_anchor_coordinate = self.cursor_coordinate
+ elif not select:
+ self.selection_anchor_coordinate = None
+
+ def action_page_down(self, select: bool = False) -> None:
+ """Move the cursor one page down."""
+ self._set_hover_cursor(False)
+ if self.show_cursor and self.cursor_type in ("cell", "row", "range"):
+ height = self.size.height - (self.header_height if self.show_header else 0)
+
+ # Determine how many rows constitutes a "page"
+ rows_to_scroll = 0
+ row_index, column_index = self.cursor_coordinate
+ self._set_selection_anchor(select)
+ # TODO: support rows with height > 1
+ # for ordered_row in self.ordered_rows[row_index:]:
+ # offset += ordered_row.height
+ # if offset > height:
+ # break
+ # rows_to_scroll += 1
+ rows_to_scroll = height
+
+ self.cursor_coordinate = Coordinate(
+ row_index + rows_to_scroll - 1, column_index
+ )
+ else:
+ super().action_page_down()
+
+ def action_page_up(self, select: bool = False) -> None:
+ """Move the cursor one page up."""
+ self._set_hover_cursor(False)
+ if self.show_cursor and self.cursor_type in ("cell", "row", "range"):
+ height = self.size.height - (self.header_height if self.show_header else 0)
+
+ # Determine how many rows constitutes a "page"
+ row_index, column_index = self.cursor_coordinate
+ self._set_selection_anchor(select)
+ # TODO: support rows with height > 1
+ # rows_to_scroll = 0
+ # for ordered_row in self.ordered_rows[: row_index + 1]:
+ # offset += ordered_row.height
+ # if offset > height:
+ # break
+ # rows_to_scroll += 1
+ rows_to_scroll = min(row_index + 1, height)
+
+ self.cursor_coordinate = Coordinate(
+ row_index - rows_to_scroll + 1, column_index
+ )
+ else:
+ super().action_page_up()
+
+ def action_scroll_home(self, select: bool = False) -> None:
+ """Scroll to the top of the data table."""
+ self._set_hover_cursor(False)
+ cursor_type = self.cursor_type
+ if self.show_cursor and cursor_type in ("cell", "row", "range"):
+ row_index, column_index = self.cursor_coordinate
+ self._set_selection_anchor(select)
+ self.cursor_coordinate = Coordinate(0, column_index)
+ else:
+ super().action_scroll_home()
+
+ def action_scroll_end(self, select: bool = False) -> None:
+ """Scroll to the bottom of the data table."""
+ self._set_hover_cursor(False)
+ cursor_type = self.cursor_type
+ if self.show_cursor and cursor_type in ("cell", "row", "range"):
+ row_index, column_index = self.cursor_coordinate
+ self._set_selection_anchor(select)
+ self.cursor_coordinate = Coordinate(self.row_count - 1, column_index)
+ else:
+ super().action_scroll_end()
+
+ def action_cursor_up(self, select: bool = False) -> None:
+ self._set_hover_cursor(False)
+ cursor_type = self.cursor_type
+ if self.show_cursor and cursor_type in ("cell", "row", "range"):
+ self._set_selection_anchor(select)
+ self.cursor_coordinate = self.cursor_coordinate.up()
+ else:
+ # If the cursor doesn't move up (e.g. column cursor can't go up),
+ # then ensure that we instead scroll the DataTable.
+ super().action_scroll_up()
+
+ def action_cursor_down(self, select: bool = False) -> None:
+ self._set_hover_cursor(False)
+ cursor_type = self.cursor_type
+ if self.show_cursor and cursor_type in ("cell", "row", "range"):
+ self._set_selection_anchor(select)
+ self.cursor_coordinate = self.cursor_coordinate.down()
+ else:
+ super().action_scroll_down()
+
+ def action_cursor_right(self, select: bool = False) -> None:
+ self._set_hover_cursor(False)
+ cursor_type = self.cursor_type
+ if self.show_cursor and cursor_type in ("cell", "column", "range"):
+ self._set_selection_anchor(select)
+ self.cursor_coordinate = self.cursor_coordinate.right()
+ self._scroll_cursor_into_view(animate=True)
+ else:
+ super().action_scroll_right()
+
+ def action_cursor_left(self, select: bool = False) -> None:
+ self._set_hover_cursor(False)
+ cursor_type = self.cursor_type
+ if self.show_cursor and cursor_type in ("cell", "column", "range"):
+ self._set_selection_anchor(select)
+ self.cursor_coordinate = self.cursor_coordinate.left()
+ self._scroll_cursor_into_view(animate=True)
+ else:
+ super().action_scroll_left()
+
+ def action_cursor_next(self) -> None:
+ self._set_hover_cursor(False)
+ if not self.show_cursor:
+ super().action_scroll_right()
+ return
+ cursor_type = self.cursor_type
+ self.selection_anchor_coordinate = None
+ old_coordinate = self.cursor_coordinate
+ if cursor_type in ("cell", "column", "range"):
+ self.cursor_coordinate = self.cursor_coordinate.right()
+ if old_coordinate == self.cursor_coordinate: # at end of row
+ if old_coordinate.row < self.row_count - 1:
+ self.cursor_coordinate = Coordinate(old_coordinate.row + 1, 0)
+ else:
+ self.cursor_coordinate = Coordinate(0, 0)
+ elif cursor_type == "row":
+ self.cursor_coordinate = self.cursor_coordinate.down()
+ if old_coordinate == self.cursor_coordinate: # at end of table
+ self.cursor_coordinate = Coordinate(0, 0)
+
+ self._scroll_cursor_into_view(animate=True)
+
+ def action_cursor_prev(self) -> None:
+ self._set_hover_cursor(False)
+ if not self.show_cursor:
+ super().action_scroll_right()
+ return
+ cursor_type = self.cursor_type
+ self.selection_anchor_coordinate = None
+ old_coordinate = self.cursor_coordinate
+ if cursor_type in ("cell", "column", "range"):
+ self.cursor_coordinate = self.cursor_coordinate.left()
+ if old_coordinate == self.cursor_coordinate: # at start of row
+ if old_coordinate.row > 0:
+ self.cursor_coordinate = Coordinate(
+ old_coordinate.row - 1, self.column_count - 1
+ )
+ else:
+ self.cursor_coordinate = Coordinate(
+ self.row_count - 1, self.column_count - 1
+ )
+ elif cursor_type == "row":
+ self.cursor_coordinate = self.cursor_coordinate.up()
+ if old_coordinate == self.cursor_coordinate: # at start of table
+ self.cursor_coordinate = Coordinate(self.row_count - 1, 0)
+ self._scroll_cursor_into_view(animate=True)
+
+ def action_cursor_row_end(self, select: bool = False) -> None:
+ self._set_hover_cursor(False)
+ cursor_type = self.cursor_type
+ if self.show_cursor and cursor_type in ("cell", "column", "range"):
+ self._set_selection_anchor(select)
+ self.cursor_coordinate = Coordinate(
+ self.cursor_coordinate.row, self.column_count - 1
+ )
+ self._scroll_cursor_into_view(animate=True)
+ else:
+ super().action_scroll_right()
+
+ def action_cursor_row_start(self, select: bool = False) -> None:
+ self._set_hover_cursor(False)
+ cursor_type = self.cursor_type
+ if self.show_cursor and cursor_type in ("cell", "column", "range"):
+ self._set_selection_anchor(select)
+ self.cursor_coordinate = Coordinate(self.cursor_coordinate.row, 0)
+ self._scroll_cursor_into_view(animate=True)
+ else:
+ super().action_scroll_left()
+
+ def action_cursor_table_end(self, select: bool = False) -> None:
+ self._set_hover_cursor(False)
+ self._set_selection_anchor(select)
+ self.cursor_coordinate = Coordinate(self.row_count - 1, self.column_count - 1)
+ self._scroll_cursor_into_view(animate=True)
+
+ def action_cursor_table_start(self, select: bool = False) -> None:
+ self._set_hover_cursor(False)
+ self._set_selection_anchor(select)
+ self.cursor_coordinate = Coordinate(0, 0)
+ self._scroll_cursor_into_view(animate=True)
+
+ def action_select_all(self) -> None:
+ self._set_hover_cursor(False)
+ if self.show_cursor and self.cursor_type == "range":
+ self.selection_anchor_coordinate = Coordinate(0, 0)
+ self.cursor_coordinate = Coordinate(
+ self.row_count - 1, self.column_count - 1
+ )
+ self._scroll_cursor_into_view(animate=True)
+
+ def action_select_cursor(self) -> None:
+ self._set_hover_cursor(False)
+ if self.show_cursor and self.cursor_type != "none":
+ self._post_selected_message()
+
+ def action_copy_selection(self) -> None:
+ if self.cursor_type == "range" and self.selection_anchor_coordinate is not None:
+ min_row, max_row, min_col, max_col = self._order_bounding_coords(
+ self.cursor_coordinate, self.selection_anchor_coordinate
+ )
+ values: list[tuple[Any, ...]] = []
+ for row_index in range(min_row, max_row + 1):
+ values.append(
+ tuple(
+ self.get_cell_at(Coordinate(row_index, column_index))
+ for column_index in range(min_col, max_col + 1)
+ )
+ )
+ elif self.cursor_type in ("cell", "range"):
+ values = [(self.get_cell_at(self.cursor_coordinate),)]
+ elif self.cursor_type == "row":
+ values = [tuple(self.get_row_at(self.cursor_row))]
+ elif self.cursor_type == "column":
+ values = [(v,) for v in self.get_column_at(self.cursor_column)]
+
+ self.post_message(DataTable.SelectionCopied(data_table=self, values=values))
+
+ def _order_bounding_coords(self, *coords: Coordinate) -> tuple[int, int, int, int]:
+ min_row = min(c.row for c in coords)
+ max_row = max(c.row for c in coords)
+ min_col = min(c.column for c in coords)
+ max_col = max(c.column for c in coords)
+ return min_row, max_row, min_col, max_col
+
+ def _post_selected_message(self) -> None:
+ """Post the appropriate message for a selection based on the `cursor_type`."""
+ cursor_coordinate = self.cursor_coordinate
+ cursor_type = self.cursor_type
+ if self.row_count == 0:
+ return
+ if cursor_type == "cell":
+ self.post_message(
+ DataTable.CellSelected(
+ self,
+ self.get_cell_at(cursor_coordinate),
+ coordinate=cursor_coordinate,
+ )
+ )
+ elif cursor_type == "row":
+ row_index, _ = cursor_coordinate
+ self.post_message(DataTable.RowSelected(self, row_index))
+ elif cursor_type == "column":
+ _, column_index = cursor_coordinate
+ self.post_message(DataTable.ColumnSelected(self, column_index))
diff --git a/src/textual_fastdatatable/formatter.py b/src/textual_fastdatatable/formatter.py
new file mode 100644
index 0000000..8088cc5
--- /dev/null
+++ b/src/textual_fastdatatable/formatter.py
@@ -0,0 +1,101 @@
+from __future__ import annotations
+
+from datetime import date, datetime, time, timedelta
+from decimal import Decimal
+from typing import cast
+
+from rich.align import Align
+from rich.console import Console, RenderableType
+from rich.errors import MarkupError
+from rich.markup import escape
+from rich.protocol import is_renderable
+from rich.text import Text
+
+from textual_fastdatatable.column import Column
+
+
+def cell_formatter(
+ obj: object, null_rep: Text, col: Column | None = None, render_markup: bool = True
+) -> RenderableType:
+ """Convert a cell into a Rich renderable for display.
+
+ For correct formatting, clients should call `locale.setlocale()` first.
+
+ Args:
+ obj: Data for a cell.
+ col: Column that the cell came from (used to compute width).
+
+ Returns:
+ A renderable to be displayed which represents the data.
+ """
+ if obj is None:
+ return Align(null_rep, align="center")
+
+ elif isinstance(obj, str) and render_markup:
+ try:
+ rich_text: Text | str = Text.from_markup(obj)
+ except MarkupError:
+ rich_text = escape(obj)
+ return rich_text
+
+ elif isinstance(obj, str):
+ return escape(obj)
+
+ elif isinstance(obj, bool):
+ return Align(
+ f"[dim]{'✓' if obj else 'X'}[/] {obj}{' ' if obj else ''}",
+ style="bold" if obj else "",
+ align="right",
+ )
+
+ elif isinstance(obj, (float, Decimal)):
+ return Align(f"{obj:n}", align="right")
+
+ elif isinstance(obj, int):
+ if col is not None and col.is_id:
+ # no separators in ID fields
+ return Align(str(obj), align="right")
+ else:
+ return Align(f"{obj:n}", align="right")
+
+ elif isinstance(obj, (datetime, time)):
+
+ def _fmt_datetime(obj: datetime | time) -> str:
+ return obj.isoformat(timespec="milliseconds").replace("+00:00", "Z")
+
+ if obj in (datetime.max, datetime.min):
+ return Align(
+ (
+ f"[bold]{'∞ ' if obj == datetime.max else '-∞ '}[/]"
+ f"[dim]{_fmt_datetime(obj)}[/]"
+ ),
+ align="right",
+ )
+
+ return Align(_fmt_datetime(obj), align="right")
+
+ elif isinstance(obj, date):
+ if obj in (date.max, date.min):
+ return Align(
+ (
+ f"[bold]{'∞ ' if obj == date.max else '-∞ '}[/]"
+ f"[dim]{obj.isoformat()}[/]"
+ ),
+ align="right",
+ )
+
+ return Align(obj.isoformat(), align="right")
+
+ elif isinstance(obj, timedelta):
+ return Align(str(obj), align="right")
+
+ elif not is_renderable(obj):
+ return str(obj)
+
+ else:
+ return cast(RenderableType, obj)
+
+
+def measure_width(obj: object, console: Console) -> int:
+ renderable = cell_formatter(obj, null_rep=Text(""))
+ return console.measure(renderable).maximum
diff --git a/src/textual_fastdatatable/py.typed b/src/textual_fastdatatable/py.typed
new file mode 100644
index 0000000..e69de29
diff --git a/stubs/pyarrow/__init__.pyi b/stubs/pyarrow/__init__.pyi
new file mode 100644
index 0000000..0b0b9e3
--- /dev/null
+++ b/stubs/pyarrow/__init__.pyi
@@ -0,0 +1,166 @@
+from __future__ import annotations
+
+from typing import Any, Iterable, Iterator, Literal, Mapping, Sequence, Type, TypeVar
+
+import pandas as pd
+
+from .compute import CastOptions
+
+class DataType: ...
+class Date32Type(DataType): ...
+class Date64Type(DataType): ...
+class TimestampType(DataType): ...
+
+def string() -> DataType: ...
+def null() -> DataType: ...
+def bool_() -> DataType: ...
+def int8() -> DataType: ...
+def int16() -> DataType: ...
+def int32() -> DataType: ...
+def int64() -> DataType: ...
+def uint8() -> DataType: ...
+def uint16() -> DataType: ...
+def uint32() -> DataType: ...
+def uint64() -> DataType: ...
+def float16() -> DataType: ...
+def float32() -> DataType: ...
+def float64() -> DataType: ...
+def date32() -> DataType: ...
+def date64() -> DataType: ...
+def binary(length: int = -1) -> DataType: ...
+def large_binary() -> DataType: ...
+def large_string() -> DataType: ...
+def month_day_nano_interval() -> DataType: ...
+def time32(unit: Literal["s", "ms", "us", "ns"]) -> DataType: ...
+def time64(unit: Literal["s", "ms", "us", "ns"]) -> DataType: ...
+def timestamp(
+ unit: Literal["s", "ms", "us", "ns"], tz: str | None = None
+) -> DataType: ...
+def duration(unit: Literal["s", "ms", "us", "ns"]) -> DataType: ...
+
+class MemoryPool: ...
+class Schema: ...
+class Field: ...
+class NativeFile: ...
+class MonthDayNano: ...
+
+class Scalar:
+ def as_py(self) -> Any: ...
+ @property
+ def type(self) -> DataType: ...
+
+A = TypeVar("A", bound="_PandasConvertible")
+
+class _PandasConvertible:
+ @property
+ def type(self) -> DataType: ... # noqa: A003
+ def cast(
+ self: A,
+ target_type: DataType | None = None,
+ safe: bool = True,
+ options: CastOptions | None = None,
+ ) -> A: ...
+ def __getitem__(self, index: int) -> Scalar: ...
+ def __iter__(self) -> Any: ...
+ def to_pylist(self) -> list[Any]: ...
+ def fill_null(self: A, fill_value: Any) -> A: ...
+ def drop_null(self: A) -> A: ...
+
+class Array(_PandasConvertible): ...
+class ChunkedArray(_PandasConvertible): ...
+
+class StructArray(Array):
+ def flatten(self, memory_pool: MemoryPool | None = None) -> list[Array]: ...
+
+T = TypeVar("T", bound="_Tabular")
+
+class _Tabular:
+ @classmethod
+ def from_arrays(
+ cls: Type[T],
+ arrays: list[_PandasConvertible],
+ names: list[str] | None = None,
+ schema: Schema | None = None,
+ metadata: Mapping | None = None,
+ ) -> T: ...
+ @classmethod
+ def from_pydict(
+ cls: Type[T],
+ mapping: Mapping,
+ schema: Schema | None = None,
+ metadata: Mapping | None = None,
+ ) -> T: ...
+ def __getitem__(self, index: int) -> _PandasConvertible: ...
+ def __len__(self) -> int: ...
+ @property
+ def column_names(self) -> list[str]: ...
+ @property
+ def columns(self) -> list[_PandasConvertible]: ...
+ @property
+ def num_rows(self) -> int: ...
+ @property
+ def num_columns(self) -> int: ...
+ @property
+ def schema(self) -> Schema: ...
+ def append_column(
+ self: T, field_: str | Field, column: Array | ChunkedArray
+ ) -> T: ...
+ def column(self, i: int | str) -> _PandasConvertible: ...
+ def equals(self: T, other: T, check_metadata: bool = False) -> bool: ...
+ def itercolumns(self) -> Iterator[_PandasConvertible]: ...
+ def rename_columns(self: T, names: list[str]) -> T: ...
+ def select(self: T, columns: Sequence[str | int]) -> T: ...
+ def set_column(
+ self: T, i: int, field_: str | Field, column: Array | ChunkedArray
+ ) -> T: ...
+ def slice( # noqa: A003
+ self: T,
+ offset: int = 0,
+ length: int | None = None,
+ ) -> T: ...
+ def sort_by(
+ self: T,
+ sorting: str | list[tuple[str, Literal["ascending", "descending"]]],
+ **kwargs: Any,
+ ) -> T: ...
+ def to_pylist(self) -> list[dict[str, Any]]: ...
+
+class RecordBatch(_Tabular): ...
+
+class Table(_Tabular):
+ @classmethod
+ def from_batches(
+ cls,
+ batches: Iterable[RecordBatch],
+ schema: Schema | None = None,
+ ) -> "Table": ...
+ def to_batches(self) -> list[RecordBatch]: ...
+
+def scalar(value: Any, type: DataType) -> Scalar: ... # noqa: A002
+def array(
+ obj: Iterable,
+ type: DataType | None = None, # noqa: A002
+ mask: Array | None = None,
+ size: int | None = None,
+ from_pandas: bool | None = None,
+ safe: bool = True,
+ memory_pool: MemoryPool | None = None,
+) -> Array | ChunkedArray: ...
+def concat_arrays(
+ arrays: Iterable[Array], memory_pool: MemoryPool | None = None
+) -> Array: ...
+def nulls(
+ size: int,
+ type: DataType | None = None, # noqa: A002
+ memory_pool: MemoryPool | None = None,
+) -> Array: ...
+def table(
+ data: pd.DataFrame
+ | Mapping[str, _PandasConvertible | list]
+ | list[_PandasConvertible],
+ names: list[str] | None = None,
+ schema: Schema | None = None,
+ metadata: Mapping | None = None,
+ nthreads: int | None = None,
+) -> Table: ...
+def set_timezone_db_path(path: str) -> None: ...
diff --git a/stubs/pyarrow/compute.pyi b/stubs/pyarrow/compute.pyi
new file mode 100644
index 0000000..a3d7821
--- /dev/null
+++ b/stubs/pyarrow/compute.pyi
@@ -0,0 +1,64 @@
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Any, Callable, Literal
+
+from . import DataType, MemoryPool, Scalar, _PandasConvertible
+
+class Expression: ...
+class ScalarAggregateOptions: ...
+
+class CastOptions:
+ def __init__(
+ self,
+ target_type: DataType | None = None,
+ allow_int_overflow: bool | None = None,
+ allow_time_truncate: bool | None = None,
+ allow_time_overflow: bool | None = None,
+ allow_decimal_truncate: bool | None = None,
+ allow_float_truncate: bool | None = None,
+ allow_invalid_utf8: bool | None = None,
+ ) -> None: ...
+
+def max( # noqa: A001
+ array: _PandasConvertible,
+ /,
+ *,
+ skip_nulls: bool = True,
+ min_count: int = 1,
+ options: ScalarAggregateOptions | None = None,
+ memory_pool: MemoryPool | None = None,
+) -> Scalar: ...
+def min( # noqa: A001
+ array: _PandasConvertible,
+ /,
+ *,
+ skip_nulls: bool = True,
+ min_count: int = 1,
+ options: ScalarAggregateOptions | None = None,
+ memory_pool: MemoryPool | None = None,
+) -> Scalar: ...
+def utf8_length(
+ strings: _PandasConvertible, /, *, memory_pool: MemoryPool | None = None
+) -> _PandasConvertible: ...
+def register_scalar_function(
+ func: Callable,
+ function_name: str,
+ function_doc: dict[Literal["summary", "description"], str],
+ in_types: dict[str, DataType],
+ out_type: DataType,
+ func_registry: Any | None = None,
+) -> None: ...
+def call_function(
+ function_name: str, target: list[_PandasConvertible]
+) -> _PandasConvertible: ...
+def assume_timezone(
+ timestamps: _PandasConvertible | Scalar | datetime,
+ /,
+ timezone: str,
+ *,
+ ambiguous: Literal["raise", "earliest", "latest"] = "raise",
+ nonexistent: Literal["raise", "earliest", "latest"] = "raise",
+ options: Any | None = None,
+ memory_pool: MemoryPool | None = None,
+) -> _PandasConvertible: ...
diff --git a/stubs/pyarrow/dataset.pyi b/stubs/pyarrow/dataset.pyi
new file mode 100644
index 0000000..24263e3
--- /dev/null
+++ b/stubs/pyarrow/dataset.pyi
@@ -0,0 +1 @@
+class Partitioning: ...
diff --git a/stubs/pyarrow/fs.pyi b/stubs/pyarrow/fs.pyi
new file mode 100644
index 0000000..df83021
--- /dev/null
+++ b/stubs/pyarrow/fs.pyi
@@ -0,0 +1 @@
+class FileSystem: ...
diff --git a/stubs/pyarrow/lib.pyi b/stubs/pyarrow/lib.pyi
new file mode 100644
index 0000000..46d26bc
--- /dev/null
+++ b/stubs/pyarrow/lib.pyi
@@ -0,0 +1,32 @@
+from . import Date32Type, Date64Type, Scalar, TimestampType
+
+class ArrowException(Exception): ...
+class ArrowInvalid(ValueError, ArrowException): ...
+class ArrowMemoryError(MemoryError, ArrowException): ...
+class ArrowKeyError(KeyError, Exception): ...
+class ArrowTypeError(TypeError, Exception): ...
+class ArrowNotImplementedError(NotImplementedError, ArrowException): ...
+class ArrowCapacityError(ArrowException): ...
+class ArrowIndexError(IndexError, ArrowException): ...
+class ArrowSerializationError(ArrowException): ...
+class ArrowCancelled(ArrowException): ...
+
+ArrowIOError = IOError
+
+class Date32Scalar(Scalar):
+ @property
+ def type(self) -> Date32Type: ...
+ @property
+ def value(self) -> int: ...
+
+class Date64Scalar(Scalar):
+ @property
+ def type(self) -> Date64Type: ...
+ @property
+ def value(self) -> int: ...
+
+class TimestampScalar(Scalar):
+ @property
+ def type(self) -> TimestampType: ...
+ @property
+ def value(self) -> int: ...
diff --git a/stubs/pyarrow/parquet.pyi b/stubs/pyarrow/parquet.pyi
new file mode 100644
index 0000000..7c2ef52
--- /dev/null
+++ b/stubs/pyarrow/parquet.pyi
@@ -0,0 +1,60 @@
+from __future__ import annotations
+
+from typing import Any, BinaryIO, Literal
+
+from . import NativeFile, Schema, Table
+from .compute import Expression
+from .dataset import Partitioning
+from .fs import FileSystem
+
+class FileMetaData: ...
+
+def read_table(
+ source: str | NativeFile | BinaryIO,
+ *,
+ columns: list | None = None,
+ use_threads: bool = True,
+ metadata: FileMetaData | None = None,
+ schema: Schema | None = None,
+ use_pandas_metadata: bool = False,
+ read_dictionary: list | None = None,
+ memory_map: bool = False,
+ buffer_size: int = 0,
+ partitioning: Partitioning | str | list[str] = "hive",
+ filesystem: FileSystem | None = None,
+ filters: Expression | list[tuple] | list[list[tuple]] | None = None,
+ use_legacy_dataset: bool = False,
+ ignore_prefixes: list | None = None,
+ pre_buffer: bool = True,
+ coerce_int96_timestamp_unit: str | None = None,
+ decryption_properties: Any | None = None,
+ thrift_string_size_limit: int | None = None,
+ thrift_container_size_limit: int | None = None,
+) -> Table: ...
+def write_table(
+ table: Table,
+ where: str | NativeFile,
+ row_group_size: int | None = None,
+ version: Literal["1.0", "2.4", "2.6"] = "2.6",
+ use_dictionary: bool | list = True,
+ compression: Literal["none", "snappy", "gzip", "brotli", "lz4", "zstd"]
+ | dict[str, Literal["none", "snappy", "gzip", "brotli", "lz4", "zstd"]] = "snappy",
+ write_statistics: bool | list = True,
+ use_deprecated_int96_timestamps: bool | None = None,
+ coerce_timestamps: str | None = None,
+ allow_truncated_timestamps: bool = False,
+ data_page_size: int | None = None,
+ flavor: Literal["spark"] | None = None,
+ filesystem: FileSystem | None = None,
+ compression_level: int | dict | None = None,
+ use_byte_stream_split: bool | list = False,
+ column_encoding: str | dict | None = None,
+ data_page_version: Literal["1.0", "2.0"] = "1.0",
+ use_compliant_nested_type: bool = True,
+ encryption_properties: Any | None = None,
+ write_batch_size: int | None = None,
+ dictionary_pagesize_limit: int | None = None,
+ store_schema: bool = True,
+ write_page_index: bool = False,
+ **kwargs: Any,
+) -> None: ...
diff --git a/stubs/pyarrow/types.pyi b/stubs/pyarrow/types.pyi
new file mode 100644
index 0000000..b71b3c5
--- /dev/null
+++ b/stubs/pyarrow/types.pyi
@@ -0,0 +1,27 @@
+from __future__ import annotations
+
+from . import DataType, Date32Type, Date64Type, TimestampType
+
+def is_null(t: DataType) -> bool: ...
+def is_struct(t: DataType) -> bool: ...
+def is_boolean(t: DataType) -> bool: ...
+def is_integer(t: DataType) -> bool: ...
+def is_floating(t: DataType) -> bool: ...
+def is_decimal(t: DataType) -> bool: ...
+def is_temporal(t: DataType) -> bool: ...
+def is_date(t: DataType) -> bool: ...
+def is_date32(t: DataType) -> bool:
+ if isinstance(t, Date32Type):
+ return True
+ return False
+
+def is_date64(t: DataType) -> bool:
+ if isinstance(t, Date64Type):
+ return True
+ return False
+
+def is_time(t: DataType) -> bool: ...
+def is_timestamp(t: DataType) -> bool:
+ if isinstance(t, TimestampType):
+ return True
+ return False
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..493bc70
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,34 @@
+from __future__ import annotations
+
+from typing import Sequence, Type
+
+import pytest
+from textual_fastdatatable.backend import ArrowBackend, DataTableBackend, PolarsBackend
+
+
+@pytest.fixture
+def pydict() -> dict[str, Sequence[str | int]]:
+ return {
+ "first column": [1, 2, 3, 4, 5],
+ "two": ["a", "b", "c", "d", "asdfasdf"],
+ "three": ["foo", "bar", "baz", "qux", "foofoo"],
+ }
+
+
+@pytest.fixture
+def records(pydict: dict[str, Sequence[str | int]]) -> list[tuple[str | int, ...]]:
+ header = tuple(pydict.keys())
+ cols = list(pydict.values())
+ num_rows = len(cols[0])
+ data = [tuple([col[i] for col in cols]) for i in range(num_rows)]
+ return [header, *data]
+
+
+@pytest.fixture(params=[ArrowBackend, PolarsBackend])
+def backend(
+ request: Type[pytest.FixtureRequest], pydict: dict[str, Sequence[str | int]]
+) -> DataTableBackend:
+ backend_cls = request.param
+ assert issubclass(backend_cls, (ArrowBackend, PolarsBackend))
+ backend: ArrowBackend | PolarsBackend = backend_cls.from_pydict(pydict)
+ return backend
diff --git a/tests/data/lap_times_100.parquet b/tests/data/lap_times_100.parquet
new file mode 100644
index 0000000..f26b428
Binary files /dev/null and b/tests/data/lap_times_100.parquet differ
diff --git a/tests/data/lap_times_1000.parquet b/tests/data/lap_times_1000.parquet
new file mode 100644
index 0000000..5878699
Binary files /dev/null and b/tests/data/lap_times_1000.parquet differ
diff --git a/tests/data/lap_times_10000.parquet b/tests/data/lap_times_10000.parquet
new file mode 100644
index 0000000..91f6bf2
Binary files /dev/null and b/tests/data/lap_times_10000.parquet differ
diff --git a/tests/data/lap_times_100000.parquet b/tests/data/lap_times_100000.parquet
new file mode 100644
index 0000000..682fc65
Binary files /dev/null and b/tests/data/lap_times_100000.parquet differ
diff --git a/tests/data/lap_times_538121.parquet b/tests/data/lap_times_538121.parquet
new file mode 100644
index 0000000..c6ef432
Binary files /dev/null and b/tests/data/lap_times_538121.parquet differ
diff --git a/tests/data/wide_10000.parquet b/tests/data/wide_10000.parquet
new file mode 100644
index 0000000..2f8ac6f
Binary files /dev/null and b/tests/data/wide_10000.parquet differ
diff --git a/tests/data/wide_100000.parquet b/tests/data/wide_100000.parquet
new file mode 100644
index 0000000..347fa00
Binary files /dev/null and b/tests/data/wide_100000.parquet differ
diff --git a/tests/snapshot_tests/LICENSE b/tests/snapshot_tests/LICENSE
new file mode 100644
index 0000000..3a43997
--- /dev/null
+++ b/tests/snapshot_tests/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2021 Will McGugan
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
new file mode 100644
index 0000000..d1c221c
--- /dev/null
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
@@ -0,0 +1,2924 @@
+# serializer version: 1
+# name: test_auto_table
+ '''
+
+
+ '''
+# ---
+# name: test_datatable_add_column
+ '''
+
+
+ '''
+# ---
+# name: test_datatable_column_cursor_render
+ '''
+
+
+ '''
+# ---
+# name: test_datatable_empty
+ '''
+
+
+ '''
+# ---
+# name: test_datatable_empty_add_col
+ '''
+
+
+ '''
+# ---
+# name: test_datatable_from_parquet
+ '''
+
+
+ '''
+# ---
+# name: test_datatable_from_pydict
+ '''
+
+
+ '''
+# ---
+# name: test_datatable_from_records
+ '''
+
+
+ '''
+# ---
+# name: test_datatable_max_width_render
+ '''
+
+
+ '''
+# ---
+# name: test_datatable_no_render_markup
+ '''
+
+
+ '''
+# ---
+# name: test_datatable_no_rows
+ '''
+
+
+ '''
+# ---
+# name: test_datatable_no_rows_empty_sequence
+ '''
+
+
+ '''
+# ---
+# name: test_datatable_null_mixed_cols
+ '''
+
+
+ '''
+# ---
+# name: test_datatable_range_cursor_render
+ '''
+
+
+ '''
+# ---
+# name: test_datatable_remove_row
+ '''
+
+
+ '''
+# ---
+# name: test_datatable_render
+ '''
+
+
+ '''
+# ---
+# name: test_datatable_row_cursor_render
+ '''
+
+
+ '''
+# ---
+# name: test_datatable_sort_multikey
+ '''
+
+
+ '''
+# ---
diff --git a/tests/snapshot_tests/_snapshots_backup/test_snapshots.ambr b/tests/snapshot_tests/_snapshots_backup/test_snapshots.ambr
new file mode 100644
index 0000000..6840bac
--- /dev/null
+++ b/tests/snapshot_tests/_snapshots_backup/test_snapshots.ambr
@@ -0,0 +1,1814 @@
+# name: test_auto_table
+ '''
+
+
+ '''
+# ---
+# name: test_datatable_add_column
+ '''
+
+
+ '''
+# ---
+# name: test_datatable_add_row_auto_height
+ '''
+
+
+ '''
+# ---
+# name: test_datatable_add_row_auto_height_sorted
+ '''
+
+
+ '''
+# ---
+# name: test_datatable_column_cursor_render
+ '''
+
+
+ '''
+# ---
+# name: test_datatable_labels_and_fixed_data
+ '''
+
+
+ '''
+# ---
+# name: test_datatable_remove_row
+ '''
+
+
+ '''
+# ---
+# name: test_datatable_render
+ '''
+
+
+ '''
+# ---
+# name: test_datatable_row_cursor_render
+ '''
+
+
+ '''
+# ---
+# name: test_datatable_sort_multikey
+ '''
+
+
+ '''
+# ---
+# name: test_datatable_style_ordering
+ '''
+
+
+ '''
+# ---
diff --git a/tests/snapshot_tests/snapshot_apps/auto-table.py b/tests/snapshot_tests/snapshot_apps/auto-table.py
new file mode 100644
index 0000000..9aea8d8
--- /dev/null
+++ b/tests/snapshot_tests/snapshot_apps/auto-table.py
@@ -0,0 +1,185 @@
+from textual.app import App
+from textual.containers import Container, Horizontal, ScrollableContainer, Vertical
+from textual.screen import Screen
+from textual.widgets import Header, Label
+from textual_fastdatatable import ArrowBackend, DataTable
+
+
+class LabeledBox(Container):
+ DEFAULT_CSS = """
+ LabeledBox {
+ layers: base_ top_;
+ width: 100%;
+ height: 100%;
+ }
+
+ LabeledBox > Container {
+ layer: base_;
+ border: round $primary;
+ width: 100%;
+ height: 100%;
+ layout: vertical;
+ }
+
+ LabeledBox > Label {
+ layer: top_;
+ offset-x: 2;
+ }
+ """
+
+ def __init__(self, title, *args, **kwargs):
+ self.__label = Label(title)
+
+ super().__init__(self.__label, Container(*args, **kwargs))
+
+ @property
+ def label(self):
+ return self.__label
+
+
+class StatusTable(DataTable):
+ def __init__(self) -> None:
+ backend = ArrowBackend.from_pydict(
+ {
+ "Foo": ["ABCDEFGH"] * 50,
+ "Bar": ["0123456789"] * 50,
+ "Baz": ["IJKLMNOPQRSTUVWXYZ"] * 50,
+ }
+ )
+ super().__init__(backend=backend)
+
+ self.cursor_type = "row"
+ self.show_cursor = False
+
+
+class Status(LabeledBox):
+ DEFAULT_CSS = """
+ Status {
+ width: auto;
+ }
+
+ Status Container {
+ width: auto;
+ }
+
+ Status StatusTable {
+ width: auto;
+ height: 100%;
+ margin-top: 1;
+ scrollbar-gutter: stable;
+ overflow-x: hidden;
+ }
+ """
+
+ def __init__(self, name: str):
+ self.__name = name
+ self.__table = StatusTable()
+
+ super().__init__(f" {self.__name} ", self.__table)
+
+ @property
+ def name(self) -> str:
+ return self.__name
+
+ @property
+ def table(self) -> StatusTable:
+ return self.__table
+
+
+class Rendering(LabeledBox):
+ DEFAULT_CSS = """
+ #issue-info {
+ height: auto;
+ border-bottom: dashed #632CA6;
+ }
+
+ #statuses-box {
+ height: 1fr;
+ width: auto;
+ }
+ """
+
+ def __init__(self):
+ self.__info = Label("test")
+
+ super().__init__(
+ "",
+ ScrollableContainer(
+ Horizontal(self.__info, id="issue-info"),
+ Horizontal(*[Status(str(i)) for i in range(4)], id="statuses-box"),
+ id="issues-box",
+ ),
+ )
+
+ @property
+ def info(self) -> Label:
+ return self.__info
+
+
+class Sidebar(LabeledBox):
+ DEFAULT_CSS = """
+ #sidebar-status {
+ height: auto;
+ border-bottom: dashed #632CA6;
+ }
+
+ #sidebar-options {
+ height: 1fr;
+ }
+ """
+
+ def __init__(self):
+ self.__status = Label("ok")
+ self.__options = Vertical()
+
+ super().__init__(
+ "",
+ Container(self.__status, id="sidebar-status"),
+ Container(self.__options, id="sidebar-options"),
+ )
+
+ @property
+ def status(self) -> Label:
+ return self.__status
+
+ @property
+ def options(self) -> Vertical:
+ return self.__options
+
+
+class MyScreen(Screen):
+ DEFAULT_CSS = """
+ #main-content {
+ layout: grid;
+ grid-size: 2;
+ grid-columns: 1fr 5fr;
+ grid-rows: 1fr;
+ }
+
+ #main-content-sidebar {
+ height: 100%;
+ }
+
+ #main-content-rendering {
+ height: 100%;
+ }
+ """
+
+ def compose(self):
+ yield Header()
+ yield Container(
+ Container(Sidebar(), id="main-content-sidebar"),
+ Container(Rendering(), id="main-content-rendering"),
+ id="main-content",
+ )
+
+
+class MyApp(App):
+ async def on_mount(self):
+ self.install_screen(MyScreen(), "myscreen")
+ await self.push_screen("myscreen")
+
+
+if __name__ == "__main__":
+ app = MyApp()
+ app.run()
diff --git a/tests/snapshot_tests/snapshot_apps/data_table.py b/tests/snapshot_tests/snapshot_apps/data_table.py
new file mode 100644
index 0000000..5ab2f16
--- /dev/null
+++ b/tests/snapshot_tests/snapshot_apps/data_table.py
@@ -0,0 +1,26 @@
+from textual.app import App, ComposeResult
+from textual_fastdatatable import ArrowBackend, DataTable
+
+ROWS = [
+ ("lane", "swimmer", "country", "time"),
+ (4, "Joseph Schooling", "Singapore", 50.39),
+ (2, "Michael Phelps", "United States", 51.14),
+ (5, "Chad le Clos", "South Africa", 51.14),
+ (6, "László Cseh", "Hungary", 51.14),
+ (3, "Li Zhuhao", "China", 51.26),
+ (8, "Mehdy Metella", "France", 51.58),
+ (7, "Tom Shields", "United States", 51.73),
+ (1, "Aleksandr Sadovnikov", "Russia", 51.84),
+ (10, "Darren Burns", "Scotland", 51.84),
+]
+
+
+class TableApp(App):
+ def compose(self) -> ComposeResult:
+ backend = ArrowBackend.from_records(ROWS, has_header=True)
+ yield DataTable(backend=backend)
+
+
+app = TableApp()
+if __name__ == "__main__":
+ app.run()
diff --git a/tests/snapshot_tests/snapshot_apps/data_table_add_column.py b/tests/snapshot_tests/snapshot_apps/data_table_add_column.py
new file mode 100644
index 0000000..083e0f5
--- /dev/null
+++ b/tests/snapshot_tests/snapshot_apps/data_table_add_column.py
@@ -0,0 +1,36 @@
+from textual.app import App, ComposeResult
+from textual.binding import Binding
+from textual_fastdatatable import ArrowBackend, DataTable
+
+MOVIES = [
+ "Severance",
+ "Foundation",
+ "Dark",
+ "The Boys",
+ "The Last of Us",
+ "Lost in Space",
+ "Altered Carbon",
+]
+
+
+class AddColumn(App):
+ BINDINGS = [
+ Binding(key="c", action="add_column", description="Add Column"),
+ ]
+
+ def compose(self) -> ComposeResult:
+ backend = ArrowBackend.from_pydict({"Movies": MOVIES})
+ table = DataTable(backend=backend)
+
+ column_idx = table.add_column("No Default")
+ table.add_column("With Default", default="ABC")
+ table.add_column("Long Default", default="01234567890123456789")
+
+ # Ensure we can update a cell
+ table.update_cell(2, column_idx, "Hello!")
+ yield table
+
+
+app = AddColumn()
+if __name__ == "__main__":
+ app.run()
diff --git a/tests/snapshot_tests/snapshot_apps/data_table_add_row_auto_height.py b/tests/snapshot_tests/snapshot_apps/data_table_add_row_auto_height.py
new file mode 100644
index 0000000..482aff3
--- /dev/null
+++ b/tests/snapshot_tests/snapshot_apps/data_table_add_row_auto_height.py
@@ -0,0 +1,24 @@
+from rich.panel import Panel
+from rich.text import Text
+from textual.app import App
+from textual_fastdatatable import DataTable
+
+
+class AutoHeightRowsApp(App[None]):
+ def compose(self):
+ table = DataTable()
+ self.column = table.add_column("N")
+ table.add_column("Column", width=10)
+ table.add_row(3, "hey there", height=None)
+ table.add_row(1, Text("hey there"), height=None)
+ table.add_row(5, Text("long string", overflow="fold"), height=None)
+ table.add_row(2, Panel.fit("Hello\nworld"), height=None)
+ table.add_row(4, "1\n2\n3\n4\n5\n6\n7", height=None)
+ yield table
+
+ def key_s(self):
+ self.query_one(DataTable).sort(self.column)
+
+
+if __name__ == "__main__":
+ AutoHeightRowsApp().run()
diff --git a/tests/snapshot_tests/snapshot_apps/data_table_column_cursor.py b/tests/snapshot_tests/snapshot_apps/data_table_column_cursor.py
new file mode 100644
index 0000000..b657c25
--- /dev/null
+++ b/tests/snapshot_tests/snapshot_apps/data_table_column_cursor.py
@@ -0,0 +1,35 @@
+import csv
+import io
+
+from textual.app import App, ComposeResult
+from textual_fastdatatable import ArrowBackend, DataTable
+
+CSV = """lane,swimmer,country,time
+4,Joseph Schooling,Singapore,50.39
+2,Michael Phelps,United States,51.14
+5,Chad le Clos,South Africa,51.14
+6,László Cseh,Hungary,51.14
+3,Li Zhuhao,China,51.26
+8,Mehdy Metella,France,51.58
+7,Tom Shields,United States,51.73
+1,Aleksandr Sadovnikov,Russia,51.84"""
+
+
+class TableApp(App):
+ def compose(self) -> ComposeResult:
+ rows = csv.reader(io.StringIO(CSV))
+ labels = next(rows)
+ data = [row for row in rows]
+ backend = ArrowBackend.from_pydict(
+ {label: [row[i] for row in data] for i, label in enumerate(labels)}
+ )
+ table = DataTable(
+ backend=backend, cursor_type="column", fixed_columns=1, fixed_rows=1
+ )
+ table.focus()
+ yield table
+
+
+if __name__ == "__main__":
+ app = TableApp()
+ app.run()
diff --git a/tests/snapshot_tests/snapshot_apps/data_table_max_width.py b/tests/snapshot_tests/snapshot_apps/data_table_max_width.py
new file mode 100644
index 0000000..443e7f6
--- /dev/null
+++ b/tests/snapshot_tests/snapshot_apps/data_table_max_width.py
@@ -0,0 +1,35 @@
+import csv
+import io
+
+from textual.app import App, ComposeResult
+from textual_fastdatatable import ArrowBackend, DataTable
+
+CSV = """lane,swimmer,country,time
+4,Joseph Schooling,Singapore,50.39
+2,Michael Phelps,United States,51.14
+5,Chad le Clos,South Africa,51.14
+6,László Cseh,Hungary,51.14
+3,Li Zhuhao,China,51.26
+8,Mehdy Metella,France,51.58
+7,Tom Shields,United States,51.73
+1,Aleksandr Sadovnikov,Russia,51.84"""
+
+
+class TableApp(App):
+ def compose(self) -> ComposeResult:
+ rows = csv.reader(io.StringIO(CSV))
+ labels = next(rows)
+ data = [row for row in rows]
+ backend = ArrowBackend.from_pydict(
+ {label: [row[i] for row in data] for i, label in enumerate(labels)}
+ )
+ table = DataTable(
+ backend=backend, cursor_type="range", max_column_content_width=8
+ )
+ table.focus()
+ yield table
+
+
+if __name__ == "__main__":
+ app = TableApp()
+ app.run()
diff --git a/tests/snapshot_tests/snapshot_apps/data_table_no_render_markup.py b/tests/snapshot_tests/snapshot_apps/data_table_no_render_markup.py
new file mode 100644
index 0000000..968e954
--- /dev/null
+++ b/tests/snapshot_tests/snapshot_apps/data_table_no_render_markup.py
@@ -0,0 +1,26 @@
+from textual.app import App, ComposeResult
+from textual_fastdatatable import ArrowBackend, DataTable
+
+ROWS = [
+ ("lane", "swimmer", "country", "time"),
+ (4, "[Joseph Schooling]", "Singapore", 50.39),
+ (2, "[red]Michael Phelps[/]", "United States", 51.14),
+ (5, "[bold]Chad le Clos[/]", "South Africa", 51.14),
+ (6, "László Cseh", "Hungary", 51.14),
+ (3, "Li Zhuhao", "China", 51.26),
+ (8, "Mehdy Metella", "France", 51.58),
+ (7, "Tom Shields", "United States", 51.73),
+ (1, "Aleksandr Sadovnikov", "Russia", 51.84),
+ (10, "Darren Burns", "Scotland", 51.84),
+]
+
+
+class TableApp(App):
+ def compose(self) -> ComposeResult:
+ backend = ArrowBackend.from_records(ROWS, has_header=True)
+ yield DataTable(backend=backend, render_markup=False)
+
+
+app = TableApp()
+if __name__ == "__main__":
+ app.run()
diff --git a/tests/snapshot_tests/snapshot_apps/data_table_null_mixed_cols.py b/tests/snapshot_tests/snapshot_apps/data_table_null_mixed_cols.py
new file mode 100644
index 0000000..9158364
--- /dev/null
+++ b/tests/snapshot_tests/snapshot_apps/data_table_null_mixed_cols.py
@@ -0,0 +1,22 @@
+from textual.app import App, ComposeResult
+from textual_fastdatatable import ArrowBackend, DataTable
+
+ROWS = [
+ ("lane", "swimmer", "country", "time"),
+ (3, "Li Zhuhao", "China", 51.26),
+ ("eight", None, "France", 51.58),
+ ("seven", "Tom Shields", "United States", None),
+ (1, "Aleksandr Sadovnikov", "Russia", 51.84),
+ (None, "Darren Burns", "Scotland", 51.84),
+]
+
+
+class TableApp(App):
+ def compose(self) -> ComposeResult:
+ backend = ArrowBackend.from_records(ROWS, has_header=True)
+ yield DataTable(backend=backend, null_rep="[dim]∅ null[/]")
+
+
+app = TableApp()
+if __name__ == "__main__":
+ app.run()
diff --git a/tests/snapshot_tests/snapshot_apps/data_table_range_cursor.py b/tests/snapshot_tests/snapshot_apps/data_table_range_cursor.py
new file mode 100644
index 0000000..836d6a8
--- /dev/null
+++ b/tests/snapshot_tests/snapshot_apps/data_table_range_cursor.py
@@ -0,0 +1,33 @@
+import csv
+import io
+
+from textual.app import App, ComposeResult
+from textual_fastdatatable import ArrowBackend, DataTable
+
+CSV = """lane,swimmer,country,time
+4,Joseph Schooling,Singapore,50.39
+2,Michael Phelps,United States,51.14
+5,Chad le Clos,South Africa,51.14
+6,László Cseh,Hungary,51.14
+3,Li Zhuhao,China,51.26
+8,Mehdy Metella,France,51.58
+7,Tom Shields,United States,51.73
+1,Aleksandr Sadovnikov,Russia,51.84"""
+
+
+class TableApp(App):
+ def compose(self) -> ComposeResult:
+ rows = csv.reader(io.StringIO(CSV))
+ labels = next(rows)
+ data = [row for row in rows]
+ backend = ArrowBackend.from_pydict(
+ {label: [row[i] for row in data] for i, label in enumerate(labels)}
+ )
+ table = DataTable(backend=backend, cursor_type="range")
+ table.focus()
+ yield table
+
+
+if __name__ == "__main__":
+ app = TableApp()
+ app.run()
diff --git a/tests/snapshot_tests/snapshot_apps/data_table_remove_row.py b/tests/snapshot_tests/snapshot_apps/data_table_remove_row.py
new file mode 100644
index 0000000..154c635
--- /dev/null
+++ b/tests/snapshot_tests/snapshot_apps/data_table_remove_row.py
@@ -0,0 +1,45 @@
+from textual.app import App, ComposeResult
+from textual.binding import Binding
+from textual_fastdatatable import ArrowBackend, DataTable
+
+ROWS = [
+ ("lane", "swimmer", "country", "time"),
+ (5, "Chad le Clos", "South Africa", 51.14),
+ (4, "Joseph Schooling", "Singapore", 50.39),
+ (2, "Michael Phelps", "United States", 51.14),
+ (6, "László Cseh", "Hungary", 51.14),
+ (3, "Li Zhuhao", "China", 51.26),
+ (8, "Mehdy Metella", "France", 51.58),
+ (7, "Tom Shields", "United States", 51.73),
+ (10, "Darren Burns", "Scotland", 51.84),
+ (1, "Aleksandr Sadovnikov", "Russia", 51.84),
+]
+
+
+class TableApp(App):
+ """Snapshot app for testing removal of rows.
+ Removes several rows, so we can check that the display of the
+ DataTable updates as expected."""
+
+ BINDINGS = [
+ Binding("r", "remove_row", "Remove Row"),
+ ]
+
+ def compose(self) -> ComposeResult:
+ backend = ArrowBackend.from_records(ROWS, has_header=True)
+ yield DataTable(backend=backend)
+
+ def on_mount(self) -> None:
+ table = self.query_one(DataTable)
+ table.focus()
+
+ def action_remove_row(self):
+ table = self.query_one(DataTable)
+ table.remove_row(2)
+ table.remove_row(4)
+ table.remove_row(6)
+
+
+app = TableApp()
+if __name__ == "__main__":
+ app.run()
diff --git a/tests/snapshot_tests/snapshot_apps/data_table_row_cursor.py b/tests/snapshot_tests/snapshot_apps/data_table_row_cursor.py
new file mode 100644
index 0000000..b006036
--- /dev/null
+++ b/tests/snapshot_tests/snapshot_apps/data_table_row_cursor.py
@@ -0,0 +1,36 @@
+import csv
+import io
+
+from textual.app import App, ComposeResult
+from textual_fastdatatable import ArrowBackend, DataTable
+
+CSV = """lane,swimmer,country,time
+4,Joseph Schooling,Singapore,50.39
+2,Michael Phelps,United States,51.14
+5,Chad le Clos,South Africa,51.14
+6,László Cseh,Hungary,51.14
+3,Li Zhuhao,China,51.26
+8,Mehdy Metella,France,51.58
+7,Tom Shields,United States,51.73
+1,Aleksandr Sadovnikov,Russia,51.84"""
+
+
+class TableApp(App):
+ def compose(self) -> ComposeResult:
+ rows = csv.reader(io.StringIO(CSV))
+ labels = next(rows)
+ data = [row for row in rows]
+ backend = ArrowBackend.from_pydict(
+ {label: [row[i] for row in data] for i, label in enumerate(labels)}
+ )
+ table = DataTable(backend=backend)
+ table.focus()
+ table.cursor_type = "row"
+ table.fixed_columns = 1
+ table.fixed_rows = 1
+ yield table
+
+
+if __name__ == "__main__":
+ app = TableApp()
+ app.run()
diff --git a/tests/snapshot_tests/snapshot_apps/data_table_row_labels.py b/tests/snapshot_tests/snapshot_apps/data_table_row_labels.py
new file mode 100644
index 0000000..e50e3cf
--- /dev/null
+++ b/tests/snapshot_tests/snapshot_apps/data_table_row_labels.py
@@ -0,0 +1,37 @@
+from textual.app import App, ComposeResult
+from textual_fastdatatable import DataTable
+
+ROWS = [
+ ("lane", "swimmer", "country", "time"),
+ (5, "Chad le Clos", "South Africa", 51.14),
+ (4, "Joseph Schooling", "Singapore", 50.39),
+ (2, "Michael Phelps", "United States", 51.14),
+ (6, "László Cseh", "Hungary", 51.14),
+ (3, "Li Zhuhao", "China", 51.26),
+ (8, "Mehdy Metella", "France", 51.58),
+ (7, "Tom Shields", "United States", 51.73),
+ (10, "Darren Burns", "Scotland", 51.84),
+ (1, "Aleksandr Sadovnikov", "Russia", 51.84),
+]
+
+
+class TableApp(App):
+ def compose(self) -> ComposeResult:
+ yield DataTable()
+
+ def on_mount(self) -> None:
+ table = self.query_one(DataTable)
+ table.fixed_rows = 1
+ table.fixed_columns = 1
+ table.focus()
+ rows = iter(ROWS)
+ column_labels = next(rows)
+ for column in column_labels:
+ table.add_column(column, key=column)
+ for index, row in enumerate(rows):
+ table.add_row(*row, label=str(index))
+
+
+app = TableApp()
+if __name__ == "__main__":
+ app.run()
diff --git a/tests/snapshot_tests/snapshot_apps/data_table_sort.py b/tests/snapshot_tests/snapshot_apps/data_table_sort.py
new file mode 100644
index 0000000..8e45167
--- /dev/null
+++ b/tests/snapshot_tests/snapshot_apps/data_table_sort.py
@@ -0,0 +1,40 @@
+from textual.app import App, ComposeResult
+from textual.binding import Binding
+from textual_fastdatatable import ArrowBackend, DataTable
+
+# Shuffled around a bit to exercise sorting.
+ROWS = [
+ ("lane", "swimmer", "country", "time"),
+ (5, "Chad le Clos", "South Africa", 51.14),
+ (4, "Joseph Schooling", "Singapore", 50.39),
+ (2, "Michael Phelps", "United States", 51.14),
+ (6, "László Cseh", "Hungary", 51.14),
+ (3, "Li Zhuhao", "China", 51.26),
+ (8, "Mehdy Metella", "France", 51.58),
+ (7, "Tom Shields", "United States", 51.73),
+ (10, "Darren Burns", "Scotland", 51.84),
+ (1, "Aleksandr Sadovnikov", "Russia", 51.84),
+]
+
+
+class TableApp(App):
+ BINDINGS = [
+ Binding("s", "sort", "Sort"),
+ ]
+
+ def compose(self) -> ComposeResult:
+ backend = ArrowBackend.from_records(ROWS, has_header=True)
+ yield DataTable(backend=backend)
+
+ def on_mount(self) -> None:
+ table = self.query_one(DataTable)
+ table.focus()
+
+ def action_sort(self):
+ table = self.query_one(DataTable)
+ table.sort([("time", "ascending"), ("lane", "ascending")])
+
+
+app = TableApp()
+if __name__ == "__main__":
+ app.run()
diff --git a/tests/snapshot_tests/snapshot_apps/data_table_style_order.py b/tests/snapshot_tests/snapshot_apps/data_table_style_order.py
new file mode 100644
index 0000000..a301b1d
--- /dev/null
+++ b/tests/snapshot_tests/snapshot_apps/data_table_style_order.py
@@ -0,0 +1,65 @@
+from __future__ import annotations
+
+from textual.app import App, ComposeResult
+from textual.widgets import Label
+from textual_fastdatatable import ArrowBackend, DataTable
+from typing_extensions import Literal
+
+data = [
+ "Severance",
+ "Foundation",
+ "Dark",
+]
+
+
+def make_datatable(
+ foreground_priority: Literal["css", "renderable"],
+ background_priority: Literal["css", "renderable"],
+) -> DataTable:
+ backend = ArrowBackend.from_pydict(
+ {"Movies": [f"[red on blue]{row}" for row in data]}
+ )
+ table = DataTable(
+ backend=backend,
+ cursor_foreground_priority=foreground_priority,
+ cursor_background_priority=background_priority,
+ )
+ table.zebra_stripes = True
+ return table
+
+
+class DataTableCursorStyles(App):
+ """Regression test snapshot app which ensures that styles
+ are layered on top of each other correctly in the DataTable.
+ In this example, the colour of the text in the cells under
+ the cursor should not be red, because the CSS should be applied
+ on top."""
+
+ CSS = """
+ DataTable {margin-bottom: 1;}
+ DataTable > .datatable--cursor {
+ color: $secondary;
+ background: $success;
+ text-style: bold italic;
+ }
+"""
+
+ def compose(self) -> ComposeResult:
+ priorities: list[
+ tuple[Literal["css", "renderable"], Literal["css", "renderable"]]
+ ] = [
+ ("css", "css"),
+ ("css", "renderable"),
+ ("renderable", "renderable"),
+ ("renderable", "css"),
+ ]
+ for foreground, background in priorities:
+ yield Label(f"Foreground is {foreground!r}, background is {background!r}:")
+ table = make_datatable(foreground, background)
+ yield table
+
+
+app = DataTableCursorStyles()
+
+if __name__ == "__main__":
+ app.run()
diff --git a/tests/snapshot_tests/snapshot_apps/datatable_hot_reloading.py b/tests/snapshot_tests/snapshot_apps/datatable_hot_reloading.py
new file mode 100644
index 0000000..451b368
--- /dev/null
+++ b/tests/snapshot_tests/snapshot_apps/datatable_hot_reloading.py
@@ -0,0 +1,58 @@
+from pathlib import Path
+
+from textual.app import App, ComposeResult
+from textual_fastdatatable import ArrowBackend, DataTable
+
+CSS_PATH = (Path(__file__) / "../datatable_hot_reloading.tcss").resolve()
+
+# Write some CSS to the file before the app loads.
+# Then, the test will clear all the CSS to see if the
+# hot reloading applies the changes correctly.
+CSS_PATH.write_text(
+ """\
+DataTable > .datatable--cursor {
+ background: purple;
+}
+
+DataTable > .datatable--fixed {
+ background: red;
+}
+
+DataTable > .datatable--fixed-cursor {
+ background: blue;
+}
+
+DataTable > .datatable--header {
+ background: yellow;
+}
+
+DataTable > .datatable--odd-row {
+ background: pink;
+}
+
+DataTable > .datatable--even-row {
+ background: brown;
+}
+"""
+)
+
+
+class DataTableHotReloadingApp(App[None]):
+ CSS_PATH = CSS_PATH
+
+ def compose(self) -> ComposeResult:
+ data = {
+ # orig test set A width=10, we fake it with spaces
+ "A ": ["one", "three", "five"],
+ "B": ["two", "four", "six"],
+ }
+ backend = ArrowBackend.from_pydict(data)
+ yield DataTable(backend, zebra_stripes=True, cursor_type="row", fixed_columns=1)
+
+ def on_mount(self) -> None:
+ self.query_one(DataTable)
+
+
+if __name__ == "__main__":
+ app = DataTableHotReloadingApp()
+ app.run()
diff --git a/tests/snapshot_tests/snapshot_apps/datatable_hot_reloading.tcss b/tests/snapshot_tests/snapshot_apps/datatable_hot_reloading.tcss
new file mode 100644
index 0000000..5e9ee82
--- /dev/null
+++ b/tests/snapshot_tests/snapshot_apps/datatable_hot_reloading.tcss
@@ -0,0 +1 @@
+/* This file is purposefully empty. */
diff --git a/tests/snapshot_tests/snapshot_apps/empty.py b/tests/snapshot_tests/snapshot_apps/empty.py
new file mode 100644
index 0000000..0d42bc1
--- /dev/null
+++ b/tests/snapshot_tests/snapshot_apps/empty.py
@@ -0,0 +1,12 @@
+from textual.app import App, ComposeResult
+from textual_fastdatatable import DataTable
+
+
+class TableApp(App):
+ def compose(self) -> ComposeResult:
+ yield DataTable()
+
+
+app = TableApp()
+if __name__ == "__main__":
+ app.run()
diff --git a/tests/snapshot_tests/snapshot_apps/empty_add_col.py b/tests/snapshot_tests/snapshot_apps/empty_add_col.py
new file mode 100644
index 0000000..d6fdc7b
--- /dev/null
+++ b/tests/snapshot_tests/snapshot_apps/empty_add_col.py
@@ -0,0 +1,17 @@
+from textual.app import App, ComposeResult
+from textual_fastdatatable import DataTable
+
+
+class TableApp(App):
+ def compose(self) -> ComposeResult:
+ yield DataTable()
+
+ def on_mount(self) -> None:
+ table = self.query_one(DataTable)
+ table.add_column("Foo")
+ table.add_rows([("1",), ("2",)])
+
+
+app = TableApp()
+if __name__ == "__main__":
+ app.run()
diff --git a/tests/snapshot_tests/snapshot_apps/from_parquet.py b/tests/snapshot_tests/snapshot_apps/from_parquet.py
new file mode 100644
index 0000000..f64d700
--- /dev/null
+++ b/tests/snapshot_tests/snapshot_apps/from_parquet.py
@@ -0,0 +1,16 @@
+from pathlib import Path
+
+from textual.app import App, ComposeResult
+from textual_fastdatatable import DataTable
+
+
+class TableApp(App):
+ def compose(self) -> ComposeResult:
+ yield DataTable(
+ data=Path(__file__).parent.parent.parent / "data" / "lap_times_100.parquet"
+ )
+
+
+app = TableApp()
+if __name__ == "__main__":
+ app.run()
diff --git a/tests/snapshot_tests/snapshot_apps/from_pydict_with_col_labels.py b/tests/snapshot_tests/snapshot_apps/from_pydict_with_col_labels.py
new file mode 100644
index 0000000..343fdce
--- /dev/null
+++ b/tests/snapshot_tests/snapshot_apps/from_pydict_with_col_labels.py
@@ -0,0 +1,20 @@
+from textual.app import App, ComposeResult
+from textual_fastdatatable import DataTable
+
+DATA = {
+ "Foo": list(range(50)),
+ "Bar": ["0123456789"] * 50,
+ "Baz": ["IJKLMNOPQRSTUVWXYZ"] * 50,
+}
+
+
+class TableApp(App):
+ def compose(self) -> ComposeResult:
+ yield DataTable(
+ data=DATA, column_labels=["[red]Not Foo[/red]", "Zig", "[reverse]Zag[/]"]
+ )
+
+
+app = TableApp()
+if __name__ == "__main__":
+ app.run()
diff --git a/tests/snapshot_tests/snapshot_apps/from_records.py b/tests/snapshot_tests/snapshot_apps/from_records.py
new file mode 100644
index 0000000..8650dce
--- /dev/null
+++ b/tests/snapshot_tests/snapshot_apps/from_records.py
@@ -0,0 +1,25 @@
+from textual.app import App, ComposeResult
+from textual_fastdatatable import DataTable
+
+ROWS = [
+ ("lane", "swimmer", "country", "time"),
+ (4, "Joseph Schooling", "Singapore", 50.39),
+ (2, "Michael Phelps", "United States", 51.14),
+ (5, "Chad le Clos", "South Africa", 51.14),
+ (6, "László Cseh", "Hungary", 51.14),
+ (3, "Li Zhuhao", "China", 51.26),
+ (8, "Mehdy Metella", "France", 51.58),
+ (7, "Tom Shields", "United States", 51.73),
+ (1, "Aleksandr Sadovnikov", "Russia", 51.84),
+ (10, "Darren Burns", "Scotland", 51.84),
+]
+
+
+class TableApp(App):
+ def compose(self) -> ComposeResult:
+ yield DataTable(data=ROWS)
+
+
+app = TableApp()
+if __name__ == "__main__":
+ app.run()
diff --git a/tests/snapshot_tests/snapshot_apps/no_rows.py b/tests/snapshot_tests/snapshot_apps/no_rows.py
new file mode 100644
index 0000000..fd99b30
--- /dev/null
+++ b/tests/snapshot_tests/snapshot_apps/no_rows.py
@@ -0,0 +1,12 @@
+from textual.app import App, ComposeResult
+from textual_fastdatatable import DataTable
+
+
+class TableApp(App):
+ def compose(self) -> ComposeResult:
+ yield DataTable(column_labels=["foo [red]foo[/red]", "bar"])
+
+
+app = TableApp()
+if __name__ == "__main__":
+ app.run()
diff --git a/tests/snapshot_tests/snapshot_apps/no_rows_empty_sequence.py b/tests/snapshot_tests/snapshot_apps/no_rows_empty_sequence.py
new file mode 100644
index 0000000..fd99b30
--- /dev/null
+++ b/tests/snapshot_tests/snapshot_apps/no_rows_empty_sequence.py
@@ -0,0 +1,12 @@
+from textual.app import App, ComposeResult
+from textual_fastdatatable import DataTable
+
+
+class TableApp(App):
+ def compose(self) -> ComposeResult:
+ yield DataTable(column_labels=["foo [red]foo[/red]", "bar"])
+
+
+app = TableApp()
+if __name__ == "__main__":
+ app.run()
diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py
new file mode 100644
index 0000000..ad8a235
--- /dev/null
+++ b/tests/snapshot_tests/test_snapshots.py
@@ -0,0 +1,119 @@
+from pathlib import Path
+from typing import Callable
+
+import pytest
+
+# These paths should be relative to THIS directory.
+SNAPSHOT_APPS_DIR = Path("./snapshot_apps")
+
+
+def test_auto_table(snap_compare: Callable) -> None:
+ assert snap_compare(SNAPSHOT_APPS_DIR / "auto-table.py", terminal_size=(120, 40))
+
+
+def test_datatable_render(snap_compare: Callable) -> None:
+ press = ["down", "down", "right", "up", "left"]
+ assert snap_compare(SNAPSHOT_APPS_DIR / "data_table.py", press=press)
+
+
+def test_datatable_row_cursor_render(snap_compare: Callable) -> None:
+ press = ["up", "left", "right", "down", "down"]
+ assert snap_compare(SNAPSHOT_APPS_DIR / "data_table_row_cursor.py", press=press)
+
+
+def test_datatable_no_render_markup(snap_compare: Callable) -> None:
+ assert snap_compare(SNAPSHOT_APPS_DIR / "data_table_no_render_markup.py")
+
+
+def test_datatable_null_mixed_cols(snap_compare: Callable) -> None:
+ assert snap_compare(SNAPSHOT_APPS_DIR / "data_table_null_mixed_cols.py")
+
+
+def test_datatable_range_cursor_render(snap_compare: Callable) -> None:
+ press = ["right", "down", "shift+right", "shift+down", "shift+down"]
+ assert snap_compare(SNAPSHOT_APPS_DIR / "data_table_range_cursor.py", press=press)
+
+
+def test_datatable_column_cursor_render(snap_compare: Callable) -> None:
+ press = ["left", "up", "down", "right", "right"]
+ assert snap_compare(SNAPSHOT_APPS_DIR / "data_table_column_cursor.py", press=press)
+
+
+def test_datatable_max_width_render(snap_compare: Callable) -> None:
+ press = ["right", "down", "shift+right", "shift+down", "shift+down"]
+ assert snap_compare(SNAPSHOT_APPS_DIR / "data_table_max_width.py", press=press)
+
+
+def test_datatable_sort_multikey(snap_compare: Callable) -> None:
+ press = ["down", "right", "s"] # Also checks that sort doesn't move cursor.
+ assert snap_compare(SNAPSHOT_APPS_DIR / "data_table_sort.py", press=press)
+
+
+def test_datatable_remove_row(snap_compare: Callable) -> None:
+ press = ["r"]
+ assert snap_compare(SNAPSHOT_APPS_DIR / "data_table_remove_row.py", press=press)
+
+
+@pytest.mark.skip(reason="Don't support row labels.")
+def test_datatable_labels_and_fixed_data(snap_compare: Callable) -> None:
+ # Ensure that we render correctly when there are fixed rows/cols and labels.
+ assert snap_compare(SNAPSHOT_APPS_DIR / "data_table_row_labels.py")
+
+
+# skip, don't xfail; see: https://github.com/Textualize/pytest-textual-snapshot/issues/6
+@pytest.mark.skip(
+ reason=(
+ "The data in this test includes markup; the backend doesn't"
+ "know these have zero width, so we draw the column wider than we used to"
+ )
+)
+def test_datatable_style_ordering(snap_compare: Callable) -> None:
+ # Regression test for https -> None://github.com/Textualize/textual/issues/2061
+ assert snap_compare(SNAPSHOT_APPS_DIR / "data_table_style_order.py")
+
+
+def test_datatable_add_column(snap_compare: Callable) -> None:
+ # Checking adding columns after adding rows
+ assert snap_compare(SNAPSHOT_APPS_DIR / "data_table_add_column.py")
+
+
+@pytest.mark.skip(reason="No multi-height rows. No Rich objects.")
+def test_datatable_add_row_auto_height(snap_compare: Callable) -> None:
+ # Check that rows added with auto height computation look right.
+ assert snap_compare(SNAPSHOT_APPS_DIR / "data_table_add_row_auto_height.py")
+
+
+@pytest.mark.skip(reason="No multi-height rows. No Rich objects.")
+def test_datatable_add_row_auto_height_sorted(snap_compare: Callable) -> None:
+ # Check that rows added with auto height computation look right.
+ assert snap_compare(
+ SNAPSHOT_APPS_DIR / "data_table_add_row_auto_height.py", press=["s"]
+ )
+
+
+def test_datatable_empty(snap_compare: Callable) -> None:
+ assert snap_compare(SNAPSHOT_APPS_DIR / "empty.py")
+
+
+def test_datatable_empty_add_col(snap_compare: Callable) -> None:
+ assert snap_compare(SNAPSHOT_APPS_DIR / "empty_add_col.py")
+
+
+def test_datatable_no_rows(snap_compare: Callable) -> None:
+ assert snap_compare(SNAPSHOT_APPS_DIR / "no_rows.py")
+
+
+def test_datatable_no_rows_empty_sequence(snap_compare: Callable) -> None:
+ assert snap_compare(SNAPSHOT_APPS_DIR / "no_rows_empty_sequence.py")
+
+
+def test_datatable_from_parquet(snap_compare: Callable) -> None:
+ assert snap_compare(SNAPSHOT_APPS_DIR / "from_parquet.py")
+
+
+def test_datatable_from_records(snap_compare: Callable) -> None:
+ assert snap_compare(SNAPSHOT_APPS_DIR / "from_records.py")
+
+
+def test_datatable_from_pydict(snap_compare: Callable) -> None:
+ assert snap_compare(SNAPSHOT_APPS_DIR / "from_pydict_with_col_labels.py")
diff --git a/tests/unit_tests/test_arrow_backend.py b/tests/unit_tests/test_arrow_backend.py
new file mode 100644
index 0000000..661b7bb
--- /dev/null
+++ b/tests/unit_tests/test_arrow_backend.py
@@ -0,0 +1,94 @@
+from __future__ import annotations
+
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+from typing import Sequence
+
+import pyarrow as pa
+from textual_fastdatatable import ArrowBackend
+
+
+def test_from_records(records: list[tuple[str | int, ...]]) -> None:
+ backend = ArrowBackend.from_records(records, has_header=True)
+ assert backend.column_count == 3
+ assert backend.row_count == 5
+ assert tuple(backend.columns) == records[0]
+
+
+def test_from_records_no_header(records: list[tuple[str | int, ...]]) -> None:
+ backend = ArrowBackend.from_records(records[1:], has_header=False)
+ assert backend.column_count == 3
+ assert backend.row_count == 5
+ assert tuple(backend.columns) == ("f0", "f1", "f2")
+
+
+def test_from_pydict(pydict: dict[str, Sequence[str | int]]) -> None:
+ backend = ArrowBackend.from_pydict(pydict)
+ assert backend.column_count == 3
+ assert backend.row_count == 5
+ assert backend.source_row_count == 5
+ assert tuple(backend.columns) == tuple(pydict.keys())
+
+
+def test_from_pydict_with_limit(pydict: dict[str, Sequence[str | int]]) -> None:
+ backend = ArrowBackend.from_pydict(pydict, max_rows=2)
+ assert backend.column_count == 3
+ assert backend.row_count == 2
+ assert backend.source_row_count == 5
+ assert tuple(backend.columns) == tuple(pydict.keys())
+
+
+def test_from_parquet(pydict: dict[str, Sequence[str | int]], tmp_path: Path) -> None:
+ tbl = pa.Table.from_pydict(pydict)
+ p = tmp_path / "test.parquet"
+ pa.parquet.write_table(tbl, str(p))
+
+ backend = ArrowBackend.from_parquet(p)
+ assert backend.data.equals(tbl)
+
+
+def test_empty_query() -> None:
+ data: dict[str, list] = {"a": []}
+ backend = ArrowBackend.from_pydict(data)
+ assert backend.column_content_widths == [0]
+
+
+def test_dupe_column_labels() -> None:
+ arr = pa.array([0, 1, 2, 3])
+ tab = pa.table([arr] * 3, names=["a", "a", "a"])
+ backend = ArrowBackend(data=tab)
+ assert backend.column_count == 3
+ assert backend.row_count == 4
+ assert backend.get_row_at(2) == [2, 2, 2]
+
+
+def test_timestamp_with_tz() -> None:
+ """
+ Ensure datetimes with offsets but no names do not crash the data table
+ when casting to string.
+ """
+ dt = datetime(2024, 1, 1, hour=15, tzinfo=timezone(offset=timedelta(hours=-5)))
+ arr = pa.array([dt, dt, dt])
+ tab = pa.table([arr], names=["created_at"])
+ backend = ArrowBackend(data=tab)
+ assert backend.column_content_widths == [29]
+
+
+def test_mixed_types() -> None:
+ data = [(1000,), ("hi",)]
+ backend = ArrowBackend.from_records(records=data)
+ assert backend
+ assert backend.row_count == 2
+ assert backend.get_row_at(0) == ["1000"]
+ assert backend.get_row_at(1) == ["hi"]
+
+
+def test_negative_timestamps() -> None:
+ dt = datetime(1, 1, 1, tzinfo=timezone.utc)
+ arr = pa.array([dt, dt, dt], type=pa.timestamp("s", tz="America/New_York"))
+ tab = pa.table([arr], names=["created_at"])
+ backend = ArrowBackend(data=tab)
+ assert backend.column_content_widths == [26]
+ assert backend.get_column_at(0) == [datetime.min, datetime.min, datetime.min]
+ assert backend.get_row_at(0) == [datetime.min]
+ assert backend.get_cell_at(0, 0) is datetime.min
diff --git a/tests/unit_tests/test_backends.py b/tests/unit_tests/test_backends.py
new file mode 100644
index 0000000..bdac451
--- /dev/null
+++ b/tests/unit_tests/test_backends.py
@@ -0,0 +1,109 @@
+from __future__ import annotations
+
+import pytest
+from textual_fastdatatable.backend import DataTableBackend
+
+
+def test_column_content_widths(backend: DataTableBackend) -> None:
+ assert backend.column_content_widths == [1, 8, 6]
+
+
+def test_get_row_at(backend: DataTableBackend) -> None:
+ assert backend.get_row_at(0) == [1, "a", "foo"]
+ assert backend.get_row_at(4) == [5, "asdfasdf", "foofoo"]
+ with pytest.raises(IndexError):
+ backend.get_row_at(10)
+ with pytest.raises(IndexError):
+ backend.get_row_at(-1)
+
+
+def test_get_column_at(backend: DataTableBackend) -> None:
+ assert backend.get_column_at(0) == [1, 2, 3, 4, 5]
+ assert backend.get_column_at(2) == ["foo", "bar", "baz", "qux", "foofoo"]
+
+ with pytest.raises(IndexError):
+ backend.get_column_at(10)
+
+
+def test_get_cell_at(backend: DataTableBackend) -> None:
+ assert backend.get_cell_at(0, 0) == 1
+ assert backend.get_cell_at(4, 1) == "asdfasdf"
+ with pytest.raises(IndexError):
+ backend.get_cell_at(10, 0)
+ with pytest.raises(IndexError):
+ backend.get_cell_at(0, 10)
+
+
+def test_append_column(backend: DataTableBackend) -> None:
+ original_table = backend.data
+ backend.append_column("new")
+ assert backend.column_count == 4
+ assert backend.row_count == 5
+ assert backend.get_column_at(3) == [None] * backend.row_count
+
+ backend.append_column("def", default="zzz")
+ assert backend.column_count == 5
+ assert backend.row_count == 5
+ assert backend.get_column_at(4) == ["zzz"] * backend.row_count
+
+ assert backend.data.select(["first column", "two", "three"]).equals(original_table)
+
+
+def test_append_rows(backend: DataTableBackend) -> None:
+ original_table = backend.data
+ backend.append_rows([(6, "w", "x"), (7, "y", "z")])
+ assert backend.column_count == 3
+ assert backend.row_count == 7
+ assert backend.column_content_widths == [1, 8, 6]
+
+ backend.append_rows([(999, "w" * 12, "x" * 15)])
+ assert backend.column_count == 3
+ assert backend.row_count == 8
+ assert backend.column_content_widths == [3, 12, 15]
+
+ assert backend.data.slice(0, 5).equals(original_table)
+
+
+def test_drop_row(backend: DataTableBackend) -> None:
+ backend.drop_row(0)
+ assert backend.row_count == 4
+ assert backend.column_count == 3
+ assert backend.column_content_widths == [1, 8, 6]
+
+ backend.drop_row(3)
+ assert backend.row_count == 3
+ assert backend.column_count == 3
+ assert backend.column_content_widths == [1, 1, 3]
+
+ with pytest.raises(IndexError):
+ backend.drop_row(3)
+
+
+def test_update_cell(backend: DataTableBackend) -> None:
+ backend.update_cell(0, 0, 0)
+ assert backend.get_column_at(0) == [0, 2, 3, 4, 5]
+ assert backend.row_count == 5
+ assert backend.column_count == 3
+ assert backend.column_content_widths == [1, 8, 6]
+
+ backend.update_cell(3, 1, "z" * 50)
+ assert backend.get_row_at(3) == [4, "z" * 50, "qux"]
+ assert backend.row_count == 5
+ assert backend.column_count == 3
+ assert backend.column_content_widths == [1, 50, 6]
+
+
+def test_sort(backend: DataTableBackend) -> None:
+ original_table = backend.data
+ original_col_one = list(backend.get_column_at(0)).copy()
+ original_col_two = list(backend.get_column_at(1)).copy()
+ backend.sort(by="two")
+ assert backend.get_column_at(0) != original_col_one
+ assert backend.get_column_at(1) == sorted(original_col_two)
+
+ backend.sort(by=[("two", "descending")])
+ assert backend.get_column_at(0) != original_col_one
+ assert backend.get_column_at(1) == sorted(original_col_two, reverse=True)
+
+ backend.sort(by=[("first column", "ascending")])
+ assert backend.data.equals(original_table)
diff --git a/tests/unit_tests/test_create_backend.py b/tests/unit_tests/test_create_backend.py
new file mode 100644
index 0000000..49b4cf8
--- /dev/null
+++ b/tests/unit_tests/test_create_backend.py
@@ -0,0 +1,54 @@
+from datetime import date, datetime
+
+import pyarrow as pa
+from textual_fastdatatable.backend import create_backend
+
+MAX_32BIT_INT = 2**31 - 1
+MAX_64BIT_INT = 2**63 - 1
+
+
+def test_empty_sequence() -> None:
+ backend = create_backend(data=[])
+ assert backend
+ assert backend.row_count == 0
+ assert backend.column_count == 0
+ assert backend.columns == []
+ assert backend.column_content_widths == []
+
+
+def test_infinity_timestamps() -> None:
+ from_py = create_backend(
+ data={"dt": [date.max, date.min], "ts": [datetime.max, datetime.min]}
+ )
+ assert from_py
+ assert from_py.row_count == 2
+
+ from_arrow = create_backend(
+ data=pa.table(
+ {
+ "dt32": [
+ pa.scalar(MAX_32BIT_INT, type=pa.date32()),
+ pa.scalar(-MAX_32BIT_INT, type=pa.date32()),
+ ],
+ "dt64": [
+ pa.scalar(MAX_64BIT_INT, type=pa.date64()),
+ pa.scalar(-MAX_64BIT_INT, type=pa.date64()),
+ ],
+ "ts": [
+ pa.scalar(MAX_64BIT_INT, type=pa.timestamp("s")),
+ pa.scalar(-MAX_64BIT_INT, type=pa.timestamp("s")),
+ ],
+ "tns": [
+ pa.scalar(MAX_64BIT_INT, type=pa.timestamp("ns")),
+ pa.scalar(-MAX_64BIT_INT, type=pa.timestamp("ns")),
+ ],
+ }
+ )
+ )
+ assert from_arrow
+ assert from_arrow.row_count == 2
+ assert from_arrow.get_row_at(0) == [date.max, date.max, datetime.max, datetime.max]
+ assert from_arrow.get_row_at(1) == [date.min, date.min, datetime.min, datetime.min]
+ assert from_arrow.get_column_at(0) == [date.max, date.min]
+ assert from_arrow.get_column_at(2) == [datetime.max, datetime.min]
+ assert from_arrow.get_cell_at(0, 0) == date.max