├── tests ├── __init__.py ├── sample-import │ ├── bar.py │ ├── foo │ │ ├── sub │ │ │ ├── __init__.py │ │ │ └── sub_a.py │ │ ├── __init__.py │ │ ├── foo_d.py │ │ ├── foo_b.py │ │ ├── foo_c.py │ │ └── foo_a.py │ ├── baz.py │ └── README └── test_import_deps.py ├── .gitignore ├── dodo.py ├── CHANGES ├── LICENSE ├── .github └── workflows │ └── test.yml ├── pyproject.toml ├── import_deps ├── __init__.py └── __main__.py ├── README.md └── uv.lock /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/sample-import/bar.py: -------------------------------------------------------------------------------- 1 | import foo 2 | -------------------------------------------------------------------------------- /tests/sample-import/foo/sub/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/sample-import/foo/__init__.py: -------------------------------------------------------------------------------- 1 | foo_i = 0 2 | -------------------------------------------------------------------------------- /tests/sample-import/foo/foo_d.py: -------------------------------------------------------------------------------- 1 | from . import foo_c 2 | -------------------------------------------------------------------------------- /tests/sample-import/baz.py: -------------------------------------------------------------------------------- 1 | from foo import obj_i 2 | 3 | obj_baz = 4 4 | -------------------------------------------------------------------------------- /tests/sample-import/foo/foo_b.py: -------------------------------------------------------------------------------- 1 | import baz.obj_baz 2 | import foo_c 3 | -------------------------------------------------------------------------------- /tests/sample-import/README: -------------------------------------------------------------------------------- 1 | Some python packages and modules used on tests. 2 | -------------------------------------------------------------------------------- /tests/sample-import/foo/foo_c.py: -------------------------------------------------------------------------------- 1 | from . import foo_i 2 | 3 | obj_c = 3 4 | -------------------------------------------------------------------------------- /tests/sample-import/foo/sub/sub_a.py: -------------------------------------------------------------------------------- 1 | from .. import foo_d 2 | 3 | obj_sub_a_xxx = 88 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | __pycache__ 3 | .pytest_cache 4 | .coverage 5 | .doit.db 6 | .venv 7 | .claude 8 | -------------------------------------------------------------------------------- /tests/sample-import/foo/foo_a.py: -------------------------------------------------------------------------------- 1 | import bar 2 | from foo import foo_b 3 | from foo.foo_c import obj_c 4 | from .. import sample_d 5 | from ..sample_e import jkl 6 | from sample_f import * 7 | import sample_g.other 8 | 9 | def x(): 10 | return 5 11 | -------------------------------------------------------------------------------- /dodo.py: -------------------------------------------------------------------------------- 1 | from doitpy.pyflakes import Pyflakes 2 | from doitpy.coverage import Coverage, PythonPackage 3 | 4 | 5 | DOIT_CONFIG = { 6 | 'default_tasks': ['pyflakes'], 7 | } 8 | 9 | 10 | def task_pyflakes(): 11 | flaker = Pyflakes() 12 | yield flaker.tasks('*.py') 13 | yield flaker.tasks('import_deps/*.py') 14 | yield flaker.tasks('tests/*.py') 15 | 16 | 17 | def task_coverage(): 18 | """show coverage for all modules including tests""" 19 | cov = Coverage( 20 | [PythonPackage('import_deps', 'tests')], 21 | config={'branch':True,}, 22 | ) 23 | yield cov.all() # create task `coverage` 24 | yield cov.src() # create task `coverage_src` 25 | -------------------------------------------------------------------------------- /CHANGES: -------------------------------------------------------------------------------- 1 | ======= 2 | Changes 3 | ======= 4 | 5 | 0.4.0 (*unreleased*) 6 | ==================== 7 | 8 | - drop support for Python 3.8 and 3.9 9 | - add support for Python 3.13 and 3.14 10 | - add support for analyzing entire package directories 11 | - add JSON output support with --json flag 12 | - add DOT output support with --dot flag for graphviz visualization 13 | - add --check flag to detect circular dependencies and exit with error if found 14 | - add --sort flag to output modules in topological order (dependencies first) 15 | 16 | 17 | 0.3.0 (*2024-05-04*) 18 | ==================== 19 | 20 | - fix script entry point 21 | - add support for python 3.11 and 3.12 22 | 23 | 24 | 0.2.0 (*2022-01-17*) 25 | ===================== 26 | 27 | - drop support python 3.5 and 3.6 28 | - add support for python 3.8, 3.9 and 3.10 29 | - do not try to get package above given relative path 30 | - add mod_imports() 31 | 32 | 33 | 0.1.0 (*2018-06-24*) 34 | ===================== 35 | 36 | - initial release -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | 3 | The MIT License 4 | 5 | Copyright (c) 2018 Eduardo Naufel Schettino 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in 15 | all copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 23 | THE SOFTWARE. 24 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: test 5 | 6 | # Drop permissions to minimum for security 7 | permissions: 8 | contents: read 9 | 10 | on: 11 | push: 12 | pull_request: 13 | schedule: 14 | - cron: '0 16 * * 5' # Every Friday 4pm 15 | 16 | jobs: 17 | build: 18 | 19 | runs-on: ubuntu-latest 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] 24 | 25 | steps: 26 | - uses: actions/checkout@v4 27 | - name: Set up Python ${{ matrix.python-version }} 28 | uses: actions/setup-python@v5 29 | with: 30 | python-version: ${{ matrix.python-version }} 31 | - name: Install dependencies 32 | run: | 33 | python -m pip install --upgrade pip 34 | python -m pip install -e ".[dev]" 35 | - name: Lint with pyflakes 36 | run: | 37 | pyflakes import_deps 38 | - name: Test with pytest 39 | run: | 40 | pytest 41 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "import_deps" 7 | version = "0.4.dev0" 8 | description = "find python module imports" 9 | readme = "README.md" 10 | requires-python = ">=3.10" 11 | license = {text = "MIT"} 12 | authors = [ 13 | {name = "Eduardo Naufel Schettino", email = "schettino72@gmail.com"} 14 | ] 15 | keywords = ["import", "graph", "quality"] 16 | classifiers = [ 17 | "Development Status :: 5 - Production/Stable", 18 | "Environment :: Console", 19 | "License :: OSI Approved :: MIT License", 20 | "Natural Language :: English", 21 | "Operating System :: OS Independent", 22 | "Operating System :: POSIX", 23 | "Programming Language :: Python :: 3", 24 | "Programming Language :: Python :: 3.10", 25 | "Programming Language :: Python :: 3.11", 26 | "Programming Language :: Python :: 3.12", 27 | "Programming Language :: Python :: 3.13", 28 | "Programming Language :: Python :: 3.14", 29 | "Intended Audience :: Developers", 30 | "Topic :: Software Development :: Quality Assurance", 31 | ] 32 | 33 | [project.urls] 34 | Homepage = "https://github.com/schettino72/import-deps" 35 | Repository = "https://github.com/schettino72/import-deps" 36 | Issues = "https://github.com/schettino72/import-deps/issues" 37 | 38 | [project.scripts] 39 | import_deps = "import_deps.__main__:main" 40 | 41 | [project.optional-dependencies] 42 | dev = [ 43 | "pyflakes", 44 | "pytest", 45 | "pytest-cov", 46 | "doit", 47 | "coverage", 48 | "doit-py", 49 | ] 50 | -------------------------------------------------------------------------------- /import_deps/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = (0, 4, 'dev0') 2 | 3 | import ast 4 | import pathlib 5 | 6 | 7 | class _ImportsFinder(ast.NodeVisitor): 8 | """find all imports 9 | :ivar imports: (list - tuple) (module, name, asname, level) 10 | """ 11 | def __init__(self): 12 | ast.NodeVisitor.__init__(self) 13 | self.imports = [] 14 | 15 | def visit_Import(self, node): 16 | """callback for 'import' statement""" 17 | self.imports.extend((None, n.name, n.asname, None) 18 | for n in node.names) 19 | ast.NodeVisitor.generic_visit(self, node) 20 | 21 | def visit_ImportFrom(self, node): 22 | """callback for 'import from' statement""" 23 | self.imports.extend((node.module, n.name, n.asname, node.level) 24 | for n in node.names) 25 | ast.NodeVisitor.generic_visit(self, node) 26 | 27 | def ast_imports(file_path): 28 | """get list of import from python module 29 | :return: (list - tuple) (module, name, asname, level) 30 | """ 31 | with pathlib.Path(file_path).open('r') as fp: 32 | text = fp.read() 33 | mod_ast = ast.parse(text, str(file_path)) 34 | finder = _ImportsFinder() 35 | finder.visit(mod_ast) 36 | return finder.imports 37 | 38 | 39 | ########## 40 | 41 | 42 | class PyModule(object): 43 | """Represents a python module 44 | 45 | :ivar path: (pathlib.Path) module's path 46 | :ivar fqn: (list - str) full qualified name as list of strings 47 | """ 48 | def __init__(self, path): 49 | self.path = pathlib.Path(path) 50 | assert self.path.suffix == '.py' 51 | self.fqn = self._get_fqn(self.path) 52 | 53 | def __repr__(self): 54 | return "".format(self.path) 55 | 56 | @staticmethod 57 | def is_pkg(path): 58 | """return True if path is a python package""" 59 | return (path.is_dir() and (path / '__init__.py').exists()) 60 | 61 | def pkg_path(self): 62 | """return pathlib.Path that contains top-most package/module 63 | Path that is supposed to be part of PYTHONPATH 64 | """ 65 | return self.path.parents[len(self.fqn)-1] 66 | 67 | @classmethod 68 | def _get_fqn(cls, path): 69 | """get full qualified name as list of strings 70 | :return: (list - str) of path segments from top package to given path 71 | """ 72 | name_list = [path.stem] 73 | current_path = path 74 | # move to parent path until parent path is a python package 75 | while True: 76 | parent = current_path.parent 77 | if parent.name in ('', '.', '..'): 78 | break 79 | if not cls.is_pkg(parent): 80 | break 81 | name_list.append(parent.name) 82 | current_path = parent 83 | return list(reversed(name_list)) 84 | 85 | 86 | 87 | class ModuleSet(object): 88 | """helper to filter import list only from within packages""" 89 | def __init__(self, path_list): 90 | self.pkgs = set() # str of fqn (dot separed) 91 | self.by_path = {} # module by path 92 | self.by_name = {} # module by name (dot separated) 93 | 94 | for path in path_list: 95 | # create modules object 96 | mod = PyModule(path) 97 | if mod.fqn[-1] == '__init__': 98 | self.pkgs.add('.'.join(mod.fqn[:-1])) 99 | self.by_path[path] = mod 100 | self.by_name['.'.join(mod.fqn)] = mod 101 | 102 | 103 | def _get_imported_module(self, module_name): 104 | """try to get imported module reference by its name""" 105 | # if imported module on module_set add to list 106 | imp_mod = self.by_name.get(module_name) 107 | if imp_mod: 108 | return imp_mod 109 | 110 | # last part of import section might not be a module 111 | # remove last section 112 | no_obj = module_name.rsplit('.', 1)[0] 113 | imp_mod2 = self.by_name.get(no_obj) 114 | if imp_mod2: 115 | return imp_mod2 116 | 117 | # special case for __init__ 118 | if module_name in self.pkgs: 119 | pkg_name = module_name + ".__init__" 120 | return self.by_name[pkg_name] 121 | 122 | if no_obj in self.pkgs: 123 | pkg_name = no_obj + ".__init__" 124 | return self.by_name[pkg_name] 125 | 126 | 127 | def get_imports(self, module, return_fqn=False): 128 | """return set of imported modules that are in self 129 | :param module: PyModule 130 | :return: (set - Path) 131 | (set - str) if return_fqn == True 132 | """ 133 | # print('####', module.fqn) 134 | # print(self.by_name.keys(), '\n\n') 135 | imports = set() 136 | raw_imports = ast_imports(module.path) 137 | for import_entry in raw_imports: 138 | # join 'from' and 'import' part of import statement 139 | full = ".".join(s for s in import_entry[:2] if s) 140 | 141 | import_level = import_entry[3] 142 | if import_level: 143 | # intra package imports 144 | intra = '.'.join(module.fqn[:-import_level] + [full]) 145 | imported = self._get_imported_module(intra) 146 | else: 147 | imported = self._get_imported_module(full) 148 | 149 | if imported: 150 | if return_fqn: 151 | imports.add('.'.join(imported.fqn)) 152 | else: 153 | imports.add(imported.path) 154 | return imports 155 | 156 | 157 | # higher level API 158 | def mod_imports(self, mod_fqn): 159 | mod = self.by_name[mod_fqn] 160 | return self.get_imports(mod, return_fqn=True) 161 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # import_deps 2 | 3 | [![PyPI version](https://img.shields.io/pypi/v/import-deps.svg)](https://pypi.org/project/import-deps/) 4 | [![Python versions](https://img.shields.io/pypi/pyversions/import-deps.svg)](https://pypi.org/project/import-deps/) 5 | [![CI Github actions](https://github.com/schettino72/import-deps/actions/workflows/test.yml/badge.svg?branch=master)](https://github.com/schettino72/import-deps/actions/workflows/test.yml?query=branch%3Amaster) 6 | 7 | Find python module's import dependencies. 8 | 9 | `import_deps` is based on [ast module](https://docs.python.org/3/library/ast.html) from standard library, 10 | so the modules being analysed are *not* executed. 11 | 12 | 13 | ## Install 14 | 15 | ``` 16 | pip install import_deps 17 | ``` 18 | 19 | 20 | ## Usage 21 | 22 | `import_deps` is designed to track only imports within a known set of package and modules. 23 | 24 | Given a package with the modules: 25 | 26 | - `foo/__init__.py` 27 | - `foo/foo_a.py` 28 | - `foo/foo_b.py` 29 | - `foo/foo_c.py` 30 | 31 | Where `foo_a.py` has the following imports: 32 | 33 | ```python3 34 | from . import foo_b 35 | from .foo_c import obj_c 36 | ``` 37 | 38 | ## Usage (CLI) 39 | 40 | ### Analyze a single file 41 | 42 | ```bash 43 | > import_deps foo/foo_a.py 44 | foo.foo_b 45 | foo.foo_c 46 | ``` 47 | 48 | ### Analyze a package directory 49 | 50 | ```bash 51 | > import_deps foo/ 52 | foo.__init__: 53 | foo.foo_a: 54 | foo.foo_b 55 | foo.foo_c 56 | foo.foo_b: 57 | foo.foo_c: 58 | foo.__init__ 59 | ``` 60 | 61 | ### JSON output 62 | 63 | Use the `--json` flag to get results in JSON format: 64 | 65 | ```bash 66 | > import_deps foo/foo_a.py --json 67 | [ 68 | { 69 | "module": "foo.foo_a", 70 | "imports": [ 71 | "foo.foo_b", 72 | "foo.foo_c" 73 | ] 74 | } 75 | ] 76 | ``` 77 | 78 | For package analysis with JSON: 79 | 80 | ```bash 81 | > import_deps foo/ --json 82 | [ 83 | { 84 | "module": "foo.__init__", 85 | "imports": [] 86 | }, 87 | { 88 | "module": "foo.foo_a", 89 | "imports": [ 90 | "foo.foo_b", 91 | "foo.foo_c" 92 | ] 93 | }, 94 | ... 95 | ] 96 | ``` 97 | 98 | ### DOT output for visualization 99 | 100 | Use the `--dot` flag to generate a dependency graph in DOT format for graphviz: 101 | 102 | ```bash 103 | > import_deps foo/ --dot 104 | digraph imports { 105 | "foo.foo_a" -> "foo.foo_b"; 106 | "foo.foo_a" -> "foo.foo_c"; 107 | "foo.foo_c" -> "foo.__init__"; 108 | "foo.foo_d" -> "foo.foo_c"; 109 | "foo.sub.sub_a" -> "foo.foo_d"; 110 | } 111 | ``` 112 | 113 | You can visualize the graph using graphviz: 114 | 115 | ```bash 116 | > import_deps foo/ --dot | dot -Tpng > dependencies.png 117 | > import_deps foo/ --dot | dot -Tsvg > dependencies.svg 118 | ``` 119 | 120 | The DOT output features: 121 | - Modules displayed as light blue rounded boxes 122 | - Packages grouped with dashed gray borders (clearly distinct from arrows) 123 | - Sub-packages nested hierarchically 124 | - Circular dependencies highlighted in **bold red arrows** 125 | 126 | ### Check for circular dependencies 127 | 128 | Use the `--check` flag to detect circular dependencies and exit with error if any are found: 129 | 130 | ```bash 131 | > import_deps foo/ --check 132 | No circular dependencies found. 133 | 134 | # If cycles are detected: 135 | > import_deps foo/ --check 136 | Circular dependencies detected: 137 | foo.module_a -> foo.module_b 138 | foo.module_b -> foo.module_a 139 | # (exits with code 1) 140 | ``` 141 | 142 | This is useful for CI/CD pipelines to enforce DAG (Directed Acyclic Graph) structure in your codebase. 143 | 144 | ### Topological sort 145 | 146 | Use the `--sort` flag to output modules in topological order (dependencies before dependents): 147 | 148 | ```bash 149 | > import_deps foo/ --sort 150 | foo.__init__ 151 | foo.foo_c 152 | foo.foo_b 153 | foo.foo_d 154 | foo.foo_a 155 | foo.sub.sub_a 156 | foo.sub.__init__ 157 | ``` 158 | 159 | The output guarantees that: 160 | - Dependencies always appear before modules that import them 161 | - When multiple modules become available, those with higher rank are prioritized 162 | - Rank is defined as the longest path from any leaf module (module that imports but isn't imported) 163 | - When multiple modules have the same rank, FIFO order is maintained 164 | - Circular dependencies are handled gracefully (see below) 165 | - Isolated modules (no dependencies, no dependents) appear last 166 | - Useful for initialization order, build systems, or understanding module hierarchy 167 | 168 | For example, if you have `A -> B -> C -> D` and `B -> E` (where `A -> B` means "A imports B"): 169 | - Ranks: A=1 (leaf), B=2, C=3, E=3, D=4 170 | - Output: `D, E, C, B, A` 171 | - D comes first (rank 4, highest) 172 | - E comes before C (both rank 3, FIFO order) 173 | - Then B and A in dependency order 174 | 175 | #### Handling circular dependencies 176 | 177 | When circular dependencies exist, the sort handles them gracefully: 178 | ```bash 179 | # If you have: A -> C -> B -> A (circular); D -> B; E (isolated) 180 | # (where A imports C, C imports B, B imports A, D imports B, E imports nothing) 181 | > import_deps circular_package/ --sort 182 | A 183 | B 184 | C 185 | D 186 | E 187 | ``` 188 | 189 | The ordering is: 190 | 1. A, B, C first (nodes in the cycle, sorted alphabetically) 191 | 2. D next (imports B which is in cycle, so comes after cycle nodes) 192 | 3. E last (isolated node with no connections) 193 | 194 | 195 | ## Usage (lib) 196 | 197 | ```python3 198 | import pathlib 199 | from import_deps import ModuleSet 200 | 201 | # First initialise a ModuleSet instance with a list str of modules to track 202 | pkg_paths = pathlib.Path('foo').glob('**/*.py') 203 | module_set = ModuleSet([str(p) for p in pkg_paths]) 204 | 205 | # then you can get the set of imports 206 | for imported in module_set.mod_imports('foo.foo_a'): 207 | print(imported) 208 | 209 | # foo.foo_c 210 | # foo.foo_b 211 | ``` 212 | 213 | ### ModuleSet 214 | 215 | You can get a list of all modules in a `ModuleSet` by path or module's full qualified name. 216 | 217 | `by_path` 218 | 219 | Note that key for `by_path` must be exactly the as provided on ModuleSet initialization. 220 | 221 | ```python3 222 | for mod in sorted(module_set.by_path.keys()): 223 | print(mod) 224 | 225 | # results in: 226 | # foo/__init__.py 227 | # foo/foo_a.py 228 | # foo/foo_b.py 229 | # foo/foo_c.py 230 | ``` 231 | 232 | `by_name` 233 | 234 | ```python3 235 | for mod in sorted(module_set.by_name.keys()): 236 | print(mod) 237 | 238 | # results in: 239 | # foo.__init__ 240 | # foo.foo_a 241 | # foo.foo_b 242 | # foo.foo_c 243 | ``` 244 | 245 | 246 | 247 | ### ast_imports(file_path) 248 | 249 | `ast_imports` is a low level function that returns a list of entries for import statement in the module. 250 | The parameter `file_path` can be a string or `pathlib.Path` instance. 251 | 252 | The return value is a list of 4-tuple items with values: 253 | - module name (of the "from" statement, `None` if a plain `import`) 254 | - object name 255 | - as name 256 | - level of relative import (number of parent, `None` if plain `import`) 257 | 258 | 259 | ```python3 260 | from import_deps import ast_imports 261 | 262 | ast_imports('foo.py') 263 | ``` 264 | 265 | 266 | ```python3 267 | # import datetime 268 | (None, 'datetime', None, None) 269 | 270 | # from datetime import time 271 | ('datetime', 'time', None, 0) 272 | 273 | # from datetime import datetime as dt 274 | ('datetime', 'datetime', 'dt', 0) 275 | 276 | # from .. import bar 277 | (None, 'bar', None, 2) 278 | 279 | # from .acme import baz 280 | ('acme', 'baz', None, 1) 281 | 282 | 283 | # note that a single statement will contain one entry per imported "name" 284 | # from datetime import time, timedelta 285 | ('datetime', 'time', None, 0) 286 | ('datetime', 'timedelta', None, 0) 287 | ``` 288 | 289 | -------------------------------------------------------------------------------- /import_deps/__main__.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import json 3 | import pathlib 4 | import sys 5 | 6 | from . import __version__, PyModule, ModuleSet 7 | 8 | 9 | def detect_cycles(results): 10 | """Detect circular dependencies using DFS 11 | Returns set of edges (module, import) that create cycles 12 | """ 13 | # Build adjacency list 14 | graph = {} 15 | for result in results: 16 | module = result['module'] 17 | graph[module] = result['imports'] 18 | 19 | cycle_edges = set() 20 | visited = set() 21 | rec_stack = set() 22 | 23 | def dfs(node, path): 24 | visited.add(node) 25 | rec_stack.add(node) 26 | path.append(node) 27 | 28 | for neighbor in graph.get(node, []): 29 | if neighbor not in visited: 30 | if neighbor in graph: # Only follow if it's in our tracked modules 31 | dfs(neighbor, path) 32 | elif neighbor in rec_stack: 33 | # Found a cycle - mark all edges in the cycle 34 | cycle_start_idx = path.index(neighbor) 35 | for i in range(cycle_start_idx, len(path)): 36 | if i + 1 < len(path): 37 | cycle_edges.add((path[i], path[i + 1])) 38 | # Add the back edge 39 | cycle_edges.add((node, neighbor)) 40 | 41 | rec_stack.remove(node) 42 | path.pop() 43 | 44 | for module in graph: 45 | if module not in visited: 46 | dfs(module, []) 47 | 48 | return cycle_edges 49 | 50 | 51 | def topological_sort(results): 52 | """Topological sort of modules (dependencies before dependents). 53 | Uses Kahn's algorithm with rank-based ordering for stability. 54 | Rank is defined as the longest path from any leaf node (module that imports but isn't imported). 55 | When multiple nodes become available, nodes with higher rank are output first. 56 | 57 | Handles circular dependencies gracefully: 58 | - Nodes actually in cycles are identified and processed last 59 | - Nodes that depend on cycles (but aren't in them) are processed normally 60 | - Isolated nodes (no dependencies, no dependents) are placed last 61 | 62 | Returns list of module names in topological order (dependencies before dependents). 63 | """ 64 | # Collect all modules 65 | all_modules = set(result['module'] for result in results) 66 | 67 | # Build dependencies: module -> list of modules it imports (its dependencies) 68 | dependencies = {module: [] for module in all_modules} 69 | for result in results: 70 | module = result['module'] 71 | dependencies[module] = [imp for imp in result['imports'] if imp in all_modules] 72 | 73 | # Build reverse graph: module -> list of modules that import it (its dependents) 74 | dependents = {module: [] for module in all_modules} 75 | 76 | for module in all_modules: 77 | for dep in dependencies[module]: 78 | dependents[dep].append(module) 79 | 80 | # Calculate rank for each node (longest path from any leaf node) 81 | # Leaf nodes are those that have no dependents (nothing imports them) 82 | # Detect cycles using DFS with recursion stack 83 | rank = {} 84 | in_cycle = set() 85 | 86 | def calculate_rank(node, visiting=None, rec_path=None): 87 | if visiting is None: 88 | visiting = set() 89 | if rec_path is None: 90 | rec_path = [] 91 | 92 | if node in rank: 93 | return rank[node] 94 | 95 | if node in visiting: 96 | # Cycle detected - mark all nodes in the cycle path 97 | cycle_start = rec_path.index(node) 98 | for i in range(cycle_start, len(rec_path)): 99 | in_cycle.add(rec_path[i]) 100 | in_cycle.add(node) 101 | return -1 # Special value for cycles 102 | 103 | visiting.add(node) 104 | rec_path.append(node) 105 | deps = dependents[node] # Use dependents (who imports this node) 106 | 107 | if not deps: 108 | rank[node] = 1 # Leaf nodes (not imported by anyone) have rank 1 109 | else: 110 | dep_ranks = [] 111 | for dep in deps: 112 | dep_rank = calculate_rank(dep, visiting, rec_path) 113 | if dep_rank == -1: 114 | # Dependent is in cycle 115 | pass 116 | else: 117 | dep_ranks.append(dep_rank) 118 | 119 | # Only mark as cycle if this node is actually in the cycle 120 | if node in in_cycle: 121 | rank[node] = -1 122 | elif dep_ranks: 123 | rank[node] = max(dep_ranks) + 1 124 | else: 125 | # All dependents are in cycles, but this node isn't 126 | rank[node] = 2 127 | 128 | rec_path.pop() 129 | visiting.remove(node) 130 | return rank[node] 131 | 132 | for module in all_modules: 133 | if module not in rank: 134 | calculate_rank(module) 135 | 136 | # Topological sort: start with roots (nodes with no dependencies) 137 | # in_degree tracks how many unprocessed dependencies each node has 138 | in_degree = {module: len(dependencies[module]) for module in all_modules} 139 | 140 | # Separate cycle nodes and isolated nodes from regular nodes 141 | cycle_nodes = {node for node in all_modules if rank[node] == -1} 142 | isolated_nodes = {node for node in all_modules 143 | if len(dependencies[node]) == 0 and len(dependents[node]) == 0} 144 | 145 | non_cycle_roots = [node for node in all_modules 146 | if in_degree[node] == 0 147 | and node not in cycle_nodes 148 | and node not in isolated_nodes] 149 | 150 | # Initial queue: non-cycle, non-isolated roots, sorted by rank DESC then name ASC 151 | queue = sorted(non_cycle_roots, key=lambda x: (-rank[x], x)) 152 | sorted_list = [] 153 | 154 | while queue: 155 | node = queue.pop(0) 156 | sorted_list.append(node) 157 | 158 | # Process all dependents of this node (nodes that import this node) 159 | for dependent in dependents[node]: 160 | if dependent not in cycle_nodes and dependent not in isolated_nodes: 161 | in_degree[dependent] -= 1 162 | if in_degree[dependent] == 0: 163 | # Insert maintaining rank order (higher rank first) 164 | # Within same rank, maintain FIFO order 165 | dep_rank = rank[dependent] 166 | insert_idx = len(queue) 167 | for i, queued_node in enumerate(queue): 168 | if rank[queued_node] < dep_rank: 169 | insert_idx = i 170 | break 171 | queue.insert(insert_idx, dependent) 172 | 173 | # Handle remaining nodes (cycles and nodes not yet processed, but not isolated) 174 | remaining = all_modules - set(sorted_list) - isolated_nodes 175 | if remaining: 176 | # Add remaining nodes sorted alphabetically 177 | sorted_list.extend(sorted(remaining)) 178 | 179 | # Add isolated nodes last (sorted alphabetically) 180 | if isolated_nodes: 181 | sorted_list.extend(sorted(isolated_nodes)) 182 | 183 | return sorted_list 184 | 185 | 186 | def format_dot(results, highlight_cycles=True): 187 | """Format results as DOT graph for graphviz""" 188 | lines = ['digraph imports {'] 189 | lines.append(' rankdir=LR;') 190 | lines.append(' node [shape=box, style="rounded,filled", fillcolor=lightblue, fontname="Arial"];') 191 | lines.append(' edge [fontname="Arial"];') 192 | 193 | # Detect cycles 194 | cycle_edges = detect_cycles(results) if highlight_cycles else set() 195 | 196 | # Group modules by package 197 | packages = {} 198 | all_modules = set() 199 | 200 | for result in results: 201 | module = result['module'] 202 | all_modules.add(module) 203 | # Extract package hierarchy 204 | parts = module.split('.') 205 | if len(parts) > 1: 206 | # Get package path (everything except last part) 207 | pkg = '.'.join(parts[:-1]) 208 | if pkg not in packages: 209 | packages[pkg] = [] 210 | packages[pkg].append(module) 211 | 212 | # Create subgraphs for packages 213 | def create_subgraph(pkg_name, modules, indent=1): 214 | ind = ' ' * indent 215 | lines.append(f'{ind}subgraph cluster_{pkg_name.replace(".", "_")} {{') 216 | lines.append(f'{ind} label = "{pkg_name}";') 217 | lines.append(f'{ind} style = "rounded,dashed";') 218 | lines.append(f'{ind} color = gray40;') 219 | lines.append(f'{ind} fontsize = 11;') 220 | lines.append(f'{ind} fontcolor = gray20;') 221 | lines.append(f'{ind} penwidth = 1.5;') 222 | 223 | # Find direct children of this package 224 | for mod in sorted(modules): 225 | if mod.rsplit('.', 1)[0] == pkg_name: 226 | lines.append(f'{ind} "{mod}";') 227 | 228 | # Find sub-packages 229 | sub_pkgs = {} 230 | for other_pkg, other_modules in packages.items(): 231 | if other_pkg.startswith(pkg_name + '.') and other_pkg.count('.') == pkg_name.count('.') + 1: 232 | sub_pkgs[other_pkg] = other_modules 233 | 234 | for sub_pkg in sorted(sub_pkgs.keys()): 235 | create_subgraph(sub_pkg, sub_pkgs[sub_pkg], indent + 1) 236 | 237 | lines.append(f'{ind}}}') 238 | 239 | # Create top-level packages 240 | top_level_pkgs = set() 241 | for pkg in packages: 242 | top = pkg.split('.')[0] 243 | top_level_pkgs.add(top) 244 | 245 | for top_pkg in sorted(top_level_pkgs): 246 | pkg_modules = [m for pkg, modules in packages.items() 247 | if pkg.startswith(top_pkg) 248 | for m in modules] 249 | if pkg_modules: 250 | create_subgraph(top_pkg, pkg_modules) 251 | 252 | # Add edges with cycle detection 253 | lines.append('') 254 | for result in results: 255 | module = result['module'] 256 | 257 | for imp in result['imports']: 258 | # Check if this edge is part of a cycle 259 | if (module, imp) in cycle_edges: 260 | lines.append(f' "{module}" -> "{imp}" [color=red, penwidth=2.0];') 261 | else: 262 | lines.append(f' "{module}" -> "{imp}";') 263 | 264 | lines.append('}') 265 | return '\n'.join(lines) 266 | 267 | 268 | def main(argv=sys.argv): 269 | parser = argparse.ArgumentParser(prog='import_deps') 270 | parser.add_argument('path', metavar='PATH', 271 | help='Python file or package directory to analyze') 272 | parser.add_argument('--json', action='store_true', 273 | help='Output results in JSON format') 274 | parser.add_argument('--dot', action='store_true', 275 | help='Output results in DOT format for graphviz') 276 | parser.add_argument('--check', action='store_true', 277 | help='Check for circular dependencies and exit with error if found') 278 | parser.add_argument('--sort', action='store_true', 279 | help='Output modules in topological sort order (dependencies first)') 280 | parser.add_argument('--version', action='version', 281 | version='.'.join(str(i) for i in __version__)) 282 | config = parser.parse_args(argv[1:]) 283 | 284 | # Check for mutually exclusive flags 285 | output_flags = sum([config.json, config.dot, config.sort]) 286 | if output_flags > 1: 287 | print("Error: --json, --dot, and --sort are mutually exclusive", file=sys.stderr) 288 | sys.exit(1) 289 | 290 | path = pathlib.Path(config.path) 291 | 292 | # Collect data 293 | if path.is_file(): 294 | # Single file analysis 295 | module = PyModule(config.path) 296 | base_path = module.pkg_path().resolve() 297 | mset = ModuleSet(base_path.glob('**/*.py')) 298 | imports = mset.get_imports(module, return_fqn=True) 299 | 300 | results = [{ 301 | 'module': '.'.join(module.fqn), 302 | 'imports': sorted(imports) 303 | }] 304 | 305 | elif path.is_dir(): 306 | # Package analysis 307 | base_path = path.resolve() 308 | py_files = list(base_path.glob('**/*.py')) 309 | mset = ModuleSet(py_files) 310 | 311 | results = [] 312 | for mod_name in sorted(mset.by_name.keys()): 313 | mod = mset.by_name[mod_name] 314 | imports = mset.get_imports(mod, return_fqn=True) 315 | results.append({ 316 | 'module': mod_name, 317 | 'imports': sorted(imports) 318 | }) 319 | 320 | else: 321 | print(f"Error: {config.path} is not a valid file or directory", file=sys.stderr) 322 | sys.exit(1) 323 | 324 | # Check for circular dependencies 325 | if config.check: 326 | cycle_edges = detect_cycles(results) 327 | if cycle_edges: 328 | print("Circular dependencies detected:", file=sys.stderr) 329 | 330 | # Group cycles by modules involved 331 | cycles_by_module = {} 332 | for src, dst in cycle_edges: 333 | if src not in cycles_by_module: 334 | cycles_by_module[src] = [] 335 | cycles_by_module[src].append(dst) 336 | 337 | for src in sorted(cycles_by_module.keys()): 338 | for dst in sorted(cycles_by_module[src]): 339 | print(f" {src} -> {dst}", file=sys.stderr) 340 | 341 | sys.exit(1) 342 | else: 343 | print("No circular dependencies found.") 344 | sys.exit(0) 345 | 346 | # Output results 347 | if config.json: 348 | print(json.dumps(results, indent=2)) 349 | elif config.dot: 350 | print(format_dot(results)) 351 | elif config.sort: 352 | sorted_modules = topological_sort(results) 353 | for module in sorted_modules: 354 | print(module) 355 | else: 356 | # Text format 357 | if len(results) == 1: 358 | # Single file - just list imports 359 | print('\n'.join(results[0]['imports'])) 360 | else: 361 | # Multiple modules - show module names with imports 362 | for result in results: 363 | print(f"{result['module']}:") 364 | for imp in result['imports']: 365 | print(f" {imp}") 366 | 367 | sys.exit(0) 368 | 369 | if __name__ == '__main__': 370 | main(sys.argv) 371 | -------------------------------------------------------------------------------- /tests/test_import_deps.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import pathlib 4 | 5 | import pytest 6 | 7 | from import_deps import ast_imports 8 | from import_deps import PyModule 9 | from import_deps import ModuleSet 10 | from import_deps.__main__ import main 11 | 12 | 13 | # list of modules in sample folder used for testing 14 | sample_dir = pathlib.Path(__file__).parent / 'sample-import' 15 | class FOO: 16 | pkg = sample_dir / 'foo' 17 | init = pkg / '__init__.py' 18 | a = pkg / 'foo_a.py' 19 | b = pkg / 'foo_b.py' 20 | c = pkg / 'foo_c.py' 21 | d = pkg / 'foo_d.py' 22 | class SUB: 23 | pkg = FOO.pkg / 'sub' 24 | init = pkg / '__init__.py' 25 | a = pkg / 'sub_a.py' 26 | BAR = sample_dir / 'bar.py' 27 | BAZ = sample_dir / 'baz.py' 28 | 29 | 30 | 31 | def test_ast_imports(): 32 | imports = ast_imports(FOO.a) 33 | # import bar 34 | assert (None, 'bar', None, None) == imports[0] 35 | # from foo import foo_b 36 | assert ('foo', 'foo_b', None, 0) == imports[1] 37 | # from foo.foo_c import obj_c 38 | assert ('foo.foo_c', 'obj_c', None, 0) == imports[2] 39 | # from .. import sample_d 40 | assert (None, 'sample_d', None, 2) == imports[3] 41 | # from ..sample_e import jkl 42 | assert ('sample_e', 'jkl', None, 2) == imports[4] 43 | # from sample_f import * 44 | assert ('sample_f', '*', None, 0) == imports[5] 45 | # import sample_g.other 46 | assert (None, 'sample_g.other', None, None) == imports[6] 47 | # TODO test `impors XXX as YYY` 48 | assert 7 == len(imports) 49 | 50 | 51 | 52 | class Test_PyModule(object): 53 | def test_repr(self): 54 | module = PyModule(SUB.a) 55 | assert "".format(SUB.a) == repr(module) 56 | 57 | def test_is_pkg(self): 58 | assert True == PyModule.is_pkg(FOO.pkg) 59 | assert False == PyModule.is_pkg(FOO.init) 60 | assert False == PyModule.is_pkg(FOO.a) 61 | assert True == PyModule.is_pkg(SUB.pkg) 62 | assert False == PyModule.is_pkg(SUB.a) 63 | 64 | def test_fqn(self): 65 | assert ['bar'] == PyModule(BAR).fqn 66 | assert ['foo', '__init__'] == PyModule(FOO.init).fqn 67 | assert ['foo', 'foo_a'] == PyModule(FOO.a).fqn 68 | assert ['foo', 'sub', 'sub_a'] == PyModule(SUB.a).fqn 69 | 70 | def test_pkg_path(self): 71 | assert sample_dir == PyModule(BAR).pkg_path() 72 | assert sample_dir == PyModule(SUB.a).pkg_path() 73 | 74 | def test_relative_path(self): 75 | cwd = os.getcwd() 76 | try: 77 | # do not try to get package beyond given relative path 78 | os.chdir(FOO.pkg.resolve()) 79 | assert ['foo_a'] == PyModule('foo_a.py').fqn 80 | finally: 81 | os.chdir(cwd) 82 | 83 | class Test_ModuleSet_Init(object): 84 | 85 | def test_init_with_packge(self): 86 | modset = ModuleSet([FOO.init, FOO.a]) 87 | assert set(['foo']) == modset.pkgs 88 | assert 2 == len(modset.by_path) 89 | assert modset.by_path[FOO.init].fqn == ['foo', '__init__'] 90 | assert modset.by_path[FOO.a].fqn == ['foo', 'foo_a'] 91 | assert 2 == len(modset.by_name) 92 | assert modset.by_name['foo.__init__'].fqn == ['foo', '__init__'] 93 | assert modset.by_name['foo.foo_a'].fqn == ['foo', 'foo_a'] 94 | 95 | def test_init_no_packge(self): 96 | # if a module of a package is added but no __init__.py 97 | # its packages is not added to the list of packages 98 | modset = ModuleSet([FOO.a]) 99 | assert 0 == len(modset.pkgs) 100 | assert 1 == len(modset.by_path) 101 | assert modset.by_path[FOO.a].fqn == ['foo', 'foo_a'] 102 | 103 | def test_init_subpackge(self): 104 | modset = ModuleSet([FOO.init, SUB.init, SUB.a]) 105 | assert set(['foo', 'foo.sub']) == modset.pkgs 106 | assert 3 == len(modset.by_path) 107 | assert modset.by_path[SUB.a].fqn == ['foo', 'sub', 'sub_a'] 108 | 109 | 110 | class Test_ModuleSet_GetImports(object): 111 | 112 | def test_import_module(self): 113 | # foo_a => import bar 114 | modset = ModuleSet([FOO.a, BAR]) 115 | got = modset.get_imports(modset.by_name['foo.foo_a']) 116 | assert len(got) == 1 117 | assert BAR in got 118 | 119 | def test_import_not_tracked(self): 120 | modset = ModuleSet([FOO.a]) 121 | got = modset.get_imports(modset.by_name['foo.foo_a']) 122 | assert len(got) == 0 123 | 124 | def test_import_pkg(self): 125 | # bar => import foo 126 | modset = ModuleSet([FOO.init, BAR]) 127 | got = modset.get_imports(modset.by_name['bar']) 128 | assert len(got) == 1 129 | assert FOO.init in got 130 | 131 | def test_from_pkg_import_module(self): 132 | # foo_a => from foo import foo_b 133 | modset = ModuleSet([FOO.init, FOO.a, FOO.b]) 134 | got = modset.get_imports(modset.by_name['foo.foo_a']) 135 | assert len(got) == 1 136 | assert FOO.b in got 137 | 138 | def test_from_import_object(self): 139 | # foo_a => from foo.foo_c import obj_c 140 | modset = ModuleSet([FOO.init, FOO.a, FOO.b, FOO.c]) 141 | got = modset.get_imports(modset.by_name['foo.foo_a']) 142 | assert len(got) == 2 143 | assert FOO.b in got # doesnt matter for this test 144 | assert FOO.c in got 145 | 146 | def test_from_pkg_import_obj(self): 147 | # baz => from foo import obj_1 148 | modset = ModuleSet([FOO.init, BAZ]) 149 | got = modset.get_imports(modset.by_name['baz']) 150 | assert len(got) == 1 151 | assert FOO.init in got 152 | 153 | def test_import_obj(self): 154 | # foo_b => import baz.obj_baz 155 | modset = ModuleSet([FOO.b, BAZ]) 156 | got = modset.get_imports(modset.by_name['foo.foo_b']) 157 | assert len(got) == 1 158 | assert BAZ in got 159 | 160 | def test_relative_intra_import_pkg_obj(self): 161 | # foo_c => from . import foo_i 162 | modset = ModuleSet([FOO.init, FOO.c]) 163 | got = modset.get_imports(modset.by_name['foo.foo_c']) 164 | assert len(got) == 1 165 | assert FOO.init in got 166 | 167 | def test_relative_intra_import_module(self): 168 | # foo_d => from . import foo_c 169 | modset = ModuleSet([FOO.init, FOO.c, FOO.d]) 170 | got = modset.get_imports(modset.by_name['foo.foo_d']) 171 | assert len(got) == 1 172 | assert FOO.c in got 173 | 174 | def test_relative_parent(self): 175 | # foo.sub.sub_a => from .. import foo_d 176 | modset = ModuleSet([FOO.init, FOO.d, SUB.init, SUB.a]) 177 | got = modset.get_imports(modset.by_name['foo.sub.sub_a']) 178 | assert len(got) == 1 179 | assert FOO.d in got 180 | 181 | def test_return_module_name(self): 182 | # foo_a => import bar 183 | modset = ModuleSet([FOO.a, BAR]) 184 | got = modset.get_imports(modset.by_name['foo.foo_a'], 185 | return_fqn=True) 186 | name = got.pop() 187 | assert len(got) == 0 188 | assert name == 'bar' 189 | 190 | 191 | 192 | def test_mod_imports(self): 193 | # foo_a => import bar 194 | modset = ModuleSet([FOO.init, FOO.a, FOO.b, FOO.c, BAR]) 195 | got = modset.mod_imports('foo.foo_a') 196 | imports = list(sorted(got)) 197 | assert imports == ['bar', 'foo.foo_b', 'foo.foo_c'] 198 | 199 | 200 | class Test_CLI(object): 201 | def test_single_file(self, capsys): 202 | # Test single file analysis 203 | with pytest.raises(SystemExit) as exc_info: 204 | main(['import_deps', str(FOO.a)]) 205 | 206 | assert exc_info.value.code == 0 207 | captured = capsys.readouterr() 208 | lines = captured.out.strip().split('\n') 209 | assert 'bar' in lines 210 | assert 'foo.foo_b' in lines 211 | assert 'foo.foo_c' in lines 212 | 213 | def test_single_file_json(self, capsys): 214 | # Test single file with JSON output 215 | with pytest.raises(SystemExit) as exc_info: 216 | main(['import_deps', str(FOO.a), '--json']) 217 | 218 | assert exc_info.value.code == 0 219 | captured = capsys.readouterr() 220 | result = json.loads(captured.out) 221 | 222 | assert len(result) == 1 223 | assert result[0]['module'] == 'foo.foo_a' 224 | assert 'bar' in result[0]['imports'] 225 | assert 'foo.foo_b' in result[0]['imports'] 226 | assert 'foo.foo_c' in result[0]['imports'] 227 | 228 | def test_directory(self, capsys): 229 | # Test directory analysis 230 | with pytest.raises(SystemExit) as exc_info: 231 | main(['import_deps', str(FOO.pkg)]) 232 | 233 | assert exc_info.value.code == 0 234 | captured = capsys.readouterr() 235 | output = captured.out 236 | 237 | # Should contain module names and their imports 238 | assert 'foo.foo_a:' in output 239 | assert 'foo.foo_d:' in output 240 | assert 'foo.sub.sub_a:' in output 241 | 242 | def test_directory_json(self, capsys): 243 | # Test directory with JSON output 244 | with pytest.raises(SystemExit) as exc_info: 245 | main(['import_deps', str(FOO.pkg), '--json']) 246 | 247 | assert exc_info.value.code == 0 248 | captured = capsys.readouterr() 249 | result = json.loads(captured.out) 250 | 251 | # Should have multiple modules 252 | assert len(result) > 1 253 | 254 | # Find foo.foo_a module 255 | foo_a = next((m for m in result if m['module'] == 'foo.foo_a'), None) 256 | assert foo_a is not None 257 | assert 'foo.foo_b' in foo_a['imports'] 258 | assert 'foo.foo_c' in foo_a['imports'] 259 | 260 | # Find foo.sub.sub_a module 261 | sub_a = next((m for m in result if m['module'] == 'foo.sub.sub_a'), None) 262 | assert sub_a is not None 263 | assert 'foo.foo_d' in sub_a['imports'] 264 | 265 | def test_single_file_dot(self, capsys): 266 | # Test single file with DOT output 267 | with pytest.raises(SystemExit) as exc_info: 268 | main(['import_deps', str(FOO.a), '--dot']) 269 | 270 | assert exc_info.value.code == 0 271 | captured = capsys.readouterr() 272 | output = captured.out 273 | 274 | # Check DOT format structure 275 | assert 'digraph imports {' in output 276 | assert '}' in output 277 | assert '"foo.foo_a" -> "foo.foo_b";' in output 278 | assert '"foo.foo_a" -> "foo.foo_c";' in output 279 | 280 | def test_directory_dot(self, capsys): 281 | # Test directory with DOT output 282 | with pytest.raises(SystemExit) as exc_info: 283 | main(['import_deps', str(FOO.pkg), '--dot']) 284 | 285 | assert exc_info.value.code == 0 286 | captured = capsys.readouterr() 287 | output = captured.out 288 | 289 | # Check DOT format structure 290 | assert 'digraph imports {' in output 291 | assert '}' in output 292 | assert '"foo.foo_a" -> "foo.foo_b";' in output 293 | assert '"foo.foo_c" -> "foo.__init__";' in output 294 | assert '"foo.sub.sub_a" -> "foo.foo_d";' in output 295 | 296 | def test_mutually_exclusive_flags(self, capsys): 297 | # Test that --json and --dot are mutually exclusive 298 | with pytest.raises(SystemExit) as exc_info: 299 | main(['import_deps', str(FOO.a), '--json', '--dot']) 300 | 301 | assert exc_info.value.code == 1 302 | captured = capsys.readouterr() 303 | assert 'mutually exclusive' in captured.err 304 | 305 | def test_no_cycles_in_sample(self, capsys): 306 | # Test that sample data has no circular dependencies 307 | # So no red edges should appear in DOT output 308 | with pytest.raises(SystemExit) as exc_info: 309 | main(['import_deps', str(FOO.pkg), '--dot']) 310 | 311 | assert exc_info.value.code == 0 312 | captured = capsys.readouterr() 313 | output = captured.out 314 | 315 | # Normal dependencies should not have color attribute 316 | assert '"foo.sub.sub_a" -> "foo.foo_d";' in output 317 | assert '"foo.foo_c" -> "foo.__init__";' in output 318 | 319 | # No cycles in sample data, so no red edges 320 | assert 'color=red' not in output 321 | 322 | def test_check_no_cycles(self, capsys): 323 | # Test --check on data without cycles 324 | with pytest.raises(SystemExit) as exc_info: 325 | main(['import_deps', str(FOO.pkg), '--check']) 326 | 327 | assert exc_info.value.code == 0 328 | captured = capsys.readouterr() 329 | assert 'No circular dependencies found' in captured.out 330 | 331 | def test_sort(self, capsys): 332 | # Test --sort topological ordering (dependencies before dependents) 333 | with pytest.raises(SystemExit) as exc_info: 334 | main(['import_deps', str(FOO.pkg), '--sort']) 335 | 336 | assert exc_info.value.code == 0 337 | captured = capsys.readouterr() 338 | modules = captured.out.strip().split('\n') 339 | 340 | # Verify all modules are present 341 | assert 'foo.__init__' in modules 342 | assert 'foo.foo_a' in modules 343 | assert 'foo.foo_b' in modules 344 | assert 'foo.foo_c' in modules 345 | assert 'foo.foo_d' in modules 346 | assert 'foo.sub.__init__' in modules 347 | assert 'foo.sub.sub_a' in modules 348 | 349 | # Verify topological order: dependencies come before dependents 350 | # foo.foo_a imports foo.foo_b and foo.foo_c, so they come before foo.foo_a 351 | assert modules.index('foo.foo_b') < modules.index('foo.foo_a') 352 | assert modules.index('foo.foo_c') < modules.index('foo.foo_a') 353 | 354 | # foo.foo_c imports foo.__init__, so foo.__init__ comes before foo.foo_c 355 | assert modules.index('foo.__init__') < modules.index('foo.foo_c') 356 | 357 | # foo.foo_d imports foo.foo_c, so foo.foo_c comes before foo.foo_d 358 | assert modules.index('foo.foo_c') < modules.index('foo.foo_d') 359 | 360 | # foo.sub.sub_a imports foo.foo_d, so foo.foo_d comes before foo.sub.sub_a 361 | assert modules.index('foo.foo_d') < modules.index('foo.sub.sub_a') 362 | 363 | def test_sort_mutually_exclusive(self, capsys): 364 | # Test that --sort and --json are mutually exclusive 365 | with pytest.raises(SystemExit) as exc_info: 366 | main(['import_deps', str(FOO.pkg), '--sort', '--json']) 367 | 368 | assert exc_info.value.code == 1 369 | captured = capsys.readouterr() 370 | assert 'mutually exclusive' in captured.err 371 | 372 | def test_sort_rank_based_ordering(self): 373 | # Test that rank-based ordering works correctly 374 | # Structure: A -> B -> C -> D; B -> E (A imports B, B imports C, etc.) 375 | # Ranks: D=1, E=1, C=2, B=3, A=4 376 | # Expected order: D, E, C, B, A (topological: dependencies first, higher rank first when available) 377 | from import_deps.__main__ import topological_sort 378 | 379 | results = [ 380 | {'module': 'A', 'imports': ['B']}, 381 | {'module': 'B', 'imports': ['C', 'E']}, 382 | {'module': 'C', 'imports': ['D']}, 383 | {'module': 'D', 'imports': []}, 384 | {'module': 'E', 'imports': []}, 385 | ] 386 | 387 | sorted_modules = topological_sort(results) 388 | 389 | # Expected order: D, E, C, B, A 390 | # D and E are roots (rank 1), D comes before E alphabetically 391 | # C comes next (rank 2, depends on D) 392 | # B comes next (rank 3, depends on C and E) 393 | # A comes last (rank 4, depends on B) 394 | assert sorted_modules == ['D', 'E', 'C', 'B', 'A'] 395 | 396 | def test_sort_with_circular_dependencies(self): 397 | # Test that circular dependencies are handled gracefully 398 | # Structure: A -> C -> B -> A (circular); D -> B; E (standalone) 399 | # (A imports C, C imports B, B imports A, D imports B, E imports nothing) 400 | from import_deps.__main__ import topological_sort 401 | 402 | results = [ 403 | {'module': 'A', 'imports': ['C']}, # A imports C 404 | {'module': 'B', 'imports': ['A']}, # B imports A 405 | {'module': 'C', 'imports': ['B']}, # C imports B (completes cycle) 406 | {'module': 'D', 'imports': ['B']}, # D imports B (depends on cycle) 407 | {'module': 'E', 'imports': []}, # E imports nothing (isolated) 408 | ] 409 | 410 | sorted_modules = topological_sort(results) 411 | 412 | # All modules should be present 413 | assert len(sorted_modules) == 5 414 | assert set(sorted_modules) == {'A', 'B', 'C', 'D', 'E'} 415 | 416 | # Cycle nodes (A, B, C) come first (sorted alphabetically), then D, then isolated E 417 | # D imports B which is in cycle, so D comes after cycle nodes 418 | # E is isolated, so it comes last 419 | assert sorted_modules.index('A') < sorted_modules.index('D') 420 | assert sorted_modules.index('B') < sorted_modules.index('D') 421 | assert sorted_modules.index('C') < sorted_modules.index('D') 422 | assert sorted_modules.index('E') == len(sorted_modules) - 1 423 | -------------------------------------------------------------------------------- /uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | requires-python = ">=3.10" 3 | 4 | [[package]] 5 | name = "cloudpickle" 6 | version = "3.1.2" 7 | source = { registry = "https://pypi.org/simple" } 8 | sdist = { url = "https://files.pythonhosted.org/packages/27/fb/576f067976d320f5f0114a8d9fa1215425441bb35627b1993e5afd8111e5/cloudpickle-3.1.2.tar.gz", hash = "sha256:7fda9eb655c9c230dab534f1983763de5835249750e85fbcef43aaa30a9a2414", size = 22330 } 9 | wheels = [ 10 | { url = "https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl", hash = "sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a", size = 22228 }, 11 | ] 12 | 13 | [[package]] 14 | name = "colorama" 15 | version = "0.4.6" 16 | source = { registry = "https://pypi.org/simple" } 17 | sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } 18 | wheels = [ 19 | { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, 20 | ] 21 | 22 | [[package]] 23 | name = "configclass" 24 | version = "0.2.0" 25 | source = { registry = "https://pypi.org/simple" } 26 | dependencies = [ 27 | { name = "mergedict" }, 28 | ] 29 | sdist = { url = "https://files.pythonhosted.org/packages/1f/b5/7972458f6dabe24fa0b957aa80348c1e1236db856d653810c4b2092f9d8c/configclass-0.2.0.tar.gz", hash = "sha256:6a80ca06e0f12427976d5c025e1b1ee8509b0c3337d3f5daf29f6a46f0e45819", size = 5373 } 30 | wheels = [ 31 | { url = "https://files.pythonhosted.org/packages/5c/ad/dd5a109f1fb55b5f4188a9b8fa90bc20617e363ee87e0968fe13dc8ae3dd/configclass-0.2.0-py3-none-any.whl", hash = "sha256:c94dfebfe3dbb89e494eebc8b7c5de3d448790ba6436c49308efa2a30d3bae78", size = 4169 }, 32 | ] 33 | 34 | [[package]] 35 | name = "coverage" 36 | version = "7.11.3" 37 | source = { registry = "https://pypi.org/simple" } 38 | sdist = { url = "https://files.pythonhosted.org/packages/d2/59/9698d57a3b11704c7b89b21d69e9d23ecf80d538cabb536c8b63f4a12322/coverage-7.11.3.tar.gz", hash = "sha256:0f59387f5e6edbbffec2281affb71cdc85e0776c1745150a3ab9b6c1d016106b", size = 815210 } 39 | wheels = [ 40 | { url = "https://files.pythonhosted.org/packages/fd/68/b53157115ef76d50d1d916d6240e5cd5b3c14dba8ba1b984632b8221fc2e/coverage-7.11.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0c986537abca9b064510f3fd104ba33e98d3036608c7f2f5537f869bc10e1ee5", size = 216377 }, 41 | { url = "https://files.pythonhosted.org/packages/14/c1/d2f9d8e37123fe6e7ab8afcaab8195f13bc84a8b2f449a533fd4812ac724/coverage-7.11.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:28c5251b3ab1d23e66f1130ca0c419747edfbcb4690de19467cd616861507af7", size = 216892 }, 42 | { url = "https://files.pythonhosted.org/packages/83/73/18f05d8010149b650ed97ee5c9f7e4ae68c05c7d913391523281e41c2495/coverage-7.11.3-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4f2bb4ee8dd40f9b2a80bb4adb2aecece9480ba1fa60d9382e8c8e0bd558e2eb", size = 243650 }, 43 | { url = "https://files.pythonhosted.org/packages/63/3c/c0cbb296c0ecc6dcbd70f4b473fcd7fe4517bbef8b09f4326d78f38adb87/coverage-7.11.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e5f4bfac975a2138215a38bda599ef00162e4143541cf7dd186da10a7f8e69f1", size = 245478 }, 44 | { url = "https://files.pythonhosted.org/packages/b9/9a/dad288cf9faa142a14e75e39dc646d968b93d74e15c83e9b13fd628f2cb3/coverage-7.11.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f4cbfff5cf01fa07464439a8510affc9df281535f41a1f5312fbd2b59b4ab5c", size = 247337 }, 45 | { url = "https://files.pythonhosted.org/packages/e3/ba/f6148ebf5547b3502013175e41bf3107a4e34b7dd19f9793a6ce0e1cd61f/coverage-7.11.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:31663572f20bf3406d7ac00d6981c7bbbcec302539d26b5ac596ca499664de31", size = 244328 }, 46 | { url = "https://files.pythonhosted.org/packages/e6/4d/b93784d0b593c5df89a0d48cbbd2d0963e0ca089eaf877405849792e46d3/coverage-7.11.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9799bd6a910961cb666196b8583ed0ee125fa225c6fdee2cbf00232b861f29d2", size = 245381 }, 47 | { url = "https://files.pythonhosted.org/packages/3a/8d/6735bfd4f0f736d457642ee056a570d704c9d57fdcd5c91ea5d6b15c944e/coverage-7.11.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:097acc18bedf2c6e3144eaf09b5f6034926c3c9bb9e10574ffd0942717232507", size = 243390 }, 48 | { url = "https://files.pythonhosted.org/packages/db/3d/7ba68ed52d1873d450aefd8d2f5a353e67b421915cb6c174e4222c7b918c/coverage-7.11.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:6f033dec603eea88204589175782290a038b436105a8f3637a81c4359df27832", size = 243654 }, 49 | { url = "https://files.pythonhosted.org/packages/14/26/be2720c4c7bf73c6591ae4ab503a7b5a31c7a60ced6dba855cfcb4a5af7e/coverage-7.11.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dd9ca2d44ed8018c90efb72f237a2a140325a4c3339971364d758e78b175f58e", size = 244272 }, 50 | { url = "https://files.pythonhosted.org/packages/90/20/086f5697780df146dbc0df4ae9b6db2b23ddf5aa550f977b2825137728e9/coverage-7.11.3-cp310-cp310-win32.whl", hash = "sha256:900580bc99c145e2561ea91a2d207e639171870d8a18756eb57db944a017d4bb", size = 218969 }, 51 | { url = "https://files.pythonhosted.org/packages/98/5c/cc6faba945ede5088156da7770e30d06c38b8591785ac99bcfb2074f9ef6/coverage-7.11.3-cp310-cp310-win_amd64.whl", hash = "sha256:c8be5bfcdc7832011b2652db29ed7672ce9d353dd19bce5272ca33dbcf60aaa8", size = 219903 }, 52 | { url = "https://files.pythonhosted.org/packages/92/92/43a961c0f57b666d01c92bcd960c7f93677de5e4ee7ca722564ad6dee0fa/coverage-7.11.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:200bb89fd2a8a07780eafcdff6463104dec459f3c838d980455cfa84f5e5e6e1", size = 216504 }, 53 | { url = "https://files.pythonhosted.org/packages/5d/5c/dbfc73329726aef26dbf7fefef81b8a2afd1789343a579ea6d99bf15d26e/coverage-7.11.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8d264402fc179776d43e557e1ca4a7d953020d3ee95f7ec19cc2c9d769277f06", size = 217006 }, 54 | { url = "https://files.pythonhosted.org/packages/a5/e0/878c84fb6661964bc435beb1e28c050650aa30e4c1cdc12341e298700bda/coverage-7.11.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:385977d94fc155f8731c895accdfcc3dd0d9dd9ef90d102969df95d3c637ab80", size = 247415 }, 55 | { url = "https://files.pythonhosted.org/packages/56/9e/0677e78b1e6a13527f39c4b39c767b351e256b333050539861c63f98bd61/coverage-7.11.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0542ddf6107adbd2592f29da9f59f5d9cff7947b5bb4f734805085c327dcffaa", size = 249332 }, 56 | { url = "https://files.pythonhosted.org/packages/54/90/25fc343e4ce35514262451456de0953bcae5b37dda248aed50ee51234cee/coverage-7.11.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d60bf4d7f886989ddf80e121a7f4d140d9eac91f1d2385ce8eb6bda93d563297", size = 251443 }, 57 | { url = "https://files.pythonhosted.org/packages/13/56/bc02bbc890fd8b155a64285c93e2ab38647486701ac9c980d457cdae857a/coverage-7.11.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0a3b6e32457535df0d41d2d895da46434706dd85dbaf53fbc0d3bd7d914b362", size = 247554 }, 58 | { url = "https://files.pythonhosted.org/packages/0f/ab/0318888d091d799a82d788c1e8d8bd280f1d5c41662bbb6e11187efe33e8/coverage-7.11.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:876a3ee7fd2613eb79602e4cdb39deb6b28c186e76124c3f29e580099ec21a87", size = 249139 }, 59 | { url = "https://files.pythonhosted.org/packages/79/d8/3ee50929c4cd36fcfcc0f45d753337001001116c8a5b8dd18d27ea645737/coverage-7.11.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a730cd0824e8083989f304e97b3f884189efb48e2151e07f57e9e138ab104200", size = 247209 }, 60 | { url = "https://files.pythonhosted.org/packages/94/7c/3cf06e327401c293e60c962b4b8a2ceb7167c1a428a02be3adbd1d7c7e4c/coverage-7.11.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b5cd111d3ab7390be0c07ad839235d5ad54d2ca497b5f5db86896098a77180a4", size = 246936 }, 61 | { url = "https://files.pythonhosted.org/packages/99/0b/ffc03dc8f4083817900fd367110015ef4dd227b37284104a5eb5edc9c106/coverage-7.11.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:074e6a5cd38e06671580b4d872c1a67955d4e69639e4b04e87fc03b494c1f060", size = 247835 }, 62 | { url = "https://files.pythonhosted.org/packages/17/4d/dbe54609ee066553d0bcdcdf108b177c78dab836292bee43f96d6a5674d1/coverage-7.11.3-cp311-cp311-win32.whl", hash = "sha256:86d27d2dd7c7c5a44710565933c7dc9cd70e65ef97142e260d16d555667deef7", size = 218994 }, 63 | { url = "https://files.pythonhosted.org/packages/94/11/8e7155df53f99553ad8114054806c01a2c0b08f303ea7e38b9831652d83d/coverage-7.11.3-cp311-cp311-win_amd64.whl", hash = "sha256:ca90ef33a152205fb6f2f0c1f3e55c50df4ef049bb0940ebba666edd4cdebc55", size = 219926 }, 64 | { url = "https://files.pythonhosted.org/packages/1f/93/bea91b6a9e35d89c89a1cd5824bc72e45151a9c2a9ca0b50d9e9a85e3ae3/coverage-7.11.3-cp311-cp311-win_arm64.whl", hash = "sha256:56f909a40d68947ef726ce6a34eb38f0ed241ffbe55c5007c64e616663bcbafc", size = 218599 }, 65 | { url = "https://files.pythonhosted.org/packages/c2/39/af056ec7a27c487e25c7f6b6e51d2ee9821dba1863173ddf4dc2eebef4f7/coverage-7.11.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5b771b59ac0dfb7f139f70c85b42717ef400a6790abb6475ebac1ecee8de782f", size = 216676 }, 66 | { url = "https://files.pythonhosted.org/packages/3c/f8/21126d34b174d037b5d01bea39077725cbb9a0da94a95c5f96929c695433/coverage-7.11.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:603c4414125fc9ae9000f17912dcfd3d3eb677d4e360b85206539240c96ea76e", size = 217034 }, 67 | { url = "https://files.pythonhosted.org/packages/d5/3f/0fd35f35658cdd11f7686303214bd5908225838f374db47f9e457c8d6df8/coverage-7.11.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:77ffb3b7704eb7b9b3298a01fe4509cef70117a52d50bcba29cffc5f53dd326a", size = 248531 }, 68 | { url = "https://files.pythonhosted.org/packages/8f/59/0bfc5900fc15ce4fd186e092451de776bef244565c840c9c026fd50857e1/coverage-7.11.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4d4ca49f5ba432b0755ebb0fc3a56be944a19a16bb33802264bbc7311622c0d1", size = 251290 }, 69 | { url = "https://files.pythonhosted.org/packages/71/88/d5c184001fa2ac82edf1b8f2cd91894d2230d7c309e937c54c796176e35b/coverage-7.11.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:05fd3fb6edff0c98874d752013588836f458261e5eba587afe4c547bba544afd", size = 252375 }, 70 | { url = "https://files.pythonhosted.org/packages/5c/29/f60af9f823bf62c7a00ce1ac88441b9a9a467e499493e5cc65028c8b8dd2/coverage-7.11.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0e920567f8c3a3ce68ae5a42cf7c2dc4bb6cc389f18bff2235dd8c03fa405de5", size = 248946 }, 71 | { url = "https://files.pythonhosted.org/packages/67/16/4662790f3b1e03fce5280cad93fd18711c35980beb3c6f28dca41b5230c6/coverage-7.11.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4bec8c7160688bd5a34e65c82984b25409563134d63285d8943d0599efbc448e", size = 250310 }, 72 | { url = "https://files.pythonhosted.org/packages/8f/75/dd6c2e28308a83e5fc1ee602f8204bd3aa5af685c104cb54499230cf56db/coverage-7.11.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:adb9b7b42c802bd8cb3927de8c1c26368ce50c8fdaa83a9d8551384d77537044", size = 248461 }, 73 | { url = "https://files.pythonhosted.org/packages/16/fe/b71af12be9f59dc9eb060688fa19a95bf3223f56c5af1e9861dfa2275d2c/coverage-7.11.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c8f563b245b4ddb591e99f28e3cd140b85f114b38b7f95b2e42542f0603eb7d7", size = 248039 }, 74 | { url = "https://files.pythonhosted.org/packages/11/b8/023b2003a2cd96bdf607afe03d9b96c763cab6d76e024abe4473707c4eb8/coverage-7.11.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e2a96fdc7643c9517a317553aca13b5cae9bad9a5f32f4654ce247ae4d321405", size = 249903 }, 75 | { url = "https://files.pythonhosted.org/packages/d6/ee/5f1076311aa67b1fa4687a724cc044346380e90ce7d94fec09fd384aa5fd/coverage-7.11.3-cp312-cp312-win32.whl", hash = "sha256:e8feeb5e8705835f0622af0fe7ff8d5cb388948454647086494d6c41ec142c2e", size = 219201 }, 76 | { url = "https://files.pythonhosted.org/packages/4f/24/d21688f48fe9fcc778956680fd5aaf69f4e23b245b7c7a4755cbd421d25b/coverage-7.11.3-cp312-cp312-win_amd64.whl", hash = "sha256:abb903ffe46bd319d99979cdba350ae7016759bb69f47882242f7b93f3356055", size = 220012 }, 77 | { url = "https://files.pythonhosted.org/packages/4f/9e/d5eb508065f291456378aa9b16698b8417d87cb084c2b597f3beb00a8084/coverage-7.11.3-cp312-cp312-win_arm64.whl", hash = "sha256:1451464fd855d9bd000c19b71bb7dafea9ab815741fb0bd9e813d9b671462d6f", size = 218652 }, 78 | { url = "https://files.pythonhosted.org/packages/6d/f6/d8572c058211c7d976f24dab71999a565501fb5b3cdcb59cf782f19c4acb/coverage-7.11.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84b892e968164b7a0498ddc5746cdf4e985700b902128421bb5cec1080a6ee36", size = 216694 }, 79 | { url = "https://files.pythonhosted.org/packages/4a/f6/b6f9764d90c0ce1bce8d995649fa307fff21f4727b8d950fa2843b7b0de5/coverage-7.11.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f761dbcf45e9416ec4698e1a7649248005f0064ce3523a47402d1bff4af2779e", size = 217065 }, 80 | { url = "https://files.pythonhosted.org/packages/a5/8d/a12cb424063019fd077b5be474258a0ed8369b92b6d0058e673f0a945982/coverage-7.11.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1410bac9e98afd9623f53876fae7d8a5db9f5a0ac1c9e7c5188463cb4b3212e2", size = 248062 }, 81 | { url = "https://files.pythonhosted.org/packages/7f/9c/dab1a4e8e75ce053d14259d3d7485d68528a662e286e184685ea49e71156/coverage-7.11.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:004cdcea3457c0ea3233622cd3464c1e32ebba9b41578421097402bee6461b63", size = 250657 }, 82 | { url = "https://files.pythonhosted.org/packages/3f/89/a14f256438324f33bae36f9a1a7137729bf26b0a43f5eda60b147ec7c8c7/coverage-7.11.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f067ada2c333609b52835ca4d4868645d3b63ac04fb2b9a658c55bba7f667d3", size = 251900 }, 83 | { url = "https://files.pythonhosted.org/packages/04/07/75b0d476eb349f1296486b1418b44f2d8780cc8db47493de3755e5340076/coverage-7.11.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:07bc7745c945a6d95676953e86ba7cebb9f11de7773951c387f4c07dc76d03f5", size = 248254 }, 84 | { url = "https://files.pythonhosted.org/packages/5a/4b/0c486581fa72873489ca092c52792d008a17954aa352809a7cbe6cf0bf07/coverage-7.11.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8bba7e4743e37484ae17d5c3b8eb1ce78b564cb91b7ace2e2182b25f0f764cb5", size = 250041 }, 85 | { url = "https://files.pythonhosted.org/packages/af/a3/0059dafb240ae3e3291f81b8de00e9c511d3dd41d687a227dd4b529be591/coverage-7.11.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:fbffc22d80d86fbe456af9abb17f7a7766e7b2101f7edaacc3535501691563f7", size = 248004 }, 86 | { url = "https://files.pythonhosted.org/packages/83/93/967d9662b1eb8c7c46917dcc7e4c1875724ac3e73c3cb78e86d7a0ac719d/coverage-7.11.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:0dba4da36730e384669e05b765a2c49f39514dd3012fcc0398dd66fba8d746d5", size = 247828 }, 87 | { url = "https://files.pythonhosted.org/packages/4c/1c/5077493c03215701e212767e470b794548d817dfc6247a4718832cc71fac/coverage-7.11.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ae12fe90b00b71a71b69f513773310782ce01d5f58d2ceb2b7c595ab9d222094", size = 249588 }, 88 | { url = "https://files.pythonhosted.org/packages/7f/a5/77f64de461016e7da3e05d7d07975c89756fe672753e4cf74417fc9b9052/coverage-7.11.3-cp313-cp313-win32.whl", hash = "sha256:12d821de7408292530b0d241468b698bce18dd12ecaf45316149f53877885f8c", size = 219223 }, 89 | { url = "https://files.pythonhosted.org/packages/ed/1c/ec51a3c1a59d225b44bdd3a4d463135b3159a535c2686fac965b698524f4/coverage-7.11.3-cp313-cp313-win_amd64.whl", hash = "sha256:6bb599052a974bb6cedfa114f9778fedfad66854107cf81397ec87cb9b8fbcf2", size = 220033 }, 90 | { url = "https://files.pythonhosted.org/packages/01/ec/e0ce39746ed558564c16f2cc25fa95ce6fc9fa8bfb3b9e62855d4386b886/coverage-7.11.3-cp313-cp313-win_arm64.whl", hash = "sha256:bb9d7efdb063903b3fdf77caec7b77c3066885068bdc0d44bc1b0c171033f944", size = 218661 }, 91 | { url = "https://files.pythonhosted.org/packages/46/cb/483f130bc56cbbad2638248915d97b185374d58b19e3cc3107359715949f/coverage-7.11.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:fb58da65e3339b3dbe266b607bb936efb983d86b00b03eb04c4ad5b442c58428", size = 217389 }, 92 | { url = "https://files.pythonhosted.org/packages/cb/ae/81f89bae3afef75553cf10e62feb57551535d16fd5859b9ee5a2a97ddd27/coverage-7.11.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8d16bbe566e16a71d123cd66382c1315fcd520c7573652a8074a8fe281b38c6a", size = 217742 }, 93 | { url = "https://files.pythonhosted.org/packages/db/6e/a0fb897041949888191a49c36afd5c6f5d9f5fd757e0b0cd99ec198a324b/coverage-7.11.3-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8258f10059b5ac837232c589a350a2df4a96406d6d5f2a09ec587cbdd539655", size = 259049 }, 94 | { url = "https://files.pythonhosted.org/packages/d9/b6/d13acc67eb402d91eb94b9bd60593411799aed09ce176ee8d8c0e39c94ca/coverage-7.11.3-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4c5627429f7fbff4f4131cfdd6abd530734ef7761116811a707b88b7e205afd7", size = 261113 }, 95 | { url = "https://files.pythonhosted.org/packages/ea/07/a6868893c48191d60406df4356aa7f0f74e6de34ef1f03af0d49183e0fa1/coverage-7.11.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:465695268414e149bab754c54b0c45c8ceda73dd4a5c3ba255500da13984b16d", size = 263546 }, 96 | { url = "https://files.pythonhosted.org/packages/24/e5/28598f70b2c1098332bac47925806353b3313511d984841111e6e760c016/coverage-7.11.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4ebcddfcdfb4c614233cff6e9a3967a09484114a8b2e4f2c7a62dc83676ba13f", size = 258260 }, 97 | { url = "https://files.pythonhosted.org/packages/0e/58/58e2d9e6455a4ed746a480c4b9cf96dc3cb2a6b8f3efbee5efd33ae24b06/coverage-7.11.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:13b2066303a1c1833c654d2af0455bb009b6e1727b3883c9964bc5c2f643c1d0", size = 261121 }, 98 | { url = "https://files.pythonhosted.org/packages/17/57/38803eefb9b0409934cbc5a14e3978f0c85cb251d2b6f6a369067a7105a0/coverage-7.11.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d8750dd20362a1b80e3cf84f58013d4672f89663aee457ea59336df50fab6739", size = 258736 }, 99 | { url = "https://files.pythonhosted.org/packages/a8/f3/f94683167156e93677b3442be1d4ca70cb33718df32a2eea44a5898f04f6/coverage-7.11.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ab6212e62ea0e1006531a2234e209607f360d98d18d532c2fa8e403c1afbdd71", size = 257625 }, 100 | { url = "https://files.pythonhosted.org/packages/87/ed/42d0bf1bc6bfa7d65f52299a31daaa866b4c11000855d753857fe78260ac/coverage-7.11.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a6b17c2b5e0b9bb7702449200f93e2d04cb04b1414c41424c08aa1e5d352da76", size = 259827 }, 101 | { url = "https://files.pythonhosted.org/packages/d3/76/5682719f5d5fbedb0c624c9851ef847407cae23362deb941f185f489c54e/coverage-7.11.3-cp313-cp313t-win32.whl", hash = "sha256:426559f105f644b69290ea414e154a0d320c3ad8a2bb75e62884731f69cf8e2c", size = 219897 }, 102 | { url = "https://files.pythonhosted.org/packages/10/e0/1da511d0ac3d39e6676fa6cc5ec35320bbf1cebb9b24e9ee7548ee4e931a/coverage-7.11.3-cp313-cp313t-win_amd64.whl", hash = "sha256:90a96fcd824564eae6137ec2563bd061d49a32944858d4bdbae5c00fb10e76ac", size = 220959 }, 103 | { url = "https://files.pythonhosted.org/packages/e5/9d/e255da6a04e9ec5f7b633c54c0fdfa221a9e03550b67a9c83217de12e96c/coverage-7.11.3-cp313-cp313t-win_arm64.whl", hash = "sha256:1e33d0bebf895c7a0905fcfaff2b07ab900885fc78bba2a12291a2cfbab014cc", size = 219234 }, 104 | { url = "https://files.pythonhosted.org/packages/84/d6/634ec396e45aded1772dccf6c236e3e7c9604bc47b816e928f32ce7987d1/coverage-7.11.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fdc5255eb4815babcdf236fa1a806ccb546724c8a9b129fd1ea4a5448a0bf07c", size = 216746 }, 105 | { url = "https://files.pythonhosted.org/packages/28/76/1079547f9d46f9c7c7d0dad35b6873c98bc5aa721eeabceafabd722cd5e7/coverage-7.11.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fe3425dc6021f906c6325d3c415e048e7cdb955505a94f1eb774dafc779ba203", size = 217077 }, 106 | { url = "https://files.pythonhosted.org/packages/2d/71/6ad80d6ae0d7cb743b9a98df8bb88b1ff3dc54491508a4a97549c2b83400/coverage-7.11.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4ca5f876bf41b24378ee67c41d688155f0e54cdc720de8ef9ad6544005899240", size = 248122 }, 107 | { url = "https://files.pythonhosted.org/packages/20/1d/784b87270784b0b88e4beec9d028e8d58f73ae248032579c63ad2ac6f69a/coverage-7.11.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9061a3e3c92b27fd8036dafa26f25d95695b6aa2e4514ab16a254f297e664f83", size = 250638 }, 108 | { url = "https://files.pythonhosted.org/packages/f5/26/b6dd31e23e004e9de84d1a8672cd3d73e50f5dae65dbd0f03fa2cdde6100/coverage-7.11.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abcea3b5f0dc44e1d01c27090bc32ce6ffb7aa665f884f1890710454113ea902", size = 251972 }, 109 | { url = "https://files.pythonhosted.org/packages/c9/ef/f9c64d76faac56b82daa036b34d4fe9ab55eb37f22062e68e9470583e688/coverage-7.11.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:68c4eb92997dbaaf839ea13527be463178ac0ddd37a7ac636b8bc11a51af2428", size = 248147 }, 110 | { url = "https://files.pythonhosted.org/packages/b6/eb/5b666f90a8f8053bd264a1ce693d2edef2368e518afe70680070fca13ecd/coverage-7.11.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:149eccc85d48c8f06547534068c41d69a1a35322deaa4d69ba1561e2e9127e75", size = 249995 }, 111 | { url = "https://files.pythonhosted.org/packages/eb/7b/871e991ffb5d067f8e67ffb635dabba65b231d6e0eb724a4a558f4a702a5/coverage-7.11.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:08c0bcf932e47795c49f0406054824b9d45671362dfc4269e0bc6e4bff010704", size = 247948 }, 112 | { url = "https://files.pythonhosted.org/packages/0a/8b/ce454f0af9609431b06dbe5485fc9d1c35ddc387e32ae8e374f49005748b/coverage-7.11.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:39764c6167c82d68a2d8c97c33dba45ec0ad9172570860e12191416f4f8e6e1b", size = 247770 }, 113 | { url = "https://files.pythonhosted.org/packages/61/8f/79002cb58a61dfbd2085de7d0a46311ef2476823e7938db80284cedd2428/coverage-7.11.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3224c7baf34e923ffc78cb45e793925539d640d42c96646db62dbd61bbcfa131", size = 249431 }, 114 | { url = "https://files.pythonhosted.org/packages/58/cc/d06685dae97468ed22999440f2f2f5060940ab0e7952a7295f236d98cce7/coverage-7.11.3-cp314-cp314-win32.whl", hash = "sha256:c713c1c528284d636cd37723b0b4c35c11190da6f932794e145fc40f8210a14a", size = 219508 }, 115 | { url = "https://files.pythonhosted.org/packages/5f/ed/770cd07706a3598c545f62d75adf2e5bd3791bffccdcf708ec383ad42559/coverage-7.11.3-cp314-cp314-win_amd64.whl", hash = "sha256:c381a252317f63ca0179d2c7918e83b99a4ff3101e1b24849b999a00f9cd4f86", size = 220325 }, 116 | { url = "https://files.pythonhosted.org/packages/ee/ac/6a1c507899b6fb1b9a56069954365f655956bcc648e150ce64c2b0ecbed8/coverage-7.11.3-cp314-cp314-win_arm64.whl", hash = "sha256:3e33a968672be1394eded257ec10d4acbb9af2ae263ba05a99ff901bb863557e", size = 218899 }, 117 | { url = "https://files.pythonhosted.org/packages/9a/58/142cd838d960cd740654d094f7b0300d7b81534bb7304437d2439fb685fb/coverage-7.11.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f9c96a29c6d65bd36a91f5634fef800212dff69dacdb44345c4c9783943ab0df", size = 217471 }, 118 | { url = "https://files.pythonhosted.org/packages/bc/2c/2f44d39eb33e41ab3aba80571daad32e0f67076afcf27cb443f9e5b5a3ee/coverage-7.11.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2ec27a7a991d229213c8070d31e3ecf44d005d96a9edc30c78eaeafaa421c001", size = 217742 }, 119 | { url = "https://files.pythonhosted.org/packages/32/76/8ebc66c3c699f4de3174a43424c34c086323cd93c4930ab0f835731c443a/coverage-7.11.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:72c8b494bd20ae1c58528b97c4a67d5cfeafcb3845c73542875ecd43924296de", size = 259120 }, 120 | { url = "https://files.pythonhosted.org/packages/19/89/78a3302b9595f331b86e4f12dfbd9252c8e93d97b8631500888f9a3a2af7/coverage-7.11.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:60ca149a446da255d56c2a7a813b51a80d9497a62250532598d249b3cdb1a926", size = 261229 }, 121 | { url = "https://files.pythonhosted.org/packages/07/59/1a9c0844dadef2a6efac07316d9781e6c5a3f3ea7e5e701411e99d619bfd/coverage-7.11.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb5069074db19a534de3859c43eec78e962d6d119f637c41c8e028c5ab3f59dd", size = 263642 }, 122 | { url = "https://files.pythonhosted.org/packages/37/86/66c15d190a8e82eee777793cabde730640f555db3c020a179625a2ad5320/coverage-7.11.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac5d5329c9c942bbe6295f4251b135d860ed9f86acd912d418dce186de7c19ac", size = 258193 }, 123 | { url = "https://files.pythonhosted.org/packages/c7/c7/4a4aeb25cb6f83c3ec4763e5f7cc78da1c6d4ef9e22128562204b7f39390/coverage-7.11.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e22539b676fafba17f0a90ac725f029a309eb6e483f364c86dcadee060429d46", size = 261107 }, 124 | { url = "https://files.pythonhosted.org/packages/ed/91/b986b5035f23cf0272446298967ecdd2c3c0105ee31f66f7e6b6948fd7f8/coverage-7.11.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2376e8a9c889016f25472c452389e98bc6e54a19570b107e27cde9d47f387b64", size = 258717 }, 125 | { url = "https://files.pythonhosted.org/packages/f0/c7/6c084997f5a04d050c513545d3344bfa17bd3b67f143f388b5757d762b0b/coverage-7.11.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4234914b8c67238a3c4af2bba648dc716aa029ca44d01f3d51536d44ac16854f", size = 257541 }, 126 | { url = "https://files.pythonhosted.org/packages/3b/c5/38e642917e406930cb67941210a366ccffa767365c8f8d9ec0f465a8b218/coverage-7.11.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f0b4101e2b3c6c352ff1f70b3a6fcc7c17c1ab1a91ccb7a33013cb0782af9820", size = 259872 }, 127 | { url = "https://files.pythonhosted.org/packages/b7/67/5e812979d20c167f81dbf9374048e0193ebe64c59a3d93d7d947b07865fa/coverage-7.11.3-cp314-cp314t-win32.whl", hash = "sha256:305716afb19133762e8cf62745c46c4853ad6f9eeba54a593e373289e24ea237", size = 220289 }, 128 | { url = "https://files.pythonhosted.org/packages/24/3a/b72573802672b680703e0df071faadfab7dcd4d659aaaffc4626bc8bbde8/coverage-7.11.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9245bd392572b9f799261c4c9e7216bafc9405537d0f4ce3ad93afe081a12dc9", size = 221398 }, 129 | { url = "https://files.pythonhosted.org/packages/f8/4e/649628f28d38bad81e4e8eb3f78759d20ac173e3c456ac629123815feb40/coverage-7.11.3-cp314-cp314t-win_arm64.whl", hash = "sha256:9a1d577c20b4334e5e814c3d5fe07fa4a8c3ae42a601945e8d7940bab811d0bd", size = 219435 }, 130 | { url = "https://files.pythonhosted.org/packages/19/8f/92bdd27b067204b99f396a1414d6342122f3e2663459baf787108a6b8b84/coverage-7.11.3-py3-none-any.whl", hash = "sha256:351511ae28e2509c8d8cae5311577ea7dd511ab8e746ffc8814a0896c3d33fbe", size = 208478 }, 131 | ] 132 | 133 | [[package]] 134 | name = "doit" 135 | version = "0.36.0" 136 | source = { registry = "https://pypi.org/simple" } 137 | dependencies = [ 138 | { name = "cloudpickle" }, 139 | { name = "importlib-metadata" }, 140 | ] 141 | sdist = { url = "https://files.pythonhosted.org/packages/5a/36/66b7dea1bb5688ba0d2d7bc113e9c0d57df697bd3f39ce2a139d9612aeee/doit-0.36.0.tar.gz", hash = "sha256:71d07ccc9514cb22fe59d98999577665eaab57e16f644d04336ae0b4bae234bc", size = 1448096 } 142 | wheels = [ 143 | { url = "https://files.pythonhosted.org/packages/44/83/a2960d2c975836daa629a73995134fd86520c101412578c57da3d2aa71ee/doit-0.36.0-py3-none-any.whl", hash = "sha256:ebc285f6666871b5300091c26eafdff3de968a6bd60ea35dd1e3fc6f2e32479a", size = 85937 }, 144 | ] 145 | 146 | [[package]] 147 | name = "doit-py" 148 | version = "0.5.0" 149 | source = { registry = "https://pypi.org/simple" } 150 | dependencies = [ 151 | { name = "configclass" }, 152 | { name = "doit" }, 153 | ] 154 | sdist = { url = "https://files.pythonhosted.org/packages/e5/a2/9311d9de7ec7ad771ab8b1ca17d139bfc745cb13f516b626fa38b1994bf5/doit-py-0.5.0.tar.gz", hash = "sha256:3618aeb78f2d2915dba7276de04fc17ae2cf302920576910cff312ea258a8722", size = 6046 } 155 | 156 | [[package]] 157 | name = "exceptiongroup" 158 | version = "1.3.0" 159 | source = { registry = "https://pypi.org/simple" } 160 | dependencies = [ 161 | { name = "typing-extensions", marker = "python_full_version < '3.13'" }, 162 | ] 163 | sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749 } 164 | wheels = [ 165 | { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674 }, 166 | ] 167 | 168 | [[package]] 169 | name = "import-deps" 170 | version = "0.4.dev0" 171 | source = { editable = "." } 172 | 173 | [package.optional-dependencies] 174 | dev = [ 175 | { name = "coverage" }, 176 | { name = "doit" }, 177 | { name = "doit-py" }, 178 | { name = "pyflakes" }, 179 | { name = "pytest" }, 180 | ] 181 | 182 | [package.metadata] 183 | requires-dist = [ 184 | { name = "coverage", marker = "extra == 'dev'" }, 185 | { name = "doit", marker = "extra == 'dev'" }, 186 | { name = "doit-py", marker = "extra == 'dev'" }, 187 | { name = "pyflakes", marker = "extra == 'dev'" }, 188 | { name = "pytest", marker = "extra == 'dev'" }, 189 | ] 190 | 191 | [[package]] 192 | name = "importlib-metadata" 193 | version = "8.7.0" 194 | source = { registry = "https://pypi.org/simple" } 195 | dependencies = [ 196 | { name = "zipp" }, 197 | ] 198 | sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641 } 199 | wheels = [ 200 | { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656 }, 201 | ] 202 | 203 | [[package]] 204 | name = "iniconfig" 205 | version = "2.3.0" 206 | source = { registry = "https://pypi.org/simple" } 207 | sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503 } 208 | wheels = [ 209 | { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 }, 210 | ] 211 | 212 | [[package]] 213 | name = "mergedict" 214 | version = "1.0.0" 215 | source = { registry = "https://pypi.org/simple" } 216 | sdist = { url = "https://files.pythonhosted.org/packages/aa/f2/27542b17a2f3d0b15957684467b9617a518caaf340c5ab86b8e8023945e4/mergedict-1.0.0.tar.gz", hash = "sha256:e1992b36a54229014fbcbc7a9c8c28d1f4ae131ea1d8d345c93973f9f0dc6fdc", size = 5188 } 217 | wheels = [ 218 | { url = "https://files.pythonhosted.org/packages/b4/f2/98a8757575ae9eb2d2ac8a7dbced7da3214f394b4c7f0716abc8e3292569/mergedict-1.0.0-py3-none-any.whl", hash = "sha256:f0eeede3d2119a002f96d56a6f7617dd2d80e225926809403d54e8c811eca22d", size = 4497 }, 219 | ] 220 | 221 | [[package]] 222 | name = "packaging" 223 | version = "25.0" 224 | source = { registry = "https://pypi.org/simple" } 225 | sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727 } 226 | wheels = [ 227 | { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469 }, 228 | ] 229 | 230 | [[package]] 231 | name = "pluggy" 232 | version = "1.6.0" 233 | source = { registry = "https://pypi.org/simple" } 234 | sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 } 235 | wheels = [ 236 | { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, 237 | ] 238 | 239 | [[package]] 240 | name = "pyflakes" 241 | version = "3.4.0" 242 | source = { registry = "https://pypi.org/simple" } 243 | sdist = { url = "https://files.pythonhosted.org/packages/45/dc/fd034dc20b4b264b3d015808458391acbf9df40b1e54750ef175d39180b1/pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58", size = 64669 } 244 | wheels = [ 245 | { url = "https://files.pythonhosted.org/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f", size = 63551 }, 246 | ] 247 | 248 | [[package]] 249 | name = "pygments" 250 | version = "2.19.2" 251 | source = { registry = "https://pypi.org/simple" } 252 | sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631 } 253 | wheels = [ 254 | { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217 }, 255 | ] 256 | 257 | [[package]] 258 | name = "pytest" 259 | version = "9.0.0" 260 | source = { registry = "https://pypi.org/simple" } 261 | dependencies = [ 262 | { name = "colorama", marker = "sys_platform == 'win32'" }, 263 | { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, 264 | { name = "iniconfig" }, 265 | { name = "packaging" }, 266 | { name = "pluggy" }, 267 | { name = "pygments" }, 268 | { name = "tomli", marker = "python_full_version < '3.11'" }, 269 | ] 270 | sdist = { url = "https://files.pythonhosted.org/packages/da/1d/eb34f286b164c5e431a810a38697409cca1112cee04b287bb56ac486730b/pytest-9.0.0.tar.gz", hash = "sha256:8f44522eafe4137b0f35c9ce3072931a788a21ee40a2ed279e817d3cc16ed21e", size = 1562764 } 271 | wheels = [ 272 | { url = "https://files.pythonhosted.org/packages/72/99/cafef234114a3b6d9f3aaed0723b437c40c57bdb7b3e4c3a575bc4890052/pytest-9.0.0-py3-none-any.whl", hash = "sha256:e5ccdf10b0bac554970ee88fc1a4ad0ee5d221f8ef22321f9b7e4584e19d7f96", size = 373364 }, 273 | ] 274 | 275 | [[package]] 276 | name = "tomli" 277 | version = "2.3.0" 278 | source = { registry = "https://pypi.org/simple" } 279 | sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392 } 280 | wheels = [ 281 | { url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236 }, 282 | { url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084 }, 283 | { url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832 }, 284 | { url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052 }, 285 | { url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555 }, 286 | { url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128 }, 287 | { url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445 }, 288 | { url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165 }, 289 | { url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891 }, 290 | { url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796 }, 291 | { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121 }, 292 | { url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070 }, 293 | { url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859 }, 294 | { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296 }, 295 | { url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124 }, 296 | { url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698 }, 297 | { url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819 }, 298 | { url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766 }, 299 | { url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771 }, 300 | { url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586 }, 301 | { url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792 }, 302 | { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909 }, 303 | { url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946 }, 304 | { url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705 }, 305 | { url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244 }, 306 | { url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637 }, 307 | { url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925 }, 308 | { url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045 }, 309 | { url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835 }, 310 | { url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109 }, 311 | { url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930 }, 312 | { url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964 }, 313 | { url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065 }, 314 | { url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088 }, 315 | { url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193 }, 316 | { url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488 }, 317 | { url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669 }, 318 | { url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709 }, 319 | { url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563 }, 320 | { url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756 }, 321 | { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408 }, 322 | ] 323 | 324 | [[package]] 325 | name = "typing-extensions" 326 | version = "4.15.0" 327 | source = { registry = "https://pypi.org/simple" } 328 | sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 } 329 | wheels = [ 330 | { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 }, 331 | ] 332 | 333 | [[package]] 334 | name = "zipp" 335 | version = "3.23.0" 336 | source = { registry = "https://pypi.org/simple" } 337 | sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547 } 338 | wheels = [ 339 | { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276 }, 340 | ] 341 | --------------------------------------------------------------------------------