├── src └── slotscheck │ ├── py.typed │ ├── __main__.py │ ├── __init__.py │ ├── checks.py │ ├── common.py │ ├── config.py │ ├── discovery.py │ └── cli.py ├── tests ├── src │ ├── __init__.py │ ├── conftest.py │ ├── test_checks.py │ ├── test_config.py │ ├── test_discovery.py │ └── test_cli.py ├── examples │ ├── files │ │ ├── foo │ │ ├── my_scripts │ │ │ ├── bla.py │ │ │ ├── foo.py │ │ │ ├── sub │ │ │ │ └── foo.py │ │ │ └── mymodule │ │ │ │ └── __init__.py │ │ ├── subdir │ │ │ ├── foo.txt │ │ │ ├── myfile.py │ │ │ ├── otherfile.py │ │ │ └── some_module │ │ │ │ ├── foo.py │ │ │ │ ├── sub │ │ │ │ ├── __init__.py │ │ │ │ └── foo.py │ │ │ │ └── __init__.py │ │ ├── otherdir │ │ │ ├── myfile.py │ │ │ └── anotherdir │ │ │ │ ├── anotherfile.py │ │ │ │ └── sub │ │ │ │ ├── namespaced │ │ │ │ ├── __init__.py │ │ │ │ └── module │ │ │ │ │ ├── sub │ │ │ │ │ ├── foo.py │ │ │ │ │ └── __init__.py │ │ │ │ │ └── __init__.py │ │ │ │ └── package │ │ │ │ └── __init__.py │ │ ├── another │ │ │ └── empty │ │ │ │ └── not-a-python-file.txt │ │ └── what-is-this-directory.txt │ ├── compiled │ │ ├── foo.py │ │ ├── __init__.py │ │ └── bar.pyc │ ├── broken │ │ ├── submodule.py │ │ └── __init__.py │ ├── module_misc │ │ ├── a │ │ │ ├── b │ │ │ │ ├── c.py │ │ │ │ ├── __init__.py │ │ │ │ ├── mypy │ │ │ │ │ ├── bla.py │ │ │ │ │ ├── __init__.py │ │ │ │ │ └── foo │ │ │ │ │ │ ├── z.py │ │ │ │ │ │ └── __init__.py │ │ │ │ └── __main__.py │ │ │ ├── data │ │ │ │ ├── bla.txt │ │ │ │ └── foo │ │ │ │ │ ├── bla.py │ │ │ │ │ └── __init__.py │ │ │ ├── evil │ │ │ │ ├── foo.py │ │ │ │ ├── sub │ │ │ │ │ ├── w.py │ │ │ │ │ └── __init__.py │ │ │ │ └── __init__.py │ │ │ ├── pytest │ │ │ │ ├── __init__.py │ │ │ │ ├── c │ │ │ │ │ ├── foo.py │ │ │ │ │ └── __init__.py │ │ │ │ └── a │ │ │ │ │ └── __init__.py │ │ │ ├── __init__.py │ │ │ └── z.py │ │ ├── __main__.py │ │ ├── s.py │ │ └── __init__.py │ ├── module_ok │ │ ├── a │ │ │ ├── __init__.py │ │ │ ├── b │ │ │ │ ├── __init__.py │ │ │ │ └── k.py │ │ │ └── c.py │ │ ├── __main__.py │ │ ├── __init__.py │ │ └── foo.py │ ├── module_not_ok │ │ ├── __init__.py │ │ ├── a │ │ │ ├── __init__.py │ │ │ └── b │ │ │ │ └── __init__.py │ │ └── foo.py │ ├── namespaced │ │ ├── module │ │ │ ├── bla.py │ │ │ ├── __init__.py │ │ │ └── foo.py │ │ └── __init__.py │ ├── other │ │ ├── module_misc │ │ │ ├── a │ │ │ │ ├── b │ │ │ │ │ ├── c.py │ │ │ │ │ ├── __init__.py │ │ │ │ │ └── __main__.py │ │ │ │ ├── __init__.py │ │ │ │ └── z.py │ │ │ ├── __main__.py │ │ │ ├── s.py │ │ │ └── __init__.py │ │ └── implicitly_namespaced │ │ │ └── bar.py │ ├── implicitly_namespaced │ │ ├── version.py │ │ ├── another │ │ │ ├── foo.py │ │ │ └── __init__.py │ │ └── module │ │ │ ├── bla.py │ │ │ ├── __init__.py │ │ │ └── foo.py │ ├── gc.py │ ├── pyproject.toml │ └── module_singular.py └── __init__.py ├── docs ├── requirements.txt ├── cli.rst ├── index.rst ├── Makefile ├── conf.py ├── configuration.rst ├── errors.rst ├── advanced.rst └── discovery.rst ├── .github ├── dependabot.yml ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── build.yml ├── .pre-commit-hooks.yaml ├── mypy.ini ├── .readthedocs.yml ├── .pre-commit-config.yaml ├── Makefile ├── LICENSE ├── tox.ini ├── pyproject.toml ├── .gitignore ├── README.rst ├── CHANGELOG.rst └── poetry.lock /src/slotscheck/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/src/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/examples/files/foo: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/examples/compiled/foo.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/examples/broken/submodule.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/examples/compiled/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/examples/files/my_scripts/bla.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/examples/files/my_scripts/foo.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/examples/files/subdir/foo.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/examples/files/subdir/myfile.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/examples/module_misc/a/b/c.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/examples/module_ok/a/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/examples/files/my_scripts/sub/foo.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/examples/files/otherdir/myfile.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/examples/files/subdir/otherfile.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/examples/module_misc/a/b/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/examples/module_misc/a/b/mypy/bla.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/examples/module_misc/a/data/bla.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/examples/module_misc/a/evil/foo.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/examples/module_misc/a/evil/sub/w.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/examples/module_not_ok/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/examples/module_not_ok/a/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/examples/module_ok/a/b/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/examples/namespaced/module/bla.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/examples/other/module_misc/a/b/c.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/examples/files/subdir/some_module/foo.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/examples/implicitly_namespaced/version.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/examples/module_misc/a/b/mypy/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/examples/module_misc/a/b/mypy/foo/z.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/examples/module_misc/a/data/foo/bla.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/examples/module_misc/a/pytest/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/examples/module_misc/a/pytest/c/foo.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/examples/namespaced/module/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/examples/other/module_misc/a/b/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/examples/files/my_scripts/mymodule/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/examples/implicitly_namespaced/another/foo.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/examples/implicitly_namespaced/module/bla.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/examples/module_misc/a/b/mypy/foo/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/examples/module_misc/a/data/foo/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/examples/module_misc/a/evil/sub/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/examples/module_misc/a/pytest/a/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/examples/module_misc/a/pytest/c/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/examples/files/another/empty/not-a-python-file.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/examples/files/otherdir/anotherdir/anotherfile.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/examples/files/subdir/some_module/sub/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/examples/implicitly_namespaced/another/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/examples/implicitly_namespaced/module/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/examples/broken/__init__.py: -------------------------------------------------------------------------------- 1 | raise Exception("BOOM") 2 | -------------------------------------------------------------------------------- /tests/examples/files/subdir/some_module/__init__.py: -------------------------------------------------------------------------------- 1 | K = 5 2 | -------------------------------------------------------------------------------- /tests/examples/files/otherdir/anotherdir/sub/namespaced/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/examples/files/otherdir/anotherdir/sub/package/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/examples/module_misc/__main__.py: -------------------------------------------------------------------------------- 1 | class Z: 2 | pass 3 | -------------------------------------------------------------------------------- /tests/examples/files/otherdir/anotherdir/sub/namespaced/module/sub/foo.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/examples/gc.py: -------------------------------------------------------------------------------- 1 | # This module is shadowed by the builtin module. 2 | -------------------------------------------------------------------------------- /tests/examples/other/module_misc/__main__.py: -------------------------------------------------------------------------------- 1 | class Z: 2 | pass 3 | -------------------------------------------------------------------------------- /tests/examples/files/otherdir/anotherdir/sub/namespaced/module/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/examples/files/otherdir/anotherdir/sub/namespaced/module/sub/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/examples/namespaced/module/foo.py: -------------------------------------------------------------------------------- 1 | class A: 2 | __slots__ = () 3 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # This file is needed for the overrides in mypy.ini to work. 2 | -------------------------------------------------------------------------------- /tests/examples/module_ok/__main__.py: -------------------------------------------------------------------------------- 1 | assert "this file should not be imported" 2 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx>8,<9 2 | furo>=2024.8.6,<2026 3 | sphinx-click~=6.0.0 4 | -------------------------------------------------------------------------------- /tests/examples/implicitly_namespaced/module/foo.py: -------------------------------------------------------------------------------- 1 | class A: 2 | __slots__ = () 3 | -------------------------------------------------------------------------------- /tests/examples/module_misc/a/b/__main__.py: -------------------------------------------------------------------------------- 1 | raise RuntimeError("Should not be run!") 2 | -------------------------------------------------------------------------------- /tests/examples/other/implicitly_namespaced/bar.py: -------------------------------------------------------------------------------- 1 | class C: 2 | __slots__ = () 3 | -------------------------------------------------------------------------------- /tests/examples/other/module_misc/a/b/__main__.py: -------------------------------------------------------------------------------- 1 | raise RuntimeError("Should not be run!") 2 | -------------------------------------------------------------------------------- /src/slotscheck/__main__.py: -------------------------------------------------------------------------------- 1 | from .cli import root 2 | 3 | if __name__ == "__main__": 4 | root() 5 | -------------------------------------------------------------------------------- /tests/examples/namespaced/__init__.py: -------------------------------------------------------------------------------- 1 | __path__ = __import__("pkgutil").extend_path(__path__, __name__) 2 | -------------------------------------------------------------------------------- /tests/examples/files/what-is-this-directory.txt: -------------------------------------------------------------------------------- 1 | this directory contains example files/dirs to test file discovery 2 | -------------------------------------------------------------------------------- /tests/examples/compiled/bar.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ariebovenberg/slotscheck/HEAD/tests/examples/compiled/bar.pyc -------------------------------------------------------------------------------- /tests/examples/module_not_ok/a/b/__init__.py: -------------------------------------------------------------------------------- 1 | class A: 2 | pass 3 | 4 | 5 | class U(A): 6 | __slots__ = () 7 | -------------------------------------------------------------------------------- /tests/examples/files/subdir/some_module/sub/foo.py: -------------------------------------------------------------------------------- 1 | from .. import K 2 | 3 | 4 | class A: 5 | pass 6 | 7 | 8 | Z = K 9 | -------------------------------------------------------------------------------- /tests/examples/pyproject.toml: -------------------------------------------------------------------------------- 1 | # an emtpy pyproject.toml to prevent examples from reverting the the root one 2 | [tool.slotscheck] 3 | -------------------------------------------------------------------------------- /tests/examples/module_misc/a/__init__.py: -------------------------------------------------------------------------------- 1 | class U: 2 | def __init__(self) -> None: 3 | pass 4 | 5 | 6 | evil_was_imported = False 7 | -------------------------------------------------------------------------------- /docs/cli.rst: -------------------------------------------------------------------------------- 1 | .. _cli: 2 | 3 | Command line interface 4 | ====================== 5 | 6 | .. click:: slotscheck.cli:root 7 | :prog: slotscheck 8 | -------------------------------------------------------------------------------- /tests/examples/other/module_misc/a/__init__.py: -------------------------------------------------------------------------------- 1 | class U: 2 | def __init__(self) -> None: 3 | pass 4 | 5 | 6 | evil_was_imported = False 7 | -------------------------------------------------------------------------------- /tests/examples/module_misc/s.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | 3 | 4 | class Y: 5 | pass 6 | 7 | 8 | class K: 9 | class A: 10 | U = UUID 11 | -------------------------------------------------------------------------------- /tests/examples/other/module_misc/s.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | 3 | 4 | class Y: 5 | pass 6 | 7 | 8 | class K: 9 | class A: 10 | U = UUID 11 | -------------------------------------------------------------------------------- /tests/examples/module_misc/__init__.py: -------------------------------------------------------------------------------- 1 | from .s import K, Y 2 | 3 | 4 | class A: 5 | pass 6 | 7 | 8 | class B(A): 9 | ... 10 | 11 | 12 | K = K 13 | X = Y 14 | -------------------------------------------------------------------------------- /tests/examples/other/module_misc/__init__.py: -------------------------------------------------------------------------------- 1 | from .s import K, Y 2 | 3 | 4 | class A: 5 | pass 6 | 7 | 8 | class B(A): 9 | ... 10 | 11 | 12 | K = K 13 | X = Y 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "04:00" 8 | open-pull-requests-limit: 10 9 | -------------------------------------------------------------------------------- /tests/examples/module_misc/a/evil/__init__.py: -------------------------------------------------------------------------------- 1 | from ... import a 2 | 3 | a.evil_was_imported = True 4 | 5 | 6 | class MyException(BaseException): 7 | pass 8 | 9 | 10 | raise MyException("Can't import this!") 11 | -------------------------------------------------------------------------------- /src/slotscheck/__init__.py: -------------------------------------------------------------------------------- 1 | # Single-sourcing the version number with poetry: 2 | # https://github.com/python-poetry/poetry/pull/2366#issuecomment-652418094 3 | __version__ = __import__("importlib.metadata").metadata.version(__name__) 4 | -------------------------------------------------------------------------------- /.pre-commit-hooks.yaml: -------------------------------------------------------------------------------- 1 | - id: slotscheck 2 | name: slotscheck 3 | description: "Slotscheck: Ensure your __slots__ are working properly." 4 | entry: python -m slotscheck -v 5 | language: python 6 | minimum_pre_commit_version: 2.9.2 7 | require_serial: true 8 | types: [python] 9 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | strict = True 3 | warn_redundant_casts = True 4 | warn_unused_ignores = True 5 | warn_unreachable = True 6 | exclude= ^tests/examples/.*$ 7 | 8 | [mypy-tests.*] 9 | check_untyped_defs = True 10 | disable_error_code = 11 | no-untyped-def 12 | disallow_untyped_defs = False 13 | warn_unreachable = True 14 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | sphinx: 4 | builder: html 5 | configuration: docs/conf.py 6 | fail_on_warning: true 7 | 8 | build: 9 | os: ubuntu-22.04 10 | tools: 11 | python: "3.11" 12 | 13 | python: 14 | install: 15 | - requirements: docs/requirements.txt 16 | - method: pip 17 | path: . 18 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | 3 | Contents 4 | ======== 5 | 6 | .. toctree:: 7 | :maxdepth: 2 8 | 9 | cli.rst 10 | configuration.rst 11 | errors.rst 12 | discovery.rst 13 | advanced.rst 14 | 15 | 16 | .. include:: ../CHANGELOG.rst 17 | 18 | 19 | Indices and tables 20 | ================== 21 | 22 | * :ref:`genindex` 23 | * :ref:`modindex` 24 | * :ref:`search` 25 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # Note: don't use this configuration for your own projects. 2 | # Instead, see https://slotscheck.rtfd.io/en/latest/advanced.html#pre-commit-hook 3 | repos: 4 | - repo: local 5 | hooks: 6 | - id: slotscheck 7 | name: slotscheck 8 | language: system 9 | entry: "python -m slotscheck -v" 10 | minimum_pre_commit_version: 2.9.2 11 | require_serial: true 12 | types_or: [python] 13 | exclude: "^(?!src/)" 14 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Description 2 | 3 | 4 | 5 | # Before merge 6 | 7 | - [ ] ``tox`` runs successfully 8 | - [ ] Docs updated 9 | 10 | # Before release (if applicable) 11 | 12 | - [ ] Version updated in ``pyproject.toml`` 13 | - [ ] Version updated in pre-commit hook example 14 | - [ ] Version updated in changelog 15 | - [ ] Branch merged 16 | - [ ] Tag created and pushed 17 | - [ ] Published 18 | - [ ] Github release created 19 | -------------------------------------------------------------------------------- /tests/examples/module_misc/a/z.py: -------------------------------------------------------------------------------- 1 | "A collection of evil objects that crash on introspection" 2 | 3 | 4 | class Q: 5 | def __get__(self, *args) -> None: 6 | raise RuntimeError("BOOM!") 7 | 8 | 9 | class W: 10 | R = Q() 11 | 12 | 13 | class Z: 14 | @property 15 | def __class__(self) -> type: 16 | raise RuntimeError("BOOM!") 17 | 18 | @property 19 | def __module__(self) -> type: 20 | raise RuntimeError("BOOM!") 21 | 22 | 23 | z = Z() 24 | 25 | 26 | W.boom = Z() 27 | 28 | 29 | def __getattr__(name): 30 | raise RuntimeError(f"BOOM: {name}") 31 | -------------------------------------------------------------------------------- /tests/examples/other/module_misc/a/z.py: -------------------------------------------------------------------------------- 1 | "A collection of evil objects that crash on introspection" 2 | 3 | 4 | class Q: 5 | def __get__(self, *args) -> None: 6 | raise RuntimeError("BOOM!") 7 | 8 | 9 | class W: 10 | R = Q() 11 | 12 | 13 | class Z: 14 | @property 15 | def __class__(self) -> type: 16 | raise RuntimeError("BOOM!") 17 | 18 | @property 19 | def __module__(self) -> type: 20 | raise RuntimeError("BOOM!") 21 | 22 | 23 | z = Z() 24 | 25 | 26 | W.boom = Z() 27 | 28 | 29 | def __getattr__(name): 30 | raise RuntimeError(f"BOOM: {name}") 31 | -------------------------------------------------------------------------------- /tests/examples/module_singular.py: -------------------------------------------------------------------------------- 1 | from typing import NamedTuple 2 | from uuid import UUID 3 | 4 | 5 | class A: 6 | pass 7 | 8 | 9 | class B: 10 | def __init__(self) -> None: 11 | pass 12 | 13 | @property 14 | def foo(self) -> None: 15 | pass 16 | 17 | class C: 18 | def __init__(self) -> None: 19 | pass 20 | 21 | class D: 22 | pass 23 | 24 | K = A 25 | 26 | J = D 27 | 28 | F = C 29 | 30 | Z = UUID 31 | 32 | 33 | class E(NamedTuple): 34 | r: float 35 | 36 | 37 | B.Foo = B 38 | 39 | 40 | Alias1 = B 41 | Alias2 = B.C.D 42 | Alias3 = UUID 43 | Alias4 = Alias2 44 | Alias5 = B.C.K 45 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = Slotscheck 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /tests/examples/module_ok/a/b/k.py: -------------------------------------------------------------------------------- 1 | class A: 2 | pass 3 | 4 | 5 | class B: 6 | __slots__ = () 7 | 8 | 9 | class C(B): 10 | pass 11 | 12 | 13 | class D(C): 14 | pass 15 | 16 | 17 | class E(B): 18 | __slots__ = ("a", "b") 19 | 20 | 21 | class F(E): 22 | __slots__ = () 23 | 24 | 25 | class G(F): 26 | __slots__ = ("k", "j") 27 | 28 | 29 | class H: 30 | __slots__ = {"k": "something", "z": "else"} 31 | 32 | 33 | class J(H): 34 | __slots__ = ["j", "a"] 35 | 36 | 37 | class K(H): 38 | __slots__ = {"j", "a"} 39 | 40 | 41 | class L(D): 42 | pass 43 | 44 | 45 | class M(zip): 46 | __slots__ = () 47 | 48 | 49 | class N(zip): 50 | __slots__ = ("a", "b") 51 | 52 | 53 | class P(int): 54 | __slots__ = () 55 | 56 | 57 | class Q(B, H): 58 | __slots__ = ("w",) 59 | 60 | 61 | class R(Q): 62 | pass 63 | -------------------------------------------------------------------------------- /tests/examples/module_ok/a/c.py: -------------------------------------------------------------------------------- 1 | class A: 2 | pass 3 | 4 | 5 | class B: 6 | __slots__ = () 7 | 8 | 9 | class C(B): 10 | pass 11 | 12 | 13 | class D(C): 14 | pass 15 | 16 | 17 | class E(B): 18 | __slots__ = ("a", "b") 19 | 20 | 21 | class F(E): 22 | __slots__ = () 23 | 24 | 25 | class G(F): 26 | __slots__ = ("k", "j") 27 | 28 | 29 | class H: 30 | __slots__ = {"k": "something", "z": "else"} 31 | 32 | 33 | class J(H): 34 | __slots__ = ["j", "a"] 35 | 36 | 37 | class K(H): 38 | __slots__ = {"j", "a"} 39 | 40 | 41 | class L(D): 42 | pass 43 | 44 | 45 | class M(zip): 46 | __slots__ = () 47 | 48 | 49 | class N(zip): 50 | __slots__ = ("a", "b") 51 | 52 | 53 | class P(int): 54 | __slots__ = () 55 | 56 | 57 | class Q(B, H): 58 | __slots__ = ("w",) 59 | 60 | 61 | class R(Q): 62 | pass 63 | -------------------------------------------------------------------------------- /tests/examples/module_ok/__init__.py: -------------------------------------------------------------------------------- 1 | class A: 2 | pass 3 | 4 | 5 | class B: 6 | __slots__ = () 7 | 8 | 9 | class C(B): 10 | pass 11 | 12 | 13 | class D(C): 14 | pass 15 | 16 | 17 | class E(B): 18 | __slots__ = ("a", "b") 19 | 20 | 21 | class F(E): 22 | __slots__ = () 23 | 24 | 25 | class G(F): 26 | __slots__ = ("k", "j") 27 | 28 | 29 | class H: 30 | __slots__ = {"k": "something", "z": "else"} 31 | 32 | 33 | class J(H): 34 | __slots__ = ["j", "a"] 35 | 36 | 37 | class K(H): 38 | __slots__ = {"j", "a"} 39 | 40 | 41 | class L(D): 42 | pass 43 | 44 | 45 | class M(zip): 46 | __slots__ = () 47 | 48 | 49 | class N(zip): 50 | __slots__ = ("a", "b") 51 | 52 | 53 | class P(int): 54 | __slots__ = () 55 | 56 | 57 | class Q(B, H): 58 | __slots__ = ("w",) 59 | 60 | 61 | class R(Q): 62 | pass 63 | -------------------------------------------------------------------------------- /tests/examples/module_ok/foo.py: -------------------------------------------------------------------------------- 1 | class A: 2 | pass 3 | 4 | 5 | class B: 6 | __slots__ = () 7 | 8 | 9 | class C(B): 10 | pass 11 | 12 | 13 | class D(C): 14 | pass 15 | 16 | 17 | class E(B): 18 | __slots__ = ("a", "b") 19 | 20 | 21 | class F(E): 22 | __slots__ = () 23 | 24 | 25 | class G(F): 26 | __slots__ = ("k", "j") 27 | 28 | 29 | class H: 30 | __slots__ = {"k": "something", "z": "else"} 31 | 32 | 33 | class J(H): 34 | __slots__ = ["j", "a"] 35 | 36 | 37 | class K(H): 38 | __slots__ = {"j", "a"} 39 | 40 | 41 | class L(D): 42 | pass 43 | 44 | 45 | class M(zip): 46 | __slots__ = () 47 | 48 | 49 | class N(zip): 50 | __slots__ = ("a", "b") 51 | 52 | 53 | class P(int): 54 | __slots__ = () 55 | 56 | 57 | class Q(B, H): 58 | __slots__ = ("w",) 59 | 60 | 61 | class R(Q): 62 | pass 63 | 64 | 65 | S = R 66 | -------------------------------------------------------------------------------- /tests/src/conftest.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from pathlib import Path 3 | from typing import Iterator 4 | 5 | import pytest 6 | 7 | EXAMPLES_DIR = Path(__file__).parents[1] / "examples" 8 | EXAMPLE_NAMES = tuple( 9 | p.name for p in EXAMPLES_DIR.iterdir() if not p.name.startswith((".", "_")) 10 | ) 11 | 12 | 13 | @pytest.fixture(scope="session", autouse=True) 14 | def add_pypath() -> Iterator[None]: 15 | "Add example modules to the python path" 16 | sys.path[:0] = [str(EXAMPLES_DIR), str(EXAMPLES_DIR / "other")] 17 | yield 18 | del sys.path[:2] 19 | 20 | 21 | @pytest.fixture(autouse=True) 22 | def undo_examples_import() -> Iterator[None]: 23 | "Undo any imports of example modules" 24 | yield 25 | to_remove = [ 26 | name for name in sys.modules if name.startswith(EXAMPLE_NAMES) 27 | ] 28 | for name in to_remove: 29 | del sys.modules[name] 30 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean isort isort-check format format-check fix lint type-check pytest check test documentation docs 2 | 3 | 4 | 5 | init: 6 | poetry install 7 | pip install -r docs/requirements.txt 8 | 9 | clean: 10 | rm -rf .coverage .hypothesis .mypy_cache .pytest_cache .tox *.egg-info 11 | rm -rf dist 12 | find . | grep -E "(__pycache__|docs_.*$$|\.pyc|\.pyo$$)" | xargs rm -rf 13 | 14 | isort: 15 | isort src tests/src/ 16 | 17 | isort-check: 18 | isort . --check-only --diff 19 | 20 | format: 21 | black src tests/src 22 | 23 | format-check: 24 | black --check --diff . 25 | 26 | fix: isort format 27 | 28 | lint: 29 | flake8 src tests --exclude=.tox,build 30 | 31 | type-check: 32 | mypy --pretty src tests/src 33 | 34 | check: lint isort-check format-check type-check 35 | 36 | pytest: 37 | pytest --cov=slotscheck 38 | 39 | test: check pytest 40 | 41 | docs: 42 | @touch docs/cli.rst 43 | make -C docs/ html 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Arie Bovenberg 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -- Project information ----------------------------------------------------- 2 | import importlib.metadata 3 | 4 | metadata = importlib.metadata.metadata("slotscheck") 5 | 6 | project = metadata["Name"] 7 | author = metadata["Author"] 8 | 9 | version = metadata["Version"] 10 | release = metadata["Version"] 11 | 12 | # -- General configuration --------------------------------------------------- 13 | extensions = [ 14 | "sphinx.ext.autodoc", 15 | "sphinx.ext.napoleon", 16 | "sphinx.ext.intersphinx", 17 | "sphinx.ext.viewcode", 18 | "sphinx_click", 19 | ] 20 | 21 | templates_path = ["_templates"] 22 | 23 | source_suffix = ".rst" 24 | 25 | # The master toctree document. 26 | master_doc = "index" 27 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 28 | 29 | 30 | # -- Options for HTML output ---------------------------------------------- 31 | 32 | html_theme = "furo" 33 | pygments_style = "tango" 34 | highlight_language = "python3" 35 | pygments_style = "default" 36 | pygments_dark_style = "lightbulb" 37 | autodoc_member_order = "bysource" 38 | 39 | toc_object_entries_show_parents = "hide" 40 | 41 | intersphinx_mapping = { 42 | "python": ("https://docs.python.org/3", None), 43 | } 44 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | workflow_dispatch: 11 | 12 | jobs: 13 | poetry-lockfile-up-to-date: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v1 17 | - name: Set up Python ${{ matrix.python-version }} 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install 'poetry>=1.8,<1.9' 25 | poetry check --lock 26 | 27 | build: 28 | runs-on: ubuntu-latest 29 | strategy: 30 | matrix: 31 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 32 | 33 | steps: 34 | - uses: actions/checkout@v1 35 | - name: Set up Python ${{ matrix.python-version }} 36 | uses: actions/setup-python@v2 37 | with: 38 | python-version: ${{ matrix.python-version }} 39 | - name: Install dependencies 40 | run: | 41 | python -m pip install --upgrade pip 42 | pip install 'tox<5' tox-gh-actions 'poetry>=1.8,<1.9' 43 | - name: Test with tox 44 | run: tox 45 | 46 | - name: Upload coverage to Codecov 47 | if: matrix.python-version == 3.11 48 | uses: codecov/codecov-action@v2.1.0 49 | with: 50 | file: ./coverage.xml 51 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | isolated_build = true 3 | envlist = py{39,310,311,312,313},lint,docs,mypy,isort,slots 4 | 5 | [testenv] 6 | allowlist_externals = 7 | poetry 8 | commands_pre= 9 | poetry install -n -v --no-root 10 | commands = 11 | poetry run pytest 12 | 13 | [testenv:py311] 14 | commands= 15 | poetry run pytest --cov=slotscheck --cov-report=xml {posargs} 16 | 17 | [testenv:lint] 18 | commands= 19 | poetry run black --check --diff src/ tests/src 20 | poetry run flake8 src/ tests/src 21 | 22 | [testenv:isort] 23 | commands= 24 | poetry run isort --check-only --diff src/ tests/src 25 | 26 | [testenv:mypy] 27 | commands= 28 | poetry run mypy --pretty --show-error-codes src tests/src 29 | 30 | [testenv:slots] 31 | commands= 32 | poetry run slotscheck -m slotscheck --verbose 33 | 34 | [testenv:docs] 35 | basepython=python3.11 36 | deps= 37 | -rdocs/requirements.txt 38 | commands= 39 | sphinx-build -W -d "{toxworkdir}/docs_doctree" docs "{toxworkdir}/docs_out" \ 40 | --color -bhtml 41 | python -c 'import pathlib; print("documentation available under " \ 42 | + (pathlib.Path(r"{toxworkdir}") / "docs_out" / "index.html").as_uri())' 43 | 44 | [coverage:run] 45 | branch=True 46 | omit=**/__main__.py 47 | [coverage:report] 48 | fail_under=100 49 | exclude_lines= 50 | pragma: no cover 51 | raise NotImplementedError 52 | \.\.\. 53 | 54 | [gh-actions] 55 | python = 56 | 3.9: py39 57 | 3.10: py310 58 | 3.11: py311, docs, lint, isort, slots 59 | 3.12: py312, mypy 60 | 3.13: py313 61 | -------------------------------------------------------------------------------- /docs/configuration.rst: -------------------------------------------------------------------------------- 1 | Configuration 2 | ============= 3 | 4 | ``slotscheck`` can be configured through the command line options or file 5 | (``pyproject.toml`` or ``setup.cfg``). 6 | 7 | Command line options 8 | -------------------- 9 | 10 | See the :ref:`command line interface ` documentation. 11 | 12 | Configuration file 13 | ------------------ 14 | 15 | The ``pyproject.toml`` or ``setup.cfg`` files offer the same configuration 16 | options as the CLI. See the :ref:`CLI docs `. 17 | 18 | An example TOML configuration: 19 | 20 | .. code-block:: toml 21 | 22 | [tool.slotscheck] 23 | strict-imports = true 24 | exclude-modules = ''' 25 | ( 26 | (^|\.)test_ # ignore any tests 27 | |^some\.specific\.module # do not check his module 28 | ) 29 | ''' 30 | require-superclass = false 31 | 32 | The equivalent ``setup.cfg``: 33 | 34 | .. code-block:: cfg 35 | 36 | [slotscheck] 37 | strict-imports = true 38 | exclude-modules = ( 39 | (^|\.)test_ # ignore any tests 40 | |^some\.specific\.module # do not check his module 41 | ) 42 | require-superclass = false 43 | 44 | Slotscheck will first try to find a ``pyproject.toml`` with a ``tool.slotscheck`` 45 | section in the current working directory. If not found, it will try to find a 46 | ``setup.cfg`` with ``slotscheck`` section. 47 | Until a file is found, this search will be repeated for each parent directory. 48 | 49 | Alternatively, you can manually specify the config file to be used with the 50 | ``--settings`` CLI option. 51 | 52 | Note that CLI options have precedence over a config file. 53 | Thus, you can always override what's configured there. 54 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "slotscheck" 3 | version = "0.19.1" 4 | description = "Ensure your __slots__ are working properly." 5 | authors = ["Arie Bovenberg "] 6 | license = "MIT" 7 | classifiers = [ 8 | "Typing :: Typed", 9 | "Topic :: Software Development :: Quality Assurance", 10 | "Programming Language :: Python :: Implementation :: CPython", 11 | "Programming Language :: Python :: 3.9", 12 | "Programming Language :: Python :: 3.10", 13 | "Programming Language :: Python :: 3.11", 14 | "Programming Language :: Python :: 3.12", 15 | "Programming Language :: Python :: 3.13", 16 | ] 17 | packages = [ 18 | { include = "slotscheck", from = "src" }, 19 | ] 20 | readme = "README.rst" 21 | repository = "https://github.com/ariebovenberg/slotscheck" 22 | homepage = "https://github.com/ariebovenberg/slotscheck" 23 | 24 | [tool.poetry.dependencies] 25 | python = ">=3.8.1" 26 | click = "^8.0" 27 | tomli = {version = ">=0.2.6,<3.0.0", python = "<3.11"} 28 | 29 | [tool.poetry.dev-dependencies] 30 | flake8 = "^7.1" 31 | isort = "^5.13.2" 32 | mypy = {version = "^1.14", extras = ["mypyc"]} 33 | pytest = "^8.3.5" 34 | black = "^24" 35 | pytest-cov = "^5.0.0" 36 | pytest-mock = "^3.14.1" 37 | typing_extensions = ">=4.1,<5" 38 | # Used as an example of an extension module. 39 | ujson = "^5.10.0" 40 | 41 | [tool.poetry.scripts] 42 | slotscheck = "slotscheck.cli:root" 43 | 44 | [tool.black] 45 | line-length = 79 46 | include = '\.pyi?$' 47 | exclude = ''' 48 | /( 49 | \.eggs 50 | | \.hg 51 | | \.git 52 | | \.mypy_cache 53 | | \.tox 54 | | \.venv 55 | | _build 56 | | build 57 | | dist 58 | )/ 59 | ''' 60 | 61 | [tool.isort] 62 | profile = 'black' 63 | line_length = 79 64 | 65 | [tool.slotscheck] 66 | strict-imports = true 67 | require-subclass = true 68 | require-superclass = true 69 | 70 | [build-system] 71 | requires = ["poetry-core>=1.9.0"] 72 | build-backend = "poetry.core.masonry.api" 73 | -------------------------------------------------------------------------------- /tests/examples/module_not_ok/foo.py: -------------------------------------------------------------------------------- 1 | from typing import Generic, Protocol, TypeVar 2 | 3 | from typing_extensions import Protocol as TypingExtProtocol 4 | 5 | 6 | class A: 7 | pass 8 | 9 | 10 | class B: 11 | __slots__ = () 12 | 13 | 14 | class C(B): 15 | pass 16 | 17 | 18 | class D(C): 19 | pass 20 | 21 | 22 | class E(B): 23 | __slots__ = ("a", "b") 24 | 25 | 26 | class F(E): 27 | __slots__ = () 28 | 29 | 30 | class G(F): 31 | __slots__ = ("k", "j") 32 | 33 | 34 | class H: 35 | __slots__ = {"k": "something", "z": "else"} 36 | 37 | 38 | class J(H): 39 | __slots__ = ["j", "a"] 40 | 41 | 42 | class K(H): 43 | __slots__ = {"j", "a"} 44 | 45 | 46 | class L(D): 47 | pass 48 | 49 | 50 | class M(zip): 51 | __slots__ = () 52 | 53 | 54 | class N(zip): 55 | __slots__ = ("a", "b") 56 | 57 | 58 | class P(int): 59 | __slots__ = () 60 | 61 | 62 | class Q(B, H): 63 | __slots__ = ("w",) 64 | 65 | 66 | class R(Q): 67 | pass 68 | 69 | 70 | class S(R): 71 | __slots__ = () 72 | 73 | 74 | class U(L): 75 | __slots__ = ("o", "p") 76 | 77 | class Ua(Q): 78 | __slots__ = ("i", "w") 79 | 80 | class Ub(Ua): 81 | __slots__ = "w" 82 | 83 | 84 | class T(A): 85 | __slots__ = {"z", "r"} 86 | 87 | 88 | class V(U): 89 | __slots__ = ("v",) 90 | 91 | 92 | class W(V): 93 | __slots__ = {"p": "", "q": "", "v": ""} 94 | 95 | 96 | class X(RuntimeError): 97 | pass 98 | 99 | 100 | class Z: 101 | __slots__ = ("a", "b", "c", "b", "b", "c") 102 | 103 | 104 | class Za(Z): 105 | __slots__ = ("b", "c") 106 | 107 | 108 | class MyProto(Protocol): 109 | pass 110 | 111 | 112 | class MyOtherProto(TypingExtProtocol): 113 | pass 114 | 115 | 116 | class Zb(MyProto): 117 | __slots__ = () 118 | 119 | 120 | Tvar = TypeVar("Tvar") 121 | 122 | 123 | class Zc(Generic[Tvar]): 124 | __slots__ = () 125 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | pip-wheel-metadata/ 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *.cover 48 | .hypothesis/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | 58 | # Flask stuff: 59 | instance/ 60 | .webassets-cache 61 | 62 | # Scrapy stuff: 63 | .scrapy 64 | 65 | # Sphinx documentation 66 | docs/_build/ 67 | 68 | # PyBuilder 69 | target/ 70 | 71 | # Jupyter Notebook 72 | .ipynb_checkpoints 73 | 74 | # pyenv 75 | .python-version 76 | 77 | # celery beat schedule file 78 | celerybeat-schedule 79 | 80 | # SageMath parsed files 81 | *.sage.py 82 | 83 | # dotenv 84 | .env 85 | 86 | # virtualenv 87 | .venv 88 | venv/ 89 | ENV/ 90 | 91 | # Spyder project settings 92 | .spyderproject 93 | .spyproject 94 | 95 | # Rope project settings 96 | .ropeproject 97 | 98 | # mkdocs documentation 99 | /site 100 | 101 | # mypy 102 | .mypy_cache/ 103 | /.idea 104 | 105 | .vim 106 | 107 | ### macOS ### 108 | # General 109 | .DS_Store 110 | .AppleDouble 111 | .LSOverride 112 | 113 | # Icon must end with two \r 114 | Icon 115 | 116 | # Thumbnails 117 | ._* 118 | 119 | # Files that might appear in the root of a volume 120 | .DocumentRevisions-V100 121 | .fseventsd 122 | .Spotlight-V100 123 | .TemporaryItems 124 | .Trashes 125 | .VolumeIcon.icns 126 | .com.apple.timemachine.donotpresent 127 | 128 | # Directories potentially created on remote AFP share 129 | .AppleDB 130 | .AppleDesktop 131 | Network Trash Folder 132 | Temporary Items 133 | .apdisk 134 | 135 | .envrc 136 | *.csv 137 | -------------------------------------------------------------------------------- /docs/errors.rst: -------------------------------------------------------------------------------- 1 | Errors and violations 2 | ===================== 3 | 4 | Slotscheck detects several different problems with slots. 5 | 6 | Superclass has no slots 7 | ----------------------- 8 | 9 | In order for memory savings from ``__slots__`` to work fully, 10 | all base classes of a slotted classe need to define slots as well: 11 | 12 | .. code-block:: python 13 | 14 | class NoSlots: 15 | pass 16 | 17 | class HasSlots: 18 | __slots__ = () 19 | 20 | 21 | class Bad(NoSlots): 22 | __slots__ = ('x', 'y') 23 | 24 | class Good(HasSlots): 25 | __slots__ = ('x', 'y') 26 | 27 | 28 | You can see the memory impact of this mistake with ``pympler`` library: 29 | 30 | .. code-block:: python 31 | 32 | from pympler.asizeof import asizeof 33 | 34 | asizeof(Bad()) # 168 35 | asizeof(Good()) # 48 36 | 37 | 38 | In addition, if a superclass has no slots, all subclasses will get ``__dict__``, 39 | which allows setting of arbitrary attributes. 40 | 41 | .. code-block:: python 42 | 43 | Bad().foo = 5 # no error, even though `foo` is not a slot! 44 | Good().foo = 3 # raises AttributeError, as it should. 45 | 46 | 47 | Overlapping slots 48 | ----------------- 49 | 50 | If a class defines a slot also present in a base class, 51 | the slot in the base class becomes inaccessible. 52 | This can cause surprising behavior. 53 | What's worse: these inaccessible slots do take up space in memory! 54 | 55 | .. code-block:: python 56 | 57 | class Base: 58 | __slots__ = ('x', ) 59 | 60 | class Bad(Base): 61 | __slots__ = ('x', 'z') 62 | 63 | class Good(Base): 64 | __slots__ = ('z', ) 65 | 66 | 67 | The official Python docs has this to say about overlapping slots 68 | (emphasis mine): 69 | 70 | If a class defines a slot also defined in a base class, 71 | the instance variable defined by the base class slot is inaccessible 72 | (except by retrieving its descriptor directly from the base class). 73 | **This renders the meaning of the program undefined.** In the future, 74 | a check may be added to prevent this. 75 | 76 | Duplicate slots 77 | --------------- 78 | 79 | Python doesn't stop you from declaring the same slot multiple times. 80 | This mistake will cost you some memory: 81 | 82 | .. code-block:: python 83 | 84 | class Good: 85 | __slots__ = ('a', 'b') 86 | 87 | class Bad: 88 | __slots__ = ('a', 'a', 'a', 'b', 'a', 'b') 89 | 90 | 91 | asizeof(Good()) # 48 92 | asizeof(Bad()) # 80 93 | -------------------------------------------------------------------------------- /docs/advanced.rst: -------------------------------------------------------------------------------- 1 | Advanced topics 2 | =============== 3 | 4 | Pre-commit hook 5 | --------------- 6 | 7 | You can run ``slotscheck`` as a pre-commit hook. 8 | Use the following configuration: 9 | 10 | .. attention:: 11 | 12 | Although slotscheck supports pre-commit, it is not recommended to use it. 13 | Use ``tox`` or a similar tool instead. 14 | 15 | Why? Slotscheck needs to import your code, so it needs the same 16 | dependencies. Pre-commit isn't meant for this use case. 17 | Configuring slotscheck to work with pre-commit is possible, but it's 18 | error-prone since you need to make sure the dependencies in the pre-commit 19 | container match the dependencies in your project. 20 | 21 | .. warning:: 22 | 23 | Slotscheck imports files to check them. 24 | Be sure to specify ``exclude`` 25 | to prevent slotscheck from importing scripts unintentionally. 26 | 27 | .. code-block:: yaml 28 | 29 | repos: 30 | - repo: https://github.com/ariebovenberg/slotscheck 31 | rev: v0.19.1 32 | hooks: 33 | - id: slotscheck 34 | # If your Python files are not importable from the project root, 35 | # (for example if they're in a "src" directory) 36 | # you will need to add this directory to Python's import path. 37 | # See slotscheck.rtfd.io/en/latest/discovery.html for more info. 38 | # Below is what you need to add if you're code isn't importable 39 | # from the project root, in a "src" directory: 40 | # 41 | # NOTE: This won't work on Windows though. 42 | # entry: env PYTHONPATH=src slotscheck --verbose 43 | 44 | # Add files you don't want slotscheck to import. 45 | # The example below ensures slotscheck will only run on 46 | # files in the "src/foo" directory: 47 | # 48 | # exclude: "^(?!src/foo/)" 49 | 50 | # For slotscheck to be able to import the code, 51 | # it needs access to the same dependencies. 52 | # One way is to use `additional_dependencies`. 53 | # These will then be added to the isolated slotscheck pre-commit env. 54 | # An example set of requirements is given below: 55 | # 56 | # additional_dependencies: 57 | # - requests==2.26 58 | # - click~=8.0 59 | 60 | # Instead of `additional_dependencies`, you can reuse the currently 61 | # active environment by setting language to `system`. 62 | # This requires `slotscheck` to be installed in that environment. 63 | # 64 | # language: system 65 | -------------------------------------------------------------------------------- /src/slotscheck/checks.py: -------------------------------------------------------------------------------- 1 | "Slots-related checks and inspection tools" 2 | import platform 3 | import sys 4 | from typing import Collection, Generic, Iterator, Optional 5 | 6 | from .common import is_typeddict 7 | 8 | 9 | def slots(c: type) -> Optional[Collection[str]]: 10 | try: 11 | slots_raw = c.__dict__["__slots__"] 12 | except KeyError: 13 | return None 14 | if isinstance(slots_raw, str): 15 | return (slots_raw,) 16 | elif isinstance(slots_raw, Iterator): 17 | raise NotImplementedError("Iterator __slots__ not supported. See #22") 18 | else: 19 | # We know it's a collection of strings now, since class creation 20 | # would have failed otherwise 21 | return slots_raw # type: ignore[no-any-return] 22 | 23 | 24 | def has_slots(c: type) -> bool: 25 | return ( 26 | "__slots__" in c.__dict__ 27 | or not (issubclass(c, BaseException) or is_pure_python(c)) 28 | or is_typeddict(c) 29 | or c is Generic # type: ignore[comparison-overlap] 30 | ) 31 | 32 | 33 | def has_slotless_base(c: type) -> bool: 34 | return not all(map(has_slots, c.__bases__)) 35 | 36 | 37 | def slots_overlap(c: type) -> bool: 38 | maybe_slots = slots(c) 39 | if maybe_slots is None: 40 | return False 41 | slots_ = set(maybe_slots) 42 | for ancestor in c.__mro__[1:]: 43 | if not slots_.isdisjoint(slots(ancestor) or ()): 44 | return True 45 | return False 46 | 47 | 48 | def has_duplicate_slots(c: type) -> bool: 49 | slots_ = slots(c) or () 50 | return len(set(slots_)) != len(list(slots_)) 51 | 52 | 53 | # The 'is a pure python class' logic below is adapted 54 | # from https://stackoverflow.com/a/41012823/ 55 | 56 | # If the active Python interpreter is the official CPython interpreter, 57 | # prefer a more reliable CPython-specific solution guaranteed to succeed. 58 | if platform.python_implementation() == "CPython": 59 | # Magic numbers defined by the Python codebase at "Include/object.h". 60 | Py_TPFLAGS_IMMUTABLETYPE = 1 << 8 61 | Py_TPFLAGS_HEAPTYPE = 1 << 9 62 | 63 | # Starting with CPython 3.10, `Py_TPFLAGS_HEAPTYPE` should no longer 64 | # be relied on, and `!Py_TPFLAGS_IMMUTABLETYPE` should be used instead. 65 | if sys.version_info >= (3, 10): 66 | 67 | def is_pure_python(cls: type) -> bool: 68 | "Whether the class is pure-Python or C-based" 69 | return not (cls.__flags__ & Py_TPFLAGS_IMMUTABLETYPE) 70 | 71 | else: # pragma: no cover 72 | 73 | def is_pure_python(cls: type) -> bool: 74 | "Whether the class is pure-Python or C-based" 75 | return bool(cls.__flags__ & Py_TPFLAGS_HEAPTYPE) 76 | 77 | 78 | # Else, fallback to a CPython-agnostic solution typically but *NOT* 79 | # necessarily succeeding. For all real-world objects of interest, this is 80 | # effectively successful. Edge cases exist but are suitably rare. 81 | else: # pragma: no cover 82 | 83 | def is_pure_python(cls: type) -> bool: 84 | "Whether the class is pure-Python or C-based" 85 | return "__dict__" in dir(cls) or hasattr(cls, "__slots__") 86 | -------------------------------------------------------------------------------- /docs/discovery.rst: -------------------------------------------------------------------------------- 1 | Module discovery 2 | ================ 3 | 4 | Slotscheck needs to import files in order to check them. 5 | This process usually behaves as you would expect. 6 | However, there are some complications that you may need to be aware of. 7 | 8 | .. admonition:: Summary 9 | 10 | You should generally be fine if you follow these rules: 11 | 12 | - To check files in your current directory, or subdirectories of it, 13 | you should run slotscheck as ``python -m slotscheck``. 14 | - To check files elsewhere, you may need to set the ``$PYTHONPATH`` 15 | environment variable. 16 | 17 | Whether you run ``python -m slotscheck`` or just ``slotscheck`` has an impact 18 | on which files will be imported and checked. 19 | This is not a choice by ``slotscheck``, but simply the way Python works. 20 | When running ``python -m slotscheck``, the current working 21 | directory is added to ``sys.path``, so any modules in the current directory 22 | can be imported. This is not the case when running bare ``slotscheck``. 23 | 24 | So if you run ``slotscheck foo.py``, ``foo`` will not be importable. 25 | In fact, if ``foo`` happens to be the name of an installed module, 26 | ``import foo`` will import that instead! 27 | In that case ``slotscheck`` will refuse to run, 28 | and print an informative message. 29 | An alternative way to ensure the correct files can be imported is with the 30 | ``$PYTHONPATH`` environment variable. 31 | 32 | To illustrate all this, imagine the following file tree:: 33 | 34 | src/ 35 | foo/ 36 | __init__.py 37 | bar.py 38 | 39 | In this example: 40 | 41 | - ❌ ``slotscheck src/foo/bar.py`` will result in an error, because ``src`` is 42 | not in ``sys.path``. 43 | - ❌ ``slotscheck -m foo.bar`` will result in an error, because ``src`` is 44 | not in ``sys.path``. 45 | - ❌ ``cd src && slotscheck foo/bar.py`` will also result in an error, 46 | because the current working directory is not in ``sys.path``. 47 | - ❌ ``cd src && slotscheck -m foo.bar`` will also result in an error, 48 | because the current working directory is not in ``sys.path``. 49 | - ✅ ``cd src && python -m slotscheck foo/bar.py`` will scan the ``foo.bar`` module as 50 | expected, because the current working directory *is* in the import path. 51 | - ✅ ``cd src && python -m slotscheck -m foo.bar`` will scan the ``foo.bar`` module as 52 | expected, because the current working directory *is* in the import path. 53 | - ✅ ``env PYTHONPATH=src slotscheck src/foo/bar.py`` will scan the ``foo.bar`` module 54 | as expected, because ``src`` is added to ``sys.path`` by environment variable. 55 | - ✅ ``env PYTHONPATH=src slotscheck -m foo.bar`` will scan the ``foo.bar`` module 56 | as expected, because ``src`` is added to ``sys.path`` by environment variable. 57 | 58 | Once the ``foo`` module is installed into site-packages, 59 | the following behavior will change: 60 | if ``foo/`` files are passed, but the installed module would be imported 61 | instead, slotscheck will report an error. 62 | 63 | .. admonition:: Why doesn't slotscheck just add the right paths 64 | to ``sys.path`` for me? 65 | 66 | Automatically changing ``sys.path`` is a global change that 67 | could have unintended consequences. 68 | In this case it's best not to assume this is the user's intention. 69 | Since you'll probably only need to add a single entry to Python's path, 70 | it's easy to define ``$PYTHONPATH`` explicitly. 71 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | 🎰 Slotscheck 2 | ============= 3 | 4 | .. image:: https://img.shields.io/pypi/v/slotscheck.svg?color=blue 5 | :target: https://pypi.python.org/pypi/slotscheck 6 | 7 | .. image:: https://img.shields.io/pypi/l/slotscheck.svg 8 | :target: https://pypi.python.org/pypi/slotscheck 9 | 10 | .. image:: https://img.shields.io/pypi/pyversions/slotscheck.svg 11 | :target: https://pypi.python.org/pypi/slotscheck 12 | 13 | .. image:: https://img.shields.io/readthedocs/slotscheck.svg 14 | :target: http://slotscheck.readthedocs.io/ 15 | 16 | .. image:: https://github.com/ariebovenberg/slotscheck/actions/workflows/build.yml/badge.svg 17 | :target: https://github.com/ariebovenberg/slotscheck/actions/workflows/build.yml 18 | 19 | .. image:: https://img.shields.io/codecov/c/github/ariebovenberg/slotscheck.svg 20 | :target: https://codecov.io/gh/ariebovenberg/slotscheck 21 | 22 | .. image:: https://img.shields.io/badge/code%20style-black-000000.svg 23 | :target: https://github.com/psf/black 24 | 25 | Adding ``__slots__`` to a class in Python is a great way to improve performance. 26 | But to work properly, all base classes need to implement it — without overlap! 27 | It's easy to get wrong, and what's worse: there is nothing warning you that you messed up. 28 | 29 | ✨ *Until now!* ✨ 30 | 31 | ``slotscheck`` helps you validate your slots are working properly. 32 | You can even use it to enforce the use of slots across (parts of) your codebase. 33 | 34 | See my `blog post `_ 35 | for the origin story behind ``slotscheck``. 36 | 37 | Quickstart 38 | ---------- 39 | 40 | Usage is quick from the command line: 41 | 42 | .. code-block:: bash 43 | 44 | python -m slotscheck [FILES]... 45 | # or 46 | slotscheck -m [MODULES]... 47 | 48 | For example: 49 | 50 | .. code-block:: bash 51 | 52 | $ slotscheck -m sanic 53 | ERROR: 'sanic.app:Sanic' defines overlapping slots. 54 | ERROR: 'sanic.response:HTTPResponse' has slots but superclass does not. 55 | Oh no, found some problems! 56 | Scanned 72 module(s), 111 class(es). 57 | 58 | Now get to fixing — 59 | and add ``slotscheck`` to your CI pipeline or 60 | `pre-commit `_ 61 | to prevent mistakes from creeping in again! 62 | See `here `__ and 63 | `here `__ for examples. 64 | 65 | Features 66 | -------- 67 | 68 | - Detect broken slots inheritance 69 | - Detect overlapping slots 70 | - Detect duplicate slots 71 | - `Pre-commit `_ hook 72 | - (Optionally) enforce the use of slots 73 | 74 | See `the documentation `_ for more details 75 | and configuration options. 76 | 77 | Why not a flake8 plugin? 78 | ------------------------ 79 | 80 | Flake8 plugins need to work without running the code. 81 | Many libraries use conditional imports, star imports, re-exports, 82 | and define slots with decorators or metaclasses. 83 | This all but requires running the code to determine the slots and class tree. 84 | 85 | There's `an issue `_ 86 | to discuss the matter. 87 | 88 | Notes 89 | ----- 90 | 91 | - ``slotscheck`` will try to import all submodules of the given package. 92 | If there are scripts without ``if __name__ == "__main__":`` blocks, 93 | they may be executed. 94 | - Even in the case that slots are not inherited properly, 95 | there may still be an advantage to using them 96 | (i.e. attribute access speed and *some* memory savings). 97 | However, in most cases this is unintentional. 98 | ``slotscheck`` allows you to ignore specific cases. 99 | - Because ``slotscheck`` imports your code in arbitrary order, 100 | it can—in rare cases—result in confusing and randomly-occurring import errors 101 | in third-party libraries. 102 | In such a case, it is recommended to omit modules such as your tests 103 | and mypy plugins from the slotscheck run. 104 | See `here `_. 105 | Alternatively, you can use ``PYTHONHASHSEED=`` to make the import order deterministic. 106 | A solution to this problem is being worked on in `this issue `_. 107 | 108 | Installation 109 | ------------ 110 | 111 | It's available on PyPI. 112 | 113 | .. code-block:: bash 114 | 115 | pip install slotscheck 116 | -------------------------------------------------------------------------------- /src/slotscheck/common.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | from dataclasses import dataclass, fields 3 | from itertools import chain, filterfalse 4 | from typing import ( 5 | Any, 6 | Callable, 7 | Collection, 8 | Iterable, 9 | Iterator, 10 | Mapping, 11 | Optional, 12 | Protocol, 13 | Set, 14 | Tuple, 15 | TypeVar, 16 | ) 17 | 18 | __all__ = [ 19 | "both", 20 | "compose", 21 | "groupby", 22 | "is_protocol", 23 | "is_typeddict", 24 | "map_optional", 25 | "unique", 26 | ] 27 | 28 | flatten = chain.from_iterable 29 | 30 | 31 | _T1 = TypeVar("_T1") 32 | _T2 = TypeVar("_T2") 33 | _Ttype = TypeVar("_Ttype", bound=type) 34 | 35 | 36 | # adapted from itertools recipe 37 | def unique(iterable: Iterable[_T1]) -> Iterable[_T1]: 38 | "List unique elements, preserving order." 39 | seen: Set[_T1] = set() 40 | seen_add = seen.add 41 | for element in filterfalse(seen.__contains__, iterable): 42 | seen_add(element) 43 | yield element 44 | 45 | 46 | def groupby( 47 | it: Iterable[_T1], *, key: Callable[[_T1], _T2] 48 | ) -> Mapping[_T2, Collection[_T1]]: 49 | "Group items into a dict by key" 50 | grouped = defaultdict(list) 51 | for i in it: 52 | grouped[key(i)].append(i) 53 | 54 | return grouped 55 | 56 | 57 | @dataclass(frozen=True, repr=False) 58 | class compose: 59 | "Funtion composition" 60 | __slots__ = ("_functions",) 61 | _functions: Tuple[Callable[[Any], Any], ...] 62 | 63 | def __init__(self, *functions: Any) -> None: 64 | object.__setattr__(self, "_functions", functions) 65 | 66 | def __call__(self, value: Any) -> Any: 67 | for f in reversed(self._functions): 68 | value = f(value) 69 | return value 70 | 71 | 72 | # I'd like for this to be a proper Protocol, 73 | # but mypy won't allow `object` to be recognized as such. 74 | # So let's just go with object. 75 | SupportsBool = object 76 | Predicate = Callable[[_T1], SupportsBool] 77 | 78 | 79 | def both(__a: Predicate[_T1], __b: Predicate[_T1]) -> Predicate[_T1]: 80 | return lambda x: __a(x) and __b(x) 81 | 82 | 83 | def map_optional( 84 | f: Callable[[_T1], Optional[_T2]], it: Iterable[_T1] 85 | ) -> Iterator[_T2]: 86 | return filterfalse(_is_none, map(f, it)) # type: ignore 87 | 88 | 89 | def _is_none(x: object) -> bool: 90 | return x is None 91 | 92 | 93 | try: 94 | from typing_extensions import is_typeddict # noqa 95 | except ImportError: # pragma: no cover 96 | from typing import _TypedDictMeta # type: ignore 97 | 98 | def is_typeddict(tp: object) -> bool: 99 | return isinstance(tp, _TypedDictMeta) 100 | 101 | 102 | # Note that typing.is_protocol is not available yet (CPython PR 104878) 103 | # The implementation below is derived from it. 104 | def is_protocol(t: type) -> bool: # pragma: no cover 105 | return ( 106 | getattr(t, "_is_protocol", False) 107 | and t != Protocol # type: ignore[comparison-overlap] 108 | ) 109 | 110 | 111 | try: 112 | from typing_extensions import Protocol as _TypingExtProtocol 113 | except ImportError: # pragma: no cover 114 | pass 115 | else: 116 | 117 | def is_protocol(t: type) -> bool: # noqa: F811 118 | return getattr(t, "_is_protocol", False) and t not in ( 119 | Protocol, 120 | _TypingExtProtocol, 121 | ) # type: ignore[comparison-overlap] 122 | 123 | 124 | # From https://github.com/ericvsmith/dataclasses/blob/master/dataclass_tools.py 125 | # License: https://github.com/ericvsmith/dataclasses/blob/master/LICENSE.txt 126 | # Changed only `dataclass.fields` naming 127 | def add_slots(cls: _Ttype) -> _Ttype: # pragma: no cover 128 | # Need to create a new class, since we can't set __slots__ 129 | # after a class has been created. 130 | 131 | # Make sure __slots__ isn't already set. 132 | if "__slots__" in cls.__dict__: 133 | raise TypeError(f"{cls.__name__} already specifies __slots__") 134 | 135 | # Create a new dict for our new class. 136 | cls_dict = dict(cls.__dict__) 137 | field_names = tuple(f.name for f in fields(cls)) 138 | cls_dict["__slots__"] = field_names 139 | for field_name in field_names: 140 | # Remove our attributes, if present. They'll still be 141 | # available in _MARKER. 142 | cls_dict.pop(field_name, None) 143 | # Remove __dict__ itself. 144 | cls_dict.pop("__dict__", None) 145 | # And finally create the class. 146 | qualname = getattr(cls, "__qualname__", None) 147 | cls = type(cls)(cls.__name__, cls.__bases__, cls_dict) 148 | if qualname is not None: 149 | cls.__qualname__ = qualname 150 | return cls 151 | -------------------------------------------------------------------------------- /tests/src/test_checks.py: -------------------------------------------------------------------------------- 1 | from array import array 2 | from datetime import date 3 | from decimal import Decimal 4 | from enum import Enum 5 | from fractions import Fraction 6 | from random import Random 7 | from xml.etree.ElementTree import Element 8 | 9 | import pytest 10 | from typing_extensions import TypedDict as TypingExtensionsTypedDict 11 | 12 | from slotscheck.checks import has_slotless_base, has_slots, slots_overlap 13 | 14 | try: 15 | from typing import TypedDict 16 | except ImportError: 17 | TypedDict = TypingExtensionsTypedDict 18 | 19 | 20 | class HasSlots: 21 | __slots__ = ("a", "b") 22 | 23 | 24 | class GoodInherit(HasSlots): 25 | __slots__ = ["c", "d", "e"] 26 | 27 | 28 | class BadOverlaps(GoodInherit): 29 | __slots__ = {"a": "some docstring", "f": "bla"} 30 | 31 | 32 | class NoSlotsInherits(HasSlots): 33 | pass 34 | 35 | 36 | class BadInherit(Random): 37 | __slots__ = () 38 | 39 | 40 | class BadInheritAndOverlap(NoSlotsInherits): 41 | __slots__ = ["z", "b", "a"] 42 | 43 | 44 | class ChildOfBadClass(BadInheritAndOverlap): 45 | pass 46 | 47 | 48 | class _RestrictiveMeta(type): 49 | def __setattr__(self, name, value) -> None: 50 | raise TypeError("BOOM!") 51 | 52 | 53 | class _UnsettableClass(metaclass=_RestrictiveMeta): 54 | pass 55 | 56 | 57 | class OneStringSlot(HasSlots): 58 | __slots__ = "baz" 59 | 60 | 61 | class ArrayInherit(array): # type: ignore[type-arg] 62 | __slots__ = () 63 | 64 | 65 | class FooMeta(type): 66 | __slots__ = () 67 | 68 | 69 | class Foo(metaclass=FooMeta): 70 | __slots__ = () 71 | 72 | 73 | class MyDict(TypedDict): 74 | foo: str 75 | 76 | 77 | class MyTypingExtensionsTypedDict(TypingExtensionsTypedDict): 78 | bla: int 79 | 80 | 81 | class TestHasSlots: 82 | @pytest.mark.parametrize( 83 | "klass", 84 | [type, dict, date, float, Decimal, Element, array], 85 | ) 86 | def test_not_purepython(self, klass): 87 | assert has_slots(klass) 88 | 89 | def test_typeddict(self): 90 | assert has_slots(MyDict) 91 | 92 | def test_typing_extensions_typeddict(self): 93 | assert has_slots(MyTypingExtensionsTypedDict) 94 | 95 | @pytest.mark.parametrize( 96 | "klass", 97 | [ 98 | Fraction, 99 | HasSlots, 100 | GoodInherit, 101 | BadInherit, 102 | BadOverlaps, 103 | OneStringSlot, 104 | ArrayInherit, 105 | Foo, 106 | FooMeta, 107 | ], 108 | ) 109 | def test_slots(self, klass): 110 | assert has_slots(klass) 111 | 112 | @pytest.mark.parametrize( 113 | "klass", 114 | [ 115 | Random, 116 | Enum, 117 | NoSlotsInherits, 118 | ChildOfBadClass, 119 | RuntimeError, 120 | KeyboardInterrupt, 121 | ], 122 | ) 123 | def test_no_slots(self, klass): 124 | assert not has_slots(klass) 125 | 126 | def test_immutable_class(self): 127 | assert not has_slots(_UnsettableClass) 128 | 129 | 130 | class TestSlotsOverlap: 131 | @pytest.mark.parametrize( 132 | "klass", 133 | [type, dict, date, float, Decimal, AssertionError, RuntimeError], 134 | ) 135 | def test_not_purepython(self, klass): 136 | assert not slots_overlap(klass) 137 | 138 | @pytest.mark.parametrize( 139 | "klass", 140 | [ 141 | Fraction, 142 | HasSlots, 143 | GoodInherit, 144 | BadInherit, 145 | OneStringSlot, 146 | Foo, 147 | FooMeta, 148 | ArrayInherit, 149 | ], 150 | ) 151 | def test_slots_ok(self, klass): 152 | assert not slots_overlap(klass) 153 | 154 | @pytest.mark.parametrize("klass", [BadOverlaps, BadInheritAndOverlap]) 155 | def test_slots_not_ok(self, klass): 156 | assert slots_overlap(klass) 157 | 158 | @pytest.mark.parametrize( 159 | "klass", [Random, Enum, NoSlotsInherits, ChildOfBadClass] 160 | ) 161 | def test_no_slots(self, klass): 162 | assert not slots_overlap(klass) 163 | 164 | 165 | class TestHasSlotlessBase: 166 | @pytest.mark.parametrize( 167 | "klass", 168 | [type, dict, date, float, Decimal], 169 | ) 170 | def test_not_purepython(self, klass): 171 | assert not has_slotless_base(klass) 172 | 173 | @pytest.mark.parametrize( 174 | "klass", [Fraction, HasSlots, GoodInherit, BadOverlaps, OneStringSlot] 175 | ) 176 | def test_slots_ok(self, klass): 177 | assert not has_slotless_base(klass) 178 | 179 | @pytest.mark.parametrize( 180 | "klass", 181 | [BadInherit, BadInheritAndOverlap, AssertionError, RuntimeError], 182 | ) 183 | def test_slots_not_ok(self, klass): 184 | assert has_slotless_base(klass) 185 | 186 | @pytest.mark.parametrize("klass", [Enum, NoSlotsInherits, ChildOfBadClass]) 187 | def test_no_slots(self, klass): 188 | assert not has_slotless_base(klass) 189 | -------------------------------------------------------------------------------- /src/slotscheck/config.py: -------------------------------------------------------------------------------- 1 | "Logic for gathering and managing the configuration settings" 2 | 3 | import configparser 4 | import sys 5 | from dataclasses import dataclass, fields 6 | from itertools import chain 7 | from pathlib import Path 8 | from typing import ClassVar, Collection, Mapping, Optional, Type, TypeVar 9 | 10 | if sys.version_info >= (3, 11): 11 | import tomllib 12 | else: # pragma: no cover 13 | import tomli as tomllib 14 | 15 | from .common import add_slots, both 16 | 17 | RegexStr = str 18 | "A regex string in Python's verbose syntax" 19 | DEFAULT_MODULE_EXCLUDE_RE = r"(^|\.)__main__(\.|$)" 20 | _T = TypeVar("_T") 21 | 22 | 23 | @add_slots 24 | @dataclass(frozen=True) 25 | class PartialConfig: 26 | "Configuration options where not all options may be defined." 27 | strict_imports: Optional[bool] 28 | require_subclass: Optional[bool] 29 | require_superclass: Optional[bool] 30 | include_modules: Optional[RegexStr] 31 | exclude_modules: Optional[RegexStr] 32 | include_classes: Optional[RegexStr] 33 | exclude_classes: Optional[RegexStr] 34 | 35 | EMPTY: ClassVar["PartialConfig"] 36 | 37 | @classmethod 38 | def load(cls, p: Path) -> "PartialConfig": 39 | "May raise various exceptions on parse problems. File must exist." 40 | if p.suffix == ".toml": 41 | return cls._load_toml(p) 42 | elif p.suffix in (".ini", ".cfg"): 43 | return cls._load_ini(p) 44 | else: 45 | raise ValueError( 46 | "Settings file with invalid extension. " 47 | "Expected .toml, .ini, or .cfg" 48 | ) 49 | 50 | @classmethod 51 | def _load_toml(cls, p: Path) -> "PartialConfig": 52 | with p.open("rb") as rfile: 53 | return cls._load_confmap( 54 | tomllib.load(rfile).get("tool", {}).get("slotscheck", {}) 55 | ) 56 | 57 | @classmethod 58 | def _load_ini(cls, p: Path) -> "PartialConfig": 59 | cfg = configparser.ConfigParser() 60 | cfg.read(p, encoding="utf-8") 61 | return cls._load_confmap( 62 | { 63 | k: cfg.BOOLEAN_STATES.get(v, v) 64 | for k, v in cfg.items("slotscheck") 65 | } 66 | if cfg.has_section("slotscheck") 67 | else {} 68 | ) 69 | 70 | @classmethod 71 | def _load_confmap(cls, m: Mapping[str, object]) -> "PartialConfig": 72 | if not m.keys() <= _ALLOWED_KEYS.keys(): 73 | raise InvalidKeys(m.keys() - _ALLOWED_KEYS) 74 | 75 | return PartialConfig( 76 | **{ 77 | key.replace("-", "_"): _extract_value(m, key, expect_type) 78 | for key, expect_type in _ALLOWED_KEYS.items() 79 | }, 80 | ) 81 | 82 | 83 | PartialConfig.EMPTY = PartialConfig(None, None, None, None, None, None, None) 84 | 85 | 86 | @dataclass(frozen=True) 87 | class Config(PartialConfig): 88 | "A full set of options" 89 | __slots__ = () 90 | strict_imports: bool 91 | require_subclass: bool 92 | require_superclass: bool 93 | exclude_modules: RegexStr 94 | 95 | DEFAULT: ClassVar["Config"] 96 | 97 | def apply(self, other: PartialConfig) -> "Config": 98 | return Config( 99 | **{ 100 | f.name: _none_or(getattr(other, f.name), getattr(self, f.name)) 101 | for f in fields(PartialConfig) 102 | } 103 | ) 104 | 105 | 106 | Config.DEFAULT = Config( 107 | strict_imports=True, 108 | require_subclass=False, 109 | require_superclass=True, 110 | include_modules=None, 111 | exclude_modules=DEFAULT_MODULE_EXCLUDE_RE, 112 | include_classes=None, 113 | exclude_classes=None, 114 | ) 115 | 116 | 117 | def collect( 118 | from_cli: PartialConfig, cwd: Path, config: Optional[Path] 119 | ) -> Config: 120 | "Gather and combine configuration options from the available sources" 121 | confpath = config or find_config_file(cwd) 122 | conf = PartialConfig.load(confpath) if confpath else PartialConfig.EMPTY 123 | return Config.DEFAULT.apply(conf).apply(from_cli) 124 | 125 | 126 | _CONFIG_FILENAMES = ("pyproject.toml", "setup.cfg") 127 | 128 | 129 | def find_config_file(path: Path) -> Optional[Path]: 130 | "Find a configuration file in the given directory or its parents" 131 | candidates = ( 132 | directory / name 133 | for directory in chain((path,), path.parents) 134 | for name in _CONFIG_FILENAMES 135 | ) 136 | return next( 137 | filter( 138 | both( 139 | Path.is_file, # type: ignore[arg-type] 140 | _has_slotscheck_section, 141 | ), 142 | candidates, 143 | ), 144 | None, 145 | ) 146 | 147 | 148 | def _has_slotscheck_section(p: Path) -> bool: 149 | with p.open("rb") as rfile: 150 | if p.suffix == ".toml": 151 | return "slotscheck" in tomllib.load(rfile).get("tool", {}) 152 | else: 153 | assert p.suffix == ".cfg" 154 | cfg = configparser.ConfigParser() 155 | cfg.read(p, encoding="utf-8") 156 | return cfg.has_section("slotscheck") 157 | 158 | 159 | class InvalidKeys(Exception): 160 | def __init__(self, keys: Collection[str]) -> None: 161 | self.keys = keys 162 | 163 | def __str__(self) -> str: 164 | return ( 165 | "Invalid configuration key(s): " 166 | + ", ".join(map("'{}'".format, sorted(self.keys))) 167 | + "." 168 | ) 169 | 170 | 171 | class InvalidValueType(Exception): 172 | def __init__(self, key: str) -> None: 173 | self.key = key 174 | 175 | def __str__(self) -> str: 176 | return f"Invalid value type for '{self.key}'." 177 | 178 | 179 | def _none_or(a: Optional[_T], b: _T) -> _T: 180 | return b if a is None else a 181 | 182 | 183 | def _extract_value( 184 | c: Mapping[str, object], key: str, expect_type: Type[_T] 185 | ) -> Optional[_T]: 186 | try: 187 | raw_value = c[key] 188 | except KeyError: 189 | return None 190 | else: 191 | if isinstance(raw_value, expect_type): 192 | return raw_value 193 | else: 194 | raise InvalidValueType(key) 195 | 196 | 197 | _ALLOWED_KEYS = { 198 | "strict-imports": bool, 199 | "require-subclass": bool, 200 | "require-superclass": bool, 201 | "include-modules": str, 202 | "exclude-modules": str, 203 | "include-classes": str, 204 | "exclude-classes": str, 205 | } 206 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | 0.19.1 (2024-10-19) 5 | ------------------- 6 | 7 | - Explicit Python 3.13 support, drop Python 3.8 support 8 | - Documentation improvements 9 | 10 | 0.19.0 (2024-03-25) 11 | ------------------- 12 | 13 | - Improved support for implicit/native namespace packages (#228, #230) 14 | 15 | 0.18.0 (2024-03-21) 16 | ------------------- 17 | 18 | - Improved robustness of importing of namespace packages and built-in modules (#227) 19 | 20 | 0.17.3 (2024-02-04) 21 | ------------------- 22 | 23 | - Fix packaging metadata issue where README/CHANGELOG were installed to the 24 | wrong location. 25 | 26 | 0.17.2 (2024-02-04) 27 | ------------------- 28 | 29 | - Remove Python upper version constraint in package metadata 30 | 31 | 0.17.1 (2023-11-03) 32 | ------------------- 33 | 34 | - Fixed ``Generic`` false positive in Python 3.12+ (#201). 35 | - Remove ``pkgutil`` deprecation warning on Python 3.12+. 36 | - Official Python 3.12 support. 37 | 38 | 0.17.0 (2023-07-31) 39 | ------------------- 40 | 41 | - Drop Python 3.7 support. 42 | - Remove ``typing-extensions`` and ``importlib_metadata`` dependencies. 43 | - More robust checks to prevent ``Protocol`` false positives. 44 | 45 | 0.16.5 (2023-03-01) 46 | ------------------- 47 | 48 | - Don't flag ``TypedDict`` from ``typing_extensions`` in Python versions 49 | where ``typing`` has ``TypedDict`` itself. 50 | 51 | 0.16.4 (2023-01-13) 52 | ------------------- 53 | 54 | - Remove ``tomli`` dependency on Python 3.11+ 55 | 56 | 0.16.3 (2023-01-03) 57 | ------------------- 58 | 59 | - Fix broken ``Protocol`` import due to absent ``typing_extensions`` 60 | on Python <3.10. 61 | 62 | 0.16.2 (2022-12-29) 63 | ------------------- 64 | 65 | - Don't flag ``Protocol`` classes as needing slots if strict 66 | ``require-subclass`` option is enabled. 67 | 68 | 0.16.1 (2022-11-21) 69 | ------------------- 70 | 71 | - Don't flag ``TypedDict`` subclasses as missing slots (#120). 72 | 73 | 0.16.0 (2022-11-01) 74 | ------------------- 75 | 76 | **Breaking changes** 77 | 78 | - The "strict-imports" default value was mistakenly ``False`` in contrast to what is documented. 79 | The default value is now ``True``, in line with documentation. 80 | If you were relying on this permissive import behavior, 81 | you will need to set this option in the CLI or settings file. 82 | - Drop Python 3.6 support 83 | 84 | **Bugfixes** 85 | 86 | - Disabling "strict-imports" now also gracefully handles failed imports 87 | of the root module given (#113). 88 | 89 | **Features** 90 | 91 | - Add official Python 3.11 support 92 | 93 | 0.15.0 (2022-08-09) 94 | ------------------- 95 | 96 | - Fix error when encountering metaclass with slots (#106) 97 | - Improved detection of immutable classes on Python 3.10+ (#92) 98 | 99 | 0.14.1 (2022-06-26) 100 | ------------------- 101 | 102 | - Gracefully handle inspection failure due to 103 | unimportable parent package (#90). 104 | 105 | 0.14.0 (2022-02-27) 106 | ------------------- 107 | 108 | - Improved robustness of importing implicitly namespaced and 109 | extension packages (#64) 110 | 111 | 0.13.0 (2022-02-26) 112 | ------------------- 113 | 114 | - Improved robustness of determining whether a class is pure Python (#65) 115 | 116 | 0.12.1 (2022-02-12) 117 | ------------------- 118 | 119 | - Add helpful link to 'failed to find module' message. 120 | 121 | 0.12.0 (2022-02-06) 122 | ------------------- 123 | 124 | - Support ``setup.cfg`` as alternate settings file (#52). 125 | 126 | 0.11.0 (2022-02-05) 127 | ------------------- 128 | 129 | - Explicitly prevent ambiguous imports (#51). 130 | 131 | 0.10.2 (2022-01-30) 132 | ------------------- 133 | 134 | - Correctly handle case when ``__slots__`` is defined as a single string (#47). 135 | 136 | 0.10.1 (2022-01-30) 137 | ------------------- 138 | 139 | - Small improvements to verbose error messages. 140 | 141 | 0.10.0 (2022-01-29) 142 | ------------------- 143 | 144 | - Detect duplicate slots (#21). 145 | - Improvements to docs. 146 | - Clarify pre-commit usage (#45). 147 | 148 | 0.9.0 (2022-01-25) 149 | ------------------ 150 | 151 | - Support specifying the location of settings file with ``--settings`` option. 152 | - Improved verbose error messages. 153 | 154 | 0.8.0 (2022-01-21) 155 | ------------------ 156 | 157 | - Extension modules are now traversed. 158 | - Small tweaks to documentation. 159 | 160 | 0.7.2 (2022-01-18) 161 | ------------------ 162 | 163 | - Recommend running as ``python -m slotscheck`` when checking files. 164 | Update pre-commit hook to reflect this. 165 | 166 | 0.7.1 (2022-01-18) 167 | ------------------ 168 | 169 | - Add Python 3.6 support 170 | 171 | 0.7.0 (2022-01-18) 172 | ------------------ 173 | 174 | **Breaking changes** 175 | 176 | - Strict imports are now the default 177 | - Include/exclude regex patterns now use partial patch (like mypy, isort do). 178 | 179 | **Adjustments** 180 | 181 | - Clarifications in documentation 182 | - Pre-commit hook uses verbose mode by default 183 | 184 | 0.6.0 (2022-01-17) 185 | ------------------ 186 | 187 | **Breaking changes** 188 | 189 | - Arguments are now file paths. Use the ``-m/--module`` option to scan modules. 190 | 191 | **Features** 192 | 193 | - Support use as pre-commit hook. 194 | - Multiple modules or files allowed as input. 195 | - Document the types of slot errors. 196 | 197 | 0.5.3 (2022-01-14) 198 | ------------------ 199 | 200 | - Fix typo in readme. 201 | 202 | 0.5.2 (2022-01-14) 203 | ------------------ 204 | 205 | - Fix crash when encountering overlapping slots from multiple classes. 206 | 207 | 0.5.1 (2022-01-14) 208 | ------------------ 209 | 210 | - Relax ``tomli`` dependency pin. 211 | 212 | 0.5.0 (2022-01-14) 213 | ------------------ 214 | 215 | - More descriptive output on overlapping slots (#26). 216 | - Simplify slot requirement flags. 217 | - allow configuration by ``pyproject.toml`` (#28). 218 | 219 | 0.4.0 (2022-01-12) 220 | ------------------ 221 | 222 | - Recognize builtin exceptions as not having slots. 223 | - Split ``--exclude-modules`` and ``exclude-classes``. 224 | - Add flags to specify inclusion as well as exclusion of modules/classes. 225 | - Allow disabling slot inheritance check. 226 | - Add ``--require-slots`` option. 227 | 228 | 0.3.1 (2022-01-10) 229 | ------------------ 230 | 231 | - Catch ``BaseException`` in module import. 232 | 233 | 0.3.0 (2022-01-10) 234 | ------------------ 235 | 236 | - Add ``--strict-imports`` flag (#24) 237 | - Detect overlapping slots (#10) 238 | - 100% test coverage (#15) 239 | - Add ``--exclude`` flag (#9) 240 | 241 | 0.2.1 (2022-01-04) 242 | ------------------ 243 | 244 | - Improved error message if module cannot be found (#18) 245 | 246 | 0.2.0 (2022-01-03) 247 | ------------------ 248 | 249 | - Enable running with ``-m slotscheck`` (#13) 250 | 251 | 0.1.2 (2022-01-03) 252 | ------------------ 253 | 254 | - Skip ``__main__.py`` in module scan to prevent running unintented code 255 | 256 | 0.1.1 (2022-01-03) 257 | ------------------ 258 | 259 | - Improve output report 260 | 261 | 0.1.0 (2021-12-30) 262 | ------------------ 263 | 264 | - Improve documentation 265 | 266 | 0.0.1 (2021-12-29) 267 | ------------------ 268 | 269 | - Initial release 270 | -------------------------------------------------------------------------------- /tests/src/test_config.py: -------------------------------------------------------------------------------- 1 | import re 2 | import sys 3 | from pathlib import Path 4 | 5 | import pytest 6 | 7 | if sys.version_info >= (3, 11): 8 | import tomllib 9 | else: 10 | import tomli as tomllib 11 | 12 | from slotscheck.config import ( 13 | DEFAULT_MODULE_EXCLUDE_RE, 14 | Config, 15 | InvalidKeys, 16 | InvalidValueType, 17 | PartialConfig, 18 | collect, 19 | find_config_file, 20 | ) 21 | 22 | EXAMPLE_TOML = b""" 23 | [tool.foo] 24 | bla = 5 25 | 26 | [tool.slotscheck] 27 | require-subclass = true 28 | exclude-modules = ''' 29 | ( 30 | .*\\.test\\..* 31 | |__main__ 32 | |some\\.specific\\.module 33 | ) 34 | ''' 35 | require-superclass = false 36 | """ 37 | 38 | EXAMPLE_INI = """ 39 | [foo] 40 | bla = 5 41 | 42 | [slotscheck] 43 | require-subclass = false 44 | exclude-modules = ( 45 | .*\\.test\\..* 46 | |__main__ 47 | |some\\.specific\\.module 48 | ) 49 | require-superclass = false 50 | """ 51 | 52 | 53 | class TestFindConfigFile: 54 | def test_working_directory_toml(self, tmpdir): 55 | workdir, parentdir = tmpdir.ensure_dir("foo"), tmpdir 56 | (parentdir / "pyproject.toml").write_binary(EXAMPLE_TOML) 57 | (parentdir / "setup.cfg").write_binary(EXAMPLE_INI.encode()) 58 | (workdir / "pyproject.toml").write_binary(EXAMPLE_TOML) 59 | (workdir / "setup.cfg").write_binary(EXAMPLE_INI.encode()) 60 | assert find_config_file(Path(workdir)) == Path( 61 | workdir / "pyproject.toml" 62 | ) 63 | 64 | def test_working_directory_cfg(self, tmpdir): 65 | workdir, parentdir = tmpdir.ensure_dir("foo"), tmpdir 66 | (parentdir / "pyproject.toml").write_binary(EXAMPLE_TOML) 67 | (parentdir / "setup.cfg").write_binary(EXAMPLE_INI.encode()) 68 | (workdir / "pyproject.toml").write_binary(b"[foo]\nbar = 5") 69 | (workdir / "setup.cfg").write_binary(EXAMPLE_INI.encode()) 70 | assert find_config_file(Path(workdir)) == Path(workdir / "setup.cfg") 71 | 72 | def test_not_found(self, tmpdir): 73 | (tmpdir / "pyproject.toml").write_binary(b"[foo]\nbar = 5") 74 | (tmpdir / "setup.cfg").write_binary(b"[foo]\nbar = 5") 75 | assert find_config_file(Path(tmpdir)) is None 76 | 77 | def test_parent_directory_toml(self, tmpdir): 78 | workdir, parentdir = tmpdir.ensure_dir("foo"), tmpdir 79 | (parentdir / "pyproject.toml").write_binary(EXAMPLE_TOML) 80 | (parentdir / "setup.cfg").write_binary(EXAMPLE_INI.encode()) 81 | (workdir / "pyproject.toml").write_binary(b"[foo]\nbar = 5") 82 | (workdir / "setup.cfg").write_binary(b"[foo]\nbar = 5") 83 | assert find_config_file(Path(workdir)) == Path( 84 | parentdir / "pyproject.toml" 85 | ) 86 | 87 | def test_parent_directory_cfg(self, tmpdir): 88 | workdir, parentdir = tmpdir.ensure_dir("foo"), tmpdir 89 | (parentdir / "setup.cfg").write_binary(EXAMPLE_INI.encode()) 90 | (workdir / "pyproject.toml").write_binary(b"[foo]\nbar = 5") 91 | (workdir / "setup.cfg").write_binary(b"[foo]\nbar = 5") 92 | assert find_config_file(Path(workdir)) == Path(parentdir / "setup.cfg") 93 | 94 | 95 | def test_collect(tmpdir): 96 | (tmpdir / "setup.cfg").write_text(EXAMPLE_INI, encoding="utf-8") 97 | (tmpdir / "pyproject.toml").write_binary(EXAMPLE_TOML) 98 | assert collect(PartialConfig.EMPTY, Path(tmpdir), None).require_subclass 99 | 100 | 101 | class TestOptionsApply: 102 | def test_empty(self): 103 | assert Config(True, False, True, None, "", "hello", None).apply( 104 | PartialConfig(None, None, None, None, None, None, None) 105 | ) == Config(True, False, True, None, "", "hello", None) 106 | 107 | def test_different(self): 108 | assert Config(True, False, True, None, "", "hello", None).apply( 109 | PartialConfig(False, None, None, "hi", "", None, None) 110 | ) == Config(False, False, True, "hi", "", "hello", None) 111 | 112 | 113 | class TestPartialOptionsFromToml: 114 | def test_options_from_toml(self, tmpdir): 115 | (tmpdir / "myconf.toml").write_binary(EXAMPLE_TOML) 116 | Config = PartialConfig.load(Path(tmpdir / "myconf.toml")) 117 | assert Config == PartialConfig( 118 | strict_imports=None, 119 | require_subclass=True, 120 | require_superclass=False, 121 | include_modules=None, 122 | include_classes=None, 123 | exclude_classes=None, 124 | exclude_modules="""\ 125 | ( 126 | .*\\.test\\..* 127 | |__main__ 128 | |some\\.specific\\.module 129 | ) 130 | """, 131 | ) 132 | 133 | def test_invalid_toml(self, tmpdir): 134 | (tmpdir / "myconf.toml").write_binary(b"[foo inv]alid") 135 | with pytest.raises(tomllib.TOMLDecodeError): 136 | PartialConfig.load(Path(tmpdir / "myconf.toml")) 137 | 138 | def test_no_slotscheck_section(self, tmpdir): 139 | (tmpdir / "myconf.toml").write_binary( 140 | b""" 141 | [tool.bla] 142 | k = 5 143 | """ 144 | ) 145 | assert PartialConfig.load( 146 | Path(tmpdir / "myconf.toml") 147 | ) == PartialConfig(None, None, None, None, None, None, None) 148 | 149 | def test_empty_slotscheck_section(self, tmpdir): 150 | (tmpdir / "myconf.toml").write_binary( 151 | b""" 152 | [tool.slotscheck] 153 | """ 154 | ) 155 | assert PartialConfig.load( 156 | Path(tmpdir / "myconf.toml") 157 | ) == PartialConfig(None, None, None, None, None, None, None) 158 | 159 | def test_empty(self, tmpdir): 160 | (tmpdir / "myconf.toml").write_binary(b"") 161 | assert PartialConfig.load( 162 | Path(tmpdir / "myconf.toml") 163 | ) == PartialConfig(None, None, None, None, None, None, None) 164 | 165 | def test_invalid_keys(self, tmpdir): 166 | (tmpdir / "myconf.toml").write_binary( 167 | b""" 168 | [tool.slotscheck] 169 | k = 9 170 | strict-imports = true 171 | foo = 4 172 | """ 173 | ) 174 | with pytest.raises( 175 | InvalidKeys, 176 | match=re.escape("Invalid configuration key(s): 'foo', 'k'."), 177 | ): 178 | PartialConfig.load(Path(tmpdir / "myconf.toml")) 179 | 180 | def test_invalid_types(self, tmpdir): 181 | (tmpdir / "myconf.toml").write_binary( 182 | b""" 183 | [tool.slotscheck] 184 | strict-imports = true 185 | include-modules = false 186 | """ 187 | ) 188 | with pytest.raises( 189 | InvalidValueType, 190 | match=re.escape("Invalid value type for 'include-modules'."), 191 | ): 192 | PartialConfig.load(Path(tmpdir / "myconf.toml")) 193 | 194 | 195 | class TestPartialOptionsFromIni: 196 | def test_options_from_ini(self, tmpdir): 197 | (tmpdir / "setup.cfg").write_text(EXAMPLE_INI, encoding="utf-8") 198 | Config = PartialConfig.load(Path(tmpdir / "setup.cfg")) 199 | assert Config == PartialConfig( 200 | strict_imports=None, 201 | require_subclass=False, 202 | require_superclass=False, 203 | include_modules=None, 204 | include_classes=None, 205 | exclude_classes=None, 206 | exclude_modules="""\ 207 | ( 208 | .*\\.test\\..* 209 | |__main__ 210 | |some\\.specific\\.module 211 | )""", 212 | ) 213 | 214 | def test_no_slotscheck_section(self, tmpdir): 215 | (tmpdir / "setup.cfg").write_text( 216 | """ 217 | [tool.bla] 218 | k = 5 219 | """, 220 | encoding="utf-8", 221 | ) 222 | assert PartialConfig.load(Path(tmpdir / "setup.cfg")) == PartialConfig( 223 | None, None, None, None, None, None, None 224 | ) 225 | 226 | def test_empty_slotscheck_section(self, tmpdir): 227 | (tmpdir / "myconf.toml").write_text( 228 | """ 229 | [slotscheck] 230 | """, 231 | encoding="utf-8", 232 | ) 233 | assert PartialConfig.load(Path(tmpdir / "setup.cfg")) == PartialConfig( 234 | None, None, None, None, None, None, None 235 | ) 236 | 237 | def test_empty(self, tmpdir): 238 | (tmpdir / "setup.cfg").write_text("", encoding="utf-8") 239 | assert PartialConfig.load(Path(tmpdir / "setup.cfg")) == PartialConfig( 240 | None, None, None, None, None, None, None 241 | ) 242 | 243 | def test_invalid_keys(self, tmpdir): 244 | (tmpdir / "setup.cfg").write_text( 245 | """ 246 | [slotscheck] 247 | k = 9 248 | strict-imports = true 249 | foo = 4 250 | """, 251 | encoding="utf-8", 252 | ) 253 | with pytest.raises( 254 | InvalidKeys, 255 | match=re.escape("Invalid configuration key(s): 'foo', 'k'."), 256 | ): 257 | PartialConfig.load(Path(tmpdir / "setup.cfg")) 258 | 259 | def test_invalid_types(self, tmpdir): 260 | (tmpdir / "setup.cfg").write_text( 261 | """ 262 | [slotscheck] 263 | strict-imports = true 264 | include-modules = false 265 | """, 266 | encoding="utf-8", 267 | ) 268 | with pytest.raises( 269 | InvalidValueType, 270 | match=re.escape("Invalid value type for 'include-modules'."), 271 | ): 272 | PartialConfig.load(Path(tmpdir / "setup.cfg")) 273 | 274 | 275 | def test_config_from_file_invalid_extension(): 276 | with pytest.raises(ValueError, match="extension.*toml"): 277 | PartialConfig.load(Path("foo.py")) 278 | 279 | 280 | @pytest.mark.parametrize( 281 | "string, expect", 282 | [ 283 | ("__main__", True), 284 | ("__main__.bla.foo", True), 285 | ("fz.k.__main__.bla.foo", True), 286 | ("fz.k.__main__", True), 287 | ("Some__main__", False), 288 | ("fr.__main__thing", False), 289 | ], 290 | ) 291 | def test_default_exclude(string, expect): 292 | assert bool(re.search(DEFAULT_MODULE_EXCLUDE_RE, string)) is expect 293 | -------------------------------------------------------------------------------- /src/slotscheck/discovery.py: -------------------------------------------------------------------------------- 1 | "Tools to discover and inspect modules, packages, and classes" 2 | 3 | import importlib 4 | import pkgutil 5 | import sys 6 | from dataclasses import dataclass, field, replace 7 | from functools import partial, reduce 8 | from importlib.util import find_spec 9 | from inspect import isclass 10 | from itertools import chain 11 | from pathlib import Path 12 | from textwrap import indent 13 | from types import ModuleType 14 | from typing import ( 15 | Callable, 16 | Collection, 17 | Dict, 18 | FrozenSet, 19 | Iterable, 20 | Iterator, 21 | NamedTuple, 22 | Optional, 23 | Tuple, 24 | Union, 25 | ) 26 | 27 | from .common import add_slots, flatten, unique 28 | 29 | ModuleName = str 30 | "The full, dotted name of a module" 31 | 32 | ModuleNamePart = str 33 | "Part of a module name -- no dots" 34 | 35 | ModuleTree = Union["Module", "Package"] 36 | 37 | AbsPath = Path 38 | "A resolved filepath. Contains no '..'." 39 | 40 | 41 | def consolidate(trees: Iterable[ModuleTree]) -> Collection[ModuleTree]: 42 | "Deduplicate and merge module trees" 43 | seen: Dict[ModuleNamePart, ModuleTree] = {} 44 | for t in trees: 45 | try: 46 | overlap = seen[t.name] 47 | except KeyError: 48 | seen[t.name] = t 49 | else: 50 | seen[t.name] = overlap.merge(t) 51 | 52 | return seen.values() 53 | 54 | 55 | @add_slots 56 | @dataclass(frozen=True) 57 | class Module: 58 | name: ModuleNamePart 59 | 60 | def __post_init__(self) -> None: 61 | assert "." not in self.name 62 | 63 | def display(self) -> str: 64 | return self.name 65 | 66 | def __iter__(self) -> Iterator[ModuleTree]: 67 | yield self 68 | 69 | def __len__(self) -> int: 70 | return 1 71 | 72 | def filtername( 73 | self, __pred: Callable[[ModuleName], bool], *, prefix: str = "" 74 | ) -> Optional[ModuleTree]: 75 | return self if __pred(prefix + self.name) else None 76 | 77 | def merge(self, other: ModuleTree) -> ModuleTree: 78 | "Merge along shared components. Raises if no shared components." 79 | if other.name == self.name: 80 | return other 81 | raise ValueError("Cannot merge modules without shared components.") 82 | 83 | 84 | @add_slots 85 | @dataclass(frozen=True) 86 | class Package: 87 | name: ModuleNamePart 88 | content: FrozenSet[ModuleTree] 89 | 90 | def __post_init__(self) -> None: 91 | assert "." not in self.name 92 | 93 | def display(self) -> str: 94 | return ( 95 | f"{self.name}" 96 | + ("\n" * bool(self.content)) 97 | + "\n".join( 98 | indent(node.display(), " ") 99 | for node in sorted( 100 | self.content, key=lambda n: (type(n) is Package, n.name) 101 | ) 102 | ) 103 | ) 104 | 105 | def __iter__(self) -> Iterator[ModuleTree]: 106 | yield self 107 | yield from flatten(self.content) 108 | 109 | def __len__(self) -> int: 110 | return 1 + sum(map(len, self.content)) 111 | 112 | def filtername( 113 | self, __pred: Callable[[ModuleName], bool], *, prefix: str = "" 114 | ) -> Optional[ModuleTree]: 115 | if not __pred(prefix + self.name): 116 | return None 117 | 118 | new_prefix = f"{prefix}{self.name}." 119 | return replace( 120 | self, 121 | content=frozenset( 122 | filter( 123 | None, 124 | ( 125 | sub.filtername(__pred, prefix=new_prefix) 126 | for sub in self.content 127 | ), 128 | ) 129 | ), 130 | ) 131 | 132 | def merge(self, other: ModuleTree) -> ModuleTree: 133 | "Merge along shared components. Raises if no shared components." 134 | if self.name != other.name: 135 | raise ValueError("Cannot merge modules without shared components.") 136 | if isinstance(other, Module): 137 | return self 138 | else: 139 | return Package( 140 | self.name, 141 | frozenset(consolidate(chain(self.content, other.content))), 142 | ) 143 | 144 | 145 | @dataclass(frozen=True) 146 | class UnexpectedImportLocation(Exception): 147 | module: ModuleName 148 | expected: AbsPath 149 | actual: Optional[AbsPath] 150 | 151 | 152 | @add_slots 153 | @dataclass(frozen=True) 154 | class FailedImport: 155 | module: ModuleName 156 | exc: BaseException = field(compare=False, hash=False) 157 | 158 | 159 | def module_tree( 160 | module: ModuleName, 161 | expected_location: Optional[AbsPath], 162 | ) -> Union[ModuleTree, FailedImport]: 163 | """May raise ModuleNotFoundError or UnexpectedImportLocation""" 164 | try: 165 | spec = find_spec(module) 166 | except BaseException as e: 167 | return FailedImport(module, e) 168 | if spec is None: 169 | raise ModuleNotFoundError(f"No module named {module!r}", name=module) 170 | *namespaces, name = module.split(".") 171 | location = Path(spec.origin) if spec.has_location and spec.origin else None 172 | tree: ModuleTree 173 | if spec.submodule_search_locations is None: 174 | tree = Module(name) 175 | else: 176 | tree = _package(name, spec.submodule_search_locations) 177 | 178 | if expected_location and location != expected_location: 179 | raise UnexpectedImportLocation(module, expected_location, location) 180 | 181 | return reduce(_add_namespace, reversed(namespaces), tree) 182 | 183 | 184 | def _add_namespace(tree: ModuleTree, name: ModuleNamePart) -> ModuleTree: 185 | return Package(name, frozenset([tree])) 186 | 187 | 188 | def _submodule(m: pkgutil.ModuleInfo) -> ModuleTree: 189 | if m.ispkg: 190 | spec = m.module_finder.find_spec(m.name) # type: ignore[call-arg] 191 | assert spec is not None 192 | assert spec.submodule_search_locations is not None 193 | return _package(m.name, spec.submodule_search_locations) 194 | else: 195 | return Module(m.name) 196 | 197 | 198 | def _is_submodule(m: pkgutil.ModuleInfo, paths: Tuple[str, ...]) -> bool: 199 | return getattr(m.module_finder, "path", "").startswith(paths) 200 | 201 | 202 | def _package(name: ModuleNamePart, paths: Collection[str]) -> Package: 203 | return Package( 204 | name, 205 | frozenset( 206 | map( 207 | _submodule, 208 | filter( 209 | partial(_is_submodule, paths=tuple(paths)), 210 | pkgutil.iter_modules(paths), 211 | ), 212 | ) 213 | ), 214 | ) 215 | 216 | 217 | def walk_classes( 218 | n: ModuleTree, prefix: str = "" 219 | ) -> Iterator[Union[FailedImport, FrozenSet[type]]]: 220 | fullname = prefix + n.name 221 | try: 222 | module = importlib.import_module(fullname) 223 | except BaseException as e: 224 | # Sometimes packages make it impossible to import 225 | # certain modules directly or out of order, or without 226 | # some system or dependency requirement. 227 | # The exceptions can be quite exotic, 228 | # inheriting from BaseException in some cases! 229 | if isinstance(e, KeyboardInterrupt): 230 | raise 231 | yield FailedImport(fullname, e) 232 | else: 233 | yield frozenset(_classes_in_module(module)) 234 | if isinstance(n, Package): 235 | yield from flatten( 236 | map(partial(walk_classes, prefix=fullname + "."), n.content) 237 | ) 238 | 239 | 240 | def _classes_in_module(module: ModuleType) -> Iterable[type]: 241 | return flatten( 242 | map( 243 | _walk_nested_classes, 244 | unique( 245 | filter( 246 | partial(_is_module_class, module=module), 247 | module.__dict__.values(), 248 | ) 249 | ), 250 | ) 251 | ) 252 | 253 | 254 | def _is_module_class(obj: object, module: ModuleType) -> bool: 255 | try: 256 | return isclass(obj) and obj.__module__ == module.__name__ 257 | except Exception: 258 | # Some rare objects crash on introspection. It's best to exclude them. 259 | return False 260 | 261 | 262 | def _walk_nested_classes(c: type) -> Iterable[type]: 263 | yield c 264 | yield from flatten(map(_walk_nested_classes, _nested_classes(c))) 265 | 266 | 267 | def _nested_classes(c: type) -> Iterable[type]: 268 | return unique( 269 | filter(partial(_is_nested_class, parent=c), c.__dict__.values()) 270 | ) 271 | 272 | 273 | def _is_nested_class(obj: object, parent: type) -> bool: 274 | try: 275 | return ( 276 | isclass(obj) 277 | and obj.__module__ == parent.__module__ 278 | and obj.__qualname__.startswith(parent.__name__ + ".") 279 | ) 280 | except Exception: 281 | # Some rare objects crash on introspection. It's best to exclude them. 282 | return False 283 | 284 | 285 | _INIT_PY = "__init__.py" 286 | 287 | 288 | class ModuleLocated(NamedTuple): 289 | name: ModuleName 290 | expected_location: Optional[AbsPath] 291 | 292 | 293 | class FileNotInSysPathError(Exception): 294 | def __init__(self, file: Path) -> None: 295 | super().__init__(f"File {str(file)!r} is not in PYTHONPATH") 296 | self.file = file 297 | 298 | 299 | def _is_module(p: AbsPath) -> bool: 300 | return (p.is_file() and p.suffixes == [".py"]) or _is_package(p) 301 | 302 | 303 | def _is_package(p: AbsPath) -> bool: 304 | return p.is_dir() and (p / _INIT_PY).is_file() 305 | 306 | 307 | def _module_parents( 308 | p: AbsPath, sys_path: FrozenSet[AbsPath] 309 | ) -> Iterable[AbsPath]: 310 | yield p 311 | for pp in p.parents: 312 | if pp in sys_path: 313 | return 314 | yield pp 315 | raise FileNotInSysPathError(p) 316 | 317 | 318 | def _find_modules( 319 | p: AbsPath, sys_path: FrozenSet[AbsPath] 320 | ) -> Iterable[ModuleLocated]: 321 | if p.name == _INIT_PY: 322 | yield from _find_modules(p.parent, sys_path) 323 | elif _is_module(p): 324 | parents = list(_module_parents(p, sys_path)) 325 | yield ModuleLocated( 326 | ".".join(p.stem for p in reversed(parents)), 327 | (p / _INIT_PY if _is_package(p) else p), 328 | ) 329 | elif p.is_dir(): 330 | yield from flatten(_find_modules(cp, sys_path) for cp in p.iterdir()) 331 | 332 | 333 | def find_modules(p: AbsPath) -> Iterable[ModuleLocated]: 334 | "Recursively find modules at given path. Nonexistent Path is ignored" 335 | return _find_modules(p, frozenset(map(Path, sys.path))) 336 | -------------------------------------------------------------------------------- /src/slotscheck/cli.py: -------------------------------------------------------------------------------- 1 | import re 2 | import sys 3 | from abc import ABC, abstractmethod 4 | from collections import Counter 5 | from dataclasses import dataclass 6 | from functools import partial 7 | from itertools import chain, filterfalse, starmap 8 | from operator import attrgetter, itemgetter, methodcaller, not_ 9 | from pathlib import Path 10 | from textwrap import indent 11 | from typing import ( 12 | Collection, 13 | Iterable, 14 | Iterator, 15 | List, 16 | Optional, 17 | Sequence, 18 | Tuple, 19 | ) 20 | 21 | import click 22 | 23 | from . import config 24 | from .checks import ( 25 | has_duplicate_slots, 26 | has_slotless_base, 27 | has_slots, 28 | is_pure_python, 29 | slots, 30 | slots_overlap, 31 | ) 32 | from .common import ( 33 | Predicate, 34 | add_slots, 35 | both, 36 | compose, 37 | flatten, 38 | groupby, 39 | is_protocol, 40 | map_optional, 41 | ) 42 | from .discovery import ( 43 | AbsPath, 44 | FailedImport, 45 | FileNotInSysPathError, 46 | ModuleLocated, 47 | ModuleName, 48 | ModuleTree, 49 | UnexpectedImportLocation, 50 | consolidate, 51 | find_modules, 52 | module_tree, 53 | walk_classes, 54 | ) 55 | 56 | 57 | @click.command("slotscheck") 58 | @click.argument( 59 | "FILES", 60 | type=click.Path(path_type=Path, exists=True, resolve_path=True), 61 | required=False, 62 | nargs=-1, 63 | ) 64 | @click.option( 65 | "-m", 66 | "--module", 67 | help="Check this module. Cannot be combined with FILES argument. " 68 | "Can be repeated multiple times to scan several modules. ", 69 | multiple=True, 70 | ) 71 | @click.option( 72 | "--require-superclass/--no-require-superclass", 73 | help="Report an error when a slots class inherits from " 74 | "a non-slotted class.", 75 | default=None, 76 | show_default="required", 77 | ) 78 | @click.option( 79 | "--require-subclass/--no-require-subclass", 80 | help="Report an error when a non-slotted class inherits from " 81 | "a slotted class. In effect, this option enforces the use of slots " 82 | "wherever possible.", 83 | default=None, 84 | show_default="not required", 85 | ) 86 | @click.option( 87 | "--include-modules", 88 | help="A regular expression that matches modules to include. " 89 | "Exclusions are determined first, then inclusions. " 90 | "Uses Python's verbose regex dialect, so whitespace is mostly ignored.", 91 | ) 92 | @click.option( 93 | "--exclude-modules", 94 | help="A regular expression that matches modules to exclude. " 95 | "Excluded modules will not be imported. " 96 | "Uses Python's verbose regex dialect, so whitespace is mostly ignored.", 97 | show_default=f"``{config.DEFAULT_MODULE_EXCLUDE_RE}``", 98 | ) 99 | @click.option( 100 | "--include-classes", 101 | help="A regular expression that matches classes to include. " 102 | "Use ``:`` to separate module and class paths. " 103 | "For example: ``app\\.config:.*Settings``, ``:(Foo|Bar)``. " 104 | "Exclusions are determined first, then inclusions. " 105 | "Uses Python's verbose regex dialect, so whitespace is mostly ignored.", 106 | ) 107 | @click.option( 108 | "--exclude-classes", 109 | help="A regular expression that matches classes to exclude. " 110 | "Use ``:`` to separate module and class paths. " 111 | "For example: ``app\\.config:Settings``, ``:.*(Exception|Error)``. " 112 | "Uses Python's verbose regex dialect, so whitespace is mostly ignored.", 113 | show_default="``^$``", 114 | ) 115 | @click.option( 116 | "--strict-imports/--no-strict-imports", 117 | help="Treat failed imports as errors.", 118 | default=None, 119 | show_default="strict", 120 | ) 121 | @click.option( 122 | "-v", "--verbose", is_flag=True, help="Display extra descriptive output." 123 | ) 124 | @click.option( 125 | "--settings", 126 | help="Path to the configuration file to use. " 127 | "Allowed extensions are toml, cfg, ini.", 128 | type=click.Path( 129 | path_type=Path, exists=True, resolve_path=True, dir_okay=False 130 | ), 131 | ) 132 | @click.version_option() 133 | def root( 134 | files: Sequence[AbsPath], 135 | module: Sequence[str], 136 | verbose: bool, 137 | settings: Optional[AbsPath], 138 | require_superclass: Optional[bool], 139 | require_subclass: Optional[bool], 140 | strict_imports: Optional[bool], 141 | exclude_classes: Optional[config.RegexStr], 142 | include_classes: Optional[config.RegexStr], 143 | exclude_modules: Optional[config.RegexStr], 144 | include_modules: Optional[config.RegexStr], 145 | ) -> None: 146 | "Check whether your __slots__ are working properly." 147 | conf = config.collect( 148 | config.PartialConfig( 149 | strict_imports=strict_imports, 150 | require_superclass=require_superclass, 151 | require_subclass=require_subclass, 152 | exclude_classes=exclude_classes, 153 | include_classes=include_classes, 154 | exclude_modules=exclude_modules, 155 | include_modules=include_modules, 156 | ), 157 | Path.cwd(), 158 | settings, 159 | ) 160 | if not (files or module): 161 | print("No files or modules given. Nothing to do!") 162 | exit(0) 163 | 164 | try: 165 | classes, modules = _collect(files, module, conf) 166 | except (ModuleNotFoundError, FileNotInSysPathError) as e: 167 | print( 168 | f"ERROR: {e}.\n\n" 169 | "See slotscheck.rtfd.io/en/latest/discovery.html\n" 170 | "for help resolving common import problems." 171 | ) 172 | exit(1) 173 | except UnexpectedImportLocation as e: 174 | print( 175 | """\ 176 | Cannot check due to import ambiguity. 177 | The given files do not correspond with what would be imported: 178 | 179 | 'import {0.module}' would load from: 180 | {0.actual} 181 | instead of: 182 | {0.expected} 183 | 184 | You may need to define $PYTHONPATH or run as 'python -m slotscheck' 185 | to ensure the correct files can be imported. 186 | 187 | See slotscheck.rtfd.io/en/latest/discovery.html 188 | for more information on why this happens and how to resolve it.""".format( 189 | e 190 | ) 191 | ) 192 | exit(1) 193 | 194 | if not (modules.filtered or modules.skipped): 195 | print( 196 | "Files or modules given, but filtered out by exclude/include. " 197 | "Nothing to do!" 198 | ) 199 | exit(0) 200 | messages = list( 201 | chain( 202 | map( 203 | partial(Message, error=conf.strict_imports), 204 | sorted(modules.skipped, key=attrgetter("failure.module")), 205 | ), 206 | _check_classes( 207 | classes, 208 | conf.require_superclass, 209 | conf.include_classes, 210 | conf.exclude_classes, 211 | conf.require_subclass, 212 | ), 213 | ) 214 | ) 215 | for msg in messages: 216 | print(msg.for_display(verbose)) 217 | 218 | any_errors = any(m.error for m in messages) 219 | 220 | if any_errors: 221 | print("Oh no, found some problems!") 222 | else: 223 | print("All OK!") 224 | 225 | if verbose: 226 | _print_report( 227 | modules, 228 | classes, 229 | ) 230 | else: 231 | print( 232 | f"Scanned {sum(map(len, modules.filtered))} module(s), " 233 | f"{len(classes)} class(es)." 234 | ) 235 | 236 | if any_errors: 237 | exit(1) 238 | 239 | 240 | class Notice(ABC): 241 | "Base class for notices to be displayed" 242 | __slots__ = () 243 | 244 | @abstractmethod 245 | def for_display(self, verbose: bool) -> str: 246 | raise NotImplementedError() 247 | 248 | 249 | @add_slots 250 | @dataclass(frozen=True) 251 | class ModuleSkipped(Notice): 252 | "Notice that a module has been skipped due to an import failure." 253 | failure: FailedImport 254 | 255 | def for_display(self, verbose: bool) -> str: 256 | return ( 257 | f"Failed to import '{self.failure.module}'." 258 | + verbose * f"\nDue to {self.failure.exc!r}" 259 | ) 260 | 261 | 262 | @add_slots 263 | @dataclass(frozen=True) 264 | class OverlappingSlots(Notice): 265 | "Notice that slots on a class overlap with its superclass(es)." 266 | cls: type 267 | 268 | def for_display(self, verbose: bool) -> str: 269 | return ( 270 | f"'{_class_fullname(self.cls)}' defines overlapping slots." 271 | + verbose 272 | * ( 273 | "\nSlots already defined in superclass:\n" 274 | + _bulletlist( 275 | f"'{name}' ({_class_fullname(base)})" 276 | for name, base in sorted( 277 | _overlapping_slots(self.cls), key=itemgetter(0) 278 | ) 279 | ) 280 | ) 281 | ) 282 | 283 | 284 | def _overlapping_slots(c: type) -> Iterable[Tuple[str, type]]: 285 | maybe_slots = slots(c) 286 | assert maybe_slots is not None 287 | slots_ = set(maybe_slots) 288 | for base in c.mro()[1:]: 289 | for overlap in slots_.intersection(slots(base) or ()): 290 | yield (overlap, base) 291 | 292 | 293 | @add_slots 294 | @dataclass(frozen=True) 295 | class DuplicateSlots(Notice): 296 | "Notice that a class defines duplicate slots." 297 | cls: type 298 | 299 | def for_display(self, verbose: bool) -> str: 300 | return f"'{_class_fullname(self.cls)}' has duplicate slots." + ( 301 | verbose 302 | * ( 303 | "\nDuplicate slot names:\n" 304 | + _bulletlist(map("'{}'".format, _duplicate_slots(self.cls))) 305 | ) 306 | ) 307 | 308 | 309 | def _duplicate_slots(c: type) -> Iterable[str]: 310 | return ( 311 | slot for slot, count in Counter(slots(c) or ()).items() if count > 1 312 | ) 313 | 314 | 315 | @add_slots 316 | @dataclass(frozen=True) 317 | class BadSlotInheritance(Notice): 318 | "Notice that a class has slots, but some of its superclasses do not." 319 | cls: type 320 | 321 | def for_display(self, verbose: bool) -> str: 322 | return ( 323 | f"'{_class_fullname(self.cls)}' has slots " 324 | "but superclass does not." 325 | + verbose 326 | * ( 327 | "\nSuperclasses without slots:\n" 328 | + _bulletlist( 329 | map( 330 | compose( 331 | "'{}'".format, 332 | _class_fullname, 333 | ), 334 | _slotless_superclasses(self.cls), 335 | ) 336 | ) 337 | ) 338 | ) 339 | 340 | 341 | @add_slots 342 | @dataclass(frozen=True) 343 | class ShouldHaveSlots(Notice): 344 | "Notice that a class should have slots, but doesn't." 345 | cls: type 346 | 347 | def for_display(self, verbose: bool) -> str: 348 | return ( 349 | f"'{_class_fullname(self.cls)}' has no slots, but it could have." 350 | + verbose 351 | * "\nThis is flagged because the strict `require-subclass` " 352 | "setting is active." 353 | ) 354 | 355 | 356 | @add_slots 357 | @dataclass(frozen=True) 358 | class Message: 359 | "A notice with error level." 360 | notice: Notice 361 | error: bool 362 | 363 | def for_display(self, verbose: bool) -> str: 364 | return (_format_error if self.error else _format_note)( 365 | self.notice.for_display(verbose) 366 | ) 367 | 368 | 369 | @add_slots 370 | @dataclass(frozen=True) 371 | class ModulesReport: 372 | "Report with data on modules excluded or skipped." 373 | all: Collection[ModuleTree] 374 | filtered: Collection[ModuleTree] 375 | skipped: Collection[ModuleSkipped] 376 | 377 | 378 | def _collect( 379 | files: Collection[AbsPath], 380 | modules: Collection[ModuleName], 381 | conf: config.Config, 382 | ) -> Tuple[Collection[type], ModulesReport]: 383 | modulefilter = _create_filter(conf.include_modules, conf.exclude_modules) 384 | modules_inspected = list( 385 | starmap( 386 | module_tree, 387 | filter( 388 | compose(modulefilter, attrgetter("name")), 389 | _as_modules(files, modules), 390 | ), 391 | ) 392 | ) 393 | modules_all = consolidate( 394 | m for m in modules_inspected if not isinstance(m, FailedImport) 395 | ) 396 | modules_filtered: Collection[ModuleTree] = list( 397 | map_optional(methodcaller("filtername", modulefilter), modules_all) 398 | ) 399 | classes, modules_skipped = _collect_classes(modules_filtered) 400 | return classes, ModulesReport( 401 | modules_all, 402 | modules_filtered, 403 | list( 404 | chain( 405 | ( 406 | ModuleSkipped(m) 407 | for m in modules_inspected 408 | if isinstance(m, FailedImport) 409 | ), 410 | modules_skipped, 411 | ) 412 | ), 413 | ) 414 | 415 | 416 | def _create_filter(include: Optional[str], exclude: str) -> Predicate[str]: 417 | excluder: Predicate[str] = compose( 418 | not_, re.compile(exclude, flags=re.VERBOSE).search 419 | ) 420 | return ( 421 | excluder 422 | if include is None 423 | else both(excluder, re.compile(include, flags=re.VERBOSE).search) 424 | ) 425 | 426 | 427 | def _as_modules( 428 | files: Collection[AbsPath], names: Iterable[ModuleName] 429 | ) -> Iterable[ModuleLocated]: 430 | if files and names: 431 | print( 432 | _format_error( 433 | "Specify either FILES argument or `-m/--module` " 434 | "option, not both." 435 | ), 436 | file=sys.stderr, 437 | ) 438 | exit(2) 439 | elif files: 440 | return flatten(map(find_modules, files)) 441 | else: 442 | return map(partial(ModuleLocated, expected_location=None), names) 443 | 444 | 445 | def _collect_classes( 446 | trees: Collection[ModuleTree], 447 | ) -> Tuple[Collection[type], Collection[ModuleSkipped]]: 448 | classes: List[type] = [] 449 | skipped: List[ModuleSkipped] = [] 450 | for result in flatten(map(walk_classes, trees)): 451 | if isinstance(result, FailedImport): 452 | skipped.append(ModuleSkipped(result)) 453 | else: 454 | classes.extend(result) 455 | return classes, skipped 456 | 457 | 458 | def _print_report( 459 | modules: ModulesReport, 460 | classes: Collection[type], 461 | ) -> None: 462 | classes_by_status = groupby( 463 | classes, 464 | key=lambda c: ( 465 | None if not is_pure_python(c) else True if has_slots(c) else False 466 | ), 467 | ) 468 | print( 469 | """\ 470 | stats: 471 | modules: {} 472 | checked: {} 473 | excluded: {} 474 | skipped: {} 475 | 476 | classes: {} 477 | has slots: {} 478 | no slots: {} 479 | n/a: {}""".format( 480 | sum(map(len, modules.all)), 481 | sum(map(len, modules.filtered)), 482 | sum(map(len, modules.all)) - sum(map(len, modules.filtered)), 483 | len(modules.skipped), 484 | len(classes), 485 | len(classes_by_status[True]), 486 | len(classes_by_status[False]), 487 | len(classes_by_status[None]), 488 | ), 489 | file=sys.stderr, 490 | ) 491 | 492 | 493 | def _check_classes( 494 | classes: Iterable[type], 495 | require_superclass: bool, 496 | include: Optional[str], 497 | exclude: Optional[str], 498 | require_subclass: bool, 499 | ) -> Iterator[Message]: 500 | return map( 501 | partial(Message, error=True), 502 | flatten( 503 | map( 504 | partial( 505 | slot_messages, 506 | require_subclass=require_subclass, 507 | require_superclass=require_superclass, 508 | ), 509 | sorted( 510 | _class_includes( 511 | _class_excludes(classes, exclude), 512 | include, 513 | ), 514 | key=_class_fullname, 515 | ), 516 | ) 517 | ), 518 | ) 519 | 520 | 521 | def _class_excludes( 522 | classes: Iterable[type], exclude: Optional[str] 523 | ) -> Iterable[type]: 524 | return ( 525 | filter( 526 | compose( 527 | not_, 528 | re.compile(exclude, flags=re.VERBOSE).search, 529 | _class_fullname, 530 | ), 531 | classes, 532 | ) 533 | if exclude 534 | else classes 535 | ) 536 | 537 | 538 | def _class_includes( 539 | classes: Iterable[type], include: Optional[str] 540 | ) -> Iterable[type]: 541 | return ( 542 | filter( 543 | compose( 544 | re.compile(include, flags=re.VERBOSE).search, 545 | _class_fullname, 546 | ), 547 | classes, 548 | ) 549 | if include 550 | else classes 551 | ) 552 | 553 | 554 | def slot_messages( 555 | c: type, require_superclass: bool, require_subclass: bool 556 | ) -> Iterable[Notice]: 557 | if slots_overlap(c): 558 | yield OverlappingSlots(c) 559 | if has_duplicate_slots(c): 560 | yield DuplicateSlots(c) 561 | if require_superclass and has_slots(c) and has_slotless_base(c): 562 | yield BadSlotInheritance(c) 563 | elif ( 564 | require_subclass 565 | and not has_slots(c) 566 | and not has_slotless_base(c) 567 | and not is_protocol(c) 568 | ): 569 | yield ShouldHaveSlots(c) 570 | 571 | 572 | _ERROR_PREFIX = "ERROR: " 573 | _NOTE_PREFIX = "NOTE: " 574 | 575 | 576 | def _format_error(msg: str) -> str: 577 | return ( 578 | _ERROR_PREFIX 579 | + indent(msg, " " * len(_ERROR_PREFIX))[len(_ERROR_PREFIX) :] # noqa 580 | ) 581 | 582 | 583 | def _format_note(msg: str) -> str: 584 | return ( 585 | _NOTE_PREFIX 586 | + indent(msg, " " * len(_NOTE_PREFIX))[len(_NOTE_PREFIX) :] # noqa 587 | ) 588 | 589 | 590 | def _class_fullname(k: type) -> str: 591 | return f"{k.__module__}:{k.__qualname__}" 592 | 593 | 594 | def _bulletlist(s: Iterable[str]) -> str: 595 | return "\n".join(map("- {}".format, s)) 596 | 597 | 598 | def _slotless_superclasses(c: type) -> Iterable[type]: 599 | return filterfalse(has_slots, c.mro()) 600 | -------------------------------------------------------------------------------- /tests/src/test_discovery.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import FrozenSet, List, TypeVar 3 | from unittest import mock 4 | 5 | import pytest 6 | 7 | from slotscheck.discovery import ( 8 | FailedImport, 9 | FileNotInSysPathError, 10 | Module, 11 | ModuleLocated, 12 | ModuleTree, 13 | Package, 14 | UnexpectedImportLocation, 15 | consolidate, 16 | find_modules, 17 | module_tree, 18 | walk_classes, 19 | ) 20 | 21 | from .conftest import EXAMPLES_DIR 22 | 23 | T = TypeVar("T") 24 | 25 | 26 | def fset(*args: T) -> FrozenSet[T]: 27 | return frozenset(args) 28 | 29 | 30 | def make_pkg(name: str, *content) -> Package: 31 | return Package(name, frozenset(content)) 32 | 33 | 34 | class TestWalkClasses: 35 | def test_module_does_not_exist(self): 36 | [result] = walk_classes(Module("cannot_import")) 37 | assert isinstance(result, FailedImport) 38 | assert result == FailedImport("cannot_import", mock.ANY) 39 | 40 | with pytest.raises(ModuleNotFoundError, match="cannot_import"): 41 | raise result.exc 42 | 43 | def test_module_import_raises_other_error(self): 44 | [_, _, result] = list( 45 | walk_classes( 46 | make_pkg("module_misc", make_pkg("a", Module("evil"))) 47 | ) 48 | ) 49 | assert isinstance(result, FailedImport) 50 | assert result == FailedImport("module_misc.a.evil", mock.ANY) 51 | 52 | with pytest.raises(BaseException, match="Can't import this!"): 53 | raise result.exc 54 | 55 | def test_module_import_raises_keyboardinterrupt(self, mocker): 56 | mocker.patch( 57 | "importlib.import_module", side_effect=KeyboardInterrupt("foo") 58 | ) 59 | with pytest.raises(KeyboardInterrupt, match="foo"): 60 | next(walk_classes(make_pkg("module_misc", Module("a")))) 61 | 62 | def test_single_module(self): 63 | [result] = list(walk_classes(Module("module_singular"))) 64 | 65 | import module_singular # type: ignore 66 | 67 | assert result == fset( 68 | module_singular.A, 69 | module_singular.B, 70 | module_singular.B.C, 71 | module_singular.B.C.D, 72 | module_singular.E, 73 | ) 74 | 75 | def test_package(self): 76 | result = list( 77 | walk_classes( 78 | Package( 79 | "module_misc", 80 | fset( 81 | Module("__main__"), 82 | Module("s"), 83 | Package( 84 | "doesnt_exist", 85 | fset(Module("foo"), Package("g", fset())), 86 | ), 87 | Package( 88 | "a", 89 | fset( 90 | Package( 91 | "evil", 92 | fset( 93 | Module("foo"), 94 | Package("sub", fset(Module("w"))), 95 | ), 96 | ), 97 | Module("z"), 98 | ), 99 | ), 100 | ), 101 | ) 102 | ) 103 | ) 104 | assert len(result) == 7 105 | 106 | import module_misc # type: ignore 107 | 108 | assert set(result) == { 109 | fset( 110 | module_misc.A, 111 | module_misc.B, 112 | ), 113 | fset( 114 | module_misc.a.U, 115 | ), 116 | fset( 117 | module_misc.s.K, 118 | module_misc.s.K.A, 119 | module_misc.s.Y, 120 | ), 121 | fset( 122 | module_misc.__main__.Z, 123 | ), 124 | fset(module_misc.a.z.W, module_misc.a.z.Q), 125 | FailedImport("module_misc.a.evil", mock.ANY), 126 | FailedImport("module_misc.doesnt_exist", mock.ANY), 127 | } 128 | 129 | 130 | class TestModuleTree: 131 | def test_package(self): 132 | tree = module_tree("module_misc", None) 133 | assert isinstance(tree, Package) 134 | assert tree == Package( 135 | "module_misc", 136 | fset( 137 | Module("__main__"), 138 | Module("s"), 139 | Package( 140 | "a", 141 | fset( 142 | Module("z"), 143 | Package( 144 | "evil", 145 | fset( 146 | Module("foo"), 147 | Package("sub", fset(Module("w"))), 148 | ), 149 | ), 150 | Package( 151 | "b", 152 | fset( 153 | Module("__main__"), 154 | Module("c"), 155 | Package( 156 | "mypy", 157 | fset( 158 | Module("bla"), 159 | Package("foo", fset(Module("z"))), 160 | ), 161 | ), 162 | ), 163 | ), 164 | Package( 165 | "pytest", 166 | fset( 167 | Package("a", fset()), 168 | Package("c", fset(Module("foo"))), 169 | ), 170 | ), 171 | ), 172 | ), 173 | ), 174 | ) 175 | 176 | assert len(list(tree)) == len(tree) == 20 177 | 178 | def test_subpackage(self): 179 | tree = module_tree("module_misc.a.b", None) 180 | assert isinstance(tree, Package) 181 | assert tree == make_pkg( 182 | "module_misc", 183 | make_pkg( 184 | "a", 185 | make_pkg( 186 | "b", 187 | Module("__main__"), 188 | Module("c"), 189 | Package( 190 | "mypy", 191 | fset( 192 | Module("bla"), 193 | Package("foo", fset(Module("z"))), 194 | ), 195 | ), 196 | ), 197 | ), 198 | ) 199 | assert len(list(tree)) == len(tree) == 9 200 | assert ( 201 | tree.display() 202 | == """\ 203 | module_misc 204 | a 205 | b 206 | __main__ 207 | c 208 | mypy 209 | bla 210 | foo 211 | z""" 212 | ) 213 | 214 | def test_submodule(self): 215 | tree = module_tree("module_misc.a.b.c", None) 216 | assert tree == make_pkg( 217 | "module_misc", 218 | make_pkg("a", make_pkg("b", Module("c"))), 219 | ) 220 | 221 | def test_namespaced(self): 222 | assert module_tree("namespaced.module", None) == make_pkg( 223 | "namespaced", make_pkg("module", Module("foo"), Module("bla")) 224 | ) 225 | 226 | def test_implicitly_namespaced_submodule(self): 227 | assert module_tree("implicitly_namespaced.module", None) == make_pkg( 228 | "implicitly_namespaced", 229 | make_pkg("module", Module("foo"), Module("bla")), 230 | ) 231 | 232 | def test_implicitly_namespaced(self): 233 | assert module_tree("implicitly_namespaced", None) == make_pkg( 234 | "implicitly_namespaced", 235 | Module("bar"), 236 | Module("version"), 237 | make_pkg("module", Module("foo"), Module("bla")), 238 | make_pkg("another", Module("foo")), 239 | ) 240 | 241 | @pytest.mark.parametrize("module", ["_ast", "builtins", "gc", "sys"]) 242 | def test_builtin(self, module: str): 243 | assert module_tree(module, None) == Module(module) 244 | 245 | def test_extension(self): 246 | assert module_tree("ujson", None) == Module("ujson") 247 | 248 | def test_import_causes_base_exception_no_strict_imports(self, mocker): 249 | assert module_tree( 250 | "module_misc.a.evil.foo", 251 | None, 252 | ) == FailedImport("module_misc.a.evil.foo", mocker.ANY) 253 | 254 | def test_import_error(self, mocker): 255 | assert module_tree( 256 | "broken.submodule", expected_location=None 257 | ) == FailedImport("broken.submodule", mocker.ANY) 258 | 259 | def test_extension_package(self): 260 | tree = module_tree("mypyc", None) 261 | assert isinstance(tree, Package) 262 | assert len(tree.content) > 10 263 | 264 | def test_module(self): 265 | assert module_tree( 266 | "module_singular", 267 | expected_location=EXAMPLES_DIR / "module_singular.py", 268 | ) == Module("module_singular") 269 | 270 | def test_does_not_exist(self): 271 | with pytest.raises(ModuleNotFoundError): 272 | module_tree("doesnt_exist", None) 273 | 274 | def test_unexpected_location(self): 275 | with pytest.raises(UnexpectedImportLocation) as exc: 276 | module_tree( 277 | "module_misc.a.b.c", 278 | expected_location=EXAMPLES_DIR / "other/module_misc/a/b/c.py", 279 | ) 280 | 281 | assert exc.value == UnexpectedImportLocation( 282 | "module_misc.a.b.c", 283 | EXAMPLES_DIR / "other/module_misc/a/b/c.py", 284 | EXAMPLES_DIR / "module_misc/a/b/c.py", 285 | ) 286 | 287 | def test_shadowed_by_builtin(self): 288 | with pytest.raises(UnexpectedImportLocation) as exc: 289 | module_tree("gc", expected_location=EXAMPLES_DIR / "gc.py") 290 | 291 | assert exc.value == UnexpectedImportLocation( 292 | "gc", EXAMPLES_DIR / "gc.py", None 293 | ) 294 | 295 | def test_pyc_file(self): 296 | assert module_tree("compiled", None) == make_pkg( 297 | "compiled", Module("foo"), Module("bar") 298 | ) 299 | 300 | 301 | class TestFilterName: 302 | def test_module(self): 303 | assert Module("foo").filtername(lambda _: True) == Module("foo") 304 | assert Module("foo").filtername(lambda _: False) is None 305 | assert Module("foo").filtername(lambda s: s.startswith("f")) == Module( 306 | "foo" 307 | ) 308 | assert Module("foo").filtername(lambda s: s.startswith("b")) is None 309 | 310 | def test_package(self): 311 | package = Package( 312 | "a", 313 | fset( 314 | Module("a"), 315 | Module("b"), 316 | Package( 317 | "c", 318 | fset(Module("a"), Package("b", fset(Module("a")))), 319 | ), 320 | Package( 321 | "d", 322 | fset( 323 | Package( 324 | "a", 325 | fset( 326 | Module("a"), 327 | Package( 328 | "b", fset(Package("a", fset(Module("x")))) 329 | ), 330 | ), 331 | ), 332 | Module("z"), 333 | Module("b"), 334 | ), 335 | ), 336 | ), 337 | ) 338 | result = package.filtername(lambda x: "b.a" not in x) 339 | 340 | assert result == Package( 341 | "a", 342 | fset( 343 | Module("a"), 344 | Module("b"), 345 | Package( 346 | "c", 347 | fset(Module("a"), Package("b", fset())), 348 | ), 349 | Package( 350 | "d", 351 | fset( 352 | Package( 353 | "a", 354 | fset(Module("a"), Package("b", fset())), 355 | ), 356 | Module("z"), 357 | Module("b"), 358 | ), 359 | ), 360 | ), 361 | ) 362 | 363 | assert package.filtername(lambda _: False) is None 364 | 365 | 366 | class TestFindModules: 367 | def test_given_directory_without_python(self): 368 | assert list(find_modules(EXAMPLES_DIR / "files/another")) == [] 369 | 370 | def test_given_nonpython_file(self): 371 | assert list(find_modules(EXAMPLES_DIR / "files/foo")) == [] 372 | 373 | def test_given_python_file(self): 374 | location = EXAMPLES_DIR / "files/subdir/myfile.py" 375 | result = list(find_modules(location)) 376 | assert result == [ModuleLocated("files.subdir.myfile", location)] 377 | 378 | def test_given_python_root_module(self): 379 | location = EXAMPLES_DIR / "files/subdir/some_module/" 380 | result = list(find_modules(location)) 381 | assert result == [ 382 | ModuleLocated("files.subdir.some_module", location / "__init__.py") 383 | ] 384 | 385 | def test_given_dir_containing_python_files(self): 386 | location = EXAMPLES_DIR / "files/my_scripts/" 387 | result = list(find_modules(location)) 388 | assert len(result) == 4 389 | assert set(result) == { 390 | ModuleLocated("files.my_scripts.bla", location / "bla.py"), 391 | ModuleLocated("files.my_scripts.foo", location / "foo.py"), 392 | ModuleLocated("files.my_scripts.sub.foo", location / "sub/foo.py"), 393 | ModuleLocated( 394 | "files.my_scripts.mymodule", location / "mymodule/__init__.py" 395 | ), 396 | } 397 | 398 | def test_given_file_within_module(self): 399 | location = EXAMPLES_DIR / "files/subdir/some_module/sub/foo.py" 400 | result = list(find_modules(location)) 401 | assert result == [ 402 | ModuleLocated( 403 | "files.subdir.some_module.sub.foo", 404 | EXAMPLES_DIR / "files/subdir/some_module/sub/foo.py", 405 | ) 406 | ] 407 | 408 | def test_given_submodule(self): 409 | location = EXAMPLES_DIR / "files/subdir/some_module/sub" 410 | result = list(find_modules(location)) 411 | assert result == [ 412 | ModuleLocated( 413 | "files.subdir.some_module.sub", location / "__init__.py" 414 | ) 415 | ] 416 | 417 | def test_given_init_py(self): 418 | location = EXAMPLES_DIR / "files/subdir/some_module/sub/__init__.py" 419 | result = list(find_modules(location)) 420 | assert result == [ 421 | ModuleLocated("files.subdir.some_module.sub", location) 422 | ] 423 | 424 | def test_given_file_not_in_sys_path(self, tmp_path: Path): 425 | location = tmp_path / "foo.py" 426 | location.touch() 427 | with pytest.raises(FileNotInSysPathError, match=r"foo\.py"): 428 | list(find_modules(location)) 429 | 430 | 431 | class TestConsolidate: 432 | def test_empty(self): 433 | assert list(consolidate(iter([]))) == [] 434 | 435 | @pytest.mark.parametrize( 436 | "tree", 437 | [ 438 | Module("foo"), 439 | Package("foo", fset(Module("bla"), Package("qux", fset()))), 440 | Package("bar", fset(Module("bla"), Package("qux", fset()))), 441 | ], 442 | ) 443 | def test_one_module(self, tree: ModuleTree): 444 | assert list(consolidate(iter([tree]))) == [tree] 445 | 446 | def test_distinct_modules(self): 447 | trees: List[ModuleTree] = [ 448 | Module("foo"), 449 | Package("bar", fset(Module("bla"), Package("qux", fset()))), 450 | Module("bla"), 451 | Package("buzz", fset()), 452 | Module("qux"), 453 | ] 454 | result = list(consolidate(iter(trees))) 455 | assert len(result) == 5 456 | assert set(result) == set(trees) 457 | 458 | def test_module_and_package(self): 459 | trees: List[ModuleTree] = [ 460 | Module("foo"), 461 | Package("foo", fset(Module("foo"), Module("bar"))), 462 | ] 463 | assert list(consolidate(iter(trees))) == [ 464 | Package("foo", fset(Module("foo"), Module("bar"))), 465 | ] 466 | assert list(consolidate(reversed(trees))) == [ 467 | Package("foo", fset(Module("foo"), Module("bar"))), 468 | ] 469 | 470 | 471 | class TestMergeTrees: 472 | def test_no_overlap(self): 473 | with pytest.raises(ValueError, match="shared components"): 474 | Module("foo").merge(Module("bar")) 475 | 476 | with pytest.raises(ValueError, match="shared components"): 477 | Package("foo", fset(Module("bar"))).merge(Package("bar", fset())) 478 | 479 | def test_same_module(self): 480 | assert Module("foo").merge(Module("foo")) == Module("foo") 481 | 482 | def test_package_override_module(self): 483 | assert Package("foo", fset(Module("bar"))).merge( 484 | Module("foo") 485 | ) == Package("foo", fset(Module("bar"))) 486 | assert Module("foo").merge( 487 | Package("foo", fset(Module("bar"))) 488 | ) == Package("foo", fset(Module("bar"))) 489 | 490 | def test_same_packages(self): 491 | package = Package( 492 | "foo", fset(Package("bar", fset(Module("bla"), Module("foo")))) 493 | ) 494 | assert package.merge(package) == package 495 | 496 | @pytest.mark.parametrize( 497 | "a, b, expect", 498 | [ 499 | ( 500 | Package( 501 | "foo", 502 | fset( 503 | Module("foo"), 504 | ), 505 | ), 506 | Package("foo", fset()), 507 | Package( 508 | "foo", 509 | fset( 510 | Module("foo"), 511 | ), 512 | ), 513 | ), 514 | ( 515 | Package( 516 | "a", 517 | fset( 518 | Package( 519 | "b", 520 | fset( 521 | Module("z"), 522 | ), 523 | ), 524 | Module("c"), 525 | ), 526 | ), 527 | Package( 528 | "a", 529 | fset( 530 | Package( 531 | "c", 532 | fset( 533 | Module("y"), 534 | ), 535 | ) 536 | ), 537 | ), 538 | Package( 539 | "a", 540 | fset( 541 | Package( 542 | "b", 543 | fset( 544 | Module("z"), 545 | ), 546 | ), 547 | Package( 548 | "c", 549 | fset( 550 | Module("y"), 551 | ), 552 | ), 553 | ), 554 | ), 555 | ), 556 | ( 557 | Package( 558 | "a", 559 | fset( 560 | Package( 561 | "b", 562 | fset( 563 | Module("z"), 564 | ), 565 | ), 566 | Module("c"), 567 | ), 568 | ), 569 | Package( 570 | "a", 571 | fset( 572 | Package( 573 | "b", 574 | fset( 575 | Module("y"), 576 | ), 577 | ) 578 | ), 579 | ), 580 | Package( 581 | "a", 582 | fset( 583 | Package( 584 | "b", 585 | fset( 586 | Module("z"), 587 | Module("y"), 588 | ), 589 | ), 590 | Module("c"), 591 | ), 592 | ), 593 | ), 594 | ], 595 | ) 596 | def test_different_packages(self, a, b, expect): 597 | assert a.merge(b) == expect 598 | assert b.merge(a) == expect 599 | -------------------------------------------------------------------------------- /tests/src/test_cli.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | from importlib.util import find_spec 4 | from pathlib import Path 5 | 6 | import pytest 7 | from click.testing import CliRunner 8 | 9 | from slotscheck.cli import root as cli 10 | 11 | from .conftest import EXAMPLES_DIR 12 | 13 | 14 | @pytest.fixture() 15 | def runner(): 16 | return CliRunner() 17 | 18 | 19 | @pytest.fixture(autouse=True) 20 | def set_cwd(request): 21 | os.chdir(EXAMPLES_DIR) 22 | yield 23 | os.chdir(request.config.invocation_dir) 24 | 25 | 26 | def test_no_inputs(runner: CliRunner): 27 | result = runner.invoke(cli, []) 28 | assert result.exit_code == 0 29 | assert result.output == "No files or modules given. Nothing to do!\n" 30 | 31 | 32 | def test_module_doesnt_exist(runner: CliRunner): 33 | result = runner.invoke(cli, ["-m", "foo"]) 34 | assert result.exit_code == 1 35 | assert isinstance(result.exception, SystemExit) 36 | assert result.output == ( 37 | "ERROR: No module named 'foo'.\n\n" 38 | "See slotscheck.rtfd.io/en/latest/discovery.html\n" 39 | "for help resolving common import problems.\n" 40 | ) 41 | 42 | 43 | def test_python_file_not_in_sys_path(runner: CliRunner, tmp_path: Path): 44 | file = tmp_path / "foo.py" 45 | file.write_text('print("Hello, world!")', encoding="utf-8") 46 | result = runner.invoke(cli, [str(file)]) 47 | assert result.exit_code == 1 48 | assert isinstance(result.exception, SystemExit) 49 | assert re.fullmatch( 50 | "ERROR: File '.*/foo.py' is not in PYTHONPATH.\n\n" 51 | "See slotscheck.rtfd.io/en/latest/discovery.html\n" 52 | "for help resolving common import problems.\n", 53 | result.output, 54 | ) 55 | 56 | 57 | def test_module_is_uninspectable(runner: CliRunner): 58 | result = runner.invoke(cli, ["-m", "broken.submodule"]) 59 | assert result.exit_code == 1 60 | assert isinstance(result.exception, SystemExit) 61 | assert ( 62 | result.output 63 | == """\ 64 | ERROR: Failed to import 'broken.submodule'. 65 | Oh no, found some problems! 66 | Scanned 0 module(s), 0 class(es). 67 | """ 68 | ) 69 | 70 | 71 | def test_module_is_uninspectable_no_strict_imports(runner: CliRunner): 72 | result = runner.invoke( 73 | cli, ["-m", "broken.submodule", "--no-strict-imports"] 74 | ) 75 | assert result.exit_code == 0 76 | assert ( 77 | result.output 78 | == """\ 79 | NOTE: Failed to import 'broken.submodule'. 80 | All OK! 81 | Scanned 0 module(s), 0 class(es). 82 | """ 83 | ) 84 | 85 | 86 | def test_path_doesnt_exist(runner: CliRunner): 87 | result = runner.invoke(cli, ["doesnt_exist"]) 88 | assert result.exit_code == 2 89 | assert isinstance(result.exception, SystemExit) 90 | assert ( 91 | result.output 92 | == """\ 93 | Usage: slotscheck [OPTIONS] [FILES]... 94 | Try 'slotscheck --help' for help. 95 | 96 | Error: Invalid value for '[FILES]...': Path 'doesnt_exist' does not exist. 97 | """ 98 | ) 99 | 100 | 101 | def test_everything_ok(runner: CliRunner): 102 | result = runner.invoke(cli, ["-m", "module_ok"]) 103 | assert result.exit_code == 0 104 | assert result.output == "All OK!\nScanned 6 module(s), 64 class(es).\n" 105 | 106 | 107 | def test_single_file_module(runner: CliRunner): 108 | result = runner.invoke( 109 | cli, ["-m", "module_singular"], catch_exceptions=False 110 | ) 111 | assert result.exit_code == 0 112 | assert result.output == "All OK!\nScanned 1 module(s), 5 class(es).\n" 113 | 114 | 115 | def test_builtins(runner: CliRunner): 116 | result = runner.invoke(cli, ["-m", "builtins"]) 117 | assert result.exit_code == 0 118 | 119 | 120 | def test_extension(runner: CliRunner): 121 | result = runner.invoke(cli, ["-m", "ujson"]) 122 | assert result.exit_code == 0 123 | assert result.output == ("All OK!\nScanned 1 module(s), 1 class(es).\n") 124 | 125 | 126 | def test_success_verbose(runner: CliRunner): 127 | result = runner.invoke( 128 | cli, ["-m", "module_ok", "-v"], catch_exceptions=False 129 | ) 130 | assert result.exit_code == 0 131 | assert ( 132 | result.output 133 | == """\ 134 | All OK! 135 | stats: 136 | modules: 7 137 | checked: 6 138 | excluded: 1 139 | skipped: 0 140 | 141 | classes: 64 142 | has slots: 44 143 | no slots: 20 144 | n/a: 0 145 | """ 146 | ) 147 | 148 | 149 | def test_submodule(runner: CliRunner): 150 | result = runner.invoke( 151 | cli, ["-m", "module_ok.a.b"], catch_exceptions=False 152 | ) 153 | assert result.exit_code == 0 154 | assert result.output == "All OK!\nScanned 4 module(s), 32 class(es).\n" 155 | 156 | 157 | def test_namespaced(runner: CliRunner): 158 | result = runner.invoke( 159 | cli, ["-m", "namespaced.module"], catch_exceptions=False 160 | ) 161 | assert result.exit_code == 0 162 | assert result.output == "All OK!\nScanned 4 module(s), 1 class(es).\n" 163 | 164 | 165 | def test_implicitly_namespaced(runner: CliRunner): 166 | result = runner.invoke( 167 | cli, ["-m", "implicitly_namespaced"], catch_exceptions=False 168 | ) 169 | assert result.exit_code == 0 170 | assert result.output == "All OK!\nScanned 8 module(s), 2 class(es).\n" 171 | 172 | 173 | def test_multiple_modules(runner: CliRunner): 174 | result = runner.invoke( 175 | cli, 176 | ["-m", "module_singular", "-m", "module_ok", "-m", "namespaced"], 177 | catch_exceptions=False, 178 | ) 179 | assert result.exit_code == 0 180 | assert result.output == "All OK!\nScanned 11 module(s), 70 class(es).\n" 181 | 182 | 183 | def test_implicitly_namespaced_path(runner: CliRunner): 184 | result = runner.invoke( 185 | cli, 186 | [str(EXAMPLES_DIR / "implicitly_namespaced")], 187 | catch_exceptions=False, 188 | ) 189 | assert result.exit_code == 0 190 | assert result.output == "All OK!\nScanned 7 module(s), 1 class(es).\n" 191 | 192 | 193 | def test_multiple_paths(runner: CliRunner): 194 | result = runner.invoke( 195 | cli, 196 | [ 197 | str(EXAMPLES_DIR / "module_singular.py"), 198 | str(EXAMPLES_DIR / "module_ok/a/b/../b"), 199 | str(EXAMPLES_DIR / "namespaced/module/foo.py"), 200 | ], 201 | catch_exceptions=False, 202 | ) 203 | assert result.exit_code == 0 204 | assert result.output == "All OK!\nScanned 8 module(s), 38 class(es).\n" 205 | 206 | 207 | def test_path_is_module_directory(runner: CliRunner): 208 | # let's define the path indirectly to ensure it works 209 | path = str(EXAMPLES_DIR / "module_ok/a/../") 210 | result = runner.invoke(cli, [path], catch_exceptions=False) 211 | assert result.exit_code == 0 212 | assert result.output == "All OK!\nScanned 6 module(s), 64 class(es).\n" 213 | 214 | 215 | def test_cannot_pass_both_path_and_module(runner: CliRunner): 216 | result = runner.invoke(cli, ["module_ok", "-m", "click"]) 217 | assert result.exit_code == 2 218 | assert ( 219 | result.output 220 | == "ERROR: Specify either FILES argument or `-m/--module` " 221 | "option, not both.\n" 222 | ) 223 | 224 | 225 | def test_errors_with_default_settings(runner: CliRunner): 226 | result = runner.invoke(cli, ["-m", "module_not_ok"]) 227 | assert result.exit_code == 1 228 | assert isinstance(result.exception, SystemExit) 229 | assert ( 230 | result.output 231 | == """\ 232 | ERROR: 'module_not_ok.a.b:U' has slots but superclass does not. 233 | ERROR: 'module_not_ok.foo:S' has slots but superclass does not. 234 | ERROR: 'module_not_ok.foo:T' has slots but superclass does not. 235 | ERROR: 'module_not_ok.foo:U' has slots but superclass does not. 236 | ERROR: 'module_not_ok.foo:U.Ua' defines overlapping slots. 237 | ERROR: 'module_not_ok.foo:U.Ub' defines overlapping slots. 238 | ERROR: 'module_not_ok.foo:W' defines overlapping slots. 239 | ERROR: 'module_not_ok.foo:Z' has duplicate slots. 240 | ERROR: 'module_not_ok.foo:Za' defines overlapping slots. 241 | ERROR: 'module_not_ok.foo:Zb' has slots but superclass does not. 242 | Oh no, found some problems! 243 | Scanned 4 module(s), 32 class(es). 244 | """ 245 | ) 246 | 247 | 248 | def test_errors_require_slots_subclass(runner: CliRunner): 249 | result = runner.invoke(cli, ["-m", "module_not_ok", "--require-subclass"]) 250 | assert result.exit_code == 1 251 | assert isinstance(result.exception, SystemExit) 252 | assert ( 253 | result.output 254 | == """\ 255 | ERROR: 'module_not_ok.a.b:A' has no slots, but it could have. 256 | ERROR: 'module_not_ok.a.b:U' has slots but superclass does not. 257 | ERROR: 'module_not_ok.foo:A' has no slots, but it could have. 258 | ERROR: 'module_not_ok.foo:C' has no slots, but it could have. 259 | ERROR: 'module_not_ok.foo:R' has no slots, but it could have. 260 | ERROR: 'module_not_ok.foo:S' has slots but superclass does not. 261 | ERROR: 'module_not_ok.foo:T' has slots but superclass does not. 262 | ERROR: 'module_not_ok.foo:U' has slots but superclass does not. 263 | ERROR: 'module_not_ok.foo:U.Ua' defines overlapping slots. 264 | ERROR: 'module_not_ok.foo:U.Ub' defines overlapping slots. 265 | ERROR: 'module_not_ok.foo:W' defines overlapping slots. 266 | ERROR: 'module_not_ok.foo:Z' has duplicate slots. 267 | ERROR: 'module_not_ok.foo:Za' defines overlapping slots. 268 | ERROR: 'module_not_ok.foo:Zb' has slots but superclass does not. 269 | Oh no, found some problems! 270 | Scanned 4 module(s), 32 class(es). 271 | """ 272 | ) 273 | 274 | 275 | def test_errors_disallow_nonslot_inherit(runner: CliRunner): 276 | result = runner.invoke( 277 | cli, ["-m", "module_not_ok", "--require-superclass"] 278 | ) 279 | assert result.exit_code == 1 280 | assert isinstance(result.exception, SystemExit) 281 | assert ( 282 | result.output 283 | == """\ 284 | ERROR: 'module_not_ok.a.b:U' has slots but superclass does not. 285 | ERROR: 'module_not_ok.foo:S' has slots but superclass does not. 286 | ERROR: 'module_not_ok.foo:T' has slots but superclass does not. 287 | ERROR: 'module_not_ok.foo:U' has slots but superclass does not. 288 | ERROR: 'module_not_ok.foo:U.Ua' defines overlapping slots. 289 | ERROR: 'module_not_ok.foo:U.Ub' defines overlapping slots. 290 | ERROR: 'module_not_ok.foo:W' defines overlapping slots. 291 | ERROR: 'module_not_ok.foo:Z' has duplicate slots. 292 | ERROR: 'module_not_ok.foo:Za' defines overlapping slots. 293 | ERROR: 'module_not_ok.foo:Zb' has slots but superclass does not. 294 | Oh no, found some problems! 295 | Scanned 4 module(s), 32 class(es). 296 | """ 297 | ) 298 | 299 | 300 | def test_errors_no_require_superclass(runner: CliRunner): 301 | result = runner.invoke( 302 | cli, ["-m", "module_not_ok", "--no-require-superclass"] 303 | ) 304 | assert result.exit_code == 1 305 | assert isinstance(result.exception, SystemExit) 306 | assert ( 307 | result.output 308 | == """\ 309 | ERROR: 'module_not_ok.foo:U.Ua' defines overlapping slots. 310 | ERROR: 'module_not_ok.foo:U.Ub' defines overlapping slots. 311 | ERROR: 'module_not_ok.foo:W' defines overlapping slots. 312 | ERROR: 'module_not_ok.foo:Z' has duplicate slots. 313 | ERROR: 'module_not_ok.foo:Za' defines overlapping slots. 314 | Oh no, found some problems! 315 | Scanned 4 module(s), 32 class(es). 316 | """ 317 | ) 318 | 319 | 320 | def test_errors_with_exclude_classes(runner: CliRunner): 321 | result = runner.invoke( 322 | cli, 323 | ["-m", "module_not_ok", "--exclude-classes", "(foo:U$|:(W|S))"], 324 | ) 325 | assert result.exit_code == 1 326 | assert isinstance(result.exception, SystemExit) 327 | assert ( 328 | result.output 329 | == """\ 330 | ERROR: 'module_not_ok.a.b:U' has slots but superclass does not. 331 | ERROR: 'module_not_ok.foo:T' has slots but superclass does not. 332 | ERROR: 'module_not_ok.foo:U.Ua' defines overlapping slots. 333 | ERROR: 'module_not_ok.foo:U.Ub' defines overlapping slots. 334 | ERROR: 'module_not_ok.foo:Z' has duplicate slots. 335 | ERROR: 'module_not_ok.foo:Za' defines overlapping slots. 336 | ERROR: 'module_not_ok.foo:Zb' has slots but superclass does not. 337 | Oh no, found some problems! 338 | Scanned 4 module(s), 32 class(es). 339 | """ 340 | ) 341 | 342 | 343 | def test_errors_with_include_classes(runner: CliRunner): 344 | result = runner.invoke( 345 | cli, 346 | ["-m", "module_not_ok", "--include-classes", "(foo:.*a|:(W|S))"], 347 | ) 348 | assert result.exit_code == 1 349 | assert isinstance(result.exception, SystemExit) 350 | assert ( 351 | result.output 352 | == """\ 353 | ERROR: 'module_not_ok.foo:S' has slots but superclass does not. 354 | ERROR: 'module_not_ok.foo:U.Ua' defines overlapping slots. 355 | ERROR: 'module_not_ok.foo:W' defines overlapping slots. 356 | ERROR: 'module_not_ok.foo:Za' defines overlapping slots. 357 | Oh no, found some problems! 358 | Scanned 4 module(s), 32 class(es). 359 | """ 360 | ) 361 | 362 | 363 | def test_errors_with_include_modules(runner: CliRunner): 364 | result = runner.invoke( 365 | cli, 366 | [ 367 | "-m", 368 | "module_not_ok", 369 | "--include-modules", 370 | "(module_not_ok$ | a)", 371 | ], 372 | ) 373 | assert result.exit_code == 1 374 | assert isinstance(result.exception, SystemExit) 375 | assert ( 376 | result.output 377 | == """\ 378 | ERROR: 'module_not_ok.a.b:U' has slots but superclass does not. 379 | Oh no, found some problems! 380 | Scanned 3 module(s), 2 class(es). 381 | """ 382 | ) 383 | 384 | 385 | def test_ignores_given_module_completely(runner: CliRunner): 386 | result = runner.invoke( 387 | cli, 388 | [ 389 | "-m", 390 | "module_not_ok", 391 | "--include-modules", 392 | "nomatch", 393 | ], 394 | ) 395 | assert result.exit_code == 0 396 | assert ( 397 | result.output 398 | == "Files or modules given, but filtered out by exclude/include. " 399 | "Nothing to do!\n" 400 | ) 401 | 402 | 403 | def test_module_not_ok_verbose(runner: CliRunner): 404 | result = runner.invoke(cli, ["-m", "module_not_ok", "-v"]) 405 | assert result.exit_code == 1 406 | assert isinstance(result.exception, SystemExit) 407 | assert ( 408 | result.output 409 | == """\ 410 | ERROR: 'module_not_ok.a.b:U' has slots but superclass does not. 411 | Superclasses without slots: 412 | - 'module_not_ok.a.b:A' 413 | ERROR: 'module_not_ok.foo:S' has slots but superclass does not. 414 | Superclasses without slots: 415 | - 'module_not_ok.foo:R' 416 | ERROR: 'module_not_ok.foo:T' has slots but superclass does not. 417 | Superclasses without slots: 418 | - 'module_not_ok.foo:A' 419 | ERROR: 'module_not_ok.foo:U' has slots but superclass does not. 420 | Superclasses without slots: 421 | - 'module_not_ok.foo:L' 422 | - 'module_not_ok.foo:D' 423 | - 'module_not_ok.foo:C' 424 | ERROR: 'module_not_ok.foo:U.Ua' defines overlapping slots. 425 | Slots already defined in superclass: 426 | - 'w' (module_not_ok.foo:Q) 427 | ERROR: 'module_not_ok.foo:U.Ub' defines overlapping slots. 428 | Slots already defined in superclass: 429 | - 'w' (module_not_ok.foo:U.Ua) 430 | - 'w' (module_not_ok.foo:Q) 431 | ERROR: 'module_not_ok.foo:W' defines overlapping slots. 432 | Slots already defined in superclass: 433 | - 'p' (module_not_ok.foo:U) 434 | - 'v' (module_not_ok.foo:V) 435 | ERROR: 'module_not_ok.foo:Z' has duplicate slots. 436 | Duplicate slot names: 437 | - 'b' 438 | - 'c' 439 | ERROR: 'module_not_ok.foo:Za' defines overlapping slots. 440 | Slots already defined in superclass: 441 | - 'b' (module_not_ok.foo:Z) 442 | - 'c' (module_not_ok.foo:Z) 443 | ERROR: 'module_not_ok.foo:Zb' has slots but superclass does not. 444 | Superclasses without slots: 445 | - 'module_not_ok.foo:MyProto' 446 | Oh no, found some problems! 447 | stats: 448 | modules: 4 449 | checked: 4 450 | excluded: 0 451 | skipped: 0 452 | 453 | classes: 32 454 | has slots: 23 455 | no slots: 9 456 | n/a: 0 457 | """ 458 | ) 459 | 460 | 461 | def test_module_misc(runner: CliRunner): 462 | result = runner.invoke( 463 | cli, 464 | ["-m", "module_misc", "--no-strict-imports"], 465 | catch_exceptions=False, 466 | ) 467 | assert result.exit_code == 0 468 | assert ( 469 | result.output 470 | == """\ 471 | NOTE: Failed to import 'module_misc.a.evil'. 472 | All OK! 473 | Scanned 18 module(s), 8 class(es). 474 | """ 475 | ) 476 | 477 | 478 | def test_module_exclude(runner: CliRunner): 479 | result = runner.invoke( 480 | cli, 481 | [ 482 | "-m", 483 | "module_misc", 484 | "--exclude-modules", 485 | "evil", 486 | "--no-strict-imports", 487 | ], 488 | catch_exceptions=False, 489 | ) 490 | assert result.exit_code == 0 491 | assert ( 492 | result.output 493 | == """\ 494 | NOTE: Failed to import 'module_misc.a.b.__main__'. 495 | All OK! 496 | Scanned 16 module(s), 9 class(es). 497 | """ 498 | ) 499 | 500 | from module_misc import a # type: ignore 501 | 502 | assert not a.evil_was_imported 503 | 504 | 505 | def test_module_disallow_import_failures(runner: CliRunner): 506 | result = runner.invoke(cli, ["-m", "module_misc", "--strict-imports"]) 507 | assert result.exit_code == 1 508 | assert isinstance(result.exception, SystemExit) 509 | assert ( 510 | result.output 511 | == """\ 512 | ERROR: Failed to import 'module_misc.a.evil'. 513 | Oh no, found some problems! 514 | Scanned 18 module(s), 8 class(es). 515 | """ 516 | ) 517 | 518 | 519 | def test_module_allow_import_failures(runner: CliRunner): 520 | result = runner.invoke(cli, ["-m", "module_misc", "--no-strict-imports"]) 521 | assert result.exit_code == 0 522 | assert ( 523 | result.output 524 | == """\ 525 | NOTE: Failed to import 'module_misc.a.evil'. 526 | All OK! 527 | Scanned 18 module(s), 8 class(es). 528 | """ 529 | ) 530 | 531 | 532 | def test_finds_config(runner: CliRunner, mocker, tmpdir): 533 | (tmpdir / "myconf.toml").write_binary( 534 | b""" 535 | [tool.slotscheck] 536 | require-superclass = false 537 | """ 538 | ) 539 | mocker.patch( 540 | "slotscheck.config.find_config_file", 541 | return_value=Path(tmpdir / "myconf.toml"), 542 | ) 543 | result = runner.invoke(cli, ["-m", "module_not_ok"]) 544 | assert result.exit_code == 1 545 | assert isinstance(result.exception, SystemExit) 546 | assert ( 547 | result.output 548 | == """\ 549 | ERROR: 'module_not_ok.foo:U.Ua' defines overlapping slots. 550 | ERROR: 'module_not_ok.foo:U.Ub' defines overlapping slots. 551 | ERROR: 'module_not_ok.foo:W' defines overlapping slots. 552 | ERROR: 'module_not_ok.foo:Z' has duplicate slots. 553 | ERROR: 'module_not_ok.foo:Za' defines overlapping slots. 554 | Oh no, found some problems! 555 | Scanned 4 module(s), 32 class(es). 556 | """ 557 | ) 558 | 559 | 560 | def test_given_config(runner: CliRunner, tmpdir): 561 | my_config = tmpdir / "myconf.toml" 562 | my_config.write_binary( 563 | b""" 564 | [tool.slotscheck] 565 | require-superclass = false 566 | """ 567 | ) 568 | result = runner.invoke( 569 | cli, 570 | ["-m", "module_not_ok", "--settings", str(my_config)], 571 | catch_exceptions=False, 572 | ) 573 | assert result.exit_code == 1 574 | assert isinstance(result.exception, SystemExit) 575 | assert ( 576 | result.output 577 | == """\ 578 | ERROR: 'module_not_ok.foo:U.Ua' defines overlapping slots. 579 | ERROR: 'module_not_ok.foo:U.Ub' defines overlapping slots. 580 | ERROR: 'module_not_ok.foo:W' defines overlapping slots. 581 | ERROR: 'module_not_ok.foo:Z' has duplicate slots. 582 | ERROR: 'module_not_ok.foo:Za' defines overlapping slots. 583 | Oh no, found some problems! 584 | Scanned 4 module(s), 32 class(es). 585 | """ 586 | ) 587 | 588 | 589 | def test_ambiguous_import(runner: CliRunner): 590 | result = runner.invoke( 591 | cli, 592 | [str(EXAMPLES_DIR / "other/module_misc/a/b/c.py")], 593 | catch_exceptions=False, 594 | ) 595 | assert result.exit_code == 1 596 | assert isinstance(result.exception, SystemExit) 597 | assert ( 598 | result.output 599 | == """\ 600 | Cannot check due to import ambiguity. 601 | The given files do not correspond with what would be imported: 602 | 603 | 'import module_misc.a.b.c' would load from: 604 | {} 605 | instead of: 606 | {} 607 | 608 | You may need to define $PYTHONPATH or run as 'python -m slotscheck' 609 | to ensure the correct files can be imported. 610 | 611 | See slotscheck.rtfd.io/en/latest/discovery.html 612 | for more information on why this happens and how to resolve it. 613 | """.format( 614 | find_spec( 615 | "module_misc.a.b.c" 616 | ).loader.path, # type: ignore[union-attr] 617 | EXAMPLES_DIR / "other/module_misc/a/b/c.py", 618 | ) 619 | ) 620 | 621 | 622 | def test_ambiguous_import_excluded(runner: CliRunner): 623 | result = runner.invoke( 624 | cli, 625 | ["other/module_misc/a/b/c.py", "--exclude-modules", "module_misc"], 626 | catch_exceptions=False, 627 | ) 628 | assert result.exit_code == 0 629 | assert ( 630 | result.output 631 | == """\ 632 | Files or modules given, but filtered out by exclude/include. Nothing to do! 633 | """ 634 | ) 635 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 2.2.0 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "black" 5 | version = "24.8.0" 6 | description = "The uncompromising code formatter." 7 | optional = false 8 | python-versions = ">=3.8" 9 | groups = ["dev"] 10 | files = [ 11 | {file = "black-24.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:09cdeb74d494ec023ded657f7092ba518e8cf78fa8386155e4a03fdcc44679e6"}, 12 | {file = "black-24.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:81c6742da39f33b08e791da38410f32e27d632260e599df7245cccee2064afeb"}, 13 | {file = "black-24.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:707a1ca89221bc8a1a64fb5e15ef39cd755633daa672a9db7498d1c19de66a42"}, 14 | {file = "black-24.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:d6417535d99c37cee4091a2f24eb2b6d5ec42b144d50f1f2e436d9fe1916fe1a"}, 15 | {file = "black-24.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fb6e2c0b86bbd43dee042e48059c9ad7830abd5c94b0bc518c0eeec57c3eddc1"}, 16 | {file = "black-24.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:837fd281f1908d0076844bc2b801ad2d369c78c45cf800cad7b61686051041af"}, 17 | {file = "black-24.8.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62e8730977f0b77998029da7971fa896ceefa2c4c4933fcd593fa599ecbf97a4"}, 18 | {file = "black-24.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:72901b4913cbac8972ad911dc4098d5753704d1f3c56e44ae8dce99eecb0e3af"}, 19 | {file = "black-24.8.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:7c046c1d1eeb7aea9335da62472481d3bbf3fd986e093cffd35f4385c94ae368"}, 20 | {file = "black-24.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:649f6d84ccbae73ab767e206772cc2d7a393a001070a4c814a546afd0d423aed"}, 21 | {file = "black-24.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2b59b250fdba5f9a9cd9d0ece6e6d993d91ce877d121d161e4698af3eb9c1018"}, 22 | {file = "black-24.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:6e55d30d44bed36593c3163b9bc63bf58b3b30e4611e4d88a0c3c239930ed5b2"}, 23 | {file = "black-24.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:505289f17ceda596658ae81b61ebbe2d9b25aa78067035184ed0a9d855d18afd"}, 24 | {file = "black-24.8.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b19c9ad992c7883ad84c9b22aaa73562a16b819c1d8db7a1a1a49fb7ec13c7d2"}, 25 | {file = "black-24.8.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f13f7f386f86f8121d76599114bb8c17b69d962137fc70efe56137727c7047e"}, 26 | {file = "black-24.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:f490dbd59680d809ca31efdae20e634f3fae27fba3ce0ba3208333b713bc3920"}, 27 | {file = "black-24.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:eab4dd44ce80dea27dc69db40dab62d4ca96112f87996bca68cd75639aeb2e4c"}, 28 | {file = "black-24.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3c4285573d4897a7610054af5a890bde7c65cb466040c5f0c8b732812d7f0e5e"}, 29 | {file = "black-24.8.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e84e33b37be070ba135176c123ae52a51f82306def9f7d063ee302ecab2cf47"}, 30 | {file = "black-24.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:73bbf84ed136e45d451a260c6b73ed674652f90a2b3211d6a35e78054563a9bb"}, 31 | {file = "black-24.8.0-py3-none-any.whl", hash = "sha256:972085c618ee94f402da1af548a4f218c754ea7e5dc70acb168bfaca4c2542ed"}, 32 | {file = "black-24.8.0.tar.gz", hash = "sha256:2500945420b6784c38b9ee885af039f5e7471ef284ab03fa35ecdde4688cd83f"}, 33 | ] 34 | 35 | [package.dependencies] 36 | click = ">=8.0.0" 37 | mypy-extensions = ">=0.4.3" 38 | packaging = ">=22.0" 39 | pathspec = ">=0.9.0" 40 | platformdirs = ">=2" 41 | tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} 42 | typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} 43 | 44 | [package.extras] 45 | colorama = ["colorama (>=0.4.3)"] 46 | d = ["aiohttp (>=3.7.4) ; sys_platform != \"win32\" or implementation_name != \"pypy\"", "aiohttp (>=3.7.4,!=3.9.0) ; sys_platform == \"win32\" and implementation_name == \"pypy\""] 47 | jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] 48 | uvloop = ["uvloop (>=0.15.2)"] 49 | 50 | [[package]] 51 | name = "click" 52 | version = "8.1.8" 53 | description = "Composable command line interface toolkit" 54 | optional = false 55 | python-versions = ">=3.7" 56 | groups = ["main", "dev"] 57 | files = [ 58 | {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, 59 | {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, 60 | ] 61 | 62 | [package.dependencies] 63 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 64 | 65 | [[package]] 66 | name = "colorama" 67 | version = "0.4.6" 68 | description = "Cross-platform colored terminal text." 69 | optional = false 70 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 71 | groups = ["main", "dev"] 72 | files = [ 73 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 74 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 75 | ] 76 | markers = {main = "platform_system == \"Windows\"", dev = "platform_system == \"Windows\" or sys_platform == \"win32\""} 77 | 78 | [[package]] 79 | name = "coverage" 80 | version = "7.6.0" 81 | description = "Code coverage measurement for Python" 82 | optional = false 83 | python-versions = ">=3.8" 84 | groups = ["dev"] 85 | files = [ 86 | {file = "coverage-7.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dff044f661f59dace805eedb4a7404c573b6ff0cdba4a524141bc63d7be5c7fd"}, 87 | {file = "coverage-7.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a8659fd33ee9e6ca03950cfdcdf271d645cf681609153f218826dd9805ab585c"}, 88 | {file = "coverage-7.6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7792f0ab20df8071d669d929c75c97fecfa6bcab82c10ee4adb91c7a54055463"}, 89 | {file = "coverage-7.6.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d4b3cd1ca7cd73d229487fa5caca9e4bc1f0bca96526b922d61053ea751fe791"}, 90 | {file = "coverage-7.6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7e128f85c0b419907d1f38e616c4f1e9f1d1b37a7949f44df9a73d5da5cd53c"}, 91 | {file = "coverage-7.6.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a94925102c89247530ae1dab7dc02c690942566f22e189cbd53579b0693c0783"}, 92 | {file = "coverage-7.6.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:dcd070b5b585b50e6617e8972f3fbbee786afca71b1936ac06257f7e178f00f6"}, 93 | {file = "coverage-7.6.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d50a252b23b9b4dfeefc1f663c568a221092cbaded20a05a11665d0dbec9b8fb"}, 94 | {file = "coverage-7.6.0-cp310-cp310-win32.whl", hash = "sha256:0e7b27d04131c46e6894f23a4ae186a6a2207209a05df5b6ad4caee6d54a222c"}, 95 | {file = "coverage-7.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:54dece71673b3187c86226c3ca793c5f891f9fc3d8aa183f2e3653da18566169"}, 96 | {file = "coverage-7.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c7b525ab52ce18c57ae232ba6f7010297a87ced82a2383b1afd238849c1ff933"}, 97 | {file = "coverage-7.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bea27c4269234e06f621f3fac3925f56ff34bc14521484b8f66a580aacc2e7d"}, 98 | {file = "coverage-7.6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed8d1d1821ba5fc88d4a4f45387b65de52382fa3ef1f0115a4f7a20cdfab0e94"}, 99 | {file = "coverage-7.6.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01c322ef2bbe15057bc4bf132b525b7e3f7206f071799eb8aa6ad1940bcf5fb1"}, 100 | {file = "coverage-7.6.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03cafe82c1b32b770a29fd6de923625ccac3185a54a5e66606da26d105f37dac"}, 101 | {file = "coverage-7.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0d1b923fc4a40c5832be4f35a5dab0e5ff89cddf83bb4174499e02ea089daf57"}, 102 | {file = "coverage-7.6.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4b03741e70fb811d1a9a1d75355cf391f274ed85847f4b78e35459899f57af4d"}, 103 | {file = "coverage-7.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a73d18625f6a8a1cbb11eadc1d03929f9510f4131879288e3f7922097a429f63"}, 104 | {file = "coverage-7.6.0-cp311-cp311-win32.whl", hash = "sha256:65fa405b837060db569a61ec368b74688f429b32fa47a8929a7a2f9b47183713"}, 105 | {file = "coverage-7.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:6379688fb4cfa921ae349c76eb1a9ab26b65f32b03d46bb0eed841fd4cb6afb1"}, 106 | {file = "coverage-7.6.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f7db0b6ae1f96ae41afe626095149ecd1b212b424626175a6633c2999eaad45b"}, 107 | {file = "coverage-7.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bbdf9a72403110a3bdae77948b8011f644571311c2fb35ee15f0f10a8fc082e8"}, 108 | {file = "coverage-7.6.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc44bf0315268e253bf563f3560e6c004efe38f76db03a1558274a6e04bf5d5"}, 109 | {file = "coverage-7.6.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da8549d17489cd52f85a9829d0e1d91059359b3c54a26f28bec2c5d369524807"}, 110 | {file = "coverage-7.6.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0086cd4fc71b7d485ac93ca4239c8f75732c2ae3ba83f6be1c9be59d9e2c6382"}, 111 | {file = "coverage-7.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1fad32ee9b27350687035cb5fdf9145bc9cf0a094a9577d43e909948ebcfa27b"}, 112 | {file = "coverage-7.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:044a0985a4f25b335882b0966625270a8d9db3d3409ddc49a4eb00b0ef5e8cee"}, 113 | {file = "coverage-7.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:76d5f82213aa78098b9b964ea89de4617e70e0d43e97900c2778a50856dac605"}, 114 | {file = "coverage-7.6.0-cp312-cp312-win32.whl", hash = "sha256:3c59105f8d58ce500f348c5b56163a4113a440dad6daa2294b5052a10db866da"}, 115 | {file = "coverage-7.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:ca5d79cfdae420a1d52bf177de4bc2289c321d6c961ae321503b2ca59c17ae67"}, 116 | {file = "coverage-7.6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d39bd10f0ae453554798b125d2f39884290c480f56e8a02ba7a6ed552005243b"}, 117 | {file = "coverage-7.6.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:beb08e8508e53a568811016e59f3234d29c2583f6b6e28572f0954a6b4f7e03d"}, 118 | {file = "coverage-7.6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2e16f4cd2bc4d88ba30ca2d3bbf2f21f00f382cf4e1ce3b1ddc96c634bc48ca"}, 119 | {file = "coverage-7.6.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6616d1c9bf1e3faea78711ee42a8b972367d82ceae233ec0ac61cc7fec09fa6b"}, 120 | {file = "coverage-7.6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad4567d6c334c46046d1c4c20024de2a1c3abc626817ae21ae3da600f5779b44"}, 121 | {file = "coverage-7.6.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d17c6a415d68cfe1091d3296ba5749d3d8696e42c37fca5d4860c5bf7b729f03"}, 122 | {file = "coverage-7.6.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:9146579352d7b5f6412735d0f203bbd8d00113a680b66565e205bc605ef81bc6"}, 123 | {file = "coverage-7.6.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:cdab02a0a941af190df8782aafc591ef3ad08824f97850b015c8c6a8b3877b0b"}, 124 | {file = "coverage-7.6.0-cp38-cp38-win32.whl", hash = "sha256:df423f351b162a702c053d5dddc0fc0ef9a9e27ea3f449781ace5f906b664428"}, 125 | {file = "coverage-7.6.0-cp38-cp38-win_amd64.whl", hash = "sha256:f2501d60d7497fd55e391f423f965bbe9e650e9ffc3c627d5f0ac516026000b8"}, 126 | {file = "coverage-7.6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7221f9ac9dad9492cecab6f676b3eaf9185141539d5c9689d13fd6b0d7de840c"}, 127 | {file = "coverage-7.6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ddaaa91bfc4477d2871442bbf30a125e8fe6b05da8a0015507bfbf4718228ab2"}, 128 | {file = "coverage-7.6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4cbe651f3904e28f3a55d6f371203049034b4ddbce65a54527a3f189ca3b390"}, 129 | {file = "coverage-7.6.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:831b476d79408ab6ccfadaaf199906c833f02fdb32c9ab907b1d4aa0713cfa3b"}, 130 | {file = "coverage-7.6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46c3d091059ad0b9c59d1034de74a7f36dcfa7f6d3bde782c49deb42438f2450"}, 131 | {file = "coverage-7.6.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4d5fae0a22dc86259dee66f2cc6c1d3e490c4a1214d7daa2a93d07491c5c04b6"}, 132 | {file = "coverage-7.6.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:07ed352205574aad067482e53dd606926afebcb5590653121063fbf4e2175166"}, 133 | {file = "coverage-7.6.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:49c76cdfa13015c4560702574bad67f0e15ca5a2872c6a125f6327ead2b731dd"}, 134 | {file = "coverage-7.6.0-cp39-cp39-win32.whl", hash = "sha256:482855914928c8175735a2a59c8dc5806cf7d8f032e4820d52e845d1f731dca2"}, 135 | {file = "coverage-7.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:543ef9179bc55edfd895154a51792b01c017c87af0ebaae092720152e19e42ca"}, 136 | {file = "coverage-7.6.0-pp38.pp39.pp310-none-any.whl", hash = "sha256:6fe885135c8a479d3e37a7aae61cbd3a0fb2deccb4dda3c25f92a49189f766d6"}, 137 | {file = "coverage-7.6.0.tar.gz", hash = "sha256:289cc803fa1dc901f84701ac10c9ee873619320f2f9aff38794db4a4a0268d51"}, 138 | ] 139 | 140 | [package.dependencies] 141 | tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} 142 | 143 | [package.extras] 144 | toml = ["tomli ; python_full_version <= \"3.11.0a6\""] 145 | 146 | [[package]] 147 | name = "exceptiongroup" 148 | version = "1.2.2" 149 | description = "Backport of PEP 654 (exception groups)" 150 | optional = false 151 | python-versions = ">=3.7" 152 | groups = ["dev"] 153 | markers = "python_version < \"3.11\"" 154 | files = [ 155 | {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, 156 | {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, 157 | ] 158 | 159 | [package.extras] 160 | test = ["pytest (>=6)"] 161 | 162 | [[package]] 163 | name = "flake8" 164 | version = "7.1.2" 165 | description = "the modular source code checker: pep8 pyflakes and co" 166 | optional = false 167 | python-versions = ">=3.8.1" 168 | groups = ["dev"] 169 | files = [ 170 | {file = "flake8-7.1.2-py2.py3-none-any.whl", hash = "sha256:1cbc62e65536f65e6d754dfe6f1bada7f5cf392d6f5db3c2b85892466c3e7c1a"}, 171 | {file = "flake8-7.1.2.tar.gz", hash = "sha256:c586ffd0b41540951ae41af572e6790dbd49fc12b3aa2541685d253d9bd504bd"}, 172 | ] 173 | 174 | [package.dependencies] 175 | mccabe = ">=0.7.0,<0.8.0" 176 | pycodestyle = ">=2.12.0,<2.13.0" 177 | pyflakes = ">=3.2.0,<3.3.0" 178 | 179 | [[package]] 180 | name = "iniconfig" 181 | version = "2.0.0" 182 | description = "brain-dead simple config-ini parsing" 183 | optional = false 184 | python-versions = ">=3.7" 185 | groups = ["dev"] 186 | files = [ 187 | {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, 188 | {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, 189 | ] 190 | 191 | [[package]] 192 | name = "isort" 193 | version = "5.13.2" 194 | description = "A Python utility / library to sort Python imports." 195 | optional = false 196 | python-versions = ">=3.8.0" 197 | groups = ["dev"] 198 | files = [ 199 | {file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"}, 200 | {file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"}, 201 | ] 202 | 203 | [package.extras] 204 | colors = ["colorama (>=0.4.6)"] 205 | 206 | [[package]] 207 | name = "mccabe" 208 | version = "0.7.0" 209 | description = "McCabe checker, plugin for flake8" 210 | optional = false 211 | python-versions = ">=3.6" 212 | groups = ["dev"] 213 | files = [ 214 | {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, 215 | {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, 216 | ] 217 | 218 | [[package]] 219 | name = "mypy" 220 | version = "1.14.1" 221 | description = "Optional static typing for Python" 222 | optional = false 223 | python-versions = ">=3.8" 224 | groups = ["dev"] 225 | files = [ 226 | {file = "mypy-1.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:52686e37cf13d559f668aa398dd7ddf1f92c5d613e4f8cb262be2fb4fedb0fcb"}, 227 | {file = "mypy-1.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1fb545ca340537d4b45d3eecdb3def05e913299ca72c290326be19b3804b39c0"}, 228 | {file = "mypy-1.14.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90716d8b2d1f4cd503309788e51366f07c56635a3309b0f6a32547eaaa36a64d"}, 229 | {file = "mypy-1.14.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ae753f5c9fef278bcf12e1a564351764f2a6da579d4a81347e1d5a15819997b"}, 230 | {file = "mypy-1.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0fe0f5feaafcb04505bcf439e991c6d8f1bf8b15f12b05feeed96e9e7bf1427"}, 231 | {file = "mypy-1.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:7d54bd85b925e501c555a3227f3ec0cfc54ee8b6930bd6141ec872d1c572f81f"}, 232 | {file = "mypy-1.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f995e511de847791c3b11ed90084a7a0aafdc074ab88c5a9711622fe4751138c"}, 233 | {file = "mypy-1.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d64169ec3b8461311f8ce2fd2eb5d33e2d0f2c7b49116259c51d0d96edee48d1"}, 234 | {file = "mypy-1.14.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba24549de7b89b6381b91fbc068d798192b1b5201987070319889e93038967a8"}, 235 | {file = "mypy-1.14.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:183cf0a45457d28ff9d758730cd0210419ac27d4d3f285beda038c9083363b1f"}, 236 | {file = "mypy-1.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f2a0ecc86378f45347f586e4163d1769dd81c5a223d577fe351f26b179e148b1"}, 237 | {file = "mypy-1.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:ad3301ebebec9e8ee7135d8e3109ca76c23752bac1e717bc84cd3836b4bf3eae"}, 238 | {file = "mypy-1.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30ff5ef8519bbc2e18b3b54521ec319513a26f1bba19a7582e7b1f58a6e69f14"}, 239 | {file = "mypy-1.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cb9f255c18052343c70234907e2e532bc7e55a62565d64536dbc7706a20b78b9"}, 240 | {file = "mypy-1.14.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b4e3413e0bddea671012b063e27591b953d653209e7a4fa5e48759cda77ca11"}, 241 | {file = "mypy-1.14.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:553c293b1fbdebb6c3c4030589dab9fafb6dfa768995a453d8a5d3b23784af2e"}, 242 | {file = "mypy-1.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fad79bfe3b65fe6a1efaed97b445c3d37f7be9fdc348bdb2d7cac75579607c89"}, 243 | {file = "mypy-1.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:8fa2220e54d2946e94ab6dbb3ba0a992795bd68b16dc852db33028df2b00191b"}, 244 | {file = "mypy-1.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:92c3ed5afb06c3a8e188cb5da4984cab9ec9a77ba956ee419c68a388b4595255"}, 245 | {file = "mypy-1.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dbec574648b3e25f43d23577309b16534431db4ddc09fda50841f1e34e64ed34"}, 246 | {file = "mypy-1.14.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8c6d94b16d62eb3e947281aa7347d78236688e21081f11de976376cf010eb31a"}, 247 | {file = "mypy-1.14.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4b19b03fdf54f3c5b2fa474c56b4c13c9dbfb9a2db4370ede7ec11a2c5927d9"}, 248 | {file = "mypy-1.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0c911fde686394753fff899c409fd4e16e9b294c24bfd5e1ea4675deae1ac6fd"}, 249 | {file = "mypy-1.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:8b21525cb51671219f5307be85f7e646a153e5acc656e5cebf64bfa076c50107"}, 250 | {file = "mypy-1.14.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7084fb8f1128c76cd9cf68fe5971b37072598e7c31b2f9f95586b65c741a9d31"}, 251 | {file = "mypy-1.14.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8f845a00b4f420f693f870eaee5f3e2692fa84cc8514496114649cfa8fd5e2c6"}, 252 | {file = "mypy-1.14.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:44bf464499f0e3a2d14d58b54674dee25c031703b2ffc35064bd0df2e0fac319"}, 253 | {file = "mypy-1.14.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c99f27732c0b7dc847adb21c9d47ce57eb48fa33a17bc6d7d5c5e9f9e7ae5bac"}, 254 | {file = "mypy-1.14.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:bce23c7377b43602baa0bd22ea3265c49b9ff0b76eb315d6c34721af4cdf1d9b"}, 255 | {file = "mypy-1.14.1-cp38-cp38-win_amd64.whl", hash = "sha256:8edc07eeade7ebc771ff9cf6b211b9a7d93687ff892150cb5692e4f4272b0837"}, 256 | {file = "mypy-1.14.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3888a1816d69f7ab92092f785a462944b3ca16d7c470d564165fe703b0970c35"}, 257 | {file = "mypy-1.14.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:46c756a444117c43ee984bd055db99e498bc613a70bbbc120272bd13ca579fbc"}, 258 | {file = "mypy-1.14.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:27fc248022907e72abfd8e22ab1f10e903915ff69961174784a3900a8cba9ad9"}, 259 | {file = "mypy-1.14.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:499d6a72fb7e5de92218db961f1a66d5f11783f9ae549d214617edab5d4dbdbb"}, 260 | {file = "mypy-1.14.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:57961db9795eb566dc1d1b4e9139ebc4c6b0cb6e7254ecde69d1552bf7613f60"}, 261 | {file = "mypy-1.14.1-cp39-cp39-win_amd64.whl", hash = "sha256:07ba89fdcc9451f2ebb02853deb6aaaa3d2239a236669a63ab3801bbf923ef5c"}, 262 | {file = "mypy-1.14.1-py3-none-any.whl", hash = "sha256:b66a60cc4073aeb8ae00057f9c1f64d49e90f918fbcef9a977eb121da8b8f1d1"}, 263 | {file = "mypy-1.14.1.tar.gz", hash = "sha256:7ec88144fe9b510e8475ec2f5f251992690fcf89ccb4500b214b4226abcd32d6"}, 264 | ] 265 | 266 | [package.dependencies] 267 | mypy_extensions = ">=1.0.0" 268 | setuptools = {version = ">=50", optional = true, markers = "extra == \"mypyc\""} 269 | tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} 270 | typing_extensions = ">=4.6.0" 271 | 272 | [package.extras] 273 | dmypy = ["psutil (>=4.0)"] 274 | faster-cache = ["orjson"] 275 | install-types = ["pip"] 276 | mypyc = ["setuptools (>=50)"] 277 | reports = ["lxml"] 278 | 279 | [[package]] 280 | name = "mypy-extensions" 281 | version = "1.0.0" 282 | description = "Type system extensions for programs checked with the mypy type checker." 283 | optional = false 284 | python-versions = ">=3.5" 285 | groups = ["dev"] 286 | files = [ 287 | {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, 288 | {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, 289 | ] 290 | 291 | [[package]] 292 | name = "packaging" 293 | version = "24.1" 294 | description = "Core utilities for Python packages" 295 | optional = false 296 | python-versions = ">=3.8" 297 | groups = ["dev"] 298 | files = [ 299 | {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, 300 | {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, 301 | ] 302 | 303 | [[package]] 304 | name = "pathspec" 305 | version = "0.12.1" 306 | description = "Utility library for gitignore style pattern matching of file paths." 307 | optional = false 308 | python-versions = ">=3.8" 309 | groups = ["dev"] 310 | files = [ 311 | {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, 312 | {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, 313 | ] 314 | 315 | [[package]] 316 | name = "platformdirs" 317 | version = "4.2.2" 318 | description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." 319 | optional = false 320 | python-versions = ">=3.8" 321 | groups = ["dev"] 322 | files = [ 323 | {file = "platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee"}, 324 | {file = "platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3"}, 325 | ] 326 | 327 | [package.extras] 328 | docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] 329 | test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"] 330 | type = ["mypy (>=1.8)"] 331 | 332 | [[package]] 333 | name = "pluggy" 334 | version = "1.5.0" 335 | description = "plugin and hook calling mechanisms for python" 336 | optional = false 337 | python-versions = ">=3.8" 338 | groups = ["dev"] 339 | files = [ 340 | {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, 341 | {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, 342 | ] 343 | 344 | [package.extras] 345 | dev = ["pre-commit", "tox"] 346 | testing = ["pytest", "pytest-benchmark"] 347 | 348 | [[package]] 349 | name = "pycodestyle" 350 | version = "2.12.0" 351 | description = "Python style guide checker" 352 | optional = false 353 | python-versions = ">=3.8" 354 | groups = ["dev"] 355 | files = [ 356 | {file = "pycodestyle-2.12.0-py2.py3-none-any.whl", hash = "sha256:949a39f6b86c3e1515ba1787c2022131d165a8ad271b11370a8819aa070269e4"}, 357 | {file = "pycodestyle-2.12.0.tar.gz", hash = "sha256:442f950141b4f43df752dd303511ffded3a04c2b6fb7f65980574f0c31e6e79c"}, 358 | ] 359 | 360 | [[package]] 361 | name = "pyflakes" 362 | version = "3.2.0" 363 | description = "passive checker of Python programs" 364 | optional = false 365 | python-versions = ">=3.8" 366 | groups = ["dev"] 367 | files = [ 368 | {file = "pyflakes-3.2.0-py2.py3-none-any.whl", hash = "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a"}, 369 | {file = "pyflakes-3.2.0.tar.gz", hash = "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f"}, 370 | ] 371 | 372 | [[package]] 373 | name = "pytest" 374 | version = "8.3.5" 375 | description = "pytest: simple powerful testing with Python" 376 | optional = false 377 | python-versions = ">=3.8" 378 | groups = ["dev"] 379 | files = [ 380 | {file = "pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820"}, 381 | {file = "pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845"}, 382 | ] 383 | 384 | [package.dependencies] 385 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 386 | exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} 387 | iniconfig = "*" 388 | packaging = "*" 389 | pluggy = ">=1.5,<2" 390 | tomli = {version = ">=1", markers = "python_version < \"3.11\""} 391 | 392 | [package.extras] 393 | dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] 394 | 395 | [[package]] 396 | name = "pytest-cov" 397 | version = "5.0.0" 398 | description = "Pytest plugin for measuring coverage." 399 | optional = false 400 | python-versions = ">=3.8" 401 | groups = ["dev"] 402 | files = [ 403 | {file = "pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857"}, 404 | {file = "pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652"}, 405 | ] 406 | 407 | [package.dependencies] 408 | coverage = {version = ">=5.2.1", extras = ["toml"]} 409 | pytest = ">=4.6" 410 | 411 | [package.extras] 412 | testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] 413 | 414 | [[package]] 415 | name = "pytest-mock" 416 | version = "3.14.1" 417 | description = "Thin-wrapper around the mock package for easier use with pytest" 418 | optional = false 419 | python-versions = ">=3.8" 420 | groups = ["dev"] 421 | files = [ 422 | {file = "pytest_mock-3.14.1-py3-none-any.whl", hash = "sha256:178aefcd11307d874b4cd3100344e7e2d888d9791a6a1d9bfe90fbc1b74fd1d0"}, 423 | {file = "pytest_mock-3.14.1.tar.gz", hash = "sha256:159e9edac4c451ce77a5cdb9fc5d1100708d2dd4ba3c3df572f14097351af80e"}, 424 | ] 425 | 426 | [package.dependencies] 427 | pytest = ">=6.2.5" 428 | 429 | [package.extras] 430 | dev = ["pre-commit", "pytest-asyncio", "tox"] 431 | 432 | [[package]] 433 | name = "setuptools" 434 | version = "71.1.0" 435 | description = "Easily download, build, install, upgrade, and uninstall Python packages" 436 | optional = false 437 | python-versions = ">=3.8" 438 | groups = ["dev"] 439 | files = [ 440 | {file = "setuptools-71.1.0-py3-none-any.whl", hash = "sha256:33874fdc59b3188304b2e7c80d9029097ea31627180896fb549c578ceb8a0855"}, 441 | {file = "setuptools-71.1.0.tar.gz", hash = "sha256:032d42ee9fb536e33087fb66cac5f840eb9391ed05637b3f2a76a7c8fb477936"}, 442 | ] 443 | 444 | [package.extras] 445 | core = ["importlib-metadata (>=6) ; python_version < \"3.10\"", "importlib-resources (>=5.10.2) ; python_version < \"3.9\"", "jaraco.text (>=3.7)", "more-itertools (>=8.8)", "ordered-set (>=3.1.1)", "packaging (>=24)", "platformdirs (>=2.6.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] 446 | doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] 447 | test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "mypy (==1.11.*)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-ruff (<0.4) ; platform_system == \"Windows\"", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "pytest-ruff (>=0.3.2) ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] 448 | 449 | [[package]] 450 | name = "tomli" 451 | version = "2.3.0" 452 | description = "A lil' TOML parser" 453 | optional = false 454 | python-versions = ">=3.8" 455 | groups = ["main", "dev"] 456 | files = [ 457 | {file = "tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45"}, 458 | {file = "tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba"}, 459 | {file = "tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf"}, 460 | {file = "tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441"}, 461 | {file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845"}, 462 | {file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c"}, 463 | {file = "tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456"}, 464 | {file = "tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be"}, 465 | {file = "tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac"}, 466 | {file = "tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22"}, 467 | {file = "tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f"}, 468 | {file = "tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52"}, 469 | {file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8"}, 470 | {file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6"}, 471 | {file = "tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876"}, 472 | {file = "tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878"}, 473 | {file = "tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b"}, 474 | {file = "tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae"}, 475 | {file = "tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b"}, 476 | {file = "tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf"}, 477 | {file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f"}, 478 | {file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05"}, 479 | {file = "tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606"}, 480 | {file = "tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999"}, 481 | {file = "tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e"}, 482 | {file = "tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3"}, 483 | {file = "tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc"}, 484 | {file = "tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0"}, 485 | {file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879"}, 486 | {file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005"}, 487 | {file = "tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463"}, 488 | {file = "tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8"}, 489 | {file = "tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77"}, 490 | {file = "tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf"}, 491 | {file = "tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530"}, 492 | {file = "tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b"}, 493 | {file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67"}, 494 | {file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f"}, 495 | {file = "tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0"}, 496 | {file = "tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba"}, 497 | {file = "tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b"}, 498 | {file = "tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549"}, 499 | ] 500 | markers = {main = "python_version < \"3.11\"", dev = "python_full_version <= \"3.11.0a6\""} 501 | 502 | [[package]] 503 | name = "typing-extensions" 504 | version = "4.13.2" 505 | description = "Backported and Experimental Type Hints for Python 3.8+" 506 | optional = false 507 | python-versions = ">=3.8" 508 | groups = ["dev"] 509 | files = [ 510 | {file = "typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c"}, 511 | {file = "typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef"}, 512 | ] 513 | 514 | [[package]] 515 | name = "ujson" 516 | version = "5.10.0" 517 | description = "Ultra fast JSON encoder and decoder for Python" 518 | optional = false 519 | python-versions = ">=3.8" 520 | groups = ["dev"] 521 | files = [ 522 | {file = "ujson-5.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2601aa9ecdbee1118a1c2065323bda35e2c5a2cf0797ef4522d485f9d3ef65bd"}, 523 | {file = "ujson-5.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:348898dd702fc1c4f1051bc3aacbf894caa0927fe2c53e68679c073375f732cf"}, 524 | {file = "ujson-5.10.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22cffecf73391e8abd65ef5f4e4dd523162a3399d5e84faa6aebbf9583df86d6"}, 525 | {file = "ujson-5.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26b0e2d2366543c1bb4fbd457446f00b0187a2bddf93148ac2da07a53fe51569"}, 526 | {file = "ujson-5.10.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:caf270c6dba1be7a41125cd1e4fc7ba384bf564650beef0df2dd21a00b7f5770"}, 527 | {file = "ujson-5.10.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a245d59f2ffe750446292b0094244df163c3dc96b3ce152a2c837a44e7cda9d1"}, 528 | {file = "ujson-5.10.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:94a87f6e151c5f483d7d54ceef83b45d3a9cca7a9cb453dbdbb3f5a6f64033f5"}, 529 | {file = "ujson-5.10.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:29b443c4c0a113bcbb792c88bea67b675c7ca3ca80c3474784e08bba01c18d51"}, 530 | {file = "ujson-5.10.0-cp310-cp310-win32.whl", hash = "sha256:c18610b9ccd2874950faf474692deee4223a994251bc0a083c114671b64e6518"}, 531 | {file = "ujson-5.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:924f7318c31874d6bb44d9ee1900167ca32aa9b69389b98ecbde34c1698a250f"}, 532 | {file = "ujson-5.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a5b366812c90e69d0f379a53648be10a5db38f9d4ad212b60af00bd4048d0f00"}, 533 | {file = "ujson-5.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:502bf475781e8167f0f9d0e41cd32879d120a524b22358e7f205294224c71126"}, 534 | {file = "ujson-5.10.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b91b5d0d9d283e085e821651184a647699430705b15bf274c7896f23fe9c9d8"}, 535 | {file = "ujson-5.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:129e39af3a6d85b9c26d5577169c21d53821d8cf68e079060602e861c6e5da1b"}, 536 | {file = "ujson-5.10.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f77b74475c462cb8b88680471193064d3e715c7c6074b1c8c412cb526466efe9"}, 537 | {file = "ujson-5.10.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7ec0ca8c415e81aa4123501fee7f761abf4b7f386aad348501a26940beb1860f"}, 538 | {file = "ujson-5.10.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ab13a2a9e0b2865a6c6db9271f4b46af1c7476bfd51af1f64585e919b7c07fd4"}, 539 | {file = "ujson-5.10.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:57aaf98b92d72fc70886b5a0e1a1ca52c2320377360341715dd3933a18e827b1"}, 540 | {file = "ujson-5.10.0-cp311-cp311-win32.whl", hash = "sha256:2987713a490ceb27edff77fb184ed09acdc565db700ee852823c3dc3cffe455f"}, 541 | {file = "ujson-5.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:f00ea7e00447918ee0eff2422c4add4c5752b1b60e88fcb3c067d4a21049a720"}, 542 | {file = "ujson-5.10.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:98ba15d8cbc481ce55695beee9f063189dce91a4b08bc1d03e7f0152cd4bbdd5"}, 543 | {file = "ujson-5.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9d2edbf1556e4f56e50fab7d8ff993dbad7f54bac68eacdd27a8f55f433578e"}, 544 | {file = "ujson-5.10.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6627029ae4f52d0e1a2451768c2c37c0c814ffc04f796eb36244cf16b8e57043"}, 545 | {file = "ujson-5.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8ccb77b3e40b151e20519c6ae6d89bfe3f4c14e8e210d910287f778368bb3d1"}, 546 | {file = "ujson-5.10.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3caf9cd64abfeb11a3b661329085c5e167abbe15256b3b68cb5d914ba7396f3"}, 547 | {file = "ujson-5.10.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6e32abdce572e3a8c3d02c886c704a38a1b015a1fb858004e03d20ca7cecbb21"}, 548 | {file = "ujson-5.10.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a65b6af4d903103ee7b6f4f5b85f1bfd0c90ba4eeac6421aae436c9988aa64a2"}, 549 | {file = "ujson-5.10.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:604a046d966457b6cdcacc5aa2ec5314f0e8c42bae52842c1e6fa02ea4bda42e"}, 550 | {file = "ujson-5.10.0-cp312-cp312-win32.whl", hash = "sha256:6dea1c8b4fc921bf78a8ff00bbd2bfe166345f5536c510671bccececb187c80e"}, 551 | {file = "ujson-5.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:38665e7d8290188b1e0d57d584eb8110951a9591363316dd41cf8686ab1d0abc"}, 552 | {file = "ujson-5.10.0-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:618efd84dc1acbd6bff8eaa736bb6c074bfa8b8a98f55b61c38d4ca2c1f7f287"}, 553 | {file = "ujson-5.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:38d5d36b4aedfe81dfe251f76c0467399d575d1395a1755de391e58985ab1c2e"}, 554 | {file = "ujson-5.10.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67079b1f9fb29ed9a2914acf4ef6c02844b3153913eb735d4bf287ee1db6e557"}, 555 | {file = "ujson-5.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7d0e0ceeb8fe2468c70ec0c37b439dd554e2aa539a8a56365fd761edb418988"}, 556 | {file = "ujson-5.10.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:59e02cd37bc7c44d587a0ba45347cc815fb7a5fe48de16bf05caa5f7d0d2e816"}, 557 | {file = "ujson-5.10.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2a890b706b64e0065f02577bf6d8ca3b66c11a5e81fb75d757233a38c07a1f20"}, 558 | {file = "ujson-5.10.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:621e34b4632c740ecb491efc7f1fcb4f74b48ddb55e65221995e74e2d00bbff0"}, 559 | {file = "ujson-5.10.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b9500e61fce0cfc86168b248104e954fead61f9be213087153d272e817ec7b4f"}, 560 | {file = "ujson-5.10.0-cp313-cp313-win32.whl", hash = "sha256:4c4fc16f11ac1612f05b6f5781b384716719547e142cfd67b65d035bd85af165"}, 561 | {file = "ujson-5.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:4573fd1695932d4f619928fd09d5d03d917274381649ade4328091ceca175539"}, 562 | {file = "ujson-5.10.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a984a3131da7f07563057db1c3020b1350a3e27a8ec46ccbfbf21e5928a43050"}, 563 | {file = "ujson-5.10.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:73814cd1b9db6fc3270e9d8fe3b19f9f89e78ee9d71e8bd6c9a626aeaeaf16bd"}, 564 | {file = "ujson-5.10.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:61e1591ed9376e5eddda202ec229eddc56c612b61ac6ad07f96b91460bb6c2fb"}, 565 | {file = "ujson-5.10.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2c75269f8205b2690db4572a4a36fe47cd1338e4368bc73a7a0e48789e2e35a"}, 566 | {file = "ujson-5.10.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7223f41e5bf1f919cd8d073e35b229295aa8e0f7b5de07ed1c8fddac63a6bc5d"}, 567 | {file = "ujson-5.10.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc2fd6b3067c0782e7002ac3b38cf48608ee6366ff176bbd02cf969c9c20fe"}, 568 | {file = "ujson-5.10.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:232cc85f8ee3c454c115455195a205074a56ff42608fd6b942aa4c378ac14dd7"}, 569 | {file = "ujson-5.10.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:cc6139531f13148055d691e442e4bc6601f6dba1e6d521b1585d4788ab0bfad4"}, 570 | {file = "ujson-5.10.0-cp38-cp38-win32.whl", hash = "sha256:e7ce306a42b6b93ca47ac4a3b96683ca554f6d35dd8adc5acfcd55096c8dfcb8"}, 571 | {file = "ujson-5.10.0-cp38-cp38-win_amd64.whl", hash = "sha256:e82d4bb2138ab05e18f089a83b6564fee28048771eb63cdecf4b9b549de8a2cc"}, 572 | {file = "ujson-5.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:dfef2814c6b3291c3c5f10065f745a1307d86019dbd7ea50e83504950136ed5b"}, 573 | {file = "ujson-5.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4734ee0745d5928d0ba3a213647f1c4a74a2a28edc6d27b2d6d5bd9fa4319e27"}, 574 | {file = "ujson-5.10.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d47ebb01bd865fdea43da56254a3930a413f0c5590372a1241514abae8aa7c76"}, 575 | {file = "ujson-5.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dee5e97c2496874acbf1d3e37b521dd1f307349ed955e62d1d2f05382bc36dd5"}, 576 | {file = "ujson-5.10.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7490655a2272a2d0b072ef16b0b58ee462f4973a8f6bbe64917ce5e0a256f9c0"}, 577 | {file = "ujson-5.10.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ba17799fcddaddf5c1f75a4ba3fd6441f6a4f1e9173f8a786b42450851bd74f1"}, 578 | {file = "ujson-5.10.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2aff2985cef314f21d0fecc56027505804bc78802c0121343874741650a4d3d1"}, 579 | {file = "ujson-5.10.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ad88ac75c432674d05b61184178635d44901eb749786c8eb08c102330e6e8996"}, 580 | {file = "ujson-5.10.0-cp39-cp39-win32.whl", hash = "sha256:2544912a71da4ff8c4f7ab5606f947d7299971bdd25a45e008e467ca638d13c9"}, 581 | {file = "ujson-5.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:3ff201d62b1b177a46f113bb43ad300b424b7847f9c5d38b1b4ad8f75d4a282a"}, 582 | {file = "ujson-5.10.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:5b6fee72fa77dc172a28f21693f64d93166534c263adb3f96c413ccc85ef6e64"}, 583 | {file = "ujson-5.10.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:61d0af13a9af01d9f26d2331ce49bb5ac1fb9c814964018ac8df605b5422dcb3"}, 584 | {file = "ujson-5.10.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ecb24f0bdd899d368b715c9e6664166cf694d1e57be73f17759573a6986dd95a"}, 585 | {file = "ujson-5.10.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fbd8fd427f57a03cff3ad6574b5e299131585d9727c8c366da4624a9069ed746"}, 586 | {file = "ujson-5.10.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:beeaf1c48e32f07d8820c705ff8e645f8afa690cca1544adba4ebfa067efdc88"}, 587 | {file = "ujson-5.10.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:baed37ea46d756aca2955e99525cc02d9181de67f25515c468856c38d52b5f3b"}, 588 | {file = "ujson-5.10.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7663960f08cd5a2bb152f5ee3992e1af7690a64c0e26d31ba7b3ff5b2ee66337"}, 589 | {file = "ujson-5.10.0-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:d8640fb4072d36b08e95a3a380ba65779d356b2fee8696afeb7794cf0902d0a1"}, 590 | {file = "ujson-5.10.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78778a3aa7aafb11e7ddca4e29f46bc5139131037ad628cc10936764282d6753"}, 591 | {file = "ujson-5.10.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0111b27f2d5c820e7f2dbad7d48e3338c824e7ac4d2a12da3dc6061cc39c8e6"}, 592 | {file = "ujson-5.10.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:c66962ca7565605b355a9ed478292da628b8f18c0f2793021ca4425abf8b01e5"}, 593 | {file = "ujson-5.10.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ba43cc34cce49cf2d4bc76401a754a81202d8aa926d0e2b79f0ee258cb15d3a4"}, 594 | {file = "ujson-5.10.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:ac56eb983edce27e7f51d05bc8dd820586c6e6be1c5216a6809b0c668bb312b8"}, 595 | {file = "ujson-5.10.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f44bd4b23a0e723bf8b10628288c2c7c335161d6840013d4d5de20e48551773b"}, 596 | {file = "ujson-5.10.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7c10f4654e5326ec14a46bcdeb2b685d4ada6911050aa8baaf3501e57024b804"}, 597 | {file = "ujson-5.10.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0de4971a89a762398006e844ae394bd46991f7c385d7a6a3b93ba229e6dac17e"}, 598 | {file = "ujson-5.10.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:e1402f0564a97d2a52310ae10a64d25bcef94f8dd643fcf5d310219d915484f7"}, 599 | {file = "ujson-5.10.0.tar.gz", hash = "sha256:b3cd8f3c5d8c7738257f1018880444f7b7d9b66232c64649f562d7ba86ad4bc1"}, 600 | ] 601 | 602 | [metadata] 603 | lock-version = "2.1" 604 | python-versions = ">=3.8.1" 605 | content-hash = "5d67ad533038de587b19263ae9e8cf2353b8069f756fe8a6a0b56d54cfc5ac52" 606 | --------------------------------------------------------------------------------