From f8a0117f733fe259df0665b9c2afde51b0c9452f Mon Sep 17 00:00:00 2001 From: Nathan Gilbert Date: Sat, 21 Mar 2026 10:51:12 -0600 Subject: [PATCH 01/22] First draft --- .mdl_style.rb | 7 + .mdlrc | 1 + .pre-commit-config.yaml | 58 +++++ pyproject.toml | 74 +++++- uv.lock | 528 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 666 insertions(+), 2 deletions(-) create mode 100644 .mdl_style.rb create mode 100644 .mdlrc create mode 100644 .pre-commit-config.yaml create mode 100644 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..4a8949d --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,58 @@ +--- +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: + - id: remove-tabs # Replace tabs by whitespaces before committing + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.7 + hooks: + - id: ruff-check + args: [ --fix, --exit-non-zero-on-fix ] + - 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 + - repo: https://github.com/astral-sh/uv-pre-commit + rev: 0.10.12 + hooks: + - id: uv-lock diff --git a/pyproject.toml b/pyproject.toml index fa7093a..3d3161c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,73 @@ [build-system] -requires = ["setuptools>=42"] -build-backend = "setuptools.build_meta" \ No newline at end of file +requires = [ "hatchling",] +build-backend = "hatchling.build" + +[project] +name = "graphworks" +description = "Graph theoretic classes and helper functions." +readme = "README.md" +requires-python = ">=3.13" +classifiers = [ + "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", +] +dependencies = [ "graphviz", "numpy",] +dynamic = [ "version",] + +[project.license] +text = "MIT" + +[[project.authors]] +name = "Nathan Gilbert" +email = "nathan.gilbert@gmail.com" + +[project.urls] +Homepage = "https://github.com/nathan-gilbert/graphworks" +"Bug Tracker" = "https://github.com/nathan-gilbert/graphworks/issues" + +[tool.hatch.version] +path = "src/graphworks/__init__.py" + +[tool.hatch.build.targets.wheel] +packages = [ "src/graphworks",] + +[tool.uv] +dev-dependencies = [ "pytest", "pytest-cov", "black", "ruff", "ty", "import-linter", "radon",] + +[tool.black] +line-length = 88 +target-version = [ "py313", "py314", "py315",] + +[tool.ruff] +line-length = 88 +target-version = "py313" + +[tool.ruff.lint] +select = [ "E", "W", "F", "I", "UP", "B", "C4", "SIM",] +ignore = [] + +[tool.ruff.lint.isort] +known-first-party = [ "graphworks",] + +[tool.ty] +python-version = "3.13" + +[tool.pytest.ini_options] +testpaths = [ "tests",] +addopts = "--tb=short -v" + +[tool.coverage.run] +source = [ "src/graphworks",] +omit = [ "tests/*",] + +[tool.coverage.report] +show_missing = true diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..5de60b9 --- /dev/null +++ b/uv.lock @@ -0,0 +1,528 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" + +[[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 = "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 = "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 = "." } +dependencies = [ + { name = "graphviz" }, + { name = "numpy" }, +] + +[package.dev-dependencies] +dev = [ + { name = "black" }, + { name = "import-linter" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "radon" }, + { name = "ruff" }, + { name = "ty" }, +] + +[package.metadata] +requires-dist = [ + { name = "graphviz" }, + { name = "numpy" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "black" }, + { name = "import-linter" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "radon" }, + { name = "ruff" }, + { name = "ty" }, +] + +[[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 = "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 = "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 = "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 = "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.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[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 = "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 = "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 = "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 = "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" }, +] From 0b1abc72d5a0551030a6fdeb3fe526253f48ed73 Mon Sep 17 00:00:00 2001 From: Nathan Gilbert Date: Sat, 21 Mar 2026 11:01:03 -0600 Subject: [PATCH 02/22] Add pre-commit for ty --- .icewormignore | 161 ++++++++++++++ .pre-commit-config.yaml | 13 +- pyproject.toml | 61 ++++- uv.lock | 477 ++++++++++++++++++++++++++++++++++++++-- 4 files changed, 685 insertions(+), 27 deletions(-) create mode 100644 .icewormignore 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/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4a8949d..1329411 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -23,6 +23,15 @@ repos: 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 + - 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: @@ -52,7 +61,3 @@ repos: # hooks: # - id: import-linter # language: system - - repo: https://github.com/astral-sh/uv-pre-commit - rev: 0.10.12 - hooks: - - id: uv-lock diff --git a/pyproject.toml b/pyproject.toml index 3d3161c..ab7f0f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,9 @@ name = "graphworks" description = "Graph theoretic classes and helper functions." readme = "README.md" requires-python = ">=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", @@ -17,10 +19,11 @@ classifiers = [ "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 = [ "graphviz", "numpy",] +dependencies = [ "numpy",] dynamic = [ "version",] [project.license] @@ -30,44 +33,82 @@ text = "MIT" 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.optional-dependencies] +viz = [ "graphviz",] +docs = [ "myst-parser", "sphinx", "sphinx-autodoc-typehints", "sphinx-rtd-theme",] +dev = [ "black", "import-linter", "pytest", "pytest-cov", "ruff", "ty", "xenon",] +all = [ "graphworks[dev,docs,viz]",] [tool.hatch.version] path = "src/graphworks/__init__.py" +[tool.hatch.build.targets.sdist] +include = [ "src/", "tests/", "docs/", "README.md", "CHANGELOG.md", "LICENSE", "pyproject.toml",] + [tool.hatch.build.targets.wheel] packages = [ "src/graphworks",] -[tool.uv] -dev-dependencies = [ "pytest", "pytest-cov", "black", "ruff", "ty", "import-linter", "radon",] - [tool.black] line-length = 88 -target-version = [ "py313", "py314", "py315",] +target-version = [ "py313", "py314",] [tool.ruff] line-length = 88 target-version = "py313" [tool.ruff.lint] -select = [ "E", "W", "F", "I", "UP", "B", "C4", "SIM",] -ignore = [] +select = [ "E", "W", "F", "I", "UP", "B", "C4", "SIM", "TCH", "ANN", "D",] +ignore = [ "D203", "D213",] + +[tool.ruff.lint.pydocstyle] +convention = "pep257" [tool.ruff.lint.isort] known-first-party = [ "graphworks",] +[tool.ruff.lint.per-file-ignores] +"tests/**/*.py" = [ "ANN", "D",] + [tool.ty] python-version = "3.13" +[tool.ty.rules] +strict = true + [tool.pytest.ini_options] testpaths = [ "tests",] -addopts = "--tb=short -v" +addopts = [ + "--tb=short", + "--strict-markers", + "-v", + "--cov=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/*",] +omit = [ "tests/*", "docs/*",] +branch = true [tool.coverage.report] show_missing = true +skip_covered = false +fail_under = 80 +exclude_lines = [ "pragma: no cover", "if TYPE_CHECKING:", "raise NotImplementedError", "@(abc\\.)?abstractmethod",] diff --git a/uv.lock b/uv.lock index 5de60b9..a0e659c 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,24 @@ version = 1 revision = 3 requires-python = ">=3.13" +[[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" @@ -29,6 +47,72 @@ wheels = [ { 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" @@ -119,6 +203,15 @@ wheels = [ { 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" @@ -132,38 +225,73 @@ wheels = [ name = "graphworks" source = { editable = "." } dependencies = [ - { name = "graphviz" }, { name = "numpy" }, ] -[package.dev-dependencies] -dev = [ +[package.optional-dependencies] +all = [ { name = "black" }, + { name = "graphviz" }, { name = "import-linter" }, + { name = "myst-parser" }, { name = "pytest" }, { name = "pytest-cov" }, - { name = "radon" }, { name = "ruff" }, + { name = "sphinx" }, + { name = "sphinx-autodoc-typehints" }, + { name = "sphinx-rtd-theme" }, { name = "ty" }, + { name = "xenon" }, ] - -[package.metadata] -requires-dist = [ - { name = "graphviz" }, - { name = "numpy" }, -] - -[package.metadata.requires-dev] dev = [ { name = "black" }, { name = "import-linter" }, { name = "pytest" }, { name = "pytest-cov" }, - { name = "radon" }, { name = "ruff" }, { name = "ty" }, + { name = "xenon" }, +] +docs = [ + { name = "myst-parser" }, + { name = "sphinx" }, + { name = "sphinx-autodoc-typehints" }, + { name = "sphinx-rtd-theme" }, +] +viz = [ + { name = "graphviz" }, ] +[package.metadata] +requires-dist = [ + { name = "black", marker = "extra == 'all'" }, + { name = "black", marker = "extra == 'dev'" }, + { name = "graphviz", marker = "extra == 'all'" }, + { name = "graphviz", marker = "extra == 'viz'" }, + { name = "import-linter", marker = "extra == 'all'" }, + { name = "import-linter", marker = "extra == 'dev'" }, + { name = "myst-parser", marker = "extra == 'all'" }, + { name = "myst-parser", marker = "extra == 'docs'" }, + { name = "numpy" }, + { name = "pytest", marker = "extra == 'all'" }, + { name = "pytest", marker = "extra == 'dev'" }, + { name = "pytest-cov", marker = "extra == 'all'" }, + { name = "pytest-cov", marker = "extra == 'dev'" }, + { name = "ruff", marker = "extra == 'all'" }, + { name = "ruff", marker = "extra == 'dev'" }, + { 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'" }, + { name = "ty", marker = "extra == 'all'" }, + { name = "ty", marker = "extra == 'dev'" }, + { name = "xenon", marker = "extra == 'all'" }, + { name = "xenon", marker = "extra == 'dev'" }, +] +provides-extras = ["all", "dev", "docs", "viz"] + [[package]] name = "grimp" version = "3.14" @@ -219,6 +347,24 @@ wheels = [ { 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" @@ -243,6 +389,18 @@ 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 = "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" @@ -267,6 +425,70 @@ 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" @@ -285,6 +507,23 @@ 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" @@ -434,6 +673,42 @@ wheels = [ { 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" @@ -447,6 +722,21 @@ 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" @@ -460,6 +750,15 @@ 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" @@ -494,6 +793,135 @@ 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" @@ -526,3 +954,26 @@ sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac8 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" }, +] From e539493bd5a58dd905b50cac601c58617a73c456 Mon Sep 17 00:00:00 2001 From: Nathan Gilbert Date: Sat, 21 Mar 2026 11:12:34 -0600 Subject: [PATCH 03/22] Manage version via hatchling-vcs --- .gitignore | 1 + .pre-commit-config.yaml | 3 ++- pyproject.toml | 14 +++++++++----- uv.lock | 12 ++++++------ 4 files changed, 18 insertions(+), 12 deletions(-) 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/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1329411..0db5fa1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,7 +17,8 @@ repos: - repo: https://github.com/Lucas-C/pre-commit-hooks rev: v1.5.6 hooks: - - id: remove-tabs # Replace tabs by whitespaces before committing + # Replace tabs by whitespaces before committing + - id: remove-tabs - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.15.7 hooks: diff --git a/pyproject.toml b/pyproject.toml index ab7f0f1..ad44b58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,12 @@ [build-system] -requires = [ "hatchling",] +requires = [ "hatch-vcs", "hatchling",] build-backend = "hatchling.build" [project] name = "graphworks" -description = "Graph theoretic classes and helper functions." +description = "Graph theoretic classes and algorithm helper functions." readme = "README.md" -requires-python = ">=3.13" +requires-python = "<=3.15,>=3.13" keywords = [ "algorithms", "data-structures", "graph", "graph-theory", "mathematics", "network",] classifiers = [ "Development Status :: 3 - Alpha", @@ -23,7 +23,7 @@ classifiers = [ "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed", ] -dependencies = [ "numpy",] +dependencies = [] dynamic = [ "version",] [project.license] @@ -45,13 +45,17 @@ Changelog = "https://github.com/nathan-gilbert/graphworks/blob/main/CHANGELOG.md Documentation = "https://graphworks.readthedocs.io" [project.optional-dependencies] +matrix = [ "numpy",] viz = [ "graphviz",] docs = [ "myst-parser", "sphinx", "sphinx-autodoc-typehints", "sphinx-rtd-theme",] dev = [ "black", "import-linter", "pytest", "pytest-cov", "ruff", "ty", "xenon",] all = [ "graphworks[dev,docs,viz]",] [tool.hatch.version] -path = "src/graphworks/__init__.py" +source = "vcs" + +[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",] diff --git a/uv.lock b/uv.lock index a0e659c..8e9dff1 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 3 -requires-python = ">=3.13" +requires-python = ">=3.13, <=3.15" [[package]] name = "alabaster" @@ -224,9 +224,6 @@ wheels = [ [[package]] name = "graphworks" source = { editable = "." } -dependencies = [ - { name = "numpy" }, -] [package.optional-dependencies] all = [ @@ -258,6 +255,9 @@ docs = [ { name = "sphinx-autodoc-typehints" }, { name = "sphinx-rtd-theme" }, ] +matrix = [ + { name = "numpy" }, +] viz = [ { name = "graphviz" }, ] @@ -272,7 +272,7 @@ requires-dist = [ { name = "import-linter", marker = "extra == 'dev'" }, { name = "myst-parser", marker = "extra == 'all'" }, { name = "myst-parser", marker = "extra == 'docs'" }, - { name = "numpy" }, + { name = "numpy", marker = "extra == 'matrix'" }, { name = "pytest", marker = "extra == 'all'" }, { name = "pytest", marker = "extra == 'dev'" }, { name = "pytest-cov", marker = "extra == 'all'" }, @@ -290,7 +290,7 @@ requires-dist = [ { name = "xenon", marker = "extra == 'all'" }, { name = "xenon", marker = "extra == 'dev'" }, ] -provides-extras = ["all", "dev", "docs", "viz"] +provides-extras = ["all", "dev", "docs", "matrix", "viz"] [[package]] name = "grimp" From fe6bf6b15b63eeacf4ffcf0de44dd430f14444a7 Mon Sep 17 00:00:00 2001 From: Nathan Gilbert Date: Sat, 21 Mar 2026 11:32:09 -0600 Subject: [PATCH 04/22] Latest changes; dirty; needs linting and various style fixes --- pyproject.toml | 42 ++- src/graphworks/__init__.py | 4 +- src/graphworks/algorithms/__init__.py | 86 +++++ src/graphworks/algorithms/basic.py | 325 ----------------- src/graphworks/algorithms/directed.py | 15 +- src/graphworks/algorithms/paths.py | 128 +++++++ src/graphworks/algorithms/properties.py | 338 ++++++++++++++++++ src/graphworks/algorithms/search.py | 13 +- src/graphworks/algorithms/sort.py | 10 +- src/graphworks/edge.py | 1 + src/graphworks/graph.py | 453 ++++++++++++++++-------- src/graphworks/numpy_compat.py | 65 ++++ src/graphworks/types.py | 23 ++ tests/basic_tests.py | 39 +- tests/directed_tests.py | 3 +- tests/graph_tests.py | 3 +- tests/search_tests.py | 14 +- tests/test_graph.py | 187 ++++++++++ uv.lock | 3 + 19 files changed, 1217 insertions(+), 535 deletions(-) delete mode 100644 src/graphworks/algorithms/basic.py create mode 100644 src/graphworks/algorithms/paths.py create mode 100644 src/graphworks/algorithms/properties.py mode change 100755 => 100644 src/graphworks/graph.py create mode 100644 src/graphworks/numpy_compat.py create mode 100644 src/graphworks/types.py create mode 100644 tests/test_graph.py diff --git a/pyproject.toml b/pyproject.toml index ad44b58..4d46e2c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ Documentation = "https://graphworks.readthedocs.io" matrix = [ "numpy",] viz = [ "graphviz",] docs = [ "myst-parser", "sphinx", "sphinx-autodoc-typehints", "sphinx-rtd-theme",] -dev = [ "black", "import-linter", "pytest", "pytest-cov", "ruff", "ty", "xenon",] +dev = [ "black", "import-linter", "isort", "pytest", "pytest-cov", "ruff", "ty", "xenon",] all = [ "graphworks[dev,docs,viz]",] [tool.hatch.version] @@ -65,7 +65,7 @@ packages = [ "src/graphworks",] [tool.black] line-length = 88 -target-version = [ "py313", "py314",] +target-version = [ "py314"] [tool.ruff] line-length = 88 @@ -78,7 +78,10 @@ ignore = [ "D203", "D213",] [tool.ruff.lint.pydocstyle] convention = "pep257" -[tool.ruff.lint.isort] +[tool.isort] +profile = "black" +line_length = 88 +multi_line_output = 3 known-first-party = [ "graphworks",] [tool.ruff.lint.per-file-ignores] @@ -90,13 +93,23 @@ python-version = "3.13" [tool.ty.rules] strict = true -[tool.pytest.ini_options] -testpaths = [ "tests",] +[tool.uv] +required-version = ">=0.10.12" +python-preference = "managed" +resolution = "highest" +prerelease = "disallow" +index-strategy = "first-index" + +[tool.pytest] +testpaths = ["tests"] addopts = [ - "--tb=short", + "--color=yes", + "--tb=auto", + "--maxfail=10", + "--durations=10", + "-ra", "--strict-markers", - "-v", - "--cov=graphworks", + "--cov=src/graphworks", "--cov-report=term-missing", "--cov-report=html:htmlcov", ] @@ -107,12 +120,17 @@ markers = [ ] [tool.coverage.run] -source = [ "src/graphworks",] -omit = [ "tests/*", "docs/*",] +source = ["src/graphworks"] +omit = ["tests/*", "docs/*"] branch = true [tool.coverage.report] show_missing = true skip_covered = false -fail_under = 80 -exclude_lines = [ "pragma: no cover", "if TYPE_CHECKING:", "raise NotImplementedError", "@(abc\\.)?abstractmethod",] +fail_under = 90 +exclude_lines = [ + "pragma: no cover", + "if TYPE_CHECKING:", + "raise NotImplementedError", + "@(abc\\.)?abstractmethod", +] diff --git a/src/graphworks/__init__.py b/src/graphworks/__init__.py index 0dac231..62ba05a 100644 --- a/src/graphworks/__init__.py +++ b/src/graphworks/__init__.py @@ -1,3 +1 @@ -__all__ = ["graph", "algorithms", "export"] -__version__ = '0.5.0' -__author__ = 'Nathan Gilbert' +__author__ = "Nathan Gilbert" diff --git a/src/graphworks/algorithms/__init__.py b/src/graphworks/algorithms/__init__.py index e69de29..d4574d3 100644 --- a/src/graphworks/algorithms/__init__.py +++ b/src/graphworks/algorithms/__init__.py @@ -0,0 +1,86 @@ +""" +graphworks.algorithms +~~~~~~~~~~~~~~~~~~~~~ + +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..93189a8 100644 --- a/src/graphworks/algorithms/directed.py +++ b/src/graphworks/algorithms/directed.py @@ -1,7 +1,6 @@ -from typing import List, Dict -from src.graphworks.graph import Graph from src.graphworks.algorithms.search import arrival_departure_dfs +from src.graphworks.graph import Graph def is_dag(graph: Graph) -> bool: @@ -13,12 +12,12 @@ def is_dag(graph: Graph) -> 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 +41,7 @@ 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]]: adjacency_matrix = {} for v in graph.vertices(): adjacency_matrix[v] = graph.get_neighbors(v) @@ -50,7 +49,7 @@ def build_neighbor_matrix(graph: Graph) -> Dict[str, List[str]]: return adjacency_matrix -def find_circuit(graph: Graph) -> List[str]: +def find_circuit(graph: Graph) -> list[str]: """ Using Hierholzer’s algorithm to find an eulerian circuit :param graph: @@ -61,7 +60,7 @@ def find_circuit(graph: Graph) -> List[str]: 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..18c971d --- /dev/null +++ b/src/graphworks/algorithms/paths.py @@ -0,0 +1,128 @@ +""" +graphworks.algorithms.paths +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +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 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..e39757b --- /dev/null +++ b/src/graphworks/algorithms/properties.py @@ -0,0 +1,338 @@ +""" +graphworks.algorithms.properties +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +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. + +:author: Nathan Gilbert +""" + +from __future__ import annotations + +from graphworks.graph import Graph +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: + if 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 + """ + from graphworks.algorithms.paths import find_all_paths # avoid circular + + 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 = find_all_paths(graph, start, end) + if all_paths: + shortest_paths.append(sorted(all_paths, key=len)[0]) + + 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..935097a 100644 --- a/src/graphworks/algorithms/search.py +++ b/src/graphworks/algorithms/search.py @@ -1,9 +1,8 @@ -from typing import List, Dict from src.graphworks.graph import Graph -def breadth_first_search(graph: Graph, start: str) -> List[str]: +def breadth_first_search(graph: Graph, start: str) -> list[str]: """ :param graph: @@ -11,7 +10,7 @@ def breadth_first_search(graph: Graph, start: str) -> List[str]: :return: """ # 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,7 +27,7 @@ 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]: """ :param graph: @@ -46,9 +45,9 @@ def depth_first_search(graph: Graph, start: str) -> List[str]: def arrival_departure_dfs(graph: Graph, v: str, - discovered: Dict[str, bool], - arrival: Dict[str, int], - departure: Dict[str, int], + 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 diff --git a/src/graphworks/algorithms/sort.py b/src/graphworks/algorithms/sort.py index d83f5a0..60d7842 100644 --- a/src/graphworks/algorithms/sort.py +++ b/src/graphworks/algorithms/sort.py @@ -1,22 +1,22 @@ -from typing import List, Dict + from src.graphworks.graph import Graph -def topological(graph: Graph) -> List[str]: +def topological(graph: Graph) -> list[str]: """ O(V+E) :param graph: :return: List of vertices sorted topologically """ - 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]): 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..0fb6d77 100644 --- a/src/graphworks/edge.py +++ b/src/graphworks/edge.py @@ -7,6 +7,7 @@ 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. """ + vertex1: str vertex2: str directed: bool = False diff --git a/src/graphworks/graph.py b/src/graphworks/graph.py old mode 100755 new mode 100644 index 83f5dcf..f223e15 --- a/src/graphworks/graph.py +++ b/src/graphworks/graph.py @@ -1,101 +1,194 @@ +""" +graphworks.graph +~~~~~~~~~~~~~~~~ + +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`. + +:author: Nathan Gilbert +""" + +from __future__ import annotations + import json -import uuid -from typing import List -from typing import DefaultDict import random +import uuid from collections import defaultdict -import numpy as np -from numpy.typing import NDArray - -from src.graphworks.edge import Edge +from graphworks.edge import Edge +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. - :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 :class:`Graph` can be constructed from: + + * 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: + """Initialise 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." + ) + + # ------------------------------------------------------------------ + # Public interface — vertices, edges, metadata + # ------------------------------------------------------------------ - def vertices(self) -> List[str]: - """ :return: list of vertices' names in the graph """ + 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 + neighbour 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: None + :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: None + :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: None + :rtype: None """ if vertex1 in self.__graph: self.__graph[vertex1].append(vertex2) @@ -106,129 +199,174 @@ 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 random.choice(self.vertices()) + """Return a uniformly random vertex from the graph. - @staticmethod - def __validate_array(arr: NDArray) -> bool: + :return: A vertex name chosen at random. + :rtype: str """ + return random.choice(self.vertices()) - :param arr: matrix of graph to validate - :return: whether it is true that the array is a valid graph - """ - if len(arr.shape) != 2: - return False - if arr.shape[0] != arr.shape[1]: - return False - return True + # ------------------------------------------------------------------ + # Dunder methods + # ------------------------------------------------------------------ - def __repr__(self): - return self.__label + def __repr__(self) -> str: + """Return the graph label as its canonical representation. - def __str__(self): + :return: Graph label string. + :rtype: str """ + return self.__label - :return: a string rep of the graph with name and edges + 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 """ - 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}" + 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): - # 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): - return self.__graph.get(node, []) + """Iterate over vertex names in insertion order. + + :return: An iterator yielding vertex name strings. + :rtype: Iterator[str] + """ + return iter(self.vertices()) + + def __getitem__(self, node: str) -> list[str]: + """Return the neighbour list for *node*. - def __extract_fields_from_json(self, json_data: dict): + :param node: Vertex name. + :type node: str + :return: List of neighbour vertex names, or an empty list if *node* + is not in the graph. + :rtype: list[str] """ + return self.__graph.get(node, []) + + # ------------------------------------------------------------------ + # 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: None + :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", {}) - 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 +374,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: None + :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]): + self.__graph[vertex] # ensure key exists via defaultdict + 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..8c94fb9 --- /dev/null +++ b/src/graphworks/numpy_compat.py @@ -0,0 +1,65 @@ +""" +graphworks.numpy_compat +~~~~~~~~~~~~~~~~~~~~~~~ + +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 graphworks.types import AdjacencyMatrix + +try: + import numpy as np + from numpy.typing import NDArray +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 a numpy ndarray adjacency representation to an :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..42fc19a --- /dev/null +++ b/src/graphworks/types.py @@ -0,0 +1,23 @@ +""" +graphworks.types +~~~~~~~~~~~~~~~~ + +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. + +:author: Nathan Gilbert +""" + +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/basic_tests.py b/tests/basic_tests.py index 02657e9..b2aa486 100644 --- a/tests/basic_tests.py +++ b/tests/basic_tests.py @@ -1,24 +1,27 @@ 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.algorithms.basic import ( + degree_sequence, + density, + diameter, + find_all_paths, + find_isolated_vertices, + find_path, + generate_edges, + get_complement, + is_complete, + is_connected, + is_degree_sequence, + is_erdos_gallai, + is_regular, + is_simple, + is_sparse, + max_degree, + min_degree, + vertex_degree, +) + from src.graphworks.graph import Graph diff --git a/tests/directed_tests.py b/tests/directed_tests.py index 73895be..f687bf2 100644 --- a/tests/directed_tests.py +++ b/tests/directed_tests.py @@ -1,6 +1,7 @@ import json import unittest -from src.graphworks.algorithms.directed import is_dag, find_circuit + +from src.graphworks.algorithms.directed import find_circuit, is_dag from src.graphworks.graph import Graph diff --git a/tests/graph_tests.py b/tests/graph_tests.py index b563725..d2c92b7 100644 --- a/tests/graph_tests.py +++ b/tests/graph_tests.py @@ -6,8 +6,7 @@ import numpy as np -from src.graphworks.graph import Graph -from src.graphworks.graph import Edge +from src.graphworks.graph import Edge, Graph class GraphTests(unittest.TestCase): diff --git a/tests/search_tests.py b/tests/search_tests.py index 7766680..49f89a2 100644 --- a/tests/search_tests.py +++ b/tests/search_tests.py @@ -1,9 +1,11 @@ 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.algorithms.search import ( + arrival_departure_dfs, + breadth_first_search, + depth_first_search, +) from src.graphworks.graph import Graph @@ -42,11 +44,11 @@ def test_arrival_departure_dfs(self): graph = Graph(input_graph=json.dumps(disjoint_graph)) # list to store the arrival time of vertex - arrival = {v: 0 for v in graph.vertices()} + arrival = dict.fromkeys(graph.vertices(), 0) # list to store the departure time of vertex - departure = {v: 0 for v in graph.vertices()} + departure = dict.fromkeys(graph.vertices(), 0) # mark all the vertices as not discovered - discovered = {v: False for v in graph.vertices()} + discovered = dict.fromkeys(graph.vertices(), False) time = -1 for v in graph.vertices(): diff --git a/tests/test_graph.py b/tests/test_graph.py new file mode 100644 index 0000000..cfc36c6 --- /dev/null +++ b/tests/test_graph.py @@ -0,0 +1,187 @@ +""" +tests.test_graph +~~~~~~~~~~~~~~~~ + +Unit tests for :class:`graphworks.graph.Graph`. + +The numpy-dependent tests are skipped automatically when the ``[matrix]`` +extra is not installed. +""" + +import json +import shutil +import tempfile +import unittest +from os import path + +from src.graphworks.edge import Edge +from src.graphworks.graph import Graph + +try: + import numpy as np + + from src.graphworks.numpy_compat import matrix_to_ndarray, ndarray_to_matrix + + HAS_NUMPY = True +except ImportError: + HAS_NUMPY = False + + +class GraphLabelTests(unittest.TestCase): + """Tests for graph label and repr behaviour.""" + + 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\nA -> B\nB -> 0" + json_graph = {"label": "my graph", "graph": {"A": ["B"], "B": []}} + graph = Graph(input_graph=json.dumps(json_graph)) + self.assertEqual(answer, str(graph)) + + +class GraphEdgeVertexTests(unittest.TestCase): + """Tests for vertex and edge manipulation.""" + + 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.assertFalse(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_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_neighbors(self): + json_graph = {"label": "my graph", "graph": {"A": ["B"], "B": []}} + graph = Graph(input_graph=json.dumps(json_graph)) + self.assertEqual(["B"], graph.get_neighbors("A")) + self.assertEqual([], graph.get_neighbors("B")) + + +class GraphFileTests(unittest.TestCase): + """Tests for file-based graph construction.""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp() + + def tearDown(self): + shutil.rmtree(self.test_dir) + + def test_read_graph_from_file(self): + json_graph = {"label": "my graph", "graph": {"A": ["B"], "B": []}} + file_path = path.join(self.test_dir, "test.json") + with open(file_path, "w", encoding="utf-8") as f: + f.write(json.dumps(json_graph)) + + graph = Graph(input_file=file_path) + self.assertEqual(json_graph["label"], graph.get_label()) + self.assertFalse(graph.is_directed()) + self.assertEqual(json_graph["graph"], graph.get_graph()) + + +class GraphAdjacencyMatrixTests(unittest.TestCase): + """Tests for the stdlib adjacency matrix interface.""" + + 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() + # A -> B means matrix[0][1] = 1; all others 0 + self.assertEqual([[0, 1], [0, 0]], matrix) + + def test_set_from_adjacency_matrix(self): + matrix: list[list[int]] = [[0, 1], [1, 0]] + graph = Graph(input_matrix=matrix) + self.assertEqual(2, len(graph.vertices())) + self.assertEqual(1, len(graph.edges())) + + def test_malformed_matrix_non_square(self): + with self.assertRaises(ValueError): + Graph(input_matrix=[[0, 1, 0], [1, 0]]) + + def test_malformed_matrix_wrong_row_count(self): + with self.assertRaises(ValueError): + Graph(input_matrix=[[0, 1], [1, 0], [1, 0]]) + + def test_matrix_index_helpers(self): + json_graph = {"graph": {"A": ["B"], "B": []}} + graph = Graph(input_graph=json.dumps(json_graph)) + idx = graph.vertex_to_matrix_index("A") + self.assertEqual("A", graph.matrix_index_to_vertex(idx)) + + +class GraphValidationTests(unittest.TestCase): + """Tests for graph construction validation.""" + + def test_malformed_json_missing_vertex(self): + json_graph = {"graph": {"A": ["B", "C", "D"], "B": []}} + with self.assertRaises(ValueError): + Graph(input_graph=json.dumps(json_graph)) + + +@unittest.skipUnless(HAS_NUMPY, "numpy not installed — skipping numpy interop tests") +class GraphNumpyCompatTests(unittest.TestCase): + """Tests for numpy ndarray interop via graphworks.numpy_compat. + + These tests are skipped automatically when numpy is not installed. + Install with: ``pip install graphworks[matrix]`` + """ + + def test_ndarray_to_matrix(self): + arr = np.array([[0, 1], [1, 0]]) + matrix = ndarray_to_matrix(arr) + self.assertEqual([[0, 1], [1, 0]], matrix) + + def test_matrix_to_ndarray(self): + matrix = [[0, 1], [1, 0]] + arr = matrix_to_ndarray(matrix) + np.testing.assert_array_equal(arr, np.array([[0, 1], [1, 0]])) + + def test_graph_from_ndarray_via_compat(self): + arr = np.array([[0, 1], [1, 0]], dtype=object) + matrix = ndarray_to_matrix(arr) + graph = Graph(input_matrix=matrix) + self.assertEqual(2, len(graph.vertices())) + self.assertEqual(1, len(graph.edges())) + + def test_malformed_ndarray_non_square(self): + arr = np.array([[0, 1, 0, 0, 0], [1, 0]], dtype=object) + with self.assertRaises(ValueError): + ndarray_to_matrix(arr) + + def test_adjacency_matrix_roundtrip(self): + json_graph = {"graph": {"A": ["B"], "B": []}} + graph = Graph(input_graph=json.dumps(json_graph)) + stdlib_matrix = graph.get_adjacency_matrix() + np_arr = matrix_to_ndarray(stdlib_matrix) + np.testing.assert_array_equal(np_arr, np.array([[0, 1], [0, 0]])) + + +if __name__ == "__main__": + unittest.main() diff --git a/uv.lock b/uv.lock index 8e9dff1..e6f65eb 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,9 @@ version = 1 revision = 3 requires-python = ">=3.13, <=3.15" +[options] +prerelease-mode = "disallow" + [[package]] name = "alabaster" version = "1.0.0" From 8bb0d2ccd6db9a1206b88611afdef74711d95411 Mon Sep 17 00:00:00 2001 From: Nathan Gilbert Date: Sat, 21 Mar 2026 11:53:58 -0600 Subject: [PATCH 05/22] update pyproject --- pyproject.toml | 22 ++-- requirements.txt | 43 ------- setup.cfg | 37 ------ tests/basic_tests.py | 224 ---------------------------------- tests/directed_tests.py | 47 ------- tests/edge_tests.py | 12 -- tests/export_tests.py | 52 -------- tests/graph_iterator_tests.py | 28 ----- tests/graph_tests.py | 108 ---------------- tests/search_tests.py | 65 ---------- tests/sort_tests.py | 32 ----- uv.lock | 13 ++ 12 files changed, 22 insertions(+), 661 deletions(-) delete mode 100644 requirements.txt delete mode 100644 setup.cfg delete mode 100644 tests/basic_tests.py delete mode 100644 tests/directed_tests.py delete mode 100644 tests/edge_tests.py delete mode 100644 tests/export_tests.py delete mode 100644 tests/graph_iterator_tests.py delete mode 100644 tests/graph_tests.py delete mode 100644 tests/search_tests.py delete mode 100644 tests/sort_tests.py diff --git a/pyproject.toml b/pyproject.toml index 4d46e2c..df78219 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ classifiers = [ "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", @@ -65,7 +66,7 @@ packages = [ "src/graphworks",] [tool.black] line-length = 88 -target-version = [ "py314"] +target-version = [ "py314",] [tool.ruff] line-length = 88 @@ -78,15 +79,15 @@ ignore = [ "D203", "D213",] [tool.ruff.lint.pydocstyle] convention = "pep257" +[tool.ruff.lint.per-file-ignores] +"tests/**/*.py" = [ "ANN", "D",] + [tool.isort] profile = "black" line_length = 88 multi_line_output = 3 known-first-party = [ "graphworks",] -[tool.ruff.lint.per-file-ignores] -"tests/**/*.py" = [ "ANN", "D",] - [tool.ty] python-version = "3.13" @@ -101,7 +102,7 @@ prerelease = "disallow" index-strategy = "first-index" [tool.pytest] -testpaths = ["tests"] +testpaths = [ "tests",] addopts = [ "--color=yes", "--tb=auto", @@ -120,17 +121,12 @@ markers = [ ] [tool.coverage.run] -source = ["src/graphworks"] -omit = ["tests/*", "docs/*"] +source = [ "src/graphworks",] +omit = [ "tests/*", "docs/*",] 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", -] +exclude_lines = [ "pragma: no cover", "if TYPE_CHECKING:", "raise NotImplementedError", "@(abc\\.)?abstractmethod",] 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/tests/basic_tests.py b/tests/basic_tests.py deleted file mode 100644 index b2aa486..0000000 --- a/tests/basic_tests.py +++ /dev/null @@ -1,224 +0,0 @@ -import json -import unittest - -from src.graphworks.algorithms.basic import ( - degree_sequence, - density, - diameter, - find_all_paths, - find_isolated_vertices, - find_path, - generate_edges, - get_complement, - is_complete, - is_connected, - is_degree_sequence, - is_erdos_gallai, - is_regular, - is_simple, - is_sparse, - max_degree, - min_degree, - vertex_degree, -) - -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 f687bf2..0000000 --- a/tests/directed_tests.py +++ /dev/null @@ -1,47 +0,0 @@ -import json -import unittest - -from src.graphworks.algorithms.directed import find_circuit, is_dag -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 d2c92b7..0000000 --- a/tests/graph_tests.py +++ /dev/null @@ -1,108 +0,0 @@ -import json -import shutil -import tempfile -import unittest -from os import path - -import numpy as np - -from src.graphworks.graph import Edge, Graph - - -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 49f89a2..0000000 --- a/tests/search_tests.py +++ /dev/null @@ -1,65 +0,0 @@ -import json -import unittest - -from src.graphworks.algorithms.search import ( - arrival_departure_dfs, - breadth_first_search, - depth_first_search, -) -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 = dict.fromkeys(graph.vertices(), 0) - # list to store the departure time of vertex - departure = dict.fromkeys(graph.vertices(), 0) - # mark all the vertices as not discovered - 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) - - # 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/uv.lock b/uv.lock index e6f65eb..804276e 100644 --- a/uv.lock +++ b/uv.lock @@ -233,6 +233,7 @@ all = [ { name = "black" }, { name = "graphviz" }, { name = "import-linter" }, + { name = "isort" }, { name = "myst-parser" }, { name = "pytest" }, { name = "pytest-cov" }, @@ -246,6 +247,7 @@ all = [ dev = [ { name = "black" }, { name = "import-linter" }, + { name = "isort" }, { name = "pytest" }, { name = "pytest-cov" }, { name = "ruff" }, @@ -273,6 +275,8 @@ requires-dist = [ { name = "graphviz", marker = "extra == 'viz'" }, { name = "import-linter", marker = "extra == 'all'" }, { name = "import-linter", marker = "extra == 'dev'" }, + { name = "isort", marker = "extra == 'all'" }, + { name = "isort", marker = "extra == 'dev'" }, { name = "myst-parser", marker = "extra == 'all'" }, { name = "myst-parser", marker = "extra == 'docs'" }, { name = "numpy", marker = "extra == 'matrix'" }, @@ -392,6 +396,15 @@ 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" From 85db0009f10c09ea2c1f5f207f147447be5fe82f Mon Sep 17 00:00:00 2001 From: Nathan Gilbert Date: Sat, 21 Mar 2026 11:55:18 -0600 Subject: [PATCH 06/22] Ran linters; more work needed --- .github/workflows/python-package-ci.yml | 2 +- pyproject.toml | 2 +- src/graphworks/README.md | 2 +- src/graphworks/algorithms/__init__.py | 46 ++++++++++++------------- src/graphworks/algorithms/directed.py | 1 - src/graphworks/algorithms/search.py | 15 ++++---- src/graphworks/algorithms/sort.py | 6 ++-- src/graphworks/export/json_utils.py | 6 ++-- 8 files changed, 42 insertions(+), 38 deletions(-) diff --git a/.github/workflows/python-package-ci.yml b/.github/workflows/python-package-ci.yml index 5b2d82a..1ca3154 100644 --- a/.github/workflows/python-package-ci.yml +++ b/.github/workflows/python-package-ci.yml @@ -42,4 +42,4 @@ jobs: 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 + python -m unittest discover tests '*_tests.py' diff --git a/pyproject.toml b/pyproject.toml index df78219..20ac491 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,7 +86,7 @@ convention = "pep257" profile = "black" line_length = 88 multi_line_output = 3 -known-first-party = [ "graphworks",] +known_first_party = [ "graphworks",] [tool.ty] python-version = "3.13" 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/algorithms/__init__.py b/src/graphworks/algorithms/__init__.py index d4574d3..4d729bf 100644 --- a/src/graphworks/algorithms/__init__.py +++ b/src/graphworks/algorithms/__init__.py @@ -21,33 +21,33 @@ from graphworks.algorithms.directed import find_circuit, is_dag from graphworks.algorithms.paths import ( - find_all_paths, - find_isolated_vertices, - find_path, - generate_edges, + 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, + 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, + arrival_departure_dfs, + breadth_first_search, + depth_first_search, ) from graphworks.algorithms.sort import topological diff --git a/src/graphworks/algorithms/directed.py b/src/graphworks/algorithms/directed.py index 93189a8..1a10ad5 100644 --- a/src/graphworks/algorithms/directed.py +++ b/src/graphworks/algorithms/directed.py @@ -1,4 +1,3 @@ - from src.graphworks.algorithms.search import arrival_departure_dfs from src.graphworks.graph import Graph diff --git a/src/graphworks/algorithms/search.py b/src/graphworks/algorithms/search.py index 935097a..dd2c3d6 100644 --- a/src/graphworks/algorithms/search.py +++ b/src/graphworks/algorithms/search.py @@ -1,4 +1,3 @@ - from src.graphworks.graph import Graph @@ -43,12 +42,14 @@ 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: +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 diff --git a/src/graphworks/algorithms/sort.py b/src/graphworks/algorithms/sort.py index 60d7842..c8cccd3 100644 --- a/src/graphworks/algorithms/sort.py +++ b/src/graphworks/algorithms/sort.py @@ -1,4 +1,3 @@ - from src.graphworks.graph import Graph @@ -8,7 +7,10 @@ def topological(graph: Graph) -> list[str]: :param graph: :return: List of vertices sorted topologically """ - 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] + ): v_map[v] = True for n in g.get_neighbors(v): if not v_map[n]: diff --git a/src/graphworks/export/json_utils.py b/src/graphworks/export/json_utils.py index 09d8c91..9dd1568 100644 --- a/src/graphworks/export/json_utils.py +++ b/src/graphworks/export/json_utils.py @@ -14,8 +14,10 @@ def save_to_json(graph: Graph, out_dir): 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)) From 5002c95987594780dbc47db2234e6c4fe563ec85 Mon Sep 17 00:00:00 2001 From: Nathan Gilbert Date: Sat, 21 Mar 2026 12:08:09 -0600 Subject: [PATCH 07/22] Updated tests; Update Github Actions; Added CLAUDE.md --- .github/workflows/publish-to-pypi.yml | 50 +- .github/workflows/python-package-ci.yml | 79 +-- CLAUDE.md | 73 +++ README.md | 83 ++-- conftest.py | 381 +++++++++++++++ pyproject.toml | 1 + tests/__init__.py | 18 + tests/test_directed.py | 73 +++ tests/test_edge.py | 79 +++ tests/test_export.py | 116 +++++ tests/test_graph.py | 621 ++++++++++++++++++------ tests/test_numpy_compat.py | 198 ++++++++ tests/test_paths.py | 145 ++++++ tests/test_properties.py | 583 ++++++++++++++++++++++ tests/test_search.py | 208 ++++++++ tests/test_sort.py | 68 +++ 16 files changed, 2541 insertions(+), 235 deletions(-) create mode 100644 CLAUDE.md create mode 100644 conftest.py create mode 100644 tests/test_directed.py create mode 100644 tests/test_edge.py create mode 100644 tests/test_export.py create mode 100644 tests/test_numpy_compat.py create mode 100644 tests/test_paths.py create mode 100644 tests/test_properties.py create mode 100644 tests/test_search.py create mode 100644 tests/test_sort.py 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 1ca3154..e1c25a1 100644 --- a/.github/workflows/python-package-ci.yml +++ b/.github/workflows/python-package-ci.yml @@ -1,45 +1,66 @@ -# 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: 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 dev + - 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' + - 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/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1322f3a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,73 @@ +# 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..e68425e 100755 --- a/README.md +++ b/README.md @@ -4,79 +4,88 @@ ## 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 +``` + ## 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 diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..7646f0d --- /dev/null +++ b/conftest.py @@ -0,0 +1,381 @@ +""" +tests.conftest +~~~~~~~~~~~~~~ + +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 src.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. + +:author: Nathan Gilbert +""" + +from __future__ import annotations + +import json +import shutil +import tempfile +from collections.abc import Generator +from pathlib import Path + +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) -> 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) -> 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) -> 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) -> 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) -> 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) -> 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) -> 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) -> 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) -> 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) -> 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/pyproject.toml b/pyproject.toml index 20ac491..a19ed70 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -103,6 +103,7 @@ index-strategy = "first-index" [tool.pytest] testpaths = [ "tests",] +pythonpath = [ "src",] addopts = [ "--color=yes", "--tb=auto", diff --git a/tests/__init__.py b/tests/__init__.py index e69de29..e2631c2 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1,18 @@ +""" +tests +~~~~~ + +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/test_directed.py b/tests/test_directed.py new file mode 100644 index 0000000..eb95061 --- /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 src.graphworks.algorithms.directed import find_circuit, is_dag +from src.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..6589b65 --- /dev/null +++ b/tests/test_export.py @@ -0,0 +1,116 @@ +""" +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 pathlib import Path + +import pytest + +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.""" + from src.graphworks.export.graphviz_utils import save_to_dot + + 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.""" + from src.graphworks.export.graphviz_utils import save_to_dot + + 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).""" + from src.graphworks.export.graphviz_utils import save_to_dot + + 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 index cfc36c6..18a3d57 100644 --- a/tests/test_graph.py +++ b/tests/test_graph.py @@ -2,186 +2,533 @@ tests.test_graph ~~~~~~~~~~~~~~~~ -Unit tests for :class:`graphworks.graph.Graph`. - -The numpy-dependent tests are skipped automatically when the ``[matrix]`` -extra is not installed. +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 src.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 -import shutil -import tempfile -import unittest -from os import path +from pathlib import Path -from src.graphworks.edge import Edge -from src.graphworks.graph import Graph +import pytest -try: - import numpy as np +from graphworks.graph import Graph - from src.graphworks.numpy_compat import matrix_to_ndarray, ndarray_to_matrix +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- - HAS_NUMPY = True -except ImportError: - HAS_NUMPY = False +def _edge_pairs(graph: Graph) -> list[tuple[str, str]]: + """Return the edges of *graph* as ``(vertex1, vertex2)`` tuples. -class GraphLabelTests(unittest.TestCase): - """Tests for graph label and repr behaviour.""" + Using tuples instead of ``Edge`` objects avoids class-identity issues + when the test suite is run without an editable install. - def test_name(self): - graph = Graph("graph") - self.assertEqual("graph", graph.get_label()) + :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()] - def test_repr(self): - graph = Graph("graph") - self.assertEqual("graph", repr(graph)) - def test_str(self): - answer = "my graph\nA -> B\nB -> 0" - json_graph = {"label": "my graph", "graph": {"A": ["B"], "B": []}} - graph = Graph(input_graph=json.dumps(json_graph)) - self.assertEqual(answer, str(graph)) +# --------------------------------------------------------------------------- +# Label, repr, and str +# --------------------------------------------------------------------------- -class GraphEdgeVertexTests(unittest.TestCase): - """Tests for vertex and edge manipulation.""" +class TestGraphLabel: + """Tests for graph label, repr, and str behaviour.""" - 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.assertFalse(graph.is_directed()) - self.assertEqual(json_graph["graph"], graph.get_graph()) - self.assertEqual([Edge("A", "B")], graph.edges()) + def test_label_from_positional_arg(self) -> None: + """Graph label is stored and returned correctly. - def test_add_vertex(self): + :return: None + :rtype: None + """ graph = Graph("my graph") - graph.add_vertex("A") - self.assertEqual(["A"], graph.vertices()) + 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. - 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())) + :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_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_zero_matrix_no_edges(self) -> None: + """A zero matrix produces no edges. - def test_get_neighbors(self): - json_graph = {"label": "my graph", "graph": {"A": ["B"], "B": []}} - graph = Graph(input_graph=json.dumps(json_graph)) - self.assertEqual(["B"], graph.get_neighbors("A")) - self.assertEqual([], graph.get_neighbors("B")) + :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. -class GraphFileTests(unittest.TestCase): - """Tests for file-based graph construction.""" + :return: None + :rtype: None + """ + with pytest.raises(ValueError): + Graph(input_matrix=[[0, 1, 0], [1, 0]]) - def setUp(self): - self.test_dir = tempfile.mkdtemp() + def test_wrong_row_count_raises_value_error(self) -> None: + """A matrix where row count != column count raises ValueError. - def tearDown(self): - shutil.rmtree(self.test_dir) + :return: None + :rtype: None + """ + with pytest.raises(ValueError): + Graph(input_matrix=[[0, 1], [1, 0], [1, 0]]) - def test_read_graph_from_file(self): - json_graph = {"label": "my graph", "graph": {"A": ["B"], "B": []}} - file_path = path.join(self.test_dir, "test.json") - with open(file_path, "w", encoding="utf-8") as f: - f.write(json.dumps(json_graph)) + def test_empty_matrix_raises_value_error(self) -> None: + """An empty matrix raises ValueError. - graph = Graph(input_file=file_path) - self.assertEqual(json_graph["label"], graph.get_label()) - self.assertFalse(graph.is_directed()) - self.assertEqual(json_graph["graph"], graph.get_graph()) + :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. -class GraphAdjacencyMatrixTests(unittest.TestCase): - """Tests for the stdlib adjacency matrix interface.""" + :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()) - 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() - # A -> B means matrix[0][1] = 1; all others 0 - self.assertEqual([[0, 1], [0, 0]], matrix) - def test_set_from_adjacency_matrix(self): - matrix: list[list[int]] = [[0, 1], [1, 0]] - graph = Graph(input_matrix=matrix) - self.assertEqual(2, len(graph.vertices())) - self.assertEqual(1, len(graph.edges())) +# --------------------------------------------------------------------------- +# Vertex and edge manipulation +# --------------------------------------------------------------------------- - def test_malformed_matrix_non_square(self): - with self.assertRaises(ValueError): - Graph(input_matrix=[[0, 1, 0], [1, 0]]) - def test_malformed_matrix_wrong_row_count(self): - with self.assertRaises(ValueError): - Graph(input_matrix=[[0, 1], [1, 0], [1, 0]]) +class TestVertexEdgeManipulation: + """Tests for add_vertex, add_edge, vertices(), edges(), order(), size().""" - def test_matrix_index_helpers(self): - json_graph = {"graph": {"A": ["B"], "B": []}} - graph = Graph(input_graph=json.dumps(json_graph)) - idx = graph.vertex_to_matrix_index("A") - self.assertEqual("A", graph.matrix_index_to_vertex(idx)) + 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"] -class GraphValidationTests(unittest.TestCase): - """Tests for graph construction validation.""" + def test_add_duplicate_vertex_is_idempotent(self) -> None: + """Adding a vertex that already exists does not duplicate it. - def test_malformed_json_missing_vertex(self): - json_graph = {"graph": {"A": ["B", "C", "D"], "B": []}} - with self.assertRaises(ValueError): - Graph(input_graph=json.dumps(json_graph)) + :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. -@unittest.skipUnless(HAS_NUMPY, "numpy not installed — skipping numpy interop tests") -class GraphNumpyCompatTests(unittest.TestCase): - """Tests for numpy ndarray interop via graphworks.numpy_compat. + :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 - These tests are skipped automatically when numpy is not installed. - Install with: ``pip install graphworks[matrix]`` - """ + def test_add_edge_creates_missing_vertices(self) -> None: + """add_edge auto-creates vertices that do not yet exist. - def test_ndarray_to_matrix(self): - arr = np.array([[0, 1], [1, 0]]) - matrix = ndarray_to_matrix(arr) - self.assertEqual([[0, 1], [1, 0]], matrix) + :return: None + :rtype: None + """ + graph = Graph("g") + graph.add_edge("X", "Y") + assert len(graph.edges()) == 1 + assert len(graph.vertices()) == 2 - def test_matrix_to_ndarray(self): - matrix = [[0, 1], [1, 0]] - arr = matrix_to_ndarray(matrix) - np.testing.assert_array_equal(arr, np.array([[0, 1], [1, 0]])) + 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() - def test_graph_from_ndarray_via_compat(self): - arr = np.array([[0, 1], [1, 0]], dtype=object) - matrix = ndarray_to_matrix(arr) - graph = Graph(input_matrix=matrix) - self.assertEqual(2, len(graph.vertices())) - self.assertEqual(1, len(graph.edges())) - def test_malformed_ndarray_non_square(self): - arr = np.array([[0, 1, 0, 0, 0], [1, 0]], dtype=object) - with self.assertRaises(ValueError): - ndarray_to_matrix(arr) +# --------------------------------------------------------------------------- +# Adjacency matrix interface (stdlib only) +# --------------------------------------------------------------------------- - def test_adjacency_matrix_roundtrip(self): - json_graph = {"graph": {"A": ["B"], "B": []}} - graph = Graph(input_graph=json.dumps(json_graph)) - stdlib_matrix = graph.get_adjacency_matrix() - np_arr = matrix_to_ndarray(stdlib_matrix) - np.testing.assert_array_equal(np_arr, np.array([[0, 1], [0, 0]])) +class TestAdjacencyMatrix: + """Tests for the stdlib adjacency matrix interface.""" -if __name__ == "__main__": - unittest.main() + 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(list(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..03fec72 --- /dev/null +++ b/tests/test_numpy_compat.py @@ -0,0 +1,198 @@ +""" +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..8345286 --- /dev/null +++ b/tests/test_search.py @@ -0,0 +1,208 @@ +""" +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: None + :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: None + :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: None + :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: None + :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: None + :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: None + :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: None + :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: None + :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: None + :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: None + :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: None + :rtype: None + """ + arrival, departure, _ = self._run_full_traversal(disjoint_directed_graph) + result = list(zip(arrival.values(), departure.values())) + 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: None + :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: None + :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: None + :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") From 2ccf61340610e694d9bcace24b6c9f8242ae4bf4 Mon Sep 17 00:00:00 2001 From: Nathan Gilbert Date: Sun, 22 Mar 2026 10:34:10 -0600 Subject: [PATCH 08/22] Fixed many type errors; still style errors left --- CLAUDE.md | 31 +++++--- README.md | 6 +- conftest.py | 2 +- pyproject.toml | 19 +++-- src/graphworks/algorithms/directed.py | 4 +- src/graphworks/algorithms/properties.py | 37 ++++------ src/graphworks/algorithms/search.py | 2 +- src/graphworks/algorithms/sort.py | 6 +- src/graphworks/edge.py | 20 +++-- src/graphworks/export/graphviz_utils.py | 2 +- src/graphworks/export/json_utils.py | 6 +- src/graphworks/graph.py | 98 ++++++++++++------------- tests/test_directed.py | 4 +- tests/test_export.py | 25 ++----- tests/test_graph.py | 2 +- uv.lock | 6 +- 16 files changed, 126 insertions(+), 144 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1322f3a..854ea31 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,10 +1,14 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +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]`). +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 @@ -42,27 +46,31 @@ pre-commit run --all-files ### 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. +- **`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 + - `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`. +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/` +- **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 @@ -70,4 +78,5 @@ Tests mirror the source modules (e.g., `test_properties.py` tests `algorithms/pr ## Publishing -Tag a commit with `git tag -a vX.Y.Z -m 'message'` and push tags to trigger PyPI publish via GitHub Actions. +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 e68425e..07cf656 100755 --- a/README.md +++ b/README.md @@ -87,8 +87,8 @@ Version is managed automatically via git tags using `hatchling-vcs`. - Create Vertex class - Build out directed graphs algorithms - - + - - Allow for weighted graph algorithms - - Jarnik's algorithm - - Dijkstra's algorithm + - Jarnik's algorithm + - Dijkstra's algorithm - C++ binaries for speeding up graph computations diff --git a/conftest.py b/conftest.py index 7646f0d..5ea950f 100644 --- a/conftest.py +++ b/conftest.py @@ -19,7 +19,7 @@ pythonpath = ["src"] -Both approaches ensure that ``from src.graphworks.x import Y`` and the +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. diff --git a/pyproject.toml b/pyproject.toml index a19ed70..14356f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,16 +65,16 @@ include = [ "src/", "tests/", "docs/", "README.md", "CHANGELOG.md", "LICENSE", " packages = [ "src/graphworks",] [tool.black] -line-length = 88 +line-length = 100 target-version = [ "py314",] [tool.ruff] -line-length = 88 -target-version = "py313" +line-length = 100 +target-version = "py314" [tool.ruff.lint] select = [ "E", "W", "F", "I", "UP", "B", "C4", "SIM", "TCH", "ANN", "D",] -ignore = [ "D203", "D213",] +ignore = [ "D203", "D401", "D213",] [tool.ruff.lint.pydocstyle] convention = "pep257" @@ -84,15 +84,14 @@ convention = "pep257" [tool.isort] profile = "black" -line_length = 88 +line_length = 100 multi_line_output = 3 known_first_party = [ "graphworks",] -[tool.ty] -python-version = "3.13" - -[tool.ty.rules] -strict = true +[tool.ty.environment] +python-platform = "darwin" +python-version = "3.14" +root = [ "./src",] [tool.uv] required-version = ">=0.10.12" diff --git a/src/graphworks/algorithms/directed.py b/src/graphworks/algorithms/directed.py index 1a10ad5..0740adb 100644 --- a/src/graphworks/algorithms/directed.py +++ b/src/graphworks/algorithms/directed.py @@ -1,5 +1,5 @@ -from src.graphworks.algorithms.search import arrival_departure_dfs -from src.graphworks.graph import Graph +from graphworks.algorithms.search import arrival_departure_dfs +from graphworks.graph import Graph def is_dag(graph: Graph) -> bool: diff --git a/src/graphworks/algorithms/properties.py b/src/graphworks/algorithms/properties.py index e39757b..c7ceca5 100644 --- a/src/graphworks/algorithms/properties.py +++ b/src/graphworks/algorithms/properties.py @@ -1,8 +1,4 @@ -""" -graphworks.algorithms.properties -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Graph property queries and structural metrics. +"""Graph property queries and structural metrics. This module provides predicate functions (``is_*``) and quantitative metrics (``density``, ``diameter``, ``degree_sequence``, etc.) that inspect a @@ -10,14 +6,17 @@ All functions are pure: they take a graph (and optional parameters) and return a value. None of the functions here require numpy. - -:author: Nathan Gilbert """ from __future__ import annotations +from typing import TYPE_CHECKING + +from graphworks.algorithms.paths import find_all_paths # avoid circular from graphworks.graph import Graph -from graphworks.types import AdjacencyMatrix + +if TYPE_CHECKING: + from graphworks.types import AdjacencyMatrix # --------------------------------------------------------------------------- # Degree helpers @@ -190,9 +189,10 @@ def is_connected( if len(vertices_encountered) != len(verts): for vertex in graph[start_vertex]: - if vertex not in vertices_encountered: - if is_connected(graph, vertex, vertices_encountered): - return True + if vertex not in vertices_encountered and is_connected( + graph, vertex, vertices_encountered + ): + return True else: return True return False @@ -272,28 +272,21 @@ def density(graph: Graph) -> float: 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. + 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 """ - from graphworks.algorithms.paths import find_all_paths # avoid circular - verts = graph.vertices() - pairs = [ - (verts[i], verts[j]) - for i in range(len(verts) - 1) - for j in range(i + 1, len(verts)) - ] + 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 = find_all_paths(graph, start, end) + all_paths: list[list[str]] = find_all_paths(graph, start, end) if all_paths: - shortest_paths.append(sorted(all_paths, key=len)[0]) + shortest_paths.append(min(all_paths, key=lambda path: len(path))) if not shortest_paths: return 0 diff --git a/src/graphworks/algorithms/search.py b/src/graphworks/algorithms/search.py index dd2c3d6..dcbb82d 100644 --- a/src/graphworks/algorithms/search.py +++ b/src/graphworks/algorithms/search.py @@ -1,4 +1,4 @@ -from src.graphworks.graph import Graph +from graphworks.graph import Graph def breadth_first_search(graph: Graph, start: str) -> list[str]: diff --git a/src/graphworks/algorithms/sort.py b/src/graphworks/algorithms/sort.py index c8cccd3..8c72181 100644 --- a/src/graphworks/algorithms/sort.py +++ b/src/graphworks/algorithms/sort.py @@ -1,4 +1,4 @@ -from src.graphworks.graph import Graph +from graphworks.graph import Graph def topological(graph: Graph) -> list[str]: @@ -8,9 +8,7 @@ def topological(graph: Graph) -> list[str]: :return: List of vertices sorted topologically """ - 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]): v_map[v] = True for n in g.get_neighbors(v): if not v_map[n]: diff --git a/src/graphworks/edge.py b/src/graphworks/edge.py index 0fb6d77..f91dedf 100644 --- a/src/graphworks/edge.py +++ b/src/graphworks/edge.py @@ -1,19 +1,25 @@ +"""Implementation of graph edge between 2 vertices.""" + 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/graphviz_utils.py b/src/graphworks/export/graphviz_utils.py index 7aa821a..5820c49 100755 --- a/src/graphworks/export/graphviz_utils.py +++ b/src/graphworks/export/graphviz_utils.py @@ -2,7 +2,7 @@ from graphviz import Graph as GraphViz -from src.graphworks.graph import Graph +from graphworks.graph import Graph def save_to_dot(graph: Graph, out_dir: str): diff --git a/src/graphworks/export/json_utils.py b/src/graphworks/export/json_utils.py index 9dd1568..e9d2dbd 100644 --- a/src/graphworks/export/json_utils.py +++ b/src/graphworks/export/json_utils.py @@ -1,7 +1,7 @@ import json from os import path -from src.graphworks.graph import Graph +from graphworks.graph import Graph def save_to_json(graph: Graph, out_dir): @@ -17,7 +17,5 @@ def save_to_json(graph: Graph, out_dir): "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 index f223e15..8466b6b 100644 --- a/src/graphworks/graph.py +++ b/src/graphworks/graph.py @@ -1,15 +1,9 @@ -""" -graphworks.graph -~~~~~~~~~~~~~~~~ - -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`. +"""Core graph data structure for the graphworks library. -:author: Nathan Gilbert +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 @@ -18,17 +12,21 @@ import random import uuid from collections import defaultdict +from typing import TYPE_CHECKING from graphworks.edge import Edge -from graphworks.types import AdjacencyMatrix + +if TYPE_CHECKING: + from collections.abc import Iterator + + from graphworks.types import AdjacencyMatrix class Graph: """Implementation of both undirected and directed graphs. - 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. + 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: @@ -41,13 +39,13 @@ class Graph: 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', ...)] + >>> 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__( @@ -57,10 +55,10 @@ def __init__( input_graph: str | None = None, input_matrix: AdjacencyMatrix | None = None, ) -> None: - """Initialise a :class:`Graph`. + """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. + 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 @@ -68,11 +66,11 @@ def __init__( :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. + :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. + :raises ValueError: If *input_matrix* is not square, or if edge endpoints in a JSON graph + reference vertices that do not exist. """ self.__label: str = label if label is not None else "" self.__is_directed: bool = False @@ -89,15 +87,14 @@ def __init__( 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]]." + "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( - "Graph is invalid: edge endpoints reference vertices that do " - "not exist in the vertex set." + "Graph is invalid: edge endpoints reference vertices that do not exist in the " + "vertex set." ) # ------------------------------------------------------------------ @@ -115,8 +112,8 @@ def vertices(self) -> list[str]: 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). + 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] @@ -126,8 +123,7 @@ def edges(self) -> list[Edge]: def get_graph(self) -> defaultdict[str, list[str]]: """Return the raw adjacency-list dictionary. - :return: The underlying ``defaultdict`` mapping vertex names to their - neighbour lists. + :return: The underlying ``defaultdict`` mapping vertex names to their neighbor lists. :rtype: DefaultDict[str, list[str]] """ return self.__graph @@ -143,10 +139,9 @@ def get_label(self) -> str: 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. + :param is_directed: ``True`` for a directed graph, ``False`` for undirected. :type is_directed: bool - :return: None + :return: Nothing :rtype: None """ self.__is_directed = is_directed @@ -172,7 +167,7 @@ def add_vertex(self, vertex: str) -> None: :param vertex: Name of the vertex to add. :type vertex: str - :return: None + :return: Nothing :rtype: None """ if vertex not in self.__graph: @@ -187,7 +182,7 @@ def add_edge(self, vertex1: str, vertex2: str) -> None: :type vertex1: str :param vertex2: Destination vertex name. :type vertex2: str - :return: None + :return: Nothing :rtype: None """ if vertex1 in self.__graph: @@ -296,8 +291,8 @@ def __repr__(self) -> str: 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. + :return: Multi-line string with ``vertex -> neighbours`` per line, preceded by the graph + label. :rtype: str """ lines: list[str] = [] @@ -307,7 +302,7 @@ def __str__(self) -> str: lines.append(f"{key} -> {rhs}") return f"{self.__label}\n" + "\n".join(lines) - def __iter__(self): + def __iter__(self) -> Iterator[str]: """Iterate over vertex names in insertion order. :return: An iterator yielding vertex name strings. @@ -316,12 +311,11 @@ def __iter__(self): return iter(self.vertices()) def __getitem__(self, node: str) -> list[str]: - """Return the neighbour list for *node*. + """Return the neighbor list for *node*. :param node: Vertex name. :type node: str - :return: List of neighbour vertex names, or an empty list if *node* - is not in the graph. + :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, []) @@ -335,7 +329,7 @@ def __extract_fields_from_json(self, json_data: dict) -> None: :param json_data: Parsed JSON representation of the graph. :type json_data: dict - :return: None + :return: Nothing :rtype: None """ self.__label = json_data.get("label", "") @@ -393,17 +387,15 @@ def __matrix_to_graph(self, matrix: AdjacencyMatrix) -> None: Vertex names are generated as UUID strings to guarantee uniqueness. - :param matrix: Square adjacency matrix where non-zero values denote - edges. + :param matrix: Square adjacency matrix where non-zero values denote edges. :type matrix: AdjacencyMatrix - :return: None + :return: Nothing :rtype: None """ n = len(matrix) names = [str(uuid.uuid4()) for _ in range(n)] for r_idx in range(n): vertex = names[r_idx] - self.__graph[vertex] # ensure key exists via defaultdict for c_idx, val in enumerate(matrix[r_idx]): if val > 0: self.__graph[vertex].append(names[c_idx]) diff --git a/tests/test_directed.py b/tests/test_directed.py index eb95061..937dc36 100644 --- a/tests/test_directed.py +++ b/tests/test_directed.py @@ -13,8 +13,8 @@ import json -from src.graphworks.algorithms.directed import find_circuit, is_dag -from src.graphworks.graph import Graph +from graphworks.algorithms.directed import find_circuit, is_dag +from graphworks.graph import Graph class TestIsDag: diff --git a/tests/test_export.py b/tests/test_export.py index 6589b65..97bded4 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -16,6 +16,7 @@ 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 @@ -29,9 +30,7 @@ def test_output_file_is_created(self, simple_edge_graph, tmp_dir: Path) -> None: 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: + 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" @@ -45,9 +44,7 @@ def test_output_contains_label(self, simple_edge_graph, tmp_dir: Path) -> None: 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: + 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" @@ -55,14 +52,9 @@ def test_output_contains_directed_flag( assert "directed" in data assert data["directed"] is False - def test_output_matches_expected_string( - self, simple_edge_graph, tmp_dir: Path - ) -> None: + 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": []}}' - ) + 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 @@ -87,18 +79,14 @@ def _skip_if_no_graphviz(self) -> None: 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.""" - from src.graphworks.export.graphviz_utils import save_to_dot 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: + def test_dot_content_matches_expected(self, simple_edge_graph, tmp_dir: Path) -> None: """The .gv file contains the expected Graphviz dot language content.""" - from src.graphworks.export.graphviz_utils import save_to_dot 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)) @@ -107,7 +95,6 @@ def test_dot_content_matches_expected( def test_directed_graph_skipped_by_save_to_dot(self, tmp_dir: Path) -> None: """save_to_dot silently skips directed graphs (undirected only).""" - from src.graphworks.export.graphviz_utils import save_to_dot data = {"directed": True, "label": "d", "graph": {"A": ["B"], "B": []}} graph = Graph(input_graph=json.dumps(data)) diff --git a/tests/test_graph.py b/tests/test_graph.py index 18a3d57..d683583 100644 --- a/tests/test_graph.py +++ b/tests/test_graph.py @@ -13,7 +13,7 @@ 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 src.graphworks.edge import Edge`` resolve to two + 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 diff --git a/uv.lock b/uv.lock index 804276e..cac5db8 100644 --- a/uv.lock +++ b/uv.lock @@ -653,16 +653,16 @@ wheels = [ [[package]] name = "pytest-cov" -version = "7.0.0" +version = "7.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "coverage" }, { name = "pluggy" }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +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/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, + { 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]] From 328f1039cd5cf51c121744d7308b51ab707717db Mon Sep 17 00:00:00 2001 From: Nathan Gilbert Date: Sun, 22 Mar 2026 10:36:35 -0600 Subject: [PATCH 09/22] Fix some conftest stuff --- conftest.py | 33 +++++++++++++++------------------ 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/conftest.py b/conftest.py index 5ea950f..8f425d9 100644 --- a/conftest.py +++ b/conftest.py @@ -1,8 +1,4 @@ -""" -tests.conftest -~~~~~~~~~~~~~~ - -Shared pytest fixtures for the graphworks test suite. +"""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. @@ -23,8 +19,6 @@ 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. - -:author: Nathan Gilbert """ from __future__ import annotations @@ -32,8 +26,11 @@ import json import shutil import tempfile -from collections.abc import Generator from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Generator import pytest @@ -282,7 +279,7 @@ def disjoint_directed_json() -> dict: @pytest.fixture() -def simple_edge_graph(simple_edge_json) -> Graph: +def simple_edge_graph(simple_edge_json: dict) -> Graph: """Two-vertex undirected :class:`Graph` with one edge (A → B). :return: Constructed Graph instance. @@ -292,7 +289,7 @@ def simple_edge_graph(simple_edge_json) -> Graph: @pytest.fixture() -def triangle_graph(triangle_json) -> Graph: +def triangle_graph(triangle_json: dict) -> Graph: """Complete undirected :class:`Graph` on three vertices (K₃). :return: Constructed Graph instance. @@ -302,7 +299,7 @@ def triangle_graph(triangle_json) -> Graph: @pytest.fixture() -def isolated_graph(isolated_json) -> Graph: +def isolated_graph(isolated_json: dict) -> Graph: """Three-vertex :class:`Graph` with no edges. :return: Constructed Graph instance. @@ -312,7 +309,7 @@ def isolated_graph(isolated_json) -> Graph: @pytest.fixture() -def connected_graph(connected_json) -> Graph: +def connected_graph(connected_json: dict) -> Graph: """Six-vertex connected undirected :class:`Graph`. :return: Constructed Graph instance. @@ -322,7 +319,7 @@ def connected_graph(connected_json) -> Graph: @pytest.fixture() -def big_graph(big_graph_json) -> Graph: +def big_graph(big_graph_json: dict) -> Graph: """Six-vertex connected undirected :class:`Graph` for diameter tests. :return: Constructed Graph instance. @@ -332,7 +329,7 @@ def big_graph(big_graph_json) -> Graph: @pytest.fixture() -def directed_dag(directed_dag_json) -> Graph: +def directed_dag(directed_dag_json: dict) -> Graph: """Directed acyclic :class:`Graph`. :return: Constructed Graph instance. @@ -342,7 +339,7 @@ def directed_dag(directed_dag_json) -> Graph: @pytest.fixture() -def directed_cycle_graph(directed_cycle_json) -> Graph: +def directed_cycle_graph(directed_cycle_json: dict) -> Graph: """Directed :class:`Graph` containing a cycle. :return: Constructed Graph instance. @@ -352,7 +349,7 @@ def directed_cycle_graph(directed_cycle_json) -> Graph: @pytest.fixture() -def circuit_graph(circuit_json) -> Graph: +def circuit_graph(circuit_json: dict) -> Graph: """Directed :class:`Graph` with an Eulerian circuit A → B → C → A. :return: Constructed Graph instance. @@ -362,7 +359,7 @@ def circuit_graph(circuit_json) -> Graph: @pytest.fixture() -def search_graph(search_graph_json) -> Graph: +def search_graph(search_graph_json: dict) -> Graph: """Four-vertex :class:`Graph` for BFS / DFS tests. :return: Constructed Graph instance. @@ -372,7 +369,7 @@ def search_graph(search_graph_json) -> Graph: @pytest.fixture() -def disjoint_directed_graph(disjoint_directed_json) -> Graph: +def disjoint_directed_graph(disjoint_directed_json: dict) -> Graph: """Directed :class:`Graph` with two disjoint components. :return: Constructed Graph instance. From 264216f570ab761a4f0285181853b2f57c1fe57d Mon Sep 17 00:00:00 2001 From: Nathan Gilbert Date: Sun, 22 Mar 2026 17:31:11 -0600 Subject: [PATCH 10/22] Fix all ruff warnings --- .pre-commit-config.yaml | 1 + src/graphworks/__init__.py | 2 ++ src/graphworks/algorithms/__init__.py | 6 +--- src/graphworks/algorithms/directed.py | 25 +++++++++++--- src/graphworks/algorithms/paths.py | 13 ++++--- src/graphworks/algorithms/search.py | 45 +++++++++++++++++-------- src/graphworks/algorithms/sort.py | 28 +++++++++++++-- src/graphworks/export/__init__.py | 1 + src/graphworks/export/graphviz_utils.py | 15 ++++++--- src/graphworks/export/json_utils.py | 15 ++++++--- src/graphworks/graph.py | 6 +++- src/graphworks/numpy_compat.py | 31 +++++++++-------- src/graphworks/types.py | 8 +---- tests/__init__.py | 6 +--- tests/test_export.py | 5 ++- tests/test_graph.py | 13 ++++--- tests/test_numpy_compat.py | 4 +-- tests/test_search.py | 34 +++++++++---------- 18 files changed, 159 insertions(+), 99 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0db5fa1..16d6c3b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -29,6 +29,7 @@ repos: rev: v0.0.24 hooks: - id: ty-check + language: system - repo: https://github.com/astral-sh/uv-pre-commit rev: 0.10.12 hooks: diff --git a/src/graphworks/__init__.py b/src/graphworks/__init__.py index 62ba05a..8e1c957 100644 --- a/src/graphworks/__init__.py +++ b/src/graphworks/__init__.py @@ -1 +1,3 @@ +"""Graphworks package.""" + __author__ = "Nathan Gilbert" diff --git a/src/graphworks/algorithms/__init__.py b/src/graphworks/algorithms/__init__.py index 4d729bf..59b1e06 100644 --- a/src/graphworks/algorithms/__init__.py +++ b/src/graphworks/algorithms/__init__.py @@ -1,8 +1,4 @@ -""" -graphworks.algorithms -~~~~~~~~~~~~~~~~~~~~~ - -Graph algorithm implementations. +"""Graph algorithm implementations. Submodules ---------- diff --git a/src/graphworks/algorithms/directed.py b/src/graphworks/algorithms/directed.py index 0740adb..2bf81f9 100644 --- a/src/graphworks/algorithms/directed.py +++ b/src/graphworks/algorithms/directed.py @@ -1,12 +1,20 @@ +"""Directed graph utilities.""" + +from typing import TYPE_CHECKING + from graphworks.algorithms.search import arrival_departure_dfs -from graphworks.graph import Graph + +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 @@ -41,6 +49,13 @@ def is_dag(graph: Graph) -> bool: 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) @@ -49,10 +64,12 @@ def build_neighbor_matrix(graph: Graph) -> dict[str, list[str]]: def find_circuit(graph: Graph) -> list[str]: - """ - Using Hierholzer’s algorithm to find an eulerian circuit + """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 [] diff --git a/src/graphworks/algorithms/paths.py b/src/graphworks/algorithms/paths.py index 18c971d..9d3c551 100644 --- a/src/graphworks/algorithms/paths.py +++ b/src/graphworks/algorithms/paths.py @@ -1,8 +1,4 @@ -""" -graphworks.algorithms.paths -~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Path-finding and edge-generation utilities. +"""Path-finding and edge-generation utilities. This module provides functions for discovering paths between vertices, generating edge lists, and finding structurally isolated vertices. All @@ -14,8 +10,11 @@ from __future__ import annotations -from graphworks.edge import Edge -from graphworks.graph import Graph +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]: diff --git a/src/graphworks/algorithms/search.py b/src/graphworks/algorithms/search.py index dcbb82d..08caa8e 100644 --- a/src/graphworks/algorithms/search.py +++ b/src/graphworks/algorithms/search.py @@ -1,12 +1,20 @@ -from graphworks.graph import Graph +"""This module implements DFS with arrival and departure times.""" + +from typing import TYPE_CHECKING + +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 = dict.fromkeys(graph.vertices(), False) @@ -27,11 +35,14 @@ def breadth_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: @@ -50,18 +61,24 @@ def arrival_departure_dfs( departure: dict[str, int], time: int, ) -> int: - """ - Method for DFS with arrival and departure times for each vertex + """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 8c72181..855ba4a 100644 --- a/src/graphworks/algorithms/sort.py +++ b/src/graphworks/algorithms/sort.py @@ -1,14 +1,36 @@ -from graphworks.graph import Graph +"""Sorting algorithms.""" + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from graphworks.graph import Graph def topological(graph: Graph) -> list[str]: - """ + """Topological sort. + 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]: 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 5820c49..1bc7a23 100755 --- a/src/graphworks/export/graphviz_utils.py +++ b/src/graphworks/export/graphviz_utils.py @@ -1,16 +1,23 @@ +"""Graphviz utilities.""" + from os import path +from typing import TYPE_CHECKING from graphviz import Graph as GraphViz -from 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 e9d2dbd..2868a8e 100644 --- a/src/graphworks/export/json_utils.py +++ b/src/graphworks/export/json_utils.py @@ -1,15 +1,22 @@ +"""JSON utilities.""" + import json from os import path +from typing import TYPE_CHECKING -from 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(), diff --git a/src/graphworks/graph.py b/src/graphworks/graph.py index 8466b6b..cf639fc 100644 --- a/src/graphworks/graph.py +++ b/src/graphworks/graph.py @@ -335,7 +335,11 @@ def __extract_fields_from_json(self, json_data: dict) -> 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]: """Build and return the edge list from the adjacency list. diff --git a/src/graphworks/numpy_compat.py b/src/graphworks/numpy_compat.py index 8c94fb9..9295036 100644 --- a/src/graphworks/numpy_compat.py +++ b/src/graphworks/numpy_compat.py @@ -1,8 +1,4 @@ -""" -graphworks.numpy_compat -~~~~~~~~~~~~~~~~~~~~~~~ - -Optional numpy interop for graphworks. +"""Optional numpy interop for graphworks. This module is **only available** when the ``[matrix]`` extra is installed:: @@ -15,33 +11,36 @@ 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 + >>> 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 graphworks.types import AdjacencyMatrix +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from numpy.typing import NDArray + + from graphworks.types import AdjacencyMatrix try: import numpy as np - from numpy.typing import NDArray except ImportError as exc: # pragma: no cover raise ImportError( - "numpy is required for numpy interop. " - "Install it with: pip install graphworks[matrix]" + "numpy is required for numpy interop. " "Install it with: pip install graphworks[matrix]" ) from exc def ndarray_to_matrix(arr: NDArray) -> AdjacencyMatrix: - """Convert a numpy ndarray adjacency representation to an :data:`~graphworks.types.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. + 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 diff --git a/src/graphworks/types.py b/src/graphworks/types.py index 42fc19a..feeba1d 100644 --- a/src/graphworks/types.py +++ b/src/graphworks/types.py @@ -1,13 +1,7 @@ -""" -graphworks.types -~~~~~~~~~~~~~~~~ - -Shared type aliases used throughout the graphworks library. +"""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. - -:author: Nathan Gilbert """ from __future__ import annotations diff --git a/tests/__init__.py b/tests/__init__.py index e2631c2..fe3fb26 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,8 +1,4 @@ -""" -tests -~~~~~ - -Graphworks test suite. +"""Graphworks test suite. Run with:: diff --git a/tests/test_export.py b/tests/test_export.py index 97bded4..cfb2572 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -12,7 +12,10 @@ from __future__ import annotations import json -from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pathlib import Path import pytest diff --git a/tests/test_graph.py b/tests/test_graph.py index d683583..2dd3b88 100644 --- a/tests/test_graph.py +++ b/tests/test_graph.py @@ -1,8 +1,4 @@ -""" -tests.test_graph -~~~~~~~~~~~~~~~~ - -Unit and integration tests for :class:`graphworks.graph.Graph`. +"""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, @@ -25,7 +21,10 @@ from __future__ import annotations import json -from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pathlib import Path import pytest @@ -489,7 +488,7 @@ def test_iter_visits_all_vertices(self) -> None: """ data = {"graph": {"A": ["B", "C", "D"], "B": [], "C": [], "D": []}} graph = Graph(input_graph=json.dumps(data)) - assert sorted(list(graph)) == ["A", "B", "C", "D"] + assert sorted(graph) == ["A", "B", "C", "D"] def test_iter_count(self) -> None: """Number of iterations equals the number of vertices. diff --git a/tests/test_numpy_compat.py b/tests/test_numpy_compat.py index 03fec72..637fabb 100644 --- a/tests/test_numpy_compat.py +++ b/tests/test_numpy_compat.py @@ -20,9 +20,7 @@ import pytest -numpy = pytest.importorskip( - "numpy", reason="numpy not installed — skipping matrix tests" -) +numpy = pytest.importorskip("numpy", reason="numpy not installed — skipping matrix tests") np = numpy from graphworks.graph import Graph # noqa: E402 diff --git a/tests/test_search.py b/tests/test_search.py index 8345286..69e41f3 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -27,7 +27,7 @@ class TestBreadthFirstSearch: def test_bfs_from_c(self, search_graph) -> None: """BFS from 'c' visits all reachable vertices in level order. - :return: None + :return: Nothing :rtype: None """ walk = breadth_first_search(search_graph, "c") @@ -36,7 +36,7 @@ def test_bfs_from_c(self, search_graph) -> None: def test_bfs_visits_all_vertices(self, search_graph) -> None: """BFS visits every vertex in the connected graph. - :return: None + :return: Nothing :rtype: None """ walk = breadth_first_search(search_graph, "a") @@ -45,7 +45,7 @@ def test_bfs_visits_all_vertices(self, search_graph) -> None: def test_bfs_single_vertex(self) -> None: """BFS on a single-vertex graph returns just that vertex. - :return: None + :return: Nothing :rtype: None """ g = Graph(input_graph=json.dumps({"graph": {"x": []}})) @@ -54,7 +54,7 @@ def test_bfs_single_vertex(self) -> None: def test_bfs_start_vertex_is_first(self, search_graph) -> None: """BFS walk always begins with the given start vertex. - :return: None + :return: Nothing :rtype: None """ walk = breadth_first_search(search_graph, "b") @@ -63,7 +63,7 @@ def test_bfs_start_vertex_is_first(self, search_graph) -> None: def test_bfs_no_duplicates(self, search_graph) -> None: """BFS never visits a vertex more than once. - :return: None + :return: Nothing :rtype: None """ walk = breadth_first_search(search_graph, "a") @@ -76,7 +76,7 @@ class TestDepthFirstSearch: def test_dfs_from_c(self, search_graph) -> None: """DFS from 'c' visits vertices in depth-first order. - :return: None + :return: Nothing :rtype: None """ walk = depth_first_search(search_graph, "c") @@ -85,7 +85,7 @@ def test_dfs_from_c(self, search_graph) -> None: def test_dfs_visits_all_vertices(self, search_graph) -> None: """DFS visits every vertex in the connected graph. - :return: None + :return: Nothing :rtype: None """ walk = depth_first_search(search_graph, "a") @@ -94,7 +94,7 @@ def test_dfs_visits_all_vertices(self, search_graph) -> None: def test_dfs_start_vertex_is_first(self, search_graph) -> None: """DFS walk always begins with the given start vertex. - :return: None + :return: Nothing :rtype: None """ walk = depth_first_search(search_graph, "a") @@ -103,7 +103,7 @@ def test_dfs_start_vertex_is_first(self, search_graph) -> None: def test_dfs_no_duplicates(self, search_graph) -> None: """DFS never visits a vertex more than once. - :return: None + :return: Nothing :rtype: None """ walk = depth_first_search(search_graph, "a") @@ -121,7 +121,7 @@ def test_dfs_shared_neighbour_visited_only_once(self) -> None: 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: None + :return: Nothing :rtype: None """ data = {"graph": {"a": ["b", "c"], "b": [], "c": ["b"]}} @@ -151,19 +151,17 @@ def _run_full_traversal( time = -1 for v in graph.vertices(): if not discovered[v]: - time = arrival_departure_dfs( - graph, v, discovered, arrival, departure, time - ) + 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: None + :return: Nothing :rtype: None """ arrival, departure, _ = self._run_full_traversal(disjoint_directed_graph) - result = list(zip(arrival.values(), departure.values())) + result = list(zip(arrival.values(), departure.values(), strict=False)) expected = [ (0, 11), (1, 2), @@ -179,7 +177,7 @@ def test_arrival_departure_times(self, disjoint_directed_graph) -> None: def test_all_vertices_discovered(self, disjoint_directed_graph) -> None: """Every vertex is discovered after a full traversal. - :return: None + :return: Nothing :rtype: None """ _, _, discovered = self._run_full_traversal(disjoint_directed_graph) @@ -188,7 +186,7 @@ def test_all_vertices_discovered(self, disjoint_directed_graph) -> None: def test_departure_always_after_arrival(self, disjoint_directed_graph) -> None: """Departure time is strictly greater than arrival time for every vertex. - :return: None + :return: Nothing :rtype: None """ arrival, departure, _ = self._run_full_traversal(disjoint_directed_graph) @@ -200,7 +198,7 @@ def test_departure_always_after_arrival(self, disjoint_directed_graph) -> None: def test_times_are_unique(self, disjoint_directed_graph) -> None: """No two events (arrival or departure) share the same timestamp. - :return: None + :return: Nothing :rtype: None """ arrival, departure, _ = self._run_full_traversal(disjoint_directed_graph) From 682e4ab3cd72957cc23b07f2b7891f050e5a0b0a Mon Sep 17 00:00:00 2001 From: Nathan Gilbert Date: Sun, 22 Mar 2026 17:34:57 -0600 Subject: [PATCH 11/22] Fix the test --- src/graphworks/graph.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/graphworks/graph.py b/src/graphworks/graph.py index cf639fc..e25721a 100644 --- a/src/graphworks/graph.py +++ b/src/graphworks/graph.py @@ -400,6 +400,8 @@ def __matrix_to_graph(self, matrix: AdjacencyMatrix) -> None: names = [str(uuid.uuid4()) for _ in range(n)] for r_idx in range(n): vertex = names[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: self.__graph[vertex].append(names[c_idx]) From 1cb5133f8226f9c4d2a60cb5d97d4964785b5c16 Mon Sep 17 00:00:00 2001 From: Nathan Gilbert Date: Sun, 22 Mar 2026 17:39:38 -0600 Subject: [PATCH 12/22] Skip _version file in isort --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 14356f7..26a77c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,6 +87,7 @@ profile = "black" line_length = 100 multi_line_output = 3 known_first_party = [ "graphworks",] +skip = [ "src/graphworks/_version.py",] [tool.ty.environment] python-platform = "darwin" From ee4b98ded6c81b804d0298028bb4db9d8e71a706 Mon Sep 17 00:00:00 2001 From: Nathan Gilbert Date: Sun, 22 Mar 2026 17:42:52 -0600 Subject: [PATCH 13/22] Fix some github actions --- src/graphworks/algorithms/directed.py | 2 ++ src/graphworks/algorithms/search.py | 2 ++ src/graphworks/algorithms/sort.py | 2 ++ src/graphworks/edge.py | 2 ++ src/graphworks/export/graphviz_utils.py | 2 ++ src/graphworks/export/json_utils.py | 2 ++ 6 files changed, 12 insertions(+) diff --git a/src/graphworks/algorithms/directed.py b/src/graphworks/algorithms/directed.py index 2bf81f9..ce8523d 100644 --- a/src/graphworks/algorithms/directed.py +++ b/src/graphworks/algorithms/directed.py @@ -1,5 +1,7 @@ """Directed graph utilities.""" +from __future__ import annotations + from typing import TYPE_CHECKING from graphworks.algorithms.search import arrival_departure_dfs diff --git a/src/graphworks/algorithms/search.py b/src/graphworks/algorithms/search.py index 08caa8e..6180c67 100644 --- a/src/graphworks/algorithms/search.py +++ b/src/graphworks/algorithms/search.py @@ -1,5 +1,7 @@ """This module implements DFS with arrival and departure times.""" +from __future__ import annotations + from typing import TYPE_CHECKING if TYPE_CHECKING: diff --git a/src/graphworks/algorithms/sort.py b/src/graphworks/algorithms/sort.py index 855ba4a..ba294ed 100644 --- a/src/graphworks/algorithms/sort.py +++ b/src/graphworks/algorithms/sort.py @@ -1,5 +1,7 @@ """Sorting algorithms.""" +from __future__ import annotations + from typing import TYPE_CHECKING if TYPE_CHECKING: diff --git a/src/graphworks/edge.py b/src/graphworks/edge.py index f91dedf..f039264 100644 --- a/src/graphworks/edge.py +++ b/src/graphworks/edge.py @@ -1,5 +1,7 @@ """Implementation of graph edge between 2 vertices.""" +from __future__ import annotations + from dataclasses import dataclass diff --git a/src/graphworks/export/graphviz_utils.py b/src/graphworks/export/graphviz_utils.py index 1bc7a23..7c8fad9 100755 --- a/src/graphworks/export/graphviz_utils.py +++ b/src/graphworks/export/graphviz_utils.py @@ -1,5 +1,7 @@ """Graphviz utilities.""" +from __future__ import annotations + from os import path from typing import TYPE_CHECKING diff --git a/src/graphworks/export/json_utils.py b/src/graphworks/export/json_utils.py index 2868a8e..f625ace 100644 --- a/src/graphworks/export/json_utils.py +++ b/src/graphworks/export/json_utils.py @@ -1,5 +1,7 @@ """JSON utilities.""" +from __future__ import annotations + import json from os import path from typing import TYPE_CHECKING From da60a15776d6f71ac5374d64702033fbbeb7e694 Mon Sep 17 00:00:00 2001 From: Nathan Gilbert Date: Sun, 22 Mar 2026 17:45:43 -0600 Subject: [PATCH 14/22] Fix linter job --- .github/workflows/python-package-ci.yml | 2 +- pyproject.toml | 2 +- uv.lock | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python-package-ci.yml b/.github/workflows/python-package-ci.yml index e1c25a1..03dfab6 100644 --- a/.github/workflows/python-package-ci.yml +++ b/.github/workflows/python-package-ci.yml @@ -15,7 +15,7 @@ jobs: with: version: ">=0.10.12" - name: Install dependencies - run: uv sync --extra dev + run: uv sync --extra all - name: Ruff check run: uv run ruff check src/ tests/ - name: Black check diff --git a/pyproject.toml b/pyproject.toml index 26a77c4..91442b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,7 +50,7 @@ matrix = [ "numpy",] viz = [ "graphviz",] docs = [ "myst-parser", "sphinx", "sphinx-autodoc-typehints", "sphinx-rtd-theme",] dev = [ "black", "import-linter", "isort", "pytest", "pytest-cov", "ruff", "ty", "xenon",] -all = [ "graphworks[dev,docs,viz]",] +all = [ "graphworks[dev,docs,matrix,viz]",] [tool.hatch.version] source = "vcs" diff --git a/uv.lock b/uv.lock index cac5db8..873f21c 100644 --- a/uv.lock +++ b/uv.lock @@ -235,6 +235,7 @@ all = [ { name = "import-linter" }, { name = "isort" }, { name = "myst-parser" }, + { name = "numpy" }, { name = "pytest" }, { name = "pytest-cov" }, { name = "ruff" }, @@ -279,6 +280,7 @@ requires-dist = [ { name = "isort", marker = "extra == 'dev'" }, { name = "myst-parser", marker = "extra == 'all'" }, { name = "myst-parser", marker = "extra == 'docs'" }, + { name = "numpy", marker = "extra == 'all'" }, { name = "numpy", marker = "extra == 'matrix'" }, { name = "pytest", marker = "extra == 'all'" }, { name = "pytest", marker = "extra == 'dev'" }, From fc792cf1ffc538033bc7111f1d8984408e4ad909 Mon Sep 17 00:00:00 2001 From: Nathan Gilbert Date: Sun, 22 Mar 2026 17:48:46 -0600 Subject: [PATCH 15/22] Reduce CI jobs --- .github/workflows/python-package-ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/python-package-ci.yml b/.github/workflows/python-package-ci.yml index 03dfab6..40e56dd 100644 --- a/.github/workflows/python-package-ci.yml +++ b/.github/workflows/python-package-ci.yml @@ -2,6 +2,8 @@ name: CI on: push: + branches: + - main pull_request: branches: - main From 82b59f3f6f20c173c1850a1755df9418e5e879a9 Mon Sep 17 00:00:00 2001 From: Nathan Gilbert Date: Sun, 22 Mar 2026 17:58:27 -0600 Subject: [PATCH 16/22] Add demo script --- examples/README.md | 35 ++++++ examples/__init__.py | 1 + examples/demo.py | 252 +++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 7 ++ 4 files changed, 295 insertions(+) create mode 100644 examples/README.md create mode 100644 examples/__init__.py create mode 100644 examples/demo.py diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..95804ce --- /dev/null +++ b/examples/README.md @@ -0,0 +1,35 @@ +# 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 +``` + +## Adding new examples + +1. Create a new `.py` file in this directory. +2. Give it a `main()` entry point. +3. Register it in `pyproject.toml` under `[project.scripts]`: + + ```toml + [project.scripts] + demo = "examples.demo:main" # existing + my-example = "examples.my_example:main" # new + ``` + +4. Make sure `"examples"` appears in + `[tool.hatch.build.targets.sdist].exclude` so examples stay out of the published package. diff --git a/examples/__init__.py b/examples/__init__.py new file mode 100644 index 0000000..f8e9941 --- /dev/null +++ b/examples/__init__.py @@ -0,0 +1 @@ +"""Demo package.""" diff --git a/examples/demo.py b/examples/demo.py new file mode 100644 index 0000000..820f0a5 --- /dev/null +++ b/examples/demo.py @@ -0,0 +1,252 @@ +#!/usr/bin/env python3 +"""Graphworks demo — a quick tour of the library's core features. + +Run with:: + + uv run demo + +Or directly:: + + uv run python examples/demo.py + +This script is **not** shipped with the library. It lives in the ``examples/`` +directory and is registered as a ``[project.scripts]`` entry point for +convenience during development. +""" + +from __future__ import annotations + +import json + +from graphworks.algorithms import ( + breadth_first_search, + degree_sequence, + density, + depth_first_search, + diameter, + find_all_paths, + find_isolated_vertices, + find_path, + get_complement, + is_complete, + is_connected, + is_dag, + is_regular, + is_simple, + topological, +) +from graphworks.graph import Graph + + +def _section(title: str) -> None: + """Print a section header to stdout. + + :param title: Section heading text. + :type title: str + :return: Nothing. + :rtype: None + """ + width = 60 + print(f"\n{'─' * width}") + print(f" {title}") + print(f"{'─' * width}") + + +def demo_construction() -> Graph: + """Demonstrate the three ways to construct a Graph. + + :return: The JSON-constructed graph used in subsequent demos. + :rtype: Graph + """ + _section("1 · Graph construction") + + # --- From a JSON string --- + 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)) + print(f"From JSON string → {graph.order()} vertices, {graph.size()} edges") + print(f" label : {graph.get_label()}") + print(f" verts : {graph.vertices()}") + + # --- From an adjacency matrix --- + matrix = [ + [0, 1, 0], + [1, 0, 1], + [0, 1, 0], + ] + matrix_graph = Graph(input_matrix=matrix) + print(f"\nFrom matrix → {matrix_graph.order()} vertices, {matrix_graph.size()} edges") + + # --- Programmatic construction --- + manual = Graph("manual") + for v in ("X", "Y", "Z"): + manual.add_vertex(v) + manual.add_edge("X", "Y") + manual.add_edge("Y", "Z") + print(f"Programmatic → {manual.order()} vertices, {manual.size()} edges") + + return graph + + +def demo_properties(graph: Graph) -> None: + """Show structural property queries. + + :param graph: An undirected graph to inspect. + :type graph: Graph + :return: Nothing. + :rtype: None + """ + _section("2 · Structural properties") + + print(f" connected? {is_connected(graph)}") + print(f" complete? {is_complete(graph)}") + print(f" simple? {is_simple(graph)}") + print(f" regular? {is_regular(graph)}") + print(f" density {density(graph):.4f}") + print(f" diameter {diameter(graph)}") + print(f" deg sequence {degree_sequence(graph)}") + + isolated = find_isolated_vertices(graph) + print(f" isolated {isolated if isolated else '(none)'}") + + +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("3 · Traversals") + + start = graph.vertices()[0] + bfs = breadth_first_search(graph, start) + dfs = depth_first_search(graph, start) + print(f" BFS from {start!r}: {bfs}") + print(f" DFS from {start!r}: {dfs}") + + +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("4 · Path finding") + + src, dst = "Alice", "Eve" + single = find_path(graph, src, dst) + print(f" One path {src} → {dst}: {single}") + + all_paths = find_all_paths(graph, src, dst) + print(f" All paths ({len(all_paths)} total):") + for p in all_paths: + print(f" {' → '.join(p)}") + + +def demo_complement(graph: Graph) -> None: + """Show the complement graph. + + :param graph: An undirected graph. + :type graph: Graph + :return: Nothing. + :rtype: None + """ + _section("5 · Complement graph") + + comp = get_complement(graph) + print(f" Original : {graph.order()} vertices, {graph.size()} edges") + print(f" Complement: {comp.order()} vertices, {comp.size()} edges") + + +def demo_directed() -> None: + """Demonstrate directed graph features: DAG detection and topological sort. + + :return: Nothing. + :rtype: None + """ + _section("6 · 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)) + print(f" DAG? {is_dag(dag)}") + print(f" Topo sort {topological(dag)}") + + # Introduce a cycle: deploy → lint → … → deploy + cycle_def = { + "directed": True, + "label": "cyclic pipeline", + "graph": { + "lint": ["compile"], + "compile": ["test"], + "test": ["deploy"], + "deploy": ["lint"], + }, + } + cyclic = Graph(input_graph=json.dumps(cycle_def)) + print(f"\n Cyclic graph DAG? {is_dag(cyclic)}") + + +def demo_iteration(graph: Graph) -> None: + """Show iteration and subscript access on a graph. + + :param graph: An undirected graph. + :type graph: Graph + :return: Nothing. + :rtype: None + """ + _section("7 · Iteration & subscript access") + + for vertex in graph: + neighbours = graph[vertex] + print(f" {vertex:>8} → {neighbours}") + + +def main() -> None: + """Run all demo sections. + + :return: Nothing. + :rtype: None + """ + print("=" * 60) + print(" graphworks — library demo") + print("=" * 60) + + graph = demo_construction() + demo_properties(graph) + demo_traversal(graph) + demo_paths(graph) + demo_complement(graph) + demo_directed() + demo_iteration(graph) + + print(f"\n{'─' * 60}") + print(" Done! Explore the source at src/graphworks/") + print(f"{'─' * 60}\n") + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 91442b2..5262e51 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,9 @@ Repository = "https://github.com/nathan-gilbert/graphworks" 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",] @@ -55,11 +58,15 @@ all = [ "graphworks[dev,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",] From aa231c2d1e08cdc5a2f05863aa9f0fd5411a804a Mon Sep 17 00:00:00 2001 From: Nathan Gilbert Date: Sun, 22 Mar 2026 18:00:42 -0600 Subject: [PATCH 17/22] Add dependency group --- pyproject.toml | 6 +++-- uv.lock | 60 +++++++++++++++++++++----------------------------- 2 files changed, 29 insertions(+), 37 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5262e51..ceae9ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,8 +52,7 @@ demo = "examples.demo:main" matrix = [ "numpy",] viz = [ "graphviz",] docs = [ "myst-parser", "sphinx", "sphinx-autodoc-typehints", "sphinx-rtd-theme",] -dev = [ "black", "import-linter", "isort", "pytest", "pytest-cov", "ruff", "ty", "xenon",] -all = [ "graphworks[dev,docs,matrix,viz]",] +all = [ "graphworks[docs,matrix,viz]",] [tool.hatch.version] source = "vcs" @@ -138,3 +137,6 @@ 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/uv.lock b/uv.lock index 873f21c..8a54dd5 100644 --- a/uv.lock +++ b/uv.lock @@ -230,30 +230,12 @@ source = { editable = "." } [package.optional-dependencies] all = [ - { name = "black" }, { name = "graphviz" }, - { name = "import-linter" }, - { name = "isort" }, { name = "myst-parser" }, { name = "numpy" }, - { name = "pytest" }, - { name = "pytest-cov" }, - { name = "ruff" }, { name = "sphinx" }, { name = "sphinx-autodoc-typehints" }, { name = "sphinx-rtd-theme" }, - { name = "ty" }, - { name = "xenon" }, -] -dev = [ - { name = "black" }, - { name = "import-linter" }, - { name = "isort" }, - { name = "pytest" }, - { name = "pytest-cov" }, - { name = "ruff" }, - { name = "ty" }, - { name = "xenon" }, ] docs = [ { name = "myst-parser" }, @@ -268,38 +250,46 @@ viz = [ { name = "graphviz" }, ] +[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 = "black", marker = "extra == 'all'" }, - { name = "black", marker = "extra == 'dev'" }, { name = "graphviz", marker = "extra == 'all'" }, { name = "graphviz", marker = "extra == 'viz'" }, - { name = "import-linter", marker = "extra == 'all'" }, - { name = "import-linter", marker = "extra == 'dev'" }, - { name = "isort", marker = "extra == 'all'" }, - { name = "isort", marker = "extra == 'dev'" }, { name = "myst-parser", marker = "extra == 'all'" }, { name = "myst-parser", marker = "extra == 'docs'" }, { name = "numpy", marker = "extra == 'all'" }, { name = "numpy", marker = "extra == 'matrix'" }, - { name = "pytest", marker = "extra == 'all'" }, - { name = "pytest", marker = "extra == 'dev'" }, - { name = "pytest-cov", marker = "extra == 'all'" }, - { name = "pytest-cov", marker = "extra == 'dev'" }, - { name = "ruff", marker = "extra == 'all'" }, - { name = "ruff", marker = "extra == 'dev'" }, { 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'" }, - { name = "ty", marker = "extra == 'all'" }, - { name = "ty", marker = "extra == 'dev'" }, - { name = "xenon", marker = "extra == 'all'" }, - { name = "xenon", marker = "extra == 'dev'" }, ] -provides-extras = ["all", "dev", "docs", "matrix", "viz"] +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" From 1b9f05cb8295f903d8ff34368d080b4918d91bcb Mon Sep 17 00:00:00 2001 From: Nathan Gilbert Date: Sun, 22 Mar 2026 18:23:14 -0600 Subject: [PATCH 18/22] Better demo --- examples/README.md | 23 +--- examples/__init__.py | 7 +- examples/demo.py | 299 ++++++++++++++++++++++++++++++++++--------- pyproject.toml | 4 +- uv.lock | 4 + 5 files changed, 250 insertions(+), 87 deletions(-) diff --git a/examples/README.md b/examples/README.md index 95804ce..e83615b 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,13 +1,11 @@ # 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). +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`: +The recommended way is via the `uv run` script alias defined in `pyproject.toml`: ```sh uv run demo @@ -18,18 +16,3 @@ You can also invoke the file directly: ```sh uv run python examples/demo.py ``` - -## Adding new examples - -1. Create a new `.py` file in this directory. -2. Give it a `main()` entry point. -3. Register it in `pyproject.toml` under `[project.scripts]`: - - ```toml - [project.scripts] - demo = "examples.demo:main" # existing - my-example = "examples.my_example:main" # new - ``` - -4. Make sure `"examples"` appears in - `[tool.hatch.build.targets.sdist].exclude` so examples stay out of the published package. diff --git a/examples/__init__.py b/examples/__init__.py index f8e9941..db007ec 100644 --- a/examples/__init__.py +++ b/examples/__init__.py @@ -1 +1,6 @@ -"""Demo package.""" +"""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 index 820f0a5..431592b 100644 --- a/examples/demo.py +++ b/examples/demo.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Graphworks demo — a quick tour of the library's core features. +"""Graphworks demo — a rich tour of the library's core features. Run with:: @@ -9,15 +9,24 @@ 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 registered as a ``[project.scripts]`` entry point for -convenience during development. +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, @@ -34,33 +43,110 @@ is_regular, is_simple, topological, + vertex_degree, ) from graphworks.graph import Graph +console = Console() + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + def _section(title: str) -> None: - """Print a section header to stdout. + """Print a Rich rule as a section divider. :param title: Section heading text. :type title: str :return: Nothing. :rtype: None """ - width = 60 - print(f"\n{'─' * width}") - print(f" {title}") - print(f"{'─' * width}") + 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)) + + +def _edge_table(graph: Graph) -> None: + """Display edges in a compact Rich table. + + :param graph: The graph whose edges to display. + :type graph: Graph + :return: Nothing. + :rtype: None + """ + edges = graph.edges() + if not edges: + console.print(" [dim](no edges)[/dim]") + return + arrow = "→" if graph.is_directed() else "—" + table = Table(show_header=True, header_style="bold", title="Edges") + 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, e in enumerate(edges, 1): + table.add_row(str(i), e.vertex1, arrow, e.vertex2) + console.print(table) + + +# --------------------------------------------------------------------------- +# Demo sections +# --------------------------------------------------------------------------- def demo_construction() -> Graph: - """Demonstrate the three ways to construct a Graph. + """Demonstrate graph construction and display. :return: The JSON-constructed graph used in subsequent demos. :rtype: Graph """ _section("1 · Graph construction") - # --- From a JSON string --- json_def = { "label": "social network", "directed": False, @@ -73,32 +159,32 @@ def demo_construction() -> Graph: }, } graph = Graph(input_graph=json.dumps(json_def)) - print(f"From JSON string → {graph.order()} vertices, {graph.size()} edges") - print(f" label : {graph.get_label()}") - print(f" verts : {graph.vertices()}") - - # --- From an adjacency matrix --- - matrix = [ - [0, 1, 0], - [1, 0, 1], - [0, 1, 0], - ] + _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) - print(f"\nFrom matrix → {matrix_graph.order()} vertices, {matrix_graph.size()} edges") + _kv("from matrix", f"{matrix_graph.order()} vertices, {matrix_graph.size()} edges") - # --- Programmatic construction --- manual = Graph("manual") for v in ("X", "Y", "Z"): manual.add_vertex(v) manual.add_edge("X", "Y") manual.add_edge("Y", "Z") - print(f"Programmatic → {manual.order()} vertices, {manual.size()} edges") + _kv("programmatic", f"{manual.order()} vertices, {manual.size()} edges") return graph def demo_properties(graph: Graph) -> None: - """Show structural property queries. + """Show structural property queries in a Rich table. :param graph: An undirected graph to inspect. :type graph: Graph @@ -107,16 +193,49 @@ def demo_properties(graph: Graph) -> None: """ _section("2 · Structural properties") - print(f" connected? {is_connected(graph)}") - print(f" complete? {is_complete(graph)}") - print(f" simple? {is_simple(graph)}") - print(f" regular? {is_regular(graph)}") - print(f" density {density(graph):.4f}") - print(f" diameter {diameter(graph)}") - print(f" deg sequence {degree_sequence(graph)}") + 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") - isolated = find_isolated_vertices(graph) - print(f" isolated {isolated if isolated else '(none)'}") + 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: @@ -127,13 +246,18 @@ def demo_traversal(graph: Graph) -> None: :return: Nothing. :rtype: None """ - _section("3 · Traversals") + _section("4 · Traversals") start = graph.vertices()[0] bfs = breadth_first_search(graph, start) dfs = depth_first_search(graph, start) - print(f" BFS from {start!r}: {bfs}") - print(f" DFS from {start!r}: {dfs}") + + 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: @@ -144,16 +268,26 @@ def demo_paths(graph: Graph) -> None: :return: Nothing. :rtype: None """ - _section("4 · Path finding") + _section("5 · Path finding") src, dst = "Alice", "Eve" single = find_path(graph, src, dst) - print(f" One path {src} → {dst}: {single}") + + _kv("shortest path", " → ".join(single) if single else "(none)") + console.print() all_paths = find_all_paths(graph, src, dst) - print(f" All paths ({len(all_paths)} total):") - for p in all_paths: - print(f" {' → '.join(p)}") + 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: @@ -164,11 +298,14 @@ def demo_complement(graph: Graph) -> None: :return: Nothing. :rtype: None """ - _section("5 · Complement graph") + _section("6 · Complement graph") comp = get_complement(graph) - print(f" Original : {graph.order()} vertices, {graph.size()} edges") - print(f" Complement: {comp.order()} vertices, {comp.size()} edges") + _kv("original", f"{graph.order()} vertices, {graph.size()} edges") + _kv("complement", f"{comp.order()} vertices, {comp.size()} edges") + + console.print() + _edge_table(comp) def demo_directed() -> None: @@ -177,7 +314,7 @@ def demo_directed() -> None: :return: Nothing. :rtype: None """ - _section("6 · Directed graphs") + _section("7 · Directed graphs") dag_def = { "directed": True, @@ -192,10 +329,15 @@ def demo_directed() -> None: }, } dag = Graph(input_graph=json.dumps(dag_def)) - print(f" DAG? {is_dag(dag)}") - print(f" Topo sort {topological(dag)}") - # Introduce a cycle: deploy → lint → … → deploy + _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", @@ -207,22 +349,43 @@ def demo_directed() -> None: }, } cyclic = Graph(input_graph=json.dumps(cycle_def)) - print(f"\n Cyclic graph DAG? {is_dag(cyclic)}") + _kv("cyclic graph", f"DAG? {is_dag(cyclic)}") + console.print() + _graph_panel(cyclic, "cyclic pipeline (NOT a DAG)", border_style="red") -def demo_iteration(graph: Graph) -> None: - """Show iteration and subscript access on a graph. - :param graph: An undirected graph. +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("7 · Iteration & subscript access") + _section("8 · Adjacency matrix") + + matrix = graph.get_adjacency_matrix() + verts = sorted(graph.vertices()) - for vertex in graph: - neighbours = graph[vertex] - print(f" {vertex:>8} → {neighbours}") + 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: @@ -231,21 +394,29 @@ def main() -> None: :return: Nothing. :rtype: None """ - print("=" * 60) - print(" graphworks — library demo") - print("=" * 60) + 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_iteration(graph) - - print(f"\n{'─' * 60}") - print(" Done! Explore the source at src/graphworks/") - print(f"{'─' * 60}\n") + 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__": diff --git a/pyproject.toml b/pyproject.toml index ceae9ea..a74694f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,7 +50,7 @@ demo = "examples.demo:main" [project.optional-dependencies] matrix = [ "numpy",] -viz = [ "graphviz",] +viz = [ "graphviz", "rich",] docs = [ "myst-parser", "sphinx", "sphinx-autodoc-typehints", "sphinx-rtd-theme",] all = [ "graphworks[docs,matrix,viz]",] @@ -129,7 +129,7 @@ markers = [ [tool.coverage.run] source = [ "src/graphworks",] -omit = [ "tests/*", "docs/*",] +omit = [ "tests/*", "docs/*", "examples/*",] branch = true [tool.coverage.report] diff --git a/uv.lock b/uv.lock index 8a54dd5..8531fe0 100644 --- a/uv.lock +++ b/uv.lock @@ -233,6 +233,7 @@ all = [ { name = "graphviz" }, { name = "myst-parser" }, { name = "numpy" }, + { name = "rich" }, { name = "sphinx" }, { name = "sphinx-autodoc-typehints" }, { name = "sphinx-rtd-theme" }, @@ -248,6 +249,7 @@ matrix = [ ] viz = [ { name = "graphviz" }, + { name = "rich" }, ] [package.dev-dependencies] @@ -270,6 +272,8 @@ requires-dist = [ { 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'" }, From dd04c4f1f4e8c1fe6e97cef87a445fb9f3019cbb Mon Sep 17 00:00:00 2001 From: Nathan Gilbert Date: Sun, 22 Mar 2026 18:33:25 -0600 Subject: [PATCH 19/22] Plan next steps --- README.md | 43 +++++++++++++++++++++++++++------ examples/demo.py | 60 ++++++++++++++++++++++++----------------------- todos.png | Bin 0 -> 275861 bytes 3 files changed, 67 insertions(+), 36 deletions(-) create mode 100644 todos.png diff --git a/README.md b/README.md index 07cf656..8aeae05 100755 --- a/README.md +++ b/README.md @@ -85,10 +85,39 @@ Version is managed automatically via git tags using `hatchling-vcs`. ## 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/examples/demo.py b/examples/demo.py index 431592b..c5b4474 100644 --- a/examples/demo.py +++ b/examples/demo.py @@ -36,7 +36,6 @@ find_all_paths, find_isolated_vertices, find_path, - get_complement, is_complete, is_connected, is_dag, @@ -111,29 +110,6 @@ def _graph_panel(graph: Graph, title: str, border_style: str = "blue") -> None: console.print(Panel(tree, border_style=border_style)) -def _edge_table(graph: Graph) -> None: - """Display edges in a compact Rich table. - - :param graph: The graph whose edges to display. - :type graph: Graph - :return: Nothing. - :rtype: None - """ - edges = graph.edges() - if not edges: - console.print(" [dim](no edges)[/dim]") - return - arrow = "→" if graph.is_directed() else "—" - table = Table(show_header=True, header_style="bold", title="Edges") - 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, e in enumerate(edges, 1): - table.add_row(str(i), e.vertex1, arrow, e.vertex2) - console.print(table) - - # --------------------------------------------------------------------------- # Demo sections # --------------------------------------------------------------------------- @@ -293,6 +269,11 @@ def demo_paths(graph: Graph) -> None: 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. @@ -300,12 +281,33 @@ def demo_complement(graph: Graph) -> None: """ _section("6 · Complement graph") - comp = get_complement(graph) - _kv("original", f"{graph.order()} vertices, {graph.size()} edges") - _kv("complement", f"{comp.order()} vertices, {comp.size()} edges") - + 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() - _edge_table(comp) + + 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: diff --git a/todos.png b/todos.png new file mode 100644 index 0000000000000000000000000000000000000000..2726855073211178c65fe8e54a5d39f3c9eaa1da GIT binary patch literal 275861 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 Date: Sun, 22 Mar 2026 18:36:32 -0600 Subject: [PATCH 20/22] Add PR template --- .github/pull_request_template.md | 37 ++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/pull_request_template.md 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 + + From 457b5757ad20fcff27d1107441aa3e6dfb303092 Mon Sep 17 00:00:00 2001 From: Nathan Gilbert Date: Sun, 22 Mar 2026 18:41:24 -0600 Subject: [PATCH 21/22] Update README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 8aeae05..1040f8d 100755 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ Optional extras: ```sh pip install graphworks[matrix] # numpy adjacency matrix support pip install graphworks[viz] # graphviz export +pip install graphworks[docs] # generate documentation ``` ## Development From 600ef18fa07e01d13f8cfca1d0edbb2cc4413c6a Mon Sep 17 00:00:00 2001 From: Nathan Gilbert Date: Sun, 22 Mar 2026 18:44:42 -0600 Subject: [PATCH 22/22] Update roadmap --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 1040f8d..854df77 100755 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ Version is managed automatically via git tags using `hatchling-vcs`. ![TODO List](./todos.png) -Tier 1 — Data model (do first, everything depends on it) The biggest gap right now is that vertices +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 @@ -98,27 +98,27 @@ label, and an attribute dict) and a richer Edge (already a dataclass, but needs 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 +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 +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 +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 +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 +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.