├── testing ├── __init__.py ├── test_cases │ ├── __init__.py │ ├── object_formatting_test_cases.py │ ├── dummy_arg_suppress_test_cases.py │ ├── annotation_presence_test_cases.py │ ├── mypy_init_return_test_cases.py │ ├── none_return_suppress_test_cases.py │ ├── argument_parsing_test_cases.py │ ├── dynamic_function_test_cases.py │ ├── overload_decorator_test_cases.py │ ├── dispatch_decorator_test_cases.py │ ├── variable_formatting_test_cases.py │ ├── column_line_numbers_test_cases.py │ ├── classifier_object_attributes.py │ └── function_parsing_test_cases.py ├── test_empty_src.py ├── test_flake8_actually_runs_checker.py ├── test_object_formatting.py ├── test_type_ignore.py ├── test_type_comments.py ├── test_fully_annotated.py ├── test_dummy_arg_error_suppression.py ├── test_none_return_error_suppression.py ├── test_mypy_init_return.py ├── test_dispatch_decorator.py ├── test_overload_decorator.py ├── test_variable_formatting.py ├── test_column_line_numbers.py ├── test_flake8_format.py ├── helpers.py ├── test_opinionated_any.py ├── test_dynamic_function_error_suppression.py ├── test_classifier.py └── test_parser.py ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ ├── pypi_release.yml │ └── lint_test.yml ├── flake8_annotations ├── __init__.py ├── enums.py ├── error_codes.py ├── checker.py └── ast_walker.py ├── .flake8 ├── .bumpversion.cfg ├── .gitignore ├── tox.ini ├── .ruff.toml ├── LICENSE ├── .pre-commit-config.yaml ├── CONTRIBUTING.md ├── pyproject.toml ├── CHANGELOG.md └── README.md /testing/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testing/test_cases/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: sco1 2 | -------------------------------------------------------------------------------- /flake8_annotations/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "3.2.0" 2 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | extend-ignore= 3 | E,F,W, 4 | # flake8-annotations 5 | ANN002,ANN003,ANN101,ANN102,ANN204,ANN206, 6 | extend-exclude= 7 | .venv, error_codes.py 8 | -------------------------------------------------------------------------------- /testing/test_empty_src.py: -------------------------------------------------------------------------------- 1 | from testing.helpers import check_source 2 | 3 | 4 | def test_empty_source() -> None: 5 | errs = check_source("") 6 | assert len(list(errs)) == 0 7 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project! 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Description** 11 | Please provide a clear & concise description of the proposed feature. 12 | 13 | **Rationale/Use Case** 14 | If possible, try to include some example code & a description of how you'd expect the linter to behave. 15 | -------------------------------------------------------------------------------- /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 3.2.0 3 | commit = False 4 | 5 | [bumpversion:file:README.md] 6 | search = flake8-annotations/{current_version} 7 | replace = flake8-annotations/{new_version} 8 | 9 | [bumpversion:file:pyproject.toml] 10 | search = version = "{current_version}" 11 | replace = version = "{new_version}" 12 | 13 | [bumpversion:file:flake8_annotations/__init__.py] 14 | search = __version__ = "{current_version}" 15 | replace = __version__ = "{new_version}" 16 | -------------------------------------------------------------------------------- /testing/test_flake8_actually_runs_checker.py: -------------------------------------------------------------------------------- 1 | from subprocess import PIPE, run 2 | 3 | 4 | def test_checker_runs() -> None: 5 | """Test that the checker is properly registered by Flake8 as needing to run on the input src.""" 6 | substr = "ANN001 Missing type annotation for function argument 'x'" 7 | p = run( 8 | ["flake8", "--select=ANN", "-"], 9 | stdout=PIPE, 10 | input="def bar(x) -> None:\n pass\n", 11 | encoding="ascii", 12 | ) 13 | 14 | assert substr in p.stdout 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # Packaging 7 | *.egg-info/ 8 | build/ 9 | dist/ 10 | 11 | # Jupyter 12 | .ipynb_checkpoints 13 | *.ipynb 14 | 15 | # Environments 16 | .env 17 | .venv 18 | env/ 19 | venv/ 20 | ENV/ 21 | env.bak/ 22 | venv.bak/ 23 | .python-version 24 | 25 | # jetbrains 26 | .idea/ 27 | .DS_Store 28 | 29 | # vscode 30 | .vscode 31 | 32 | # logs 33 | *.log 34 | test-*.xml 35 | 36 | # Unit test / coverage reports 37 | .coverage 38 | .tox 39 | .coverage.* 40 | coverage.xml 41 | cov.xml 42 | htmlcov 43 | .pytest_cache/ 44 | 45 | # mypy 46 | .mypy_cache/ 47 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: I have encountered a bug! 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description the bug you encountered. 12 | 13 | If this is something behaving contrary to what you'd expect, please provide your expectation as well. 14 | 15 | **To Reproduce** 16 | Minimal code example to reproduce the behavior: 17 | 18 | ```py 19 | Your code here 20 | ``` 21 | 22 | **Version Information** 23 | Please provide the full output of `flake8 --version` 24 | 25 | ```bash 26 | $ flake8 --version 27 | Your output here 28 | ``` 29 | 30 | As well as your Python version: 31 | ```bash 32 | $ python -V 33 | Your output here 34 | ``` 35 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | testpaths = testing/ 3 | addopts = 4 | --cov=flake8_annotations 5 | --cov=testing 6 | --cov-branch 7 | --cov-append 8 | --cov-report term-missing:skip-covered 9 | 10 | [coverage:report] 11 | exclude_lines = 12 | pragma: no cover 13 | if t.TYPE_CHECKING: 14 | if typing.TYPE_CHECKING: 15 | if TYPE_CHECKING: 16 | 17 | [tox] 18 | envlist = clean,py{3.10,3.11,3.12,3.13,3.14},cog 19 | skip_missing_interpreters = True 20 | isolated_build = True 21 | requires = 22 | tox >= 4.0 23 | 24 | [testenv] 25 | commands = python -m pytest 26 | deps = 27 | pytest 28 | pytest-check 29 | pytest-cov 30 | pytest-randomly 31 | flake8 32 | 33 | [testenv:clean] 34 | deps = coverage 35 | skip_install = true 36 | commands = coverage erase 37 | 38 | [testenv:cog] 39 | commands = cog -r README.md 40 | deps = 41 | cogapp 42 | flake8 43 | -------------------------------------------------------------------------------- /testing/test_object_formatting.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | 3 | import pytest 4 | import pytest_check as check 5 | 6 | from testing.test_cases.object_formatting_test_cases import FormatTestCase, formatting_test_cases 7 | 8 | 9 | @pytest.fixture(params=formatting_test_cases.keys()) 10 | def build_test_cases(request) -> Tuple[FormatTestCase, str]: # noqa: ANN001 11 | """ 12 | Create a fixture for the provided test cases. 13 | 14 | Test cases are provided as a (test_object, str_output, repr_output) named tuple, along with a 15 | formatted message to use for a more explicit assertion failure 16 | """ 17 | failure_msg = f"Comparison check failed for case: '{request.param}'" 18 | return formatting_test_cases[request.param], failure_msg 19 | 20 | 21 | def test_str(build_test_cases: Tuple[FormatTestCase, str]) -> None: 22 | """Test the __str__ method for Argument and Function objects.""" 23 | test_case, failure_msg = build_test_cases 24 | check.equal(str(test_case.test_object), test_case.str_output, msg=failure_msg) 25 | -------------------------------------------------------------------------------- /flake8_annotations/enums.py: -------------------------------------------------------------------------------- 1 | from enum import Enum, auto 2 | 3 | 4 | class FunctionType(Enum): 5 | """ 6 | Represent Python's function types. 7 | 8 | Note: while Python differentiates between a function and a method, for the purposes of this 9 | tool, both will be referred to as functions outside of any class-specific context. This also 10 | aligns with ast's naming convention. 11 | """ 12 | 13 | PUBLIC = auto() 14 | PROTECTED = auto() # Leading single underscore 15 | PRIVATE = auto() # Leading double underscore 16 | SPECIAL = auto() # Leading & trailing double underscore 17 | 18 | 19 | class ClassDecoratorType(Enum): 20 | """Represent Python's built-in class method decorators.""" 21 | 22 | CLASSMETHOD = auto() 23 | STATICMETHOD = auto() 24 | 25 | 26 | class AnnotationType(Enum): 27 | """Represent the kind of missing type annotation.""" 28 | 29 | POSONLYARGS = auto() 30 | ARGS = auto() 31 | VARARG = auto() 32 | KWONLYARGS = auto() 33 | KWARG = auto() 34 | RETURN = auto() 35 | -------------------------------------------------------------------------------- /.ruff.toml: -------------------------------------------------------------------------------- 1 | line-length = 100 2 | fix = false 3 | output-format = "grouped" 4 | 5 | extend-exclude = [ 6 | "__pycache__", 7 | ".cache", 8 | "error_codes.py", 9 | ] 10 | 11 | [lint] 12 | select = [ 13 | "B", # flake8-bugbear 14 | "C4", # flake8-comprehensions 15 | "D", # pydocstyle/flake8-docstrings 16 | "E", # pycodestyle 17 | "F", # Pyflakes 18 | "FIX", # flake8-fixme 19 | "N", # pep8-naming 20 | "W", # pycodestyle 21 | ] 22 | 23 | ignore = [ 24 | # pydocstyle 25 | "D100", 26 | "D104", 27 | "D105", 28 | "D107", 29 | "D203", 30 | "D212", 31 | "D214", 32 | "D215", 33 | "D301", 34 | "D400", 35 | "D401", 36 | "D402", 37 | "D404", 38 | "D405", 39 | "D406", 40 | "D407", 41 | "D408", 42 | "D409", 43 | "D410", 44 | "D411", 45 | "D412", 46 | "D413", 47 | "D414", 48 | "D416", 49 | "D417", 50 | # pep8-naming 51 | "N802", 52 | "N806", 53 | "N815", 54 | ] 55 | 56 | [lint.per-file-ignores] 57 | "testing/test_*.py" = [ 58 | "D103", 59 | "E501", 60 | ] 61 | -------------------------------------------------------------------------------- /testing/test_type_ignore.py: -------------------------------------------------------------------------------- 1 | from textwrap import dedent 2 | 3 | from testing.helpers import check_source 4 | 5 | SAMPLE_SRC = dedent( 6 | """\ 7 | def bar(a): # type: ignore 8 | ... 9 | 10 | def foo( # type: ignore 11 | a, 12 | ): 13 | ... 14 | """ 15 | ) 16 | 17 | 18 | def test_respect_type_ignore() -> None: 19 | errs = check_source(SAMPLE_SRC, respect_type_ignore=True) 20 | assert len(list(errs)) == 0 21 | 22 | 23 | SAMPLE_TOP_LEVEL_MYPY_IGNORE = dedent( 24 | """\ 25 | # mypy: ignore-errors 26 | 27 | def bar(a): 28 | ... 29 | """ 30 | ) 31 | 32 | 33 | def test_respect_module_level_mypy_ignore() -> None: 34 | errs = check_source(SAMPLE_TOP_LEVEL_MYPY_IGNORE, respect_type_ignore=True) 35 | assert len(list(errs)) == 0 36 | 37 | 38 | SAMPLE_TOP_LEVEL_IGNORE = dedent( 39 | """\ 40 | # type: ignore 41 | 42 | def bar(a): 43 | ... 44 | """ 45 | ) 46 | 47 | 48 | def test_respect_module_level_type_ignore() -> None: 49 | errs = check_source(SAMPLE_TOP_LEVEL_IGNORE, respect_type_ignore=True) 50 | assert len(list(errs)) == 0 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 - Present S. Co1 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 | -------------------------------------------------------------------------------- /.github/workflows/pypi_release.yml: -------------------------------------------------------------------------------- 1 | name: Publish to PyPI 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | build: 9 | name: Build dist & publish 10 | runs-on: ubuntu-latest 11 | environment: 12 | name: pypi 13 | url: https://pypi.org/p/flake8-annotations 14 | permissions: 15 | contents: write 16 | id-token: write 17 | 18 | steps: 19 | - uses: actions/checkout@v5 20 | 21 | - name: Install uv 22 | uses: astral-sh/setup-uv@v7 23 | with: 24 | version: "0.9.x" 25 | enable-cache: true 26 | cache-dependency-glob: "uv.lock" 27 | 28 | - name: Set up Python 29 | uses: actions/setup-python@v6 30 | with: 31 | python-version-file: "pyproject.toml" 32 | 33 | - name: Build package 34 | run: uv build 35 | 36 | - name: Publish to PyPI 37 | uses: pypa/gh-action-pypi-publish@release/v1.13 38 | with: 39 | print-hash: true 40 | 41 | - name: Upload wheel to release 42 | env: 43 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 44 | run: 45 | gh release upload ${{ github.event.release.tag_name }} ./dist/flake8_annotations-*.whl 46 | -------------------------------------------------------------------------------- /testing/test_type_comments.py: -------------------------------------------------------------------------------- 1 | from textwrap import dedent 2 | 3 | import pytest 4 | 5 | from testing.helpers import check_source 6 | 7 | TEST_CASES = ( 8 | ( 9 | dedent( 10 | """\ 11 | def foo(a): 12 | # type: int -> None 13 | ... 14 | """ 15 | ), 16 | 3, 17 | 1, 18 | ), 19 | ( 20 | dedent( 21 | """\ 22 | def foo( 23 | a # type: int 24 | ): 25 | ... 26 | """ 27 | ), 28 | 3, 29 | 1, 30 | ), 31 | ( 32 | dedent( 33 | """\ 34 | def foo( 35 | a # type: int 36 | ): 37 | # type: (...) -> int 38 | ... 39 | """ 40 | ), 41 | 4, 42 | 2, 43 | ), 44 | ) 45 | 46 | 47 | @pytest.mark.parametrize(("src", "n_errors", "n_ann402"), TEST_CASES) 48 | def test_dynamic_typing_errors(src: str, n_errors: int, n_ann402: int) -> None: 49 | found_errors = list(check_source(src)) 50 | assert len(found_errors) == n_errors 51 | 52 | assert sum("ANN402" in err[2] for err in found_errors) == n_ann402 53 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | skip: [flake8] 3 | autoupdate_schedule: quarterly 4 | 5 | repos: 6 | - repo: https://github.com/psf/black-pre-commit-mirror 7 | rev: 25.9.0 8 | hooks: 9 | - id: black 10 | - repo: https://github.com/pycqa/isort 11 | rev: 6.1.0 12 | hooks: 13 | - id: isort 14 | name: isort 15 | - repo: https://github.com/pre-commit/pre-commit-hooks 16 | rev: v6.0.0 17 | hooks: 18 | - id: check-merge-conflict 19 | - id: check-toml 20 | - id: check-yaml 21 | - id: end-of-file-fixer 22 | - id: mixed-line-ending 23 | - repo: https://github.com/pre-commit/pygrep-hooks 24 | rev: v1.10.0 25 | hooks: 26 | - id: python-check-blanket-noqa 27 | - id: python-check-blanket-type-ignore 28 | exclude: "test_type_ignore.py" 29 | - repo: https://github.com/astral-sh/ruff-pre-commit 30 | rev: v0.14.0 31 | hooks: 32 | - id: ruff-check 33 | - repo: local 34 | hooks: 35 | - id: flake8 36 | name: flake8-local 37 | description: This hook runs flake8 within our project's uv environment. 38 | entry: uv run flake8 39 | language: python 40 | types: [python] 41 | require_serial: true 42 | -------------------------------------------------------------------------------- /testing/test_cases/object_formatting_test_cases.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | from typing import NamedTuple, Union 3 | 4 | from flake8_annotations.ast_walker import Argument, Function 5 | from flake8_annotations.enums import AnnotationType 6 | 7 | 8 | class FormatTestCase(NamedTuple): 9 | """Named tuple for representing our test cases.""" 10 | 11 | test_object: Union[Argument, Function] 12 | str_output: str 13 | 14 | 15 | # Define partial functions to simplify object creation 16 | arg = partial(Argument, lineno=0, col_offset=0, annotation_type=AnnotationType.ARGS) 17 | func = partial(Function, name="test_func", lineno=0, col_offset=0, decorator_list=[]) 18 | 19 | formatting_test_cases = { 20 | "arg": FormatTestCase( 21 | test_object=arg(argname="test_arg"), 22 | str_output="", 23 | ), 24 | "func_no_args": FormatTestCase( 25 | test_object=func(args=[arg(argname="return")]), 26 | str_output="]>", 27 | ), 28 | "func_has_arg": FormatTestCase( 29 | test_object=func(args=[arg(argname="foo"), arg(argname="return")]), 30 | str_output=", ]>", 31 | ), 32 | } 33 | -------------------------------------------------------------------------------- /testing/test_fully_annotated.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | 3 | import pytest 4 | import pytest_check as check 5 | 6 | from flake8_annotations.ast_walker import Function 7 | from testing.helpers import functions_from_source 8 | from testing.test_cases.annotation_presence_test_cases import ( 9 | AnnotationTestCase, 10 | annotation_test_cases, 11 | ) 12 | 13 | 14 | class TestFunctionParsing: 15 | """Test for proper determinition of whether the parsed Function is fully annotated.""" 16 | 17 | @pytest.fixture(params=annotation_test_cases.items(), ids=annotation_test_cases.keys()) 18 | def functions(self, request) -> Tuple[Function, AnnotationTestCase, str]: # noqa: ANN001 19 | """Provide the Function object from the test case source & the TestCase instance.""" 20 | test_case_name, test_case = request.param 21 | function_definitions = functions_from_source(test_case.src) 22 | 23 | return function_definitions[0], test_case, test_case_name 24 | 25 | def test_fully_annotated(self, functions: Tuple[Function, AnnotationTestCase, str]) -> None: 26 | """Check the result of Function.is_fully_annotated() against the test case's truth value.""" 27 | failure_msg = f"Comparison check failed for function: '{functions[2]}'" 28 | 29 | check.equal( 30 | functions[0].is_fully_annotated(), functions[1].is_fully_annotated, msg=failure_msg 31 | ) 32 | -------------------------------------------------------------------------------- /testing/test_cases/dummy_arg_suppress_test_cases.py: -------------------------------------------------------------------------------- 1 | from textwrap import dedent 2 | from typing import NamedTuple 3 | 4 | 5 | class DummyArgSuppressionTestCase(NamedTuple): 6 | """Helper container for tests for the suppression of dummy arg errors.""" 7 | 8 | src: str 9 | should_yield_ANN000: bool 10 | 11 | 12 | dummy_arg_suppression_test_cases = { 13 | "only_dummy_arg": DummyArgSuppressionTestCase( 14 | src=dedent( 15 | """\ 16 | def foo(_) -> None: 17 | ... 18 | """ 19 | ), 20 | should_yield_ANN000=False, 21 | ), 22 | "only_dummy_vararg": DummyArgSuppressionTestCase( 23 | src=dedent( 24 | """\ 25 | def foo(*_) -> None: 26 | ... 27 | """ 28 | ), 29 | should_yield_ANN000=False, 30 | ), 31 | "only_dummy_kwarg": DummyArgSuppressionTestCase( 32 | src=dedent( 33 | """\ 34 | def foo(**_) -> None: 35 | ... 36 | """ 37 | ), 38 | should_yield_ANN000=False, 39 | ), 40 | "dummy_with_annotated_arg": DummyArgSuppressionTestCase( 41 | src=dedent( 42 | """\ 43 | def foo(a: int, _) -> None: 44 | ... 45 | """ 46 | ), 47 | should_yield_ANN000=False, 48 | ), 49 | "nested_dummy_arg": DummyArgSuppressionTestCase( 50 | src=dedent( 51 | """\ 52 | def foo() -> None: 53 | def bar(_) -> None: 54 | ... 55 | """ 56 | ), 57 | should_yield_ANN000=False, 58 | ), 59 | } 60 | -------------------------------------------------------------------------------- /testing/test_dummy_arg_error_suppression.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | 3 | import pytest 4 | import pytest_check as check 5 | 6 | from flake8_annotations.checker import FORMATTED_ERROR 7 | from testing.helpers import check_source 8 | from testing.test_cases.dummy_arg_suppress_test_cases import ( 9 | DummyArgSuppressionTestCase, 10 | dummy_arg_suppression_test_cases, 11 | ) 12 | 13 | 14 | class TestDummyArgErrorSuppression: 15 | """Test suppression of None returns.""" 16 | 17 | @pytest.fixture( 18 | params=dummy_arg_suppression_test_cases.items(), ids=dummy_arg_suppression_test_cases.keys() 19 | ) 20 | def yielded_errors( 21 | self, request # noqa: ANN001 22 | ) -> Tuple[str, DummyArgSuppressionTestCase, Tuple[FORMATTED_ERROR]]: 23 | """ 24 | Build a fixture for the error codes emitted from parsing the dummy argument test code. 25 | 26 | Fixture provides a tuple of: test case name, its corresponding DummyArgSuppressionTestCase 27 | instance, and a tuple of the errors yielded by the checker 28 | """ 29 | test_case_name, test_case = request.param 30 | 31 | return ( 32 | test_case_name, 33 | test_case, 34 | tuple(check_source(test_case.src, suppress_dummy_args=True)), 35 | ) 36 | 37 | def test_suppressed_return_error( 38 | self, yielded_errors: Tuple[str, DummyArgSuppressionTestCase, Tuple[FORMATTED_ERROR]] 39 | ) -> None: 40 | """Test that ANN000 level errors are suppressed if an annotation is named '_'.""" 41 | failure_msg = f"Check failed for case '{yielded_errors[0]}'" 42 | 43 | yielded_ANN000 = any("ANN0" in error[2] for error in yielded_errors[2]) 44 | check.equal(yielded_errors[1].should_yield_ANN000, yielded_ANN000, msg=failure_msg) 45 | -------------------------------------------------------------------------------- /testing/test_none_return_error_suppression.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | 3 | import pytest 4 | import pytest_check as check 5 | 6 | from flake8_annotations.checker import FORMATTED_ERROR 7 | from flake8_annotations.error_codes import Error 8 | from testing.helpers import check_source 9 | from testing.test_cases.none_return_suppress_test_cases import ( 10 | NoneReturnSuppressionTestCase, 11 | return_suppression_test_cases, 12 | ) 13 | 14 | 15 | class TestNoneReturnErrorSuppression: 16 | """Test suppression of None returns.""" 17 | 18 | @pytest.fixture( 19 | params=return_suppression_test_cases.items(), ids=return_suppression_test_cases.keys() 20 | ) 21 | def yielded_errors( 22 | self, request # noqa: ANN001 23 | ) -> Tuple[str, NoneReturnSuppressionTestCase, Tuple[Error]]: 24 | """ 25 | Build a fixture for the error codes emitted from parsing the None return test code. 26 | 27 | Fixture provides a tuple of: test case name, its corresponding NoneReturnSuppressionTestCase 28 | instance, and a tuple of the errors yielded by the checker 29 | """ 30 | test_case_name, test_case = request.param 31 | 32 | return ( 33 | test_case_name, 34 | test_case, 35 | tuple(check_source(test_case.src, suppress_none_returns=True)), 36 | ) 37 | 38 | def test_suppressed_return_error( 39 | self, yielded_errors: Tuple[str, NoneReturnSuppressionTestCase, Tuple[FORMATTED_ERROR]] 40 | ) -> None: 41 | """Test that ANN200 level errors are suppressed if a function only returns None.""" 42 | failure_msg = f"Check failed for case '{yielded_errors[0]}'" 43 | 44 | yielded_ANN200 = any("ANN2" in error[2] for error in yielded_errors[2]) 45 | check.equal(yielded_errors[1].should_yield_ANN200, yielded_ANN200, msg=failure_msg) 46 | -------------------------------------------------------------------------------- /testing/test_mypy_init_return.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | 3 | import pytest 4 | import pytest_check as check 5 | 6 | from flake8_annotations.checker import FORMATTED_ERROR 7 | from testing.helpers import check_source 8 | from testing.test_cases.mypy_init_return_test_cases import ( 9 | MypyInitReturnTestCase, 10 | mypy_init_test_cases, 11 | ) 12 | 13 | 14 | class TestMypyStyleInitReturnErrorSuppression: 15 | """Test Mypy-style omission of return type hints for typed __init__ methods.""" 16 | 17 | @pytest.fixture(params=mypy_init_test_cases.items(), ids=mypy_init_test_cases.keys()) 18 | def yielded_errors( 19 | self, request # noqa: ANN001 20 | ) -> Tuple[str, MypyInitReturnTestCase, Tuple[FORMATTED_ERROR]]: 21 | """ 22 | Build a fixture for the error codes emitted from parsing the Mypy __init__ return test code. 23 | 24 | Fixture provides a tuple of: test case name, its corresponding MypyInitReturnTestCase 25 | instance, and a tuple of the errors yielded by the checker 26 | """ 27 | test_case_name, test_case = request.param 28 | 29 | return ( 30 | test_case_name, 31 | test_case, 32 | tuple(check_source(test_case.src, mypy_init_return=True)), 33 | ) 34 | 35 | def test_suppressed_return_error( 36 | self, yielded_errors: Tuple[str, MypyInitReturnTestCase, Tuple[FORMATTED_ERROR]] 37 | ) -> None: 38 | """ 39 | Test that ANN200 level errors are suppressed in class __init__ according to Mypy's behavior. 40 | 41 | Mypy allows omission of the return type hint for __init__ methods if at least one argument 42 | is annotated. 43 | """ 44 | test_case_name, test_case, errors = yielded_errors 45 | failure_msg = f"Check failed for case '{test_case_name}'" 46 | 47 | yielded_ANN200 = any("ANN2" in error[2] for error in errors) 48 | check.equal(test_case.should_yield_return_error, yielded_ANN200, msg=failure_msg) 49 | -------------------------------------------------------------------------------- /testing/test_cases/annotation_presence_test_cases.py: -------------------------------------------------------------------------------- 1 | from textwrap import dedent 2 | from typing import NamedTuple 3 | 4 | 5 | class AnnotationTestCase(NamedTuple): 6 | """Helper container for annotation presence test cases.""" 7 | 8 | src: str 9 | is_fully_annotated: bool 10 | 11 | 12 | annotation_test_cases = { 13 | "no_arg_no_return": AnnotationTestCase( 14 | src=dedent( 15 | """\ 16 | def foo(a, b): 17 | pass 18 | """ 19 | ), 20 | is_fully_annotated=False, 21 | ), 22 | "partial_arg_no_return": AnnotationTestCase( 23 | src=dedent( 24 | """\ 25 | def foo(a: int, b): 26 | pass 27 | """ 28 | ), 29 | is_fully_annotated=False, 30 | ), 31 | "partial_arg_return": AnnotationTestCase( 32 | src=dedent( 33 | """\ 34 | def foo(a: int, b) -> int: 35 | pass 36 | """ 37 | ), 38 | is_fully_annotated=False, 39 | ), 40 | "full_args_no_return": AnnotationTestCase( 41 | src=dedent( 42 | """\ 43 | def foo(a: int, b: int): 44 | pass 45 | """ 46 | ), 47 | is_fully_annotated=False, 48 | ), 49 | "no_args_no_return": AnnotationTestCase( 50 | src=dedent( 51 | """\ 52 | def foo(): 53 | pass 54 | """ 55 | ), 56 | is_fully_annotated=False, 57 | ), 58 | "full_arg_return": AnnotationTestCase( 59 | src=dedent( 60 | """\ 61 | def foo(a: int, b: int) -> int: 62 | pass 63 | """ 64 | ), 65 | is_fully_annotated=True, 66 | ), 67 | "no_args_return": AnnotationTestCase( 68 | src=dedent( 69 | """\ 70 | def foo() -> int: 71 | pass 72 | """ 73 | ), 74 | is_fully_annotated=True, 75 | ), 76 | } 77 | -------------------------------------------------------------------------------- /testing/test_dispatch_decorator.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | 3 | import pytest 4 | 5 | from flake8_annotations.error_codes import Error 6 | from testing.helpers import check_is_empty, check_is_not_empty, check_source 7 | from testing.test_cases.dispatch_decorator_test_cases import ( 8 | DispatchDecoratorTestCase, 9 | dispatch_decorator_test_cases, 10 | ) 11 | 12 | 13 | class TestDispatchDecoratorErrorSuppression: 14 | """Test suppression of errors for the dispatch decorated functions.""" 15 | 16 | @pytest.fixture( 17 | params=dispatch_decorator_test_cases.items(), ids=dispatch_decorator_test_cases.keys() 18 | ) 19 | def yielded_errors( 20 | self, request # noqa: ANN001 21 | ) -> Tuple[str, DispatchDecoratorTestCase, Tuple[Error]]: 22 | """ 23 | Build a fixture for the errors emitted from parsing dispatch decorated test code. 24 | 25 | Fixture provides a tuple of: test case name, its corresponding 26 | `DispatchDecoratorTestCase` instance, and a tuple of the errors yielded by the 27 | checker, which should be empty if the test case's `should_yield_error` is `False`. 28 | 29 | To support decorator aliases, the `dispatch_decorators` param is optionally specified by the 30 | test case. If none is explicitly set, the decorator list defaults to the checker's default. 31 | """ 32 | test_case_name, test_case = request.param 33 | 34 | return ( 35 | test_case_name, 36 | test_case, 37 | tuple(check_source(test_case.src, dispatch_decorators=test_case.dispatch_decorators)), 38 | ) 39 | 40 | def test_dispatch_decorator_error_suppression( 41 | self, yielded_errors: Tuple[str, DispatchDecoratorTestCase, Tuple[Error]] 42 | ) -> None: 43 | """Test that no errors are yielded dispatch decorated functions.""" 44 | test_case_name, test_case, errors = yielded_errors 45 | failure_msg = f"Check failed for case '{test_case_name}'" 46 | 47 | if test_case.should_yield_error: 48 | check_is_not_empty(errors, msg=failure_msg) 49 | else: 50 | check_is_empty(errors, msg=failure_msg) 51 | -------------------------------------------------------------------------------- /testing/test_overload_decorator.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | 3 | import pytest 4 | 5 | from flake8_annotations.error_codes import Error 6 | from testing.helpers import check_is_empty, check_is_not_empty, check_source 7 | from testing.test_cases.overload_decorator_test_cases import ( 8 | OverloadDecoratorTestCase, 9 | overload_decorator_test_cases, 10 | ) 11 | 12 | 13 | class TestOverloadDecoratorErrorSuppression: 14 | """Test suppression of errors for the closing def of a `typing.overload` series.""" 15 | 16 | @pytest.fixture( 17 | params=overload_decorator_test_cases.items(), ids=overload_decorator_test_cases.keys() 18 | ) 19 | def yielded_errors( 20 | self, request # noqa: ANN001 21 | ) -> Tuple[str, OverloadDecoratorTestCase, Tuple[Error]]: 22 | """ 23 | Build a fixture for the errors emitted from parsing `@overload` decorated test code. 24 | 25 | Fixture provides a tuple of: test case name, its corresponding 26 | `OverloadDecoratorTestCase` instance, and a tuple of the errors yielded by the 27 | checker, which should be empty if the test case's `should_yield_error` is `False`. 28 | 29 | To support decorator aliases, the `overload_decorators` param is optionally specified by the 30 | test case. If none is explicitly set, the decorator list defaults to the checker's default. 31 | """ 32 | test_case_name, test_case = request.param 33 | 34 | return ( 35 | test_case_name, 36 | test_case, 37 | tuple(check_source(test_case.src, overload_decorators=test_case.overload_decorators)), 38 | ) 39 | 40 | def test_overload_decorator_error_suppression( 41 | self, yielded_errors: Tuple[str, OverloadDecoratorTestCase, Tuple[Error]] 42 | ) -> None: 43 | """Test that no errors are yielded for the closing def of a `typing.overload` series.""" 44 | test_case_name, test_case, errors = yielded_errors 45 | failure_msg = f"Check failed for case '{test_case_name}'" 46 | 47 | if test_case.should_yield_error: 48 | check_is_not_empty(errors, msg=failure_msg) 49 | else: 50 | check_is_empty(errors, msg=failure_msg) 51 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | ## Python Version Support 3 | A best attempt is made to support Python versions until they reach EOL, after which support will be formally dropped by the next minor or major release of this package, whichever arrives first. The status of Python versions can be found [here](https://devguide.python.org/versions/). 4 | 5 | ## Development Environment 6 | 7 | Development of this project is done using the supported Python version most recently released. Note that tests are run against all supported versions of Python; see: [Testing & Coverage](#testing--coverage) for additional information. 8 | 9 | This project uses [uv](https://docs.astral.sh/uv) to manage dependencies. With your fork cloned to your local machine, you can install the project and its dependencies to create a development environment using: 10 | 11 | ```text 12 | $ uv venv 13 | $ uv sync --all-extras --dev 14 | ``` 15 | 16 | A [`pre-commit`](https://pre-commit.com) configuration is also provided to create a pre-commit hook so linting errors aren't committed: 17 | 18 | ```text 19 | $ pre-commit install 20 | ``` 21 | 22 | [`mypy`](https://mypy-lang.org/) is also used by this project to provide static type checking. It can be invoked using: 23 | 24 | ```text 25 | $ mypy . 26 | ``` 27 | 28 | Note that `mypy` is not included as a pre-commit hook. 29 | 30 | ## Testing & Coverage 31 | 32 | A [pytest](https://docs.pytest.org/en/latest/) suite is provided, with coverage reporting from [`pytest-cov`](https://github.com/pytest-dev/pytest-cov). A [`tox`](https://github.com/tox-dev/tox/) configuration is provided to test across all supported versions of Python. Testing will be skipped locally for Python versions that cannot be found; all supported versions are tested in CI. 33 | 34 | ```text 35 | $ tox 36 | ``` 37 | 38 | Details on missing coverage, including in the test suite, is provided in the report to allow the user to generate additional tests for full coverage. Full code coverage is expected for the majority of code contributed to this project. Some exceptions are expected, primarily around code whose functionality relies on either user input or the presence of external drives; these interactions are currently not mocked, though this may change in the future. 39 | -------------------------------------------------------------------------------- /testing/test_cases/mypy_init_return_test_cases.py: -------------------------------------------------------------------------------- 1 | from textwrap import dedent 2 | from typing import NamedTuple 3 | 4 | 5 | class MypyInitReturnTestCase(NamedTuple): 6 | """ 7 | Helper container for testing mypy-style omission of __init__ return hints. 8 | 9 | Mypy allows the omission of return type hints if at least one argument is annotated. 10 | """ 11 | 12 | src: str 13 | should_yield_return_error: bool 14 | 15 | 16 | mypy_init_test_cases = { 17 | "no_args_no_return": MypyInitReturnTestCase( 18 | src=dedent( 19 | """\ 20 | class Foo: 21 | 22 | def __init__(self): 23 | ... 24 | """ 25 | ), 26 | should_yield_return_error=True, 27 | ), 28 | "arg_no_hint_no_return": MypyInitReturnTestCase( 29 | src=dedent( 30 | """\ 31 | class Foo: 32 | 33 | def __init__(self, foo): 34 | ... 35 | """ 36 | ), 37 | should_yield_return_error=True, 38 | ), 39 | "arg_no_hint_return": MypyInitReturnTestCase( 40 | src=dedent( 41 | """\ 42 | class Foo: 43 | 44 | def __init__(self, foo) -> None: 45 | ... 46 | """ 47 | ), 48 | should_yield_return_error=False, 49 | ), 50 | "no_arg_return": MypyInitReturnTestCase( 51 | src=dedent( 52 | """\ 53 | class Foo: 54 | 55 | def __init__(self) -> None: 56 | ... 57 | """ 58 | ), 59 | should_yield_return_error=False, 60 | ), 61 | "arg_hint_no_return": MypyInitReturnTestCase( 62 | src=dedent( 63 | """\ 64 | class Foo: 65 | 66 | def __init__(self, foo: int): 67 | ... 68 | """ 69 | ), 70 | should_yield_return_error=False, 71 | ), 72 | "arg_hint_return": MypyInitReturnTestCase( 73 | src=dedent( 74 | """\ 75 | class Foo: 76 | 77 | def __init__(self, foo: int) -> None: 78 | ... 79 | """ 80 | ), 81 | should_yield_return_error=False, 82 | ), 83 | "cheeky_non_method_init": MypyInitReturnTestCase( 84 | src=dedent( 85 | """\ 86 | def __init__(self, foo: int): 87 | ... 88 | """ 89 | ), 90 | should_yield_return_error=True, 91 | ), 92 | } 93 | -------------------------------------------------------------------------------- /testing/test_variable_formatting.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import List, Tuple 3 | 4 | import pytest 5 | 6 | from flake8_annotations.checker import FORMATTED_ERROR 7 | from testing.helpers import check_source 8 | from testing.test_cases.variable_formatting_test_cases import variable_formatting_test_cases 9 | 10 | SIMPLE_ERROR_CODE = Tuple[str, str] 11 | 12 | # Error type specific matching patterns 13 | TEST_ARG_NAMES = {"ANN001": "some_arg", "ANN002": "some_args", "ANN003": "some_kwargs"} 14 | RE_DICT = {"ANN001": r"'(\w+)'", "ANN002": r"\*(\w+)", "ANN003": r"\*\*(\w+)"} 15 | 16 | 17 | def _simplify_error(error_code: FORMATTED_ERROR) -> SIMPLE_ERROR_CODE: 18 | """ 19 | Simplify the error yielded by the flake8 checker into an (error type, argument name) tuple. 20 | 21 | Input error codes are assumed to be tuples of the form: 22 | (line number, column number, error string, checker class) 23 | 24 | Where the error string begins with "ANNxxx" and contains the arg name in the following form: 25 | ANN001: '{arg name}' 26 | ANN002: *{arg name} 27 | ANN003: **{arg name} 28 | """ 29 | error_type = error_code[2].split()[0] 30 | arg_name = re.findall(RE_DICT[error_type], error_code[2])[0] 31 | return error_type, arg_name 32 | 33 | 34 | class TestArgumentFormatting: 35 | """Testing class for containerizing parsed error codes & running the fixtured tests.""" 36 | 37 | @pytest.fixture( 38 | params=variable_formatting_test_cases.items(), ids=variable_formatting_test_cases.keys() 39 | ) 40 | def parsed_errors(self, request) -> Tuple[List[SIMPLE_ERROR_CODE], str]: # noqa: ANN001 41 | """ 42 | Create a fixture for the error codes emitted by the test case source code. 43 | 44 | Error codes for the test case source code are simplified into a list of 45 | (error code, argument name) tuples. 46 | """ 47 | test_case_name, test_case = request.param 48 | simplified_errors = [_simplify_error(error) for error in check_source(test_case.src)] 49 | 50 | return simplified_errors, test_case_name 51 | 52 | def test_arg_name(self, parsed_errors: Tuple[List[SIMPLE_ERROR_CODE], str]) -> None: 53 | """ 54 | Check for correctly formatted argument names. 55 | 56 | Simplified error code information is provided by the fixture as a list of 57 | (yielded error, test case name) tuples 58 | """ 59 | assert all( 60 | TEST_ARG_NAMES[error_type] == arg_name for error_type, arg_name in parsed_errors[0] 61 | ) 62 | -------------------------------------------------------------------------------- /testing/test_column_line_numbers.py: -------------------------------------------------------------------------------- 1 | from itertools import zip_longest 2 | from typing import Generator, Tuple 3 | 4 | import pytest 5 | import pytest_check as check 6 | 7 | from flake8_annotations import checker 8 | from testing.helpers import check_source 9 | from testing.test_cases.column_line_numbers_test_cases import ParserTestCase, parser_test_cases 10 | 11 | ERROR_CODE = Tuple[int, int, str, checker.TypeHintChecker] 12 | 13 | 14 | @pytest.fixture(params=parser_test_cases.items(), ids=parser_test_cases.keys()) 15 | def parsed_errors( 16 | request, # noqa: ANN001 17 | ) -> Tuple[Generator[ERROR_CODE, None, None], ParserTestCase]: 18 | """ 19 | Create a fixture for the error codes emitted by our testing code. 20 | 21 | `parser_test_cases` is a dictionary of ParserTestCase named tuples, which provide the 22 | following: 23 | * `src` - Source code for the test case to be parsed 24 | * `error_locations` - Truthe value tuple of (row number, column offset) tuples 25 | * Row numbers are 1-indexed 26 | * Column offsets are 0-indexed when yielded by our checker; flake8 adds 1 when emitted 27 | 28 | The fixture provides a generator of yielded errors for the input source, along with the test 29 | case to use for obtaining truth values 30 | """ 31 | test_case_name, test_case = request.param 32 | return check_source(test_case.src), test_case 33 | 34 | 35 | def test_lineno(parsed_errors: Tuple[Generator[ERROR_CODE, None, None], ParserTestCase]) -> None: 36 | """ 37 | Check for correct line number values. 38 | 39 | Emitted error codes are tuples of the form: 40 | (line number, column number, error string, checker class) 41 | 42 | Note: Line numbers are 1-indexed 43 | """ 44 | for should_error_idx, raised_error_code in zip_longest( 45 | parsed_errors[1].error_locations, parsed_errors[0] 46 | ): 47 | check.equal(should_error_idx[0], raised_error_code[0]) 48 | 49 | 50 | def test_column_offset( 51 | parsed_errors: Tuple[Generator[ERROR_CODE, None, None], ParserTestCase], 52 | ) -> None: 53 | """ 54 | Check for correct column number values. 55 | 56 | Emitted error codes are tuples of the form: 57 | (line number, column number, error string, checker class) 58 | 59 | Note: Column offsets are 0-indexed when yielded by our checker; flake8 adds 1 when emitted 60 | """ 61 | for should_error_idx, raised_error_code in zip_longest( 62 | parsed_errors[1].error_locations, parsed_errors[0] 63 | ): 64 | check.equal(should_error_idx[1], raised_error_code[1]) 65 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "flake8-annotations" 3 | version = "3.2.0" 4 | description = "Flake8 Type Annotation Checks" 5 | license = "MIT" 6 | license-files = ["LICENSE"] 7 | authors = [ 8 | {name = "sco1", email = "sco1.git@gmail.com"} 9 | ] 10 | 11 | readme = "README.md" 12 | classifiers = [ 13 | "Framework :: Flake8", 14 | "Environment :: Console", 15 | "Intended Audience :: Developers", 16 | "Operating System :: OS Independent", 17 | "Development Status :: 6 - Mature", 18 | "Programming Language :: Python", 19 | "Programming Language :: Python :: 3", 20 | "Programming Language :: Python :: 3.10", 21 | "Programming Language :: Python :: 3.11", 22 | "Programming Language :: Python :: 3.12", 23 | "Programming Language :: Python :: 3.13", 24 | "Programming Language :: Python :: 3.14", 25 | "Programming Language :: Python :: 3 :: Only", 26 | "Topic :: Software Development :: Libraries :: Python Modules", 27 | "Topic :: Software Development :: Quality Assurance", 28 | "Typing :: Typed", 29 | ] 30 | 31 | requires-python = ">=3.10" 32 | dependencies = [ 33 | "flake8>=5.0", 34 | ] 35 | 36 | [project.urls] 37 | Homepage = "https://github.com/sco1/" 38 | Documentation = "https://github.com/sco1/flake8-annotations/blob/main/README.md" 39 | Repository = "https://github.com/sco1/flake8-annotations" 40 | Issues = "https://github.com/sco1/flake8-annotations/issues" 41 | Changelog = "https://github.com/sco1/flake8-annotations/blob/main/CHANGELOG.md" 42 | 43 | [project.entry-points."flake8.extension"] 44 | "ANN" = "flake8_annotations.checker:TypeHintChecker" 45 | 46 | [dependency-groups] 47 | dev = [ 48 | "black~=25.0", 49 | "bump2version~=1.0", 50 | "cogapp~=3.3", 51 | "isort~=6.0", 52 | "mypy~=1.11", 53 | "pre-commit~=4.0", 54 | "pytest~=8.3", 55 | "pytest-check~=2.4", 56 | "pytest-cov~=7.0", 57 | "pytest-randomly~=4.0", 58 | "ruff~=0.6", 59 | "tox~=4.18", 60 | "tox-uv~=1.11", 61 | ] 62 | 63 | [tool.black] 64 | line-length = 100 65 | 66 | [tool.isort] 67 | case_sensitive = true 68 | known_first_party = "flake8-annotations,testing" 69 | no_lines_before = "LOCALFOLDER" 70 | order_by_type = false 71 | profile = "black" 72 | line_length = 100 73 | 74 | [tool.mypy] 75 | exclude = "^testing/" 76 | disallow_incomplete_defs = true 77 | disallow_untyped_calls = true 78 | disallow_untyped_decorators = true 79 | disallow_untyped_defs = true 80 | enable_error_code = "exhaustive-match" 81 | ignore_missing_imports = true 82 | no_implicit_optional = true 83 | show_error_codes = true 84 | warn_redundant_casts = true 85 | warn_return_any = true 86 | warn_unused_configs = true 87 | warn_unused_ignores = true 88 | 89 | [tool.uv.build-backend] 90 | module-name = "flake8_annotations" 91 | module-root = "" 92 | 93 | [build-system] 94 | requires = ["uv_build"] 95 | build-backend = "uv_build" 96 | -------------------------------------------------------------------------------- /testing/test_cases/none_return_suppress_test_cases.py: -------------------------------------------------------------------------------- 1 | from textwrap import dedent 2 | from typing import NamedTuple 3 | 4 | 5 | class NoneReturnSuppressionTestCase(NamedTuple): 6 | """Helper container for tests for the suppression of `None` return errors.""" 7 | 8 | src: str 9 | should_yield_ANN200: bool 10 | 11 | 12 | return_suppression_test_cases = { 13 | "no_returns": NoneReturnSuppressionTestCase( 14 | src=dedent( 15 | """\ 16 | def foo(): 17 | a = 2 + 2 18 | """ 19 | ), 20 | should_yield_ANN200=False, 21 | ), 22 | "implicit_none_return": NoneReturnSuppressionTestCase( 23 | src=dedent( 24 | """\ 25 | def foo(): 26 | return 27 | """ 28 | ), 29 | should_yield_ANN200=False, 30 | ), 31 | "explicit_none_return": NoneReturnSuppressionTestCase( 32 | src=dedent( 33 | """\ 34 | def foo(): 35 | return None 36 | """ 37 | ), 38 | should_yield_ANN200=False, 39 | ), 40 | "branched_all_none_return": NoneReturnSuppressionTestCase( 41 | src=dedent( 42 | """\ 43 | def foo(): 44 | a = 2 + 2 45 | if a == 4: 46 | return 47 | else: 48 | return 49 | """ 50 | ), 51 | should_yield_ANN200=False, 52 | ), 53 | "mixed_none_return": NoneReturnSuppressionTestCase( 54 | src=dedent( 55 | """\ 56 | def foo(): 57 | a = 2 + 2 58 | if a == 4: 59 | return None 60 | else: 61 | return 62 | """ 63 | ), 64 | should_yield_ANN200=False, 65 | ), 66 | "nested_return": NoneReturnSuppressionTestCase( 67 | src=dedent( 68 | """\ 69 | def foo(): 70 | def bar() -> bool: 71 | return True 72 | 73 | bar() 74 | """ 75 | ), 76 | should_yield_ANN200=False, 77 | ), 78 | "non_none_return": NoneReturnSuppressionTestCase( 79 | src=dedent( 80 | """\ 81 | def foo(): 82 | return True 83 | """ 84 | ), 85 | should_yield_ANN200=True, 86 | ), 87 | "mixed_return": NoneReturnSuppressionTestCase( 88 | src=dedent( 89 | """\ 90 | def foo(): 91 | a = 2 + 2 92 | if a == 4: 93 | return True 94 | else: 95 | return 96 | """ 97 | ), 98 | should_yield_ANN200=True, 99 | ), 100 | } 101 | -------------------------------------------------------------------------------- /testing/test_flake8_format.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | 3 | import pytest 4 | import pytest_check as check 5 | 6 | from flake8_annotations import error_codes 7 | from flake8_annotations.checker import TypeHintChecker 8 | 9 | ALL_ERROR_CODES = ( 10 | error_codes.ANN001, 11 | error_codes.ANN002, 12 | error_codes.ANN003, 13 | error_codes.ANN101, 14 | error_codes.ANN102, 15 | error_codes.ANN201, 16 | error_codes.ANN202, 17 | error_codes.ANN203, 18 | error_codes.ANN204, 19 | error_codes.ANN205, 20 | error_codes.ANN206, 21 | error_codes.ANN401, 22 | error_codes.ANN402, 23 | ) 24 | 25 | 26 | @pytest.fixture(params=ALL_ERROR_CODES) 27 | def error_objects(request) -> Tuple[Tuple, error_codes.Error]: # noqa: ANN001 28 | """ 29 | Create a fixture for the error object's tuple-formatted parameters emitted for flake8. 30 | 31 | Expected output should be (this is what we're testing!) a tuple with the following information: 32 | (line number: int, column number: int, message: str, checker type: TypeHintChecker object) 33 | """ 34 | error_object = request.param(argname="test_arg", lineno=0, col_offset=0) 35 | return error_object.to_flake8(), error_object 36 | 37 | 38 | def test_emitted_tuple_format(error_objects: Tuple[Tuple, error_codes.Error]) -> None: 39 | """ 40 | Test that the emitted message is a tuple with the appropriate information. 41 | 42 | The tuple should be formatted with the following information: 43 | (line number: int, column number: int, message: str, checker type: TypeHintChecker object) 44 | """ 45 | emitted_error = error_objects[0] 46 | 47 | # Emitted warning should be a tuple 48 | check.is_instance(emitted_error, Tuple) 49 | 50 | # Tuple should be of length 4 51 | check.equal(len(emitted_error), 4) 52 | 53 | # First two values should be integers 54 | check.is_instance(emitted_error[0], int) 55 | check.is_instance(emitted_error[1], int) 56 | 57 | # Third value should be a string 58 | check.is_instance(emitted_error[2], str) 59 | 60 | # Fourth value should be a type (not an instance) and the same as TypeHintChecker 61 | check.is_instance(emitted_error[3], type) 62 | check.equal(emitted_error[3], TypeHintChecker) 63 | 64 | 65 | def test_emitted_message_prefix(error_objects: Tuple[Tuple, error_codes.Error]) -> None: 66 | """ 67 | Test that the emitted message is prefixed with a code that matches the error object's name. 68 | 69 | The prefix should be of the form: ANNxxx 70 | """ 71 | error_tuple, error_code = error_objects 72 | error_message = error_tuple[2] 73 | 74 | # Error message should start with "ANN" 75 | check.is_true(error_message.startswith("ANN")) 76 | 77 | # Error prefix should be followed by 3 digits 78 | check.is_true(all(char.isdigit() for char in error_message[3:6])) 79 | 80 | # Error prefix should match error object's name 81 | check.equal(error_message[:6], type(error_code).__name__) 82 | -------------------------------------------------------------------------------- /testing/helpers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | 5 | from pytest_check import check_func 6 | 7 | from flake8_annotations.ast_walker import Function, FunctionVisitor, ast 8 | from flake8_annotations.checker import ( 9 | FORMATTED_ERROR, 10 | TypeHintChecker, 11 | _DEFAULT_DISPATCH_DECORATORS, 12 | _DEFAULT_OVERLOAD_DECORATORS, 13 | ) 14 | 15 | 16 | def parse_source(src: str) -> t.Tuple[ast.Module, t.List[str]]: 17 | """Parse the provided Python source string and return an (typed AST, source) tuple.""" 18 | tree = ast.parse(src, type_comments=True) 19 | lines = src.splitlines(keepends=True) 20 | 21 | return tree, lines 22 | 23 | 24 | def check_source( 25 | src: str, 26 | suppress_none_returns: bool = False, 27 | suppress_dummy_args: bool = False, 28 | allow_untyped_defs: bool = False, 29 | allow_untyped_nested: bool = False, 30 | mypy_init_return: bool = False, 31 | allow_star_arg_any: bool = False, 32 | respect_type_ignore: bool = False, 33 | dispatch_decorators: t.AbstractSet[str] = frozenset(_DEFAULT_DISPATCH_DECORATORS), 34 | overload_decorators: t.AbstractSet[str] = frozenset(_DEFAULT_OVERLOAD_DECORATORS), 35 | ) -> t.Generator[FORMATTED_ERROR, None, None]: 36 | """Helper for generating linting errors from the provided source code.""" 37 | _, lines = parse_source(src) 38 | checker_instance = TypeHintChecker(None, lines) 39 | 40 | # Manually set flake8 configuration options, as the test suite bypasses flake8's config parser 41 | checker_instance.suppress_none_returning = suppress_none_returns 42 | checker_instance.suppress_dummy_args = suppress_dummy_args 43 | checker_instance.allow_untyped_defs = allow_untyped_defs 44 | checker_instance.allow_untyped_nested = allow_untyped_nested 45 | checker_instance.mypy_init_return = mypy_init_return 46 | checker_instance.allow_star_arg_any = allow_star_arg_any 47 | checker_instance.respect_type_ignore = respect_type_ignore 48 | checker_instance.dispatch_decorators = dispatch_decorators 49 | checker_instance.overload_decorators = overload_decorators 50 | 51 | return checker_instance.run() 52 | 53 | 54 | def functions_from_source(src: str) -> t.List[Function]: 55 | """Helper for obtaining a list of Function objects from the provided source code.""" 56 | tree, lines = parse_source(src) 57 | visitor = FunctionVisitor(lines) 58 | visitor.visit(tree) 59 | 60 | return visitor.function_definitions 61 | 62 | 63 | def find_matching_function(func_list: t.Iterable[Function], match_name: str) -> Function: 64 | """ 65 | Iterate over a list of Function objects & find the first matching named function. 66 | 67 | Due to the definition of the test cases, this should always return something, but there is no 68 | protection if a match isn't found & will raise an `IndexError`. 69 | """ 70 | return [function for function in func_list if function.name == match_name][0] 71 | 72 | 73 | @check_func 74 | def check_is_empty(in_sequence: t.Sequence, msg: str = "") -> None: 75 | """Check whether the input sequence is empty.""" 76 | assert not in_sequence 77 | 78 | 79 | @check_func 80 | def check_is_not_empty(in_sequence: t.Sequence, msg: str = "") -> None: 81 | """Check whether the input sequence is not empty.""" 82 | assert in_sequence 83 | -------------------------------------------------------------------------------- /testing/test_cases/argument_parsing_test_cases.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | from textwrap import dedent 3 | from typing import NamedTuple, Tuple 4 | 5 | from flake8_annotations.ast_walker import Argument 6 | from flake8_annotations.enums import AnnotationType 7 | 8 | 9 | class ArgumentTestCase(NamedTuple): # noqa: D101 10 | src: str 11 | args: Tuple[Argument, ...] 12 | 13 | 14 | # Note: For testing purposes, lineno and col_offset are ignored so these are set to dummy values 15 | # using partial objects 16 | untyped_arg = partial(Argument, lineno=0, col_offset=0, has_type_annotation=False) 17 | typed_arg = partial(Argument, lineno=0, col_offset=0, has_type_annotation=True) 18 | 19 | argument_test_cases = { 20 | "all_args_untyped": ArgumentTestCase( 21 | src=dedent( 22 | """\ 23 | def foo(arg, *vararg, kwonlyarg, **kwarg): 24 | pass 25 | """ 26 | ), 27 | args=( 28 | untyped_arg(argname="arg", annotation_type=AnnotationType.ARGS), 29 | untyped_arg(argname="vararg", annotation_type=AnnotationType.VARARG), 30 | untyped_arg(argname="kwonlyarg", annotation_type=AnnotationType.KWONLYARGS), 31 | untyped_arg(argname="kwarg", annotation_type=AnnotationType.KWARG), 32 | untyped_arg(argname="return", annotation_type=AnnotationType.RETURN), 33 | ), 34 | ), 35 | "all_args_typed": ArgumentTestCase( 36 | src=dedent( 37 | """\ 38 | def foo(arg: int, *vararg: int, kwonlyarg: int, **kwarg: int) -> int: 39 | pass 40 | """ 41 | ), 42 | args=( 43 | typed_arg(argname="arg", annotation_type=AnnotationType.ARGS), 44 | typed_arg(argname="vararg", annotation_type=AnnotationType.VARARG), 45 | typed_arg(argname="kwonlyarg", annotation_type=AnnotationType.KWONLYARGS), 46 | typed_arg(argname="kwarg", annotation_type=AnnotationType.KWARG), 47 | typed_arg(argname="return", annotation_type=AnnotationType.RETURN), 48 | ), 49 | ), 50 | "posonly_arg_untyped": ArgumentTestCase( 51 | src=dedent( 52 | """\ 53 | def foo(posonlyarg, /) -> int: 54 | pass 55 | """ 56 | ), 57 | args=( 58 | untyped_arg(argname="posonlyarg", annotation_type=AnnotationType.POSONLYARGS), 59 | typed_arg(argname="return", annotation_type=AnnotationType.RETURN), 60 | ), 61 | ), 62 | "posonly_arg_typed": ArgumentTestCase( 63 | src=dedent( 64 | """\ 65 | def foo(posonlyarg: int, /) -> int: 66 | pass 67 | """ 68 | ), 69 | args=( 70 | typed_arg(argname="posonlyarg", annotation_type=AnnotationType.POSONLYARGS), 71 | typed_arg(argname="return", annotation_type=AnnotationType.RETURN), 72 | ), 73 | ), 74 | "posonly_and_arg_args": ArgumentTestCase( 75 | src=dedent( 76 | """\ 77 | def foo(posonlyarg: int, /, bar: int) -> int: 78 | pass 79 | """ 80 | ), 81 | args=( 82 | typed_arg(argname="posonlyarg", annotation_type=AnnotationType.POSONLYARGS), 83 | typed_arg(argname="bar", annotation_type=AnnotationType.ARGS), 84 | typed_arg(argname="return", annotation_type=AnnotationType.RETURN), 85 | ), 86 | ), 87 | } 88 | -------------------------------------------------------------------------------- /testing/test_cases/dynamic_function_test_cases.py: -------------------------------------------------------------------------------- 1 | from textwrap import dedent 2 | from typing import NamedTuple 3 | 4 | 5 | class DynamicallyTypedFunctionTestCase(NamedTuple): 6 | """Container for tests for the suppression of errors for dynamically typed functions.""" 7 | 8 | src: str 9 | should_yield_error: bool 10 | 11 | 12 | dynamic_function_test_cases = { 13 | "def_no_hints": DynamicallyTypedFunctionTestCase( 14 | src=dedent( 15 | """\ 16 | def foo(a): 17 | b = a + 2 18 | """ 19 | ), 20 | should_yield_error=False, 21 | ), 22 | "def_has_return_hint": DynamicallyTypedFunctionTestCase( 23 | src=dedent( 24 | """\ 25 | def foo(a) -> None: 26 | b = a + 2 27 | """ 28 | ), 29 | should_yield_error=True, 30 | ), 31 | "class_init_no_hints": DynamicallyTypedFunctionTestCase( 32 | src=dedent( 33 | """\ 34 | class Foo: 35 | 36 | def __init__(self): 37 | self.a = "Hello World" 38 | """ 39 | ), 40 | should_yield_error=False, 41 | ), 42 | "typed_class_init_no_return_hint": DynamicallyTypedFunctionTestCase( 43 | src=dedent( 44 | """\ 45 | class Foo: 46 | 47 | def __init__(self: "Foo", a: str): 48 | self.a = a 49 | """ 50 | ), 51 | should_yield_error=True, 52 | ), 53 | } 54 | 55 | 56 | class DynamicallyTypedNestedFunctionTestCase(NamedTuple): 57 | """Container for tests for the suppression of errors for dynamically typed nested functions.""" 58 | 59 | src: str 60 | should_yield_error: bool 61 | 62 | 63 | nested_dynamic_function_test_cases = { 64 | "def_no_hints": DynamicallyTypedNestedFunctionTestCase( 65 | src=dedent( 66 | """\ 67 | def foo(a): 68 | b = a + 2 69 | """ 70 | ), 71 | should_yield_error=True, 72 | ), 73 | "class_init_no_hints": DynamicallyTypedNestedFunctionTestCase( 74 | src=dedent( 75 | """\ 76 | class Foo: 77 | 78 | def __init__(self): 79 | self.a = "Hello World" 80 | """ 81 | ), 82 | should_yield_error=True, 83 | ), 84 | "nested_def_partial_hints": DynamicallyTypedNestedFunctionTestCase( 85 | src=dedent( 86 | """\ 87 | def foo() -> None: 88 | def bar(a: int): 89 | b = a + 2 90 | """ 91 | ), 92 | should_yield_error=True, 93 | ), 94 | "nested_def_no_hints": DynamicallyTypedNestedFunctionTestCase( 95 | src=dedent( 96 | """\ 97 | def foo() -> None: 98 | def bar(a): 99 | b = a + 2 100 | """ 101 | ), 102 | should_yield_error=False, 103 | ), 104 | "double_nested_def_no_hints": DynamicallyTypedNestedFunctionTestCase( 105 | src=dedent( 106 | """\ 107 | def foo() -> None: 108 | def bar() -> None: 109 | def baz(a): 110 | b = a + 2 111 | """ 112 | ), 113 | should_yield_error=False, 114 | ), 115 | } 116 | -------------------------------------------------------------------------------- /testing/test_opinionated_any.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | from subprocess import PIPE, run 3 | from textwrap import dedent 4 | 5 | import pytest 6 | 7 | from flake8_annotations import error_codes 8 | from testing.helpers import check_source 9 | 10 | ERR = partial(error_codes.ANN401, lineno=3) 11 | 12 | TEST_CASES = ( 13 | ( 14 | dedent( 15 | """\ 16 | from typing import Any 17 | 18 | def foo(a: Any) -> None: 19 | ... 20 | """ 21 | ), 22 | ), 23 | ( 24 | dedent( 25 | """\ 26 | from typing import Any 27 | 28 | def foo(a: int) -> Any: 29 | ... 30 | """ 31 | ), 32 | ), 33 | ( 34 | dedent( 35 | """\ 36 | import typing as t 37 | 38 | def foo(a: t.Any) -> None: 39 | ... 40 | """ 41 | ), 42 | ), 43 | ( 44 | dedent( 45 | """\ 46 | import typing as t 47 | 48 | def foo(a: int) -> t.Any: 49 | ... 50 | """ 51 | ), 52 | ), 53 | ) 54 | 55 | 56 | @pytest.mark.parametrize(("src",), TEST_CASES) 57 | def test_dynamic_typing_errors(src: str) -> None: 58 | found_errors = list(check_source(src)) 59 | assert len(found_errors) == 1 60 | 61 | _, _, err_msg, _ = found_errors[0] 62 | assert "ANN401" in err_msg 63 | 64 | 65 | INP = dedent( 66 | """\ 67 | import typing 68 | def foo(a: typing.Any) -> None: 69 | ... 70 | """ 71 | ) 72 | 73 | RUN_PARTIAL = partial(run, stdout=PIPE, input=INP, encoding="ascii") 74 | 75 | 76 | def test_ANN401_ignored_default() -> None: 77 | p = RUN_PARTIAL(["flake8", "-"]) 78 | 79 | assert "ANN401" not in p.stdout 80 | 81 | 82 | def test_ANN401_fire_when_selected() -> None: 83 | p = RUN_PARTIAL(["flake8", "--extend-select=ANN401", "-"]) 84 | 85 | assert "ANN401" in p.stdout 86 | 87 | 88 | STARG_CASES = ( 89 | ( 90 | dedent( 91 | """\ 92 | from typing import Any 93 | 94 | def foo(*a: Any) -> None: 95 | ... 96 | """ 97 | ), 98 | ), 99 | ( 100 | dedent( 101 | """\ 102 | from typing import Any 103 | 104 | def foo(**a: Any) -> None: 105 | ... 106 | """ 107 | ), 108 | ), 109 | ( 110 | dedent( 111 | """\ 112 | from typing import Any 113 | 114 | def foo(a: int, *b: Any, c: int) -> None: 115 | ... 116 | """ 117 | ), 118 | ), 119 | ( 120 | dedent( 121 | """\ 122 | import datetime as dt 123 | from typing import Any 124 | 125 | def foo(a: int, *b: Any, c: dt.datetime) -> None: 126 | ... 127 | """ 128 | ), 129 | ), 130 | ( 131 | dedent( 132 | """\ 133 | from typing import Any 134 | 135 | def foo(a: int, *, b: int, **c: Any) -> None: 136 | ... 137 | """ 138 | ), 139 | ), 140 | ) 141 | 142 | 143 | @pytest.mark.parametrize(("src",), STARG_CASES) 144 | def test_ignore_stargs(src: str) -> None: 145 | found_errors = list(check_source(src, allow_star_arg_any=True)) 146 | assert len(found_errors) == 0 147 | -------------------------------------------------------------------------------- /testing/test_cases/overload_decorator_test_cases.py: -------------------------------------------------------------------------------- 1 | from textwrap import dedent 2 | from typing import AbstractSet, NamedTuple 3 | 4 | from flake8_annotations.checker import _DEFAULT_OVERLOAD_DECORATORS 5 | 6 | 7 | class OverloadDecoratorTestCase(NamedTuple): 8 | """Helper container for tests for the suppression of errors for `typing.overload` decorators.""" 9 | 10 | src: str 11 | should_yield_error: bool 12 | overload_decorators: AbstractSet[str] = frozenset(_DEFAULT_OVERLOAD_DECORATORS) 13 | 14 | 15 | overload_decorator_test_cases = { 16 | "overload_decorated_attribute": OverloadDecoratorTestCase( 17 | src=dedent( 18 | """\ 19 | @typing.overload 20 | def foo(a: int) -> int: 21 | ... 22 | 23 | def foo(a): 24 | ... 25 | """ 26 | ), 27 | should_yield_error=False, 28 | ), 29 | "overload_decorated_aliased_attribute": OverloadDecoratorTestCase( 30 | src=dedent( 31 | """\ 32 | @t.overload 33 | def foo(a: int) -> int: 34 | ... 35 | 36 | def foo(a): 37 | ... 38 | """ 39 | ), 40 | should_yield_error=False, 41 | ), 42 | "overload_decorated_direct_import": OverloadDecoratorTestCase( 43 | src=dedent( 44 | """\ 45 | @overload 46 | def foo(a: int) -> int: 47 | ... 48 | 49 | def foo(a): 50 | ... 51 | """ 52 | ), 53 | should_yield_error=False, 54 | ), 55 | "overload_decorated_aliased_import": OverloadDecoratorTestCase( 56 | src=dedent( 57 | """\ 58 | @ovrld 59 | def foo(a: int) -> int: 60 | ... 61 | 62 | def foo(a): 63 | ... 64 | """ 65 | ), 66 | should_yield_error=True, 67 | ), 68 | "overload_decorated_aliased_import_configured": OverloadDecoratorTestCase( 69 | src=dedent( 70 | """\ 71 | @ovrld 72 | def foo(a: int) -> int: 73 | ... 74 | 75 | def foo(a): 76 | ... 77 | """ 78 | ), 79 | should_yield_error=False, 80 | overload_decorators={"ovrld"}, 81 | ), 82 | "overload_decorated_name_mismatch": OverloadDecoratorTestCase( 83 | src=dedent( 84 | """\ 85 | @typing.overload 86 | def foo(a: int) -> int: 87 | ... 88 | 89 | def bar(a): 90 | ... 91 | """ 92 | ), 93 | should_yield_error=True, 94 | ), 95 | "overload_decorated_attribute_callable": OverloadDecoratorTestCase( 96 | src=dedent( 97 | """\ 98 | @typing.overload() 99 | def foo(a: int) -> int: 100 | ... 101 | 102 | def foo(a): 103 | ... 104 | """ 105 | ), 106 | should_yield_error=False, 107 | ), 108 | "overload_decorated_direct_import_callable": OverloadDecoratorTestCase( 109 | src=dedent( 110 | """\ 111 | @overload() 112 | def foo(a: int) -> int: 113 | ... 114 | 115 | def foo(a): 116 | ... 117 | """ 118 | ), 119 | should_yield_error=False, 120 | ), 121 | } 122 | -------------------------------------------------------------------------------- /.github/workflows/lint_test.yml: -------------------------------------------------------------------------------- 1 | name: lint-and-test 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | tags-ignore: 9 | - "**" # Skip re-linting when tags are added 10 | 11 | jobs: 12 | lint: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v5 17 | 18 | - name: Set up Python 19 | uses: actions/setup-python@v6 20 | with: 21 | python-version-file: "pyproject.toml" 22 | 23 | - name: Install uv 24 | uses: astral-sh/setup-uv@v7 25 | with: 26 | version: "0.9.x" 27 | enable-cache: true 28 | cache-dependency-glob: "uv.lock" 29 | 30 | - name: Install dependencies 31 | run: uv sync --all-extras --dev 32 | 33 | - name: Run mypy 34 | run: uv run mypy . 35 | if: always() 36 | 37 | test: 38 | runs-on: ubuntu-latest 39 | strategy: 40 | matrix: 41 | python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] 42 | fail-fast: false 43 | 44 | steps: 45 | - uses: actions/checkout@v5 46 | 47 | - name: Get Non-Hyphenated Python Version 48 | id: get-pyver 49 | run: | 50 | echo "PYVER=$(cut -d '-' -f 1 <<< ${{ matrix.python-version }})" >> $GITHUB_OUTPUT 51 | 52 | - name: Set up (release) Python ${{ matrix.python-version }} 53 | uses: actions/setup-python@v6 54 | if: "!endsWith(matrix.python-version, '-dev')" 55 | with: 56 | python-version: ${{ matrix.python-version }} 57 | 58 | - name: Set up (deadsnakes) Python ${{ matrix.python-version }} 59 | uses: deadsnakes/action@v3.2.0 60 | if: endsWith(matrix.python-version, '-dev') 61 | with: 62 | python-version: ${{ matrix.python-version }} 63 | 64 | - name: Install uv 65 | uses: astral-sh/setup-uv@v7 66 | with: 67 | version: "0.9.x" 68 | enable-cache: true 69 | cache-dependency-glob: "uv.lock" 70 | 71 | - name: Install dependencies 72 | run: | 73 | uv venv --python ${{ steps.get-pyver.outputs.PYVER }} 74 | uv pip install tox-uv 75 | 76 | - name: Run tests w/tox 77 | run: | 78 | uv run tox -e ${{ steps.get-pyver.outputs.PYVER }} 79 | 80 | - name: Cache coverage for ${{ matrix.python-version }} 81 | uses: actions/upload-artifact@v4 82 | with: 83 | name: cov_py${{ matrix.python-version }} 84 | path: .coverage 85 | if-no-files-found: error 86 | include-hidden-files: true 87 | 88 | combine-cov: 89 | runs-on: ubuntu-latest 90 | needs: test 91 | 92 | steps: 93 | - uses: actions/checkout@v5 94 | 95 | - name: Set up Python 96 | uses: actions/setup-python@v6 97 | with: 98 | python-version-file: "pyproject.toml" 99 | 100 | - name: Pull coverage workflow artifacts 101 | uses: actions/download-artifact@v5 102 | with: 103 | path: cov_cache/ 104 | 105 | - name: Install cov & combine 106 | run: | 107 | python -m pip install coverage 108 | coverage combine ./cov_cache/**/.coverage 109 | 110 | - name: Report coverage 111 | run: | 112 | coverage html 113 | 114 | # Report a markdown version to the action summary 115 | echo '**Combined Coverage**' >> $GITHUB_STEP_SUMMARY 116 | coverage report --format=markdown >> $GITHUB_STEP_SUMMARY 117 | 118 | - name: Publish cov HTML 119 | uses: actions/upload-artifact@v4 120 | with: 121 | path: htmlcov/ 122 | name: cov_report_html 123 | -------------------------------------------------------------------------------- /testing/test_dynamic_function_error_suppression.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | 3 | import pytest 4 | 5 | from flake8_annotations.error_codes import Error 6 | from testing.helpers import check_is_empty, check_is_not_empty, check_source 7 | from testing.test_cases.dynamic_function_test_cases import ( 8 | DynamicallyTypedFunctionTestCase, 9 | DynamicallyTypedNestedFunctionTestCase, 10 | dynamic_function_test_cases, 11 | nested_dynamic_function_test_cases, 12 | ) 13 | 14 | 15 | class TestDynamicallyTypedFunctionErrorSuppression: 16 | """Test suppression of errors for dynamically typed functions.""" 17 | 18 | @pytest.fixture( 19 | params=dynamic_function_test_cases.items(), ids=dynamic_function_test_cases.keys() 20 | ) 21 | def yielded_errors( 22 | self, request # noqa: ANN001 23 | ) -> Tuple[str, DynamicallyTypedFunctionTestCase, Tuple[Error]]: 24 | """ 25 | Build a fixture for the errors emitted from parsing the dynamically typed def test code. 26 | 27 | Fixture provides a tuple of: test case name, its corresponding 28 | `DynamicallyTypedFunctionTestCase` instance, and a tuple of the errors yielded by the 29 | checker, which should be empty if the test case's `should_yield_error` is `False`. 30 | """ 31 | test_case_name, test_case = request.param 32 | 33 | return ( 34 | test_case_name, 35 | test_case, 36 | tuple(check_source(test_case.src, allow_untyped_defs=True)), 37 | ) 38 | 39 | def test_suppressed_dynamic_function_error( 40 | self, yielded_errors: Tuple[str, DynamicallyTypedFunctionTestCase, Tuple[Error]] 41 | ) -> None: 42 | """Test that all errors are suppressed if a function is dynamically typed.""" 43 | test_case_name, test_case, errors = yielded_errors 44 | failure_msg = f"Check failed for case '{test_case_name}'" 45 | 46 | if test_case.should_yield_error: 47 | check_is_not_empty(errors, msg=failure_msg) 48 | else: 49 | check_is_empty(errors, msg=failure_msg) 50 | 51 | 52 | class TestDynamicallyTypedNestedFunctionErrorSuppression: 53 | """Test suppression of errors for dynamically typed nested functions.""" 54 | 55 | @pytest.fixture( 56 | params=nested_dynamic_function_test_cases.items(), 57 | ids=nested_dynamic_function_test_cases.keys(), 58 | ) 59 | def yielded_errors( 60 | self, request # noqa: ANN001 61 | ) -> Tuple[str, DynamicallyTypedNestedFunctionTestCase, Tuple[Error]]: 62 | """ 63 | Build a fixture for the errors emitted from parsing the dynamically typed def test code. 64 | 65 | Fixture provides a tuple of: test case name, its corresponding 66 | `DynamicallyTypedNestedFunctionTestCase` instance, and a tuple of the errors yielded by the 67 | checker, which should be empty if the test case's `should_yield_error` is `False`. 68 | """ 69 | test_case_name, test_case = request.param 70 | 71 | return ( 72 | test_case_name, 73 | test_case, 74 | tuple(check_source(test_case.src, allow_untyped_nested=True)), 75 | ) 76 | 77 | def test_suppressed_dynamic_nested_function_error( 78 | self, yielded_errors: Tuple[str, DynamicallyTypedNestedFunctionTestCase, Tuple[Error]] 79 | ) -> None: 80 | """Test that all errors are suppressed if a nested function is dynamically typed.""" 81 | test_case_name, test_case, errors = yielded_errors 82 | failure_msg = f"Check failed for case '{test_case_name}'" 83 | 84 | if test_case.should_yield_error: 85 | check_is_not_empty(errors, msg=failure_msg) 86 | else: 87 | check_is_empty(errors, msg=failure_msg) 88 | -------------------------------------------------------------------------------- /testing/test_classifier.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | 3 | import pytest 4 | 5 | from flake8_annotations.ast_walker import Argument, Function 6 | from flake8_annotations.checker import classify_error 7 | from flake8_annotations.enums import AnnotationType 8 | from flake8_annotations.error_codes import Error 9 | from testing.test_cases import classifier_object_attributes 10 | 11 | 12 | class TestReturnClassifier: 13 | """Test missing return annotation error classifications.""" 14 | 15 | dummy_return = Argument( 16 | argname="return", lineno=0, col_offset=0, annotation_type=AnnotationType.RETURN 17 | ) 18 | 19 | @pytest.fixture(params=classifier_object_attributes.return_classifications.keys()) 20 | def function_builder(self, request) -> Tuple[Function, Error]: # noqa: ANN001 21 | """ 22 | Build a Function object from the fixtured parameters. 23 | 24 | `classifier_object_attributes.return_classifications` is a dictionary of possible function 25 | combinations along with the resultant error code: 26 | * Keys are named tuples of the form: 27 | (function_type, is_class_method, class_decorator_type) 28 | * Values are the error object that should be returned by the error classifier 29 | """ 30 | error_object = classifier_object_attributes.return_classifications[request.param] 31 | function_object = Function( 32 | name="ReturnTest", 33 | lineno=0, 34 | col_offset=0, 35 | function_type=request.param.function_type, 36 | is_class_method=request.param.is_class_method, 37 | class_decorator_type=request.param.class_decorator_type, 38 | decorator_list=[], 39 | args=[self.dummy_return], 40 | ) 41 | return function_object, error_object 42 | 43 | def test_return(self, function_builder: Tuple[Function, Error]) -> None: 44 | """Test missing return annotation error codes.""" 45 | test_function, error_object = function_builder 46 | assert isinstance(classify_error(test_function, self.dummy_return), error_object) 47 | 48 | 49 | class TestArgumentClassifier: 50 | """Test missing argument annotation error classifications.""" 51 | 52 | # Build a dummy argument to substitute for self/cls in class methods if we're looking at the 53 | # other arguments 54 | dummy_arg = Argument(argname="DummyArg", lineno=0, col_offset=0, annotation_type=None) 55 | 56 | @pytest.fixture(params=classifier_object_attributes.argument_classifications.keys()) 57 | def function_builder(self, request) -> Tuple[Function, Argument, Error]: # noqa: ANN001 58 | """ 59 | Build function and argument objects from the fixtured parameters. 60 | 61 | `classifier_object_attributes.argument_classifications` is a dictionary of possible argument 62 | and function combinations along with the resultant error code: 63 | * Keys are tuples of the form: 64 | (is_class_method, is_first_arg, classs_decorator_type, annotation_type) 65 | * Values are the error object that should be returned by the error classifier 66 | """ 67 | error_object = classifier_object_attributes.argument_classifications[request.param] 68 | function_object = Function( 69 | name="ArgumentTest", 70 | lineno=0, 71 | col_offset=0, 72 | function_type=None, 73 | is_class_method=request.param.is_class_method, 74 | class_decorator_type=request.param.class_decorator_type, 75 | decorator_list=[], 76 | args=[], # Functions will always have a return arg but we don't need it for this test 77 | ) 78 | argument_object = Argument( 79 | argname="TestArgument", 80 | lineno=0, 81 | col_offset=0, 82 | annotation_type=request.param.annotation_type, 83 | ) 84 | 85 | # Build dummy function object arguments 86 | if request.param.is_first_arg: 87 | function_object.args = [argument_object] 88 | else: 89 | # If we're not the first argument, add in the dummy 90 | function_object.args = [self.dummy_arg, argument_object] 91 | 92 | return function_object, argument_object, error_object 93 | 94 | def test_argument(self, function_builder: Tuple[Function, Argument, Error]) -> None: 95 | """Test missing argument annotation error codes.""" 96 | test_function, test_argument, error_object = function_builder 97 | assert isinstance(classify_error(test_function, test_argument), error_object) 98 | -------------------------------------------------------------------------------- /testing/test_cases/dispatch_decorator_test_cases.py: -------------------------------------------------------------------------------- 1 | from textwrap import dedent 2 | from typing import AbstractSet, NamedTuple 3 | 4 | from flake8_annotations.checker import _DEFAULT_DISPATCH_DECORATORS 5 | 6 | 7 | class DispatchDecoratorTestCase(NamedTuple): 8 | """Helper container for tests for the suppression of errors for dispatch decorators.""" 9 | 10 | src: str 11 | should_yield_error: bool 12 | dispatch_decorators: AbstractSet[str] = frozenset(_DEFAULT_DISPATCH_DECORATORS) 13 | 14 | 15 | dispatch_decorator_test_cases = { 16 | "singledispatch_decorated_attribute": DispatchDecoratorTestCase( 17 | src=dedent( 18 | """\ 19 | @functools.singledispatch 20 | def foo(a): 21 | print(a) 22 | """ 23 | ), 24 | should_yield_error=False, 25 | ), 26 | "singledispatch_decorated_aliased_attribute": DispatchDecoratorTestCase( 27 | src=dedent( 28 | """\ 29 | @fnctls.singledispatch 30 | def foo(a): 31 | print(a) 32 | """ 33 | ), 34 | should_yield_error=False, 35 | ), 36 | "singledispatch_decorated_direct_import": DispatchDecoratorTestCase( 37 | src=dedent( 38 | """\ 39 | @singledispatch 40 | def foo(a): 41 | print(a) 42 | """ 43 | ), 44 | should_yield_error=False, 45 | ), 46 | "singledispatch_decorated_aliased_import": DispatchDecoratorTestCase( 47 | src=dedent( 48 | """\ 49 | @sngldsptch 50 | def foo(a): 51 | print(a) 52 | """ 53 | ), 54 | should_yield_error=True, 55 | ), 56 | "singledispatch_decorated_aliased_import_configured": DispatchDecoratorTestCase( 57 | src=dedent( 58 | """\ 59 | @sngldsptch 60 | def foo(a): 61 | print(a) 62 | """ 63 | ), 64 | should_yield_error=False, 65 | dispatch_decorators={"sngldsptch"}, 66 | ), 67 | "singledispatchmethod_decorated_attribute": DispatchDecoratorTestCase( 68 | src=dedent( 69 | """\ 70 | class Foo: 71 | @functools.singledispatchmethod 72 | def foo(self, a): 73 | print(a) 74 | """ 75 | ), 76 | should_yield_error=False, 77 | ), 78 | "singledispatchmethod_decorated_aliased_attribute": DispatchDecoratorTestCase( 79 | src=dedent( 80 | """\ 81 | class Foo: 82 | @fnctls.singledispatchmethod 83 | def foo(self, a): 84 | print(a) 85 | """ 86 | ), 87 | should_yield_error=False, 88 | ), 89 | "singledispatchmethod_decorated_direct_import": DispatchDecoratorTestCase( 90 | src=dedent( 91 | """\ 92 | class Foo: 93 | @singledispatchmethod 94 | def foo(self, a): 95 | print(a) 96 | """ 97 | ), 98 | should_yield_error=False, 99 | ), 100 | "singledispatchmethod_decorated_aliased_import": DispatchDecoratorTestCase( 101 | src=dedent( 102 | """\ 103 | class Foo: 104 | @sngldsptchmthd 105 | def foo(self, a): 106 | print(a) 107 | """ 108 | ), 109 | should_yield_error=True, 110 | ), 111 | "singledispatchmethod_decorated_aliased_import_configured": DispatchDecoratorTestCase( 112 | src=dedent( 113 | """\ 114 | class Foo: 115 | @sngldsptchmthd 116 | def foo(self, a): 117 | print(a) 118 | """ 119 | ), 120 | should_yield_error=False, 121 | dispatch_decorators={"sngldsptchmthd"}, 122 | ), 123 | "singledispatch_attribute_callable": DispatchDecoratorTestCase( 124 | src=dedent( 125 | """\ 126 | @functools.singledispatch() 127 | def foo(a): 128 | print(a) 129 | """ 130 | ), 131 | should_yield_error=False, 132 | ), 133 | "singledispatch_import_callable": DispatchDecoratorTestCase( 134 | src=dedent( 135 | """\ 136 | @singledispatch() 137 | def foo(a): 138 | print(a) 139 | """ 140 | ), 141 | should_yield_error=False, 142 | ), 143 | } 144 | -------------------------------------------------------------------------------- /testing/test_cases/variable_formatting_test_cases.py: -------------------------------------------------------------------------------- 1 | from textwrap import dedent 2 | from typing import NamedTuple 3 | 4 | 5 | class FormatTestCase(NamedTuple): 6 | """Helper container for variable formatting test cases.""" 7 | 8 | src: str 9 | 10 | 11 | variable_formatting_test_cases = { 12 | "public_function": FormatTestCase( 13 | src=dedent( 14 | """\ 15 | def foo(some_arg, *some_args, **some_kwargs) -> int: 16 | pass 17 | """ 18 | ), 19 | ), 20 | "protected_function": FormatTestCase( 21 | src=dedent( 22 | """\ 23 | def _foo(some_arg, *some_args, **some_kwargs) -> int: 24 | pass 25 | """ 26 | ), 27 | ), 28 | "private_function": FormatTestCase( 29 | src=dedent( 30 | """\ 31 | def __foo(some_arg, *some_args, **some_kwargs) -> int: 32 | pass 33 | """ 34 | ), 35 | ), 36 | "special_function": FormatTestCase( 37 | src=dedent( 38 | """\ 39 | def __foo__(some_arg, *some_args, **some_kwargs) -> int: 40 | pass 41 | """ 42 | ), 43 | ), 44 | "class_method": FormatTestCase( 45 | src=dedent( 46 | """\ 47 | class Snek: 48 | def foo(self: Snek, some_arg, *some_args, **some_kwargs) -> int: 49 | pass 50 | """ 51 | ), 52 | ), 53 | "protected_class_method": FormatTestCase( 54 | src=dedent( 55 | """\ 56 | class Snek: 57 | def _foo(self: Snek, some_arg, *some_args, **some_kwargs) -> int: 58 | pass 59 | """ 60 | ), 61 | ), 62 | "private_class_method": FormatTestCase( 63 | src=dedent( 64 | """\ 65 | class Snek: 66 | def __foo(self: Snek, some_arg, *some_args, **some_kwargs) -> int: 67 | pass 68 | """ 69 | ), 70 | ), 71 | "magic_class_method": FormatTestCase( 72 | src=dedent( 73 | """\ 74 | class Snek: 75 | def __foo__(self: Snek, some_arg, *some_args, **some_kwargs) -> int: 76 | pass 77 | """ 78 | ), 79 | ), 80 | "public_classmethod": FormatTestCase( 81 | src=dedent( 82 | """\ 83 | class Snek: 84 | @classmethod 85 | def bar(cls: Snek, some_arg, *some_args, **some_kwargs) -> int: 86 | pass 87 | """ 88 | ), 89 | ), 90 | "protected_classmethod": FormatTestCase( 91 | src=dedent( 92 | """\ 93 | class Snek: 94 | @classmethod 95 | def _bar(cls: Snek, some_arg, *some_args, **some_kwargs) -> int: 96 | pass 97 | """ 98 | ), 99 | ), 100 | "private_classmethod": FormatTestCase( 101 | src=dedent( 102 | """\ 103 | class Snek: 104 | @classmethod 105 | def __bar(self: Snek, some_arg, *some_args, **some_kwargs) -> int: 106 | pass 107 | """ 108 | ), 109 | ), 110 | "magic_classmethod": FormatTestCase( 111 | src=dedent( 112 | """\ 113 | class Snek: 114 | @classmethod 115 | def __bar__(cls: Snek, some_arg, *some_args, **some_kwargs) -> int: 116 | pass 117 | """ 118 | ), 119 | ), 120 | "public_staticmethod": FormatTestCase( 121 | src=dedent( 122 | """\ 123 | class Snek: 124 | @staticmethod 125 | def baz(some_arg, *some_args, **some_kwargs) -> int: 126 | pass 127 | """ 128 | ), 129 | ), 130 | "protected_staticmethod": FormatTestCase( 131 | src=dedent( 132 | """\ 133 | class Snek: 134 | @staticmethod 135 | def _baz(some_arg, *some_args, **some_kwargs) -> int: 136 | pass 137 | """ 138 | ), 139 | ), 140 | "private_staticmethod": FormatTestCase( 141 | src=dedent( 142 | """\ 143 | class Snek: 144 | @staticmethod 145 | def __baz(some_arg, *some_args, **some_kwargs) -> int: 146 | pass 147 | """ 148 | ), 149 | ), 150 | "magic_staticmethod": FormatTestCase( 151 | src=dedent( 152 | """\ 153 | class Snek: 154 | @staticmethod 155 | def __baz__(some_arg, *some_args, **some_kwargs) -> int: 156 | pass 157 | """ 158 | ), 159 | ), 160 | } 161 | -------------------------------------------------------------------------------- /testing/test_cases/column_line_numbers_test_cases.py: -------------------------------------------------------------------------------- 1 | from textwrap import dedent 2 | from typing import NamedTuple, Tuple 3 | 4 | 5 | class ParserTestCase(NamedTuple): 6 | """ 7 | Helper container for line & column number test cases. 8 | 9 | Error locations are provided as a tuple of (row number, column offset) tuples 10 | * Row numbers are 1-indexed 11 | * Column offsets are 0-indexed when yielded by our checker; flake8 adds 1 when emitted 12 | """ 13 | 14 | src: str 15 | error_locations: Tuple[Tuple[int, int], ...] 16 | 17 | 18 | parser_test_cases = { 19 | "undecorated_def": ParserTestCase( 20 | src=dedent( 21 | """\ 22 | def bar(x): # 1 23 | pass # 2 24 | """ 25 | ), 26 | error_locations=( 27 | (1, 8), 28 | (1, 10), 29 | ), 30 | ), 31 | "decorated_def": ParserTestCase( 32 | src=dedent( 33 | """\ 34 | @property # 1 35 | @some_decorator # 2 36 | @some_other_decorator # 3 37 | def foo( # 4 38 | x, # 5 39 | y, # 6 40 | ): # 7 41 | pass # 8 42 | """ 43 | ), 44 | error_locations=( 45 | (5, 4), 46 | (6, 4), 47 | (7, 1), 48 | ), 49 | ), 50 | "single_line_docstring": ParserTestCase( 51 | src=dedent( 52 | """\ 53 | def baz(): # 1 54 | \"\"\"A docstring.\"\"\" # 2 55 | pass # 3 56 | """ 57 | ), 58 | error_locations=((1, 9),), 59 | ), 60 | "multiline_docstring": ParserTestCase( 61 | src=dedent( 62 | """\ 63 | def snek(): # 1 64 | \"\"\" # 2 65 | Some. # 3 66 | # 4 67 | Multiline docstring # 5 68 | \"\"\" # 6 69 | pass # 7 70 | """ 71 | ), 72 | error_locations=((1, 10),), 73 | ), 74 | "hinted_arg": ParserTestCase( 75 | src=dedent( 76 | """\ 77 | def foo(bar: bool): # 1 78 | return True # 2 79 | """ 80 | ), 81 | error_locations=((1, 18),), 82 | ), 83 | "docstring_with_colon": ParserTestCase( 84 | src=dedent( 85 | """\ 86 | def baz(): # 1 87 | \"\"\"A: docstring.\"\"\" # 2 88 | pass # 3 89 | """ 90 | ), 91 | error_locations=((1, 9),), 92 | ), 93 | "multiline_docstring_with_colon": ParserTestCase( 94 | src=dedent( 95 | """\ 96 | def snek(): # 1 97 | \"\"\" # 2 98 | Some. # 3 99 | # 4 100 | Multiline: docstring # 5 101 | \"\"\" # 6 102 | pass # 7 103 | """ 104 | ), 105 | error_locations=((1, 10),), 106 | ), 107 | "single_line_def": ParserTestCase( 108 | src=dedent( 109 | """\ 110 | def lol(): \"\"\"Some docstring.\"\"\" # 1 111 | """ 112 | ), 113 | error_locations=((1, 9),), 114 | ), 115 | "single_line_def_docstring_with_colon": ParserTestCase( 116 | src=dedent( 117 | """\ 118 | def lol(): \"\"\"Some: docstring.\"\"\" # 1 119 | """ 120 | ), 121 | error_locations=((1, 9),), 122 | ), 123 | "single_line_hinted_def": ParserTestCase( 124 | src=dedent( 125 | """\ 126 | def lol(x: int): \"\"\"Some: docstring.\"\"\" # 1 127 | """ 128 | ), 129 | error_locations=((1, 15),), 130 | ), 131 | "multiline_docstring_no_content": ParserTestCase( 132 | src=dedent( 133 | """\ 134 | def foo(): # 1 135 | \"\"\" # 2 136 | \"\"\" # 3 137 | ... # 4 138 | """ 139 | ), 140 | error_locations=((1, 9),), 141 | ), 142 | "multiline_docstring_summary_at_open": ParserTestCase( 143 | src=dedent( 144 | """\ 145 | def foo(): # 1 146 | \"\"\"Some docstring. # 2 147 | \"\"\" # 3 148 | ... # 4 149 | """ 150 | ), 151 | error_locations=((1, 9),), 152 | ), 153 | "multiline_docstring_single_line_summary": ParserTestCase( 154 | src=dedent( 155 | """\ 156 | def foo(): # 1 157 | \"\"\" # 2 158 | Some docstring. # 3 159 | \"\"\" # 4 160 | ... # 5 161 | """ 162 | ), 163 | error_locations=((1, 9),), 164 | ), 165 | } 166 | -------------------------------------------------------------------------------- /testing/test_parser.py: -------------------------------------------------------------------------------- 1 | from itertools import zip_longest 2 | from typing import List, Tuple 3 | 4 | import pytest 5 | import pytest_check as check 6 | 7 | from flake8_annotations.ast_walker import Argument, Function, FunctionVisitor 8 | from testing.helpers import find_matching_function, parse_source 9 | from testing.test_cases.argument_parsing_test_cases import argument_test_cases 10 | from testing.test_cases.function_parsing_test_cases import function_test_cases 11 | 12 | ARG_FIXTURE_TYPE = Tuple[List[Argument], List[Argument], str] 13 | FUNC_FIXTURE_TYPE = Tuple[Tuple[Function], List[Function], str] 14 | 15 | 16 | class TestArgumentParsing: 17 | """Test for proper argument parsing from source.""" 18 | 19 | @pytest.fixture(params=argument_test_cases.items(), ids=argument_test_cases.keys()) 20 | def argument_lists(self, request) -> ARG_FIXTURE_TYPE: # noqa: ANN001 21 | """ 22 | Build a pair of lists of arguments to compare and return as a (truth, parsed) tuple. 23 | 24 | `argument_test_cases` is a dictionary of the TestCase named tuples that provide the source 25 | code to be parsed and a list of Argument objects to be used as truth values 26 | 27 | A list of parsed Argument objects is taken from the class-level source parser 28 | 29 | The function name is also returned in order to provide a more verbose message for a failed 30 | assertion 31 | 32 | Note: For testing purposes, Argument lineno and col_offset are ignored so these are set to 33 | dummy values in the truth dictionary 34 | """ 35 | test_case_name, test_case = request.param 36 | truth_arguments = test_case.args 37 | 38 | tree, lines = parse_source(test_case.src) 39 | visitor = FunctionVisitor(lines) 40 | visitor.visit(tree) 41 | parsed_arguments = visitor.function_definitions[0].args 42 | 43 | return truth_arguments, parsed_arguments, test_case_name 44 | 45 | def test_argument_parsing(self, argument_lists: ARG_FIXTURE_TYPE) -> None: 46 | """ 47 | Test argument parsing of the testing source code. 48 | 49 | Argument objects are provided as a tuple of (truth, source) lists 50 | """ 51 | for truth_arg, parsed_arg in zip_longest(*argument_lists[:2]): 52 | failure_msg = ( 53 | f"Comparison check failed for arg '{parsed_arg.argname}' in '{argument_lists[2]}'" 54 | ) 55 | check.is_true(self._is_same_arg(truth_arg, parsed_arg), msg=failure_msg) 56 | 57 | @staticmethod 58 | def _is_same_arg(arg_a: Argument, arg_b: Argument) -> bool: 59 | """ 60 | Compare two Argument objects for "equality". 61 | 62 | Because we are testing column/line number parsing in another test, we can make this 63 | comparison less fragile by ignoring line & column indices and instead comparing only the 64 | following: 65 | * argname 66 | * annotation_type 67 | * has_type_annotation 68 | """ 69 | return all( 70 | ( 71 | arg_a.argname == arg_b.argname, 72 | arg_a.annotation_type == arg_b.annotation_type, 73 | arg_a.has_type_annotation == arg_b.has_type_annotation, 74 | ) 75 | ) 76 | 77 | 78 | class TestFunctionParsing: 79 | """Test for proper function parsing from source.""" 80 | 81 | @pytest.fixture(params=function_test_cases.items(), ids=function_test_cases.keys()) 82 | def functions(self, request) -> FUNC_FIXTURE_TYPE: # noqa: ANN001 83 | """ 84 | Build a pair of Function objects to compare and return as a (truth, parsed) tuple. 85 | 86 | `parser_object_attributes.parsed_functions` is a dictionary of the Functions that should be 87 | parsed out of the testing source code: 88 | * Keys are the function name, as str 89 | * Values are the Function object that should be parsed from the source 90 | """ 91 | test_case_name, test_case = request.param 92 | 93 | truth_functions = test_case.func 94 | 95 | tree, lines = parse_source(test_case.src) 96 | visitor = FunctionVisitor(lines) 97 | visitor.visit(tree) 98 | parsed_functions = visitor.function_definitions 99 | 100 | return truth_functions, parsed_functions, test_case_name 101 | 102 | def test_function_parsing(self, functions: FUNC_FIXTURE_TYPE) -> None: 103 | """ 104 | Test function parsing of the testing source code. 105 | 106 | Function objects are provided as a (truth, source) tuple 107 | """ 108 | failure_msg = f"Comparison check failed for function: '{functions[2]}'" 109 | 110 | for function in functions[1]: 111 | matched_truth_function = find_matching_function(functions[0], function.name) 112 | check.is_true(self._is_same_func(matched_truth_function, function), msg=failure_msg) 113 | 114 | @staticmethod 115 | def _is_same_func(func_a: Function, func_b: Function) -> bool: 116 | """ 117 | Compare two Function objects for "equality". 118 | 119 | Because we are testing column/line number parsing in another test, along with argument 120 | parsing, we can simplify this comparison by comparing a subset of the Function object's 121 | attributes: 122 | * name 123 | * function_type 124 | * is_class_method 125 | * class_decorator_type 126 | * is_return_annotated 127 | """ 128 | return all( 129 | ( 130 | func_a.name == func_b.name, 131 | func_a.function_type == func_b.function_type, 132 | func_a.is_class_method == func_b.is_class_method, 133 | func_a.class_decorator_type == func_b.class_decorator_type, 134 | func_a.is_return_annotated == func_b.is_return_annotated, 135 | ) 136 | ) 137 | -------------------------------------------------------------------------------- /testing/test_cases/classifier_object_attributes.py: -------------------------------------------------------------------------------- 1 | from typing import NamedTuple, Optional 2 | 3 | from flake8_annotations import error_codes 4 | from flake8_annotations.enums import AnnotationType, ClassDecoratorType, FunctionType 5 | 6 | 7 | # Build a dictionary of possible function combinations & the resultant error code 8 | # Keys are named tuples of the form (function_type, is_class_method, class_decorator_type) 9 | class RT(NamedTuple): 10 | """Helper object for return type attribute specification.""" 11 | 12 | function_type: FunctionType 13 | is_class_method: bool 14 | class_decorator_type: Optional[ClassDecoratorType] 15 | 16 | 17 | return_classifications = { 18 | # ANN206 Missing return type annotation for classmethod 19 | RT(FunctionType.PUBLIC, True, ClassDecoratorType.CLASSMETHOD): error_codes.ANN206, 20 | RT(FunctionType.PROTECTED, True, ClassDecoratorType.CLASSMETHOD): error_codes.ANN206, 21 | RT(FunctionType.PRIVATE, True, ClassDecoratorType.CLASSMETHOD): error_codes.ANN206, 22 | RT(FunctionType.SPECIAL, True, ClassDecoratorType.CLASSMETHOD): error_codes.ANN206, 23 | # ANN205 Missing return type annotation for staticmethod 24 | RT(FunctionType.PUBLIC, True, ClassDecoratorType.STATICMETHOD): error_codes.ANN205, 25 | RT(FunctionType.PROTECTED, True, ClassDecoratorType.STATICMETHOD): error_codes.ANN205, 26 | RT(FunctionType.PRIVATE, True, ClassDecoratorType.STATICMETHOD): error_codes.ANN205, 27 | RT(FunctionType.SPECIAL, True, ClassDecoratorType.STATICMETHOD): error_codes.ANN205, 28 | # ANN204 Missing return type annotation for special method 29 | RT(FunctionType.SPECIAL, True, None): error_codes.ANN204, 30 | RT(FunctionType.SPECIAL, False, None): error_codes.ANN204, 31 | # ANN203 Missing return type annotation for secret function 32 | RT(FunctionType.PRIVATE, True, None): error_codes.ANN203, 33 | RT(FunctionType.PRIVATE, False, None): error_codes.ANN203, 34 | # ANN202 Missing return type annotation for protected function 35 | RT(FunctionType.PROTECTED, True, None): error_codes.ANN202, 36 | RT(FunctionType.PROTECTED, False, None): error_codes.ANN202, 37 | # ANN201 Missing return type annotation for public function 38 | RT(FunctionType.PUBLIC, True, None): error_codes.ANN201, 39 | RT(FunctionType.PUBLIC, False, None): error_codes.ANN201, 40 | } 41 | 42 | 43 | # Build a dictionary of possible argument combinations & the resultant error code 44 | # Keys are named tuples of the form: 45 | # (is_class_method, is_first_arg, class_decorator_type, annotation_type) 46 | class AT(NamedTuple): 47 | """Helper object for return type attribute specification.""" 48 | 49 | is_class_method: bool 50 | is_first_arg: bool 51 | class_decorator_type: Optional[ClassDecoratorType] 52 | annotation_type: AnnotationType 53 | 54 | 55 | argument_classifications = { 56 | # ANN102 Missing type annotation for cls in classmethod 57 | AT(True, True, ClassDecoratorType.CLASSMETHOD, AnnotationType.ARGS): error_codes.ANN102, 58 | AT(True, True, ClassDecoratorType.CLASSMETHOD, AnnotationType.VARARG): error_codes.ANN102, 59 | AT(True, True, ClassDecoratorType.CLASSMETHOD, AnnotationType.KWONLYARGS): error_codes.ANN102, 60 | AT(True, True, ClassDecoratorType.CLASSMETHOD, AnnotationType.KWARG): error_codes.ANN102, 61 | # ANN101 Missing type annotation for self in method 62 | AT(True, True, None, AnnotationType.ARGS): error_codes.ANN101, 63 | AT(True, True, None, AnnotationType.VARARG): error_codes.ANN101, 64 | AT(True, True, None, AnnotationType.KWONLYARGS): error_codes.ANN101, 65 | AT(True, True, None, AnnotationType.KWARG): error_codes.ANN101, 66 | # ANN003 Missing type annotation for **kwargs 67 | AT(True, False, ClassDecoratorType.CLASSMETHOD, AnnotationType.KWARG): error_codes.ANN003, 68 | AT(True, True, ClassDecoratorType.STATICMETHOD, AnnotationType.KWARG): error_codes.ANN003, 69 | AT(True, False, ClassDecoratorType.STATICMETHOD, AnnotationType.KWARG): error_codes.ANN003, 70 | AT(True, False, None, AnnotationType.KWARG): error_codes.ANN003, 71 | AT(False, True, None, AnnotationType.KWARG): error_codes.ANN003, 72 | AT(False, False, None, AnnotationType.KWARG): error_codes.ANN003, 73 | # ANN002 Missing type annotation for *args 74 | AT(True, False, ClassDecoratorType.CLASSMETHOD, AnnotationType.VARARG): error_codes.ANN002, 75 | AT(True, True, ClassDecoratorType.STATICMETHOD, AnnotationType.VARARG): error_codes.ANN002, 76 | AT(True, False, ClassDecoratorType.STATICMETHOD, AnnotationType.VARARG): error_codes.ANN002, 77 | AT(True, False, None, AnnotationType.VARARG): error_codes.ANN002, 78 | AT(False, True, None, AnnotationType.VARARG): error_codes.ANN002, 79 | AT(False, False, None, AnnotationType.VARARG): error_codes.ANN002, 80 | # ANN001 Missing type annotation for function argument 81 | AT(True, False, ClassDecoratorType.CLASSMETHOD, AnnotationType.ARGS): error_codes.ANN001, 82 | AT(True, True, ClassDecoratorType.STATICMETHOD, AnnotationType.ARGS): error_codes.ANN001, 83 | AT(True, False, ClassDecoratorType.STATICMETHOD, AnnotationType.ARGS): error_codes.ANN001, 84 | AT(True, False, ClassDecoratorType.CLASSMETHOD, AnnotationType.KWONLYARGS): error_codes.ANN001, 85 | AT(True, True, ClassDecoratorType.STATICMETHOD, AnnotationType.KWONLYARGS): error_codes.ANN001, 86 | AT(True, False, ClassDecoratorType.STATICMETHOD, AnnotationType.KWONLYARGS): error_codes.ANN001, 87 | AT(True, False, ClassDecoratorType.CLASSMETHOD, AnnotationType.POSONLYARGS): error_codes.ANN001, 88 | AT(True, True, ClassDecoratorType.STATICMETHOD, AnnotationType.POSONLYARGS): error_codes.ANN001, 89 | AT( 90 | True, False, ClassDecoratorType.STATICMETHOD, AnnotationType.POSONLYARGS 91 | ): error_codes.ANN001, 92 | AT(True, False, None, AnnotationType.ARGS): error_codes.ANN001, 93 | AT(False, True, None, AnnotationType.ARGS): error_codes.ANN001, 94 | AT(False, False, None, AnnotationType.ARGS): error_codes.ANN001, 95 | AT(True, False, None, AnnotationType.KWONLYARGS): error_codes.ANN001, 96 | AT(False, True, None, AnnotationType.KWONLYARGS): error_codes.ANN001, 97 | AT(False, False, None, AnnotationType.KWONLYARGS): error_codes.ANN001, 98 | AT(True, False, None, AnnotationType.POSONLYARGS): error_codes.ANN001, 99 | AT(False, True, None, AnnotationType.POSONLYARGS): error_codes.ANN001, 100 | AT(False, False, None, AnnotationType.POSONLYARGS): error_codes.ANN001, 101 | } 102 | -------------------------------------------------------------------------------- /flake8_annotations/error_codes.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | 5 | from flake8_annotations import checker 6 | from flake8_annotations.ast_walker import Argument, Function 7 | 8 | 9 | class Error: 10 | """ 11 | Represent linting error codes & relevant metadata. 12 | 13 | This is not designed to be instantiated directly, instead providing common methods to reduce 14 | copypasta when defining new error codes. New error codes should inherit this class & accept the 15 | `argname`, `lineno`, and `col_offset` parameters; the error `message` should be constant for 16 | each error code. 17 | """ 18 | 19 | argname: str 20 | lineno: int 21 | col_offset: int 22 | 23 | def __init__(self, message: str): 24 | self.message = message 25 | 26 | @classmethod 27 | def from_argument(cls, argument: Argument) -> Error: 28 | """Set error metadata from the input Argument object.""" 29 | return cls(argument.argname, argument.lineno, argument.col_offset) # type: ignore[call-arg] 30 | 31 | @classmethod 32 | def from_function(cls, function: Function) -> Error: 33 | """Set error metadata from the input Function object.""" 34 | return cls(function.name, function.lineno, function.col_offset) # type: ignore[call-arg] 35 | 36 | def to_flake8(self) -> t.Tuple[int, int, str, t.Type[t.Any]]: 37 | """ 38 | Format the Error into what Flake8 is expecting. 39 | 40 | Expected output is a tuple with the following information: 41 | (line number, column number, message, checker type) 42 | """ 43 | return (self.lineno, self.col_offset, self.message, checker.TypeHintChecker) 44 | 45 | 46 | # Function Annotations 47 | class ANN001(Error): 48 | def __init__(self, argname: str, lineno: int, col_offset: int): 49 | super().__init__("ANN001 Missing type annotation for function argument '{}'") 50 | self.argname = argname 51 | self.lineno = lineno 52 | self.col_offset = col_offset 53 | 54 | def to_flake8(self) -> t.Tuple[int, int, str, t.Type[t.Any]]: 55 | """Overload super's formatter so we can include argname in the output.""" 56 | return ( 57 | self.lineno, 58 | self.col_offset, 59 | self.message.format(self.argname), 60 | checker.TypeHintChecker, 61 | ) 62 | 63 | 64 | class ANN002(Error): 65 | def __init__(self, argname: str, lineno: int, col_offset: int): 66 | super().__init__("ANN002 Missing type annotation for *{}") 67 | self.argname = argname 68 | self.lineno = lineno 69 | self.col_offset = col_offset 70 | 71 | def to_flake8(self) -> t.Tuple[int, int, str, t.Type[t.Any]]: 72 | """Overload super's formatter so we can include argname in the output.""" 73 | return ( 74 | self.lineno, 75 | self.col_offset, 76 | self.message.format(self.argname), 77 | checker.TypeHintChecker, 78 | ) 79 | 80 | 81 | class ANN003(Error): 82 | def __init__(self, argname: str, lineno: int, col_offset: int): 83 | super().__init__("ANN003 Missing type annotation for **{}") 84 | self.argname = argname 85 | self.lineno = lineno 86 | self.col_offset = col_offset 87 | 88 | def to_flake8(self) -> t.Tuple[int, int, str, t.Type[t.Any]]: 89 | """Overload super's formatter so we can include argname in the output.""" 90 | return ( 91 | self.lineno, 92 | self.col_offset, 93 | self.message.format(self.argname), 94 | checker.TypeHintChecker, 95 | ) 96 | 97 | 98 | # Method annotations 99 | class ANN101(Error): 100 | def __init__(self, argname: str, lineno: int, col_offset: int): 101 | super().__init__("ANN101 Missing type annotation for self in method") 102 | self.argname = argname 103 | self.lineno = lineno 104 | self.col_offset = col_offset 105 | 106 | 107 | class ANN102(Error): 108 | def __init__(self, argname: str, lineno: int, col_offset: int): 109 | super().__init__("ANN102 Missing type annotation for cls in classmethod") 110 | self.argname = argname 111 | self.lineno = lineno 112 | self.col_offset = col_offset 113 | 114 | 115 | # Return annotations 116 | class ANN201(Error): 117 | def __init__(self, argname: str, lineno: int, col_offset: int): 118 | super().__init__("ANN201 Missing return type annotation for public function") 119 | self.argname = argname 120 | self.lineno = lineno 121 | self.col_offset = col_offset 122 | 123 | 124 | class ANN202(Error): 125 | def __init__(self, argname: str, lineno: int, col_offset: int): 126 | super().__init__("ANN202 Missing return type annotation for protected function") 127 | self.argname = argname 128 | self.lineno = lineno 129 | self.col_offset = col_offset 130 | 131 | 132 | class ANN203(Error): 133 | def __init__(self, argname: str, lineno: int, col_offset: int): 134 | super().__init__("ANN203 Missing return type annotation for secret function") 135 | self.argname = argname 136 | self.lineno = lineno 137 | self.col_offset = col_offset 138 | 139 | 140 | class ANN204(Error): 141 | def __init__(self, argname: str, lineno: int, col_offset: int): 142 | super().__init__("ANN204 Missing return type annotation for special method") 143 | self.argname = argname 144 | self.lineno = lineno 145 | self.col_offset = col_offset 146 | 147 | 148 | class ANN205(Error): 149 | def __init__(self, argname: str, lineno: int, col_offset: int): 150 | super().__init__("ANN205 Missing return type annotation for staticmethod") 151 | self.argname = argname 152 | self.lineno = lineno 153 | self.col_offset = col_offset 154 | 155 | 156 | class ANN206(Error): 157 | def __init__(self, argname: str, lineno: int, col_offset: int): 158 | super().__init__("ANN206 Missing return type annotation for classmethod") 159 | self.argname = argname 160 | self.lineno = lineno 161 | self.col_offset = col_offset 162 | 163 | 164 | # Opinionated warnings 165 | class ANN401(Error): 166 | def __init__(self, argname: str, lineno: int, col_offset: int): 167 | super().__init__("ANN401 Dynamically typed expressions (typing.Any) are disallowed") 168 | self.argname = argname 169 | self.lineno = lineno 170 | self.col_offset = col_offset 171 | 172 | 173 | class ANN402(Error): 174 | def __init__(self, argname: str, lineno: int, col_offset: int): 175 | super().__init__("ANN402 Type comments are disallowed") 176 | self.argname = argname 177 | self.lineno = lineno 178 | self.col_offset = col_offset 179 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) (``.``.``) 3 | 4 | ## [v3.2.0] 5 | ### Changed 6 | * Python 3.10 is now the minimum supported version 7 | * (Internal) Remove `attrs` as a dependency 8 | 9 | ## [v3.1.1] 10 | ### Changed 11 | * #167 Add module-level support for the `--respect-type-ignore` flag 12 | 13 | ## [v3.1.0] 14 | ### Added 15 | * #164 Add `--respect-type-ignore` to support suppression of errors for functions annotated with `type: ignore` 16 | 17 | ## [v3.0.1] 18 | ### Changed 19 | * #155 Remove upper bound on Python constraint 20 | 21 | ## [v3.0.0] 22 | ### Added 23 | * Add `ANN402` for the presence of type comments 24 | ### Changed 25 | * Python 3.8.1 is now the minimum supported version 26 | * Flake8 v5.0 is now the minimum supported version 27 | 28 | ### Removed 29 | * Remove support for [PEP 484-style](https://www.python.org/dev/peps/pep-0484/#type-comments) type comments 30 | * See: https://mail.python.org/archives/list/typing-sig@python.org/thread/66JDHQ2I3U3CPUIYA43W7SPEJLLPUETG/ 31 | * See: https://github.com/python/mypy/issues/12947 32 | * Remove `ANN301` 33 | 34 | ## [v2.9.1] 35 | ### Changed 36 | * #144 Unpin the version ceiling for `attrs`. 37 | 38 | ### Fixed 39 | * (Internal) Fix unit tests for opinionated warning codes in `flake8 >= 5.0` (See: https://github.com/pycqa/flake8/issues/284) 40 | 41 | ## [v2.9.0] 42 | ### Added 43 | * #135 Add `--allow-star-arg-any` to support suppression of `ANN401` for `*args` and `**kwargs`. 44 | 45 | ## [v2.8.0] 46 | ### Added 47 | * #131 Add the `ANN4xx` error level for opinionated warnings that are disabled by default. 48 | * #131 Add `ANN401` for use of `typing.Any` as an argument annotation. 49 | 50 | ### Changed 51 | * Python 3.7 is now the minimum supported version 52 | 53 | ## [v2.7.0] 54 | ### Added 55 | * #122 Add support for Flake8 v4.x 56 | ### Fixed 57 | * #117 Stop including `CHANGELOG.md` when building wheels. 58 | 59 | ## [v2.6.2] 60 | ### Fixed 61 | * #107, #108 Change incorrect column index yielded for return annotation errors. 62 | 63 | ## [v2.6.1] 64 | ### Changed 65 | * Remove the explicitly pinned minor version ceiling for flake8. 66 | 67 | ## [v2.6.0] 68 | ### Added 69 | * #98 Add `--dispatch-decorators` to support suppression of all errors from functions decorated by decorators such as `functools.singledispatch` and `functools.singledispatchmethod`. 70 | * #99 Add `--overload-decorators` to support generic aliasing of the `typing.overload` decorator. 71 | 72 | ### Fixed 73 | * #106 Fix incorrect parsing of multiline docstrings with less than two lines of content, causing incorrect line numbers for yielded errors in Python versions prior to 3.8 74 | 75 | ## [v2.5.0] 76 | ### Added 77 | * #103 Add `--allow-untyped-nested` to suppress all errors from dynamically typted nested functions. A function is considered dynamically typed if it does not contain any type hints. 78 | 79 | ## [v2.4.1] 80 | ### Fixed 81 | * #100 Fix incorrect positioning of posonlyargs in the `Function` argument list, causing incorrect classification of the type of missing argument. 82 | 83 | ## [v2.4.0] 84 | ### Fixed 85 | * #92 Fix inconsistent linting behavior between function-level type comments and their equivalent PEP 3107-style function annotations of class methods and classmethods. 86 | * #94 Fix improper handling of the closing definition in a series of `typing.overload` decorated functions. 87 | 88 | ## [v2.3.0] 89 | ### Added 90 | * #87 Add `--mypy-init-return` to allow omission of a return type hint for `__init__` if at least one argument is annotated. See [mypy's documentation](https://mypy.readthedocs.io/en/stable/class_basics.html?#annotating-init-methods) for additional details. 91 | 92 | ## [v2.2.1] 93 | ### Fixed 94 | * #89 Revert change to Python version pinning to prevent unnecessary locking headaches 95 | 96 | ## [v2.2.0] 97 | ### Added 98 | * #87 Add `--allow-untyped-defs` to suppress all errors from dynamically typed functions. A function is considered dynamically typed if it does not contain any type hints. 99 | 100 | ### Fixed 101 | * #77, #81 Fix incorrect return error locations in the presence of a multiline docstring containing colon(s) 102 | * #81 Fix incorrect return error locations for single-line function definitions 103 | * Fix incorrectly pinned project specifications 104 | 105 | ## [v2.1.0] 106 | ### Added 107 | * #68 Add `--suppress-dummy-args` configuration option to suppress ANN000 level errors for dummy arguments, defined as `"_"` 108 | 109 | ## [v2.0.1] 110 | ### Added 111 | * (Internal) #71 Add `pep8-naming` to linting toolchain 112 | * (Internal) Expand pre-commit hooks 113 | * Add `black` 114 | * Add `check-merge-conflict` 115 | * Add `check-toml` 116 | * Add `check-yaml` 117 | * Add `end-of-file-fixer` 118 | * Add `mixed-line-ending` 119 | * Add `python-check-blanket-noqa` 120 | 121 | ### Changed 122 | * (Internal) Add argument names to `Argument` and `Function` `__repr__` methods to make the string more helpful to read 123 | 124 | ### Fixed 125 | * #70 Fix incorrect column index for missing return annotations when other annotations are present on the same line of source 126 | * #69 Fix misclassification of `None` returning functions when they contained nested functions with non-`None` returns (thanks @isidentical!) 127 | * #67 Fix methods of nested classes being improperly classified as "regular" functions (thanks @isidentical!) 128 | 129 | ## [v2.0.0] 130 | ### Changed 131 | * #64 Change prefix from `TYP` to `ANN` in order to deconflict with `flake8-typing-imports` 132 | 133 | ## [v1.2.0] 134 | ### Added 135 | * (Internal) Add test case for checking whether flake8 invokes our plugin 136 | * #41 Add `--suppress-none-returning` configuration option to suppress `TYP200` level errors for functions that either lack a `return` statement or only explicitly return `None`. 137 | * (Internal) Add `black` as an explicit developer requirement (codebase already adheres to `black` formatting) 138 | 139 | ### Changed 140 | * (Internal) #61 Migrate from Pipenv to Poetry for developer environment setup 141 | 142 | ## [v1.1.3] 143 | ### Fixed 144 | * (Internal) Add missing classifier test cases for POSONLYARGS 145 | * Re-add the `tree` argument to the checker so flake8 identifies the plugin as needing to run 146 | 147 | ## [v1.1.2] 148 | ### Changed 149 | * Request source from `flake8` as lines of code rather than parsing it from the requested filename ourselves, allowing for proper parsing of `stdin` inputs 150 | * (Internal) Remove `flake8-string-format` from dev dependencies, as `str.format()` isn't used anywhere 151 | 152 | ### Fixed 153 | * #52 Fix error when invoking with `stdin` source code instead of a filename 154 | 155 | ## [v1.1.1] 156 | ### Added 157 | * (Internal) Add [`pipenv-setup`](https://github.com/Madoshakalaka/pipenv-setup) as a dev dependency & CI check to ensure synchronization between `Pipfile` and `setup.py` 158 | * (Internal) Add [tox](https://github.com/tox-dev/tox) configuration for local testing across Python versions 159 | * (Internal) Add test for checking a single yield of TYP301 per function 160 | * (Internal) Add coverage reporting to test suite 161 | * (Internal) Add testing for positional only arguments 162 | 163 | ### Changed 164 | * (Internal) [`typed_ast`](https://github.com/python/typed_ast) is now required only for Python versions `< 3.8` 165 | * (Internal) Update flake8 minimum version to `3.7.9` for Python 3.8 compatibility 166 | * (Internal) #50 Completely refactor test suite for maintainability 167 | 168 | ### Fixed 169 | * (Internal) Fix mixed type hint tests not being run due to misnamed test class 170 | * Fix `TYP301` classification issue where error is not yielded if the first argument is type annotated and the remaining arguments have type comments 171 | 172 | ## [v1.1.0] 173 | ### Added 174 | * (Internal) #35: Issue templates 175 | * #36: Support for PEP 484-style type comments 176 | * #36: Add `TYP301` for presence of type comment & type annotation for same entity 177 | * (Internal) #36: Add `error_code.from_function` class method to generate argument for an entire function 178 | * (Internal) #18: PyPI release via GitHub Action 179 | * (Internal) #38: Improve `setup.py` metadata 180 | 181 | ### Fixed 182 | * #32: Incorrect line number for return values in the presence of multiline docstrings 183 | * #33: Improper handling of nested functions in class methods 184 | * (Internal) `setup.py` dev dependencies out of sync with Pipfile 185 | * (Internal) Incorrect order of arguments in `Argument` and `Function` `__repr__` methods 186 | 187 | ## [v1.0.0] - 2019-09-09 188 | Initial release 189 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # flake8-annotations 2 | [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/flake8-annotations/3.2.0?logo=python&logoColor=FFD43B)](https://pypi.org/project/flake8-annotations/) 3 | [![PyPI](https://img.shields.io/pypi/v/flake8-annotations?logo=Python&logoColor=FFD43B)](https://pypi.org/project/flake8-annotations/) 4 | [![PyPI - License](https://img.shields.io/pypi/l/flake8-annotations?color=magenta)](https://github.com/sco1/flake8-annotations/blob/main/LICENSE) 5 | [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/sco1/flake8-annotations/main.svg)](https://results.pre-commit.ci/latest/github/sco1/flake8-annotations/main) 6 | 7 | `flake8-annotations` is a plugin for [Flake8](http://flake8.pycqa.org/en/latest/) that detects the absence of [PEP 3107-style](https://www.python.org/dev/peps/pep-3107/) function annotations. 8 | 9 | What this won't do: replace [mypy](http://mypy-lang.org/), check type comments (see: [PEP 484](https://peps.python.org/pep-0484/#type-comments)), check variable annotations (see: [PEP 526](https://www.python.org/dev/peps/pep-0526/)), or respect stub files. 10 | 11 | ## Installation 12 | Install from PyPi with your favorite `pip` invocation: 13 | 14 | ```bash 15 | $ pip install flake8-annotations 16 | ``` 17 | 18 | It will then be run automatically as part of flake8. 19 | 20 | You can verify it's being picked up by invoking the following in your shell: 21 | 22 | 23 | 32 | ```bash 33 | $ flake8 --version 34 | 7.3.0 (flake8-annotations: 3.2.0, mccabe: 0.7.0, pycodestyle: 2.14.0, pyflakes: 3.4.0) CPython 3.14.0 on Darwin 35 | ``` 36 | 37 | 38 | ## Table of Warnings 39 | With the exception of `ANN4xx`-level warnings, all warnings are enabled by default. 40 | 41 | ### Function Annotations 42 | | ID | Description | 43 | |----------|-----------------------------------------------| 44 | | `ANN001` | Missing type annotation for function argument | 45 | | `ANN002` | Missing type annotation for `*args` | 46 | | `ANN003` | Missing type annotation for `**kwargs` | 47 | 48 | ### Method Annotations 49 | | ID | Description | 50 | |----------|--------------------------------------------------------------| 51 | | `ANN101` | Missing type annotation for `self` in method1 | 52 | | `ANN102` | Missing type annotation for `cls` in classmethod1 | 53 | 54 | ### Return Annotations 55 | | ID | Description | 56 | |----------|-------------------------------------------------------| 57 | | `ANN201` | Missing return type annotation for public function | 58 | | `ANN202` | Missing return type annotation for protected function | 59 | | `ANN203` | Missing return type annotation for secret function | 60 | | `ANN204` | Missing return type annotation for special method | 61 | | `ANN205` | Missing return type annotation for staticmethod | 62 | | `ANN206` | Missing return type annotation for classmethod | 63 | 64 | ### Opinionated Warnings 65 | These warnings are disabled by default. 66 | | ID | Description | 67 | |----------|-------------------------------------------------------------------------| 68 | | `ANN401` | Dynamically typed expressions (typing.Any) are disallowed2,3 | 69 | | `ANN402` | Type comments are disallowed3 | 70 | 71 | Use [`extend-select`](https://flake8.pycqa.org/en/latest/user/options.html#cmdoption-flake8-extend-ignore) to enable opinionated warnings without overriding other implicit configurations4. 72 | 73 | **Notes:** 74 | 1. See: [PEP 484](https://www.python.org/dev/peps/pep-0484/#annotating-instance-and-class-methods) and [PEP 563](https://www.python.org/dev/peps/pep-0563/) for suggestions on annotating `self` and `cls` arguments 75 | 2. See: [Dynamic Typing Caveats](#dynamic-typing-caveats) 76 | 3. Only function declarations are considered by this plugin; type annotations in function/module bodies are not checked 77 | 4. Common pitfall: the use of [`ignore`](https://flake8.pycqa.org/en/latest/user/options.html#cmdoption-flake8-ignore) will enable all implicitly disabled warnings 78 | 79 | ## Configuration Options 80 | Some opinionated flags are provided to tailor the linting errors emitted. 81 | 82 | ### `--suppress-none-returning`: `bool` 83 | Suppress `ANN200`-level errors for functions that meet one of the following criteria: 84 | * Contain no `return` statement, or 85 | * Explicit `return` statement(s) all return `None` (explicitly or implicitly). 86 | 87 | Default: `False` 88 | 89 | ### `--suppress-dummy-args`: `bool` 90 | Suppress `ANN000`-level errors for dummy arguments, defined as `_`. 91 | 92 | Default: `False` 93 | 94 | ### `--allow-untyped-defs`: `bool` 95 | Suppress all errors for dynamically typed functions. A function is considered dynamically typed if it does not contain any type hints. 96 | 97 | Default: `False` 98 | 99 | ### `--allow-untyped-nested`: `bool` 100 | Suppress all errors for dynamically typed nested functions. A function is considered dynamically typed if it does not contain any type hints. 101 | 102 | Default: `False` 103 | 104 | ### `--mypy-init-return`: `bool` 105 | Allow omission of a return type hint for `__init__` if at least one argument is annotated. See [mypy's documentation](https://mypy.readthedocs.io/en/stable/class_basics.html?#annotating-init-methods) for additional details. 106 | 107 | Default: `False` 108 | 109 | ### `--dispatch-decorators`: `list[str]` 110 | Comma-separated list of decorators flake8-annotations should consider as dispatch decorators. Linting errors are suppressed for functions decorated with at least one of these functions. 111 | 112 | Decorators are matched based on their attribute name. For example, `"singledispatch"` will match any of the following: 113 | * `import functools; @functools.singledispatch` 114 | * `import functools as ; @.singledispatch` 115 | * `from functools import singledispatch; @singledispatch` 116 | 117 | **NOTE:** Deeper imports, such as `a.b.singledispatch` are not supported. 118 | 119 | See: [Generic Functions](#generic-functions) for additional information. 120 | 121 | Default: `"singledispatch, singledispatchmethod"` 122 | 123 | ### `--overload-decorators`: `list[str]` 124 | Comma-separated list of decorators flake8-annotations should consider as [`typing.overload`](https://docs.python.org/3/library/typing.html#typing.overload) decorators. 125 | 126 | Decorators are matched based on their attribute name. For example, `"overload"` will match any of the following: 127 | * `import typing; @typing.overload` 128 | * `import typing as ; @.overload` 129 | * `from typing import overload; @overload` 130 | 131 | **NOTE:** Deeper imports, such as `a.b.overload` are not supported. 132 | 133 | See: [The `typing.overload` Decorator](#the-typingoverload-decorator) for additional information. 134 | 135 | Default: `"overload"` 136 | 137 | ### `--allow-star-arg-any` 138 | Suppress `ANN401` for dynamically typed `*args` and `**kwargs`. 139 | 140 | Default: `False` 141 | 142 | ### `--respect-type-ignore` 143 | Suppress linting errors for functions annotated with a `# type: ignore` comment. Support is also provided for module-level blanket ignores (see: [mypy: Ignoring a whole file](https://mypy.readthedocs.io/en/stable/common_issues.html#ignoring-a-whole-file)). 144 | 145 | **NOTE:** Type ignore tags are not considered, e.g. `# type: ignore[arg-type]` is treated the same as `# type: ignore`. 146 | **NOTE:** Module-level suppression is only considered for the `# mypy: ignore-errors` or `# type: ignore` tags when provided as the sole contents of the first line of the module. 147 | 148 | Default: `False` 149 | 150 | 151 | ## Generic Functions 152 | Per the Python Glossary, a [generic function](https://docs.python.org/3/glossary.html#term-generic-function) is defined as: 153 | 154 | > A function composed of multiple functions implementing the same operation for different types. Which implementation should be used during a call is determined by the dispatch algorithm. 155 | 156 | In the standard library we have some examples of decorators for implementing these generic functions: [`functools.singledispatch`](https://docs.python.org/3/library/functools.html#functools.singledispatch) and [`functools.singledispatchmethod`](https://docs.python.org/3/library/functools.html#functools.singledispatchmethod). In the spirit of the purpose of these decorators, errors for missing annotations for functions decorated with at least one of these are ignored. 157 | 158 | For example, this code: 159 | 160 | ```py 161 | import functools 162 | 163 | @functools.singledispatch 164 | def foo(a): 165 | print(a) 166 | 167 | @foo.register 168 | def _(a: list) -> None: 169 | for idx, thing in enumerate(a): 170 | print(idx, thing) 171 | ``` 172 | 173 | Will not raise any linting errors for `foo`. 174 | 175 | Decorator(s) to treat as defining generic functions may be specified by the [`--dispatch-decorators`](#--dispatch-decorators-liststr) configuration option. 176 | 177 | ## The `typing.overload` Decorator 178 | Per the [`typing`](https://docs.python.org/3/library/typing.html#typing.overload) documentation: 179 | 180 | > The `@overload` decorator allows describing functions and methods that support multiple different combinations of argument types. A series of `@overload`-decorated definitions must be followed by exactly one non-`@overload`-decorated definition (for the same function/method). 181 | 182 | In the spirit of the purpose of this decorator, errors for missing annotations for non-`@overload`-decorated functions are ignored if they meet this criteria. 183 | 184 | For example, this code: 185 | 186 | ```py 187 | import typing 188 | 189 | 190 | @typing.overload 191 | def foo(a: int) -> int: 192 | ... 193 | 194 | def foo(a): 195 | ... 196 | ``` 197 | 198 | Will not raise linting errors for missing annotations for the arguments & return of the non-decorated `foo` definition. 199 | 200 | Decorator(s) to treat as `typing.overload` may be specified by the [`--overload-decorators`](#--overload-decorators-liststr) configuration option. 201 | 202 | ## Dynamic Typing Caveats 203 | Support is only provided for the following patterns: 204 | * `from typing import any; foo: Any` 205 | * `import typing; foo: typing.Any` 206 | * `import typing as ; foo: .Any` 207 | 208 | Nested dynamic types (e.g. `typing.Tuple[typing.Any]`) and redefinition (e.g. `from typing import Any as Foo`) will not be identified. 209 | -------------------------------------------------------------------------------- /flake8_annotations/checker.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | from argparse import Namespace 5 | from functools import lru_cache 6 | 7 | from flake8.options.manager import OptionManager 8 | 9 | from flake8_annotations import __version__, enums, error_codes 10 | from flake8_annotations.ast_walker import Argument, Function, FunctionVisitor, ast 11 | 12 | FORMATTED_ERROR = t.Tuple[int, int, str, t.Type[t.Any]] 13 | 14 | _DEFAULT_DISPATCH_DECORATORS = [ 15 | "singledispatch", 16 | "singledispatchmethod", 17 | ] 18 | 19 | _DEFAULT_OVERLOAD_DECORATORS = [ 20 | "overload", 21 | ] 22 | 23 | # Disable opinionated warnings by default 24 | _DISABLED_BY_DEFAULT = ( 25 | "ANN401", 26 | "ANN402", 27 | ) 28 | 29 | 30 | class TypeHintChecker: 31 | """Top level checker for linting the presence of type hints in function definitions.""" 32 | 33 | name = "flake8-annotations" 34 | version = __version__ 35 | 36 | def __init__(self, tree: t.Optional[ast.Module], lines: t.List[str]): 37 | # Request `tree` in order to ensure flake8 will run the plugin, even though we don't use it 38 | # Request `lines` here and join to allow for correct handling of input from stdin 39 | self.lines = lines 40 | 41 | self.tree = ast.parse("".join(lines), type_comments=True) # flake8 doesn't strip newlines 42 | 43 | # Type ignores are provided by ast at the module level & we'll need them later when deciding 44 | # whether or not to emit errors for a given function 45 | self._type_ignore_lineno = {ti.lineno for ti in self.tree.type_ignores} 46 | self._has_mypy_ignore_errors = "# mypy: ignore-errors" in lines[0] if lines else False 47 | 48 | # Set by flake8's config parser 49 | self.suppress_none_returning: bool 50 | self.suppress_dummy_args: bool 51 | self.allow_untyped_defs: bool 52 | self.allow_untyped_nested: bool 53 | self.mypy_init_return: bool 54 | self.allow_star_arg_any: bool 55 | self.respect_type_ignore: bool 56 | self.dispatch_decorators: t.Set[str] 57 | self.overload_decorators: t.Set[str] 58 | 59 | def run(self) -> t.Generator[FORMATTED_ERROR, None, None]: 60 | """ 61 | This method is called by flake8 to perform the actual check(s) on the source code. 62 | 63 | This should yield tuples with the following information: 64 | (line number, column number, message, checker type) 65 | """ 66 | visitor = FunctionVisitor(self.lines) 67 | visitor.visit(self.tree) 68 | 69 | # Keep track of the last encountered function decorated by `typing.overload`, if any. 70 | # Per the `typing` module documentation, a series of overload-decorated definitions must be 71 | # followed by exactly one non-overload-decorated definition of the same function. 72 | last_overload_decorated_function_name: t.Optional[str] = None 73 | 74 | # Iterate over the arguments with missing type hints, by function, and yield linting errors 75 | # to flake8 76 | # 77 | # Flake8 handles all noqa and error code ignore configurations after the error is yielded 78 | for function in visitor.function_definitions: 79 | if function.has_type_comment: 80 | yield error_codes.ANN402.from_function(function).to_flake8() 81 | 82 | if function.is_dynamically_typed(): 83 | if self.allow_untyped_defs: 84 | # Skip yielding errors from dynamically typed functions 85 | continue 86 | elif function.is_nested and self.allow_untyped_nested: 87 | # Skip yielding errors from dynamically typed nested functions 88 | continue 89 | 90 | # Skip yielding errors for configured dispatch functions, such as (by default) 91 | # `functools.singledispatch` and `functools.singledispatchmethod` 92 | if function.has_decorator(self.dispatch_decorators): 93 | continue 94 | 95 | # Iterate over the annotated args to look for opinionated warnings 96 | annotated_args = function.get_annotated_arguments() 97 | for arg in annotated_args: 98 | if arg.is_dynamically_typed: 99 | if self.allow_star_arg_any and arg.annotation_type in { 100 | enums.AnnotationType.VARARG, 101 | enums.AnnotationType.KWARG, 102 | }: 103 | continue 104 | 105 | yield error_codes.ANN401.from_argument(arg).to_flake8() 106 | 107 | # Before we iterate over the function's missing annotations, check to see if it's the 108 | # closing function def in a series of `typing.overload` decorated functions. 109 | if last_overload_decorated_function_name == function.name: 110 | continue 111 | 112 | # If it's not, and it is overload decorated, store it for the next iteration 113 | if function.has_decorator(self.overload_decorators): 114 | last_overload_decorated_function_name = function.name 115 | 116 | # Optionally respect a type: ignore comment 117 | # These are considered at the function level & tags are not considered 118 | if self.respect_type_ignore: 119 | if function.lineno in self._type_ignore_lineno: 120 | # function-level ignore 121 | continue 122 | elif (1 in self._type_ignore_lineno) or ( 123 | self._has_mypy_ignore_errors 124 | ): # pragma: no branch 125 | # module-level ignore 126 | # lineno from ast is 1-indexed 127 | continue 128 | 129 | # Yield explicit errors for arguments that are missing annotations 130 | for arg in function.get_missed_annotations(): 131 | # Check for type comments here since we're not considering them as typed args 132 | if arg.has_type_comment: 133 | yield error_codes.ANN402.from_argument(arg).to_flake8() 134 | 135 | if arg.argname == "return": 136 | # return annotations have multiple possible short-circuit paths 137 | if self.suppress_none_returning: 138 | # Skip yielding return errors if the function has only `None` returns 139 | # This includes the case of no returns. 140 | if function.has_only_none_returns: 141 | continue 142 | if self.mypy_init_return: 143 | # Skip yielding return errors for `__init__` if at least one argument is 144 | # annotated 145 | if function.is_class_method and function.name == "__init__": 146 | # If we've gotten here, then our annotated args won't contain "return" 147 | # since we're in a logic check for missing "return". So if our annotated 148 | # are non-empty, then __init__ has at least one annotated argument 149 | if annotated_args: 150 | continue 151 | 152 | # If the `--suppress-dummy-args` flag is `True`, skip yielding errors for any 153 | # arguments named `_` 154 | if arg.argname == "_" and self.suppress_dummy_args: 155 | continue 156 | 157 | yield classify_error(function, arg).to_flake8() 158 | 159 | @classmethod 160 | def add_options(cls, parser: OptionManager) -> None: # pragma: no cover 161 | """Add custom configuration option(s) to flake8.""" 162 | parser.extend_default_ignore(_DISABLED_BY_DEFAULT) 163 | 164 | parser.add_option( 165 | "--suppress-none-returning", 166 | default=False, 167 | action="store_true", 168 | parse_from_config=True, 169 | help=( 170 | "Suppress ANN200-level errors for functions that contain no return statement or " 171 | "contain only bare return statements. (Default: %(default)s)" 172 | ), 173 | ) 174 | 175 | parser.add_option( 176 | "--suppress-dummy-args", 177 | default=False, 178 | action="store_true", 179 | parse_from_config=True, 180 | help="Suppress ANN000-level errors for dummy arguments, defined as '_'. (Default: %(default)s)", # noqa: E501 181 | ) 182 | 183 | parser.add_option( 184 | "--allow-untyped-defs", 185 | default=False, 186 | action="store_true", 187 | parse_from_config=True, 188 | help="Suppress all errors for dynamically typed functions. (Default: %(default)s)", 189 | ) 190 | 191 | parser.add_option( 192 | "--allow-untyped-nested", 193 | default=False, 194 | action="store_true", 195 | parse_from_config=True, 196 | help="Suppress all errors for dynamically typed nested functions. (Default: %(default)s)", # noqa: E501 197 | ) 198 | 199 | parser.add_option( 200 | "--mypy-init-return", 201 | default=False, 202 | action="store_true", 203 | parse_from_config=True, 204 | help=( 205 | "Allow omission of a return type hint for __init__ if at least one argument is " 206 | "annotated. (Default: %(default)s)" 207 | ), 208 | ) 209 | 210 | parser.add_option( 211 | "--dispatch-decorators", 212 | default=_DEFAULT_DISPATCH_DECORATORS, 213 | action="store", 214 | type=str, 215 | parse_from_config=True, 216 | comma_separated_list=True, 217 | help=( 218 | "Comma-separated list of decorators flake8-annotations should consider as dispatch " 219 | "decorators. (Default: %(default)s)" 220 | ), 221 | ) 222 | 223 | parser.add_option( 224 | "--overload-decorators", 225 | default=_DEFAULT_OVERLOAD_DECORATORS, 226 | action="store", 227 | type=str, 228 | parse_from_config=True, 229 | comma_separated_list=True, 230 | help=( 231 | "Comma-separated list of decorators flake8-annotations should consider as " 232 | "typing.overload decorators. (Default: %(default)s)" 233 | ), 234 | ) 235 | 236 | parser.add_option( 237 | "--allow-star-arg-any", 238 | default=False, 239 | action="store_true", 240 | parse_from_config=True, 241 | help="Suppress ANN401 for dynamically typed *args and **kwargs. (Default: %(default)s)", 242 | ) 243 | 244 | parser.add_option( 245 | "--respect-type-ignore", 246 | default=False, 247 | action="store_true", 248 | parse_from_config=True, 249 | help=( 250 | "Supress errors for functions annotated with a 'type: ignore' comment. (Default: " 251 | "%(default)s)" 252 | ), 253 | ) 254 | 255 | @classmethod 256 | def parse_options(cls, options: Namespace) -> None: # pragma: no cover 257 | """Parse the custom configuration options given to flake8.""" 258 | cls.suppress_none_returning = options.suppress_none_returning 259 | cls.suppress_dummy_args = options.suppress_dummy_args 260 | cls.allow_untyped_defs = options.allow_untyped_defs 261 | cls.allow_untyped_nested = options.allow_untyped_nested 262 | cls.mypy_init_return = options.mypy_init_return 263 | cls.allow_star_arg_any = options.allow_star_arg_any 264 | cls.respect_type_ignore = options.respect_type_ignore 265 | 266 | # Store decorator lists as sets for easier lookup 267 | cls.dispatch_decorators = set(options.dispatch_decorators) 268 | cls.overload_decorators = set(options.overload_decorators) 269 | 270 | 271 | def classify_error(function: Function, arg: Argument) -> error_codes.Error: 272 | """ 273 | Classify the missing type annotation based on the Function & Argument metadata. 274 | 275 | For the currently defined rules & program flow, the assumption can be made that an argument 276 | passed to this method will match a linting error, and will only match a single linting error 277 | 278 | This function provides an initial classificaton, then passes relevant attributes to cached 279 | helper function(s). 280 | """ 281 | # Check for return type 282 | # All return "arguments" have an explicitly defined name "return" 283 | if arg.argname == "return": 284 | error_code = _return_error_classifier( 285 | function.is_class_method, function.class_decorator_type, function.function_type 286 | ) 287 | else: 288 | # Otherwise, classify function argument error 289 | is_first_arg = arg == function.args[0] 290 | error_code = _argument_error_classifier( 291 | function.is_class_method, 292 | is_first_arg, 293 | function.class_decorator_type, 294 | arg.annotation_type, 295 | ) 296 | 297 | return error_code.from_argument(arg) 298 | 299 | 300 | @lru_cache() 301 | def _return_error_classifier( 302 | is_class_method: bool, 303 | class_decorator_type: enums.ClassDecoratorType, 304 | function_type: enums.FunctionType, 305 | ) -> t.Type[error_codes.Error]: 306 | """Classify return type annotation error.""" 307 | # Decorated class methods (@classmethod, @staticmethod) have a higher priority than the rest 308 | if is_class_method: 309 | if class_decorator_type == enums.ClassDecoratorType.CLASSMETHOD: 310 | return error_codes.ANN206 311 | elif class_decorator_type == enums.ClassDecoratorType.STATICMETHOD: 312 | return error_codes.ANN205 313 | 314 | if function_type == enums.FunctionType.SPECIAL: 315 | return error_codes.ANN204 316 | elif function_type == enums.FunctionType.PRIVATE: 317 | return error_codes.ANN203 318 | elif function_type == enums.FunctionType.PROTECTED: 319 | return error_codes.ANN202 320 | else: 321 | return error_codes.ANN201 322 | 323 | 324 | @lru_cache() 325 | def _argument_error_classifier( 326 | is_class_method: bool, 327 | is_first_arg: bool, 328 | class_decorator_type: enums.ClassDecoratorType, 329 | annotation_type: enums.AnnotationType, 330 | ) -> t.Type[error_codes.Error]: 331 | """Classify argument type annotation error.""" 332 | # Check for regular class methods and @classmethod, @staticmethod is deferred to final check 333 | if is_class_method: 334 | # The first function argument here would be an instance of self or class 335 | if is_first_arg: 336 | if class_decorator_type == enums.ClassDecoratorType.CLASSMETHOD: 337 | return error_codes.ANN102 338 | elif class_decorator_type != enums.ClassDecoratorType.STATICMETHOD: 339 | # Regular class method 340 | return error_codes.ANN101 341 | 342 | # Check for remaining codes 343 | if annotation_type == enums.AnnotationType.KWARG: 344 | return error_codes.ANN003 345 | elif annotation_type == enums.AnnotationType.VARARG: 346 | return error_codes.ANN002 347 | else: 348 | # Combine POSONLYARG, ARG, and KWONLYARGS 349 | return error_codes.ANN001 350 | -------------------------------------------------------------------------------- /testing/test_cases/function_parsing_test_cases.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | from textwrap import dedent 3 | from typing import NamedTuple, Tuple 4 | 5 | from flake8_annotations.ast_walker import Function 6 | from flake8_annotations.enums import ClassDecoratorType, FunctionType 7 | 8 | 9 | class FunctionTestCase(NamedTuple): 10 | """Helper container for Function parsing test cases.""" 11 | 12 | src: str 13 | func: Tuple[Function, ...] 14 | 15 | 16 | # Note: For testing purposes, lineno and col_offset are ignored so these are set to dummy values 17 | # using partial objects 18 | nonclass_func = partial( 19 | Function, 20 | lineno=0, 21 | col_offset=0, 22 | is_class_method=False, 23 | class_decorator_type=None, 24 | decorator_list=[], 25 | args=[], 26 | ) 27 | class_func = partial( 28 | Function, lineno=0, col_offset=0, is_class_method=True, decorator_list=[], args=[] 29 | ) 30 | 31 | function_test_cases = { 32 | "public_fun": FunctionTestCase( 33 | src=dedent( 34 | """\ 35 | def foo(): 36 | pass 37 | """ 38 | ), 39 | func=(nonclass_func(name="foo", function_type=FunctionType.PUBLIC),), 40 | ), 41 | "public_fun_return_annotated": FunctionTestCase( 42 | src=dedent( 43 | """\ 44 | def foo() -> None: 45 | pass 46 | """ 47 | ), 48 | func=( 49 | nonclass_func( 50 | name="foo", 51 | function_type=FunctionType.PUBLIC, 52 | is_return_annotated=True, 53 | ), 54 | ), 55 | ), 56 | "protected_fun": FunctionTestCase( 57 | src=dedent( 58 | """\ 59 | def _foo(): 60 | pass 61 | """ 62 | ), 63 | func=(nonclass_func(name="_foo", function_type=FunctionType.PROTECTED),), 64 | ), 65 | "private_fun": FunctionTestCase( 66 | src=dedent( 67 | """\ 68 | def __foo(): 69 | pass 70 | """ 71 | ), 72 | func=(nonclass_func(name="__foo", function_type=FunctionType.PRIVATE),), 73 | ), 74 | "special_fun": FunctionTestCase( 75 | src=dedent( 76 | """\ 77 | def __foo__(): 78 | pass 79 | """ 80 | ), 81 | func=(nonclass_func(name="__foo__", function_type=FunctionType.SPECIAL),), 82 | ), 83 | "async_public_fun": FunctionTestCase( 84 | src=dedent( 85 | """\ 86 | async def foo(): 87 | pass 88 | """ 89 | ), 90 | func=(nonclass_func(name="foo", function_type=FunctionType.PUBLIC),), 91 | ), 92 | "async_public_fun_return_annotated": FunctionTestCase( 93 | src=dedent( 94 | """\ 95 | async def foo() -> None: 96 | pass 97 | """ 98 | ), 99 | func=( 100 | nonclass_func( 101 | name="foo", 102 | function_type=FunctionType.PUBLIC, 103 | is_return_annotated=True, 104 | ), 105 | ), 106 | ), 107 | "async_protected_fun": FunctionTestCase( 108 | src=dedent( 109 | """\ 110 | async def _foo(): 111 | pass 112 | """ 113 | ), 114 | func=(nonclass_func(name="_foo", function_type=FunctionType.PROTECTED),), 115 | ), 116 | "async_private_fun": FunctionTestCase( 117 | src=dedent( 118 | """\ 119 | async def __foo(): 120 | pass 121 | """ 122 | ), 123 | func=(nonclass_func(name="__foo", function_type=FunctionType.PRIVATE),), 124 | ), 125 | "async_special_fun__": FunctionTestCase( 126 | src=dedent( 127 | """\ 128 | async def __foo__(): 129 | pass 130 | """ 131 | ), 132 | func=(nonclass_func(name="__foo__", function_type=FunctionType.SPECIAL),), 133 | ), 134 | "double_nested_public_no_return_annotation": FunctionTestCase( 135 | src=dedent( 136 | """\ 137 | def foo() -> None: 138 | def bar(): 139 | def baz(): 140 | pass 141 | """ 142 | ), 143 | func=( 144 | nonclass_func(name="foo", function_type=FunctionType.PUBLIC, is_return_annotated=True), 145 | nonclass_func(name="bar", function_type=FunctionType.PUBLIC), 146 | nonclass_func(name="baz", function_type=FunctionType.PUBLIC), 147 | ), 148 | ), 149 | "double_nested_public_return_annotation": FunctionTestCase( 150 | src=dedent( 151 | """\ 152 | def foo() -> None: 153 | def bar() -> None: 154 | def baz() -> None: 155 | pass 156 | """ 157 | ), 158 | func=( 159 | nonclass_func(name="foo", function_type=FunctionType.PUBLIC, is_return_annotated=True), 160 | nonclass_func(name="bar", function_type=FunctionType.PUBLIC, is_return_annotated=True), 161 | nonclass_func(name="baz", function_type=FunctionType.PUBLIC, is_return_annotated=True), 162 | ), 163 | ), 164 | "double_nested_public_async_no_return_annotation": FunctionTestCase( 165 | src=dedent( 166 | """\ 167 | async def foo() -> None: 168 | async def bar(): 169 | async def baz(): 170 | pass 171 | """ 172 | ), 173 | func=( 174 | nonclass_func(name="foo", function_type=FunctionType.PUBLIC, is_return_annotated=True), 175 | nonclass_func(name="bar", function_type=FunctionType.PUBLIC), 176 | nonclass_func(name="baz", function_type=FunctionType.PUBLIC), 177 | ), 178 | ), 179 | "double_nested_public_async_return_annotation": FunctionTestCase( 180 | src=dedent( 181 | """\ 182 | async def foo() -> None: 183 | async def bar() -> None: 184 | async def baz() -> None: 185 | pass 186 | """ 187 | ), 188 | func=( 189 | nonclass_func(name="foo", function_type=FunctionType.PUBLIC, is_return_annotated=True), 190 | nonclass_func(name="bar", function_type=FunctionType.PUBLIC, is_return_annotated=True), 191 | nonclass_func(name="baz", function_type=FunctionType.PUBLIC, is_return_annotated=True), 192 | ), 193 | ), 194 | "decorated_noncallable_method": FunctionTestCase( 195 | src=dedent( 196 | """\ 197 | class Foo: 198 | @some_decorator 199 | def foo(self): 200 | pass 201 | """ 202 | ), 203 | func=( 204 | class_func( 205 | name="foo", 206 | function_type=FunctionType.PUBLIC, 207 | class_decorator_type=None, 208 | ), 209 | ), 210 | ), 211 | "decorated_callable_method": FunctionTestCase( 212 | src=dedent( 213 | """\ 214 | class Foo: 215 | @some_decorator() 216 | def foo(self): 217 | pass 218 | """ 219 | ), 220 | func=( 221 | class_func( 222 | name="foo", 223 | function_type=FunctionType.PUBLIC, 224 | class_decorator_type=None, 225 | ), 226 | ), 227 | ), 228 | "decorated_noncallable_async_method": FunctionTestCase( 229 | src=dedent( 230 | """\ 231 | class Foo: 232 | @some_decorator 233 | async def foo(self): 234 | pass 235 | """ 236 | ), 237 | func=( 238 | class_func( 239 | name="foo", 240 | function_type=FunctionType.PUBLIC, 241 | class_decorator_type=None, 242 | ), 243 | ), 244 | ), 245 | "decorated_callable_async_method": FunctionTestCase( 246 | src=dedent( 247 | """\ 248 | class Foo: 249 | @some_decorator() 250 | async def foo(self): 251 | pass 252 | """ 253 | ), 254 | func=( 255 | class_func( 256 | name="foo", 257 | function_type=FunctionType.PUBLIC, 258 | class_decorator_type=None, 259 | ), 260 | ), 261 | ), 262 | "decorated_classmethod": FunctionTestCase( 263 | src=dedent( 264 | """\ 265 | class Foo: 266 | @classmethod 267 | def foo(cls): 268 | pass 269 | """ 270 | ), 271 | func=( 272 | class_func( 273 | name="foo", 274 | function_type=FunctionType.PUBLIC, 275 | class_decorator_type=ClassDecoratorType.CLASSMETHOD, 276 | ), 277 | ), 278 | ), 279 | "decorated_staticmethod": FunctionTestCase( 280 | src=dedent( 281 | """\ 282 | class Foo: 283 | @staticmethod 284 | def foo(): 285 | pass 286 | """ 287 | ), 288 | func=( 289 | class_func( 290 | name="foo", 291 | function_type=FunctionType.PUBLIC, 292 | class_decorator_type=ClassDecoratorType.STATICMETHOD, 293 | ), 294 | ), 295 | ), 296 | "decorated_async_classmethod": FunctionTestCase( 297 | src=dedent( 298 | """\ 299 | class Foo: 300 | @classmethod 301 | async def foo(cls): 302 | pass 303 | """ 304 | ), 305 | func=( 306 | class_func( 307 | name="foo", 308 | function_type=FunctionType.PUBLIC, 309 | class_decorator_type=ClassDecoratorType.CLASSMETHOD, 310 | ), 311 | ), 312 | ), 313 | "decorated_async_staticmethod": FunctionTestCase( 314 | src=dedent( 315 | """\ 316 | class Foo: 317 | @staticmethod 318 | async def foo(): 319 | pass 320 | """ 321 | ), 322 | func=( 323 | class_func( 324 | name="foo", 325 | function_type=FunctionType.PUBLIC, 326 | class_decorator_type=ClassDecoratorType.STATICMETHOD, 327 | ), 328 | ), 329 | ), 330 | "decorated_noncallable_classmethod": FunctionTestCase( 331 | src=dedent( 332 | """\ 333 | class Foo: 334 | @some_decorator 335 | @classmethod 336 | def foo(cls): 337 | pass 338 | """ 339 | ), 340 | func=( 341 | class_func( 342 | name="foo", 343 | function_type=FunctionType.PUBLIC, 344 | class_decorator_type=ClassDecoratorType.CLASSMETHOD, 345 | ), 346 | ), 347 | ), 348 | "decorated_callable_classmethod": FunctionTestCase( 349 | src=dedent( 350 | """\ 351 | class Foo: 352 | @some_decorator() 353 | @classmethod 354 | def foo(cls): 355 | pass 356 | """ 357 | ), 358 | func=( 359 | class_func( 360 | name="foo", 361 | function_type=FunctionType.PUBLIC, 362 | class_decorator_type=ClassDecoratorType.CLASSMETHOD, 363 | ), 364 | ), 365 | ), 366 | "decorated_noncallable_staticmethod": FunctionTestCase( 367 | src=dedent( 368 | """\ 369 | class Foo: 370 | @some_decorator 371 | @staticmethod 372 | def foo(): 373 | pass 374 | """ 375 | ), 376 | func=( 377 | class_func( 378 | name="foo", 379 | function_type=FunctionType.PUBLIC, 380 | class_decorator_type=ClassDecoratorType.STATICMETHOD, 381 | ), 382 | ), 383 | ), 384 | "decorated_callable_staticmethod": FunctionTestCase( 385 | src=dedent( 386 | """\ 387 | class Foo: 388 | @some_decorator() 389 | @staticmethod 390 | def foo(): 391 | pass 392 | """ 393 | ), 394 | func=( 395 | class_func( 396 | name="foo", 397 | function_type=FunctionType.PUBLIC, 398 | class_decorator_type=ClassDecoratorType.STATICMETHOD, 399 | ), 400 | ), 401 | ), 402 | "decorated_noncallable_async_classmethod": FunctionTestCase( 403 | src=dedent( 404 | """\ 405 | class Foo: 406 | @some_decorator 407 | @classmethod 408 | async def foo(cls): 409 | pass 410 | """ 411 | ), 412 | func=( 413 | class_func( 414 | name="foo", 415 | function_type=FunctionType.PUBLIC, 416 | class_decorator_type=ClassDecoratorType.CLASSMETHOD, 417 | ), 418 | ), 419 | ), 420 | "decorated_callable_async_classmethod": FunctionTestCase( 421 | src=dedent( 422 | """\ 423 | class Foo: 424 | @some_decorator() 425 | @classmethod 426 | async def foo(cls): 427 | pass 428 | """ 429 | ), 430 | func=( 431 | class_func( 432 | name="foo", 433 | function_type=FunctionType.PUBLIC, 434 | class_decorator_type=ClassDecoratorType.CLASSMETHOD, 435 | ), 436 | ), 437 | ), 438 | "decorated_noncallable_async_staticmethod": FunctionTestCase( 439 | src=dedent( 440 | """\ 441 | class Foo: 442 | @some_decorator 443 | @staticmethod 444 | async def foo(): 445 | pass 446 | """ 447 | ), 448 | func=( 449 | class_func( 450 | name="foo", 451 | function_type=FunctionType.PUBLIC, 452 | class_decorator_type=ClassDecoratorType.STATICMETHOD, 453 | ), 454 | ), 455 | ), 456 | "decorated_callable_async_staticmethod": FunctionTestCase( 457 | src=dedent( 458 | """\ 459 | class Foo: 460 | @some_decorator() 461 | @staticmethod 462 | async def foo(): 463 | pass 464 | """ 465 | ), 466 | func=( 467 | class_func( 468 | name="foo", 469 | function_type=FunctionType.PUBLIC, 470 | class_decorator_type=ClassDecoratorType.STATICMETHOD, 471 | ), 472 | ), 473 | ), 474 | "double_nested_method": FunctionTestCase( 475 | src=dedent( 476 | """\ 477 | class Foo: 478 | def foo(self) -> None: 479 | def bar(): 480 | def baz(): 481 | pass 482 | """ 483 | ), 484 | func=( 485 | class_func( 486 | name="foo", 487 | function_type=FunctionType.PUBLIC, 488 | class_decorator_type=None, 489 | is_return_annotated=True, 490 | ), 491 | nonclass_func(name="bar", function_type=FunctionType.PUBLIC), 492 | nonclass_func(name="baz", function_type=FunctionType.PUBLIC), 493 | ), 494 | ), 495 | "double_nested_async_method": FunctionTestCase( 496 | src=dedent( 497 | """\ 498 | class Foo: 499 | async def foo(self) -> None: 500 | async def bar(): 501 | async def baz(): 502 | pass 503 | """ 504 | ), 505 | func=( 506 | class_func( 507 | name="foo", 508 | function_type=FunctionType.PUBLIC, 509 | class_decorator_type=None, 510 | is_return_annotated=True, 511 | ), 512 | nonclass_func(name="bar", function_type=FunctionType.PUBLIC), 513 | nonclass_func(name="baz", function_type=FunctionType.PUBLIC), 514 | ), 515 | ), 516 | "nested_classes": FunctionTestCase( 517 | src=dedent( 518 | """\ 519 | class Foo: 520 | class Bar: 521 | def bar_method(self): 522 | pass 523 | 524 | def foo_method(self): 525 | pass 526 | """ 527 | ), 528 | func=( 529 | class_func(name="bar_method", function_type=FunctionType.PUBLIC), 530 | class_func(name="foo_method", function_type=FunctionType.PUBLIC), 531 | ), 532 | ), 533 | "overload_decorated_non_alias": FunctionTestCase( 534 | src=dedent( 535 | """\ 536 | @overload 537 | def foo(): 538 | pass 539 | """ 540 | ), 541 | func=( 542 | nonclass_func( 543 | name="foo", 544 | function_type=FunctionType.PUBLIC, 545 | ), 546 | ), 547 | ), 548 | "overload_decorated_attribute": FunctionTestCase( 549 | src=dedent( 550 | """\ 551 | @typing.overload 552 | def foo(): 553 | pass 554 | """ 555 | ), 556 | func=( 557 | nonclass_func( 558 | name="foo", 559 | function_type=FunctionType.PUBLIC, 560 | ), 561 | ), 562 | ), 563 | } 564 | -------------------------------------------------------------------------------- /flake8_annotations/ast_walker.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import ast 4 | import typing as t 5 | from dataclasses import dataclass 6 | 7 | from flake8_annotations.enums import AnnotationType, ClassDecoratorType, FunctionType 8 | 9 | AST_DECORATOR_NODES = t.Union[ast.Attribute, ast.Call, ast.Name] 10 | AST_DEF_NODES = t.Union[ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef] 11 | AST_FUNCTION_TYPES = t.Union[ast.FunctionDef, ast.AsyncFunctionDef] 12 | 13 | # The order of AST_ARG_TYPES must match Python's grammar 14 | # See: https://docs.python.org/3/library/ast.html#abstract-grammar 15 | AST_ARG_TYPES: t.Tuple[str, ...] = ("posonlyargs", "args", "vararg", "kwonlyargs", "kwarg") 16 | 17 | 18 | @dataclass(slots=True) 19 | class Argument: 20 | """Represent a function argument & its metadata.""" 21 | 22 | argname: str 23 | lineno: int 24 | col_offset: int 25 | annotation_type: AnnotationType 26 | has_type_annotation: bool = False 27 | has_type_comment: bool = False 28 | is_dynamically_typed: bool = False 29 | 30 | def __str__(self) -> str: 31 | """ 32 | Format the Argument object into a readable representation. 33 | 34 | The output string will be formatted as: 35 | ', Annotated: >' 36 | """ 37 | return f"" 38 | 39 | @classmethod 40 | def from_arg_node(cls, node: ast.arg, annotation_type_name: str) -> Argument: 41 | """Create an Argument object from an ast.arguments node.""" 42 | annotation_type = AnnotationType[annotation_type_name] 43 | new_arg = cls(node.arg, node.lineno, node.col_offset, annotation_type) 44 | 45 | if node.annotation: 46 | new_arg.has_type_annotation = True 47 | 48 | if cls._is_annotated_any(node.annotation): 49 | new_arg.is_dynamically_typed = True 50 | 51 | if node.type_comment: 52 | new_arg.has_type_comment = True 53 | 54 | return new_arg 55 | 56 | @staticmethod 57 | def _is_annotated_any(arg_expr: ast.expr) -> bool: 58 | """ 59 | Check if the provided expression node is annotated with `typing.Any`. 60 | 61 | Support is provided for the following patterns: 62 | * `from typing import Any; foo: Any` 63 | * `import typing; foo: typing.Any` 64 | * `import typing as ; foo: .Any` 65 | """ 66 | if isinstance(arg_expr, ast.Name): 67 | if arg_expr.id == "Any": 68 | return True 69 | elif isinstance(arg_expr, ast.Attribute): 70 | if arg_expr.attr == "Any": 71 | return True 72 | 73 | return False 74 | 75 | 76 | @dataclass(slots=True) 77 | class Function: 78 | """ 79 | Represent a function and its relevant metadata. 80 | 81 | Note: while Python differentiates between a function and a method, for the purposes of this 82 | tool, both will be referred to as functions outside of any class-specific context. This also 83 | aligns with ast's naming convention. 84 | """ 85 | 86 | name: str 87 | lineno: int 88 | col_offset: int 89 | decorator_list: t.List[AST_DECORATOR_NODES] 90 | args: t.List[Argument] 91 | function_type: FunctionType = FunctionType.PUBLIC 92 | is_class_method: bool = False 93 | class_decorator_type: t.Union[ClassDecoratorType, None] = None 94 | is_return_annotated: bool = False 95 | has_type_comment: bool = False 96 | has_only_none_returns: bool = True 97 | is_nested: bool = False 98 | 99 | def is_fully_annotated(self) -> bool: 100 | """ 101 | Check that all of the function's inputs are type annotated. 102 | 103 | Note that self.args will always include an Argument object for return 104 | """ 105 | return all(arg.has_type_annotation for arg in self.args) 106 | 107 | def is_dynamically_typed(self) -> bool: 108 | """Determine if the function is dynamically typed, defined as completely lacking hints.""" 109 | return not any(arg.has_type_annotation for arg in self.args) 110 | 111 | def get_missed_annotations(self) -> t.List[Argument]: 112 | """Provide a list of arguments with missing type annotations.""" 113 | return [arg for arg in self.args if not arg.has_type_annotation] 114 | 115 | def get_annotated_arguments(self) -> t.List[Argument]: 116 | """Provide a list of arguments with type annotations.""" 117 | return [arg for arg in self.args if arg.has_type_annotation] 118 | 119 | def has_decorator(self, check_decorators: t.Set[str]) -> bool: 120 | """ 121 | Determine whether the function node is decorated by any of the provided decorators. 122 | 123 | Decorator matching is done against the provided `check_decorators` set, allowing the user 124 | to specify any expected aliasing in the relevant flake8 configuration option. Decorators are 125 | assumed to be either a module attribute (e.g. `@typing.overload`) or name 126 | (e.g. `@overload`). For the case of a module attribute, only the attribute is checked 127 | against `overload_decorators`. 128 | 129 | NOTE: Deeper decorator imports (e.g. `a.b.overload`) are not explicitly supported 130 | """ 131 | for decorator in self.decorator_list: 132 | # Drop to a helper to allow for simpler handling of callable decorators 133 | return self._decorator_checker(decorator, check_decorators) 134 | else: 135 | return False 136 | 137 | def _decorator_checker( 138 | self, decorator: AST_DECORATOR_NODES, check_decorators: t.Set[str] 139 | ) -> bool: 140 | """ 141 | Check the provided decorator for a match against the provided set of check names. 142 | 143 | Decorators are assumed to be of the following form: 144 | * `a.name` or `a.name()` 145 | * `name` or `name()` 146 | 147 | NOTE: Deeper imports (e.g. `a.b.name`) are not explicitly supported. 148 | """ 149 | if isinstance(decorator, ast.Name): 150 | # e.g. `@overload`, where `decorator.id` will be the name 151 | if decorator.id in check_decorators: 152 | return True 153 | elif isinstance(decorator, ast.Attribute): 154 | # e.g. `@typing.overload`, where `decorator.attr` will be the name 155 | if decorator.attr in check_decorators: 156 | return True 157 | elif isinstance(decorator, ast.Call): # pragma: no branch 158 | # e.g. `@overload()` or `@typing.overload()`, where `decorator.func` will be `ast.Name` 159 | # or `ast.Attribute`, which we can check recursively 160 | # Ignore typing here, the AST stub just uses `expr` as the type for `decorator.func` 161 | return self._decorator_checker(decorator.func, check_decorators) # type: ignore[arg-type] # noqa: E501 162 | 163 | # There shouldn't be any possible way to get here 164 | return False # pragma: no cover 165 | 166 | def __str__(self) -> str: 167 | """ 168 | Format the Function object into a readable representation. 169 | 170 | The output string will be formatted as: 171 | ', Args: >' 172 | """ 173 | # Manually join the list so we get Argument's __str__ instead of __repr__ 174 | # Function will always have a list of at least one Argument ("return" is always added) 175 | str_args = f"[{', '.join([str(arg) for arg in self.args])}]" 176 | 177 | return f"" 178 | 179 | @classmethod 180 | def from_function_node( 181 | cls, node: AST_FUNCTION_TYPES, lines: t.List[str], **kwargs: t.Any 182 | ) -> Function: 183 | """ 184 | Create an Function object from ast.FunctionDef or ast.AsyncFunctionDef nodes. 185 | 186 | Accept the source code, as a list of strings, in order to get the column where the function 187 | definition ends. 188 | 189 | With exceptions, input kwargs are passed straight through to Function's __init__. The 190 | following kwargs will be overridden: 191 | * function_type 192 | * class_decorator_type 193 | * args 194 | """ 195 | # Extract function types from function name 196 | kwargs["function_type"] = cls.get_function_type(node.name) 197 | 198 | # Identify type of class method, if applicable 199 | if kwargs.get("is_class_method", False): 200 | kwargs["class_decorator_type"] = cls.get_class_decorator_type(node) 201 | 202 | # Store raw decorator list for use by property methods 203 | kwargs["decorator_list"] = node.decorator_list 204 | 205 | # Instantiate empty args list here since it has no default (mutable defaults bad!) 206 | kwargs["args"] = [] 207 | 208 | new_function = cls(node.name, node.lineno, node.col_offset, **kwargs) 209 | 210 | # Iterate over arguments by type & add 211 | for arg_type in AST_ARG_TYPES: 212 | args = node.args.__getattribute__(arg_type) 213 | if args: 214 | if not isinstance(args, list): 215 | args = [args] 216 | 217 | new_function.args.extend( 218 | [Argument.from_arg_node(arg, arg_type.upper()) for arg in args] 219 | ) 220 | 221 | # Create an Argument object for the return hint 222 | def_end_lineno, def_end_col_offset = cls.colon_seeker(node, lines) 223 | return_arg = Argument("return", def_end_lineno, def_end_col_offset, AnnotationType.RETURN) 224 | if node.returns: 225 | return_arg.has_type_annotation = True 226 | new_function.is_return_annotated = True 227 | 228 | if Argument._is_annotated_any(node.returns): 229 | return_arg.is_dynamically_typed = True 230 | 231 | new_function.args.append(return_arg) 232 | 233 | if node.type_comment: 234 | new_function.has_type_comment = True 235 | 236 | # Check for the presence of non-`None` returns using the special-case return node visitor 237 | return_visitor = ReturnVisitor(node) 238 | return_visitor.visit(node) 239 | new_function.has_only_none_returns = return_visitor.has_only_none_returns 240 | 241 | return new_function 242 | 243 | @staticmethod 244 | def colon_seeker(node: AST_FUNCTION_TYPES, lines: t.List[str]) -> t.Tuple[int, int]: 245 | """ 246 | Find the line & column indices of the function definition's closing colon. 247 | 248 | For Python >= 3.8, docstrings are contained in the body of the function node. 249 | 250 | NOTE: AST's line numbers are 1-indexed, column offsets are 0-indexed. Since `lines` is a 251 | list, it will be 0-indexed. 252 | """ 253 | # Special case single line function definitions 254 | if node.lineno == node.body[0].lineno: 255 | return Function._single_line_colon_seeker(node, lines[node.lineno - 1]) 256 | 257 | # Once we've gotten here, we've found the line where the docstring begins, so we have 258 | # to step up one more line to get to the close of the def 259 | def_end_lineno = node.body[0].lineno 260 | def_end_lineno -= 1 261 | 262 | # Use str.rfind() to account for annotations on the same line, definition closure should 263 | # be the last : on the line 264 | def_end_col_offset = lines[def_end_lineno - 1].rfind(":") 265 | 266 | return def_end_lineno, def_end_col_offset 267 | 268 | @staticmethod 269 | def _single_line_colon_seeker(node: AST_FUNCTION_TYPES, line: str) -> t.Tuple[int, int]: 270 | """Locate the closing colon for a single-line function definition.""" 271 | col_start = node.col_offset 272 | col_end = node.body[0].col_offset 273 | def_end_col_offset = line.rfind(":", col_start, col_end) 274 | 275 | return node.lineno, def_end_col_offset 276 | 277 | @staticmethod 278 | def get_function_type(function_name: str) -> FunctionType: 279 | """ 280 | Determine the function's FunctionType from its name. 281 | 282 | MethodType is determined by the following priority: 283 | 1. Special: function name prefixed & suffixed by "__" 284 | 2. Private: function name prefixed by "__" 285 | 3. Protected: function name prefixed by "_" 286 | 4. Public: everything else 287 | """ 288 | if function_name.startswith("__") and function_name.endswith("__"): 289 | return FunctionType.SPECIAL 290 | elif function_name.startswith("__"): 291 | return FunctionType.PRIVATE 292 | elif function_name.startswith("_"): 293 | return FunctionType.PROTECTED 294 | else: 295 | return FunctionType.PUBLIC 296 | 297 | @staticmethod 298 | def get_class_decorator_type( 299 | function_node: AST_FUNCTION_TYPES, 300 | ) -> t.Union[ClassDecoratorType, None]: 301 | """ 302 | Get the class method's decorator type from its function node. 303 | 304 | For the purposes of this tool, only @classmethod and @staticmethod decorators are 305 | identified; all other decorators are ignored 306 | 307 | If @classmethod or @staticmethod decorators are not present, this function will return None 308 | """ 309 | # @classmethod and @staticmethod will show up as ast.Name objects, where callable decorators 310 | # will show up as ast.Call, which we can ignore 311 | decorators = [ 312 | decorator.id 313 | for decorator in function_node.decorator_list 314 | if isinstance(decorator, ast.Name) 315 | ] 316 | 317 | if "classmethod" in decorators: 318 | return ClassDecoratorType.CLASSMETHOD 319 | elif "staticmethod" in decorators: 320 | return ClassDecoratorType.STATICMETHOD 321 | else: 322 | return None 323 | 324 | 325 | class FunctionVisitor(ast.NodeVisitor): 326 | """An ast.NodeVisitor instance for walking the AST and describing all contained functions.""" 327 | 328 | AST_FUNC_TYPES = (ast.FunctionDef, ast.AsyncFunctionDef) 329 | 330 | def __init__(self, lines: t.List[str]): 331 | self.lines = lines 332 | self.function_definitions: t.List[Function] = [] 333 | self._context: t.List[AST_DEF_NODES] = [] 334 | 335 | def switch_context(self, node: AST_DEF_NODES) -> None: 336 | """ 337 | Utilize a context switcher as a generic function visitor in order to track function context. 338 | 339 | Without keeping track of context, it's challenging to reliably differentiate class methods 340 | from "regular" functions, especially in the case of nested classes. 341 | 342 | Thank you for the inspiration @isidentical :) 343 | """ 344 | if isinstance(node, self.AST_FUNC_TYPES): 345 | # Check for non-empty context first to prevent IndexErrors for non-nested nodes 346 | if self._context: 347 | if isinstance(self._context[-1], ast.ClassDef): 348 | # Check if current context is a ClassDef node & pass the appropriate flag 349 | self.function_definitions.append( 350 | Function.from_function_node(node, self.lines, is_class_method=True) 351 | ) 352 | elif isinstance(self._context[-1], self.AST_FUNC_TYPES): # pragma: no branch 353 | # Check for nested function & pass the appropriate flag 354 | self.function_definitions.append( 355 | Function.from_function_node(node, self.lines, is_nested=True) 356 | ) 357 | else: 358 | self.function_definitions.append(Function.from_function_node(node, self.lines)) 359 | 360 | self._context.append(node) 361 | self.generic_visit(node) 362 | self._context.pop() 363 | 364 | visit_FunctionDef = switch_context 365 | visit_AsyncFunctionDef = switch_context 366 | visit_ClassDef = switch_context 367 | 368 | 369 | class ReturnVisitor(ast.NodeVisitor): 370 | """ 371 | Special-case of `ast.NodeVisitor` for visiting return statements of a function node. 372 | 373 | If the function node being visited has an explicit return statement of anything other than 374 | `None`, the `instance.has_only_none_returns` flag will be set to `False`. 375 | 376 | If the function node being visited has no return statement, or contains only return 377 | statement(s) that explicitly return `None`, the `instance.has_only_none_returns` flag will be 378 | set to `True`. 379 | 380 | Due to the generic visiting being done, we need to keep track of the context in which a 381 | non-`None` return node is found. These functions are added to a set that is checked to see 382 | whether nor not the parent node is present. 383 | """ 384 | 385 | def __init__(self, parent_node: AST_FUNCTION_TYPES): 386 | self.parent_node = parent_node 387 | self._context: t.List[AST_FUNCTION_TYPES] = [] 388 | self._non_none_return_nodes: t.Set[AST_FUNCTION_TYPES] = set() 389 | 390 | @property 391 | def has_only_none_returns(self) -> bool: 392 | """Return `True` if the parent node isn't in the visited nodes that don't return `None`.""" 393 | return self.parent_node not in self._non_none_return_nodes 394 | 395 | def visit_Return(self, node: ast.Return) -> None: 396 | """ 397 | Check each Return node to see if it returns anything other than `None`. 398 | 399 | If the node being visited returns anything other than `None`, its parent context is added to 400 | the set of non-returning child nodes of the parent node. 401 | """ 402 | if node.value is not None: 403 | # In the event of an explicit `None` return (`return None`), the node body will be an 404 | # instance of `ast.Constant`, which we need to check to see if it's actually `None` 405 | if isinstance(node.value, ast.Constant): # pragma: no branch 406 | if node.value.value is None: 407 | return 408 | 409 | self._non_none_return_nodes.add(self._context[-1]) 410 | 411 | def switch_context(self, node: AST_FUNCTION_TYPES) -> None: 412 | """ 413 | Utilize a context switcher as a generic visitor in order to properly track function context. 414 | 415 | Using a traditional `ast.generic_visit` setup, return nodes of nested functions are visited 416 | without any knowledge of their context, causing the top-level function to potentially be 417 | mis-classified. 418 | 419 | Thank you for the inspiration @isidentical :) 420 | """ 421 | self._context.append(node) 422 | self.generic_visit(node) 423 | self._context.pop() 424 | 425 | visit_FunctionDef = switch_context 426 | visit_AsyncFunctionDef = switch_context 427 | --------------------------------------------------------------------------------