├── .github └── workflows │ └── main.yml ├── .gitignore ├── .markdownlint.yaml ├── LICENSE ├── README.md ├── Taskfile.yml ├── flake8_warnings ├── __init__.py ├── __main__.py ├── _cli.py ├── _extractors │ ├── __init__.py │ ├── _base.py │ ├── _decorators.py │ ├── _docstrings.py │ ├── _stdlib.py │ └── _warnings.py ├── _finder.py ├── _flake8_plugin.py └── _pylint_plugin.py ├── pyproject.toml ├── setup.cfg └── tests ├── __init__.py ├── samples ├── warnings_function.py └── warnings_module.py ├── test_extractors ├── __init__.py ├── helpers.py ├── test_base.py ├── test_common.py ├── test_decorators.py └── test_warnings.py └── test_finder.py /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: main 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | workflow_dispatch: 9 | 10 | concurrency: 11 | group: ${{ github.head_ref || github.run_id }} 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | lint: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v3 19 | - uses: actions/setup-python@v4 20 | with: 21 | python-version: "3.8" 22 | - uses: arduino/setup-task@v1 23 | with: 24 | repo-token: ${{ github.token }} 25 | - run: task lint 26 | 27 | test: 28 | runs-on: ubuntu-latest 29 | strategy: 30 | fail-fast: false 31 | matrix: 32 | python-version: 33 | - "3.8" 34 | - "3.9" 35 | - "3.10" 36 | - "3.11" 37 | # - "3.12.0-rc.3" 38 | steps: 39 | - uses: actions/checkout@v3 40 | - uses: actions/setup-python@v4 41 | with: 42 | python-version: ${{ matrix.python-version }} 43 | - uses: arduino/setup-task@v1 44 | with: 45 | repo-token: ${{ github.token }} 46 | - run: task test 47 | 48 | markdownlint-cli: 49 | runs-on: ubuntu-latest 50 | steps: 51 | - uses: actions/checkout@v3 52 | - uses: nosborn/github-action-markdown-cli@v3.2.0 53 | with: 54 | files: . 55 | config_file: .markdownlint.yaml 56 | dot: true 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | /.coverage 3 | /htmlcov/ 4 | .*_cache/ 5 | /tmp.py 6 | /dist/ 7 | /.task/ 8 | /.venvs/ 9 | -------------------------------------------------------------------------------- /.markdownlint.yaml: -------------------------------------------------------------------------------- 1 | # https://github.com/DavidAnson/markdownlint/blob/main/schema/.markdownlint.yaml 2 | default: true # enable all by default 3 | MD007: # unordered list indentation 4 | indent: 2 5 | MD013: false # do not validate line length 6 | MD014: false # allow $ before command output 7 | MD029: # ordered list prefix 8 | style: "one" 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | 2022 Gram 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 flake8_warnings and this permission flake8_warnings shall be included in 13 | all 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # flake8-warnings 2 | 3 | Python linter that warns you about using deprecated modules, classes, and functions. It provides a CLI as well as [flake8][flake8] and [pylint][pylint] plugins. 4 | 5 | ## Usage 6 | 7 | Installation: 8 | 9 | ```bash 10 | python3 -m pip install flake8-warnings 11 | ``` 12 | 13 | Now, you can use it in one of the following ways: 14 | 15 | 1. Directly from CLI: `python3 -m flake8_warnings ./my_project/` 16 | 1. As a [flake8][flake8] plugin. Just run `flake8 ./my_project/`, it will automatically detect the plugin. 17 | 1. As a [pylint][pylint] plugin. For pylint, plugins must be explicitly specified: `pylint --load-plugins=flake8_warnings ./my_project/`. 18 | 19 | [flake8]: https://flake8.pycqa.org/en/latest/ 20 | [pylint]: https://pylint.org/ 21 | 22 | ## How it works 23 | 24 | It analyzes all imported modules, classes and functions and detects the following: 25 | 26 | 1. [warnings.warn](https://docs.python.org/3/library/warnings.html#warnings.warn) function calls. 27 | 1. Deprecation decorators like [deprecated](https://github.com/tantale/deprecated) or [deprecation](https://github.com/briancurtin/deprecation). 28 | 1. Deprecation messages in docstrings. 29 | 1. Stdlib modules deprecated by [PEP 594](https://peps.python.org/pep-0594/). 30 | 31 | ## Error codes 32 | 33 | The tool provides a different error code for each [warning category](https://docs.python.org/3/library/warnings.html#warning-categories): 34 | 35 | + 01: Warning 36 | + 02: UserWarning 37 | + 03: DeprecationWarning 38 | + 04: SyntaxWarning 39 | + 05: RuntimeWarning 40 | + 06: FutureWarning 41 | + 07: PendingDeprecationWarning 42 | + 08: ImportWarning 43 | + 09: UnicodeWarning 44 | + 10: BytesWarning 45 | + 11: ResourceWarning 46 | 47 | This is how they are used in different linters: 48 | 49 | + In flake8, the code prefix is `WS0`, so `DeprecationWarning` will be reported as `WS003`. 50 | + In pylint, the prefix is `W99`, so `DeprecationWarning` will be reported as `W9903`. The "message-symbol" is the warning category. So, if you want to ignore an error about `DeprecationWarning`, add `# pylint: disable=DeprecationWarning` to this line. 51 | + If you use CLI, the warning category will be shown you directly, without any obscure codes. 52 | 53 | In all cases, the error message is the detected warning message. 54 | 55 | ## License 56 | 57 | 1. flake8-wranings is licensed under [MIT License](./LICENSE). On practice, I don't care how you're going to use it. i did the project because it is fun, not because I want to be famous or whatever. 58 | 1. [astroid](https://github.com/PyCQA/astroid) is a direct runtime dependency of flake8-warning and it is licensed under [LGPL-2.1 License](https://github.com/PyCQA/astroid/blob/main/LICENSE). It allows commercial and private usage, distribution and whatever, don't confuse it with GPL. However, if your legal department is still nervous, just don't make flake8-warnings a production dependency (why would you?), use it only on dev and test environments. 59 | -------------------------------------------------------------------------------- /Taskfile.yml: -------------------------------------------------------------------------------- 1 | # https://taskfile.dev/ 2 | version: "3" 3 | 4 | vars: 5 | PYTHON: python3 6 | VENVS: .venvs 7 | TEST_ENV: .venvs/test 8 | LINT_ENV: .venvs/lint 9 | TEST_PYTHON: "{{.TEST_ENV}}/bin/python3" 10 | LINT_PYTHON: "{{.LINT_ENV}}/bin/python3" 11 | 12 | env: 13 | FLIT_ROOT_INSTALL: "1" 14 | 15 | tasks: 16 | install:flit: 17 | status: 18 | - which flit 19 | cmds: 20 | - python3 -m pip install flit 21 | venv:test: 22 | status: 23 | - test -d {{.TEST_ENV}} 24 | cmds: 25 | - "{{.PYTHON}} -m venv {{.TEST_ENV}}" 26 | venv:lint: 27 | status: 28 | - test -d {{.LINT_ENV}} 29 | cmds: 30 | - "{{.PYTHON}} -m venv {{.LINT_ENV}}" 31 | install:test: 32 | sources: 33 | - pyproject.toml 34 | deps: 35 | - install:flit 36 | - venv:test 37 | cmds: 38 | - > 39 | flit install 40 | --python {{.TEST_PYTHON}} 41 | --extras=test,integrations 42 | --deps=production 43 | --symlink 44 | install:lint: 45 | sources: 46 | - pyproject.toml 47 | deps: 48 | - install:flit 49 | - venv:lint 50 | cmds: 51 | - > 52 | flit install 53 | --python {{.LINT_PYTHON}} 54 | --extras=lint,integrations 55 | --deps=production 56 | --symlink 57 | 58 | release: 59 | desc: generate and upload a new release 60 | deps: 61 | - install:flit 62 | cmds: 63 | - which gh 64 | - test {{.CLI_ARGS}} 65 | - cat flake8_warnings/__init__.py | grep {{.CLI_ARGS}} 66 | - rm -rf dist/ 67 | - flit build 68 | - flit publish 69 | - git tag {{.CLI_ARGS}} 70 | - git push 71 | - git push --tags 72 | - gh release create --generate-notes {{.CLI_ARGS}} 73 | - gh release upload {{.CLI_ARGS}} ./dist/* 74 | 75 | pytest: 76 | desc: "run Python tests" 77 | deps: 78 | - install:test 79 | cmds: 80 | - "{{.TEST_PYTHON}} -m pytest {{.CLI_ARGS}}" 81 | flake8: 82 | desc: "lint Python code" 83 | deps: 84 | - install:lint 85 | cmds: 86 | - "{{.LINT_PYTHON}} -m flake8 {{.CLI_ARGS}} ." 87 | mypy: 88 | desc: "check type annotations" 89 | deps: 90 | - install:lint 91 | cmds: 92 | - "{{.LINT_PYTHON}} -m mypy {{.CLI_ARGS}}" 93 | unify: 94 | desc: "convert double quotes to single ones" 95 | deps: 96 | - install:lint 97 | cmds: 98 | - "{{.LINT_PYTHON}} -m unify -r -i --quote=\\' {{.CLI_ARGS}} flake8_warnings tests" 99 | isort: 100 | desc: "sort imports" 101 | deps: 102 | - install:lint 103 | cmds: 104 | - "{{.LINT_PYTHON}} -m isort {{.CLI_ARGS}} ." 105 | isort:check: 106 | desc: "sort imports" 107 | deps: 108 | - install:lint 109 | cmds: 110 | - "{{.LINT_PYTHON}} -m isort --check {{.CLI_ARGS}} ." 111 | 112 | # groups 113 | format: 114 | desc: "run all code formatters" 115 | cmds: 116 | - task: isort 117 | - task: unify 118 | lint: 119 | desc: "run all linters" 120 | cmds: 121 | - task: flake8 122 | - task: mypy 123 | - task: isort:check 124 | test: 125 | desc: "run all tests" 126 | cmds: 127 | - task: pytest 128 | all: 129 | desc: "run all code formatters, linters, and tests" 130 | cmds: 131 | - task: format 132 | - task: lint 133 | - task: test 134 | -------------------------------------------------------------------------------- /flake8_warnings/__init__.py: -------------------------------------------------------------------------------- 1 | """Linter (flake8, pylint, custom CLI) for finding usage of deprecated functions. 2 | """ 3 | 4 | from ._flake8_plugin import Flake8Checker 5 | from ._pylint_plugin import register 6 | 7 | 8 | __version__ = '0.4.1' 9 | __all__ = ['Flake8Checker', 'register'] 10 | -------------------------------------------------------------------------------- /flake8_warnings/__main__.py: -------------------------------------------------------------------------------- 1 | from ._cli import entrypoint 2 | 3 | 4 | entrypoint() 5 | -------------------------------------------------------------------------------- /flake8_warnings/_cli.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from argparse import ArgumentParser 3 | from pathlib import Path 4 | from typing import Iterator, List, NoReturn, TextIO 5 | 6 | from ._finder import WarningFinder 7 | 8 | 9 | def get_paths(path: Path) -> Iterator[Path]: 10 | """Recursively yields python files. 11 | """ 12 | if not path.exists(): 13 | raise FileNotFoundError(str(path)) 14 | if path.is_file(): 15 | if path.suffix == '.py': 16 | yield path 17 | return 18 | for subpath in path.iterdir(): 19 | if subpath.name[0] == '.': 20 | continue 21 | if subpath.name == '__pycache__': 22 | continue 23 | yield from get_paths(subpath) 24 | 25 | 26 | def main(argv: List[str], stream: TextIO) -> int: 27 | parser = ArgumentParser() 28 | parser.add_argument('paths', nargs='+') 29 | args = parser.parse_args(argv) 30 | found_warnings = 0 31 | for root in args.paths: 32 | for path in get_paths(Path(root)): 33 | path_shown = False 34 | finder = WarningFinder.from_path(path) 35 | for warning in finder.find(): 36 | if not path_shown: 37 | print(path, file=stream) 38 | path_shown = True 39 | print(' ', warning, file=stream) 40 | found_warnings += 1 41 | return min(found_warnings, 100) 42 | 43 | 44 | def entrypoint() -> NoReturn: 45 | sys.exit(main(sys.argv[1:], sys.stdout)) 46 | -------------------------------------------------------------------------------- /flake8_warnings/_extractors/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple, Type 2 | 3 | from ._base import CODES, Extractor, WarningInfo 4 | from ._decorators import DecoratorsExtractor 5 | from ._docstrings import DocstringsExtractor 6 | from ._stdlib import StdlibExtractor 7 | from ._warnings import WarningsExtractor 8 | 9 | 10 | __all__ = ['CODES', 'EXTRACTORS', 'Extractor', 'WarningInfo'] 11 | EXTRACTORS: Tuple[Type[Extractor], ...] = ( 12 | DecoratorsExtractor, 13 | DocstringsExtractor, 14 | StdlibExtractor, 15 | WarningsExtractor, 16 | ) 17 | -------------------------------------------------------------------------------- /flake8_warnings/_extractors/_base.py: -------------------------------------------------------------------------------- 1 | from types import MappingProxyType 2 | from typing import Iterator, Mapping, NamedTuple, Optional, Type 3 | 4 | import astroid 5 | 6 | 7 | # https://docs.python.org/3/library/warnings.html 8 | CODES: Mapping[Type[Warning], int] = MappingProxyType({ 9 | Warning: 1, 10 | UserWarning: 2, 11 | DeprecationWarning: 3, 12 | SyntaxWarning: 4, 13 | RuntimeWarning: 5, 14 | FutureWarning: 6, 15 | PendingDeprecationWarning: 7, 16 | ImportWarning: 8, 17 | UnicodeWarning: 9, 18 | BytesWarning: 10, 19 | ResourceWarning: 11, 20 | }) 21 | NAMES = MappingProxyType({cat.__name__: cat for cat in CODES}) 22 | 23 | 24 | class WarningInfo(NamedTuple): 25 | message: str 26 | category: Type[Warning] 27 | argument: Optional[str] = None 28 | node: Optional[astroid.NodeNG] = None 29 | line: int = 1 30 | col: int = 0 31 | 32 | @property 33 | def code(self) -> int: 34 | return CODES.get(self.category, 1) 35 | 36 | def evolve(self, **kwargs) -> 'WarningInfo': 37 | return self._replace(**kwargs) 38 | 39 | def __str__(self) -> str: 40 | return f'{self.line}:{self.col} [{self.category.__name__}] {self.message}' 41 | 42 | 43 | class Extractor: 44 | def extract(self, node: astroid.NodeNG) -> Iterator[WarningInfo]: 45 | raise NotImplementedError 46 | -------------------------------------------------------------------------------- /flake8_warnings/_extractors/_decorators.py: -------------------------------------------------------------------------------- 1 | from typing import Iterator, Optional 2 | 3 | import astroid 4 | 5 | from ._base import Extractor, WarningInfo 6 | 7 | 8 | class DecoratorsExtractor(Extractor): 9 | """Extractor for deprecation decorators. 10 | 11 | A few examples: 12 | + https://github.com/tantale/deprecated 13 | + @deprecated 14 | + https://github.com/briancurtin/deprecation 15 | + @deprecation.deprecated 16 | """ 17 | 18 | def extract(self, node: astroid.NodeNG) -> Iterator[WarningInfo]: 19 | if not isinstance(node, (astroid.FunctionDef, astroid.ClassDef)): 20 | return 21 | if not node.decorators: 22 | return 23 | assert isinstance(node.decorators, astroid.Decorators) 24 | dec: astroid.NodeNG 25 | for dec in node.decorators.nodes: 26 | warn = self._get_warning(dec) 27 | if warn is not None: 28 | yield warn 29 | 30 | def _get_warning(self, node: astroid.NodeNG) -> Optional[WarningInfo]: 31 | if not isinstance(node, astroid.Call): 32 | return None 33 | if 'deprecat' not in node.func.as_string(): 34 | return None 35 | return WarningInfo( 36 | message=self._get_message(node), 37 | category=DeprecationWarning, 38 | ) 39 | 40 | def _get_message(self, node: astroid.Call) -> str: 41 | for arg in node.args: 42 | msg = self._get_message_from_node(arg) 43 | if msg: 44 | return msg 45 | for kwarg in node.keywords: 46 | msg = self._get_message_from_node(kwarg.value) 47 | if msg: 48 | return msg 49 | return ' '.join(node.as_string().split()) 50 | 51 | @staticmethod 52 | def _get_message_from_node(node: astroid.NodeNG) -> Optional[str]: 53 | if not isinstance(node, astroid.Const): 54 | return None 55 | if not isinstance(node.value, str): 56 | return None 57 | if ' ' not in node.value: 58 | return None 59 | return node.value 60 | -------------------------------------------------------------------------------- /flake8_warnings/_extractors/_docstrings.py: -------------------------------------------------------------------------------- 1 | from typing import Iterator 2 | 3 | import astroid 4 | 5 | from ._base import Extractor, WarningInfo 6 | 7 | 8 | class DocstringsExtractor(Extractor): 9 | """Extractor for warning messages in docstrings. 10 | """ 11 | 12 | def extract(self, node: astroid.NodeNG) -> Iterator[WarningInfo]: 13 | yield from () 14 | -------------------------------------------------------------------------------- /flake8_warnings/_extractors/_stdlib.py: -------------------------------------------------------------------------------- 1 | from typing import Iterator 2 | 3 | import astroid 4 | 5 | from ._base import Extractor, WarningInfo 6 | 7 | 8 | MODULES = frozenset({ 9 | # PEP 594: Removing dead batteries 10 | 'aifc', 11 | 'asynchat', 12 | 'asyncore', 13 | 'audioop', 14 | 'cgi', 15 | 'cgitb', 16 | 'chunk', 17 | 'crypt', 18 | 'imghdr', 19 | 'mailcap', 20 | 'msilib', 21 | 'nntplib', 22 | 'nis', 23 | 'ossaudiodev', 24 | 'pipes', 25 | 'smtpd', 26 | 'sndhdr', 27 | 'spwd', 28 | 'sunau', 29 | 'telnetlib', 30 | 'uu', 31 | 'xdrlib', 32 | 33 | # PEP 632, removed in Python 3.12 34 | 'distutils', 35 | 36 | # deprecated but not announced to be removed 37 | 'optparse', 38 | 'tkinter.tix', 39 | 'xml.etree.cElementTree', 40 | }) 41 | 42 | 43 | class StdlibExtractor(Extractor): 44 | """Extractor for warning messages in docstrings. 45 | """ 46 | 47 | def extract(self, node: astroid.NodeNG) -> Iterator[WarningInfo]: 48 | if not isinstance(node, astroid.Module): 49 | return 50 | qname = node.qname() 51 | if qname not in MODULES: 52 | qname = qname.split('.')[0] 53 | if qname in MODULES: 54 | yield WarningInfo( 55 | message=f'stdlib module {qname} is deprecated', 56 | category=DeprecationWarning, 57 | ) 58 | -------------------------------------------------------------------------------- /flake8_warnings/_extractors/_warnings.py: -------------------------------------------------------------------------------- 1 | from typing import Iterator, List, Optional 2 | 3 | import astroid 4 | 5 | from ._base import NAMES, Extractor, WarningInfo 6 | 7 | 8 | BRANCHING = ( 9 | astroid.If, 10 | astroid.With, 11 | astroid.Try, 12 | astroid.Return, 13 | astroid.Raise, 14 | ) 15 | 16 | 17 | class WarningsExtractor(Extractor): 18 | """Extractor for `wanings.warn()` invocations. 19 | """ 20 | 21 | def extract(self, node: astroid.NodeNG) -> Iterator[WarningInfo]: 22 | if isinstance(node, (astroid.FunctionDef, astroid.Module)): 23 | yield from self._extract_from_body(node.body) 24 | 25 | def _extract_from_body(self, body: List[astroid.NodeNG]) -> Iterator[WarningInfo]: 26 | for node in body: 27 | if isinstance(node, astroid.Expr): 28 | node = node.value 29 | if isinstance(node, BRANCHING): 30 | return 31 | warning = self._get_warning(node) 32 | if warning is not None: 33 | yield warning 34 | 35 | def _get_warning(self, node: astroid.NodeNG) -> Optional[WarningInfo]: 36 | # check if it is a call to `warnings.warn` 37 | if not isinstance(node, astroid.Call): 38 | return None 39 | if node.func.as_string() != 'warnings.warn': 40 | return None 41 | return WarningInfo( 42 | message=self._get_message(node), 43 | category=NAMES.get(self._get_category(node), Warning), 44 | ) 45 | 46 | @staticmethod 47 | def _get_message(node: astroid.Call) -> str: 48 | # extract positional category 49 | if node.args: 50 | arg_node = node.args[0] 51 | if isinstance(arg_node, astroid.Const): 52 | return str(arg_node.value) 53 | 54 | # extract keyword category 55 | for kwarg in node.keywords: 56 | if kwarg.arg != 'message': 57 | continue 58 | if isinstance(kwarg.value, astroid.Const): 59 | return str(kwarg.value.value) 60 | return ' '.join(node.as_string().split()) 61 | 62 | @staticmethod 63 | def _get_category(node: astroid.Call) -> str: 64 | # extract positional category 65 | if len(node.args) > 1: 66 | arg_node = node.args[1] 67 | if isinstance(arg_node, astroid.Name): 68 | return arg_node.name 69 | 70 | # extract keyword category 71 | for kwarg in node.keywords: 72 | if kwarg.arg != 'category': 73 | continue 74 | arg_node = kwarg.value 75 | if isinstance(arg_node, astroid.Name): 76 | return arg_node.name 77 | return 'UserWarning' 78 | -------------------------------------------------------------------------------- /flake8_warnings/_finder.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections import deque 4 | from contextlib import suppress 5 | from pathlib import Path 6 | from typing import Iterator 7 | 8 | import astroid 9 | 10 | from ._extractors import EXTRACTORS, Extractor, WarningInfo 11 | 12 | 13 | class WarningFinder: 14 | _module: astroid.Module 15 | _extractors: tuple[Extractor, ...] 16 | 17 | def __init__(self, module: astroid.Module): 18 | self._module = module 19 | self._extractors = tuple(e() for e in EXTRACTORS) 20 | 21 | @classmethod 22 | def from_path(cls, path: Path) -> 'WarningFinder': 23 | text = path.read_text() 24 | module = astroid.parse(code=text, path=str(path)) 25 | return cls(module) 26 | 27 | @classmethod 28 | def from_text(cls, text: str) -> 'WarningFinder': 29 | module = astroid.parse(code=text) 30 | return cls(module) 31 | 32 | def find(self) -> Iterator[WarningInfo]: 33 | yield from self._check_imports() 34 | 35 | def _check_imports(self) -> Iterator[WarningInfo]: 36 | for node in self._traverse(self._module): 37 | for target_node in self._get_imported_nodes(node): 38 | for extractor in self._extractors: 39 | warnings = list(extractor.extract(target_node)) 40 | for warning in warnings: 41 | yield warning.evolve( 42 | line=node.lineno, 43 | col=node.col_offset, 44 | node=node, 45 | ) 46 | # If one extractor found something for the node, 47 | # don't try other extractors. 48 | if warnings: 49 | break 50 | 51 | def _get_imported_nodes(self, node) -> Iterator[astroid.NodeNG]: 52 | if isinstance(node, astroid.Import): 53 | for name, _ in node.names: 54 | with suppress(astroid.AstroidImportError): 55 | yield self._module.import_module(name) 56 | 57 | if not isinstance(node, astroid.ImportFrom): 58 | return 59 | try: 60 | module = self._module.import_module(node.modname) 61 | except astroid.AstroidImportError: 62 | return 63 | yield module 64 | for name, _ in node.names: 65 | _, resolved_nodes = module.lookup(name) 66 | for node in resolved_nodes: 67 | if isinstance(node, (astroid.ClassDef, astroid.FunctionDef)): 68 | yield node 69 | 70 | @staticmethod 71 | def _traverse(node: astroid.NodeNG) -> Iterator[astroid.NodeNG]: 72 | todo = deque([node]) 73 | while todo: 74 | node = todo.popleft() 75 | todo.extend(node.get_children()) 76 | yield node 77 | -------------------------------------------------------------------------------- /flake8_warnings/_flake8_plugin.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import tokenize 3 | from pathlib import Path 4 | from typing import Iterator, Optional, Sequence 5 | 6 | from ._finder import WarningFinder 7 | 8 | 9 | TEMPLATE = 'WS0{code:02} {message}' 10 | 11 | 12 | class Flake8Checker: 13 | name = __package__ 14 | version = '0.0.1' 15 | 16 | def __init__( 17 | self, 18 | tree: ast.AST, 19 | file_tokens: Sequence[tokenize.TokenInfo], 20 | filename: Optional[str] = None, 21 | ) -> None: 22 | self._tokens = file_tokens 23 | self._filename = filename 24 | 25 | @property 26 | def _finder(self) -> WarningFinder: 27 | if not self._filename or self._filename in ('stdout', '-'): 28 | text = tokenize.untokenize(self._tokens).encode() 29 | return WarningFinder.from_text(text) 30 | return WarningFinder.from_path(Path(self._filename)) 31 | 32 | def run(self) -> Iterator[tuple]: 33 | for winfo in self._finder.find(): 34 | text = TEMPLATE.format(code=winfo.code, message=winfo.message) 35 | yield ( 36 | winfo.line, winfo.col, text, type(self), 37 | ) 38 | -------------------------------------------------------------------------------- /flake8_warnings/_pylint_plugin.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | import astroid 4 | 5 | from ._extractors import CODES 6 | from ._finder import WarningFinder 7 | 8 | 9 | if TYPE_CHECKING: 10 | from pylint.lint import PyLinter 11 | 12 | try: 13 | from pylint.checkers import BaseChecker 14 | from pylint.interfaces import IAstroidChecker 15 | except ImportError: 16 | BaseChecker = object 17 | IAstroidChecker = object 18 | 19 | 20 | CODE = 'W99{:02}' 21 | 22 | 23 | def register(linter: 'PyLinter') -> None: 24 | linter.register_checker(PyLintChecker(linter)) 25 | 26 | 27 | class PyLintChecker(BaseChecker): 28 | """ 29 | https://pylint.pycqa.org/en/latest/how_tos/custom_checkers.html 30 | """ 31 | 32 | __implements__ = IAstroidChecker 33 | 34 | name = 'flake8_warnings' 35 | msgs = {CODE.format(code): ('%s', cat.__name__, '') for cat, code in CODES.items()} 36 | 37 | def visit_module(self: BaseChecker, node: astroid.Module) -> None: 38 | finder = WarningFinder(node) 39 | for winfo in finder.find(): 40 | self.add_message( 41 | CODE.format(winfo.code), 42 | line=winfo.line, 43 | col_offset=winfo.col, 44 | args=(winfo.message,), 45 | node=winfo.node, 46 | ) 47 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["flit_core >=3.2,<4"] 3 | build-backend = "flit_core.buildapi" 4 | 5 | [project] 6 | name = "flake8-warnings" 7 | authors = [{ name = "Gram", email = "gram@orsinium.dev" }] 8 | license = { file = "LICENSE" } 9 | readme = "README.md" 10 | requires-python = ">=3.6" 11 | dynamic = ["version", "description"] 12 | classifiers = [ 13 | "Development Status :: 5 - Production/Stable", 14 | "Environment :: Plugins", 15 | "Intended Audience :: Developers", 16 | "License :: OSI Approved :: MIT License", 17 | "Programming Language :: Python", 18 | "Topic :: Software Development", 19 | "Topic :: Software Development :: Quality Assurance", 20 | ] 21 | keywords = [ 22 | "deprecation", 23 | "flake8", 24 | "pylint", 25 | "warnings", 26 | "linter", 27 | "flakehell", 28 | ] 29 | dependencies = ["astroid>=3.0.0"] 30 | 31 | [project.optional-dependencies] 32 | test = ["pytest"] 33 | lint = [ 34 | "flake8", # linter 35 | "flake8-length", # allow long strings 36 | "mypy", # type checker 37 | "isort", # sort imports 38 | "unify", # use single quotes everywhere 39 | ] 40 | 41 | 42 | [project.entry-points."flake8.extension"] 43 | WS0 = "flake8_warnings:Flake8Checker" 44 | 45 | [project.urls] 46 | Source = "https://github.com/orsinium-labs/flake8-warnings" 47 | 48 | [tool.flit.module] 49 | name = "flake8_warnings" 50 | 51 | [tool.mypy] 52 | files = ["flake8_warnings", "tests"] 53 | python_version = 3.7 54 | ignore_missing_imports = true 55 | # follow_imports = "silent" 56 | show_error_codes = true 57 | allow_redefinition = true 58 | 59 | # Settings making mypy checks harder. 60 | # If something here produces too many false-positives, 61 | # consider turning it off. 62 | check_untyped_defs = true 63 | no_implicit_optional = true 64 | strict_equality = true 65 | warn_redundant_casts = true 66 | warn_unreachable = true 67 | warn_unused_ignores = true 68 | 69 | [tool.isort] 70 | profile = "django" 71 | lines_after_imports = 2 72 | skip = ".venvs/" 73 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 90 3 | ignore = C408 4 | exclude = 5 | setup.py 6 | .venvs/ 7 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orsinium-labs/flake8-warnings/a24cd11770003b2681a9abe14514c982e09a9b69/tests/__init__.py -------------------------------------------------------------------------------- /tests/samples/warnings_function.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | 4 | def func(): 5 | warnings.warn('func warn', DeprecationWarning) 6 | return 1 7 | warnings.warn('this one is ignored') # type: ignore 8 | 9 | 10 | def not_imported_func(): 11 | warnings.warn('this one is ignored') 12 | -------------------------------------------------------------------------------- /tests/samples/warnings_module.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | 4 | warnings.warn('mod warn', DeprecationWarning) 5 | 6 | 7 | def func(): 8 | pass 9 | 10 | 11 | def not_imported_func(): 12 | warnings.warn('this one is ignored') 13 | -------------------------------------------------------------------------------- /tests/test_extractors/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orsinium-labs/flake8-warnings/a24cd11770003b2681a9abe14514c982e09a9b69/tests/test_extractors/__init__.py -------------------------------------------------------------------------------- /tests/test_extractors/helpers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from textwrap import dedent 4 | 5 | import astroid 6 | 7 | from flake8_warnings._extractors import Extractor 8 | 9 | 10 | def p(text) -> astroid.Module: 11 | """Parse the text into astroid tree, print it. 12 | """ 13 | tree = astroid.parse(dedent(text)) 14 | print(tree.repr_tree()) 15 | return tree 16 | 17 | 18 | def e(extractor: type[Extractor], node: astroid.NodeNG) -> list[tuple[type, str]]: 19 | return [(w.category, w.message) for w in extractor().extract(node)] 20 | -------------------------------------------------------------------------------- /tests/test_extractors/test_base.py: -------------------------------------------------------------------------------- 1 | from flake8_warnings._extractors._base import CODES, NAMES 2 | 3 | 4 | def test_codes(): 5 | assert len(CODES) == len(set(CODES.values())) 6 | last_code = max(CODES.values()) 7 | assert set(CODES.values()) == set(range(1, last_code + 1)) 8 | 9 | 10 | def test_names(): 11 | assert NAMES['UserWarning'] is UserWarning 12 | -------------------------------------------------------------------------------- /tests/test_extractors/test_common.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from flake8_warnings._finder import WarningFinder 4 | 5 | from .helpers import p 6 | 7 | 8 | @pytest.mark.parametrize('given', [ 9 | # PEP 594: Removing dead batteries 10 | 'import aifc', 11 | 'import asynchat', 12 | 'import asyncore', 13 | 'import audioop', 14 | 'import cgi', 15 | 'import cgitb', 16 | 'import chunk', 17 | 'import crypt', 18 | 'import imghdr', 19 | 'import mailcap', 20 | # 'import msilib', # available only on windows 21 | 'import nntplib', 22 | 'import nis', 23 | 'import ossaudiodev', 24 | 'import pipes', 25 | 'import smtpd', 26 | 'import sndhdr', 27 | 'import spwd', 28 | 'import sunau', 29 | 'import telnetlib', 30 | 'import uu', 31 | 'import xdrlib', 32 | 33 | 'import distutils', 34 | 'from distutils.core import setup', 35 | 'from distutils.sysconfig import get_python_inc', 36 | 37 | 'import optparse', 38 | 'import tkinter.tix', 39 | 'import xml.etree.cElementTree', 40 | 41 | # from-import 42 | 'from smtpd import SMTPServer', 43 | ]) 44 | def test_module_deprecated(given): 45 | finder = WarningFinder(p(given)) 46 | r = [(w.category, w.message) for w in finder.find()] 47 | assert len(r) == 1 48 | assert r[0][0] is DeprecationWarning 49 | -------------------------------------------------------------------------------- /tests/test_extractors/test_decorators.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from flake8_warnings._extractors import DecoratorsExtractor 4 | 5 | from .helpers import e, p 6 | 7 | 8 | @pytest.mark.parametrize('given, exp', [ 9 | # https://github.com/tantale/deprecated 10 | ('deprecated(reason="use another function")', 'use another function'), 11 | ('deprecated.deprecated(reason="use another function")', 'use another function'), 12 | # https://github.com/briancurtin/deprecation 13 | ( 14 | 'deprecation.deprecated(details="Use the bar function instead")', 15 | 'Use the bar function instead', 16 | ), 17 | ( 18 | 'deprecation.deprecated(deprecated_in="1.0", current_version=__version__, details="Use the bar function instead")', # noqa 19 | 'Use the bar function instead', 20 | ), 21 | ( 22 | 'deprecated(deprecated_in="1.0", current_version=__version__, details="Use the bar function instead")', # noqa 23 | 'Use the bar function instead', 24 | ), 25 | # https://github.com/mfalesni/python-deprecate 26 | ('deprecated(message="call to depr func")', 'call to depr func'), 27 | ('deprecate.deprecated(message="call to depr func")', 'call to depr func'), 28 | # https://github.com/multi-vac/py_deprecate 29 | ( 30 | 'deprecated(allowed_deprecations=[allowed_sum_caller], message="sum is no longer supported.")', # noqa 31 | 'sum is no longer supported.', 32 | ), 33 | ]) 34 | def test_extract_deprecated(given, exp): 35 | r = e(DecoratorsExtractor, p(f""" 36 | from deprecated import deprecated 37 | 38 | @{given} 39 | def some_old_function(x, y): 40 | return x + y 41 | """).body[-1]) 42 | assert r == [(DeprecationWarning, exp)] 43 | -------------------------------------------------------------------------------- /tests/test_extractors/test_warnings.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from flake8_warnings._extractors import WarningsExtractor 4 | 5 | from .helpers import e, p 6 | 7 | 8 | def test_module_deprecated(): 9 | r = e(WarningsExtractor, p(""" 10 | import warnings 11 | warnings.warn( 12 | "The module is deprecated and so on", 13 | DeprecationWarning, 14 | ) 15 | """)) 16 | assert r == [(DeprecationWarning, 'The module is deprecated and so on')] 17 | 18 | 19 | @pytest.mark.parametrize('given, ecat, emsg', [ 20 | ('"oh hi mark"', UserWarning, 'oh hi mark'), 21 | ('"oh hi mark", ImportWarning', ImportWarning, 'oh hi mark'), 22 | ('"oh hi mark", category=ImportWarning', ImportWarning, 'oh hi mark'), 23 | ('message="oh hi mark", category=ImportWarning', ImportWarning, 'oh hi mark'), 24 | ('category=ImportWarning, message="oh hi mark"', ImportWarning, 'oh hi mark'), 25 | 26 | ('"oh hi mark", garbage', Warning, 'oh hi mark'), 27 | ('garbage', UserWarning, 'warnings.warn(garbage)'), 28 | ('', UserWarning, 'warnings.warn()'), 29 | ]) 30 | def test_module_deprecated__args_parsing(given, ecat, emsg): 31 | r = e(WarningsExtractor, p(f""" 32 | import warnings 33 | warnings.warn({given}) 34 | """)) 35 | assert r == [(ecat, emsg)] 36 | -------------------------------------------------------------------------------- /tests/test_finder.py: -------------------------------------------------------------------------------- 1 | from textwrap import dedent 2 | 3 | import astroid 4 | import pytest 5 | 6 | from flake8_warnings._finder import WarningFinder 7 | 8 | 9 | def p(text): 10 | tree = astroid.parse(dedent(text)) 11 | print(tree.repr_tree()) 12 | return tree 13 | 14 | 15 | def e(node): 16 | return [(w.category, w.message) for w in WarningFinder(node).find()] 17 | 18 | 19 | @pytest.mark.parametrize('given, etype, emsg', [ 20 | ('import tests.samples.warnings_module', DeprecationWarning, 'mod warn'), 21 | ('from tests.samples.warnings_module import fun', DeprecationWarning, 'mod warn'), 22 | ('from tests.samples.warnings_function import func', DeprecationWarning, 'func warn'), 23 | ]) 24 | def test_finder__import(given, etype, emsg): 25 | r = e(p(given)) 26 | assert r == [(etype, emsg)] 27 | --------------------------------------------------------------------------------