├── .codeclimate.yml ├── .editorconfig ├── .flake_master ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── build.yml │ ├── build_pr.yml │ └── publish.yml ├── .gitignore ├── .mdlrc ├── .mdlrc.rb ├── LICENSE ├── Makefile ├── README.md ├── flake8_expression_complexity ├── __init__.py ├── checker.py └── utils │ ├── __init__.py │ ├── ast.py │ ├── complexity.py │ ├── django.py │ └── iterables.py ├── requirements_dev.txt ├── setup.cfg ├── setup.py └── tests ├── conftest.py ├── test_complexity.py └── test_files ├── long_expressions.py ├── match.py └── walrus.py /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | checks: 3 | file-lines: 4 | config: 5 | threshold: 500 6 | complex-logic: 7 | config: 8 | threshold: 8 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | # Inspired by Django .editorconfig file 3 | 4 | root = true 5 | 6 | [*] 7 | indent_style = space 8 | indent_size = 4 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | end_of_line = lf 12 | charset = utf-8 13 | 14 | [*.py] 15 | max_line_length = 100 16 | 17 | [Makefile] 18 | indent_style = tab 19 | -------------------------------------------------------------------------------- /.flake_master: -------------------------------------------------------------------------------- 1 | {"name": "rose", "revision": "4", "url": "https://raw.githubusercontent.com/Melevir/flake_master_presets/master/presets/rose.cfg", "filepath": null} -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.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 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Lint and test code 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | python-version: ['3.7', '3.8', '3.9', '3.10'] 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v2 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | - name: Install dependencies 20 | run: pip install -r requirements_dev.txt 21 | - name: Run mdl 22 | uses: actionshub/markdownlint@main 23 | - name: Run checks 24 | run: make style types requirements 25 | - name: Run tests and publish coverage 26 | uses: paambaati/codeclimate-action@v2.7.5 27 | env: 28 | CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }} 29 | with: 30 | coverageCommand: make coverage 31 | -------------------------------------------------------------------------------- /.github/workflows/build_pr.yml: -------------------------------------------------------------------------------- 1 | name: Lint and test code 2 | 3 | on: 4 | pull_request: 5 | branches: [ master ] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | python-version: ['3.7', '3.8', '3.9', '3.10'] 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v2 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | - name: Install dependencies 20 | run: pip install -r requirements_dev.txt 21 | - name: Run mdl 22 | uses: actionshub/markdownlint@main 23 | - name: Run checks 24 | run: make style types requirements 25 | - name: Run tests 26 | run: make test 27 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python package 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | python-version: ['3.7', '3.8', '3.9', '3.10'] 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Set up Python ${{ matrix.python-version }} 17 | uses: actions/setup-python@v2 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | - name: Install dependencies 21 | run: pip install -r requirements_dev.txt 22 | - name: Run checks 23 | run: make style types requirements test 24 | publish: 25 | runs-on: ubuntu-latest 26 | needs: build 27 | steps: 28 | - uses: actions/checkout@v2 29 | - name: Set up Python ${{ matrix.python-version }} 30 | uses: actions/setup-python@v2 31 | - name: Install dependencies 32 | run: pip install -r requirements_dev.txt setuptools wheel twine 33 | - name: Build and publish 34 | env: 35 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 36 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 37 | run: | 38 | python setup.py sdist bdist_wheel 39 | twine upload dist/* 40 | 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # VS code 107 | .vscode 108 | 109 | .idea 110 | -------------------------------------------------------------------------------- /.mdlrc: -------------------------------------------------------------------------------- 1 | style '.mdlrc.rb' 2 | -------------------------------------------------------------------------------- /.mdlrc.rb: -------------------------------------------------------------------------------- 1 | all 2 | rule 'MD013', :line_length => 120 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 BestDoctor 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | test: 2 | python -m pytest 3 | 4 | coverage: 5 | python -m pytest --cov=flake8_expression_complexity --cov-report=xml 6 | 7 | types: 8 | mypy . 9 | 10 | style: 11 | flake8 . 12 | 13 | readme: 14 | mdl README.md 15 | 16 | requirements: 17 | safety check -r requirements_dev.txt 18 | 19 | check: 20 | make style 21 | make types 22 | make test 23 | make requirements 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # flake8-expression-complexity 2 | 3 | [![Build Status](https://github.com/best-doctor/flake8-expression-complexity/actions/workflows/build.yml/badge.svg?branch=master)](https://github.com/best-doctor/flake8-expression-complexity/actions/workflows/build.yml) 4 | [![Maintainability](https://api.codeclimate.com/v1/badges/f85c1fd2ad4af63d93b6/maintainability)](https://codeclimate.com/github/best-doctor/flake8-expression-complexity/maintainability) 5 | [![Test Coverage](https://api.codeclimate.com/v1/badges/f85c1fd2ad4af63d93b6/test_coverage)](https://codeclimate.com/github/best-doctor/flake8-expression-complexity/test_coverage) 6 | [![PyPI version](https://badge.fury.io/py/flake8-expression-complexity.svg?)](https://badge.fury.io/py/flake8-expression-complexity) 7 | ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/flake8-expression-complexity) 8 | 9 | An extension for flake8 that validates expression complexity. 10 | 11 | Splits code into expression and scores each according to how much one is complicated. 12 | Fires an error on each expression more complex than theshold. 13 | 14 | Default complexity is 7, can be configured via `--max-expression-complexity` option. 15 | 16 | Since Django ORM queries can produce long and readable expressions, 17 | checker can skip them. To enable this behaviour, 18 | use `--ignore-django-orm-queries-complexity` option. 19 | 20 | ## Installation 21 | 22 | ```terminal 23 | pip install flake8-expression-complexity 24 | ``` 25 | 26 | ## Example 27 | 28 | ```python 29 | if ( 30 | (user and user.is_authorized) 31 | and user.subscriptions.filter(start_date__lt=today, end_date__gt=today).exists() 32 | and ( 33 | user.total_credits_added 34 | - Check.objects.filter(user=user).aggregate(Sum('price'))['check__sum'] 35 | ) 36 | and UserAction.objects.filter(user=user).last().datetime > today - datetime.timedelta(days=10) 37 | ): 38 | ... 39 | ``` 40 | 41 | Usage: 42 | 43 | ```terminal 44 | $ flake8 --max-expression-complexity=3 test.py 45 | text.py:2:5: ECE001 Expression is too complex (7.0 > 3) 46 | ``` 47 | 48 | ## Error codes 49 | 50 | | Error code | Description | 51 | |:----------:|:---------------------------------:| 52 | | ECE001 | Expression is too complex (X > Y) | 53 | 54 | ## Contributing 55 | 56 | We would love you to contribute to our project. It's simple: 57 | 58 | 1. Create an issue with bug you found or proposal you have. 59 | Wait for approve from maintainer. 60 | 1. Create a pull request. Make sure all checks are green. 61 | 1. Fix review comments if any. 62 | 1. Be awesome. 63 | 64 | Here are useful tips: 65 | 66 | - You can run all checks and tests with `make check`. 67 | Please do it before TravisCI does. 68 | - We use [BestDoctor python styleguide](https://github.com/best-doctor/guides/blob/master/guides/en/python_styleguide.md). 69 | - We respect [Django CoC](https://www.djangoproject.com/conduct/). 70 | Make soft, not bullshit. 71 | -------------------------------------------------------------------------------- /flake8_expression_complexity/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.0.11' 2 | -------------------------------------------------------------------------------- /flake8_expression_complexity/checker.py: -------------------------------------------------------------------------------- 1 | from typing import Generator, Tuple 2 | 3 | 4 | from flake8_expression_complexity import __version__ as version 5 | from flake8_expression_complexity.utils.ast import iterate_over_expressions 6 | from flake8_expression_complexity.utils.complexity import get_expression_complexity 7 | from flake8_expression_complexity.utils.django import is_django_orm_query 8 | 9 | 10 | class ExpressionComplexityChecker: 11 | DEFAULT_MAX_EXPRESSION_COMPLEXITY = 7 12 | 13 | name = 'flake8-expression-complexity' 14 | version = version 15 | 16 | max_expression_complexity = DEFAULT_MAX_EXPRESSION_COMPLEXITY 17 | ignore_django_orm_queries = False 18 | 19 | def __init__(self, tree, filename: str): 20 | self.filename = filename 21 | self.tree = tree 22 | 23 | @classmethod 24 | def add_options(cls, parser) -> None: 25 | parser.add_option( 26 | '--max-expression-complexity', 27 | type=int, 28 | default=cls.DEFAULT_MAX_EXPRESSION_COMPLEXITY, 29 | parse_from_config=True, 30 | ) 31 | parser.add_option( 32 | '--ignore-django-orm-queries-complexity', 33 | action='store_true', 34 | parse_from_config=True, 35 | ) 36 | 37 | @classmethod 38 | def parse_options(cls, options) -> None: 39 | cls.max_expression_complexity = int(options.max_expression_complexity) 40 | cls.ignore_django_orm_queries = bool(options.ignore_django_orm_queries_complexity) 41 | 42 | def run(self) -> Generator[Tuple[int, int, str, type], None, None]: 43 | for expression in iterate_over_expressions(self.tree): 44 | if self.ignore_django_orm_queries and is_django_orm_query(expression): 45 | continue 46 | complexity = get_expression_complexity(expression) 47 | if complexity > self.max_expression_complexity: 48 | yield ( 49 | expression.lineno, 50 | expression.col_offset, 51 | f'ECE001 Expression is too complex ' 52 | f'({complexity} > {self.max_expression_complexity})', 53 | type(self), 54 | ) 55 | -------------------------------------------------------------------------------- /flake8_expression_complexity/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/best-doctor/flake8-expression-complexity/a62f6d3c6cff2b420b8dd304c7f900f6e79280a1/flake8_expression_complexity/utils/__init__.py -------------------------------------------------------------------------------- /flake8_expression_complexity/utils/ast.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import itertools 3 | from typing import Iterable, Callable, Tuple, List 4 | 5 | 6 | def iterate_over_expressions(node: ast.AST) -> Iterable[ast.AST]: 7 | additionals_subnodes_info: List[Tuple[Tuple, Callable]] = [ 8 | ((ast.If, ast.While), lambda n: [n.test]), 9 | ((ast.For, ), lambda n: [n.iter]), 10 | ((ast.AsyncFor, ), lambda n: [n.iter]), 11 | ((ast.With, ast.AsyncWith), lambda n: [s.context_expr for s in n.items]), 12 | ] 13 | nodes_with_subnodes = ( 14 | ast.FunctionDef, ast.AsyncFunctionDef, 15 | ast.If, ast.For, ast.AsyncFor, ast.Module, 16 | ast.ClassDef, ast.Try, ast.With, ast.AsyncWith, 17 | ast.While, 18 | ) 19 | for bases, subnodes_getter in additionals_subnodes_info: 20 | if isinstance(node, bases): 21 | for subitem in subnodes_getter(node): 22 | yield subitem 23 | nodes_to_iter = ( 24 | _get_try_node_children(node) 25 | if isinstance(node, ast.Try) 26 | else getattr(node, 'body', []) 27 | ) 28 | for child_node in nodes_to_iter: 29 | if isinstance(child_node, nodes_with_subnodes): 30 | for subnode in iterate_over_expressions(child_node): 31 | yield subnode 32 | else: 33 | yield child_node 34 | 35 | 36 | def _get_try_node_children(try_node: ast.Try): 37 | return itertools.chain(try_node.body, try_node.finalbody, *[n.body for n in try_node.handlers]) 38 | -------------------------------------------------------------------------------- /flake8_expression_complexity/utils/complexity.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import sys 3 | import itertools 4 | from typing import Mapping, Any, List 5 | 6 | from astpretty import pprint 7 | 8 | from flake8_expression_complexity.utils.iterables import max_with_default 9 | 10 | TYPES_MAP = [ 11 | (ast.UnaryOp, 'unary_op'), 12 | ( 13 | ( 14 | ast.Expr, ast.Return, ast.Starred, ast.Index, 15 | ast.Yield, ast.YieldFrom, ast.FormattedValue, 16 | ), 17 | 'item_with_value', 18 | ), 19 | (ast.Assert, 'assert'), 20 | (ast.Delete, 'delete'), 21 | (ast.Assign, 'assign'), 22 | ((ast.AugAssign, ast.AnnAssign), 'featured_assign'), 23 | (ast.Call, 'call'), 24 | (ast.Await, 'await'), 25 | ((ast.List, ast.Set, ast.Tuple), 'sized'), 26 | (ast.Dict, 'dict'), 27 | (ast.DictComp, 'dict_comprehension'), 28 | ((ast.ListComp, ast.GeneratorExp, ast.SetComp), 'simple_comprehensions'), 29 | (ast.comprehension, 'base_comprehension'), 30 | (ast.Compare, 'compare'), 31 | (ast.Subscript, 'subscript'), 32 | (ast.Slice, 'slice'), 33 | (ast.ExtSlice, 'ext_slice'), 34 | (ast.BinOp, 'binary_op'), 35 | (ast.Lambda, 'lambda'), 36 | (ast.IfExp, 'if_expr'), 37 | (ast.BoolOp, 'bool_op'), 38 | (ast.Attribute, 'attribute'), 39 | (ast.JoinedStr, 'fstring'), 40 | (ast.ClassDef, 'classdef'), 41 | ( 42 | ( 43 | ast.Name, ast.Import, ast.Str, ast.Num, ast.NameConstant, ast.Bytes, ast.Nonlocal, 44 | ast.ImportFrom, ast.Pass, ast.Raise, ast.Break, ast.Continue, type(None), 45 | ast.Ellipsis, ast.Global, 46 | ), 47 | 'simple_type', 48 | ), 49 | ] 50 | 51 | if sys.version_info >= (3, 8): 52 | TYPES_MAP.append( 53 | (ast.NamedExpr, 'walrus'), 54 | ) 55 | 56 | if sys.version_info >= (3, 10): 57 | TYPES_MAP.extend( 58 | [ 59 | (ast.Match, 'match'), 60 | (ast.match_case, 'case'), 61 | ] 62 | ) 63 | 64 | 65 | def get_expression_complexity(node: ast.AST) -> float: 66 | info = get_expression_part_info(node) 67 | score_addon = get_complexity_increase_for_node_type(info['type']) 68 | if not info['subnodes']: 69 | return score_addon 70 | return max_with_default(get_expression_complexity(n) for n in info['subnodes']) + score_addon 71 | 72 | 73 | def get_complexity_increase_for_node_type(node_type_sid: str) -> float: 74 | nodes_scores_map = { 75 | 'unary_op': 1, 76 | 'item_with_value': 0, 77 | 'assert': 1, 78 | 'delete': 1, 79 | 'assign': 1, 80 | 'featured_assign': 1, 81 | 'call': .5, 82 | 'await': .5, 83 | 'sized': 1, 84 | 'dict': 1, 85 | 'dict_comprehension': 1, 86 | 'simple_comprehensions': 1, 87 | 'base_comprehension': 0, 88 | 'compare': 1, 89 | 'subscript': 1, 90 | 'slice': 1, 91 | 'ext_slice': 1, 92 | 'binary_op': 1, 93 | 'lambda': 1, 94 | 'if_expr': 1, 95 | 'bool_op': 1, 96 | 'attribute': 1, 97 | 'simple_type': 0, 98 | 'fstring': 2, 99 | 'walrus': 2, 100 | 'match': 1, 101 | 'case': 1, 102 | } 103 | return nodes_scores_map[node_type_sid] 104 | 105 | 106 | def get_expression_part_info(node: ast.AST) -> Mapping[str, Any]: 107 | node_type_sid = None 108 | for types, node_type_name in TYPES_MAP: 109 | if isinstance(node, types): # type: ignore 110 | node_type_sid = node_type_name 111 | break 112 | else: 113 | pprint(node) # noqa 114 | raise AssertionError('should always get node type') 115 | 116 | return { 117 | 'type': node_type_sid, 118 | 'subnodes': _get_sub_nodes(node, node_type_sid), 119 | } 120 | 121 | 122 | def _get_sub_nodes(node: Any, node_type_sid: str) -> List[ast.AST]: 123 | subnodes_map = { 124 | 'unary_op': lambda n: [n.operand], 125 | 'item_with_value': lambda n: [n.value], 126 | 'assert': lambda n: [n.test], 127 | 'delete': lambda n: node.targets, 128 | 'assign': lambda n: node.targets + [node.value], 129 | 'featured_assign': lambda n: [n.target, n.value], 130 | 'call': lambda n: node.args + [n.func], 131 | 'await': lambda n: [node.value], 132 | 'sized': lambda n: node.elts, 133 | 'dict': lambda n: itertools.chain(node.keys, node.values), 134 | 'dict_comprehension': lambda n: node.generators + [n.key, n.value], 135 | 'simple_comprehensions': lambda n: node.generators + [n.elt], 136 | 'base_comprehension': lambda n: node.ifs + [n.target, n.iter], 137 | 'compare': lambda n: node.comparators + [n.left], 138 | 'subscript': lambda n: [n.value, n.slice], 139 | 'slice': lambda n: [n.lower, n.upper, n.step], 140 | 'ext_slice': lambda n: n.dims, 141 | 'binary_op': lambda n: [n.left, n.right], 142 | 'lambda': lambda n: [n.body], 143 | 'if_expr': lambda n: [n.test, n.body, n.orelse], 144 | 'bool_op': lambda n: n.values, 145 | 'fstring': lambda n: n.values, 146 | 'attribute': lambda n: [n.value], 147 | 'simple_type': lambda n: [], 148 | 'walrus': lambda n: [n.target, n.value], 149 | 'match': lambda n: n.cases, 150 | 'case': lambda n: [], 151 | } 152 | return subnodes_map[node_type_sid](node) 153 | -------------------------------------------------------------------------------- /flake8_expression_complexity/utils/django.py: -------------------------------------------------------------------------------- 1 | import ast 2 | 3 | 4 | def is_django_orm_query(node: ast.AST) -> bool: 5 | django_orm_typical_methods = { 6 | 'objects', 7 | 'filter', 8 | 'annotate', 9 | 'select_related', 10 | 'prefetch_related', 11 | 'distinct', 12 | } 13 | total_points_to_be_threated_as_django_orm_query = 0 14 | points_required_to_be_threated_as_django_orm_query = 2 15 | for attribute_node in [n for n in ast.walk(node) if isinstance(n, ast.Attribute)]: 16 | if attribute_node.attr in django_orm_typical_methods: 17 | total_points_to_be_threated_as_django_orm_query += 1 18 | return ( 19 | total_points_to_be_threated_as_django_orm_query 20 | >= points_required_to_be_threated_as_django_orm_query 21 | ) 22 | -------------------------------------------------------------------------------- /flake8_expression_complexity/utils/iterables.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable, Optional, Union, Any 2 | 3 | 4 | def max_with_default(items: Iterable[Any], default: Optional[Any] = None) -> Union[Any]: 5 | default = default or 0 6 | items = list(items) 7 | if not items and default is not None: 8 | return default 9 | return max(items) 10 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | astpretty==2.1.0 2 | 3 | pytest==6.2.5 4 | pytest-cov==3.0.0 5 | pydocstyle==6.1.1 6 | flake8==4.0.1 7 | flake8-2020==1.6.0 8 | flake8-annotations-complexity==0.0.6 9 | flake8-blind-except==0.2.0 10 | flake8-broken-line==0.4.0 11 | flake8-bugbear==22.1.11 12 | flake8-builtins==1.5.3 13 | flake8-class-attributes-order==0.1.2 14 | flake8-comprehensions==3.8.0 15 | flake8-debugger==4.0.0 16 | flake8-docstrings==1.6.0 17 | flake8-eradicate==1.2.0 18 | flake8-fixme==1.1.1 19 | flake8-polyfill==1.0.2 20 | flake8-print==3.1.4 21 | flake8-quotes==3.0.0 22 | flake8-string-format==0.3.0 23 | flake8-tidy-imports==4.6.0 24 | flake8-typing-imports==1.9.0 25 | flake8-variables-names==0.0.3 26 | mypy==0.921 27 | safety==1.10.3 28 | dlint==0.12.0 29 | flake8-if-statements==0.1.0 30 | flake8-functions==0.0.7 31 | flake8-annotations-coverage==0.0.4 32 | flake8-expression-complexity==0.0.7 33 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = W503, P103, D 3 | max-line-length = 100 4 | use_class_attributes_order_strict_mode = True 5 | max_function_length = 50 6 | max-complexity = 7 7 | per-file-ignores = 8 | __init__.py: F401 9 | tests/*: TAE001 10 | max-annotations-complexity = 4 11 | exclude = node_modules,env,venv,venv36,tests/test_files/ 12 | var_names_exclude_pathes = node_modules,env,venv,venv36 13 | assert_allowed_in_pathes = tests,migrations,env,venv,venv36 14 | adjustable-default-max-complexity = 8 15 | ban-relative-imports = True 16 | min-coverage-percents = 100 17 | 18 | [mypy] 19 | ignore_missing_imports = True 20 | allow_redefinition = True 21 | exclude = build|env|venv.*|migrations|tests 22 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from setuptools import setup, find_packages 4 | 5 | 6 | package_name = 'flake8_expression_complexity' 7 | 8 | 9 | def get_version() -> Optional[str]: 10 | with open('flake8_expression_complexity/__init__.py', 'r') as f: 11 | lines = f.readlines() 12 | for line in lines: 13 | if line.startswith('__version__'): 14 | return line.split('=')[-1].strip().strip("'") 15 | return None 16 | 17 | 18 | def get_long_description() -> str: 19 | with open('README.md') as f: 20 | return f.read() 21 | 22 | 23 | setup( 24 | name=package_name, 25 | description='A flake8 extension that checks expressions complexity', 26 | classifiers=[ 27 | 'Environment :: Console', 28 | 'Framework :: Flake8', 29 | 'Operating System :: OS Independent', 30 | 'Topic :: Software Development :: Documentation', 31 | 'Topic :: Software Development :: Libraries :: Python Modules', 32 | 'Topic :: Software Development :: Quality Assurance', 33 | 'Programming Language :: Python', 34 | 'Programming Language :: Python :: 3.7', 35 | 'Programming Language :: Python :: 3.8', 36 | 'Programming Language :: Python :: 3.9', 37 | 'Programming Language :: Python :: 3.10', 38 | ], 39 | long_description=get_long_description(), 40 | long_description_content_type='text/markdown', 41 | packages=find_packages(), 42 | python_requires='>=3.7', 43 | include_package_data=True, 44 | keywords='flake8', 45 | version=get_version(), 46 | author='Ilya Lebedev', 47 | author_email='melevir@gmail.com', 48 | install_requires=['astpretty', 'flake8'], 49 | entry_points={ 50 | 'flake8.extension': [ 51 | 'ECE = flake8_expression_complexity.checker:ExpressionComplexityChecker', 52 | ], 53 | }, 54 | url='https://github.com/best-doctor/flake8-expression-complexity', 55 | license='MIT', 56 | py_modules=[package_name], 57 | zip_safe=False, 58 | ) 59 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import os 3 | 4 | from flake8_expression_complexity.checker import ExpressionComplexityChecker 5 | 6 | 7 | def run_validator_for_test_file( 8 | filename: str, 9 | max_expression_complexity: int = None, 10 | ignore_django_orm_queries: bool = True, 11 | ): 12 | test_file_path = os.path.join( 13 | os.path.dirname(os.path.abspath(__file__)), 14 | 'test_files', 15 | filename, 16 | ) 17 | with open(test_file_path, 'r') as file_handler: 18 | raw_content = file_handler.read() 19 | tree = ast.parse(raw_content) 20 | checker = ExpressionComplexityChecker(tree=tree, filename=filename) 21 | if max_expression_complexity: 22 | checker.max_expression_complexity = max_expression_complexity 23 | if ignore_django_orm_queries: 24 | checker.ignore_django_orm_queries = ignore_django_orm_queries 25 | 26 | return list(checker.run()) 27 | -------------------------------------------------------------------------------- /tests/test_complexity.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import pytest 4 | from conftest import run_validator_for_test_file 5 | 6 | 7 | def test_fails(): 8 | errors = run_validator_for_test_file('long_expressions.py', max_expression_complexity=3) 9 | assert len(errors) == 5 10 | 11 | 12 | @pytest.mark.skipif(sys.version_info < (3, 8), reason='runs only for python 3.8+') 13 | def test_walrus(): 14 | errors = run_validator_for_test_file('walrus.py', max_expression_complexity=1) 15 | assert len(errors) == 1 16 | 17 | 18 | @pytest.mark.skipif(sys.version_info < (3, 10), reason='runs only for python 3.10+') 19 | def test_match(): 20 | errors = run_validator_for_test_file('match.py', max_expression_complexity=1) 21 | assert len(errors) == 1 22 | -------------------------------------------------------------------------------- /tests/test_files/long_expressions.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | movies = [ 4 | { 5 | 'info': ('Avengers', 2018, {'Thor': 'Chris Hemsworth'}), 6 | }, 7 | ] 8 | 9 | 10 | user = None 11 | today = datetime.datetime.today() 12 | 13 | 14 | class Check: 15 | pass 16 | 17 | 18 | class UserAction: 19 | pass 20 | 21 | 22 | class Sum: 23 | pass 24 | 25 | 26 | async def foo(): 27 | if ( 28 | (user and user.is_authorized) 29 | and user.subscriptions.filter(start_date__lt=today, end_date__gt=today).exists() 30 | and ( 31 | user.total_credits_added 32 | - Check.objects.filter(user=user).aggregate(Sum('price'))['check__sum'] 33 | ) 34 | and ( 35 | UserAction.objects.filter(user=user).last().datetime 36 | > today - datetime.timedelta(days=10) 37 | ) 38 | ): 39 | await bar() 40 | pass 41 | 42 | 43 | async def bar(): 44 | global user 45 | async with foo: 46 | return 'bar' 47 | 48 | 49 | weird_container = [] 50 | sublist = weird_container[10:datetime.datetime.today(), None] 51 | 52 | 53 | with weird_container[10:str(datetime.datetime.today().date())[:100], None]: 54 | pass 55 | 56 | 57 | async def async_for_function(): 58 | async for i in range(5 * len(str(len(range(2 * 5))) * 2[::-1][0:2] + "foo") / 3): 59 | pass 60 | -------------------------------------------------------------------------------- /tests/test_files/match.py: -------------------------------------------------------------------------------- 1 | link = '; rel="next"' 2 | match link.split('; '): 3 | case [brackets_link, 'rel="next"']: 4 | url = brackets_link[1:-1] 5 | case [brackets_link, 'rel="previous"']: 6 | url = brackets_link[1:-1] 7 | case 'blank': 8 | url = '' 9 | -------------------------------------------------------------------------------- /tests/test_files/walrus.py: -------------------------------------------------------------------------------- 1 | def foo(): 2 | if a := 1: 3 | a += 1 4 | return a 5 | --------------------------------------------------------------------------------