diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..a9dfc8d --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,37 @@ +# + +## Description + + + +## Type of Change + +- [ ] Bug fix (non-breaking change that fixes an issue) +- [ ] New feature (non-breaking change that adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to change) +- [ ] Documentation update +- [ ] Refactor (no functional changes) +- [ ] CI/build configuration + +## Related Issues + + + +## Checklist + +- [ ] Code follows the project style (`black`, `isort`, `ruff check`) +- [ ] Type checking passes (`uv run ty check`) +- [ ] All new and existing tests pass (`uv run pytest`) +- [ ] Coverage remains ≥ 90% +- [ ] Docstrings added/updated (reStructuredText/Sphinx format) +- [ ] `from __future__ import annotations` included in new modules +- [ ] No new dependencies added (or justified if added) +- [ ] Pre-commit hooks pass (`pre-commit run --all-files`) + +## Testing + + + +## Additional Notes + + diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml index 76e7109..06548fd 100644 --- a/.github/workflows/publish-to-pypi.yml +++ b/.github/workflows/publish-to-pypi.yml @@ -4,39 +4,25 @@ on: push: tags: - 'v*.*.*' - # workflow_run doesn't appear to be able to combine with publishing on tags - #workflow_run: - # workflows: [ "CI" ] - # types: [ completed ] jobs: - release: + publish: runs-on: ubuntu-latest - # TODO: only run this if the CI job succeeds and there is a tag set - #if: github.event.workflow_run.conclusion == 'success' - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') - strategy: - matrix: - python-version: - - 3.9 + permissions: + id-token: write steps: - - uses: actions/checkout@v2 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - sudo apt-get -y install graphviz - python -m pip install --upgrade pip - pip install wheel - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - - name: Build package - run: | - python -m build - twine check dist/* - - name: Publish package - uses: pypa/gh-action-pypi-publish@release/v1 - with: - user: __token__ - password: ${{ secrets.PYPI_API_TOKEN }} + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: astral-sh/setup-uv@v5 + with: + version: ">=0.10.12" + - name: Build package + run: uv build + - name: Check package + run: uv run --with twine twine check dist/* + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/python-package-ci.yml b/.github/workflows/python-package-ci.yml index 5b2d82a..40e56dd 100644 --- a/.github/workflows/python-package-ci.yml +++ b/.github/workflows/python-package-ci.yml @@ -1,45 +1,68 @@ -# This workflow will install Python dependencies, run tests and lint with a -# variety of Python versions -# For more information see: -# https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions - name: CI on: - # run pushes to all branches and on pull requests to main push: + branches: + - main pull_request: branches: - main jobs: - build-and-test: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + with: + version: ">=0.10.12" + - name: Install dependencies + run: uv sync --extra all + - name: Ruff check + run: uv run ruff check src/ tests/ + - name: Black check + run: uv run black --check src/ tests/ + - name: isort check + run: uv run isort --check src/ tests/ + - name: Type check with ty + run: uv run ty check + test: runs-on: ubuntu-latest strategy: matrix: python-version: - - 3.8 - - 3.9 + - "3.13" + - "3.14" + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + with: + version: ">=0.10.12" + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + sudo apt-get -y install graphviz + uv sync --extra all + - name: Run tests + run: uv run pytest + package: + runs-on: ubuntu-latest + needs: [lint, test] steps: - - uses: actions/checkout@v2 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - sudo apt-get -y install graphviz - python -m pip install --upgrade pip - python -m pip install flake8 - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - - name: Lint with flake8 - run: | - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - - name: Test with unittest - run: | - python -m unittest discover tests '*_tests.py' \ No newline at end of file + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: astral-sh/setup-uv@v5 + with: + version: ">=0.10.12" + - name: Build package + run: uv build + - name: Check package + run: uv run --with twine twine check dist/* + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ diff --git a/.gitignore b/.gitignore index 42fbf61..90660f8 100644 --- a/.gitignore +++ b/.gitignore @@ -107,3 +107,4 @@ venv.bak/ .vscode .idea .DS_Store +_version.py diff --git a/.icewormignore b/.icewormignore new file mode 100644 index 0000000..1d7a0ea --- /dev/null +++ b/.icewormignore @@ -0,0 +1,161 @@ +# 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 +.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/ + +.idea/ +**_build +**_static +.git/ +.ruff_cache/ +.import_linter_cache/ +.uv/ +uv.lock diff --git a/.mdl_style.rb b/.mdl_style.rb new file mode 100644 index 0000000..5dc3eae --- /dev/null +++ b/.mdl_style.rb @@ -0,0 +1,7 @@ +# Markdownlint style configuration +# frozen_string_literal: true + +all +exclude_rule 'MD024' +rule 'MD013', line_length: 100, ignore_code_blocks: true, tables: false +rule 'MD029', style: 'ordered' diff --git a/.mdlrc b/.mdlrc new file mode 100644 index 0000000..1f82ca2 --- /dev/null +++ b/.mdlrc @@ -0,0 +1 @@ +style '.mdl_style.rb' diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..16d6c3b --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,65 @@ +--- +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: check-case-conflict + - id: check-illegal-windows-names + - id: check-json + - id: check-merge-conflict + - id: check-toml + - id: check-xml + - id: check-yaml + - id: detect-private-key + - id: end-of-file-fixer + - id: mixed-line-ending + - id: trailing-whitespace + - repo: https://github.com/Lucas-C/pre-commit-hooks + rev: v1.5.6 + hooks: + # Replace tabs by whitespaces before committing + - id: remove-tabs + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.7 + hooks: + - id: ruff-check + args: [ --fix, --exit-non-zero-on-fix ] + # No official hook yet + - repo: https://github.com/NSPBot911/ty-pre-commit + rev: v0.0.24 + hooks: + - id: ty-check + language: system + - repo: https://github.com/astral-sh/uv-pre-commit + rev: 0.10.12 + hooks: + - id: uv-lock + - repo: https://github.com/psf/black + rev: 26.3.1 + hooks: + - id: black + - repo: https://github.com/pycqa/isort + rev: 8.0.1 + hooks: + - id: isort + - repo: https://github.com/markdownlint/markdownlint + rev: v0.15.0 + hooks: + - id: markdownlint + args: [ --style, ./.mdl_style.rb ] + language_version: system + - repo: https://github.com/repo-helper/pyproject-parser + rev: v0.14.0 + hooks: + - id: check-pyproject + - id: reformat-pyproject + - repo: https://github.com/yunojuno/pre-commit-xenon + rev: v0.1 + hooks: + - id: xenon + args: [ "--max-average=A", "--max-modules=B", "--max-absolute=B" ] +# - repo: https://github.com/seddonym/import-linter +# rev: v2.11 +# hooks: +# - id: import-linter +# language: system diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..854ea31 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,82 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this +repository. + +## Project Overview + +Graphworks is a zero-dependency Python library for graph theory computation. It provides a `Graph` +class using adjacency-list storage and pure-function algorithm modules for traversal, path-finding, +properties, and directed graph operations. Optional extras add numpy matrix interop (`[matrix]`) and +Graphviz export (`[viz]`). + +## Development Commands + +**Package manager:** uv (required >= 0.10.12) + +```sh +# Install all dev dependencies +uv sync --extra all + +# Run tests (includes coverage; fails under 90%) +uv run pytest + +# Run a single test file +uv run pytest tests/test_graph.py + +# Run a single test by name +uv run pytest tests/test_graph.py -k "test_method_name" + +# Lint and format +uv run ruff check --fix src/ tests/ +uv run black src/ tests/ +uv run isort src/ tests/ + +# Type checking +uv run ty check + +# Code complexity +uv run xenon --max-average=A --max-modules=B --max-absolute=B src/ + +# Run all pre-commit hooks +pre-commit run --all-files +``` + +## Architecture + +### Source layout: `src/graphworks/` + +- **`graph.py`** — Core `Graph` class. Stores graphs as `defaultdict[str, list[str]]`. Accepts JSON + files/strings, stdlib adjacency matrices, or numpy arrays as input. Supports directed, weighted, + and labeled graphs. +- **`edge.py`** — `Edge` dataclass (`vertex1`, `vertex2`, `directed`, `weight`). +- **`types.py`** — Type alias `AdjacencyMatrix = list[list[int]]` (pure Python, no numpy). +- **`numpy_compat.py`** — Optional numpy interop, gated behind `[matrix]` extra. +- **`algorithms/`** — Pure functions that take a `Graph` as input: + - `properties.py` — Degree, connectivity, density, complement, etc. + - `paths.py` — `find_path()`, `find_all_paths()`, `find_isolated_vertices()` + - `search.py` — BFS, DFS, arrival/departure DFS + - `directed.py` — `is_dag()`, `find_circuit()` (Hierholzer's), `build_neighbor_matrix()` + - `sort.py` — Topological sort +- **`export/`** — `save_to_json()` and `save_to_dot()` standalone functions. +- **`data/`** — JSON test graph fixtures (g1–g4). + +### Tests: `tests/` + +Tests mirror the source modules (e.g., `test_properties.py` tests `algorithms/properties.py`). +Markers: `@pytest.mark.unit`, `@pytest.mark.integration`, `@pytest.mark.slow`. + +## Code Style and Conventions + +- **Python 3.13+** required; `from __future__ import annotations` used throughout +- **PEP 257** docstrings on all public APIs; ruff enforces `ANN` (annotations) and `D` (docstrings) + rules on `src/` but exempts `tests/` +- **Formatting:** black (line-length 88), isort (black profile) +- **Type checking:** ty in strict mode +- All algorithm functions are pure functions — no classes wrapping algorithms +- Version is dynamic via `hatchling-vcs` (git tags like `vX.Y.Z`) + +## Publishing + +Tag a commit with `git tag -a vX.Y.Z -m 'message'` and push tags to trigger PyPI publish via GitHub +Actions. diff --git a/README.md b/README.md index 8f7ddf8..854df77 100755 --- a/README.md +++ b/README.md @@ -4,82 +4,121 @@ ## A Python module for efficient graph theoretic programming -## Usage +[Documentation](https://graphworks.readthedocs.io) | +[Wiki](https://github.com/nathan-gilbert/graphworks/wiki) -See the [wiki](https://github.com/nathan-gilbert/graphworks/wiki) +### Quick Start -### TLDR - -First, `pip install graphworks` +```sh +pip install graphworks +``` ```python import json -from src.graphworks.graph import Graph +from graphworks.graph import Graph json_graph = {"label": "my graph", "edges": {"A": ["B"], "B": []}} graph = Graph("my graph", input_graph=json.dumps(json_graph)) print(graph) ``` +Optional extras: + +```sh +pip install graphworks[matrix] # numpy adjacency matrix support +pip install graphworks[viz] # graphviz export +pip install graphworks[docs] # generate documentation +``` + ## Development ### Requirements -- Python 3.8+ -- virtualenv -- numpy -- graphviz +- Python 3.13+ +- [uv](https://docs.astral.sh/uv/) (>= 0.10.12) -### Install the required packages +### Setup ```sh -pip install virtualenv -virtualenv env +uv sync --extra all ``` -### Start the virtualenv +### Running Tests ```sh -source ./env/bin/activate -``` +# Run all tests (includes coverage; fails under 90%) +uv run pytest -### You can deactivate the virtualenv with +# Run a single test file +uv run pytest tests/test_graph.py -```sh -deactivate +# Run a single test by name +uv run pytest tests/test_graph.py -k "test_method_name" ``` -### Lastly, install the required libraries +### Linting and Formatting ```sh -pip install -r requirements.txt -``` +# Lint +uv run ruff check --fix src/ tests/ + +# Format +uv run black src/ tests/ +uv run isort src/ tests/ -### Building the package +# Type checking +uv run ty check + +# Code complexity +uv run xenon --max-average=A --max-modules=B --max-absolute=B src/ + +# Run all pre-commit hooks +pre-commit run --all-files +``` -- Update the version number in `graphworks.__init__.py` -- Run `python -m build` -- Run `twine check dist/*` -- Upload to test PyPi: `twine upload --repository-url https://test.pypi.org/legacy/ dist/*` -- Upload to PyPi main: `twine upload --skip-existing dist/*` -- To autopublish, tag commit with `git tag -a vX.Y.Z -m 'release message` -- Then `git push --tags` +### Publishing -### Diagnostics +Version is managed automatically via git tags using `hatchling-vcs`. -- Run the unit tests: `python -m unittest discover tests '*_tests.py'` -- Run unit test coverage: `coverage run --source=graphworks/ -m unittest discover tests '*_tests.py'` -- Generate test coverage reports (either works): - - `coverage report --omit="*/test*,*/venv/*"` - - `coverage html --omit="*/test*,*/venv/*"` +- Tag a commit: `git tag -a vX.Y.Z -m 'release message'` +- Push the tag: `git push --tags` +- The GitHub Actions workflow will build and publish to PyPI automatically. ## TODO -- Create Vertex class -- -- Build out directed graphs algorithms - - -- Allow for weighted graph algorithms - - Jarnik's algorithm - - Dijkstra's algorithm -- C++ binaries for speeding up graph computations +![TODO List](./todos.png) + +Tier 1: Data model (do first, everything depends on it) The biggest gap right now is that vertices +are bare strings and edges are lightweight dataclasses that the Graph class barely uses internally. +The adjacency list stores `defaultdict[str, list[str]]` — just names pointing to names. This means +vertex attributes, edge weights, and edge labels all live outside the canonical representation. Your +g4.json weighted format already hints at the tension: it uses dicts-as-neighbors instead of +strings, but the Graph class doesn't actually parse them. A Vertex class (with a name, optional +label, and an attribute dict) and a richer Edge (already a dataclass, but needs to be the actual +unit of storage rather than reconstructed on every .edges() call) would give you a foundation where +all the metadata survives every operation. + +Tier 2: Graph refactor Once Vertex and Edge exist as first-class objects, the internal +`defaultdict[str, list[str]]` can become something like `dict[str, Vertex]` for vertex lookup and an +edge storage structure that preserves weights and attributes. The critical constraint from your +philosophy: conversions to adjacency matrix and back must be lossless — this is exactly the +get_complement bug you just hit. A vertex-name-to-index mapping maintained alongside the matrix +would solve it. + +Tier 3: Lossless conversions With named vertices and attributed edges, you can build clean +`to_adjacency_matrix()` / `from_adjacency_matrix()` round-trips that carry a name mapping, +`to_edge_list()` / `from_edge_list()`, and fix get_complement to work through the matrix without +losing names. + +Tier 4: Algorithms With weighted edges actually in the data model, Dijkstra and Prim become +natural. Strongly connected components, better shortest-path implementations, and the directed graph +algorithms from your TODO list can all build on the refactored core. + +Tier 5: Export/CLI The Rich rendering and CLI app build on top of everything above. The export +layer (JSON, DOT, Rich) becomes a clean translation from your canonical format rather than ad-hoc +string building. + +Tier 6: Cross-cutting quality Thread safety (immutable graph views, or threading.Lock around +mutations), input validation, and benchmarks can happen in parallel with other tiers. Where would +you like to start? The Vertex class and Edge redesign are the natural first move — they're +self-contained, testable, and unblock everything downstream. diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..8f425d9 --- /dev/null +++ b/conftest.py @@ -0,0 +1,378 @@ +"""Shared pytest fixtures for the graphworks test suite. + +All fixtures used across multiple test modules live here so pytest discovers +them automatically without explicit imports. + +This test suite is written to run against the **installed** package. In the +typical development workflow:: + + uv sync # installs graphworks in editable mode + uv run pytest # runs the suite against the editable install + +Alternatively, add the following to ``[tool.pytest.ini_options]`` in +``pyproject.toml`` so that pytest adds ``src/`` to ``sys.path`` for +environments that do not use an editable install:: + + pythonpath = ["src"] + +Both approaches ensure that ``from graphworks.x import Y`` and the +library's internal ``from graphworks.x import Y`` resolve to the **same** +module object, which is required for dataclass ``__eq__`` to work correctly +across the test/library boundary. +""" + +from __future__ import annotations + +import json +import shutil +import tempfile +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Generator + +import pytest + +from graphworks.graph import Graph + +# --------------------------------------------------------------------------- +# Temporary filesystem helpers +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def tmp_dir() -> Generator[Path]: + """Yield a fresh temporary directory and clean it up afterwards. + + :return: Path to a temporary directory. + :rtype: Path + """ + d = tempfile.mkdtemp() + yield Path(d) + shutil.rmtree(d) + + +# --------------------------------------------------------------------------- +# Raw JSON graph definitions (dicts) shared across test modules +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def simple_edge_json() -> dict: + """Minimal two-vertex undirected graph with one edge (A → B). + + :return: Graph definition dictionary. + :rtype: dict + """ + return {"label": "my graph", "graph": {"A": ["B"], "B": []}} + + +@pytest.fixture() +def triangle_json() -> dict: + """Complete undirected graph on three vertices (K₃). + + :return: Graph definition dictionary. + :rtype: dict + """ + return { + "graph": { + "a": ["b", "c"], + "b": ["a", "c"], + "c": ["a", "b"], + } + } + + +@pytest.fixture() +def isolated_json() -> dict: + """Three-vertex graph with no edges (all isolated vertices). + + :return: Graph definition dictionary. + :rtype: dict + """ + return {"graph": {"a": [], "b": [], "c": []}} + + +@pytest.fixture() +def connected_json() -> dict: + """Six-vertex connected undirected graph that includes self-loops. + + :return: Graph definition dictionary. + :rtype: dict + """ + return { + "graph": { + "a": ["d", "f"], + "b": ["c", "b"], + "c": ["b", "c", "d", "e"], + "d": ["a", "c"], + "e": ["c"], + "f": ["a"], + } + } + + +@pytest.fixture() +def big_graph_json() -> dict: + """Six-vertex connected undirected graph used for diameter tests. + + :return: Graph definition dictionary. + :rtype: dict + """ + return { + "graph": { + "a": ["c"], + "b": ["c", "e", "f"], + "c": ["a", "b", "d", "e"], + "d": ["c"], + "e": ["b", "c", "f"], + "f": ["b", "e"], + } + } + + +@pytest.fixture() +def lollipop_json() -> dict: + """Lollipop-shaped graph that contains a cycle (d→b) but *no* self-loops. + + ``is_simple`` in this library only checks for self-loops (a vertex listed + in its own neighbour list), so this graph **is** considered simple despite + the cycle. Use :func:`self_loop_json` when you need a graph that is + definitively not simple. + + :return: Graph definition dictionary. + :rtype: dict + """ + return { + "graph": { + "z": ["a"], + "a": ["b"], + "b": ["c"], + "c": ["d"], + "d": ["b"], + } + } + + +@pytest.fixture() +def self_loop_json() -> dict: + """Two-vertex graph where vertex *a* has a self-loop — **not** simple. + + :return: Graph definition dictionary. + :rtype: dict + """ + return { + "graph": { + "a": ["a", "b"], + "b": ["a"], + } + } + + +@pytest.fixture() +def straight_line_json() -> dict: + """Linear path graph a-b-c-d: simple, no self-loops, no cycles. + + :return: Graph definition dictionary. + :rtype: dict + """ + return {"graph": {"a": ["b"], "b": ["c"], "c": ["d"], "d": []}} + + +@pytest.fixture() +def directed_dag_json() -> dict: + """Directed acyclic graph for topological sort and DAG tests. + + :return: Graph definition dictionary. + :rtype: dict + """ + return { + "directed": True, + "graph": { + "A": [], + "B": [], + "C": ["D"], + "D": ["B"], + "E": ["A", "B"], + "F": ["A", "C"], + }, + } + + +@pytest.fixture() +def directed_cycle_json() -> dict: + """Directed graph containing a cycle — **not** a DAG. + + The cycle is A → B → D → A (back-edge D→A). + + :return: Graph definition dictionary. + :rtype: dict + """ + return { + "directed": True, + "graph": { + "A": ["B"], + "B": ["C", "D"], + "C": [], + "D": ["E", "A"], + "E": [], + }, + } + + +@pytest.fixture() +def circuit_json() -> dict: + """Directed graph with a single Eulerian circuit A → B → C → A. + + :return: Graph definition dictionary. + :rtype: dict + """ + return { + "directed": True, + "graph": {"A": ["B"], "B": ["C"], "C": ["A"]}, + } + + +@pytest.fixture() +def search_graph_json() -> dict: + """Four-vertex graph used for BFS / DFS traversal tests. + + :return: Graph definition dictionary. + :rtype: dict + """ + return { + "graph": { + "a": ["b", "c"], + "b": ["c"], + "c": ["a", "d"], + "d": ["d"], + } + } + + +@pytest.fixture() +def disjoint_directed_json() -> dict: + """Directed graph with two disjoint components for arrival/departure DFS. + + :return: Graph definition dictionary. + :rtype: dict + """ + return { + "directed": True, + "graph": { + "a": ["b", "c"], + "b": [], + "c": ["d", "e"], + "d": ["b", "f"], + "e": ["f"], + "f": [], + "g": ["h"], + "h": [], + }, + } + + +# --------------------------------------------------------------------------- +# Pre-built Graph fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def simple_edge_graph(simple_edge_json: dict) -> Graph: + """Two-vertex undirected :class:`Graph` with one edge (A → B). + + :return: Constructed Graph instance. + :rtype: Graph + """ + return Graph(input_graph=json.dumps(simple_edge_json)) + + +@pytest.fixture() +def triangle_graph(triangle_json: dict) -> Graph: + """Complete undirected :class:`Graph` on three vertices (K₃). + + :return: Constructed Graph instance. + :rtype: Graph + """ + return Graph(input_graph=json.dumps(triangle_json)) + + +@pytest.fixture() +def isolated_graph(isolated_json: dict) -> Graph: + """Three-vertex :class:`Graph` with no edges. + + :return: Constructed Graph instance. + :rtype: Graph + """ + return Graph(input_graph=json.dumps(isolated_json)) + + +@pytest.fixture() +def connected_graph(connected_json: dict) -> Graph: + """Six-vertex connected undirected :class:`Graph`. + + :return: Constructed Graph instance. + :rtype: Graph + """ + return Graph(input_graph=json.dumps(connected_json)) + + +@pytest.fixture() +def big_graph(big_graph_json: dict) -> Graph: + """Six-vertex connected undirected :class:`Graph` for diameter tests. + + :return: Constructed Graph instance. + :rtype: Graph + """ + return Graph(input_graph=json.dumps(big_graph_json)) + + +@pytest.fixture() +def directed_dag(directed_dag_json: dict) -> Graph: + """Directed acyclic :class:`Graph`. + + :return: Constructed Graph instance. + :rtype: Graph + """ + return Graph(input_graph=json.dumps(directed_dag_json)) + + +@pytest.fixture() +def directed_cycle_graph(directed_cycle_json: dict) -> Graph: + """Directed :class:`Graph` containing a cycle. + + :return: Constructed Graph instance. + :rtype: Graph + """ + return Graph(input_graph=json.dumps(directed_cycle_json)) + + +@pytest.fixture() +def circuit_graph(circuit_json: dict) -> Graph: + """Directed :class:`Graph` with an Eulerian circuit A → B → C → A. + + :return: Constructed Graph instance. + :rtype: Graph + """ + return Graph(input_graph=json.dumps(circuit_json)) + + +@pytest.fixture() +def search_graph(search_graph_json: dict) -> Graph: + """Four-vertex :class:`Graph` for BFS / DFS tests. + + :return: Constructed Graph instance. + :rtype: Graph + """ + return Graph(input_graph=json.dumps(search_graph_json)) + + +@pytest.fixture() +def disjoint_directed_graph(disjoint_directed_json: dict) -> Graph: + """Directed :class:`Graph` with two disjoint components. + + :return: Constructed Graph instance. + :rtype: Graph + """ + return Graph(input_graph=json.dumps(disjoint_directed_json)) diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..e83615b --- /dev/null +++ b/examples/README.md @@ -0,0 +1,18 @@ +# Examples + +Runnable scripts that demonstrate graphworks features. These are **not** shipped with the library +(the `examples/` directory is excluded from both the sdist and wheel). + +## Running + +The recommended way is via the `uv run` script alias defined in `pyproject.toml`: + +```sh +uv run demo +``` + +You can also invoke the file directly: + +```sh +uv run python examples/demo.py +``` diff --git a/examples/__init__.py b/examples/__init__.py new file mode 100644 index 0000000..db007ec --- /dev/null +++ b/examples/__init__.py @@ -0,0 +1,6 @@ +"""Example scripts for graphworks. + +This package is **not** shipped with the library — it is excluded from both the sdist and wheel +builds. It exists only for local development use via ``uv run demo`` (or similar entry points +registered in ``pyproject.toml``). +""" diff --git a/examples/demo.py b/examples/demo.py new file mode 100644 index 0000000..c5b4474 --- /dev/null +++ b/examples/demo.py @@ -0,0 +1,425 @@ +#!/usr/bin/env python3 +"""Graphworks demo — a rich tour of the library's core features. + +Run with:: + + uv run demo + +Or directly:: + + uv run python examples/demo.py + +Requires the ``[viz]`` optional extra:: + + uv sync --extra viz + +This script is **not** shipped with the library. It lives in the ``examples/`` +directory and is excluded from both the sdist and wheel builds. +""" + +from __future__ import annotations + +import json + +from rich.console import Console +from rich.panel import Panel +from rich.table import Table +from rich.text import Text +from rich.tree import Tree + +from graphworks.algorithms import ( + breadth_first_search, + degree_sequence, + density, + depth_first_search, + diameter, + find_all_paths, + find_isolated_vertices, + find_path, + is_complete, + is_connected, + is_dag, + is_regular, + is_simple, + topological, + vertex_degree, +) +from graphworks.graph import Graph + +console = Console() + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _section(title: str) -> None: + """Print a Rich rule as a section divider. + + :param title: Section heading text. + :type title: str + :return: Nothing. + :rtype: None + """ + console.print() + console.rule(f"[bold cyan]{title}[/bold cyan]") + console.print() + + +def _kv(key: str, value: object) -> None: + """Print a key/value pair with Rich markup. + + :param key: Label. + :type key: str + :param value: Value to display. + :type value: object + :return: Nothing. + :rtype: None + """ + console.print(f" [dim]{key:<16}[/dim] {value}") + + +def _graph_panel(graph: Graph, title: str, border_style: str = "blue") -> None: + """Display a graph as a Rich Tree inside a Panel. + + This gives a clear, readable adjacency-list visualisation that works for + both directed and undirected graphs of any size — no external layout + engine required. + + :param graph: The graph to display. + :type graph: Graph + :param title: Panel title. + :type title: str + :param border_style: Rich border colour. + :type border_style: str + :return: Nothing. + :rtype: None + """ + arrow = "→" if graph.is_directed() else "—" + tree = Tree(f"[bold]{title}[/bold]") + for v in sorted(graph.vertices()): + neighbours = graph.get_neighbors(v) + if neighbours: + label = f"[green]{v}[/green] {arrow} " + ", ".join( + f"[cyan]{n}[/cyan]" for n in neighbours + ) + else: + label = f"[green]{v}[/green] [dim](no edges)[/dim]" + tree.add(label) + console.print(Panel(tree, border_style=border_style)) + + +# --------------------------------------------------------------------------- +# Demo sections +# --------------------------------------------------------------------------- + + +def demo_construction() -> Graph: + """Demonstrate graph construction and display. + + :return: The JSON-constructed graph used in subsequent demos. + :rtype: Graph + """ + _section("1 · Graph construction") + + json_def = { + "label": "social network", + "directed": False, + "graph": { + "Alice": ["Bob", "Carol"], + "Bob": ["Alice", "Dave"], + "Carol": ["Alice", "Dave"], + "Dave": ["Bob", "Carol", "Eve"], + "Eve": ["Dave"], + }, + } + graph = Graph(input_graph=json.dumps(json_def)) + _kv("label", graph.get_label()) + _kv("vertices", graph.order()) + _kv("edges", graph.size()) + _kv("directed", graph.is_directed()) + + console.print() + _graph_panel(graph, "social network") + + # Other construction methods + console.print() + matrix = [[0, 1, 0], [1, 0, 1], [0, 1, 0]] + matrix_graph = Graph(input_matrix=matrix) + _kv("from matrix", f"{matrix_graph.order()} vertices, {matrix_graph.size()} edges") + + manual = Graph("manual") + for v in ("X", "Y", "Z"): + manual.add_vertex(v) + manual.add_edge("X", "Y") + manual.add_edge("Y", "Z") + _kv("programmatic", f"{manual.order()} vertices, {manual.size()} edges") + + return graph + + +def demo_properties(graph: Graph) -> None: + """Show structural property queries in a Rich table. + + :param graph: An undirected graph to inspect. + :type graph: Graph + :return: Nothing. + :rtype: None + """ + _section("2 · Structural properties") + + props = { + "connected": is_connected(graph), + "complete": is_complete(graph), + "simple": is_simple(graph), + "regular": is_regular(graph), + "density": f"{density(graph):.4f}", + "diameter": diameter(graph), + "deg sequence": degree_sequence(graph), + "isolated": find_isolated_vertices(graph) or "(none)", + } + + table = Table(title="Graph Properties", show_header=True, header_style="bold magenta") + table.add_column("Property", style="cyan") + table.add_column("Value") + for k, v in props.items(): + val_str = str(v) + style = "" + if isinstance(v, bool): + style = "green" if v else "red" + val_str = "✓" if v else "✗" + table.add_row(k, Text(val_str, style=style)) + console.print(table) + + +def demo_degrees(graph: Graph) -> None: + """Display per-vertex degree information in a table. + + :param graph: An undirected graph to inspect. + :type graph: Graph + :return: Nothing. + :rtype: None + """ + _section("3 · Vertex degrees") + + table = Table(show_header=True, header_style="bold") + table.add_column("Vertex", style="green") + table.add_column("Degree", justify="right") + table.add_column("Neighbours") + for v in sorted(graph.vertices()): + deg = vertex_degree(graph, v) + nbrs = ", ".join(graph.get_neighbors(v)) or "(none)" + table.add_row(v, str(deg), nbrs) + console.print(table) + + +def demo_traversal(graph: Graph) -> None: + """Run BFS and DFS from a starting vertex. + + :param graph: An undirected graph to traverse. + :type graph: Graph + :return: Nothing. + :rtype: None + """ + _section("4 · Traversals") + + start = graph.vertices()[0] + bfs = breadth_first_search(graph, start) + dfs = depth_first_search(graph, start) + + table = Table(show_header=True, header_style="bold") + table.add_column("Algorithm", style="cyan") + table.add_column(f"From '{start}'") + table.add_row("BFS", " → ".join(bfs)) + table.add_row("DFS", " → ".join(dfs)) + console.print(table) + + +def demo_paths(graph: Graph) -> None: + """Demonstrate path-finding between two vertices. + + :param graph: An undirected graph to search. + :type graph: Graph + :return: Nothing. + :rtype: None + """ + _section("5 · Path finding") + + src, dst = "Alice", "Eve" + single = find_path(graph, src, dst) + + _kv("shortest path", " → ".join(single) if single else "(none)") + console.print() + + all_paths = find_all_paths(graph, src, dst) + table = Table( + title=f"All paths: {src} → {dst}", + show_header=True, + header_style="bold", + ) + table.add_column("#", justify="right", style="dim") + table.add_column("Path") + table.add_column("Length", justify="right") + for i, p in enumerate(all_paths, 1): + table.add_row(str(i), " → ".join(p), str(len(p) - 1)) + console.print(table) + + +def demo_complement(graph: Graph) -> None: + """Show the complement graph. + + The library's :func:`get_complement` returns a matrix-based graph with + UUID vertex names (the original names are lost in the matrix round-trip). + For display purposes we compute the missing edges directly so we can + show them with the original, human-readable vertex names. + + :param graph: An undirected graph. + :type graph: Graph + :return: Nothing. + :rtype: None + """ + _section("6 · Complement graph") + + verts = sorted(graph.vertices()) + original_edges: set[tuple[str, str]] = set() + for v in verts: + for n in graph.get_neighbors(v): + edge = (min(v, n), max(v, n)) + original_edges.add(edge) + + complement_edges: list[tuple[str, str]] = [] + for i, v1 in enumerate(verts): + for v2 in verts[i + 1 :]: + if (min(v1, v2), max(v1, v2)) not in original_edges: + complement_edges.append((v1, v2)) + + _kv("original edges", len(original_edges)) + _kv("complement edges", len(complement_edges)) + console.print() + + table = Table( + show_header=True, header_style="bold", title="Complement Edges (missing from original)" + ) + table.add_column("#", justify="right", style="dim") + table.add_column("From", style="green") + table.add_column("", justify="center") + table.add_column("To", style="cyan") + for i, (v1, v2) in enumerate(complement_edges, 1): + table.add_row(str(i), v1, "—", v2) + console.print(table) + + +def demo_directed() -> None: + """Demonstrate directed graph features: DAG detection and topological sort. + + :return: Nothing. + :rtype: None + """ + _section("7 · Directed graphs") + + dag_def = { + "directed": True, + "label": "build pipeline", + "graph": { + "lint": [], + "typecheck": [], + "compile": ["lint", "typecheck"], + "test": ["compile"], + "package": ["test"], + "deploy": ["package"], + }, + } + dag = Graph(input_graph=json.dumps(dag_def)) + + _kv("DAG?", is_dag(dag)) + _kv("topo sort", " → ".join(topological(dag))) + + console.print() + _graph_panel(dag, "build pipeline (DAG)", border_style="green") + + # Cyclic example + console.print() + cycle_def = { + "directed": True, + "label": "cyclic pipeline", + "graph": { + "lint": ["compile"], + "compile": ["test"], + "test": ["deploy"], + "deploy": ["lint"], + }, + } + cyclic = Graph(input_graph=json.dumps(cycle_def)) + _kv("cyclic graph", f"DAG? {is_dag(cyclic)}") + + console.print() + _graph_panel(cyclic, "cyclic pipeline (NOT a DAG)", border_style="red") + + +def demo_adjacency_matrix(graph: Graph) -> None: + """Display the adjacency matrix as a Rich table. + + :param graph: A graph to display. + :type graph: Graph + :return: Nothing. + :rtype: None + """ + _section("8 · Adjacency matrix") + + matrix = graph.get_adjacency_matrix() + verts = sorted(graph.vertices()) + + table = Table(show_header=True, header_style="bold") + table.add_column("", style="green") + for v in verts: + table.add_column(v, justify="center", min_width=3) + for i, v in enumerate(verts): + cells = [] + for val in matrix[i]: + if val: + cells.append("[bold green]1[/bold green]") + else: + cells.append("[dim]·[/dim]") + table.add_row(v, *cells) + console.print(table) + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + + +def main() -> None: + """Run all demo sections. + + :return: Nothing. + :rtype: None + """ + console.print( + Panel( + "[bold]graphworks[/bold] — library demo", + style="bold blue", + expand=False, + ) + ) + + graph = demo_construction() + demo_properties(graph) + demo_degrees(graph) + demo_traversal(graph) + demo_paths(graph) + demo_complement(graph) + demo_directed() + demo_adjacency_matrix(graph) + + console.print() + console.rule("[bold green]Done![/bold green]") + console.print( + " Explore the source at [link=https://github.com/nathan-gilbert/graphworks]" + "src/graphworks/[/link]" + ) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index fa7093a..a74694f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,142 @@ [build-system] -requires = ["setuptools>=42"] -build-backend = "setuptools.build_meta" \ No newline at end of file +requires = [ "hatch-vcs", "hatchling",] +build-backend = "hatchling.build" + +[project] +name = "graphworks" +description = "Graph theoretic classes and algorithm helper functions." +readme = "README.md" +requires-python = "<=3.15,>=3.13" +keywords = [ "algorithms", "data-structures", "graph", "graph-theory", "mathematics", "network",] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Intended Audience :: Education", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", + "Topic :: Scientific/Engineering :: Mathematics", + "Topic :: Software Development :: Libraries :: Python Modules", + "Typing :: Typed", +] +dependencies = [] +dynamic = [ "version",] + +[project.license] +text = "MIT" + +[[project.authors]] +name = "Nathan Gilbert" +email = "nathan.gilbert@gmail.com" + +[[project.maintainers]] +name = "Nathan Gilbert" +email = "nathan.gilbert@gmail.com" + +[project.urls] +Homepage = "https://github.com/nathan-gilbert/graphworks" +Repository = "https://github.com/nathan-gilbert/graphworks" +"Bug Tracker" = "https://github.com/nathan-gilbert/graphworks/issues" +Changelog = "https://github.com/nathan-gilbert/graphworks/blob/main/CHANGELOG.md" +Documentation = "https://graphworks.readthedocs.io" + +[project.scripts] +demo = "examples.demo:main" + +[project.optional-dependencies] +matrix = [ "numpy",] +viz = [ "graphviz", "rich",] +docs = [ "myst-parser", "sphinx", "sphinx-autodoc-typehints", "sphinx-rtd-theme",] +all = [ "graphworks[docs,matrix,viz]",] + +[tool.hatch.version] +source = "vcs" + +[tool.hatch.build] +dev-mode-dirs = [ "src", ".",] + +[tool.hatch.build.hooks.vcs] +version-file = "src/graphworks/_version.py" + +[tool.hatch.build.targets.sdist] +include = [ "src/", "tests/", "docs/", "README.md", "CHANGELOG.md", "LICENSE", "pyproject.toml",] +exclude = [ "examples/",] + +[tool.hatch.build.targets.wheel] +packages = [ "src/graphworks",] + +[tool.black] +line-length = 100 +target-version = [ "py314",] + +[tool.ruff] +line-length = 100 +target-version = "py314" + +[tool.ruff.lint] +select = [ "E", "W", "F", "I", "UP", "B", "C4", "SIM", "TCH", "ANN", "D",] +ignore = [ "D203", "D401", "D213",] + +[tool.ruff.lint.pydocstyle] +convention = "pep257" + +[tool.ruff.lint.per-file-ignores] +"tests/**/*.py" = [ "ANN", "D",] + +[tool.isort] +profile = "black" +line_length = 100 +multi_line_output = 3 +known_first_party = [ "graphworks",] +skip = [ "src/graphworks/_version.py",] + +[tool.ty.environment] +python-platform = "darwin" +python-version = "3.14" +root = [ "./src",] + +[tool.uv] +required-version = ">=0.10.12" +python-preference = "managed" +resolution = "highest" +prerelease = "disallow" +index-strategy = "first-index" + +[tool.pytest] +testpaths = [ "tests",] +pythonpath = [ "src",] +addopts = [ + "--color=yes", + "--tb=auto", + "--maxfail=10", + "--durations=10", + "-ra", + "--strict-markers", + "--cov=src/graphworks", + "--cov-report=term-missing", + "--cov-report=html:htmlcov", +] +markers = [ + "unit: fast, isolated unit tests", + "integration: tests that exercise multiple components together", + "slow: tests that take more than a second", +] + +[tool.coverage.run] +source = [ "src/graphworks",] +omit = [ "tests/*", "docs/*", "examples/*",] +branch = true + +[tool.coverage.report] +show_missing = true +skip_covered = false +fail_under = 90 +exclude_lines = [ "pragma: no cover", "if TYPE_CHECKING:", "raise NotImplementedError", "@(abc\\.)?abstractmethod",] + +[dependency-groups] +dev = [ "black", "import-linter", "isort", "pytest", "pytest-cov", "ruff", "ty", "xenon",] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 679b168..0000000 --- a/requirements.txt +++ /dev/null @@ -1,43 +0,0 @@ -astroid==2.11.3 -bleach==4.1.0 -build==0.8.0 -certifi==2021.10.8 -charset-normalizer==2.0.10 -colorama==0.4.4 -coverage==6.2 -dill==0.3.4 -docutils==0.18.1 -flake8==4.0.1 -graphviz==0.20 -idna==3.3 -importlib-metadata==4.10.0 -isort==5.10.1 -keyring==23.5.0 -lazy-object-proxy==1.7.1 -mccabe==0.6.1 -numpy==1.22.0 -packaging==21.3 -pep517==0.12.0 -pkginfo==1.8.2 -platformdirs==2.4.1 -pycodestyle==2.8.0 -pyflakes==2.4.0 -Pygments==2.11.1 -pylint==2.13.7 -pyparsing==3.0.8 -readme-renderer==32.0 -requests==2.27.1 -requests-toolbelt==0.9.1 -rfc3986==1.5.0 -rope==1.0.0 -six==1.16.0 -toml==0.10.2 -tomli==2.0.1 -tqdm==4.62.3 -twine==3.7.1 -typed-ast==1.5.1 -typing_extensions==4.0.1 -urllib3==1.26.7 -webencodings==0.5.1 -wrapt==1.13.3 -zipp==3.7.0 diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 7cb958a..0000000 --- a/setup.cfg +++ /dev/null @@ -1,37 +0,0 @@ -[metadata] -name = graphworks -version = attr: graphworks.__version__ -author = Nathan Gilbert -author_email = nathan.gilbert@gmail.com -description = Graph theoretic classes and helper functions. -long_description = file: README.md -long_description_content_type = text/markdown -url = https://github.com/nathan-gilbert/graphworks -project_urls = - Bug Tracker = https://github.com/nathan-gilbert/graphworks/issues -classifiers = - Intended Audience :: Developers - Intended Audience :: Education - Intended Audience :: Science/Research - Programming Language :: Python - Programming Language :: Python :: 3 - Programming Language :: Python :: 3.7 - Programming Language :: Python :: 3.8 - Programming Language :: Python :: 3.9 - Operating System :: OS Independent - Topic :: Scientific/Engineering :: Mathematics -license = MIT -include_package_data = True -zip_safe = False - -[options] -package_dir = - = src -packages = find: -python_requires = >=3.7 -install_requires = - numpy ==1.21.5 - graphviz ==0.19.1 - -[options.packages.find] -where = src \ No newline at end of file diff --git a/src/graphworks/README.md b/src/graphworks/README.md index 3ed3e54..98b0952 100644 --- a/src/graphworks/README.md +++ b/src/graphworks/README.md @@ -1,3 +1,3 @@ # Graphworks -## A library for efficient graph theoretic computation \ No newline at end of file +## A library for efficient graph theoretic computation diff --git a/src/graphworks/__init__.py b/src/graphworks/__init__.py index 0dac231..8e1c957 100644 --- a/src/graphworks/__init__.py +++ b/src/graphworks/__init__.py @@ -1,3 +1,3 @@ -__all__ = ["graph", "algorithms", "export"] -__version__ = '0.5.0' -__author__ = 'Nathan Gilbert' +"""Graphworks package.""" + +__author__ = "Nathan Gilbert" diff --git a/src/graphworks/algorithms/__init__.py b/src/graphworks/algorithms/__init__.py index e69de29..59b1e06 100644 --- a/src/graphworks/algorithms/__init__.py +++ b/src/graphworks/algorithms/__init__.py @@ -0,0 +1,82 @@ +"""Graph algorithm implementations. + +Submodules +---------- + +- :mod:`~graphworks.algorithms.properties` — structural predicates and metrics + (``is_connected``, ``density``, ``diameter``, ``degree_sequence``, etc.) +- :mod:`~graphworks.algorithms.paths` — path finding and edge utilities + (``find_path``, ``find_all_paths``, ``generate_edges``, etc.) +- :mod:`~graphworks.algorithms.search` — graph traversal + (``breadth_first_search``, ``depth_first_search``, etc.) +- :mod:`~graphworks.algorithms.directed` — directed-graph algorithms + (``is_dag``, ``find_circuit``, etc.) +- :mod:`~graphworks.algorithms.sort` — sorting algorithms + (``topological``, etc.) +""" + +from graphworks.algorithms.directed import find_circuit, is_dag +from graphworks.algorithms.paths import ( + find_all_paths, + find_isolated_vertices, + find_path, + generate_edges, +) +from graphworks.algorithms.properties import ( + degree_sequence, + density, + diameter, + get_complement, + invert, + is_complete, + is_connected, + is_degree_sequence, + is_dense, + is_erdos_gallai, + is_regular, + is_simple, + is_sparse, + max_degree, + min_degree, + vertex_degree, +) +from graphworks.algorithms.search import ( + arrival_departure_dfs, + breadth_first_search, + depth_first_search, +) +from graphworks.algorithms.sort import topological + +__all__ = [ + # properties + "degree_sequence", + "density", + "diameter", + "get_complement", + "invert", + "is_complete", + "is_connected", + "is_dense", + "is_degree_sequence", + "is_erdos_gallai", + "is_regular", + "is_simple", + "is_sparse", + "max_degree", + "min_degree", + "vertex_degree", + # paths + "find_all_paths", + "find_isolated_vertices", + "find_path", + "generate_edges", + # search + "arrival_departure_dfs", + "breadth_first_search", + "depth_first_search", + # directed + "find_circuit", + "is_dag", + # sort + "topological", +] diff --git a/src/graphworks/algorithms/basic.py b/src/graphworks/algorithms/basic.py deleted file mode 100644 index 37009ee..0000000 --- a/src/graphworks/algorithms/basic.py +++ /dev/null @@ -1,325 +0,0 @@ -import sys -from typing import Dict -from typing import List -from typing import Set -from typing import Tuple - -from numpy import invert -from src.graphworks.graph import Graph - - -def generate_edges(graph: Graph) -> List[Tuple[str, str]]: - """ - - :param graph: input graph instance - :return: List of string tuples representing the edges in the input graph - """ - edges = [] - for node in graph: - for neighbour in graph[node]: - edges.append((node, neighbour)) - return edges - - -def find_isolated_vertices(graph: Graph) -> List[str]: - """ - - :param graph: - :return: - """ - isolated = [] - for vertex in graph: - if not graph[vertex]: - isolated += vertex - return isolated - - -def find_path(graph: Graph, start_vertex: str, end_vertex: str, path=None) -> List[str]: - """ - find a path from start_vertex to end_vertex in graph - - :param graph: input graph - :param start_vertex: where the path begins - :param end_vertex: where the path terminates - :param path: the current path - :return: list of vertices in the path - """ - if path is None: - path = [] - path = path + [start_vertex] - if start_vertex == end_vertex: - return path - if start_vertex not in graph: - return [] - for vertex in graph[start_vertex]: - if vertex not in path: - extended_path = find_path(graph, vertex, end_vertex, path) - if extended_path: - return extended_path - return [] - - -def find_all_paths(graph: Graph, start_vertex: str, end_vertex: str, path=None) -> List[str]: - """ - find all paths from start_vertex to end_vertex in graph - - :param graph: input graph - :param start_vertex: where the path begins - :param end_vertex: where the path terminates - :param path: the current path - :return: list of paths between start and end vertex - """ - if path is None: - path = [] - - path = path + [start_vertex] - if start_vertex == end_vertex: - return [path] - if start_vertex not in graph: - return [] - paths = [] - for vertex in graph[start_vertex]: - if vertex not in path: - extended_paths = find_all_paths(graph, vertex, end_vertex, path) - for ept in extended_paths: - paths.append(ept) - return paths - - -def vertex_degree(graph: Graph, vertex: str) -> int: - """ The degree of a vertex is the number of edges connecting it, - i.e. the number of adjacent vertices. Loops are counted double, - i.e. every occurrence of a vertex in the list of adjacent vertices. - - :param graph: - :param vertex: - :return: the degree of the supplied vertex - """ - adj_vertices = graph[vertex] - degree = len(adj_vertices) + adj_vertices.count(vertex) - return degree - - -def min_degree(graph: Graph) -> int: - """ - - :param graph: graph instance to analyze - :return: the minimum degree of all vertices in graph - """ - minimum = sys.maxsize - for vertex in graph: - degree = vertex_degree(graph, vertex) - if degree < minimum: - minimum = degree - return minimum - - -def max_degree(graph: Graph) -> int: - """ - - :param graph: graph instance to analyze - :return: the maximum degree of any vertex in graph - """ - maximum = 0 - for vertex in graph: - maximum_degree = vertex_degree(graph, vertex) - if maximum_degree > maximum: - maximum = maximum_degree - return maximum - - -def is_regular(graph: Graph) -> bool: - """ - A regular graph is a graph where each vertex has the same number of - neighbors; i.e. every vertex has the same degree. - :param graph: - :return: whether or not graph is regular - """ - return min_degree(graph) == max_degree(graph) - - -def check_for_cycles(graph: Graph, v: str, visited: Dict[str, bool], rec_stack: List[bool]) -> bool: - """ - - :param graph: - :param v: vertex to start from - :param visited: list of visited vertices - :param rec_stack: - :return: whether or not the graph contains a cycle - """ - visited[v] = True - rec_stack[graph.vertices().index(v)] = True - - for neighbour in graph[v]: - if not visited.get(neighbour, False): - if check_for_cycles(graph, neighbour, visited, rec_stack): - return True - elif rec_stack[graph.vertices().index(neighbour)]: - return True - - rec_stack[graph.vertices().index(v)] = False - return False - - -def is_simple(graph: Graph) -> bool: - """ - A simple graph has no cycles - :param graph: - :return: whether or not the graph is simple - """ - visited = {k: False for k in graph} - rec_stack = [False] * graph.order() - for v in graph: - if not visited[v]: - if check_for_cycles(graph, v, visited, rec_stack): - return False - return True - - -def degree_sequence(graph: Graph) -> Tuple[int]: - """ - - :param graph: - :return: Tuple of degrees of all vertices, sorted - """ - seq = [] - for vertex in graph: - seq.append(vertex_degree(graph, vertex)) - seq.sort(reverse=True) - return tuple(seq) - - -def is_degree_sequence(sequence: List[int]) -> bool: - """ - Method returns True, if the sequence is a degree sequence, i.e. a - non-increasing sequence. Otherwise False. - :param sequence: - :return: - """ - # check if the sequence sequence is non-increasing: - return all(x >= y for x, y in zip(sequence, sequence[1:])) - - -def is_erdos_gallai(dsequence: List[int]) -> bool: - """ - Checks if the condition of the Erdos-Gallai inequality is fulfilled. - :param dsequence: - :return: - """ - if sum(dsequence) % 2: - # sum of sequence is odd - return False - - if is_degree_sequence(dsequence): - for k in range(1, len(dsequence) + 1): - left = sum(dsequence[:k]) - right = k * (k - 1) + sum([min(x, k) for x in dsequence[k:]]) - if left > right: - return False - else: - # the sequence is increasing - return False - return True - - -def density(graph: Graph) -> float: - """ - The graph density is defined as the ratio of the number of edges of a given - graph, and the total number of edges, the graph could have. - A dense graph is a graph G = (V, E) in which |E| = Θ(|V|^2) - :param graph: - :return: - """ - V = len(graph.vertices()) - E = len(graph.edges()) - return 2.0 * (E / (V**2 - V)) - - -def is_connected(graph: Graph, - start_vertex: str = None, - vertices_encountered: Set[str] = None) -> bool: - """ - :param graph: - :param start_vertex: - :param vertices_encountered: - :return: whether or not the graph is connected - """ - if vertices_encountered is None: - vertices_encountered = set() - vertices = graph.vertices() - if not start_vertex: - # choose a vertex from graph as a starting point - start_vertex = vertices[0] - vertices_encountered.add(start_vertex) - if len(vertices_encountered) != len(vertices): - for vertex in graph[start_vertex]: - if vertex not in vertices_encountered: - if is_connected(graph, vertex, vertices_encountered): - return True - else: - return True - return False - - -def diameter(graph: Graph) -> int: - """ - :param graph: - :return: length of longest path in graph - """ - vee = graph.vertices() - pairs = [(vee[i], vee[j]) for i in range(len(vee) - 1) for j in range(i + 1, len(vee))] - smallest_paths = [] - for (start, end) in pairs: - paths = find_all_paths(graph, start, end) - smallest = sorted(paths, key=len)[0] - smallest_paths.append(smallest) - - smallest_paths.sort(key=len) - # longest path is at the end of list, - # i.e. diameter corresponds to the length of this path - dia = len(smallest_paths[-1]) - 1 - return dia - - -def is_sparse(graph: Graph) -> bool: - """ - Checks if |E| <= |V^2| / 2 - :param graph: - :return: - """ - return graph.size() <= (graph.order()**2 / 2) - - -def get_complement(graph: Graph) -> Graph: - """ - If graph is represented as a matrix, invert that matrix - :param graph: - :return: inversion of graph - """ - adj = graph.get_adjacency_matrix() - complement = invert(adj) - return Graph(label=f"{graph.get_label()} complement", input_array=complement) - - -def is_complete(graph: Graph) -> bool: - """ - Checks that each vertex has V(V-1) / 2 edges and that each vertex is - connected to V - 1 others. - - runtime: O(n^2) - :param graph: - :return: true or false - """ - V = len(graph.vertices()) - max_edges = (V**2 - V) - if not graph.is_directed(): - max_edges //= 2 - - E = len(graph.edges()) - if E != max_edges: - return False - - for vertex in graph: - if len(graph[vertex]) != V - 1: - return False - return True diff --git a/src/graphworks/algorithms/directed.py b/src/graphworks/algorithms/directed.py index 09503d8..ce8523d 100644 --- a/src/graphworks/algorithms/directed.py +++ b/src/graphworks/algorithms/directed.py @@ -1,24 +1,32 @@ -from typing import List, Dict +"""Directed graph utilities.""" -from src.graphworks.graph import Graph -from src.graphworks.algorithms.search import arrival_departure_dfs +from __future__ import annotations + +from typing import TYPE_CHECKING + +from graphworks.algorithms.search import arrival_departure_dfs + +if TYPE_CHECKING: + from graphworks.graph import Graph def is_dag(graph: Graph) -> bool: - """ + """Returns true if graph is a directed acyclic graph. :param graph: + :type graph: Graph :return: True/False if graph is a directed, acyclic graph + :rtype: bool """ if not graph.is_directed(): return False - departure = {v: 0 for v in graph.vertices()} - discovered = {v: False for v in graph.vertices()} + departure = dict.fromkeys(graph.vertices(), 0) + discovered = dict.fromkeys(graph.vertices(), False) time = -1 # not needed in this case - arrival = {v: 0 for v in graph.vertices()} + arrival = dict.fromkeys(graph.vertices(), 0) # visit all connected components of the graph, build departure dict for n in graph.vertices(): @@ -42,7 +50,14 @@ def is_dag(graph: Graph) -> bool: return True -def build_neighbor_matrix(graph: Graph) -> Dict[str, List[str]]: +def build_neighbor_matrix(graph: Graph) -> dict[str, list[str]]: + """Builds adjacency matrix for directed acyclic graph. + + :param graph: The graph + :type graph: Graph + :return: adjacency matrix + :rtype: dict[str, list[str]] + """ adjacency_matrix = {} for v in graph.vertices(): adjacency_matrix[v] = graph.get_neighbors(v) @@ -50,18 +65,20 @@ def build_neighbor_matrix(graph: Graph) -> Dict[str, List[str]]: return adjacency_matrix -def find_circuit(graph: Graph) -> List[str]: - """ - Using Hierholzer’s algorithm to find an eulerian circuit +def find_circuit(graph: Graph) -> list[str]: + """Using Hierholzer’s algorithm to find an eulerian circuit. + :param graph: + :type graph: Graph :return: A list of vertices in the eulerian circuit of this graph + :rtype: list[str] """ if len(graph.vertices()) == 0: return [] circuit = [] adjacency_matrix = build_neighbor_matrix(graph) - current_path: List[str] = [graph.vertices()[0]] + current_path: list[str] = [graph.vertices()[0]] while len(current_path) > 0: current_vertex = current_path[-1] if len(adjacency_matrix[current_vertex]) > 0: diff --git a/src/graphworks/algorithms/paths.py b/src/graphworks/algorithms/paths.py new file mode 100644 index 0000000..9d3c551 --- /dev/null +++ b/src/graphworks/algorithms/paths.py @@ -0,0 +1,127 @@ +"""Path-finding and edge-generation utilities. + +This module provides functions for discovering paths between vertices, +generating edge lists, and finding structurally isolated vertices. All +functions operate on the adjacency-list representation and require no +external dependencies. + +:author: Nathan Gilbert +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from graphworks.edge import Edge + from graphworks.graph import Graph + + +def generate_edges(graph: Graph) -> list[Edge]: + """Return all edges in *graph* as a list of :class:`~graphworks.edge.Edge` objects. + + This is a convenience wrapper around :meth:`~graphworks.graph.Graph.edges`. + + :param graph: The graph to enumerate edges from. + :type graph: Graph + :return: List of edges. + :rtype: list[Edge] + """ + return graph.edges() + + +def find_isolated_vertices(graph: Graph) -> list[str]: + """Return vertices that have no neighbours. + + :param graph: The graph to inspect. + :type graph: Graph + :return: List of vertex names with degree zero. + :rtype: list[str] + """ + return [v for v in graph.vertices() if not graph[v]] + + +def find_path( + graph: Graph, + start: str, + end: str, + path: list[str] | None = None, +) -> list[str]: + """Find a single path between *start* and *end* using depth-first search. + + Returns the first path found, not necessarily the shortest. Returns an + empty list if no path exists or if *start* is not in the graph. + + :param graph: The graph to search. + :type graph: Graph + :param start: Source vertex name. + :type start: str + :param end: Destination vertex name. + :type end: str + :param path: Accumulated path used by recursive calls. Callers should + leave this as ``None``. + :type path: list[str] | None + :return: Ordered list of vertex names from *start* to *end*, or ``[]`` + if no path exists. + :rtype: list[str] + """ + if path is None: + path = [] + + if start not in graph.vertices(): + return [] + + path = [*path, start] + + if start == end: + return path + + for node in graph[start]: + if node not in path: + new_path = find_path(graph, node, end, path) + if new_path: + return new_path + return [] + + +def find_all_paths( + graph: Graph, + start: str, + end: str, + path: list[str] | None = None, +) -> list[list[str]]: + """Return all simple paths between *start* and *end*. + + A simple path visits each vertex at most once. Returns an empty list + if no path exists or if *start* is not in the graph. + + :param graph: The graph to search. + :type graph: Graph + :param start: Source vertex name. + :type start: str + :param end: Destination vertex name. + :type end: str + :param path: Accumulated path used by recursive calls. Callers should + leave this as ``None``. + :type path: list[str] | None + :return: List of all simple paths, each path being an ordered list of + vertex names. + :rtype: list[list[str]] + """ + if path is None: + path = [] + + if start not in graph.vertices(): + return [] + + path = [*path, start] + + if start == end: + return [path] + + paths: list[list[str]] = [] + for node in graph[start]: + if node not in path: + new_paths = find_all_paths(graph, node, end, path) + paths.extend(new_paths) + return paths diff --git a/src/graphworks/algorithms/properties.py b/src/graphworks/algorithms/properties.py new file mode 100644 index 0000000..c7ceca5 --- /dev/null +++ b/src/graphworks/algorithms/properties.py @@ -0,0 +1,331 @@ +"""Graph property queries and structural metrics. + +This module provides predicate functions (``is_*``) and quantitative metrics +(``density``, ``diameter``, ``degree_sequence``, etc.) that inspect a +:class:`~graphworks.graph.Graph` without modifying it. + +All functions are pure: they take a graph (and optional parameters) and +return a value. None of the functions here require numpy. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from graphworks.algorithms.paths import find_all_paths # avoid circular +from graphworks.graph import Graph + +if TYPE_CHECKING: + from graphworks.types import AdjacencyMatrix + +# --------------------------------------------------------------------------- +# Degree helpers +# --------------------------------------------------------------------------- + + +def vertex_degree(graph: Graph, vertex: str) -> int: + """Return the degree of *vertex*. + + Self-loops are counted twice (each loop contributes 2 to the degree). + + :param graph: The graph to inspect. + :type graph: Graph + :param vertex: Vertex name. + :type vertex: str + :return: Degree of *vertex*. + :rtype: int + """ + adj = graph[vertex] + degree = len(adj) + # each self-loop adds an extra 1 + degree += sum(1 for v in adj if v == vertex) + return degree + + +def degree_sequence(graph: Graph) -> tuple[int, ...]: + """Return the degree sequence of the graph in non-increasing order. + + :param graph: The graph to inspect. + :type graph: Graph + :return: Sorted tuple of vertex degrees (highest first). + :rtype: tuple[int, ...] + """ + return tuple( + sorted( + (vertex_degree(graph, v) for v in graph.vertices()), + reverse=True, + ) + ) + + +def min_degree(graph: Graph) -> int: + """Return the minimum vertex degree in the graph. + + :param graph: The graph to inspect. + :type graph: Graph + :return: Minimum degree across all vertices. + :rtype: int + """ + return min(vertex_degree(graph, v) for v in graph.vertices()) + + +def max_degree(graph: Graph) -> int: + """Return the maximum vertex degree in the graph. + + :param graph: The graph to inspect. + :type graph: Graph + :return: Maximum degree across all vertices. + :rtype: int + """ + return max(vertex_degree(graph, v) for v in graph.vertices()) + + +# --------------------------------------------------------------------------- +# Sequence predicates +# --------------------------------------------------------------------------- + + +def is_degree_sequence(sequence: list[int]) -> bool: + """Return whether *sequence* is a valid degree sequence. + + A valid degree sequence has a non-negative, even sum and is + non-increasing. + + :param sequence: Candidate degree sequence. + :type sequence: list[int] + :return: ``True`` if *sequence* is a valid degree sequence. + :rtype: bool + """ + if not sequence: + return True + return sum(sequence) % 2 == 0 and sequence == sorted(sequence, reverse=True) + + +def is_erdos_gallai(sequence: list[int]) -> bool: + """Return whether *sequence* satisfies the Erdős–Gallai theorem. + + A non-increasing sequence of non-negative integers is a valid degree + sequence of a simple graph if and only if its sum is even and the + Erdős–Gallai condition holds for every prefix. + + :param sequence: Candidate degree sequence (need not be sorted). + :type sequence: list[int] + :return: ``True`` if *sequence* is graphical per Erdős–Gallai. + :rtype: bool + """ + if not sequence: + return True + + seq = sorted(sequence, reverse=True) + n = len(seq) + + if sum(seq) % 2 != 0: + return False + + for k in range(1, n + 1): + lhs = sum(seq[:k]) + rhs = k * (k - 1) + sum(min(d, k) for d in seq[k:]) + if lhs > rhs: + return False + return True + + +# --------------------------------------------------------------------------- +# Structural predicates +# --------------------------------------------------------------------------- + + +def is_regular(graph: Graph) -> bool: + """Return whether every vertex in the graph has the same degree. + + :param graph: The graph to inspect. + :type graph: Graph + :return: ``True`` if the graph is regular (all degrees equal). + :rtype: bool + """ + degrees = [vertex_degree(graph, v) for v in graph.vertices()] + return len(set(degrees)) <= 1 + + +def is_simple(graph: Graph) -> bool: + """Return whether the graph contains no self-loops. + + :param graph: The graph to inspect. + :type graph: Graph + :return: ``True`` if no vertex has an edge to itself. + :rtype: bool + """ + return all(v not in graph[v] for v in graph.vertices()) + + +def is_connected( + graph: Graph, + start_vertex: str | None = None, + vertices_encountered: set[str] | None = None, +) -> bool: + """Return whether the graph is connected. + + Uses a recursive depth-first traversal from *start_vertex*. + + :param graph: The graph to inspect. + :type graph: Graph + :param start_vertex: Vertex to begin the traversal from. Defaults to + the first vertex in :meth:`~graphworks.graph.Graph.vertices`. + :type start_vertex: str | None + :param vertices_encountered: Set of already-visited vertices used by the + recursive calls. Callers should leave this as ``None``. + :type vertices_encountered: set[str] | None + :return: ``True`` if all vertices are reachable from *start_vertex*. + :rtype: bool + """ + if vertices_encountered is None: + vertices_encountered = set() + + verts = graph.vertices() + if not start_vertex: + start_vertex = verts[0] + + vertices_encountered.add(start_vertex) + + if len(vertices_encountered) != len(verts): + for vertex in graph[start_vertex]: + if vertex not in vertices_encountered and is_connected( + graph, vertex, vertices_encountered + ): + return True + else: + return True + return False + + +def is_complete(graph: Graph) -> bool: + """Return whether the graph is complete. + + A complete graph has every possible edge. Checks that the edge count + equals ``V*(V-1)`` for directed graphs or ``V*(V-1)/2`` for undirected. + + Runtime: O(n²). + + :param graph: The graph to inspect. + :type graph: Graph + :return: ``True`` if the graph is complete. + :rtype: bool + """ + v_count = len(graph.vertices()) + max_edges = v_count**2 - v_count + if not graph.is_directed(): + max_edges //= 2 + + if len(graph.edges()) != max_edges: + return False + + return all(len(graph[v]) == v_count - 1 for v in graph) + + +def is_sparse(graph: Graph) -> bool: + """Return whether the graph is sparse (``|E| ≤ |V|² / 2``). + + :param graph: The graph to inspect. + :type graph: Graph + :return: ``True`` if the graph is sparse. + :rtype: bool + """ + return graph.size() <= (graph.order() ** 2 / 2) + + +def is_dense(graph: Graph) -> bool: + """Return whether the graph is dense (``|E| = Θ(|V|²)``). + + Computed as density ≥ 0.5. + + :param graph: The graph to inspect. + :type graph: Graph + :return: ``True`` if the graph is dense. + :rtype: bool + """ + return density(graph) >= 0.5 + + +# --------------------------------------------------------------------------- +# Metrics +# --------------------------------------------------------------------------- + + +def density(graph: Graph) -> float: + """Return the density of the graph. + + Density is ``2|E| / (|V|² - |V|)``. Returns ``0.0`` for graphs with + fewer than two vertices. + + :param graph: The graph to inspect. + :type graph: Graph + :return: Density in the range ``[0.0, 1.0]``. + :rtype: float + """ + v_count = len(graph.vertices()) + if v_count < 2: + return 0.0 + e_count = len(graph.edges()) + return 2.0 * (e_count / (v_count**2 - v_count)) + + +def diameter(graph: Graph) -> int: + """Return the diameter of the graph. + + The diameter is the length of the longest shortest path between any pair of vertices. + + :param graph: The graph to inspect. + :type graph: Graph + :return: Diameter (number of edges on the longest shortest path). + :rtype: int + """ + verts = graph.vertices() + pairs = [(verts[i], verts[j]) for i in range(len(verts) - 1) for j in range(i + 1, len(verts))] + + shortest_paths: list[list[str]] = [] + for start, end in pairs: + all_paths: list[list[str]] = find_all_paths(graph, start, end) + if all_paths: + shortest_paths.append(min(all_paths, key=lambda path: len(path))) + + if not shortest_paths: + return 0 + + return len(max(shortest_paths, key=len)) - 1 + + +# --------------------------------------------------------------------------- +# Matrix-based operations (stdlib only) +# --------------------------------------------------------------------------- + + +def invert(matrix: AdjacencyMatrix) -> AdjacencyMatrix: + """Return the complement of an adjacency matrix. + + Flips ``0`` ↔ ``1`` for every cell, including the diagonal. + + :param matrix: Square adjacency matrix. + :type matrix: AdjacencyMatrix + :return: Inverted (complement) adjacency matrix. + :rtype: AdjacencyMatrix + """ + return [[1 - cell for cell in row] for row in matrix] + + +def get_complement(graph: Graph) -> Graph: + """Return the complement graph of *graph*. + + The complement is the graph on the same vertex set whose edges are + exactly the edges *not* present in *graph*. + + :param graph: The source graph. + :type graph: Graph + :return: Complement graph. + :rtype: Graph + """ + adj = graph.get_adjacency_matrix() + complement_matrix = invert(adj) + return Graph( + label=f"{graph.get_label()} complement", + input_matrix=complement_matrix, + ) diff --git a/src/graphworks/algorithms/search.py b/src/graphworks/algorithms/search.py index 6afb8e7..6180c67 100644 --- a/src/graphworks/algorithms/search.py +++ b/src/graphworks/algorithms/search.py @@ -1,17 +1,25 @@ -from typing import List, Dict +"""This module implements DFS with arrival and departure times.""" -from src.graphworks.graph import Graph +from __future__ import annotations +from typing import TYPE_CHECKING -def breadth_first_search(graph: Graph, start: str) -> List[str]: - """ +if TYPE_CHECKING: + from graphworks.graph import Graph + + +def breadth_first_search(graph: Graph, start: str) -> list[str]: + """Breadth-first search with arrival and departure times. :param graph: + :type graph: Graph :param start: the vertex to start the traversal from - :return: + :type start: str + :return: The list of vertex paths + :rtype: list[str] """ # Mark all the vertices as not visited - visited = {k: False for k in graph.vertices()} + visited = dict.fromkeys(graph.vertices(), False) # Mark the start vertices as visited and enqueue it visited[start] = True @@ -28,12 +36,15 @@ def breadth_first_search(graph: Graph, start: str) -> List[str]: return walk -def depth_first_search(graph: Graph, start: str) -> List[str]: - """ +def depth_first_search(graph: Graph, start: str) -> list[str]: + """Depth-first search with arrival and departure times. :param graph: + :type graph: Graph :param start: the vertex to start the traversal from - :return: + :type start: str + :return: The list of vertex paths + :rtype: list[str] """ visited, stack = [], [start] while stack: @@ -44,24 +55,32 @@ def depth_first_search(graph: Graph, start: str) -> List[str]: return visited -def arrival_departure_dfs(graph: Graph, - v: str, - discovered: Dict[str, bool], - arrival: Dict[str, int], - departure: Dict[str, int], - time: int) -> int: - """ - Method for DFS with arrival and departure times for each vertex +def arrival_departure_dfs( + graph: Graph, + v: str, + discovered: dict[str, bool], + arrival: dict[str, int], + departure: dict[str, int], + time: int, +) -> int: + """Method for DFS with arrival and departure times for each vertex. O(V+E) -- E could be as big as V^2 - :param graph: - :param v: - :param discovered: - :param arrival: - :param departure: - :param time: should be initialized to -1 - :return: + :param graph: The graph + :type graph: Graph + :param v: The vertex to traverse from + :type v: str + :param discovered: The discovered vertex + :type discovered: dict[str, bool] + :param arrival: The arrival vertex + :type arrival: dict[str, int] + :param departure: The departure vertex + :type departure: dict[str, int] + :param time: initialized to -1 + :type time: int + :return: The departure time + :rtype: int """ time += 1 diff --git a/src/graphworks/algorithms/sort.py b/src/graphworks/algorithms/sort.py index d83f5a0..ba294ed 100644 --- a/src/graphworks/algorithms/sort.py +++ b/src/graphworks/algorithms/sort.py @@ -1,22 +1,46 @@ -from typing import List, Dict -from src.graphworks.graph import Graph +"""Sorting algorithms.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from graphworks.graph import Graph + + +def topological(graph: Graph) -> list[str]: + """Topological sort. -def topological(graph: Graph) -> List[str]: - """ O(V+E) + :param graph: + :type graph: Graph :return: List of vertices sorted topologically + :rtype: list[str] """ - def mark_visited(g: Graph, v: str, v_map: Dict[str, bool], t_sort_results: List[str]): + + def mark_visited(g: Graph, v: str, v_map: dict[str, bool], t_sort_results: list[str]) -> None: + """Mark visited vertex as visited. + + :param g: The graphc + :type g: Graph + :param v: Vertex + :type v: str + :param v_map: Mapping from vertex to vertex index + :type v_map: dict[str, bool] + :param t_sort_results: List of vertices sorted topologically + :type t_sort_results: list[str] + :return: Nothing + :rtype: None + """ v_map[v] = True for n in g.get_neighbors(v): if not v_map[n]: mark_visited(g, n, v_map, t_sort_results) t_sort_results.append(v) - visited = {v: False for v in graph.vertices()} - result: List[str] = [] + visited = dict.fromkeys(graph.vertices(), False) + result: list[str] = [] for v in graph.vertices(): if not visited[v]: diff --git a/src/graphworks/edge.py b/src/graphworks/edge.py index 264e8f7..f039264 100644 --- a/src/graphworks/edge.py +++ b/src/graphworks/edge.py @@ -1,18 +1,27 @@ +"""Implementation of graph edge between 2 vertices.""" + +from __future__ import annotations + from dataclasses import dataclass @dataclass class Edge: + """Implementation of graph edge between 2 vertices. + + An undirected edge is a line. A directed edge is an arc or arrow. Supports weighted (float) + edges. """ - Implementation of graph edge between 2 vertices. An undirected edge is a - line. A directed edge is an arc or arrow. Supports weighted (float) edges. - """ + vertex1: str vertex2: str directed: bool = False - weight: float = None + weight: float | None = None def has_weight(self) -> bool: - if self.weight is None: - return False - return True + """Returns ``True`` if the edge has a ``weight`` attribute. + + :return: ``True`` if the edge has a ``weight`` attribute, otherwise ``False``. + :rtype: bool + """ + return self.weight is not None diff --git a/src/graphworks/export/__init__.py b/src/graphworks/export/__init__.py index e69de29..f6801d7 100644 --- a/src/graphworks/export/__init__.py +++ b/src/graphworks/export/__init__.py @@ -0,0 +1 @@ +"""Export helpers.""" diff --git a/src/graphworks/export/graphviz_utils.py b/src/graphworks/export/graphviz_utils.py index 7aa821a..7c8fad9 100755 --- a/src/graphworks/export/graphviz_utils.py +++ b/src/graphworks/export/graphviz_utils.py @@ -1,16 +1,25 @@ +"""Graphviz utilities.""" + +from __future__ import annotations + from os import path +from typing import TYPE_CHECKING from graphviz import Graph as GraphViz -from src.graphworks.graph import Graph +if TYPE_CHECKING: + from graphworks.graph import Graph -def save_to_dot(graph: Graph, out_dir: str): - """ +def save_to_dot(graph: Graph, out_dir: str) -> None: + """Save graph to Graphviz dot file. :param graph: the graph to render in dot + :type graph: Graph :param out_dir: the absolute path of the gv file to write - :return: + :type out_dir: str + :return: Nothing + :rtype: None """ if not graph.is_directed(): dot = GraphViz(comment=graph.get_label()) diff --git a/src/graphworks/export/json_utils.py b/src/graphworks/export/json_utils.py index 09d8c91..f625ace 100644 --- a/src/graphworks/export/json_utils.py +++ b/src/graphworks/export/json_utils.py @@ -1,21 +1,30 @@ +"""JSON utilities.""" + +from __future__ import annotations + import json from os import path +from typing import TYPE_CHECKING -from src.graphworks.graph import Graph +if TYPE_CHECKING: + from graphworks.graph import Graph -def save_to_json(graph: Graph, out_dir): - """ +def save_to_json(graph: Graph, out_dir: str) -> None: + """Save to json file. :param graph: the graph to write to json + :type graph: Graph :param out_dir: the absolute path to the dir to write the file - :return: + :type out_dir: str + :return: Nothing + :rtype: None """ g_dict = { "label": graph.get_label(), "directed": graph.is_directed(), - "graph": graph.get_graph() + "graph": graph.get_graph(), } - with open(path.join(out_dir, f"{graph.get_label()}.json"), 'w', encoding="utf8") as out: + with open(path.join(out_dir, f"{graph.get_label()}.json"), "w", encoding="utf8") as out: out.write(json.dumps(g_dict)) diff --git a/src/graphworks/graph.py b/src/graphworks/graph.py old mode 100755 new mode 100644 index 83f5dcf..e25721a --- a/src/graphworks/graph.py +++ b/src/graphworks/graph.py @@ -1,101 +1,189 @@ +"""Core graph data structure for the graphworks library. + +Provides :class:`Graph`, which stores graphs internally as an adjacency list (``dict[str, +list[str]]``) and exposes a numpy-free adjacency matrix interface via +:data:`~graphworks.types.AdjacencyMatrix`. Optional numpy interop is available through +:mod:`graphworks.numpy_compat`. +""" + +from __future__ import annotations + import json -import uuid -from typing import List -from typing import DefaultDict import random +import uuid from collections import defaultdict +from typing import TYPE_CHECKING -import numpy as np -from numpy.typing import NDArray +from graphworks.edge import Edge -from src.graphworks.edge import Edge +if TYPE_CHECKING: + from collections.abc import Iterator + + from graphworks.types import AdjacencyMatrix class Graph: - """ - Implementation of both non-directional and directional graphs. - """ + """Implementation of both undirected and directed graphs. - def __init__(self, - label: str = None, - input_file: str = None, - input_graph: str = None, - input_array: NDArray = None): - """ - One of input_file, input_graph or input_array must be not None + Graphs are stored internally as an adjacency-list dictionary (``dict[str, list[str]]``). + The matrix representation is derived on demand and uses only stdlib types — no numpy required. + + A :class:`Graph` can be constructed from: - :param label: a name for this graph - :param input_file: the absolute path to a json file containing a graph - :param input_graph: a string containing json representing the graph - :param input_array: an NDArray representation of the graph to generate + * a JSON file path (``input_file``), + * a JSON string (``input_graph``), or + * a stdlib adjacency matrix (``input_matrix``). + + For numpy ``ndarray`` input, convert first with + :func:`graphworks.numpy_compat.ndarray_to_matrix`. + + Example:: + + >>> import json + >>> from graphworks.graph import Graph + ... + >>> data = {"label": "demo", "graph": {"A": ["B"], "B": []}} + >>> g = Graph(input_graph=json.dumps(data)) + >>> print(g.vertices()) # ['A', 'B'] + >>> print(g.edges()) # [Edge(vertex1='A', vertex2='B', ...)] + """ + + def __init__( + self, + label: str | None = None, + input_file: str | None = None, + input_graph: str | None = None, + input_matrix: AdjacencyMatrix | None = None, + ) -> None: + """Initialize a :class:`Graph`. + + Exactly one of *input_file*, *input_graph*, or *input_matrix* should be provided. If + none is given an empty graph is created. + + :param label: Human-readable name for this graph. + :type label: str | None + :param input_file: Absolute path to a JSON file describing the graph. + :type input_file: str | None + :param input_graph: JSON string describing the graph. + :type input_graph: str | None + :param input_matrix: Square adjacency matrix (``list[list[int]]``). Non-zero values are + treated as edges. + :type input_matrix: AdjacencyMatrix | None + :raises ValueError: If *input_matrix* is not square, or if edge endpoints in a JSON graph + reference vertices that do not exist. """ - self.__label = label if label is not None else None - self.__is_directed = False - self.__is_weighted = False - self.__graph: DefaultDict[str, List[str]] = defaultdict(list) + self.__label: str = label if label is not None else "" + self.__is_directed: bool = False + self.__is_weighted: bool = False + self.__graph: defaultdict[str, list[str]] = defaultdict(list) - # process a file, string representing the graph or a ndarray - # representation if input_file is not None: - with open(input_file, 'r', encoding="utf8") as in_file: - lines = ''.join(in_file.readlines()) - json_data = json.loads(lines) + with open(input_file, encoding="utf-8") as in_file: + json_data = json.loads(in_file.read()) self.__extract_fields_from_json(json_data) - elif input_file is None and input_graph is not None: + elif input_graph is not None: json_data = json.loads(input_graph) self.__extract_fields_from_json(json_data) - elif input_array is not None: - if not self.__validate_array(input_array): - raise ValueError("input array is malformed") - self.__array_to_graph(input_array) + elif input_matrix is not None: + if not self.__validate_matrix(input_matrix): + raise ValueError( + "input_matrix is malformed: must be a non-empty square list[list[int]]." + ) + self.__matrix_to_graph(input_matrix) if not self.__validate(): - raise ValueError("Edges don't match vertices") + raise ValueError( + "Graph is invalid: edge endpoints reference vertices that do not exist in the " + "vertex set." + ) - def vertices(self) -> List[str]: - """ :return: list of vertices' names in the graph """ + # ------------------------------------------------------------------ + # Public interface — vertices, edges, metadata + # ------------------------------------------------------------------ + + def vertices(self) -> list[str]: + """Return the list of vertex names in insertion order. + + :return: All vertex names in the graph. + :rtype: list[str] + """ return list(self.__graph.keys()) - def edges(self) -> List[Edge]: - """ :return: list of edges in the graph """ + def edges(self) -> list[Edge]: + """Return all edges in the graph. + + For undirected graphs each edge is returned once (the canonical direction is *vertex1 → + vertex2* in insertion order). + + :return: List of :class:`~graphworks.edge.Edge` objects. + :rtype: list[Edge] + """ return self.__generate_edges() - def get_graph(self) -> DefaultDict[str, List[str]]: - """ :return: dictionary representation of graph """ + def get_graph(self) -> defaultdict[str, list[str]]: + """Return the raw adjacency-list dictionary. + + :return: The underlying ``defaultdict`` mapping vertex names to their neighbor lists. + :rtype: DefaultDict[str, list[str]] + """ return self.__graph def get_label(self) -> str: - """ :return: label of graph""" + """Return the graph's label. + + :return: Human-readable label string (empty string if not set). + :rtype: str + """ return self.__label - def set_directed(self, is_directed: bool): - """ setter for making a graph instance a (non)directional graph """ + def set_directed(self, is_directed: bool) -> None: + """Set whether this graph is directed. + + :param is_directed: ``True`` for a directed graph, ``False`` for undirected. + :type is_directed: bool + :return: Nothing + :rtype: None + """ self.__is_directed = is_directed def is_directed(self) -> bool: - """ :return: whether the graph is directed """ + """Return whether this graph is directed. + + :return: ``True`` if directed, ``False`` otherwise. + :rtype: bool + """ return self.__is_directed def is_weighted(self) -> bool: - """ :return whether the graph has weights on edges """ + """Return whether this graph has weighted edges. + + :return: ``True`` if weighted, ``False`` otherwise. + :rtype: bool + """ return self.__is_weighted - def add_vertex(self, vertex: str): - """ If the vertex "vertex" is not in - self.__graph, a key "vertex" with an empty - list as a value is added to the dictionary. - Otherwise, nothing has to be done. + def add_vertex(self, vertex: str) -> None: + """Add a vertex to the graph if it does not already exist. - :parameter vertex: name of the vertex to add to graph + :param vertex: Name of the vertex to add. + :type vertex: str + :return: Nothing + :rtype: None """ if vertex not in self.__graph: self.__graph[vertex] = [] - def add_edge(self, vertex1, vertex2): - """ - Set vertex1 & vertex2 to the same node for a loop - :param vertex1: - :param vertex2: + def add_edge(self, vertex1: str, vertex2: str) -> None: + """Add a directed edge from *vertex1* to *vertex2*. + + Both vertices are created automatically if they do not exist. + + :param vertex1: Source vertex name. + :type vertex1: str + :param vertex2: Destination vertex name. + :type vertex2: str + :return: Nothing + :rtype: None """ if vertex1 in self.__graph: self.__graph[vertex1].append(vertex2) @@ -106,129 +194,177 @@ def add_edge(self, vertex1, vertex2): self.__graph[vertex2] = [] def order(self) -> int: - """:return: the order of the graph (e.g. the # of vertices)""" + """Return the order of the graph (number of vertices). + + :return: Number of vertices. + :rtype: int + """ return len(self.vertices()) def size(self) -> int: - """:return: the number of edges in the graph""" + """Return the size of the graph (number of edges). + + :return: Number of edges. + :rtype: int + """ return len(self.edges()) - def get_adjacency_matrix(self) -> NDArray: - """:return: matrix representation of the graph""" - shape = (self.order(), self.order()) - matrix = np.zeros(shape, dtype=int) - for v in self.vertices(): - i = self.vertices().index(v) - for edge in self.__graph[v]: - j = self.vertices().index(edge) - matrix[i][j] = 1 + # ------------------------------------------------------------------ + # Matrix representation (stdlib only — no numpy) + # ------------------------------------------------------------------ + + def get_adjacency_matrix(self) -> AdjacencyMatrix: + """Return a stdlib adjacency matrix for this graph. + + The matrix is always freshly computed from the current adjacency + list. Row and column indices correspond to :meth:`vertices` order. + + ``matrix[i][j] == 1`` means an edge exists from ``vertices()[i]`` + to ``vertices()[j]``; ``0`` means no edge. + + :return: Square adjacency matrix as ``list[list[int]]``. + :rtype: AdjacencyMatrix + """ + verts = self.vertices() + n = len(verts) + index = {v: i for i, v in enumerate(verts)} + matrix: AdjacencyMatrix = [[0] * n for _ in range(n)] + for v in verts: + for neighbour in self.__graph[v]: + matrix[index[v]][index[neighbour]] = 1 return matrix def vertex_to_matrix_index(self, v: str) -> int: + """Return the row/column index of vertex *v* in the adjacency matrix. + + :param v: Vertex name. + :type v: str + :return: Zero-based index into :meth:`vertices`. + :rtype: int + """ return self.vertices().index(v) def matrix_index_to_vertex(self, index: int) -> str: - return self.vertices()[index] + """Return the vertex name at row/column *index* in the adjacency matrix. - def get_neighbors(self, v: str) -> List[str]: + :param index: Zero-based matrix index. + :type index: int + :return: Vertex name. + :rtype: str """ - Get a list of a vertex's neighbors - :param v: - :return: List of vertices that have an edge with V + return self.vertices()[index] + + # ------------------------------------------------------------------ + # Neighbour access + # ------------------------------------------------------------------ + + def get_neighbors(self, v: str) -> list[str]: + """Return the neighbours of vertex *v*. + + :param v: Vertex name. + :type v: str + :return: List of vertices that *v* has an edge to. + :rtype: list[str] """ return self.__graph[v] def get_random_vertex(self) -> str: + """Return a uniformly random vertex from the graph. + + :return: A vertex name chosen at random. + :rtype: str + """ return random.choice(self.vertices()) - @staticmethod - def __validate_array(arr: NDArray) -> bool: + # ------------------------------------------------------------------ + # Dunder methods + # ------------------------------------------------------------------ + + def __repr__(self) -> str: + """Return the graph label as its canonical representation. + + :return: Graph label string. + :rtype: str """ + return self.__label - :param arr: matrix of graph to validate - :return: whether it is true that the array is a valid graph + def __str__(self) -> str: + """Return a human-readable adjacency-list view of the graph. + + :return: Multi-line string with ``vertex -> neighbours`` per line, preceded by the graph + label. + :rtype: str """ - if len(arr.shape) != 2: - return False - if arr.shape[0] != arr.shape[1]: - return False - return True + lines: list[str] = [] + for key in sorted(self.__graph.keys()): + neighbours = self.__graph[key] + rhs = "".join(neighbours) if neighbours else "0" + lines.append(f"{key} -> {rhs}") + return f"{self.__label}\n" + "\n".join(lines) + + def __iter__(self) -> Iterator[str]: + """Iterate over vertex names in insertion order. + + :return: An iterator yielding vertex name strings. + :rtype: Iterator[str] + """ + return iter(self.vertices()) - def __repr__(self): - return self.__label + def __getitem__(self, node: str) -> list[str]: + """Return the neighbor list for *node*. - def __str__(self): - """ - - :return: a string rep of the graph with name and edges - """ - final_string = '' - key_list = list(self.__graph.keys()) - key_list.sort() - for key in key_list: - final_string += str(key) + " -> " - if self.__graph[key]: - for neighbor in self.__graph[key]: - final_string += neighbor - else: - final_string += "0" - final_string += "\n" - final_string = final_string.strip() - return f"{self.__label}\n{final_string}" - - def __iter__(self): - # pylint: disable=too-few-public-methods - class GraphIterator: - """ - Iterator class for Graphs - """ - def __init__(self, graph: Graph): - self._graph = graph - self._index = 0 - - def __next__(self): - if self._index < len(self._graph.vertices()): - key = list(self._graph.vertices())[self._index] - self._index += 1 - return key - raise StopIteration - - return GraphIterator(self) - - def __getitem__(self, node): + :param node: Vertex name. + :type node: str + :return: List of neighbor vertex names, or an empty list if *node* is not in the graph. + :rtype: list[str] + """ return self.__graph.get(node, []) - def __extract_fields_from_json(self, json_data: dict): - """ + # ------------------------------------------------------------------ + # Private helpers + # ------------------------------------------------------------------ + + def __extract_fields_from_json(self, json_data: dict) -> None: + """Populate the graph from a parsed JSON dictionary. - :param json_data: raw json representation of graph - :return: + :param json_data: Parsed JSON representation of the graph. + :type json_data: dict + :return: Nothing + :rtype: None """ self.__label = json_data.get("label", "") self.__is_directed = json_data.get("directed", False) self.__is_weighted = json_data.get("weighted", False) - self.__graph = json_data.get("graph", {}) + raw_graph = json_data.get("graph", {}) + self.__graph: defaultdict[str, list[str]] = defaultdict( + list, + raw_graph, + ) - def __generate_edges(self) -> List[Edge]: - """ - Generating the edges of the graph "graph". Edges are represented as - sets with one (a loop back to the vertex) or two vertices - :return: List of Edges in the graph + def __generate_edges(self) -> list[Edge]: + """Build and return the edge list from the adjacency list. + + For undirected graphs each pair is included only once. + + :return: List of :class:`~graphworks.edge.Edge` instances. + :rtype: list[Edge] """ - edges = [] + edges: list[Edge] = [] for vertex in self.__graph: for neighbour in self.__graph[vertex]: - if not self.is_directed() and Edge(neighbour, vertex) not in edges: - edges.append(Edge(vertex, neighbour)) - elif self.is_directed(): + if ( + not self.is_directed() + and Edge(neighbour, vertex) not in edges + or self.is_directed() + ): edges.append(Edge(vertex, neighbour)) return edges def __validate(self) -> bool: - """ - Test to make sure that all edge endpoints are contained in the vertex - list. - :return: True if the vertex list matches all the edge endpoints + """Verify that all edge endpoints reference existing vertices. + + :return: ``True`` if the graph is internally consistent. + :rtype: bool """ for vertex in self.__graph: for neighbor in self.__graph[vertex]: @@ -236,17 +372,36 @@ def __validate(self) -> bool: return False return True - def __array_to_graph(self, arr: NDArray): + @staticmethod + def __validate_matrix(matrix: AdjacencyMatrix) -> bool: + """Return whether *matrix* is a non-empty square 2-D list. + + :param matrix: Candidate adjacency matrix. + :type matrix: AdjacencyMatrix + :return: ``True`` if *matrix* is valid. + :rtype: bool """ - Converts an ndarray representation of a graph to a dictionary - representation. - :param arr: matrix graph - :return: + if not matrix: + return False + n = len(matrix) + return all(len(row) == n for row in matrix) + + def __matrix_to_graph(self, matrix: AdjacencyMatrix) -> None: + """Populate the adjacency list from a stdlib adjacency matrix. + + Vertex names are generated as UUID strings to guarantee uniqueness. + + :param matrix: Square adjacency matrix where non-zero values denote edges. + :type matrix: AdjacencyMatrix + :return: Nothing + :rtype: None """ - names = [str(uuid.uuid4()) for _ in range(arr.shape[0])] - for r_idx in range(arr.shape[0]): + n = len(matrix) + names = [str(uuid.uuid4()) for _ in range(n)] + for r_idx in range(n): vertex = names[r_idx] - for idx, val in enumerate(arr[r_idx]): + # Ensure the vertex exists even when its entire row is zeros. + self.__graph.setdefault(vertex, []) + for c_idx, val in enumerate(matrix[r_idx]): if val > 0: - edge = names[idx] - self.__graph[vertex].append(edge) + self.__graph[vertex].append(names[c_idx]) diff --git a/src/graphworks/numpy_compat.py b/src/graphworks/numpy_compat.py new file mode 100644 index 0000000..9295036 --- /dev/null +++ b/src/graphworks/numpy_compat.py @@ -0,0 +1,64 @@ +"""Optional numpy interop for graphworks. + +This module is **only available** when the ``[matrix]`` extra is installed:: + + pip install graphworks[matrix] + +It provides thin conversion helpers between :data:`~graphworks.types.AdjacencyMatrix` +(``list[list[int]]``) and ``numpy.ndarray`` so callers who already have numpy +arrays can pass them directly to :class:`~graphworks.graph.Graph`. + +Import pattern — always guard with :data:`TYPE_CHECKING` or a try/except so that +code using graphworks does not *require* numpy:: + + >>> try: + ... from graphworks.numpy_compat import ndarray_to_matrix, matrix_to_ndarray + >>> except ImportError: + ... pass # numpy not installed; matrix I/O unavailable + +:author: Nathan Gilbert +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from numpy.typing import NDArray + + from graphworks.types import AdjacencyMatrix + +try: + import numpy as np +except ImportError as exc: # pragma: no cover + raise ImportError( + "numpy is required for numpy interop. " "Install it with: pip install graphworks[matrix]" + ) from exc + + +def ndarray_to_matrix(arr: NDArray) -> AdjacencyMatrix: + """Convert numpy ndarray adjacency representation to :data:`~graphworks.types.AdjacencyMatrix`. + + Only integer-valued arrays are supported. Values greater than zero are treated as edges ( + coerced to ``1``); zero values mean no edge. + + :param arr: A square 2-D numpy array representing an adjacency matrix. + :type arr: numpy.typing.NDArray + :raises ValueError: If *arr* is not a 2-D square array. + :return: A pure-Python adjacency matrix. + :rtype: AdjacencyMatrix + """ + if arr.ndim != 2 or arr.shape[0] != arr.shape[1]: + raise ValueError(f"Expected a square 2-D array, got shape {arr.shape}") + return [[1 if int(val) > 0 else 0 for val in row] for row in arr] + + +def matrix_to_ndarray(matrix: AdjacencyMatrix) -> NDArray: + """Convert an :data:`~graphworks.types.AdjacencyMatrix` to a numpy ndarray. + + :param matrix: A square pure-Python adjacency matrix. + :type matrix: AdjacencyMatrix + :return: A 2-D numpy integer array with dtype ``numpy.int_``. + :rtype: numpy.typing.NDArray + """ + return np.array(matrix, dtype=np.int_) diff --git a/src/graphworks/types.py b/src/graphworks/types.py new file mode 100644 index 0000000..feeba1d --- /dev/null +++ b/src/graphworks/types.py @@ -0,0 +1,17 @@ +"""Shared type aliases used throughout the graphworks library. + +These are intentionally stdlib-only. numpy interop lives in +:mod:`graphworks.numpy_compat` and is gated behind the ``[matrix]`` extra. +""" + +from __future__ import annotations + +# A square 2-D adjacency matrix represented with pure Python lists. +# ``AdjacencyMatrix[i][j] == 1`` means an edge exists from vertex *i* to +# vertex *j*; ``0`` means no edge. +# +# Example (2-vertex graph with one directed edge 0 → 1):: +# +# [[0, 1], +# [0, 0]] +AdjacencyMatrix = list[list[int]] diff --git a/tests/__init__.py b/tests/__init__.py index e69de29..fe3fb26 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1,14 @@ +"""Graphworks test suite. + +Run with:: + + uv run pytest + # or, without uv: + python -m pytest + +The package must be installed (editable or otherwise) before running:: + + uv sync + # or: + pip install -e .[dev] +""" diff --git a/tests/basic_tests.py b/tests/basic_tests.py deleted file mode 100644 index 02657e9..0000000 --- a/tests/basic_tests.py +++ /dev/null @@ -1,221 +0,0 @@ -import json -import unittest - -from src.graphworks.algorithms.basic import degree_sequence -from src.graphworks.algorithms.basic import density -from src.graphworks.algorithms.basic import diameter -from src.graphworks.algorithms.basic import find_all_paths -from src.graphworks.algorithms.basic import find_isolated_vertices -from src.graphworks.algorithms.basic import find_path -from src.graphworks.algorithms.basic import generate_edges -from src.graphworks.algorithms.basic import is_connected -from src.graphworks.algorithms.basic import is_degree_sequence -from src.graphworks.algorithms.basic import is_erdos_gallai -from src.graphworks.algorithms.basic import is_regular -from src.graphworks.algorithms.basic import is_simple -from src.graphworks.algorithms.basic import is_sparse -from src.graphworks.algorithms.basic import max_degree -from src.graphworks.algorithms.basic import min_degree -from src.graphworks.algorithms.basic import vertex_degree -from src.graphworks.algorithms.basic import get_complement -from src.graphworks.algorithms.basic import is_complete -from src.graphworks.graph import Graph - - -class BasicTests(unittest.TestCase): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.connected_graph = { - "graph": {"a": ["d", "f"], - "b": ["c", "b"], - "c": ["b", "c", "d", "e"], - "d": ["a", "c"], - "e": ["c"], - "f": ["a"]} - } - self.complete_graph = { - "graph": {"a": ["b", "c"], - "b": ["a", "c"], - "c": ["a", "b"]} - } - self.complete_digraph = { - "directed": True, - "graph": { - "a": ["b"], - "b": ["a"] - } - } - self.isolated_graph = {"graph": {"a": [], "b": [], "c": []}} - self.big_graph = {"graph": { - "a": ["c"], - "b": ["c", "e", "f"], - "c": ["a", "b", "d", "e"], - "d": ["c"], - "e": ["b", "c", "f"], - "f": ["b", "e"] - }} - self.one_regular_graph = {"graph": { - "a": [], - "b": [], - "c": [] - }} - self.lollipop_graph = {"graph": { - "z": ["a"], - "a": ["b"], - "b": ["c"], - "c": ["d"], - "d": ["b"] - }} - self.straight_line = {"graph": { - "a": ["b"], - "b": ["c"], - "c": ["d"], - "d": [] - }} - - def test_generate_edges(self): - json_graph = {"name": "", "graph": {"A": ["B"], "B": []}} - graph = Graph(input_graph=json.dumps(json_graph)) - edges = generate_edges(graph) - self.assertEqual(len(edges), 1) - - def test_find_isolated_nodes(self): - json_graph = {"name": "", "graph": {"A": ["B"], "B": ["A"], "C": []}} - graph = Graph(input_graph=json.dumps(json_graph)) - isolated = find_isolated_vertices(graph) - self.assertEqual(len(isolated), 1) - self.assertEqual(isolated[0], 'C') - - def test_find_path(self): - json_graph = {"label": "test", - "directed": False, - "graph": - {"a": ["d"], - "b": ["c"], - "c": ["b", "c", "d", "e"], - "d": ["a", "c"], - "e": ["c"], - "f": []} - } - graph = Graph(input_graph=json.dumps(json_graph)) - path = find_path(graph, "a", "b") - self.assertListEqual(['a', 'd', 'c', 'b'], path) - path = find_path(graph, "a", "f") - self.assertListEqual([], path) - path = find_path(graph, "c", "c") - self.assertListEqual(['c'], path) - path = find_path(graph, "z", "a") - self.assertListEqual([], path) - - def test_find_all_paths(self): - json_graph = {"label": "test2", - "directed": False, - "graph": - {"a": ["d", "f"], - "b": ["c"], - "c": ["b", "c", "d", "e"], - "d": ["a", "c"], - "e": ["c"], - "f": ["d"]} - } - graph = Graph(input_graph=json.dumps(json_graph)) - paths = find_all_paths(graph, "a", "b") - self.assertListEqual([['a', 'd', 'c', 'b'], ['a', 'f', 'd', 'c', 'b']], paths) - paths = find_all_paths(graph, "z", "b") - self.assertListEqual([], paths) - - def test_vertex_degree(self): - json_graph = {"graph": {"a": ["a", "b", "c"], "b": ["a"], "c": ["a"]}} - graph = Graph(input_graph=json.dumps(json_graph)) - deg = vertex_degree(graph, 'a') - self.assertEqual(4, deg) - - def test_graph_min_degree(self): - json_graph = {"graph": {"a": ["a", "b", "c"], "b": ["a"], "c": ["a"]}} - graph = Graph(input_graph=json.dumps(json_graph)) - min_deg = min_degree(graph) - self.assertEqual(1, min_deg) - - def test_graph_max_degree(self): - json_graph = {"graph": {"a": ["a", "b", "c"], "b": ["a"], "c": ["a"]}} - graph = Graph(input_graph=json.dumps(json_graph)) - max_deg = max_degree(graph) - self.assertEqual(4, max_deg) - - def test_degree_sequence(self): - json_graph = {"graph": {"a": ["a", "b", "c"], "b": ["a"], "c": ["a"]}} - graph = Graph(input_graph=json.dumps(json_graph)) - dseq = degree_sequence(graph) - self.assertEqual((4, 1, 1), dseq) - - def test_is_degree_sequence(self): - self.assertEqual(False, is_degree_sequence([1, 2, 3])) - self.assertEqual(True, is_degree_sequence([3, 1, 1])) - self.assertEqual(True, is_degree_sequence([])) - - def test_is_erdos_gallois(self): - self.assertEqual(True, is_erdos_gallai([])) - self.assertEqual(False, is_erdos_gallai([1])) - self.assertEqual(False, is_erdos_gallai([2, 2, 4])) - self.assertEqual(False, is_erdos_gallai([32, 8, 4, 2, 2])) - # a real graphic sequence - self.assertEqual(True, is_erdos_gallai([6, 6, 6, 6, 5, 5, 2, 2])) - - def test_density(self): - graph = Graph(input_graph=json.dumps(self.connected_graph)) - self.assertAlmostEqual(0.4666666666666667, density(graph)) - - graph = Graph(input_graph=json.dumps(self.complete_graph)) - self.assertEqual(1.0, density(graph)) - - graph = Graph(input_graph=json.dumps(self.isolated_graph)) - self.assertEqual(0.0, density(graph)) - - def test_is_connected(self): - graph = Graph(input_graph=json.dumps(self.connected_graph)) - self.assertTrue(is_connected(graph)) - - def test_diameter(self): - graph = Graph(input_graph=json.dumps(self.big_graph)) - self.assertEqual(3, diameter(graph)) - - def test_is_regular_graph(self): - graph = Graph(input_graph=json.dumps(self.big_graph)) - self.assertFalse(is_regular(graph)) - one_reg_graph = Graph(input_graph=json.dumps(self.one_regular_graph)) - self.assertTrue(is_regular(one_reg_graph)) - - def test_is_simple(self): - graph = Graph(input_graph=json.dumps(self.straight_line)) - self.assertTrue(is_simple(graph)) - lolli = Graph(input_graph=json.dumps(self.lollipop_graph)) - self.assertFalse(is_simple(lolli)) - - def test_is_sparse(self): - graph = Graph(input_graph=json.dumps(self.isolated_graph)) - self.assertTrue(is_sparse(graph)) - - def test_is_complete(self): - graph = Graph(input_graph=json.dumps(self.complete_graph)) - self.assertTrue(is_complete(graph)) - graph = Graph(input_graph=json.dumps(self.isolated_graph)) - self.assertFalse(is_complete(graph)) - json_graph = {"name": "", "graph": {"A": ["B"], "B": []}} - graph = Graph(input_graph=json.dumps(json_graph)) - self.assertFalse(is_complete(graph)) - - def test_complete_digraph(self): - graph = Graph(input_graph=json.dumps(self.complete_digraph)) - self.assertTrue(is_complete(graph)) - json_graph = {"name": "", "directed": True, "graph": {"A": ["B"], "B": []}} - graph = Graph(input_graph=json.dumps(json_graph)) - self.assertFalse(is_complete(graph)) - - def test_complement(self): - graph = Graph(input_graph=json.dumps(self.isolated_graph)) - complement = get_complement(graph) - self.assertTrue(is_complete(complement)) - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/directed_tests.py b/tests/directed_tests.py deleted file mode 100644 index 73895be..0000000 --- a/tests/directed_tests.py +++ /dev/null @@ -1,46 +0,0 @@ -import json -import unittest -from src.graphworks.algorithms.directed import is_dag, find_circuit -from src.graphworks.graph import Graph - - -class DirectedTests(unittest.TestCase): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - def test_is_dag(self): - cycle_graph = { - "directed": True, - "graph": { - "A": ["B"], - "B": ["C", "D"], - "C": [], - "D": ["E", "A"], # cycle A -> B -> D -> A - "E": [] - } - } - graph = Graph(input_graph=json.dumps(cycle_graph)) - self.assertFalse(is_dag(graph)) - dag = cycle_graph - # remove the cycle - dag["graph"]["D"] = ["E"] - graph2 = Graph(input_graph=json.dumps(dag)) - self.assertTrue(is_dag(graph2)) - - def test_find_circuit(self): - circuit_graph = { - "directed": True, - "graph": { - "A": ["B"], - "B": ["C"], - "C": ["A"] # circuit A -> C -> B -> A - } - } - graph = Graph(input_graph=json.dumps(circuit_graph)) - circuit = find_circuit(graph) - expected_circuit = ["A", "C", "B", "A"] - self.assertListEqual(circuit, expected_circuit) - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/edge_tests.py b/tests/edge_tests.py deleted file mode 100644 index a0fdb30..0000000 --- a/tests/edge_tests.py +++ /dev/null @@ -1,12 +0,0 @@ -import unittest - -from src.graphworks.graph import Edge - - -class EdgeTests(unittest.TestCase): - - def test_has_weight(self): - e = Edge('a', 'b', False) - self.assertFalse(e.has_weight()) - f = Edge('a', 'b', True, 50.0) - self.assertTrue(f.has_weight()) diff --git a/tests/export_tests.py b/tests/export_tests.py deleted file mode 100644 index 7eaf25b..0000000 --- a/tests/export_tests.py +++ /dev/null @@ -1,52 +0,0 @@ -import json -import shutil -import tempfile -import unittest -from os import path - -from src.graphworks.export.graphviz_utils import save_to_dot -from src.graphworks.export.json_utils import save_to_json -from src.graphworks.graph import Graph - - -class ExportTests(unittest.TestCase): - def setUp(self): - # Create a temporary directory - self.test_dir = tempfile.mkdtemp() - - def tearDown(self): - # Remove the directory after the test - shutil.rmtree(self.test_dir) - - def test_save_to_json(self): - answer = "{\"label\": \"my graph\", \"directed\": false," \ - " \"graph\": {\"A\": [\"B\"], \"B\": []}}" - json_graph = {"label": "my graph", "graph": {"A": ["B"], "B": []}} - graph = Graph(input_graph=json.dumps(json_graph)) - save_to_json(graph, self.test_dir) - - outfile = path.join(self.test_dir, graph.get_label() + ".json") - with open(outfile) as dot_file: - dot_lines = "".join(dot_file.readlines()) - self.assertEqual(dot_lines, answer) - - def test_save_to_graphviz(self): - answer = """// my graph -graph { - A [label=A] - A -- B - B [label=B] -} -""" - json_graph = {"label": "my graph", "graph": {"A": ["B"], "B": []}} - graph = Graph(input_graph=json.dumps(json_graph)) - save_to_dot(graph, self.test_dir) - - outfile = path.join(self.test_dir, graph.get_label() + ".gv") - with open(outfile) as dot_file: - dot_lines = "".join(dot_file.readlines()) - self.assertEqual(dot_lines, answer) - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/graph_iterator_tests.py b/tests/graph_iterator_tests.py deleted file mode 100644 index 6234082..0000000 --- a/tests/graph_iterator_tests.py +++ /dev/null @@ -1,28 +0,0 @@ -import json -import unittest - -from src.graphworks.graph import Graph - - -class GraphIteratorTests(unittest.TestCase): - def test_iterator(self): - json_graph = {"name": "my graph", - "graph": {"A": ["B", "C", "D"], - "B": [], - "C": [], - "D": []} - } - graph = Graph(input_graph=json.dumps(json_graph)) - - iterations = 0 - for key in graph: - iterations += 1 - if key == "A": - self.assertEqual(len(graph[key]), 3) - else: - self.assertEqual(len(graph[key]), 0) - self.assertEqual(iterations, 4) - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/graph_tests.py b/tests/graph_tests.py deleted file mode 100644 index b563725..0000000 --- a/tests/graph_tests.py +++ /dev/null @@ -1,109 +0,0 @@ -import json -import shutil -import tempfile -import unittest -from os import path - -import numpy as np - -from src.graphworks.graph import Graph -from src.graphworks.graph import Edge - - -class GraphTests(unittest.TestCase): - def setUp(self): - # Create a temporary directory - self.test_dir = tempfile.mkdtemp() - - def tearDown(self): - # Remove the directory after the test - shutil.rmtree(self.test_dir) - - def test_name(self): - graph = Graph("graph") - self.assertEqual('graph', graph.get_label()) - - def test_repr(self): - graph = Graph("graph") - self.assertEqual('graph', repr(graph)) - - def test_str(self): - answer = """my graph -A -> B -B -> 0""" - json_graph = {"label": "my graph", "graph": {"A": ["B"], "B": []}} - graph = Graph(input_graph=json.dumps(json_graph)) - self.assertEqual(answer, str(graph)) - - def test_edges(self): - json_graph = {"label": "my graph", "graph": {"A": ["B"], "B": []}} - graph = Graph(input_graph=json.dumps(json_graph)) - self.assertEqual(json_graph['label'], graph.get_label()) - self.assertEqual(False, graph.is_directed()) - self.assertEqual(json_graph['graph'], graph.get_graph()) - self.assertEqual([Edge('A', 'B')], graph.edges()) - - def test_add_vertex(self): - graph = Graph("my graph") - graph.add_vertex("A") - self.assertEqual(['A'], graph.vertices()) - - def test_add_edge(self): - graph = Graph("my graph") - graph.add_vertex("A") - graph.add_vertex("B") - graph.add_edge("A", "B") - self.assertEqual(1, len(graph.edges())) - graph.add_edge("X", "Y") - self.assertEqual(2, len(graph.edges())) - self.assertEqual(4, len(graph.vertices())) - - def test_read_graph_from_file(self): - json_graph = {"label": "my graph", "graph": {"A": ["B"], "B": []}} - with open(path.join(self.test_dir, 'test.txt'), 'w') as out_file: - out_file.write(json.dumps(json_graph)) - graph = Graph(input_file=str(path.join(self.test_dir, 'test.txt'))) - self.assertEqual(json_graph["label"], graph.get_label()) - self.assertEqual(False, graph.is_directed()) - self.assertEqual(json_graph["graph"], graph.get_graph()) - - def test_order_and_size(self): - json_graph = {"graph": {"A": ["B"], "B": []}} - graph = Graph(input_graph=json.dumps(json_graph)) - self.assertEqual(2, graph.order()) - self.assertEqual(1, graph.size()) - - def test_get_adjacency_matrix(self): - json_graph = {"graph": {"A": ["B"], "B": []}} - graph = Graph(input_graph=json.dumps(json_graph)) - matrix = graph.get_adjacency_matrix() - answer = np.array([[0, 1], [0, 0]]) - np.testing.assert_equal(matrix, answer) - self.assertEqual(answer.size, matrix.size) - - def test_set_from_adjacency_matrix(self): - array_graph = np.array([[0, 1], [1, 0]], dtype=object) - graph = Graph(input_array=array_graph) - self.assertEqual(2, len(graph.vertices())) - self.assertEqual(1, len(graph.edges())) - - def test_malformed_array(self): - array_graph = np.array([[0, 1, 0, 0, 0], [1, 0]], dtype=object) - self.assertRaises(ValueError, Graph, input_array=array_graph) - array_graph = np.array([[0, 1], [1, 0], [1, 0]]) - self.assertRaises(ValueError, Graph, input_array=array_graph) - - def test_malformed_json(self): - json_graph = {"graph": {"A": ["B", "C", "D"], "B": []}} - self.assertRaises(ValueError, Graph, input_graph=json.dumps(json_graph)) - - def test_get_neighbors(self): - json_graph = {"label": "my graph", "graph": {"A": ["B"], "B": []}} - graph = Graph(input_graph=json.dumps(json_graph)) - - self.assertEqual(graph.get_neighbors("A"), ["B"]) - self.assertEqual(graph.get_neighbors("B"), []) - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/search_tests.py b/tests/search_tests.py deleted file mode 100644 index 7766680..0000000 --- a/tests/search_tests.py +++ /dev/null @@ -1,63 +0,0 @@ -import json -import unittest - -from src.graphworks.algorithms.search import breadth_first_search -from src.graphworks.algorithms.search import depth_first_search -from src.graphworks.algorithms.search import arrival_departure_dfs -from src.graphworks.graph import Graph - - -class SearchTests(unittest.TestCase): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.simple_graph = {"graph": { - "a": ["b", "c"], - "b": ["c"], - "c": ["a", "d"], - "d": ["d"] - }} - - def test_breadth_first_search(self): - graph = Graph(input_graph=json.dumps(self.simple_graph)) - walk = breadth_first_search(graph, "c") - self.assertListEqual(["c", "a", "d", "b"], walk) - - def test_depth_first_search(self): - graph = Graph(input_graph=json.dumps(self.simple_graph)) - walk = depth_first_search(graph, "c") - self.assertListEqual(["c", "d", "a", "b"], walk) - - def test_arrival_departure_dfs(self): - disjoint_graph = {"graph": { - "a": ["b", "c"], - "b": [], - "c": ["d", "e"], - "d": ["b", "f"], - "e": ["f"], - "f": [], - "g": ["h"], - "h": [] - }, "directed": True} - - graph = Graph(input_graph=json.dumps(disjoint_graph)) - - # list to store the arrival time of vertex - arrival = {v: 0 for v in graph.vertices()} - # list to store the departure time of vertex - departure = {v: 0 for v in graph.vertices()} - # mark all the vertices as not discovered - discovered = {v: False for v in graph.vertices()} - time = -1 - - for v in graph.vertices(): - if not discovered[v]: - time = arrival_departure_dfs(graph, v, discovered, arrival, departure, time) - - # pair up the arrival and departure times and ensure correct ordering - result = list(zip(arrival.values(), departure.values())) - expected_times = [(0, 11), (1, 2), (3, 10), (4, 7), (8, 9), (5, 6), (12, 15), (13, 14)] - self.assertListEqual(expected_times, result) - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/sort_tests.py b/tests/sort_tests.py deleted file mode 100644 index 90b633c..0000000 --- a/tests/sort_tests.py +++ /dev/null @@ -1,32 +0,0 @@ -import json -import unittest - -from src.graphworks.algorithms.sort import topological -from src.graphworks.graph import Graph - - -class SortTests(unittest.TestCase): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - def test_topological_sort(self): - t_sort_graph = { - "directed": True, - "graph": { - "A": [], - "B": [], - "C": ["D"], - "D": ["B"], - "E": ["A", "B"], - "F": ["A", "C"] - } - } - graph = Graph(input_graph=json.dumps(t_sort_graph)) - - expected_results = ["F", "E", "C", "D", "B", "A"] - results = topological(graph) - self.assertListEqual(expected_results, results) - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/test_directed.py b/tests/test_directed.py new file mode 100644 index 0000000..937dc36 --- /dev/null +++ b/tests/test_directed.py @@ -0,0 +1,73 @@ +""" +tests.test_directed +~~~~~~~~~~~~~~~~~~~ + +Unit tests for :mod:`graphworks.algorithms.directed`. + +Covers is_dag and find_circuit (Hierholzer's algorithm). + +:author: Nathan Gilbert +""" + +from __future__ import annotations + +import json + +from graphworks.algorithms.directed import find_circuit, is_dag +from graphworks.graph import Graph + + +class TestIsDag: + """Tests for is_dag.""" + + def test_dag_returns_true(self, directed_dag) -> None: + """A directed acyclic graph returns True.""" + assert is_dag(directed_dag) + + def test_cyclic_graph_returns_false(self, directed_cycle_graph) -> None: + """A directed graph with a back-edge returns False.""" + assert not is_dag(directed_cycle_graph) + + def test_undirected_graph_returns_false(self, big_graph) -> None: + """is_dag returns False for undirected graphs.""" + assert not is_dag(big_graph) + + def test_removing_cycle_makes_dag(self, directed_cycle_json) -> None: + """Removing the back-edge from a cyclic graph makes it a DAG.""" + directed_cycle_json["graph"]["D"] = ["E"] # break A→B→D→A + graph = Graph(input_graph=json.dumps(directed_cycle_json)) + assert is_dag(graph) + + def test_simple_linear_dag(self) -> None: + """A simple A→B→C chain is a DAG.""" + data = {"directed": True, "graph": {"A": ["B"], "B": ["C"], "C": []}} + graph = Graph(input_graph=json.dumps(data)) + assert is_dag(graph) + + +class TestFindCircuit: + """Tests for find_circuit (Hierholzer's algorithm).""" + + def test_simple_circuit(self, circuit_graph) -> None: + """Eulerian circuit A→B→C→A is found correctly.""" + circuit = find_circuit(circuit_graph) + # Hierholzer may return any valid rotation; check structure + assert len(circuit) == 4 + assert circuit[0] == circuit[-1] # circuit forms a closed loop + + def test_circuit_visits_all_vertices(self, circuit_graph) -> None: + """Every vertex appears in the circuit.""" + circuit = find_circuit(circuit_graph) + assert set(circuit) == {"A", "B", "C"} + + def test_empty_graph_returns_empty(self) -> None: + """find_circuit on an empty graph returns an empty list.""" + graph = Graph("empty") + assert find_circuit(graph) == [] + + def test_specific_circuit_order(self, circuit_json) -> None: + """The exact Hierholzer circuit for A→B→C→A matches expected order.""" + graph = Graph(input_graph=json.dumps(circuit_json)) + circuit = find_circuit(graph) + expected = ["A", "C", "B", "A"] + assert circuit == expected diff --git a/tests/test_edge.py b/tests/test_edge.py new file mode 100644 index 0000000..ede7b29 --- /dev/null +++ b/tests/test_edge.py @@ -0,0 +1,79 @@ +""" +tests.test_edge +~~~~~~~~~~~~~~~ + +Unit tests for :class:`graphworks.edge.Edge`. + +:author: Nathan Gilbert +""" + +from __future__ import annotations + +import pytest + +from graphworks.edge import Edge + + +class TestEdgeConstruction: + """Tests for Edge construction and default values.""" + + def test_basic_construction(self) -> None: + """An Edge stores vertex1 and vertex2 correctly.""" + e = Edge("a", "b") + assert e.vertex1 == "a" + assert e.vertex2 == "b" + + def test_directed_defaults_to_false(self) -> None: + """The directed flag defaults to False.""" + e = Edge("a", "b") + assert not e.directed + + def test_weight_defaults_to_none(self) -> None: + """The weight defaults to None.""" + e = Edge("a", "b") + assert e.weight is None + + def test_explicit_directed(self) -> None: + """The directed flag can be set to True explicitly.""" + e = Edge("a", "b", True) + assert e.directed + + def test_explicit_weight(self) -> None: + """A numeric weight is stored and accessible.""" + e = Edge("a", "b", False, 42.5) + assert e.weight == pytest.approx(42.5) + + +class TestEdgeHasWeight: + """Tests for the has_weight predicate.""" + + def test_no_weight_returns_false(self) -> None: + """has_weight() is False when weight is None.""" + e = Edge("a", "b", False) + assert not e.has_weight() + + def test_with_weight_returns_true(self) -> None: + """has_weight() is True when a weight is set.""" + e = Edge("a", "b", True, 50.0) + assert e.has_weight() + + def test_zero_weight_returns_true(self) -> None: + """A weight of 0.0 is still considered 'has weight'.""" + e = Edge("a", "b", False, 0.0) + assert e.has_weight() + + +class TestEdgeEquality: + """Tests for Edge dataclass equality semantics.""" + + def test_equal_edges(self) -> None: + """Two Edge instances with the same fields are equal.""" + assert Edge("A", "B") == Edge("A", "B") + + def test_direction_matters(self) -> None: + """Edge("A","B") != Edge("B","A") due to vertex ordering.""" + assert Edge("A", "B") != Edge("B", "A") + + def test_weight_affects_equality(self) -> None: + """Edges with different weights are not equal.""" + assert Edge("a", "b", False, 1.0) != Edge("a", "b", False, 2.0) diff --git a/tests/test_export.py b/tests/test_export.py new file mode 100644 index 0000000..cfb2572 --- /dev/null +++ b/tests/test_export.py @@ -0,0 +1,106 @@ +""" +tests.test_export +~~~~~~~~~~~~~~~~~ + +Unit and integration tests for :mod:`graphworks.export`. + +Covers save_to_json and save_to_dot (Graphviz .gv output). + +:author: Nathan Gilbert +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pathlib import Path + +import pytest + +from graphworks.export.graphviz_utils import save_to_dot +from graphworks.export.json_utils import save_to_json +from graphworks.graph import Graph + + +class TestSaveToJson: + """Tests for save_to_json.""" + + def test_output_file_is_created(self, simple_edge_graph, tmp_dir: Path) -> None: + """save_to_json creates a .json file in the output directory.""" + save_to_json(simple_edge_graph, str(tmp_dir)) + out = tmp_dir / f"{simple_edge_graph.get_label()}.json" + assert out.exists() + + def test_output_content_is_valid_json(self, simple_edge_graph, tmp_dir: Path) -> None: + """The written file is valid JSON.""" + save_to_json(simple_edge_graph, str(tmp_dir)) + out = tmp_dir / f"{simple_edge_graph.get_label()}.json" + data = json.loads(out.read_text(encoding="utf-8")) + assert isinstance(data, dict) + + def test_output_contains_label(self, simple_edge_graph, tmp_dir: Path) -> None: + """Serialised JSON includes the graph label.""" + save_to_json(simple_edge_graph, str(tmp_dir)) + out = tmp_dir / f"{simple_edge_graph.get_label()}.json" + data = json.loads(out.read_text(encoding="utf-8")) + assert data["label"] == "my graph" + + def test_output_contains_directed_flag(self, simple_edge_graph, tmp_dir: Path) -> None: + """Serialised JSON includes the directed flag.""" + save_to_json(simple_edge_graph, str(tmp_dir)) + out = tmp_dir / f"{simple_edge_graph.get_label()}.json" + data = json.loads(out.read_text(encoding="utf-8")) + assert "directed" in data + assert data["directed"] is False + + def test_output_matches_expected_string(self, simple_edge_graph, tmp_dir: Path) -> None: + """Serialised output exactly matches expected JSON string.""" + expected = '{"label": "my graph", "directed": false,' ' "graph": {"A": ["B"], "B": []}}' + save_to_json(simple_edge_graph, str(tmp_dir)) + out = tmp_dir / f"{simple_edge_graph.get_label()}.json" + assert out.read_text(encoding="utf-8") == expected + + def test_directed_graph_serialised_correctly(self, tmp_dir: Path) -> None: + """A directed graph serialises with directed=true.""" + data = {"directed": True, "label": "d", "graph": {"A": ["B"], "B": []}} + graph = Graph(input_graph=json.dumps(data)) + save_to_json(graph, str(tmp_dir)) + out = tmp_dir / "d.json" + result = json.loads(out.read_text(encoding="utf-8")) + assert result["directed"] is True + + +class TestSaveToDot: + """Tests for save_to_dot (Graphviz export).""" + + @pytest.fixture(autouse=True) + def _skip_if_no_graphviz(self) -> None: + """Skip the test class if the graphviz package is not installed.""" + pytest.importorskip("graphviz") + + def test_dot_file_is_created(self, simple_edge_graph, tmp_dir: Path) -> None: + """save_to_dot creates a .gv file in the output directory.""" + + save_to_dot(simple_edge_graph, str(tmp_dir)) + # graphviz appends .gv to the path we pass + out = tmp_dir / f"{simple_edge_graph.get_label()}.gv" + assert out.exists() + + def test_dot_content_matches_expected(self, simple_edge_graph, tmp_dir: Path) -> None: + """The .gv file contains the expected Graphviz dot language content.""" + + expected = "// my graph\ngraph {\n\tA [label=A]\n\tA -- B\n\tB [label=B]\n}\n" + save_to_dot(simple_edge_graph, str(tmp_dir)) + out = tmp_dir / f"{simple_edge_graph.get_label()}.gv" + assert out.read_text(encoding="utf-8") == expected + + def test_directed_graph_skipped_by_save_to_dot(self, tmp_dir: Path) -> None: + """save_to_dot silently skips directed graphs (undirected only).""" + + data = {"directed": True, "label": "d", "graph": {"A": ["B"], "B": []}} + graph = Graph(input_graph=json.dumps(data)) + save_to_dot(graph, str(tmp_dir)) + # no file should be produced for directed graphs + assert not (tmp_dir / "d.gv").exists() diff --git a/tests/test_graph.py b/tests/test_graph.py new file mode 100644 index 0000000..2dd3b88 --- /dev/null +++ b/tests/test_graph.py @@ -0,0 +1,533 @@ +"""Unit and integration tests for :class:`graphworks.graph.Graph`. + +Covers construction (JSON string, JSON file, adjacency matrix), vertex/edge +manipulation, the stdlib adjacency-matrix interface, validation, iteration, +and string representations. + +.. note:: + Edge equality comparisons in these tests use attribute inspection rather + than ``==`` between ``Edge`` instances produced by the library and ``Edge`` + instances constructed in test code. This avoids a subtle identity issue + that arises when the library's internal ``from graphworks.edge import Edge`` + and the test's ``from graphworks.edge import Edge`` resolve to two + different class objects — a situation that only occurs in non-installed + (non-editable) development environments. In a properly configured project + (``uv sync`` / ``pip install -e .``) both paths collapse to the same + installed module and ``==`` works as expected. + +:author: Nathan Gilbert +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pathlib import Path + +import pytest + +from graphworks.graph import Graph + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _edge_pairs(graph: Graph) -> list[tuple[str, str]]: + """Return the edges of *graph* as ``(vertex1, vertex2)`` tuples. + + Using tuples instead of ``Edge`` objects avoids class-identity issues + when the test suite is run without an editable install. + + :param graph: The graph whose edges to extract. + :type graph: Graph + :return: List of ``(vertex1, vertex2)`` pairs. + :rtype: list[tuple[str, str]] + """ + return [(e.vertex1, e.vertex2) for e in graph.edges()] + + +# --------------------------------------------------------------------------- +# Label, repr, and str +# --------------------------------------------------------------------------- + + +class TestGraphLabel: + """Tests for graph label, repr, and str behaviour.""" + + def test_label_from_positional_arg(self) -> None: + """Graph label is stored and returned correctly. + + :return: None + :rtype: None + """ + graph = Graph("my graph") + assert graph.get_label() == "my graph" + + def test_label_defaults_to_empty_string(self) -> None: + """Constructing without a label yields an empty string. + + :return: None + :rtype: None + """ + graph = Graph() + assert graph.get_label() == "" + + def test_repr_returns_label(self) -> None: + """repr() of a graph is its label. + + :return: None + :rtype: None + """ + graph = Graph("demo") + assert repr(graph) == "demo" + + def test_str_shows_adjacency_list(self, simple_edge_json) -> None: + """str() renders a labelled, sorted adjacency list. + + :return: None + :rtype: None + """ + expected = "my graph\nA -> B\nB -> 0" + graph = Graph(input_graph=json.dumps(simple_edge_json)) + assert str(graph) == expected + + def test_str_empty_vertex_shows_zero(self) -> None: + """Vertices with no neighbours render as '-> 0'. + + :return: None + :rtype: None + """ + graph = Graph("g") + graph.add_vertex("X") + assert "X -> 0" in str(graph) + + def test_str_multiple_vertices_sorted(self) -> None: + """str() renders vertices in sorted order. + + :return: None + :rtype: None + """ + data = {"label": "g", "graph": {"B": ["A"], "A": []}} + graph = Graph(input_graph=json.dumps(data)) + lines = str(graph).splitlines() + # First line is label; vertex lines must be sorted + assert lines[1].startswith("A") + assert lines[2].startswith("B") + + +# --------------------------------------------------------------------------- +# Construction — JSON string +# --------------------------------------------------------------------------- + + +class TestGraphJsonConstruction: + """Tests for building a Graph from a JSON string.""" + + def test_label_parsed(self, simple_edge_json) -> None: + """JSON 'label' key is correctly stored. + + :return: None + :rtype: None + """ + graph = Graph(input_graph=json.dumps(simple_edge_json)) + assert graph.get_label() == "my graph" + + def test_undirected_flag_default(self, simple_edge_json) -> None: + """Graph without 'directed' key is undirected by default. + + :return: None + :rtype: None + """ + graph = Graph(input_graph=json.dumps(simple_edge_json)) + assert not graph.is_directed() + + def test_adjacency_list_stored(self, simple_edge_json) -> None: + """get_graph() returns the raw adjacency dict from the JSON. + + :return: None + :rtype: None + """ + graph = Graph(input_graph=json.dumps(simple_edge_json)) + assert graph.get_graph() == simple_edge_json["graph"] + + def test_edge_is_produced(self, simple_edge_json) -> None: + """One edge A→B is produced from the JSON definition. + + :return: None + :rtype: None + """ + graph = Graph(input_graph=json.dumps(simple_edge_json)) + pairs = _edge_pairs(graph) + assert len(pairs) == 1 + assert pairs[0] == ("A", "B") + + def test_directed_flag_parsed(self) -> None: + """'directed' key in JSON sets the directed flag. + + :return: None + :rtype: None + """ + data = {"directed": True, "graph": {"X": ["Y"], "Y": []}} + graph = Graph(input_graph=json.dumps(data)) + assert graph.is_directed() + + def test_weighted_flag_parsed(self) -> None: + """'weighted' key in JSON sets the weighted flag. + + :return: None + :rtype: None + """ + data = {"weighted": True, "graph": {"X": [], "Y": []}} + graph = Graph(input_graph=json.dumps(data)) + assert graph.is_weighted() + + def test_missing_label_defaults_to_empty(self) -> None: + """JSON without 'label' key uses empty string as label. + + :return: None + :rtype: None + """ + data = {"graph": {"A": ["B"], "B": []}} + graph = Graph(input_graph=json.dumps(data)) + assert graph.get_label() == "" + + def test_invalid_edge_raises_value_error(self) -> None: + """Edge referencing a missing vertex raises ValueError. + + :return: None + :rtype: None + """ + bad = {"graph": {"A": ["B", "C", "D"], "B": []}} + with pytest.raises(ValueError): + Graph(input_graph=json.dumps(bad)) + + +# --------------------------------------------------------------------------- +# Construction — JSON file +# --------------------------------------------------------------------------- + + +class TestGraphFileConstruction: + """Tests for building a Graph from a JSON file.""" + + def test_read_from_file(self, tmp_dir: Path, simple_edge_json) -> None: + """Graph is correctly loaded from a JSON file on disk. + + :return: None + :rtype: None + """ + file_path = tmp_dir / "g.json" + file_path.write_text(json.dumps(simple_edge_json), encoding="utf-8") + graph = Graph(input_file=str(file_path)) + assert graph.get_label() == "my graph" + assert not graph.is_directed() + assert graph.get_graph() == simple_edge_json["graph"] + + def test_file_vertices_match(self, tmp_dir: Path, simple_edge_json) -> None: + """Vertices loaded from file match the JSON definition. + + :return: None + :rtype: None + """ + file_path = tmp_dir / "g.json" + file_path.write_text(json.dumps(simple_edge_json), encoding="utf-8") + graph = Graph(input_file=str(file_path)) + assert set(graph.vertices()) == {"A", "B"} + + +# --------------------------------------------------------------------------- +# Construction — stdlib adjacency matrix +# --------------------------------------------------------------------------- + + +class TestGraphMatrixConstruction: + """Tests for building a Graph from a stdlib adjacency matrix.""" + + def test_simple_two_by_two_matrix(self) -> None: + """A 2×2 symmetric matrix yields one undirected edge. + + :return: None + :rtype: None + """ + matrix = [[0, 1], [1, 0]] + graph = Graph(input_matrix=matrix) + assert len(graph.vertices()) == 2 + assert len(graph.edges()) == 1 + + def test_zero_matrix_no_edges(self) -> None: + """A zero matrix produces no edges. + + :return: None + :rtype: None + """ + matrix = [[0, 0], [0, 0]] + graph = Graph(input_matrix=matrix) + assert len(graph.edges()) == 0 + + def test_non_square_raises_value_error(self) -> None: + """A non-square matrix raises ValueError. + + :return: None + :rtype: None + """ + with pytest.raises(ValueError): + Graph(input_matrix=[[0, 1, 0], [1, 0]]) + + def test_wrong_row_count_raises_value_error(self) -> None: + """A matrix where row count != column count raises ValueError. + + :return: None + :rtype: None + """ + with pytest.raises(ValueError): + Graph(input_matrix=[[0, 1], [1, 0], [1, 0]]) + + def test_empty_matrix_raises_value_error(self) -> None: + """An empty matrix raises ValueError. + + :return: None + :rtype: None + """ + with pytest.raises(ValueError): + Graph(input_matrix=[]) + + def test_vertices_are_uuid_strings(self) -> None: + """Matrix-constructed graphs use UUID strings as vertex names. + + :return: None + :rtype: None + """ + graph = Graph(input_matrix=[[0, 1], [1, 0]]) + # UUIDs are 36 characters long + assert all(len(v) == 36 for v in graph.vertices()) + + +# --------------------------------------------------------------------------- +# Vertex and edge manipulation +# --------------------------------------------------------------------------- + + +class TestVertexEdgeManipulation: + """Tests for add_vertex, add_edge, vertices(), edges(), order(), size().""" + + def test_add_single_vertex(self) -> None: + """Adding a single vertex is reflected in vertices(). + + :return: None + :rtype: None + """ + graph = Graph("g") + graph.add_vertex("A") + assert graph.vertices() == ["A"] + + def test_add_duplicate_vertex_is_idempotent(self) -> None: + """Adding a vertex that already exists does not duplicate it. + + :return: None + :rtype: None + """ + graph = Graph("g") + graph.add_vertex("A") + graph.add_vertex("A") + assert graph.vertices().count("A") == 1 + + def test_add_edge_between_existing_vertices(self) -> None: + """add_edge creates one edge between two pre-existing vertices. + + :return: None + :rtype: None + """ + graph = Graph("g") + graph.add_vertex("A") + graph.add_vertex("B") + graph.add_edge("A", "B") + assert len(graph.edges()) == 1 + + def test_add_edge_creates_missing_vertices(self) -> None: + """add_edge auto-creates vertices that do not yet exist. + + :return: None + :rtype: None + """ + graph = Graph("g") + graph.add_edge("X", "Y") + assert len(graph.edges()) == 1 + assert len(graph.vertices()) == 2 + + def test_multiple_edges(self) -> None: + """Multiple add_edge calls accumulate correctly. + + :return: None + :rtype: None + """ + graph = Graph("g") + graph.add_vertex("A") + graph.add_vertex("B") + graph.add_edge("A", "B") + graph.add_edge("X", "Y") + assert len(graph.edges()) == 2 + assert len(graph.vertices()) == 4 + + def test_order_and_size(self, simple_edge_graph) -> None: + """order() and size() return vertex and edge counts. + + :return: None + :rtype: None + """ + assert simple_edge_graph.order() == 2 + assert simple_edge_graph.size() == 1 + + def test_get_neighbors_populated(self, simple_edge_graph) -> None: + """get_neighbors returns the correct neighbour list. + + :return: None + :rtype: None + """ + assert simple_edge_graph.get_neighbors("A") == ["B"] + + def test_get_neighbors_empty(self, simple_edge_graph) -> None: + """get_neighbors returns [] for a vertex with no out-edges. + + :return: None + :rtype: None + """ + assert simple_edge_graph.get_neighbors("B") == [] + + def test_get_random_vertex_is_in_graph(self, big_graph) -> None: + """get_random_vertex returns a vertex that exists in the graph. + + :return: None + :rtype: None + """ + v = big_graph.get_random_vertex() + assert v in big_graph.vertices() + + def test_set_directed(self) -> None: + """set_directed toggles the is_directed flag. + + :return: None + :rtype: None + """ + graph = Graph("g") + graph.add_vertex("A") + assert not graph.is_directed() + graph.set_directed(True) + assert graph.is_directed() + graph.set_directed(False) + assert not graph.is_directed() + + +# --------------------------------------------------------------------------- +# Adjacency matrix interface (stdlib only) +# --------------------------------------------------------------------------- + + +class TestAdjacencyMatrix: + """Tests for the stdlib adjacency matrix interface.""" + + def test_values_for_simple_edge(self, simple_edge_graph) -> None: + """Matrix has 1 for A→B and 0 elsewhere. + + :return: None + :rtype: None + """ + matrix = simple_edge_graph.get_adjacency_matrix() + assert matrix == [[0, 1], [0, 0]] + + def test_matrix_is_square(self, big_graph) -> None: + """Adjacency matrix dimensions equal the vertex count. + + :return: None + :rtype: None + """ + n = big_graph.order() + matrix = big_graph.get_adjacency_matrix() + assert len(matrix) == n + assert all(len(row) == n for row in matrix) + + def test_vertex_index_roundtrip(self, simple_edge_graph) -> None: + """vertex_to_matrix_index and matrix_index_to_vertex are inverses. + + :return: None + :rtype: None + """ + for v in simple_edge_graph.vertices(): + idx = simple_edge_graph.vertex_to_matrix_index(v) + assert simple_edge_graph.matrix_index_to_vertex(idx) == v + + def test_directed_graph_matrix_asymmetric(self) -> None: + """A directed graph produces an asymmetric adjacency matrix. + + :return: None + :rtype: None + """ + data = {"directed": True, "graph": {"A": ["B"], "B": []}} + graph = Graph(input_graph=json.dumps(data)) + matrix = graph.get_adjacency_matrix() + # A→B exists (1) but B→A does not (0) + assert matrix[0][1] == 1 + assert matrix[1][0] == 0 + + +# --------------------------------------------------------------------------- +# Iteration protocol +# --------------------------------------------------------------------------- + + +class TestGraphIteration: + """Tests for __iter__ and __getitem__.""" + + def test_iter_visits_all_vertices(self) -> None: + """Iterating over a graph yields every vertex exactly once. + + :return: None + :rtype: None + """ + data = {"graph": {"A": ["B", "C", "D"], "B": [], "C": [], "D": []}} + graph = Graph(input_graph=json.dumps(data)) + assert sorted(graph) == ["A", "B", "C", "D"] + + def test_iter_count(self) -> None: + """Number of iterations equals the number of vertices. + + :return: None + :rtype: None + """ + data = {"graph": {"A": ["B", "C", "D"], "B": [], "C": [], "D": []}} + graph = Graph(input_graph=json.dumps(data)) + assert sum(1 for _ in graph) == 4 + + def test_iter_yields_correct_neighbour_counts(self) -> None: + """Neighbour lists obtained via iteration have correct lengths. + + :return: None + :rtype: None + """ + data = {"graph": {"A": ["B", "C", "D"], "B": [], "C": [], "D": []}} + graph = Graph(input_graph=json.dumps(data)) + counts = {key: len(graph[key]) for key in graph} + assert counts["A"] == 3 + assert counts["B"] == 0 + + def test_getitem_returns_neighbours(self) -> None: + """graph[vertex] returns the neighbour list. + + :return: None + :rtype: None + """ + data = {"graph": {"A": ["B", "C", "D"], "B": [], "C": [], "D": []}} + graph = Graph(input_graph=json.dumps(data)) + assert len(graph["A"]) == 3 + assert graph["B"] == [] + + def test_getitem_missing_vertex_returns_empty(self) -> None: + """graph[missing] returns an empty list rather than raising. + + :return: None + :rtype: None + """ + graph = Graph("g") + assert graph["MISSING"] == [] diff --git a/tests/test_numpy_compat.py b/tests/test_numpy_compat.py new file mode 100644 index 0000000..637fabb --- /dev/null +++ b/tests/test_numpy_compat.py @@ -0,0 +1,196 @@ +""" +tests.test_numpy_compat +~~~~~~~~~~~~~~~~~~~~~~~ + +Unit tests for :mod:`graphworks.numpy_compat`. + +These tests are automatically skipped when numpy is not installed. +Install the optional dependency with:: + + pip install graphworks[matrix] + # or + uv add graphworks[matrix] + +:author: Nathan Gilbert +""" + +from __future__ import annotations + +import json + +import pytest + +numpy = pytest.importorskip("numpy", reason="numpy not installed — skipping matrix tests") +np = numpy + +from graphworks.graph import Graph # noqa: E402 +from graphworks.numpy_compat import matrix_to_ndarray, ndarray_to_matrix # noqa: E402 + + +class TestNdarrayToMatrix: + """Tests for ndarray_to_matrix.""" + + def test_basic_conversion(self) -> None: + """A simple 2×2 ndarray converts to the expected list-of-lists. + + :return: None + :rtype: None + """ + arr = np.array([[0, 1], [1, 0]]) + result = ndarray_to_matrix(arr) + assert result == [[0, 1], [1, 0]] + + def test_nonzero_values_coerced_to_one(self) -> None: + """Values greater than 0 are coerced to 1. + + :return: None + :rtype: None + """ + arr = np.array([[0, 5], [2, 0]]) + result = ndarray_to_matrix(arr) + assert result == [[0, 1], [1, 0]] + + def test_zero_values_remain_zero(self) -> None: + """Zero values in the ndarray produce 0 in the matrix. + + :return: None + :rtype: None + """ + arr = np.zeros((3, 3), dtype=int) + result = ndarray_to_matrix(arr) + assert result == [[0, 0, 0], [0, 0, 0], [0, 0, 0]] + + def test_non_square_raises_value_error(self) -> None: + """A non-square ndarray raises ValueError. + + :return: None + :rtype: None + """ + arr = np.array([[0, 1, 0, 0, 0], [1, 0]], dtype=object) + with pytest.raises(ValueError): + ndarray_to_matrix(arr) + + def test_three_dimensional_raises_value_error(self) -> None: + """A 3-D ndarray raises ValueError (must be 2-D). + + :return: None + :rtype: None + """ + arr = np.zeros((2, 2, 2)) + with pytest.raises(ValueError): + ndarray_to_matrix(arr) + + def test_result_is_list_of_lists(self) -> None: + """The returned value is a ``list[list[int]]``, not an ndarray. + + :return: None + :rtype: None + """ + arr = np.eye(2, dtype=int) + result = ndarray_to_matrix(arr) + assert isinstance(result, list) + assert all(isinstance(row, list) for row in result) + + +class TestMatrixToNdarray: + """Tests for matrix_to_ndarray.""" + + def test_basic_conversion(self) -> None: + """A list-of-lists converts to the expected numpy array. + + :return: None + :rtype: None + """ + matrix = [[0, 1], [1, 0]] + result = matrix_to_ndarray(matrix) + np.testing.assert_array_equal(result, np.array([[0, 1], [1, 0]])) + + def test_dtype_is_integer(self) -> None: + """The returned array has an integer dtype. + + :return: None + :rtype: None + """ + result = matrix_to_ndarray([[0, 1], [1, 0]]) + assert np.issubdtype(result.dtype, np.integer) + + def test_zeros_matrix(self) -> None: + """An all-zeros matrix converts without modification. + + :return: None + :rtype: None + """ + matrix = [[0, 0], [0, 0]] + result = matrix_to_ndarray(matrix) + np.testing.assert_array_equal(result, np.zeros((2, 2), dtype=int)) + + def test_result_is_ndarray(self) -> None: + """The returned value is a numpy ndarray. + + :return: None + :rtype: None + """ + result = matrix_to_ndarray([[0, 1], [1, 0]]) + assert isinstance(result, np.ndarray) + + def test_shape_preserved(self) -> None: + """The shape of the output array matches the input matrix dimensions. + + :return: None + :rtype: None + """ + matrix = [[0, 1, 0], [1, 0, 1], [0, 1, 0]] + result = matrix_to_ndarray(matrix) + assert result.shape == (3, 3) + + +class TestGraphNumpyIntegration: + """Integration tests for Graph ↔ numpy ndarray round-trips.""" + + def test_graph_from_ndarray_via_compat(self) -> None: + """Graph built from an ndarray (via ndarray_to_matrix) is valid. + + :return: None + :rtype: None + """ + arr = np.array([[0, 1], [1, 0]], dtype=object) + matrix = ndarray_to_matrix(arr) + graph = Graph(input_matrix=matrix) + assert len(graph.vertices()) == 2 + assert len(graph.edges()) == 1 + + def test_adjacency_matrix_roundtrip(self) -> None: + """get_adjacency_matrix → matrix_to_ndarray preserves structure. + + :return: None + :rtype: None + """ + data = {"graph": {"A": ["B"], "B": []}} + graph = Graph(input_graph=json.dumps(data)) + stdlib_matrix = graph.get_adjacency_matrix() + arr = matrix_to_ndarray(stdlib_matrix) + np.testing.assert_array_equal(arr, np.array([[0, 1], [0, 0]])) + + def test_symmetric_matrix_roundtrip(self) -> None: + """A symmetric adjacency matrix survives a full ndarray roundtrip. + + :return: None + :rtype: None + """ + original = [[0, 1, 0], [1, 0, 1], [0, 1, 0]] + arr = matrix_to_ndarray(original) + recovered = ndarray_to_matrix(arr) + assert original == recovered + + def test_graph_order_from_ndarray(self) -> None: + """A 4×4 ndarray produces a graph with 4 vertices. + + :return: None + :rtype: None + """ + arr = np.zeros((4, 4), dtype=int) + arr[0, 1] = 1 + arr[1, 0] = 1 + matrix = ndarray_to_matrix(arr) + graph = Graph(input_matrix=matrix) + assert graph.order() == 4 diff --git a/tests/test_paths.py b/tests/test_paths.py new file mode 100644 index 0000000..47cfcde --- /dev/null +++ b/tests/test_paths.py @@ -0,0 +1,145 @@ +""" +tests.test_paths +~~~~~~~~~~~~~~~~ + +Unit tests for :mod:`graphworks.algorithms.paths`. + +Covers generate_edges, find_isolated_vertices, find_path, and find_all_paths. + +:author: Nathan Gilbert +""" + +from __future__ import annotations + +import json + +import pytest + +from graphworks.algorithms.paths import ( + find_all_paths, + find_isolated_vertices, + find_path, + generate_edges, +) +from graphworks.graph import Graph + + +class TestGenerateEdges: + """Tests for generate_edges.""" + + def test_single_edge_graph(self) -> None: + """generate_edges returns one edge for a one-edge graph.""" + data = {"graph": {"A": ["B"], "B": []}} + graph = Graph(input_graph=json.dumps(data)) + assert len(generate_edges(graph)) == 1 + + def test_no_edge_graph(self, isolated_graph) -> None: + """generate_edges returns an empty list for an isolated graph.""" + assert generate_edges(isolated_graph) == [] + + def test_matches_graph_edges(self, big_graph) -> None: + """generate_edges output matches graph.edges().""" + assert generate_edges(big_graph) == big_graph.edges() + + +class TestFindIsolatedVertices: + """Tests for find_isolated_vertices.""" + + def test_all_isolated(self, isolated_graph) -> None: + """Every vertex is isolated when there are no edges.""" + isolated = find_isolated_vertices(isolated_graph) + assert sorted(isolated) == ["a", "b", "c"] + + def test_partial_isolation(self) -> None: + """Only the vertex with no neighbours is returned as isolated.""" + data = {"graph": {"A": ["B"], "B": ["A"], "C": []}} + graph = Graph(input_graph=json.dumps(data)) + assert find_isolated_vertices(graph) == ["C"] + + def test_no_isolated_vertices(self, big_graph) -> None: + """A fully connected graph has no isolated vertices.""" + assert find_isolated_vertices(big_graph) == [] + + +class TestFindPath: + """Tests for find_path.""" + + @pytest.fixture() + def path_graph(self) -> Graph: + """Graph used for path-finding tests. + + :return: Constructed Graph. + :rtype: Graph + """ + data = { + "label": "test", + "directed": False, + "graph": { + "a": ["d"], + "b": ["c"], + "c": ["b", "c", "d", "e"], + "d": ["a", "c"], + "e": ["c"], + "f": [], + }, + } + return Graph(input_graph=json.dumps(data)) + + def test_path_exists(self, path_graph) -> None: + """find_path returns a valid path when one exists.""" + path = find_path(path_graph, "a", "b") + assert path == ["a", "d", "c", "b"] + + def test_no_path_to_isolated_vertex(self, path_graph) -> None: + """find_path returns [] when the destination is unreachable.""" + path = find_path(path_graph, "a", "f") + assert path == [] + + def test_same_start_and_end(self, path_graph) -> None: + """find_path returns [vertex] when start equals end.""" + path = find_path(path_graph, "c", "c") + assert path == ["c"] + + def test_missing_start_vertex(self, path_graph) -> None: + """find_path returns [] when the start vertex is not in the graph.""" + path = find_path(path_graph, "z", "a") + assert path == [] + + +class TestFindAllPaths: + """Tests for find_all_paths.""" + + @pytest.fixture() + def multi_path_graph(self) -> Graph: + """Graph with multiple paths between vertices. + + :return: Constructed Graph. + :rtype: Graph + """ + data = { + "label": "test2", + "directed": False, + "graph": { + "a": ["d", "f"], + "b": ["c"], + "c": ["b", "c", "d", "e"], + "d": ["a", "c"], + "e": ["c"], + "f": ["d"], + }, + } + return Graph(input_graph=json.dumps(data)) + + def test_multiple_paths(self, multi_path_graph) -> None: + """find_all_paths returns all simple paths between two vertices.""" + paths = find_all_paths(multi_path_graph, "a", "b") + assert paths == [["a", "d", "c", "b"], ["a", "f", "d", "c", "b"]] + + def test_missing_start_returns_empty(self, multi_path_graph) -> None: + """find_all_paths returns [] when the start vertex is absent.""" + assert find_all_paths(multi_path_graph, "z", "b") == [] + + def test_same_start_and_end(self, multi_path_graph) -> None: + """find_all_paths([v, v]) returns a single path containing only v.""" + paths = find_all_paths(multi_path_graph, "a", "a") + assert paths == [["a"]] diff --git a/tests/test_properties.py b/tests/test_properties.py new file mode 100644 index 0000000..3b0a03e --- /dev/null +++ b/tests/test_properties.py @@ -0,0 +1,583 @@ +""" +tests.test_properties +~~~~~~~~~~~~~~~~~~~~~ + +Unit tests for :mod:`graphworks.algorithms.properties`. + +Covers degree helpers, sequence predicates, structural predicates, +density/diameter metrics, and matrix operations. + +Implementation notes on tested behaviour +----------------------------------------- +* ``is_degree_sequence`` — requires the sum of the sequence to be **even** + and the sequence to be non-increasing. ``[3, 1, 1]`` has an odd sum (5) + and therefore returns ``False``. + +* ``is_simple`` — checks only for **self-loops** (a vertex listed in its own + adjacency list). A graph with a cycle but no self-loop (e.g. the lollipop + graph) is considered simple by this predicate. + +* ``invert`` / ``get_complement`` — ``invert`` flips every cell in the + adjacency matrix including the diagonal, so the complement of an isolated + graph contains both cross-edges *and* self-loops. Tests reflect this + documented behaviour. + +:author: Nathan Gilbert +""" + +from __future__ import annotations + +import json + +import pytest + +from graphworks.algorithms.properties import ( + degree_sequence, + density, + diameter, + get_complement, + invert, + is_complete, + is_connected, + is_degree_sequence, + is_dense, + is_erdos_gallai, + is_regular, + is_simple, + is_sparse, + max_degree, + min_degree, + vertex_degree, +) +from graphworks.graph import Graph + +# --------------------------------------------------------------------------- +# Degree helpers +# --------------------------------------------------------------------------- + + +class TestVertexDegree: + """Tests for vertex_degree.""" + + def test_degree_without_self_loop(self) -> None: + """Vertex with no self-loop has degree equal to its out-edge count. + + :return: None + :rtype: None + """ + data = {"graph": {"a": ["b", "c"], "b": ["a"], "c": ["a"]}} + graph = Graph(input_graph=json.dumps(data)) + assert vertex_degree(graph, "b") == 1 + + def test_self_loop_counts_twice(self) -> None: + """A self-loop contributes 2 to the vertex degree. + + :return: None + :rtype: None + """ + data = {"graph": {"a": ["a", "b", "c"], "b": ["a"], "c": ["a"]}} + graph = Graph(input_graph=json.dumps(data)) + # self-loop (×2) + b + c = 4 + assert vertex_degree(graph, "a") == 4 + + def test_isolated_vertex_degree_zero(self, isolated_graph) -> None: + """An isolated vertex has degree 0. + + :return: None + :rtype: None + """ + assert vertex_degree(isolated_graph, "a") == 0 + + def test_vertex_with_multiple_neighbours(self, big_graph) -> None: + """A hub vertex has degree equal to its neighbour count. + + :return: None + :rtype: None + """ + # vertex 'c' in big_graph has neighbours: a, b, d, e → degree 4 + assert vertex_degree(big_graph, "c") == 4 + + +class TestMinMaxDegree: + """Tests for min_degree and max_degree.""" + + def test_min_degree(self) -> None: + """min_degree returns the smallest degree in the graph. + + :return: None + :rtype: None + """ + data = {"graph": {"a": ["a", "b", "c"], "b": ["a"], "c": ["a"]}} + graph = Graph(input_graph=json.dumps(data)) + assert min_degree(graph) == 1 + + def test_max_degree(self) -> None: + """max_degree returns the largest degree in the graph. + + :return: None + :rtype: None + """ + data = {"graph": {"a": ["a", "b", "c"], "b": ["a"], "c": ["a"]}} + graph = Graph(input_graph=json.dumps(data)) + assert max_degree(graph) == 4 + + def test_min_equals_max_for_regular_graph(self, isolated_graph) -> None: + """min_degree and max_degree are equal for a regular graph. + + :return: None + :rtype: None + """ + assert min_degree(isolated_graph) == max_degree(isolated_graph) + + def test_min_less_than_max_for_irregular(self, big_graph) -> None: + """min_degree < max_degree for an irregular graph. + + :return: None + :rtype: None + """ + assert min_degree(big_graph) < max_degree(big_graph) + + +class TestDegreeSequence: + """Tests for degree_sequence.""" + + def test_sequence_is_sorted_descending(self) -> None: + """degree_sequence returns degrees in non-increasing order. + + :return: None + :rtype: None + """ + data = {"graph": {"a": ["a", "b", "c"], "b": ["a"], "c": ["a"]}} + graph = Graph(input_graph=json.dumps(data)) + seq = degree_sequence(graph) + assert seq == (4, 1, 1) + assert list(seq) == sorted(seq, reverse=True) + + def test_isolated_graph_all_zeros(self, isolated_graph) -> None: + """All-isolated graph has a degree sequence of all zeros. + + :return: None + :rtype: None + """ + assert all(d == 0 for d in degree_sequence(isolated_graph)) + + +# --------------------------------------------------------------------------- +# Sequence predicates +# --------------------------------------------------------------------------- + + +class TestIsDegreeSequence: + """Tests for is_degree_sequence. + + A valid degree sequence must: + * be non-increasing, AND + * have an even sum (handshaking lemma). + + Note: ``[3, 1, 1]`` sums to 5 (odd) → ``False``. + """ + + @pytest.mark.parametrize( + "seq,expected", + [ + ([], True), + ([2, 2], True), # sum=4 (even), non-increasing + ([4, 2, 2], True), # sum=8 (even), non-increasing + ([1, 2, 3], False), # not non-increasing + ([3, 1, 1], False), # sum=5 (odd) + ([1], False), # sum=1 (odd) + ([0, 0, 0], True), # all-zero, sum=0 (even) + ], + ) + def test_various_sequences(self, seq: list[int], expected: bool) -> None: + """Parametrised check for valid and invalid degree sequences. + + :param seq: Candidate degree sequence. + :type seq: list[int] + :param expected: Expected return value. + :type expected: bool + :return: None + :rtype: None + """ + assert is_degree_sequence(seq) is expected + + +class TestIsErdosGallai: + """Tests for is_erdos_gallai.""" + + @pytest.mark.parametrize( + "seq,expected", + [ + ([], True), + ([1], False), # odd sum + ([2, 2, 4], False), # violates EG condition + ([32, 8, 4, 2, 2], False), + ([6, 6, 6, 6, 5, 5, 2, 2], True), # graphical sequence + ([0, 0, 0], True), # empty graph on 3 vertices + ([1, 1], True), # K₂: two vertices each of degree 1 + ], + ) + def test_various_sequences(self, seq: list[int], expected: bool) -> None: + """Parametrised check of the Erdős–Gallai theorem. + + :param seq: Candidate degree sequence. + :type seq: list[int] + :param expected: Expected return value. + :type expected: bool + :return: None + :rtype: None + """ + assert is_erdos_gallai(seq) is expected + + +# --------------------------------------------------------------------------- +# Structural predicates +# --------------------------------------------------------------------------- + + +class TestIsRegular: + """Tests for is_regular.""" + + def test_isolated_graph_is_regular(self, isolated_graph) -> None: + """All-isolated graph is regular (all degrees are 0). + + :return: None + :rtype: None + """ + assert is_regular(isolated_graph) + + def test_complete_triangle_is_regular(self, triangle_graph) -> None: + """Complete graph K₃ is 2-regular. + + :return: None + :rtype: None + """ + assert is_regular(triangle_graph) + + def test_irregular_graph(self, big_graph) -> None: + """Graph with mixed degrees is not regular. + + :return: None + :rtype: None + """ + assert not is_regular(big_graph) + + +class TestIsSimple: + """Tests for is_simple. + + ``is_simple`` returns ``True`` when **no vertex appears in its own + neighbour list** (i.e. no self-loop). A graph may contain cycles and + still be considered simple by this predicate. + """ + + def test_straight_line_is_simple(self, straight_line_json) -> None: + """A path graph with no self-loops is simple. + + :return: None + :rtype: None + """ + graph = Graph(input_graph=json.dumps(straight_line_json)) + assert is_simple(graph) + + def test_lollipop_graph_is_simple(self, lollipop_json) -> None: + """The lollipop graph has a cycle but no self-loop, so is simple. + + :return: None + :rtype: None + """ + graph = Graph(input_graph=json.dumps(lollipop_json)) + assert is_simple(graph) + + def test_self_loop_makes_graph_not_simple(self, self_loop_json) -> None: + """A graph where a vertex lists itself as a neighbour is not simple. + + :return: None + :rtype: None + """ + graph = Graph(input_graph=json.dumps(self_loop_json)) + assert not is_simple(graph) + + def test_connected_graph_with_self_loops_not_simple(self, connected_json) -> None: + """The connected fixture includes self-loops so it is not simple. + + :return: None + :rtype: None + """ + graph = Graph(input_graph=json.dumps(connected_json)) + # vertex 'b' has 'b' in its neighbour list; vertex 'c' has 'c' + assert not is_simple(graph) + + +class TestIsConnected: + """Tests for is_connected.""" + + def test_connected_graph(self, connected_graph) -> None: + """A connected graph returns True. + + :return: None + :rtype: None + """ + assert is_connected(connected_graph) + + def test_isolated_vertices_not_connected(self, isolated_graph) -> None: + """Isolated vertices make the graph disconnected. + + :return: None + :rtype: None + """ + assert not is_connected(isolated_graph) + + def test_big_graph_is_connected(self, big_graph) -> None: + """The big_graph fixture is connected. + + :return: None + :rtype: None + """ + assert is_connected(big_graph) + + +class TestIsComplete: + """Tests for is_complete.""" + + def test_triangle_is_complete(self, triangle_graph) -> None: + """K₃ (triangle) is a complete graph. + + :return: None + :rtype: None + """ + assert is_complete(triangle_graph) + + def test_isolated_graph_is_not_complete(self, isolated_graph) -> None: + """Isolated vertices form an incomplete graph. + + :return: None + :rtype: None + """ + assert not is_complete(isolated_graph) + + def test_partial_graph_is_not_complete(self, simple_edge_graph) -> None: + """A graph missing edges is not complete. + + :return: None + :rtype: None + """ + assert not is_complete(simple_edge_graph) + + def test_complete_directed_graph(self) -> None: + """Complete directed graph (every ordered pair has an arc) is complete. + + :return: None + :rtype: None + """ + data = {"directed": True, "graph": {"a": ["b"], "b": ["a"]}} + graph = Graph(input_graph=json.dumps(data)) + assert is_complete(graph) + + def test_incomplete_directed_graph(self) -> None: + """Directed graph missing some arcs is not complete. + + :return: None + :rtype: None + """ + data = {"directed": True, "graph": {"A": ["B"], "B": []}} + graph = Graph(input_graph=json.dumps(data)) + assert not is_complete(graph) + + +class TestIsSparseAndDense: + """Tests for is_sparse and is_dense.""" + + def test_isolated_graph_is_sparse(self, isolated_graph) -> None: + """Zero-edge graph is sparse. + + :return: None + :rtype: None + """ + assert is_sparse(isolated_graph) + + def test_complete_triangle_is_dense(self, triangle_graph) -> None: + """Complete graph has density 1.0 and is therefore dense. + + :return: None + :rtype: None + """ + assert is_dense(triangle_graph) + + def test_sparse_not_dense(self, isolated_graph) -> None: + """A sparse graph is not dense. + + :return: None + :rtype: None + """ + assert not is_dense(isolated_graph) + + +# --------------------------------------------------------------------------- +# Metrics +# --------------------------------------------------------------------------- + + +class TestDensity: + """Tests for density.""" + + def test_connected_graph_density(self, connected_graph) -> None: + """Density of the connected fixture is approximately 0.467. + + :return: None + :rtype: None + """ + assert density(connected_graph) == pytest.approx(0.4666666666666667) + + def test_complete_graph_density(self, triangle_graph) -> None: + """Density of a complete graph is 1.0. + + :return: None + :rtype: None + """ + assert density(triangle_graph) == pytest.approx(1.0) + + def test_isolated_graph_density_zero(self, isolated_graph) -> None: + """Density of a graph with no edges is 0.0. + + :return: None + :rtype: None + """ + assert density(isolated_graph) == pytest.approx(0.0) + + def test_single_vertex_density_zero(self) -> None: + """Graph with fewer than two vertices has density 0.0. + + :return: None + :rtype: None + """ + graph = Graph("g") + graph.add_vertex("A") + assert density(graph) == pytest.approx(0.0) + + +class TestDiameter: + """Tests for diameter.""" + + def test_big_graph_diameter(self, big_graph) -> None: + """Diameter of the big_graph fixture is 3. + + :return: None + :rtype: None + """ + assert diameter(big_graph) == 3 + + def test_single_edge_diameter(self, simple_edge_graph) -> None: + """A graph with one edge has diameter 1. + + :return: None + :rtype: None + """ + assert diameter(simple_edge_graph) == 1 + + def test_disconnected_graph_diameter_zero(self, isolated_graph) -> None: + """A graph with no paths between vertices returns diameter 0. + + :return: None + :rtype: None + """ + assert diameter(isolated_graph) == 0 + + +# --------------------------------------------------------------------------- +# Matrix operations and complement +# --------------------------------------------------------------------------- + + +class TestInvert: + """Tests for the invert (matrix complement) function. + + ``invert`` flips every cell including the main diagonal. + """ + + def test_invert_zeros_to_ones(self) -> None: + """Inverting a zero matrix produces an all-ones matrix. + + :return: None + :rtype: None + """ + assert invert([[0, 0], [0, 0]]) == [[1, 1], [1, 1]] + + def test_invert_ones_to_zeros(self) -> None: + """Inverting an all-ones matrix produces a zero matrix. + + :return: None + :rtype: None + """ + assert invert([[1, 1], [1, 1]]) == [[0, 0], [0, 0]] + + def test_invert_mixed(self) -> None: + """Invert flips 0↔1 for every cell including diagonal. + + :return: None + :rtype: None + """ + assert invert([[0, 1], [1, 0]]) == [[1, 0], [0, 1]] + + def test_invert_is_own_inverse(self) -> None: + """Applying invert twice returns the original matrix. + + :return: None + :rtype: None + """ + original = [[0, 1], [0, 0]] + assert invert(invert(original)) == original + + +class TestGetComplement: + """Tests for get_complement. + + Note: because ``invert`` flips the diagonal, the complement of an + isolated graph contains **self-loops** in addition to cross-edges. + The complement of a complete graph similarly gains self-loops. + This is the documented behaviour of the current ``invert`` implementation. + """ + + def test_complement_label(self, isolated_graph) -> None: + """Complement label appends ' complement' to the original label. + + :return: None + :rtype: None + """ + complement = get_complement(isolated_graph) + assert "complement" in complement.get_label() + + def test_complement_of_isolated_has_cross_edges(self, isolated_graph) -> None: + """Complement of an isolated graph has edges between distinct vertices. + + The complement matrix has 1s everywhere (including the diagonal), so + the complement graph contains both cross-edges and self-loops. + + :return: None + :rtype: None + """ + complement = get_complement(isolated_graph) + cross_edges = [e for e in complement.edges() if e.vertex1 != e.vertex2] + assert len(cross_edges) > 0 + + def test_complement_of_complete_has_only_self_loops(self, triangle_graph) -> None: + """Complement of a complete K₃ has no cross-edges (only diagonal). + + K₃ adjacency matrix has 1s everywhere except the diagonal; inverting + gives 1s only on the diagonal → only self-loops remain. + + :return: None + :rtype: None + """ + complement = get_complement(triangle_graph) + cross_edges = [e for e in complement.edges() if e.vertex1 != e.vertex2] + assert len(cross_edges) == 0 + + def test_complement_vertex_count_preserved(self, big_graph) -> None: + """Complement has the same number of vertices as the original. + + :return: None + :rtype: None + """ + complement = get_complement(big_graph) + assert complement.order() == big_graph.order() diff --git a/tests/test_search.py b/tests/test_search.py new file mode 100644 index 0000000..69e41f3 --- /dev/null +++ b/tests/test_search.py @@ -0,0 +1,206 @@ +""" +tests.test_search +~~~~~~~~~~~~~~~~~ + +Unit tests for :mod:`graphworks.algorithms.search`. + +Covers breadth_first_search, depth_first_search, and arrival_departure_dfs. + +:author: Nathan Gilbert +""" + +from __future__ import annotations + +import json + +from graphworks.algorithms.search import ( + arrival_departure_dfs, + breadth_first_search, + depth_first_search, +) +from graphworks.graph import Graph + + +class TestBreadthFirstSearch: + """Tests for breadth_first_search.""" + + def test_bfs_from_c(self, search_graph) -> None: + """BFS from 'c' visits all reachable vertices in level order. + + :return: Nothing + :rtype: None + """ + walk = breadth_first_search(search_graph, "c") + assert walk == ["c", "a", "d", "b"] + + def test_bfs_visits_all_vertices(self, search_graph) -> None: + """BFS visits every vertex in the connected graph. + + :return: Nothing + :rtype: None + """ + walk = breadth_first_search(search_graph, "a") + assert sorted(walk) == ["a", "b", "c", "d"] + + def test_bfs_single_vertex(self) -> None: + """BFS on a single-vertex graph returns just that vertex. + + :return: Nothing + :rtype: None + """ + g = Graph(input_graph=json.dumps({"graph": {"x": []}})) + assert breadth_first_search(g, "x") == ["x"] + + def test_bfs_start_vertex_is_first(self, search_graph) -> None: + """BFS walk always begins with the given start vertex. + + :return: Nothing + :rtype: None + """ + walk = breadth_first_search(search_graph, "b") + assert walk[0] == "b" + + def test_bfs_no_duplicates(self, search_graph) -> None: + """BFS never visits a vertex more than once. + + :return: Nothing + :rtype: None + """ + walk = breadth_first_search(search_graph, "a") + assert len(walk) == len(set(walk)) + + +class TestDepthFirstSearch: + """Tests for depth_first_search.""" + + def test_dfs_from_c(self, search_graph) -> None: + """DFS from 'c' visits vertices in depth-first order. + + :return: Nothing + :rtype: None + """ + walk = depth_first_search(search_graph, "c") + assert walk == ["c", "d", "a", "b"] + + def test_dfs_visits_all_vertices(self, search_graph) -> None: + """DFS visits every vertex in the connected graph. + + :return: Nothing + :rtype: None + """ + walk = depth_first_search(search_graph, "a") + assert sorted(walk) == ["a", "b", "c", "d"] + + def test_dfs_start_vertex_is_first(self, search_graph) -> None: + """DFS walk always begins with the given start vertex. + + :return: Nothing + :rtype: None + """ + walk = depth_first_search(search_graph, "a") + assert walk[0] == "a" + + def test_dfs_no_duplicates(self, search_graph) -> None: + """DFS never visits a vertex more than once. + + :return: Nothing + :rtype: None + """ + walk = depth_first_search(search_graph, "a") + assert len(walk) == len(set(walk)) + + def test_dfs_shared_neighbour_visited_only_once(self) -> None: + """DFS skips a vertex that was pushed onto the stack twice. + + When two vertices both point at the same neighbour, that neighbour may + be pushed onto the stack multiple times before being popped. The + already-visited guard (``if vertex not in visited``) ensures the vertex + is processed exactly once even when it is encountered a second time. + This exercises the ``False`` branch of that guard. + + Graph topology: ``a → [b, c]``, ``c → [b]`` — vertex *b* is reachable + from both *a* (directly) and *c* (indirectly via *a*'s push of *c*). + + :return: Nothing + :rtype: None + """ + data = {"graph": {"a": ["b", "c"], "b": [], "c": ["b"]}} + graph = Graph(input_graph=json.dumps(data)) + walk = depth_first_search(graph, "a") + # b must appear exactly once despite being pushed twice + assert walk.count("b") == 1 + assert sorted(walk) == ["a", "b", "c"] + + +class TestArrivalDepartureDFS: + """Tests for arrival_departure_dfs.""" + + def _run_full_traversal( + self, graph: Graph + ) -> tuple[dict[str, int], dict[str, int], dict[str, bool]]: + """Helper: run arrival_departure_dfs over all components of *graph*. + + :param graph: The graph to traverse. + :type graph: Graph + :return: Tuple of (arrival, departure, discovered) dictionaries. + :rtype: tuple[dict[str, int], dict[str, int], dict[str, bool]] + """ + arrival = dict.fromkeys(graph.vertices(), 0) + departure = dict.fromkeys(graph.vertices(), 0) + discovered = dict.fromkeys(graph.vertices(), False) + time = -1 + for v in graph.vertices(): + if not discovered[v]: + time = arrival_departure_dfs(graph, v, discovered, arrival, departure, time) + return arrival, departure, discovered + + def test_arrival_departure_times(self, disjoint_directed_graph) -> None: + """Arrival and departure times are correctly assigned for both components. + + :return: Nothing + :rtype: None + """ + arrival, departure, _ = self._run_full_traversal(disjoint_directed_graph) + result = list(zip(arrival.values(), departure.values(), strict=False)) + expected = [ + (0, 11), + (1, 2), + (3, 10), + (4, 7), + (8, 9), + (5, 6), + (12, 15), + (13, 14), + ] + assert result == expected + + def test_all_vertices_discovered(self, disjoint_directed_graph) -> None: + """Every vertex is discovered after a full traversal. + + :return: Nothing + :rtype: None + """ + _, _, discovered = self._run_full_traversal(disjoint_directed_graph) + assert all(discovered.values()) + + def test_departure_always_after_arrival(self, disjoint_directed_graph) -> None: + """Departure time is strictly greater than arrival time for every vertex. + + :return: Nothing + :rtype: None + """ + arrival, departure, _ = self._run_full_traversal(disjoint_directed_graph) + for v in disjoint_directed_graph.vertices(): + assert ( + departure[v] > arrival[v] + ), f"Vertex {v!r}: departure {departure[v]} not > arrival {arrival[v]}" + + def test_times_are_unique(self, disjoint_directed_graph) -> None: + """No two events (arrival or departure) share the same timestamp. + + :return: Nothing + :rtype: None + """ + arrival, departure, _ = self._run_full_traversal(disjoint_directed_graph) + all_times = list(arrival.values()) + list(departure.values()) + assert len(all_times) == len(set(all_times)) diff --git a/tests/test_sort.py b/tests/test_sort.py new file mode 100644 index 0000000..74d3cf5 --- /dev/null +++ b/tests/test_sort.py @@ -0,0 +1,68 @@ +""" +tests.test_sort +~~~~~~~~~~~~~~~ + +Unit tests for :mod:`graphworks.algorithms.sort`. + +Covers the topological sort algorithm. + +:author: Nathan Gilbert +""" + +from __future__ import annotations + +import json + +from graphworks.algorithms.sort import topological +from graphworks.graph import Graph + + +class TestTopologicalSort: + """Tests for the topological sort algorithm.""" + + def test_standard_dag(self, directed_dag) -> None: + """topological returns a valid topological order for the fixture DAG.""" + result = topological(directed_dag) + assert result == ["F", "E", "C", "D", "B", "A"] + + def test_result_is_valid_topological_order(self, directed_dag) -> None: + """Every edge (u→v) has u appearing before v in the result.""" + result = topological(directed_dag) + position = {v: i for i, v in enumerate(result)} + for v in directed_dag.vertices(): + for neighbour in directed_dag.get_neighbors(v): + assert ( + position[v] < position[neighbour] + ), f"Edge {v}→{neighbour} is out of order in topological sort" + + def test_all_vertices_present(self, directed_dag) -> None: + """Every vertex appears exactly once in the topological order.""" + result = topological(directed_dag) + assert sorted(result) == sorted(directed_dag.vertices()) + + def test_linear_chain(self) -> None: + """A simple A→B→C→D chain sorts as [A, B, C, D].""" + data = { + "directed": True, + "graph": {"A": ["B"], "B": ["C"], "C": ["D"], "D": []}, + } + graph = Graph(input_graph=json.dumps(data)) + result = topological(graph) + assert result == ["A", "B", "C", "D"] + + def test_single_vertex(self) -> None: + """A single-vertex graph sorts as [that vertex].""" + data = {"directed": True, "graph": {"A": []}} + graph = Graph(input_graph=json.dumps(data)) + assert topological(graph) == ["A"] + + def test_parallel_roots(self) -> None: + """Two independent root vertices both appear before their descendants.""" + data = { + "directed": True, + "graph": {"A": ["C"], "B": ["C"], "C": []}, + } + graph = Graph(input_graph=json.dumps(data)) + result = topological(graph) + assert result.index("C") > result.index("A") + assert result.index("C") > result.index("B") diff --git a/todos.png b/todos.png new file mode 100644 index 0000000..2726855 Binary files /dev/null and b/todos.png differ diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..8531fe0 --- /dev/null +++ b/uv.lock @@ -0,0 +1,991 @@ +version = 1 +revision = 3 +requires-python = ">=3.13, <=3.15" + +[options] +prerelease-mode = "disallow" + +[[package]] +name = "alabaster" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "black" +version = "26.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "pytokens" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/c5/61175d618685d42b005847464b8fb4743a67b1b8fdb75e50e5a96c31a27a/black-26.3.1.tar.gz", hash = "sha256:2c50f5063a9641c7eed7795014ba37b0f5fa227f3d408b968936e24bc0566b07", size = 666155, upload-time = "2026-03-12T03:36:03.593Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/77/5728052a3c0450c53d9bb3945c4c46b91baa62b2cafab6801411b6271e45/black-26.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:855822d90f884905362f602880ed8b5df1b7e3ee7d0db2502d4388a954cc8c54", size = 1895034, upload-time = "2026-03-12T03:40:21.813Z" }, + { url = "https://files.pythonhosted.org/packages/52/73/7cae55fdfdfbe9d19e9a8d25d145018965fe2079fa908101c3733b0c55a0/black-26.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8a33d657f3276328ce00e4d37fe70361e1ec7614da5d7b6e78de5426cb56332f", size = 1718503, upload-time = "2026-03-12T03:40:23.666Z" }, + { url = "https://files.pythonhosted.org/packages/e1/87/af89ad449e8254fdbc74654e6467e3c9381b61472cc532ee350d28cfdafb/black-26.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1cd08e99d2f9317292a311dfe578fd2a24b15dbce97792f9c4d752275c1fa56", size = 1793557, upload-time = "2026-03-12T03:40:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/43/10/d6c06a791d8124b843bf325ab4ac7d2f5b98731dff84d6064eafd687ded1/black-26.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:c7e72339f841b5a237ff14f7d3880ddd0fc7f98a1199e8c4327f9a4f478c1839", size = 1422766, upload-time = "2026-03-12T03:40:27.14Z" }, + { url = "https://files.pythonhosted.org/packages/59/4f/40a582c015f2d841ac24fed6390bd68f0fc896069ff3a886317959c9daf8/black-26.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc622538b430aa4c8c853f7f63bc582b3b8030fd8c80b70fb5fa5b834e575c2", size = 1232140, upload-time = "2026-03-12T03:40:28.882Z" }, + { url = "https://files.pythonhosted.org/packages/d5/da/e36e27c9cebc1311b7579210df6f1c86e50f2d7143ae4fcf8a5017dc8809/black-26.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2d6bfaf7fd0993b420bed691f20f9492d53ce9a2bcccea4b797d34e947318a78", size = 1889234, upload-time = "2026-03-12T03:40:30.964Z" }, + { url = "https://files.pythonhosted.org/packages/0e/7b/9871acf393f64a5fa33668c19350ca87177b181f44bb3d0c33b2d534f22c/black-26.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f89f2ab047c76a9c03f78d0d66ca519e389519902fa27e7a91117ef7611c0568", size = 1720522, upload-time = "2026-03-12T03:40:32.346Z" }, + { url = "https://files.pythonhosted.org/packages/03/87/e766c7f2e90c07fb7586cc787c9ae6462b1eedab390191f2b7fc7f6170a9/black-26.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b07fc0dab849d24a80a29cfab8d8a19187d1c4685d8a5e6385a5ce323c1f015f", size = 1787824, upload-time = "2026-03-12T03:40:33.636Z" }, + { url = "https://files.pythonhosted.org/packages/ac/94/2424338fb2d1875e9e83eed4c8e9c67f6905ec25afd826a911aea2b02535/black-26.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:0126ae5b7c09957da2bdbd91a9ba1207453feada9e9fe51992848658c6c8e01c", size = 1445855, upload-time = "2026-03-12T03:40:35.442Z" }, + { url = "https://files.pythonhosted.org/packages/86/43/0c3338bd928afb8ee7471f1a4eec3bdbe2245ccb4a646092a222e8669840/black-26.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:92c0ec1f2cc149551a2b7b47efc32c866406b6891b0ee4625e95967c8f4acfb1", size = 1258109, upload-time = "2026-03-12T03:40:36.832Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b", size = 207542, upload-time = "2026-03-12T03:36:01.668Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/1d/4fdabeef4e231153b6ed7567602f3b68265ec4e5b76d6024cf647d43d981/charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", size = 294823, upload-time = "2026-03-15T18:51:15.755Z" }, + { url = "https://files.pythonhosted.org/packages/47/7b/20e809b89c69d37be748d98e84dce6820bf663cf19cf6b942c951a3e8f41/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", size = 198527, upload-time = "2026-03-15T18:51:17.177Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/4f8d27527d59c039dce6f7622593cdcd3d70a8504d87d09eb11e9fdc6062/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", size = 218388, upload-time = "2026-03-15T18:51:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/f6/9b/4770ccb3e491a9bacf1c46cc8b812214fe367c86a96353ccc6daf87b01ec/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", size = 214563, upload-time = "2026-03-15T18:51:20.374Z" }, + { url = "https://files.pythonhosted.org/packages/2b/58/a199d245894b12db0b957d627516c78e055adc3a0d978bc7f65ddaf7c399/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", size = 206587, upload-time = "2026-03-15T18:51:21.807Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/3def227f1ec56f5c69dfc8392b8bd63b11a18ca8178d9211d7cc5e5e4f27/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", size = 194724, upload-time = "2026-03-15T18:51:23.508Z" }, + { url = "https://files.pythonhosted.org/packages/58/ab/9318352e220c05efd31c2779a23b50969dc94b985a2efa643ed9077bfca5/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", size = 202956, upload-time = "2026-03-15T18:51:25.239Z" }, + { url = "https://files.pythonhosted.org/packages/75/13/f3550a3ac25b70f87ac98c40d3199a8503676c2f1620efbf8d42095cfc40/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", size = 201923, upload-time = "2026-03-15T18:51:26.682Z" }, + { url = "https://files.pythonhosted.org/packages/1b/db/c5c643b912740b45e8eec21de1bbab8e7fc085944d37e1e709d3dcd9d72f/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", size = 195366, upload-time = "2026-03-15T18:51:28.129Z" }, + { url = "https://files.pythonhosted.org/packages/5a/67/3b1c62744f9b2448443e0eb160d8b001c849ec3fef591e012eda6484787c/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", size = 219752, upload-time = "2026-03-15T18:51:29.556Z" }, + { url = "https://files.pythonhosted.org/packages/f6/98/32ffbaf7f0366ffb0445930b87d103f6b406bc2c271563644bde8a2b1093/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", size = 203296, upload-time = "2026-03-15T18:51:30.921Z" }, + { url = "https://files.pythonhosted.org/packages/41/12/5d308c1bbe60cabb0c5ef511574a647067e2a1f631bc8634fcafaccd8293/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", size = 215956, upload-time = "2026-03-15T18:51:32.399Z" }, + { url = "https://files.pythonhosted.org/packages/53/e9/5f85f6c5e20669dbe56b165c67b0260547dea97dba7e187938833d791687/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", size = 208652, upload-time = "2026-03-15T18:51:34.214Z" }, + { url = "https://files.pythonhosted.org/packages/f1/11/897052ea6af56df3eef3ca94edafee410ca699ca0c7b87960ad19932c55e/charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", size = 143940, upload-time = "2026-03-15T18:51:36.15Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5c/724b6b363603e419829f561c854b87ed7c7e31231a7908708ac086cdf3e2/charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", size = 154101, upload-time = "2026-03-15T18:51:37.876Z" }, + { url = "https://files.pythonhosted.org/packages/01/a5/7abf15b4c0968e47020f9ca0935fb3274deb87cb288cd187cad92e8cdffd/charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", size = 143109, upload-time = "2026-03-15T18:51:39.565Z" }, + { url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458, upload-time = "2026-03-15T18:51:41.134Z" }, + { url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277, upload-time = "2026-03-15T18:51:42.953Z" }, + { url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758, upload-time = "2026-03-15T18:51:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/d79a9a191bb75f5aa81f3aaaa387ef29ce7cb7a9e5074ba8ea095cc073c2/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", size = 215299, upload-time = "2026-03-15T18:51:45.871Z" }, + { url = "https://files.pythonhosted.org/packages/76/7e/bc8911719f7084f72fd545f647601ea3532363927f807d296a8c88a62c0d/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", size = 206811, upload-time = "2026-03-15T18:51:47.308Z" }, + { url = "https://files.pythonhosted.org/packages/e2/40/c430b969d41dda0c465aa36cc7c2c068afb67177bef50905ac371b28ccc7/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", size = 193706, upload-time = "2026-03-15T18:51:48.849Z" }, + { url = "https://files.pythonhosted.org/packages/48/15/e35e0590af254f7df984de1323640ef375df5761f615b6225ba8deb9799a/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", size = 202706, upload-time = "2026-03-15T18:51:50.257Z" }, + { url = "https://files.pythonhosted.org/packages/5e/bd/f736f7b9cc5e93a18b794a50346bb16fbfd6b37f99e8f306f7951d27c17c/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", size = 202497, upload-time = "2026-03-15T18:51:52.012Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ba/2cc9e3e7dfdf7760a6ed8da7446d22536f3d0ce114ac63dee2a5a3599e62/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", size = 193511, upload-time = "2026-03-15T18:51:53.723Z" }, + { url = "https://files.pythonhosted.org/packages/9e/cb/5be49b5f776e5613be07298c80e1b02a2d900f7a7de807230595c85a8b2e/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", size = 220133, upload-time = "2026-03-15T18:51:55.333Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/99f1b5dad345accb322c80c7821071554f791a95ee50c1c90041c157ae99/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", size = 203035, upload-time = "2026-03-15T18:51:56.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/9a/62c2cb6a531483b55dddff1a68b3d891a8b498f3ca555fbcf2978e804d9d/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", size = 216321, upload-time = "2026-03-15T18:51:58.17Z" }, + { url = "https://files.pythonhosted.org/packages/6e/79/94a010ff81e3aec7c293eb82c28f930918e517bc144c9906a060844462eb/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", size = 208973, upload-time = "2026-03-15T18:51:59.998Z" }, + { url = "https://files.pythonhosted.org/packages/2a/57/4ecff6d4ec8585342f0c71bc03efaa99cb7468f7c91a57b105bcd561cea8/charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", size = 144610, upload-time = "2026-03-15T18:52:02.213Z" }, + { url = "https://files.pythonhosted.org/packages/80/94/8434a02d9d7f168c25767c64671fead8d599744a05d6a6c877144c754246/charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", size = 154962, upload-time = "2026-03-15T18:52:03.658Z" }, + { url = "https://files.pythonhosted.org/packages/46/4c/48f2cdbfd923026503dfd67ccea45c94fd8fe988d9056b468579c66ed62b/charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", size = 143595, upload-time = "2026-03-15T18:52:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/31/93/8878be7569f87b14f1d52032946131bcb6ebbd8af3e20446bc04053dc3f1/charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", size = 314828, upload-time = "2026-03-15T18:52:06.831Z" }, + { url = "https://files.pythonhosted.org/packages/06/b6/fae511ca98aac69ecc35cde828b0a3d146325dd03d99655ad38fc2cc3293/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", size = 208138, upload-time = "2026-03-15T18:52:08.239Z" }, + { url = "https://files.pythonhosted.org/packages/54/57/64caf6e1bf07274a1e0b7c160a55ee9e8c9ec32c46846ce59b9c333f7008/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", size = 224679, upload-time = "2026-03-15T18:52:10.043Z" }, + { url = "https://files.pythonhosted.org/packages/aa/cb/9ff5a25b9273ef160861b41f6937f86fae18b0792fe0a8e75e06acb08f1d/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", size = 223475, upload-time = "2026-03-15T18:52:11.854Z" }, + { url = "https://files.pythonhosted.org/packages/fc/97/440635fc093b8d7347502a377031f9605a1039c958f3cd18dcacffb37743/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", size = 215230, upload-time = "2026-03-15T18:52:13.325Z" }, + { url = "https://files.pythonhosted.org/packages/cd/24/afff630feb571a13f07c8539fbb502d2ab494019492aaffc78ef41f1d1d0/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", size = 199045, upload-time = "2026-03-15T18:52:14.752Z" }, + { url = "https://files.pythonhosted.org/packages/e5/17/d1399ecdaf7e0498c327433e7eefdd862b41236a7e484355b8e0e5ebd64b/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", size = 211658, upload-time = "2026-03-15T18:52:16.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/38/16baa0affb957b3d880e5ac2144caf3f9d7de7bc4a91842e447fbb5e8b67/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", size = 210769, upload-time = "2026-03-15T18:52:17.782Z" }, + { url = "https://files.pythonhosted.org/packages/05/34/c531bc6ac4c21da9ddfddb3107be2287188b3ea4b53b70fc58f2a77ac8d8/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", size = 201328, upload-time = "2026-03-15T18:52:19.553Z" }, + { url = "https://files.pythonhosted.org/packages/fa/73/a5a1e9ca5f234519c1953608a03fe109c306b97fdfb25f09182babad51a7/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", size = 225302, upload-time = "2026-03-15T18:52:21.043Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f6/cd782923d112d296294dea4bcc7af5a7ae0f86ab79f8fefbda5526b6cfc0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659", size = 211127, upload-time = "2026-03-15T18:52:22.491Z" }, + { url = "https://files.pythonhosted.org/packages/0e/c5/0b6898950627af7d6103a449b22320372c24c6feda91aa24e201a478d161/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", size = 222840, upload-time = "2026-03-15T18:52:24.113Z" }, + { url = "https://files.pythonhosted.org/packages/7d/25/c4bba773bef442cbdc06111d40daa3de5050a676fa26e85090fc54dd12f0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", size = 216890, upload-time = "2026-03-15T18:52:25.541Z" }, + { url = "https://files.pythonhosted.org/packages/35/1a/05dacadb0978da72ee287b0143097db12f2e7e8d3ffc4647da07a383b0b7/charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", size = 155379, upload-time = "2026-03-15T18:52:27.05Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7a/d269d834cb3a76291651256f3b9a5945e81d0a49ab9f4a498964e83c0416/charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", size = 169043, upload-time = "2026-03-15T18:52:28.502Z" }, + { url = "https://files.pythonhosted.org/packages/23/06/28b29fba521a37a8932c6a84192175c34d49f84a6d4773fa63d05f9aff22/charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", size = 148523, upload-time = "2026-03-15T18:52:29.956Z" }, + { url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, + { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, + { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, + { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, + { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, + { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, + { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, + { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, + { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, + { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, + { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, + { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, + { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, + { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, + { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, + { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, + { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, + { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, + { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, + { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, + { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, + { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, + { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, + { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, + { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, +] + +[[package]] +name = "docutils" +version = "0.22.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, +] + +[[package]] +name = "graphviz" +version = "0.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/b3/3ac91e9be6b761a4b30d66ff165e54439dcd48b83f4e20d644867215f6ca/graphviz-0.21.tar.gz", hash = "sha256:20743e7183be82aaaa8ad6c93f8893c923bd6658a04c32ee115edb3c8a835f78", size = 200434, upload-time = "2025-06-15T09:35:05.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/4c/e0ce1ef95d4000ebc1c11801f9b944fa5910ecc15b5e351865763d8657f8/graphviz-0.21-py3-none-any.whl", hash = "sha256:54f33de9f4f911d7e84e4191749cac8cc5653f815b06738c54db9a15ab8b1e42", size = 47300, upload-time = "2025-06-15T09:35:04.433Z" }, +] + +[[package]] +name = "graphworks" +source = { editable = "." } + +[package.optional-dependencies] +all = [ + { name = "graphviz" }, + { name = "myst-parser" }, + { name = "numpy" }, + { name = "rich" }, + { name = "sphinx" }, + { name = "sphinx-autodoc-typehints" }, + { name = "sphinx-rtd-theme" }, +] +docs = [ + { name = "myst-parser" }, + { name = "sphinx" }, + { name = "sphinx-autodoc-typehints" }, + { name = "sphinx-rtd-theme" }, +] +matrix = [ + { name = "numpy" }, +] +viz = [ + { name = "graphviz" }, + { name = "rich" }, +] + +[package.dev-dependencies] +dev = [ + { name = "black" }, + { name = "import-linter" }, + { name = "isort" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, + { name = "ty" }, + { name = "xenon" }, +] + +[package.metadata] +requires-dist = [ + { name = "graphviz", marker = "extra == 'all'" }, + { name = "graphviz", marker = "extra == 'viz'" }, + { name = "myst-parser", marker = "extra == 'all'" }, + { name = "myst-parser", marker = "extra == 'docs'" }, + { name = "numpy", marker = "extra == 'all'" }, + { name = "numpy", marker = "extra == 'matrix'" }, + { name = "rich", marker = "extra == 'all'" }, + { name = "rich", marker = "extra == 'viz'" }, + { name = "sphinx", marker = "extra == 'all'" }, + { name = "sphinx", marker = "extra == 'docs'" }, + { name = "sphinx-autodoc-typehints", marker = "extra == 'all'" }, + { name = "sphinx-autodoc-typehints", marker = "extra == 'docs'" }, + { name = "sphinx-rtd-theme", marker = "extra == 'all'" }, + { name = "sphinx-rtd-theme", marker = "extra == 'docs'" }, +] +provides-extras = ["all", "docs", "matrix", "viz"] + +[package.metadata.requires-dev] +dev = [ + { name = "black" }, + { name = "import-linter" }, + { name = "isort" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, + { name = "ty" }, + { name = "xenon" }, +] + +[[package]] +name = "grimp" +version = "3.14" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/46/79764cfb61a3ac80dadae5d94fb10acdb7800e31fecf4113cf3d345e4952/grimp-3.14.tar.gz", hash = "sha256:645fbd835983901042dae4e1b24fde3a89bf7ac152f9272dd17a97e55cb4f871", size = 830882, upload-time = "2025-12-10T17:55:01.287Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/bd/d12a9c821b79ba31fc52243e564712b64140fc6d011c2bdbb483d9092a12/grimp-3.14-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:af8a625554beea84530b98cc471902155b5fc042b42dc47ec846fa3e32b0c615", size = 2178632, upload-time = "2025-12-10T17:53:44.55Z" }, + { url = "https://files.pythonhosted.org/packages/96/8c/d6620dbc245149d5a5a7a9342733556ba91a672f358259c0ab31d889b56b/grimp-3.14-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0dd1942ffb419ad342f76b0c3d3d2d7f312b264ddc578179d13ce8d5acec1167", size = 2110288, upload-time = "2025-12-10T17:53:21.662Z" }, + { url = "https://files.pythonhosted.org/packages/60/9d/ea51edc4eb295c99786040051c66466bfa235fd1def9f592057b36e03d0f/grimp-3.14-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:537f784ce9b4acf8657f0b9714ab69a6c72ffa752eccc38a5a85506103b1a194", size = 2282197, upload-time = "2025-12-10T17:52:09.304Z" }, + { url = "https://files.pythonhosted.org/packages/28/6e/7db27818ced6a797f976ca55d981a3af5c12aec6aeda12d63965847cd028/grimp-3.14-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:78ab18c08770aa005bef67b873bc3946d33f65727e9f3e508155093db5fa57d6", size = 2235720, upload-time = "2025-12-10T17:52:21.806Z" }, + { url = "https://files.pythonhosted.org/packages/37/26/0e3bbae4826bd6eaabf404738400414071e73ddb1e65bf487dcce17858c4/grimp-3.14-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:28ca58728c27e7292c99f964e6ece9295c2f9cfdefc37c18dea0679c783ffb6f", size = 2393023, upload-time = "2025-12-10T17:53:04.149Z" }, + { url = "https://files.pythonhosted.org/packages/49/f2/7da91db5703da34c7ef4c7cddcbb1a8fc30cd85fe54756eba942c6fb27d8/grimp-3.14-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9b5577de29c6c5ae6e08d4ca0ac361b45dba323aa145796e6b320a6ea35414b7", size = 2571108, upload-time = "2025-12-10T17:52:36.523Z" }, + { url = "https://files.pythonhosted.org/packages/25/5e/4d6278f18032c7208696edf8be24a4b5f7fad80acc20ffca737344bcecb5/grimp-3.14-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d7d1f9f42306f455abcec34db877e4887ff15f2777a43491f7ccbd6936c449b", size = 2358531, upload-time = "2025-12-10T17:52:50.521Z" }, + { url = "https://files.pythonhosted.org/packages/24/fb/231c32493161ac82f27af6a56965daefa0ec6030fdaf5b948ddd5d68d000/grimp-3.14-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39bd5c9b7cef59ee30a05535e9cb4cbf45a3c503f22edce34d0aa79362a311a9", size = 2308831, upload-time = "2025-12-10T17:53:12.587Z" }, + { url = "https://files.pythonhosted.org/packages/27/70/f6db325bf5efbbebc9c85cad0af865e821a12a0ba58ee309e938cbd5fedf/grimp-3.14-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7fec3116b4f780a1bc54176b19e6b9f2e36e2ef3164b8fc840660566af35df88", size = 2462138, upload-time = "2025-12-10T17:53:52.403Z" }, + { url = "https://files.pythonhosted.org/packages/41/2e/cc3fe29cf07f70364018086840c228a190539ab8105147e34588db590792/grimp-3.14-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:0233a35a5bbb23688d63e1736b54415fa9994ace8dfeb7de8514ed9dee212968", size = 2501393, upload-time = "2025-12-10T17:54:22.486Z" }, + { url = "https://files.pythonhosted.org/packages/e5/eb/54cada9a726455148da23f64577b5cd164164d23a6449e3fa14551157356/grimp-3.14-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e46b2fef0f1da7e7e2f8129eb93c7e79db716ff7810140a22ce5504e10ed86df", size = 2504514, upload-time = "2025-12-10T17:54:36.34Z" }, + { url = "https://files.pythonhosted.org/packages/e8/c7/e6afe4f0652df07e8762f61899d1202b73c22c559c804d0a09e5aab2ff17/grimp-3.14-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3e6d9b50623ee1c3d2a1927ec3f5d408995ea1f92f3e91ed996c908bb40e856f", size = 2514018, upload-time = "2025-12-10T17:54:50.76Z" }, + { url = "https://files.pythonhosted.org/packages/75/13/2b8550acc1f010301f02c4fe9664810929fd9277cd032ab608b8534a96fb/grimp-3.14-cp313-cp313-win32.whl", hash = "sha256:fd57c56f5833c99320ec77e8ba5508d56f6fb48ec8032a942f7931cc6ebb80ce", size = 1874922, upload-time = "2025-12-10T17:55:15.239Z" }, + { url = "https://files.pythonhosted.org/packages/46/c7/bc9db5a54ef22972cd17d15ad80a8fee274a471bd3f02300405702d29ea5/grimp-3.14-cp313-cp313-win_amd64.whl", hash = "sha256:173307cf881a126fe5120b7bbec7d54384002e3c83dcd8c4df6ce7f0fee07c53", size = 2013705, upload-time = "2025-12-10T17:55:07.488Z" }, + { url = "https://files.pythonhosted.org/packages/80/7e/02710bf5e50997168c84ac622b10dd41d35515efd0c67549945ad20996a0/grimp-3.14-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ebe29f8f13fbd7c314908ed535183a36e6db71839355b04869b27f23c58fa082", size = 2281868, upload-time = "2025-12-10T17:52:10.589Z" }, + { url = "https://files.pythonhosted.org/packages/15/88/2e440c6762cc78bd50582e1b092357d2255f0852ccc6218d8db25170ab31/grimp-3.14-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:073d285b00100153fd86064c7726bb1b6d610df1356d33bb42d3fd8809cb6e72", size = 2230917, upload-time = "2025-12-10T17:52:23.212Z" }, + { url = "https://files.pythonhosted.org/packages/a0/bb/2e7dce129b88f07fc525fe5c97f28cfb7ed7b62c59386d39226b4d08969c/grimp-3.14-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f6d6efc37e1728bbfcd881b89467be5f7b046292597b3ebe5f8e44e89ea8b6cb", size = 2571371, upload-time = "2025-12-10T17:52:37.84Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2b/8f1be8294af60c953687db7dec25525d87ed9c2aa26b66dcbe5244abaca2/grimp-3.14-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5337d65d81960b712574c41e85b480d4480bbb5c6f547c94e634f6c60d730889", size = 2356980, upload-time = "2025-12-10T17:52:52.004Z" }, + { url = "https://files.pythonhosted.org/packages/35/ca/ead91e04b3ddd4774ae74601860ea0f0f21bcf6b970b6769ba9571eb2904/grimp-3.14-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:84a7fea63e352b325daa89b0b7297db411b7f0036f8d710c32f8e5090e1fc3ca", size = 2461540, upload-time = "2025-12-10T17:53:53.749Z" }, + { url = "https://files.pythonhosted.org/packages/94/aa/f8a085ff73c37d6e6a37de9f58799a3fea9e16badf267aaef6f11c9a53a3/grimp-3.14-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:d0b19a3726377165fe1f7184a8af317734d80d32b371b6c5578747867ab53c0b", size = 2497925, upload-time = "2025-12-10T17:54:23.842Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a3/db3c2d6df07fe74faf5a28fcf3b44fad2831d323ba4a3c2ff66b77a6520c/grimp-3.14-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:9caa4991f530750f88474a3f5ecf6ef9f0d064034889d92db00cfb4ecb78aa24", size = 2501794, upload-time = "2025-12-10T17:54:38.05Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/095f4e3765e7b60425a41e9fbd2b167f8b0acb957cc88c387f631778a09d/grimp-3.14-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1876efc119b99332a5cc2b08a6bdaada2f0ad94b596f0372a497e2aa8bda4d94", size = 2515203, upload-time = "2025-12-10T17:54:52.555Z" }, + { url = "https://files.pythonhosted.org/packages/c6/5f/ee02a3a1237282d324f596a50923bf9d2cb1b1230ef2fef49fb4d3563c2c/grimp-3.14-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3ccf03e65864d6bc7bf1c003c319f5330a7627b3677f31143f11691a088464c2", size = 2177150, upload-time = "2025-12-10T17:53:46.145Z" }, + { url = "https://files.pythonhosted.org/packages/f2/64/2a92889e5fc78e8ef5c548e6a5c6fed78b817eeb0253aca586c28108393a/grimp-3.14-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9ecd58fa58a270e7523f8bec9e6452f4fdb9c21e4cd370640829f1e43fa87a69", size = 2109280, upload-time = "2025-12-10T17:53:23.345Z" }, + { url = "https://files.pythonhosted.org/packages/69/02/5d0b9ab54821e7fbdeb02f3919fa2cb8b9f0c3869fa6e4b969a5766f0ffa/grimp-3.14-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d75d1f8f7944978b39b08d870315174f1ffcd5123be6ccff8ce90467ace648a", size = 2283367, upload-time = "2025-12-10T17:52:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/c2/96/a77c40c92faf7500f42ac019ab8de108b04ffe3db8ec8d6f90416d2322ce/grimp-3.14-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6f70bbb1dd6055d08d29e39a78a11c4118c1778b39d17cd8271e18e213524ca7", size = 2237125, upload-time = "2025-12-10T17:52:24.606Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5e/3e1483721c83057bff921cf454dd5ff3e661ae1d2e63150a380382d116c2/grimp-3.14-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f21b7c003626c902669dc26ede83a91220cf0a81b51b27128370998c2f247b4", size = 2391735, upload-time = "2025-12-10T17:53:05.619Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cb/25fad4a174fe672d42f3e5616761a8120a3b03c8e9e2ae3f31159561968a/grimp-3.14-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80d9f056415c936b45561310296374c4319b5df0003da802c84d2830a103792a", size = 2571388, upload-time = "2025-12-10T17:52:39.337Z" }, + { url = "https://files.pythonhosted.org/packages/29/7e/456df7f6a765ce3f160eb32a0f64ed0c1c3cd39b518555dde02087f9b6e4/grimp-3.14-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0332963cd63a45863775d4237e59dedf95455e0a1ea50c356be23100c5fc1d7c", size = 2359637, upload-time = "2025-12-10T17:52:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/7c/98/3e5005ef21a4e2243f0da489aba86aaaff0bc11d5240d67113482cba88e0/grimp-3.14-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f4144350d074f2058fe7c89230a26b34296b161f085b0471a692cb2fe27036f", size = 2308335, upload-time = "2025-12-10T17:53:13.893Z" }, + { url = "https://files.pythonhosted.org/packages/8a/03/4e055f756946d6f71ab7e9d1f8536a9e476777093dd7a050f40412d1a2b1/grimp-3.14-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e148e67975e92f90a8435b1b4c02180b9a3f3d725b7a188ba63793f1b1e445a0", size = 2463680, upload-time = "2025-12-10T17:53:55.507Z" }, + { url = "https://files.pythonhosted.org/packages/26/b9/3c76b7c2e1587e4303a6eff6587c2117c3a7efe1b100cd13d8a4a5613572/grimp-3.14-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1093f7770cb5f3ca6f99fb152f9c949381cc0b078dfdfe598c8ab99abaccda3b", size = 2502808, upload-time = "2025-12-10T17:54:25.383Z" }, + { url = "https://files.pythonhosted.org/packages/20/80/ada10b85ad3125ebedea10256d9c568b6bf28339d2f79d2d196a7b94f633/grimp-3.14-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a213f45ec69e9c2b28ffd3ba5ab12cc9859da17083ba4dc39317f2083b618111", size = 2504013, upload-time = "2025-12-10T17:54:39.762Z" }, + { url = "https://files.pythonhosted.org/packages/05/45/7c369f749d50b0ceac23cd6874ca4695cc1359a96091c7010301e5c8b619/grimp-3.14-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f003ac3f226d2437a49af0b6036f26edba57f8a32d329275dbde1b2b2a00a56", size = 2515043, upload-time = "2025-12-10T17:54:54.437Z" }, + { url = "https://files.pythonhosted.org/packages/5c/32/85135fe83826ce11ae56a340d32a1391b91eed94d25ce7bc318019f735de/grimp-3.14-cp314-cp314-win32.whl", hash = "sha256:eec81be65a18f4b2af014b1e97296cc9ee20d1115529bf70dd7e06f457eac30b", size = 1877509, upload-time = "2025-12-10T17:55:17.062Z" }, + { url = "https://files.pythonhosted.org/packages/db/61/e4a2234edecb3bb3cff8963bc4ec5cc482a9e3c54f8df0946d7d90003830/grimp-3.14-cp314-cp314-win_amd64.whl", hash = "sha256:cd3bab6164f1d5e313678f0ab4bf45955afe7f5bdb0f2f481014aa9cca7e81ba", size = 2014364, upload-time = "2025-12-10T17:55:08.896Z" }, + { url = "https://files.pythonhosted.org/packages/16/be/3d304443fbf1df4d60c09668846d0c8a605c6c95646226e41d8f5c3254da/grimp-3.14-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b1df33de479be4d620f69633d1876858a8e64a79c07907d47cf3aaf896af057", size = 2281385, upload-time = "2025-12-10T17:52:13.668Z" }, + { url = "https://files.pythonhosted.org/packages/fe/13/493e2648dbb83b3fc517ee675e464beb0154551d726053c7982a3138c6a8/grimp-3.14-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07096d4402e9d5a2c59c402ea3d601f4b7f99025f5e32f077468846fc8d3821b", size = 2231470, upload-time = "2025-12-10T17:52:26.104Z" }, + { url = "https://files.pythonhosted.org/packages/80/84/e772b302385a6b7ec752c88f84ffe35c33d14076245ae27a635aed9c63a2/grimp-3.14-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:712bc28f46b354316af50c469c77953ba3d6cb4166a62b8fb086436a8b05d301", size = 2571579, upload-time = "2025-12-10T17:52:40.889Z" }, + { url = "https://files.pythonhosted.org/packages/69/92/5b23aa7b89c5f4f2cfa636cbeaf33e784378a6b0a823d77a3448670dfacc/grimp-3.14-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:abe2bbef1cf8e27df636c02f60184319f138dee4f3a949405c21a4b491980397", size = 2356545, upload-time = "2025-12-10T17:52:54.887Z" }, + { url = "https://files.pythonhosted.org/packages/15/af/bcf2116f4b1c3939ab35f9cdddd9ca59e953e57e9a0ac0c143deaf9f29cc/grimp-3.14-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2f9ae3fabb7a7a8468ddc96acc84ecabd84f168e7ca508ee94d8f32ea9bd5de2", size = 2461022, upload-time = "2025-12-10T17:53:56.923Z" }, + { url = "https://files.pythonhosted.org/packages/81/ce/1a076dce6bc22bca4b9ad5d1bbcd7e1023dcf7bf20ea9404c6462d78f049/grimp-3.14-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:efaf11ea73f7f12d847c54a5d6edcbe919e0369dce2d1aabae6c50792e16f816", size = 2498256, upload-time = "2025-12-10T17:54:27.214Z" }, + { url = "https://files.pythonhosted.org/packages/45/ea/ac735bed202c1c5c019e611b92d3861779e0cfbe2d20fdb0dec94266d248/grimp-3.14-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e089c9ab8aa755ff5af88c55891727783b4eb6b228e7bdf278e17209d954aa1e", size = 2502056, upload-time = "2025-12-10T17:54:41.537Z" }, + { url = "https://files.pythonhosted.org/packages/80/8f/774ce522de6a7e70fbeceeaeb6fbe502f5dfb8365728fb3bb4cb23463da8/grimp-3.14-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a424ad14d5deb56721ac24ab939747f72ab3d378d42e7d1f038317d33b052b77", size = 2515157, upload-time = "2025-12-10T17:54:55.874Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "imagesize" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/e6/7bf14eeb8f8b7251141944835abd42eb20a658d89084b7e1f3e5fe394090/imagesize-2.0.0.tar.gz", hash = "sha256:8e8358c4a05c304f1fccf7ff96f036e7243a189e9e42e90851993c558cfe9ee3", size = 1773045, upload-time = "2026-03-03T14:18:29.941Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96", size = 9441, upload-time = "2026-03-03T14:18:27.892Z" }, +] + +[[package]] +name = "import-linter" +version = "2.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "grimp" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/66/55b697a17bb15c6cb88d97d73716813f5427281527b90f02cc0a600abc6e/import_linter-2.11.tar.gz", hash = "sha256:5abc3394797a54f9bae315e7242dc98715ba485f840ac38c6d3192c370d0085e", size = 1153682, upload-time = "2026-03-06T12:11:38.198Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/aa/2ed2c89543632ded7196e0d93dcc6c7fe87769e88391a648c4a298ea864a/import_linter-2.11-py3-none-any.whl", hash = "sha256:3dc54cae933bae3430358c30989762b721c77aa99d424f56a08265be0eeaa465", size = 637315, upload-time = "2026-03-06T12:11:36.599Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "isort" +version = "8.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/7c/ec4ab396d31b3b395e2e999c8f46dec78c5e29209fac49d1f4dace04041d/isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d", size = 769592, upload-time = "2026-02-28T10:08:20.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "mando" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/35/24/cd70d5ae6d35962be752feccb7dca80b5e0c2d450e995b16abd6275f3296/mando-0.7.1.tar.gz", hash = "sha256:18baa999b4b613faefb00eac4efadcf14f510b59b924b66e08289aa1de8c3500", size = 37868, upload-time = "2022-02-24T08:12:27.316Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/f0/834e479e47e499b6478e807fb57b31cc2db696c4db30557bb6f5aea4a90b/mando-0.7.1-py2.py3-none-any.whl", hash = "sha256:26ef1d70928b6057ee3ca12583d73c63e05c49de8972d620c278a7b206581a8a", size = 28149, upload-time = "2022-02-24T08:12:25.24Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205, upload-time = "2025-08-11T07:25:47.597Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "myst-parser" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "jinja2" }, + { name = "markdown-it-py" }, + { name = "mdit-py-plugins" }, + { name = "pyyaml" }, + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/fa/7b45eef11b7971f0beb29d27b7bfe0d747d063aa29e170d9edd004733c8a/myst_parser-5.0.0.tar.gz", hash = "sha256:f6f231452c56e8baa662cc352c548158f6a16fcbd6e3800fc594978002b94f3a", size = 98535, upload-time = "2026-01-15T09:08:18.036Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/ac/686789b9145413f1a61878c407210e41bfdb097976864e0913078b24098c/myst_parser-5.0.0-py3-none-any.whl", hash = "sha256:ab31e516024918296e169139072b81592336f2fef55b8986aa31c9f04b5f7211", size = 84533, upload-time = "2026-01-15T09:08:16.788Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/10/8b/c265f4823726ab832de836cdd184d0986dcf94480f81e8739692a7ac7af2/numpy-2.4.3.tar.gz", hash = "sha256:483a201202b73495f00dbc83796c6ae63137a9bdade074f7648b3e32613412dd", size = 20727743, upload-time = "2026-03-09T07:58:53.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/d0/1fe47a98ce0df229238b77611340aff92d52691bcbc10583303181abf7fc/numpy-2.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b346845443716c8e542d54112966383b448f4a3ba5c66409771b8c0889485dd3", size = 16665297, upload-time = "2026-03-09T07:56:52.296Z" }, + { url = "https://files.pythonhosted.org/packages/27/d9/4e7c3f0e68dfa91f21c6fb6cf839bc829ec920688b1ce7ec722b1a6202fb/numpy-2.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2629289168f4897a3c4e23dc98d6f1731f0fc0fe52fb9db19f974041e4cc12b9", size = 14691853, upload-time = "2026-03-09T07:56:54.992Z" }, + { url = "https://files.pythonhosted.org/packages/3a/66/bd096b13a87549683812b53ab211e6d413497f84e794fb3c39191948da97/numpy-2.4.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:bb2e3cf95854233799013779216c57e153c1ee67a0bf92138acca0e429aefaee", size = 5198435, upload-time = "2026-03-09T07:56:57.184Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/687722910b5a5601de2135c891108f51dfc873d8e43c8ed9f4ebb440b4a2/numpy-2.4.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:7f3408ff897f8ab07a07fbe2823d7aee6ff644c097cc1f90382511fe982f647f", size = 6546347, upload-time = "2026-03-09T07:56:59.531Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ec/7971c4e98d86c564750393fab8d7d83d0a9432a9d78bb8a163a6dc59967a/numpy-2.4.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:decb0eb8a53c3b009b0962378065589685d66b23467ef5dac16cbe818afde27f", size = 15664626, upload-time = "2026-03-09T07:57:01.385Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/7daecbea84ec935b7fc732e18f532073064a3816f0932a40a17f3349185f/numpy-2.4.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5f51900414fc9204a0e0da158ba2ac52b75656e7dce7e77fb9f84bfa343b4cc", size = 16608916, upload-time = "2026-03-09T07:57:04.008Z" }, + { url = "https://files.pythonhosted.org/packages/df/58/2a2b4a817ffd7472dca4421d9f0776898b364154e30c95f42195041dc03b/numpy-2.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6bd06731541f89cdc01b261ba2c9e037f1543df7472517836b78dfb15bd6e476", size = 17015824, upload-time = "2026-03-09T07:57:06.347Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ca/627a828d44e78a418c55f82dd4caea8ea4a8ef24e5144d9e71016e52fb40/numpy-2.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22654fe6be0e5206f553a9250762c653d3698e46686eee53b399ab90da59bd92", size = 18334581, upload-time = "2026-03-09T07:57:09.114Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c0/76f93962fc79955fcba30a429b62304332345f22d4daec1cb33653425643/numpy-2.4.3-cp313-cp313-win32.whl", hash = "sha256:d71e379452a2f670ccb689ec801b1218cd3983e253105d6e83780967e899d687", size = 5958618, upload-time = "2026-03-09T07:57:11.432Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3c/88af0040119209b9b5cb59485fa48b76f372c73068dbf9254784b975ac53/numpy-2.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:0a60e17a14d640f49146cb38e3f105f571318db7826d9b6fef7e4dce758faecd", size = 12312824, upload-time = "2026-03-09T07:57:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/58/ce/3d07743aced3d173f877c3ef6a454c2174ba42b584ab0b7e6d99374f51ed/numpy-2.4.3-cp313-cp313-win_arm64.whl", hash = "sha256:c9619741e9da2059cd9c3f206110b97583c7152c1dc9f8aafd4beb450ac1c89d", size = 10221218, upload-time = "2026-03-09T07:57:16.183Z" }, + { url = "https://files.pythonhosted.org/packages/62/09/d96b02a91d09e9d97862f4fc8bfebf5400f567d8eb1fe4b0cc4795679c15/numpy-2.4.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7aa4e54f6469300ebca1d9eb80acd5253cdfa36f2c03d79a35883687da430875", size = 14819570, upload-time = "2026-03-09T07:57:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/b5/ca/0b1aba3905fdfa3373d523b2b15b19029f4f3031c87f4066bd9d20ef6c6b/numpy-2.4.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d1b90d840b25874cf5cd20c219af10bac3667db3876d9a495609273ebe679070", size = 5326113, upload-time = "2026-03-09T07:57:21.052Z" }, + { url = "https://files.pythonhosted.org/packages/c0/63/406e0fd32fcaeb94180fd6a4c41e55736d676c54346b7efbce548b94a914/numpy-2.4.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:a749547700de0a20a6718293396ec237bb38218049cfce788e08fcb716e8cf73", size = 6646370, upload-time = "2026-03-09T07:57:22.804Z" }, + { url = "https://files.pythonhosted.org/packages/b6/d0/10f7dc157d4b37af92720a196be6f54f889e90dcd30dce9dc657ed92c257/numpy-2.4.3-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94f3c4a151a2e529adf49c1d54f0f57ff8f9b233ee4d44af623a81553ab86368", size = 15723499, upload-time = "2026-03-09T07:57:24.693Z" }, + { url = "https://files.pythonhosted.org/packages/66/f1/d1c2bf1161396629701bc284d958dc1efa3a5a542aab83cf11ee6eb4cba5/numpy-2.4.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22c31dc07025123aedf7f2db9e91783df13f1776dc52c6b22c620870dc0fab22", size = 16657164, upload-time = "2026-03-09T07:57:27.676Z" }, + { url = "https://files.pythonhosted.org/packages/1a/be/cca19230b740af199ac47331a21c71e7a3d0ba59661350483c1600d28c37/numpy-2.4.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:148d59127ac95979d6f07e4d460f934ebdd6eed641db9c0db6c73026f2b2101a", size = 17081544, upload-time = "2026-03-09T07:57:30.664Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c5/9602b0cbb703a0936fb40f8a95407e8171935b15846de2f0776e08af04c7/numpy-2.4.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a97cbf7e905c435865c2d939af3d93f99d18eaaa3cabe4256f4304fb51604349", size = 18380290, upload-time = "2026-03-09T07:57:33.763Z" }, + { url = "https://files.pythonhosted.org/packages/ed/81/9f24708953cd30be9ee36ec4778f4b112b45165812f2ada4cc5ea1c1f254/numpy-2.4.3-cp313-cp313t-win32.whl", hash = "sha256:be3b8487d725a77acccc9924f65fd8bce9af7fac8c9820df1049424a2115af6c", size = 6082814, upload-time = "2026-03-09T07:57:36.491Z" }, + { url = "https://files.pythonhosted.org/packages/e2/9e/52f6eaa13e1a799f0ab79066c17f7016a4a8ae0c1aefa58c82b4dab690b4/numpy-2.4.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1ec84fd7c8e652b0f4aaaf2e6e9cc8eaa9b1b80a537e06b2e3a2fb176eedcb26", size = 12452673, upload-time = "2026-03-09T07:57:38.281Z" }, + { url = "https://files.pythonhosted.org/packages/c4/04/b8cece6ead0b30c9fbd99bb835ad7ea0112ac5f39f069788c5558e3b1ab2/numpy-2.4.3-cp313-cp313t-win_arm64.whl", hash = "sha256:120df8c0a81ebbf5b9020c91439fccd85f5e018a927a39f624845be194a2be02", size = 10290907, upload-time = "2026-03-09T07:57:40.747Z" }, + { url = "https://files.pythonhosted.org/packages/70/ae/3936f79adebf8caf81bd7a599b90a561334a658be4dcc7b6329ebf4ee8de/numpy-2.4.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5884ce5c7acfae1e4e1b6fde43797d10aa506074d25b531b4f54bde33c0c31d4", size = 16664563, upload-time = "2026-03-09T07:57:43.817Z" }, + { url = "https://files.pythonhosted.org/packages/9b/62/760f2b55866b496bb1fa7da2a6db076bef908110e568b02fcfc1422e2a3a/numpy-2.4.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:297837823f5bc572c5f9379b0c9f3a3365f08492cbdc33bcc3af174372ebb168", size = 14702161, upload-time = "2026-03-09T07:57:46.169Z" }, + { url = "https://files.pythonhosted.org/packages/32/af/a7a39464e2c0a21526fb4fb76e346fb172ebc92f6d1c7a07c2c139cc17b1/numpy-2.4.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:a111698b4a3f8dcbe54c64a7708f049355abd603e619013c346553c1fd4ca90b", size = 5208738, upload-time = "2026-03-09T07:57:48.506Z" }, + { url = "https://files.pythonhosted.org/packages/29/8c/2a0cf86a59558fa078d83805589c2de490f29ed4fb336c14313a161d358a/numpy-2.4.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:4bd4741a6a676770e0e97fe9ab2e51de01183df3dcbcec591d26d331a40de950", size = 6543618, upload-time = "2026-03-09T07:57:50.591Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b8/612ce010c0728b1c363fa4ea3aa4c22fe1c5da1de008486f8c2f5cb92fae/numpy-2.4.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54f29b877279d51e210e0c80709ee14ccbbad647810e8f3d375561c45ef613dd", size = 15680676, upload-time = "2026-03-09T07:57:52.34Z" }, + { url = "https://files.pythonhosted.org/packages/a9/7e/4f120ecc54ba26ddf3dc348eeb9eb063f421de65c05fc961941798feea18/numpy-2.4.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:679f2a834bae9020f81534671c56fd0cc76dd7e5182f57131478e23d0dc59e24", size = 16613492, upload-time = "2026-03-09T07:57:54.91Z" }, + { url = "https://files.pythonhosted.org/packages/2c/86/1b6020db73be330c4b45d5c6ee4295d59cfeef0e3ea323959d053e5a6909/numpy-2.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d84f0f881cb2225c2dfd7f78a10a5645d487a496c6668d6cc39f0f114164f3d0", size = 17031789, upload-time = "2026-03-09T07:57:57.641Z" }, + { url = "https://files.pythonhosted.org/packages/07/3a/3b90463bf41ebc21d1b7e06079f03070334374208c0f9a1f05e4ae8455e7/numpy-2.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d213c7e6e8d211888cc359bab7199670a00f5b82c0978b9d1c75baf1eddbeac0", size = 18339941, upload-time = "2026-03-09T07:58:00.577Z" }, + { url = "https://files.pythonhosted.org/packages/a8/74/6d736c4cd962259fd8bae9be27363eb4883a2f9069763747347544c2a487/numpy-2.4.3-cp314-cp314-win32.whl", hash = "sha256:52077feedeff7c76ed7c9f1a0428558e50825347b7545bbb8523da2cd55c547a", size = 6007503, upload-time = "2026-03-09T07:58:03.331Z" }, + { url = "https://files.pythonhosted.org/packages/48/39/c56ef87af669364356bb011922ef0734fc49dad51964568634c72a009488/numpy-2.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:0448e7f9caefb34b4b7dd2b77f21e8906e5d6f0365ad525f9f4f530b13df2afc", size = 12444915, upload-time = "2026-03-09T07:58:06.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1f/ab8528e38d295fd349310807496fabb7cf9fe2e1f70b97bc20a483ea9d4a/numpy-2.4.3-cp314-cp314-win_arm64.whl", hash = "sha256:b44fd60341c4d9783039598efadd03617fa28d041fc37d22b62d08f2027fa0e7", size = 10494875, upload-time = "2026-03-09T07:58:08.734Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ef/b7c35e4d5ef141b836658ab21a66d1a573e15b335b1d111d31f26c8ef80f/numpy-2.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0a195f4216be9305a73c0e91c9b026a35f2161237cf1c6de9b681637772ea657", size = 14822225, upload-time = "2026-03-09T07:58:11.034Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8d/7730fa9278cf6648639946cc816e7cc89f0d891602584697923375f801ed/numpy-2.4.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:cd32fbacb9fd1bf041bf8e89e4576b6f00b895f06d00914820ae06a616bdfef7", size = 5328769, upload-time = "2026-03-09T07:58:13.67Z" }, + { url = "https://files.pythonhosted.org/packages/47/01/d2a137317c958b074d338807c1b6a383406cdf8b8e53b075d804cc3d211d/numpy-2.4.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:2e03c05abaee1f672e9d67bc858f300b5ccba1c21397211e8d77d98350972093", size = 6649461, upload-time = "2026-03-09T07:58:15.912Z" }, + { url = "https://files.pythonhosted.org/packages/5c/34/812ce12bc0f00272a4b0ec0d713cd237cb390666eb6206323d1cc9cedbb2/numpy-2.4.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d1ce23cce91fcea443320a9d0ece9b9305d4368875bab09538f7a5b4131938a", size = 15725809, upload-time = "2026-03-09T07:58:17.787Z" }, + { url = "https://files.pythonhosted.org/packages/25/c0/2aed473a4823e905e765fee3dc2cbf504bd3e68ccb1150fbdabd5c39f527/numpy-2.4.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c59020932feb24ed49ffd03704fbab89f22aa9c0d4b180ff45542fe8918f5611", size = 16655242, upload-time = "2026-03-09T07:58:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/f2/c8/7e052b2fc87aa0e86de23f20e2c42bd261c624748aa8efd2c78f7bb8d8c6/numpy-2.4.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9684823a78a6cd6ad7511fc5e25b07947d1d5b5e2812c93fe99d7d4195130720", size = 17080660, upload-time = "2026-03-09T07:58:23.067Z" }, + { url = "https://files.pythonhosted.org/packages/f3/3d/0876746044db2adcb11549f214d104f2e1be00f07a67edbb4e2812094847/numpy-2.4.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0200b25c687033316fb39f0ff4e3e690e8957a2c3c8d22499891ec58c37a3eb5", size = 18380384, upload-time = "2026-03-09T07:58:25.839Z" }, + { url = "https://files.pythonhosted.org/packages/07/12/8160bea39da3335737b10308df4f484235fd297f556745f13092aa039d3b/numpy-2.4.3-cp314-cp314t-win32.whl", hash = "sha256:5e10da9e93247e554bb1d22f8edc51847ddd7dde52d85ce31024c1b4312bfba0", size = 6154547, upload-time = "2026-03-09T07:58:28.289Z" }, + { url = "https://files.pythonhosted.org/packages/42/f3/76534f61f80d74cc9cdf2e570d3d4eeb92c2280a27c39b0aaf471eda7b48/numpy-2.4.3-cp314-cp314t-win_amd64.whl", hash = "sha256:45f003dbdffb997a03da2d1d0cb41fbd24a87507fb41605c0420a3db5bd4667b", size = 12633645, upload-time = "2026-03-09T07:58:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/1f/b6/7c0d4334c15983cec7f92a69e8ce9b1e6f31857e5ee3a413ac424e6bd63d/numpy-2.4.3-cp314-cp314t-win_arm64.whl", hash = "sha256:4d382735cecd7bcf090172489a525cd7d4087bc331f7df9f60ddc9a296cf208e", size = 10565454, upload-time = "2026-03-09T07:58:33.031Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pathspec" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.9.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "pytokens" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" }, + { url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" }, + { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" }, + { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" }, + { url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" }, + { url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" }, + { url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" }, + { url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" }, + { url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" }, + { url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "radon" +version = "6.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama" }, + { name = "mando" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/6d/98e61600febf6bd929cf04154537c39dc577ce414bafbfc24a286c4fa76d/radon-6.0.1.tar.gz", hash = "sha256:d1ac0053943a893878940fedc8b19ace70386fc9c9bf0a09229a44125ebf45b5", size = 1874992, upload-time = "2023-03-26T06:24:38.868Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/f7/d00d9b4a0313a6be3a3e0818e6375e15da6d7076f4ae47d1324e7ca986a1/radon-6.0.1-py2.py3-none-any.whl", hash = "sha256:632cc032364a6f8bb1010a2f6a12d0f14bc7e5ede76585ef29dc0cecf4cd8859", size = 52784, upload-time = "2023-03-26T06:24:33.949Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "rich" +version = "14.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, +] + +[[package]] +name = "roman-numerals" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/41dc953bbeb056c17d5f7a519f50fdf010bd0553be2d630bc69d1e022703/roman_numerals-4.1.0.tar.gz", hash = "sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2", size = 9077, upload-time = "2025-12-17T18:25:34.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/22/9e4f66ee588588dc6c9af6a994e12d26e19efbe874d1a909d09a6dac7a59/ruff-0.15.7.tar.gz", hash = "sha256:04f1ae61fc20fe0b148617c324d9d009b5f63412c0b16474f3d5f1a1a665f7ac", size = 4601277, upload-time = "2026-03-19T16:26:22.605Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/2f/0b08ced94412af091807b6119ca03755d651d3d93a242682bf020189db94/ruff-0.15.7-py3-none-linux_armv6l.whl", hash = "sha256:a81cc5b6910fb7dfc7c32d20652e50fa05963f6e13ead3c5915c41ac5d16668e", size = 10489037, upload-time = "2026-03-19T16:26:32.47Z" }, + { url = "https://files.pythonhosted.org/packages/91/4a/82e0fa632e5c8b1eba5ee86ecd929e8ff327bbdbfb3c6ac5d81631bef605/ruff-0.15.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:722d165bd52403f3bdabc0ce9e41fc47070ac56d7a91b4e0d097b516a53a3477", size = 10955433, upload-time = "2026-03-19T16:27:00.205Z" }, + { url = "https://files.pythonhosted.org/packages/ab/10/12586735d0ff42526ad78c049bf51d7428618c8b5c467e72508c694119df/ruff-0.15.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fbc2448094262552146cbe1b9643a92f66559d3761f1ad0656d4991491af49e", size = 10269302, upload-time = "2026-03-19T16:26:26.183Z" }, + { url = "https://files.pythonhosted.org/packages/eb/5d/32b5c44ccf149a26623671df49cbfbd0a0ae511ff3df9d9d2426966a8d57/ruff-0.15.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b39329b60eba44156d138275323cc726bbfbddcec3063da57caa8a8b1d50adf", size = 10607625, upload-time = "2026-03-19T16:27:03.263Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f1/f0001cabe86173aaacb6eb9bb734aa0605f9a6aa6fa7d43cb49cbc4af9c9/ruff-0.15.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87768c151808505f2bfc93ae44e5f9e7c8518943e5074f76ac21558ef5627c85", size = 10324743, upload-time = "2026-03-19T16:27:09.791Z" }, + { url = "https://files.pythonhosted.org/packages/7a/87/b8a8f3d56b8d848008559e7c9d8bf367934d5367f6d932ba779456e2f73b/ruff-0.15.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb0511670002c6c529ec66c0e30641c976c8963de26a113f3a30456b702468b0", size = 11138536, upload-time = "2026-03-19T16:27:06.101Z" }, + { url = "https://files.pythonhosted.org/packages/e4/f2/4fd0d05aab0c5934b2e1464784f85ba2eab9d54bffc53fb5430d1ed8b829/ruff-0.15.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0d19644f801849229db8345180a71bee5407b429dd217f853ec515e968a6912", size = 11994292, upload-time = "2026-03-19T16:26:48.718Z" }, + { url = "https://files.pythonhosted.org/packages/64/22/fc4483871e767e5e95d1622ad83dad5ebb830f762ed0420fde7dfa9d9b08/ruff-0.15.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4806d8e09ef5e84eb19ba833d0442f7e300b23fe3f0981cae159a248a10f0036", size = 11398981, upload-time = "2026-03-19T16:26:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce0896488562f09a27b9c91b1f58a097457143931f3c4d519690dea54e624c5", size = 11242422, upload-time = "2026-03-19T16:26:29.277Z" }, + { url = "https://files.pythonhosted.org/packages/5d/3a/a7060f145bfdcce4c987ea27788b30c60e2c81d6e9a65157ca8afe646328/ruff-0.15.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1852ce241d2bc89e5dc823e03cff4ce73d816b5c6cdadd27dbfe7b03217d2a12", size = 11232158, upload-time = "2026-03-19T16:26:42.321Z" }, + { url = "https://files.pythonhosted.org/packages/a7/53/90fbb9e08b29c048c403558d3cdd0adf2668b02ce9d50602452e187cd4af/ruff-0.15.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5f3e4b221fb4bd293f79912fc5e93a9063ebd6d0dcbd528f91b89172a9b8436c", size = 10577861, upload-time = "2026-03-19T16:26:57.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/aa/5f486226538fe4d0f0439e2da1716e1acf895e2a232b26f2459c55f8ddad/ruff-0.15.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b15e48602c9c1d9bdc504b472e90b90c97dc7d46c7028011ae67f3861ceba7b4", size = 10327310, upload-time = "2026-03-19T16:26:35.909Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/271afdffb81fe7bfc8c43ba079e9d96238f674380099457a74ccb3863857/ruff-0.15.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b4705e0e85cedc74b0a23cf6a179dbb3df184cb227761979cc76c0440b5ab0d", size = 10840752, upload-time = "2026-03-19T16:26:45.723Z" }, + { url = "https://files.pythonhosted.org/packages/bf/29/a4ae78394f76c7759953c47884eb44de271b03a66634148d9f7d11e721bd/ruff-0.15.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:112c1fa316a558bb34319282c1200a8bf0495f1b735aeb78bfcb2991e6087580", size = 11336961, upload-time = "2026-03-19T16:26:39.076Z" }, + { url = "https://files.pythonhosted.org/packages/26/6b/8786ba5736562220d588a2f6653e6c17e90c59ced34a2d7b512ef8956103/ruff-0.15.7-py3-none-win32.whl", hash = "sha256:6d39e2d3505b082323352f733599f28169d12e891f7dd407f2d4f54b4c2886de", size = 10582538, upload-time = "2026-03-19T16:26:15.992Z" }, + { url = "https://files.pythonhosted.org/packages/2b/e9/346d4d3fffc6871125e877dae8d9a1966b254fbd92a50f8561078b88b099/ruff-0.15.7-py3-none-win_amd64.whl", hash = "sha256:4d53d712ddebcd7dace1bc395367aec12c057aacfe9adbb6d832302575f4d3a1", size = 11755839, upload-time = "2026-03-19T16:26:19.897Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2", size = 11023304, upload-time = "2026-03-19T16:26:51.669Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "snowballstemmer" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/a7/9810d872919697c9d01295633f5d574fb416d47e535f258272ca1f01f447/snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895", size = 105575, upload-time = "2025-05-09T16:34:51.843Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" }, +] + +[[package]] +name = "sphinx" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alabaster" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils" }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "roman-numerals" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/f7/b1884cb3188ab181fc81fa00c266699dab600f927a964df02ec3d5d1916a/sphinx-9.1.0-py3-none-any.whl", hash = "sha256:c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978", size = 3921742, upload-time = "2025-12-31T15:09:25.561Z" }, +] + +[[package]] +name = "sphinx-autodoc-typehints" +version = "3.9.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/26/3fb37400637a3fbb099bd454298b21c420decde96c4b5acedeefee14d714/sphinx_autodoc_typehints-3.9.9.tar.gz", hash = "sha256:c862859c7d679a1495de5bcac150f6b1a6ebc24a1547379ca2aac1831588aa0d", size = 69333, upload-time = "2026-03-20T15:14:15.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/64/2dc63a88a3010e9b2ea86788d5ef1ec37bc9b9c6b544cea4f764ff343ea4/sphinx_autodoc_typehints-3.9.9-py3-none-any.whl", hash = "sha256:53c849d74ab67b51fade73c398d08aa3003158c1af88fb84876440d7382143c5", size = 36846, upload-time = "2026-03-20T15:14:14.384Z" }, +] + +[[package]] +name = "sphinx-rtd-theme" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "sphinx" }, + { name = "sphinxcontrib-jquery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/68/a1bfbf38c0f7bccc9b10bbf76b94606f64acb1552ae394f0b8285bfaea25/sphinx_rtd_theme-3.1.0.tar.gz", hash = "sha256:b44276f2c276e909239a4f6c955aa667aaafeb78597923b1c60babc76db78e4c", size = 7620915, upload-time = "2026-01-12T16:03:31.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/c7/b5c8015d823bfda1a346adb2c634a2101d50bb75d421eb6dcb31acd25ebc/sphinx_rtd_theme-3.1.0-py2.py3-none-any.whl", hash = "sha256:1785824ae8e6632060490f67cf3a72d404a85d2d9fc26bce3619944de5682b89", size = 7655617, upload-time = "2026-01-12T16:03:28.101Z" }, +] + +[[package]] +name = "sphinxcontrib-applehelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" }, +] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" }, +] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" }, +] + +[[package]] +name = "sphinxcontrib-jquery" +version = "4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/f3/aa67467e051df70a6330fe7770894b3e4f09436dea6881ae0b4f3d87cad8/sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a", size = 122331, upload-time = "2023-03-14T15:01:01.944Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/85/749bd22d1a68db7291c89e2ebca53f4306c3f205853cf31e9de279034c3c/sphinxcontrib_jquery-4.1-py2.py3-none-any.whl", hash = "sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae", size = 121104, upload-time = "2023-03-14T15:01:00.356Z" }, +] + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, +] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" }, +] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, +] + +[[package]] +name = "ty" +version = "0.0.24" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/96/652a425030f95dc2c9548d9019e52502e17079e1daeefbc4036f1c0905b4/ty-0.0.24.tar.gz", hash = "sha256:9fe42f6b98207bdaef51f71487d6d087f2cb02555ee3939884d779b2b3cc8bfc", size = 5354286, upload-time = "2026-03-19T16:55:57.035Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/e5/34457ee11708e734ba81ad65723af83030e484f961e281d57d1eecf08951/ty-0.0.24-py3-none-linux_armv6l.whl", hash = "sha256:1ab4f1f61334d533a3fdf5d9772b51b1300ac5da4f3cdb0be9657a3ccb2ce3e7", size = 10394877, upload-time = "2026-03-19T16:55:54.246Z" }, + { url = "https://files.pythonhosted.org/packages/44/81/bc9a1b1a87f43db15ab64ad781a4f999734ec3b470ad042624fa875b20e6/ty-0.0.24-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:facbf2c4aaa6985229e08f8f9bf152215eb078212f22b5c2411f35386688ab42", size = 10211109, upload-time = "2026-03-19T16:55:28.554Z" }, + { url = "https://files.pythonhosted.org/packages/e4/63/cfc805adeaa61d63ba3ea71127efa7d97c40ba36d97ee7bd957341d05107/ty-0.0.24-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b6d2a3b6d4470c483552a31e9b368c86f154dcc964bccb5406159dc9cd362246", size = 9694769, upload-time = "2026-03-19T16:55:34.309Z" }, + { url = "https://files.pythonhosted.org/packages/33/09/edc220726b6ec44a58900401f6b27140997ef15026b791e26b69a6e69eb5/ty-0.0.24-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c94c25d0500939fd5f8f16ce41cbed5b20528702c1d649bf80300253813f0a2", size = 10176287, upload-time = "2026-03-19T16:55:37.17Z" }, + { url = "https://files.pythonhosted.org/packages/f8/bf/cbe2227be711e65017655d8ee4d050f4c92b113fb4dc4c3bd6a19d3a86d8/ty-0.0.24-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:89cbe7bc7df0fab02dbd8cda79b737df83f1ef7fb573b08c0ee043dc68cffb08", size = 10214832, upload-time = "2026-03-19T16:56:08.518Z" }, + { url = "https://files.pythonhosted.org/packages/af/1d/d15803ee47e9143d10e10bd81ccc14761d08758082bda402950685f0ddfe/ty-0.0.24-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:db2c5d269bcc9b764850c99f457b5018a79b3ef40ecfbc03344e65effd6cf743", size = 10709892, upload-time = "2026-03-19T16:56:05.727Z" }, + { url = "https://files.pythonhosted.org/packages/36/12/6db0d86c477147f67b9052de209421d76c3e855197b000c25fcbbe86b3a2/ty-0.0.24-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ba44512db5b97c3bbd59d93e11296e8548d0c9a3bdd1280de36d7ff22d351896", size = 11280872, upload-time = "2026-03-19T16:56:02.899Z" }, + { url = "https://files.pythonhosted.org/packages/1b/fc/155fe83a97c06d33ccc9e0f428258b32df2e08a428300c715d34757f0111/ty-0.0.24-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a52b7f589c3205512a9c50ba5b2b1e8c0698b72e51b8b9285c90420c06f1cae8", size = 11060520, upload-time = "2026-03-19T16:55:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/ac/f1/32c05a1c4c3c2a95c5b7361dee03a9bf1231d4ad096b161c838b45bce5a0/ty-0.0.24-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7981df5c709c054da4ac5d7c93f8feb8f45e69e829e4461df4d5f0988fe67d04", size = 10791455, upload-time = "2026-03-19T16:55:25.728Z" }, + { url = "https://files.pythonhosted.org/packages/17/2c/53c1ea6bedfa4d4ab64d4de262d8f5e405ecbffefd364459c628c0310d33/ty-0.0.24-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b2860151ad95a00d0f0280b8fef79900d08dcd63276b57e6e5774f2c055979c5", size = 10156708, upload-time = "2026-03-19T16:55:45.563Z" }, + { url = "https://files.pythonhosted.org/packages/45/39/7d2919cf194707169474d80720a5f3d793e983416f25e7ffcf80504c9df2/ty-0.0.24-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5674a1146d927ab77ff198a88e0c4505134ced342a0e7d1beb4a076a728b7496", size = 10236263, upload-time = "2026-03-19T16:55:31.474Z" }, + { url = "https://files.pythonhosted.org/packages/cf/7f/48eac722f2fd12a5b7aae0effdcb75c46053f94b783d989e3ef0d7380082/ty-0.0.24-py3-none-musllinux_1_2_i686.whl", hash = "sha256:438ecbf1608a9b16dd84502f3f1b23ef2ef32bbd0ab3e0ca5a82f0e0d1cd41ea", size = 10402559, upload-time = "2026-03-19T16:55:39.602Z" }, + { url = "https://files.pythonhosted.org/packages/75/e0/8cf868b9749ce1e5166462759545964e95b02353243594062b927d8bff2a/ty-0.0.24-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ddeed3098dd92a83964e7aa7b41e509ba3530eb539fc4cd8322ff64a09daf1f5", size = 10893684, upload-time = "2026-03-19T16:55:51.439Z" }, + { url = "https://files.pythonhosted.org/packages/17/9f/f54bf3be01d2c2ed731d10a5afa3324dc66f987a6ae0a4a6cbfa2323d080/ty-0.0.24-py3-none-win32.whl", hash = "sha256:83013fb3a4764a8f8bcc6ca11ff8bdfd8c5f719fc249241cb2b8916e80778eb1", size = 9781542, upload-time = "2026-03-19T16:56:11.588Z" }, + { url = "https://files.pythonhosted.org/packages/fb/49/c004c5cc258b10b3a145666e9a9c28ae7678bc958c8926e8078d5d769081/ty-0.0.24-py3-none-win_amd64.whl", hash = "sha256:748a60eb6912d1cf27aaab105ffadb6f4d2e458a3fcadfbd3cf26db0d8062eeb", size = 10764801, upload-time = "2026-03-19T16:55:42.752Z" }, + { url = "https://files.pythonhosted.org/packages/e2/59/006a074e185bfccf5e4c026015245ab4fcd2362b13a8d24cf37a277909a9/ty-0.0.24-py3-none-win_arm64.whl", hash = "sha256:280a3d31e86d0721947238f17030c33f0911cae851d108ea9f4e3ab12a5ed01f", size = 10194093, upload-time = "2026-03-19T16:55:48.303Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "xenon" +version = "0.9.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, + { name = "radon" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/7c/2b341eaeec69d514b635ea18481885a956d196a74322a4b0942ef0c31691/xenon-0.9.3.tar.gz", hash = "sha256:4a7538d8ba08aa5d79055fb3e0b2393c0bd6d7d16a4ab0fcdef02ef1f10a43fa", size = 9883, upload-time = "2024-10-21T10:27:53.722Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/5d/29ff8665b129cafd147d90b86e92babee32e116e3c84447107da3e77f8fb/xenon-0.9.3-py2.py3-none-any.whl", hash = "sha256:6e2c2c251cc5e9d01fe984e623499b13b2140fcbf74d6c03a613fa43a9347097", size = 8966, upload-time = "2024-10-21T10:27:51.121Z" }, +]