├── tests ├── functional │ ├── __init__.py │ ├── async_usage.py │ ├── memoryview_usage.py │ ├── lists.py │ ├── global_usage.py │ ├── for_loop_checker.py │ ├── comprehensions.py │ ├── match_statements.py │ └── loop_invariance.py ├── base.py ├── test_for_loop_checker.py ├── test_list_checker.py ├── test_comprehension_checker.py └── test_loop_invariant.py ├── .pre-commit-hooks.yaml ├── pyproject.toml ├── .vscode └── settings.json ├── perflint ├── __main__.py ├── __init__.py ├── list_checker.py ├── comprehension_checker.py └── for_loop_checker.py ├── TODO.md ├── LICENSE ├── .github └── workflows │ └── python-app.yml ├── CHANGELOG.md ├── .gitignore ├── README.md └── .pylintrc /tests/functional/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/functional/async_usage.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | 4 | async def unnessecary_await(): 5 | return asyncio.sleep(10) 6 | -------------------------------------------------------------------------------- /.pre-commit-hooks.yaml: -------------------------------------------------------------------------------- 1 | - id: perflint 2 | name: perflint 3 | description: '`perflint` is a command-line utility for checking performance anti-patterns.' 4 | entry: perflint 5 | language: python 6 | types: [python] 7 | require_serial: true -------------------------------------------------------------------------------- /tests/functional/memoryview_usage.py: -------------------------------------------------------------------------------- 1 | def example_bytes_slice(): 2 | word = b'the lazy brown dog jumped' 3 | for i in range(10): 4 | # Memoryview slicing is 10x faster than bytes slicing 5 | if word[0:i] == 'the': 6 | return True 7 | 8 | def example_bytes_slice_as_arg(word: bytes): 9 | for i in range(10): 10 | # Memoryview slicing is 10x faster than bytes slicing 11 | if word[0:i] == 'the': 12 | return True 13 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["flit_core >=3.2,<4"] 3 | build-backend = "flit_core.buildapi" 4 | 5 | [project] 6 | name = "perflint" 7 | authors = [{name = "Anthony Shaw"}] 8 | readme = "README.md" 9 | classifiers = ["License :: OSI Approved :: MIT License"] 10 | dynamic = ["version", "description"] 11 | dependencies = ["pylint >=3.0.0,<4.0.0"] 12 | requires-python = ">=3.8" 13 | 14 | [project.urls] 15 | Home = "https://github.com/tonybaloney/perflint" 16 | 17 | [project.scripts] 18 | perflint = "perflint:__main__" -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.linting.banditEnabled": false, 3 | "python.linting.pylintEnabled": true, 4 | "python.linting.enabled": true, 5 | "python.linting.pylintArgs": [ 6 | "--load-plugins", 7 | "perflint", 8 | "--rcfile", 9 | "${workspaceFolder}/.pylintrc" 10 | ], 11 | "python.testing.pytestArgs": [ 12 | "tests" 13 | ], 14 | "python.testing.unittestEnabled": false, 15 | "python.testing.pytestEnabled": true, 16 | "python.formatting.provider": "black" 17 | } -------------------------------------------------------------------------------- /tests/base.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import pylint.testutils 3 | 4 | 5 | class BaseCheckerTestCase(pylint.testutils.CheckerTestCase): 6 | """Extends the basic pylint test case with easier helpers.""" 7 | 8 | @contextlib.contextmanager 9 | def assertAddedMessage(self, message): 10 | """Assert that a msg_id occurred.""" 11 | yield 12 | got = [msg.msg_id for msg in self.linter.release_messages()] 13 | got_str = "\n".join(repr(m) for m in got) 14 | msg = ( 15 | "Expected messages did not match actual.\n" 16 | f"Got:\n{got_str}\n" 17 | ) 18 | assert message in got, msg 19 | -------------------------------------------------------------------------------- /tests/functional/lists.py: -------------------------------------------------------------------------------- 1 | def mutated_list(): 2 | fruit = ["banana", "pear", "orange"] 3 | fruit.clear() 4 | 5 | 6 | def non_mutated_list(): 7 | fruit = ["banana", "pear", "orange"] 8 | len(fruit) 9 | for i in fruit: 10 | print(i) 11 | 12 | 13 | def index_mutated_list(): 14 | fruit = ["banana", "pear", "orange"] 15 | fruit[2] = "mandarin" 16 | len(fruit) 17 | for i in fruit: 18 | print(i) 19 | 20 | 21 | def index_non_mutated_list(): 22 | fruit = ["banana", "pear", "orange"] 23 | print(fruit[2]) 24 | for i in fruit: 25 | print(i) 26 | 27 | 28 | def slice_mutated_list(): 29 | fruit = ["banana", "pear", "orange"] 30 | fruit[0:1] = ("grapes", "plum") 31 | len(fruit) 32 | for i in fruit: 33 | print(i) 34 | -------------------------------------------------------------------------------- /perflint/__main__.py: -------------------------------------------------------------------------------- 1 | import pylint 2 | from pylint.lint import Run as PylintRun 3 | import sys 4 | 5 | from perflint.for_loop_checker import ForLoopChecker, LoopInvariantChecker 6 | from perflint.list_checker import ListChecker 7 | from perflint.comprehension_checker import ComprehensionChecker 8 | 9 | 10 | pylint.modify_sys_path() 11 | 12 | rules = ( 13 | list(ForLoopChecker.msgs.keys()) 14 | + list(LoopInvariantChecker.msgs.keys()) 15 | + list(ListChecker.msgs.keys()) 16 | + list(ComprehensionChecker.msgs.keys()) 17 | ) 18 | 19 | args = [] 20 | args.append("--load-plugins=perflint") 21 | args.append("--disable=all") 22 | args.append("--enable={0}".format(",".join(rules))) 23 | args.extend(sys.argv[1:]) 24 | 25 | try: 26 | PylintRun(args) 27 | except KeyboardInterrupt: 28 | sys.exit(1) 29 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | 3 | * The overhead of short-lived memory allocation and how to avoid it 4 | * Understanding GC-tracked container types and their impact on performance 5 | 6 | - Handling constants 7 | 8 | * The performance of module-level and class attribute constants 9 | * Constant folding 10 | 11 | - Calling functions 12 | 13 | * Does that need to be a function? The overhead of function calls in 3.10 14 | * Comparing function-call types 15 | * Static/classmethods v.s. functions 16 | * Why you should avoid using `**kwargs` for known arguments 17 | 18 | - Working with variables 19 | 20 | * Bad, Better, Best. Comparing the speed of globals, locals and fast locals 21 | * The overhead of instance attributes 22 | * Copying to a logically-named fast local 23 | 24 | - That can probably be a function; when classes can be overkill 25 | -------------------------------------------------------------------------------- /perflint/__init__.py: -------------------------------------------------------------------------------- 1 | """Pylint extension with performance anti-patterns""" 2 | from typing import TYPE_CHECKING 3 | 4 | from perflint.for_loop_checker import ForLoopChecker, LoopInvariantChecker 5 | from perflint.list_checker import ListChecker 6 | from perflint.comprehension_checker import ComprehensionChecker 7 | 8 | if TYPE_CHECKING: 9 | from pylint.lint import PyLinter 10 | 11 | __version__ = "0.8.1" 12 | 13 | 14 | def register(linter: "PyLinter") -> None: 15 | """This required method auto registers the checker during initialization. 16 | 17 | :param linter: The linter to register the checker to. 18 | """ 19 | 20 | linter.register_checker(ForLoopChecker(linter)) 21 | linter.register_checker(LoopInvariantChecker(linter)) 22 | linter.register_checker(ListChecker(linter)) 23 | linter.register_checker(ComprehensionChecker(linter)) 24 | -------------------------------------------------------------------------------- /tests/functional/global_usage.py: -------------------------------------------------------------------------------- 1 | """ Test Globals. """ 2 | 3 | MY_GLOBAL_CONSTANT_C = 1234 4 | MY_GLOBAL_CONSTANT_A = 3.14 5 | 6 | 7 | def global_constant_in_loop(): 8 | """Do a quick sum.""" 9 | 10 | total = MY_GLOBAL_CONSTANT_A 11 | for i in range(10_000): 12 | total += i * MY_GLOBAL_CONSTANT_C 13 | 14 | 15 | def local_constant_in_loop(): 16 | """Do a quick sum.""" 17 | 18 | total = 3.14 19 | for i in range(10_000): 20 | total += i * 1234 21 | 22 | 23 | MY_GLOBAL_VARIABLE = 1234 24 | 25 | 26 | def global_variable_in_loop(): 27 | """Use the module global as a global.""" 28 | global MY_GLOBAL_VARIABLE # [global-statement] 29 | 30 | total = MY_GLOBAL_CONSTANT_A 31 | for i in range(10_000): 32 | MY_GLOBAL_VARIABLE += total 33 | total += i 34 | 35 | 36 | def recursive_example(x): 37 | for i in range(100): 38 | recursive_example(i) 39 | 40 | for i in range(100): 41 | global_constant_in_loop() 42 | -------------------------------------------------------------------------------- /tests/functional/for_loop_checker.py: -------------------------------------------------------------------------------- 1 | """Test lists.""" 2 | 3 | 4 | def simple_list_collection(): 5 | items = [1, 2, 3] 6 | # list is already iterable 7 | for i in list(items): 8 | print(i) 9 | 10 | 11 | def simple_static_tuple(): 12 | """Test warning for casting a tuple to a list.""" 13 | items = (1, 2, 3) 14 | # tuple is already iterable 15 | for i in list(items): 16 | print(i) 17 | 18 | 19 | def simple_static_set(): 20 | """Test warning for casting a set to a list.""" 21 | items = {1, 2, 3} 22 | for i in list(items): # [unnecessary-list-cast] 23 | print(i) 24 | 25 | 26 | def simple_dict_keys(): 27 | """Check that dictionary .items() is being used correctly.""" 28 | fruit = { 29 | "a": "Apple", 30 | "b": "Banana", 31 | } 32 | 33 | for _, value in fruit.items(): # [incorrect-dictionary-iterator] 34 | print(value) 35 | 36 | for key, _ in fruit.items(): # [incorrect-dictionary-iterator] 37 | print(key) 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Anthony Shaw 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/python-app.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Python application 5 | 6 | on: 7 | push: 8 | branches: [ main ] 9 | pull_request: 10 | branches: [ main ] 11 | 12 | jobs: 13 | build: 14 | 15 | name: Test ${{ matrix.os }} Python ${{ matrix.python_version }} 16 | runs-on: ${{ matrix.os }} 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | os: ["macos-11", ubuntu-20.04, "windows-latest"] 21 | python_version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 22 | steps: 23 | - uses: actions/checkout@v2 24 | - name: Setup python 25 | uses: actions/setup-python@v2 26 | with: 27 | python-version: ${{ matrix.python_version }} 28 | architecture: x64 29 | - name: Install dependencies 30 | run: | 31 | python -m pip install --upgrade pip 32 | pip install flake8 pytest flit 33 | flit install 34 | - name: Test with pytest 35 | run: | 36 | pytest 37 | -------------------------------------------------------------------------------- /tests/functional/comprehensions.py: -------------------------------------------------------------------------------- 1 | """Test ComprehensionChecker""" 2 | 3 | 4 | def should_be_a_list_copy(): 5 | """Using the copy() method would be more efficient.""" 6 | original = range(10_000) 7 | filtered = [] 8 | for i in original: 9 | filtered.append(i) 10 | 11 | 12 | def should_be_a_list_comprehension_filtered(): 13 | """A List comprehension would be more efficient.""" 14 | original = range(10_000) 15 | filtered = [] 16 | for i in original: 17 | if i % 2: 18 | filtered.append(i) 19 | 20 | 21 | def should_be_a_dict_comprehension(): 22 | pairs = (("a", 1), ("b", 2)) 23 | result = {} 24 | for x, y in pairs: 25 | result[x] = y 26 | 27 | 28 | def should_be_a_dict_comprehension_filtered(): 29 | pairs = (("a", 1), ("b", 2)) 30 | result = {} 31 | for x, y in pairs: 32 | if y % 2: 33 | result[x] = y 34 | 35 | 36 | def should_not_be_a_list_comprehension(args): 37 | """Internal helper for get_args.""" 38 | res = [] 39 | for arg in args: 40 | if not isinstance(arg, tuple): 41 | res.append(arg) 42 | elif is_callable_type(arg[0]): 43 | if len(arg) == 2: 44 | res.append(Callable[[], arg[1]]) 45 | elif arg[1] is Ellipsis: 46 | res.append(Callable[..., arg[2]]) 47 | else: 48 | res.append(Callable[list(arg[1:-1]), arg[-1]]) 49 | else: 50 | res.append(type(arg[0]).__getitem__(arg[0], _eval_args(arg[1:]))) 51 | return tuple(res) 52 | -------------------------------------------------------------------------------- /tests/test_for_loop_checker.py: -------------------------------------------------------------------------------- 1 | import astroid 2 | import perflint.for_loop_checker 3 | 4 | from base import BaseCheckerTestCase 5 | 6 | 7 | class TestUniqueReturnChecker(BaseCheckerTestCase): 8 | CHECKER_CLASS = perflint.for_loop_checker.ForLoopChecker 9 | 10 | def test_bad_list_cast(self): 11 | for_node = astroid.extract_node(""" 12 | def test(): 13 | items = (1,2,3,4) 14 | 15 | for item in list(items): #@ 16 | pass 17 | """) 18 | 19 | with self.assertAddedMessage("unnecessary-list-cast"): 20 | self.checker.visit_for(for_node) 21 | 22 | def test_bad_list_cast_typed(self): 23 | for_node = astroid.extract_node(""" 24 | def test(items: List[int]): 25 | for item in list(items): #@ 26 | pass 27 | """) 28 | 29 | with self.assertAddedMessage("unnecessary-list-cast"): 30 | self.checker.visit_for(for_node) 31 | 32 | def test_bad_dict_usage_values(self): 33 | for_node = astroid.extract_node(""" 34 | def test(): 35 | d = {1: 1, 2: 2} 36 | 37 | for _, v in d.items(): #@ 38 | pass 39 | """) 40 | 41 | with self.assertAddedMessage("incorrect-dictionary-iterator"): 42 | self.checker.visit_for(for_node) 43 | 44 | def test_bad_dict_usage_keys(self): 45 | for_node = astroid.extract_node(""" 46 | def test(): 47 | d = {1: 1, 2: 2} 48 | 49 | for k, _ in d.items(): #@ 50 | pass 51 | """) 52 | 53 | with self.assertAddedMessage("incorrect-dictionary-iterator"): 54 | self.checker.visit_for(for_node) 55 | -------------------------------------------------------------------------------- /tests/test_list_checker.py: -------------------------------------------------------------------------------- 1 | import astroid 2 | import perflint.list_checker 3 | 4 | from base import BaseCheckerTestCase 5 | 6 | 7 | class TestListMutationChecker(BaseCheckerTestCase): 8 | CHECKER_CLASS = perflint.list_checker.ListChecker 9 | 10 | def test_const_list_should_be_tuple(self): 11 | test_func = astroid.extract_node(""" 12 | def test(): #@ 13 | items = [1,2,3,4] 14 | """) 15 | 16 | with self.assertAddedMessage("use-tuple-over-list"): 17 | self.walk(test_func) 18 | 19 | def test_const_all_be_ignored(self): 20 | test_func = astroid.extract_node(""" 21 | def test(): #@ 22 | __all__ = [1,2,3,4] 23 | """) 24 | 25 | with self.assertNoMessages(): 26 | self.walk(test_func) 27 | 28 | def test_mutated_list_by_method(self): 29 | test_func = astroid.extract_node(""" 30 | def test(): #@ 31 | items = [1,2,3,4] 32 | items.clear() 33 | """) 34 | 35 | with self.assertNoMessages(): 36 | self.walk(test_func) 37 | 38 | def test_mutated_list_by_index(self): 39 | test_func = astroid.extract_node(""" 40 | def test(): #@ 41 | items = [1,2,3,4] 42 | items[0] = 0 43 | """) 44 | 45 | with self.assertNoMessages(): 46 | self.walk(test_func) 47 | 48 | def test_mutated_global_list_by_index(self): 49 | test_func = astroid.extract_node(""" 50 | items = [1,2,3,4] 51 | def test(): #@ 52 | items[0] = 0 53 | """) 54 | 55 | with self.assertNoMessages(): 56 | self.walk(test_func) 57 | 58 | def test_mutated_global_list_by_method(self): 59 | test_func = astroid.extract_node(""" 60 | items = [1,2,3,4] 61 | def test(): #@ 62 | items.append(5) 63 | """) 64 | 65 | with self.assertNoMessages(): 66 | self.walk(test_func) -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changes 2 | 3 | ## 0.8.1 (11th January 2024) 4 | 5 | * Don't recommend `__all__` be a tuple by @tonybaloney in https://github.com/tonybaloney/perflint/pull/49 6 | * Add clarity to the try in a loop rule by @tonybaloney in https://github.com/tonybaloney/perflint/pull/48 7 | 8 | ## 0.8.0 (10th January 2024) 9 | 10 | * fix: pre-commit configuration filename by @adamantike in https://github.com/tonybaloney/perflint/pull/32 11 | * Test other versions of Python in CI by @tonybaloney in https://github.com/tonybaloney/perflint/pull/33 12 | * Migrate to pylint v3 by @jenstroeger in https://github.com/tonybaloney/perflint/pull/46 13 | * Drop support for Python 3.7 14 | * Add support for Python 3.12 15 | 16 | ## 0.7.3 (16th May 2022) 17 | 18 | * Fixes a regression bug in the loop invariant name 19 | 20 | ## 0.7.2 (12th May 2022) 21 | 22 | * Renamed the global usage in loop to `loop-global-usage` 23 | * Added support for invariant f-strings 24 | * Bugfix: Doesn't highlight list, tuple or dict with only constant values as being invariant 25 | 26 | ## 0.7.1 (1st April 2022) 27 | 28 | * Bugfix: No longer suggests list comprehensions when the if statement has an elif/else clause 29 | * Bugfix: Pylint enable/disable arguments are allowed on the `perflint` CLI entry point 30 | 31 | ## 0.7.0 (28th March 2022) 32 | 33 | * Added a standalone entry point (`perflint`) 34 | 35 | ## 0.6.0 (25th March 2022) 36 | 37 | * Added checks for list and dictionary comprehensions as faster alternatives to loops 38 | 39 | ## 0.5.1 (25th March 2022) 40 | 41 | * Marks global lists which are mutated in local scopes 42 | 43 | ## 0.5.0 (25th March 2022) 44 | 45 | * Added a check for non-mutated lists where a tuple would be more efficient (W8301) 46 | 47 | ## 0.4.1 (21st March 2022) 48 | 49 | * (BUG) No longer raises unary operators against const values 50 | * (BUG) No longer raises slices as invariant expressions 51 | * (BUG) raise, return, yield and yield from are considered variant expressions because they impact control-flow 52 | 53 | ## 0.4.0 (21st March 2022) 54 | 55 | * `print()` is considering as having a side-effect and variant. 56 | * Constant values explored in loop-invariance 57 | * Assignment statements have been corrected for constant values 58 | -------------------------------------------------------------------------------- /tests/functional/match_statements.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import curses 3 | from Engine import Game, Level, MapPoint, MapTileTypeHelper, UnitVector 4 | from Keyboard import Keyboard 5 | 6 | 7 | class Renderer(abc.ABC): 8 | def render(self, game: Game): 9 | ... 10 | 11 | 12 | class CursesRenderer(Renderer, Keyboard): 13 | def __init__(self): 14 | self.console = curses.initscr() 15 | curses.curs_set(0) 16 | self.console.keypad(True) 17 | self.console.refresh() 18 | 19 | def render(self, game: Game): 20 | self._drawLevel(game.level) 21 | self.console.refresh() 22 | 23 | def readKey(self) -> str: 24 | return self.console.getch() 25 | 26 | def tryGetUnitVector(self, key: str) -> UnitVector | None: 27 | match key: 28 | case "7": 29 | return UnitVector.NW 30 | case curses.KEY_UP | "8": 31 | return UnitVector.N 32 | case "9": 33 | return UnitVector.NE 34 | case curses.KEY_RIGHT | "6": 35 | return UnitVector.E 36 | case "3": 37 | return UnitVector.SE 38 | case curses.KEY_DOWN | "2": 39 | return UnitVector.S 40 | case "1": 41 | return UnitVector.SW 42 | case curses.KEY_LEFT | "4": 43 | return UnitVector.W 44 | case _: 45 | return None 46 | 47 | def _drawLevel(self, level: Level): 48 | for pointAndTile in level.map.tiles: 49 | self._drawAt( 50 | MapTileTypeHelper.getGlyph(pointAndTile[1].type) 51 | if pointAndTile[1].isExplored 52 | else " ", 53 | pointAndTile[0], 54 | ) 55 | 56 | for obj in level.knownObjects: 57 | self._drawAt(obj.glyph, obj.position) 58 | 59 | def _drawAt(self, glyph: str, point: MapPoint): 60 | self.console.addch(point.y, point.x, glyph) 61 | 62 | def _drawLogString(self, logEntry: str): 63 | for i in range(0, self.console.width): 64 | charToRender = logEntry[i] if i < len(logEntry) else " " 65 | self._drawAt(charToRender, MapPoint(i, self.console.height - 1)) 66 | -------------------------------------------------------------------------------- /.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 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /perflint/list_checker.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List 2 | from astroid import nodes 3 | from pylint.checkers import BaseChecker 4 | from pylint.checkers import utils as checker_utils 5 | 6 | 7 | class ListChecker(BaseChecker): 8 | """ 9 | Check for inefficient list usage 10 | """ 11 | 12 | name = 'list-checker' 13 | priority = -1 14 | msgs = { 15 | 'W8301': ( 16 | 'Use tuple instead of list for a non-mutated sequence', 17 | 'use-tuple-over-list', 18 | '' 19 | ), 20 | } 21 | 22 | def __init__(self, linter=None): 23 | super().__init__(linter) 24 | self._lists_to_watch: List[Dict[str, nodes.AssignName]] = [] 25 | 26 | def visit_assign(self, node: nodes.Assign): 27 | if not isinstance(node.value, nodes.List): 28 | return 29 | if len(node.targets) > 1: 30 | return 31 | if isinstance(node.targets[0], nodes.AssignName): 32 | if node.targets[0].name == "__all__": 33 | return 34 | self._lists_to_watch[-1][node.targets[0].name] = node.targets[0] 35 | 36 | def visit_module(self, node: nodes.Module): 37 | self._lists_to_watch.append({}) 38 | 39 | def _raise_for_scope(self): 40 | _lists = self._lists_to_watch.pop() 41 | for _assignment in _lists.values(): 42 | self.add_message("use-tuple-over-list", node=_assignment.parent.value) 43 | 44 | @checker_utils.only_required_for_messages("use-tuple-over-list") 45 | def leave_module(self, node: nodes.Module): 46 | self._raise_for_scope() 47 | 48 | def visit_functiondef(self, node: nodes.FunctionDef): 49 | self._lists_to_watch.append({}) 50 | 51 | @checker_utils.only_required_for_messages("use-tuple-over-list") 52 | def leave_functiondef(self, node: nodes.FunctionDef): 53 | self._raise_for_scope() 54 | 55 | def visit_call(self, node: nodes.Call) -> None: 56 | """Look for method calls to list nodes.""" 57 | if not isinstance(node.func, nodes.Attribute): 58 | return 59 | if not isinstance(node.func.expr, nodes.Name): 60 | return 61 | # TODO : Filter from non-mutation methods 62 | self._mark_mutated(node.func.expr) 63 | 64 | def _mark_mutated(self, _name: nodes.Name): 65 | if _name.name in self._lists_to_watch[-1]: 66 | del self._lists_to_watch[-1][_name.name] 67 | return 68 | scope, _ = _name.lookup(_name.name) 69 | if not isinstance(scope, nodes.Module): 70 | return 71 | if _name.name in scope.globals \ 72 | and _name.name in self._lists_to_watch[0]: 73 | del self._lists_to_watch[0][_name.name] 74 | 75 | def visit_subscript(self, node: nodes.Subscript): 76 | if not isinstance(node.parent, nodes.Assign): 77 | return 78 | if not isinstance(node.value, nodes.Name): 79 | return 80 | self._mark_mutated(node.value) 81 | -------------------------------------------------------------------------------- /perflint/comprehension_checker.py: -------------------------------------------------------------------------------- 1 | from astroid import nodes 2 | from pylint.checkers import BaseChecker 3 | from pylint.checkers import utils as checker_utils 4 | from astroid.helpers import safe_infer 5 | 6 | 7 | class ComprehensionChecker(BaseChecker): 8 | """ 9 | Check for comprehension usage 10 | """ 11 | 12 | name = "comprehension-checker" 13 | priority = -1 14 | msgs = { 15 | "W8401": ( 16 | "Use a list comprehension instead of a for-loop", 17 | "use-list-comprehension", 18 | "", 19 | ), 20 | "W8402": ( 21 | "Use a list copy instead of a for-loop", 22 | "use-list-copy", 23 | "", 24 | ), 25 | "W8403": ( 26 | "Use a dictionary comprehension instead of a for-loop", 27 | "use-dict-comprehension", 28 | "", 29 | ), 30 | } 31 | 32 | def visit_for(self, node: nodes.For): 33 | pass 34 | 35 | @checker_utils.only_required_for_messages( 36 | "use-list-comprehension", "use-dict-comprehension", "use-list-copy" 37 | ) 38 | def leave_for(self, node: nodes.For): 39 | if len(node.body) != 1: 40 | return 41 | if isinstance(node.body[0], nodes.If) and not node.body[0].orelse: 42 | # TODO : Support a simple, single else statement 43 | if isinstance(node.body[0].body[0], nodes.Expr): 44 | if not isinstance(node.body[0].body[0].value, nodes.Call): 45 | return 46 | # Is append call. 47 | if not isinstance(node.body[0].body[0].value.func, nodes.Attribute): 48 | return 49 | if not node.body[0].body[0].value.func.attrname in ["append", "insert"]: 50 | return 51 | self.add_message("use-list-comprehension", node=node) 52 | elif isinstance(node.body[0].body[0], nodes.Assign): 53 | if len(node.body[0].body[0].targets) != 1: 54 | return 55 | if not isinstance(node.body[0].body[0].targets[0], nodes.Subscript): 56 | return 57 | if not isinstance(node.body[0].body[0].targets[0].value, nodes.Name): 58 | return 59 | inferred_value = safe_infer(node.body[0].body[0].targets[0].value) 60 | if isinstance(inferred_value, nodes.Dict): 61 | self.add_message("use-dict-comprehension", node=node) 62 | elif isinstance(node.body[0], nodes.Expr): 63 | if not isinstance(node.body[0].value, nodes.Call): 64 | return 65 | # Is append call. 66 | if not isinstance(node.body[0].value.func, nodes.Attribute): 67 | return 68 | if not node.body[0].value.func.attrname in ["append", "insert"]: 69 | return 70 | self.add_message("use-list-copy", node=node) 71 | elif isinstance(node.body[0], nodes.Assign): 72 | if len(node.body[0].targets) != 1: 73 | return 74 | if not isinstance(node.body[0].targets[0], nodes.Subscript): 75 | return 76 | if not isinstance(node.body[0].targets[0].value, nodes.Name): 77 | return 78 | inferred_value = safe_infer(node.body[0].targets[0].value) 79 | if isinstance(inferred_value, nodes.Dict): 80 | self.add_message("use-dict-comprehension", node=node) 81 | -------------------------------------------------------------------------------- /tests/test_comprehension_checker.py: -------------------------------------------------------------------------------- 1 | import astroid 2 | import perflint.comprehension_checker 3 | 4 | from base import BaseCheckerTestCase 5 | 6 | 7 | class TestComprehensionChecker(BaseCheckerTestCase): 8 | CHECKER_CLASS = perflint.comprehension_checker.ComprehensionChecker 9 | 10 | def test_simple_list_filter(self): 11 | test_func = astroid.extract_node( 12 | """ 13 | def test(): #@ 14 | items = [1,2,3,4] 15 | result = [] 16 | for i in items: 17 | if i % 2: 18 | result.append(i) 19 | """ 20 | ) 21 | 22 | with self.assertAddedMessage("use-list-comprehension"): 23 | self.walk(test_func) 24 | 25 | def test_complex_list_filter(self): 26 | test_func = astroid.extract_node( 27 | """ 28 | def test(): #@ 29 | items = [1,2,3,4] 30 | result = [] 31 | for i in items: 32 | if i % 2: 33 | result.append(i) 34 | elif i % 2: 35 | result.append(i) 36 | else: 37 | result.append(i) 38 | """ 39 | ) 40 | 41 | with self.assertNoMessages(): 42 | self.walk(test_func) 43 | 44 | def test_simple_list_copy(self): 45 | test_func = astroid.extract_node( 46 | """ 47 | def test(): #@ 48 | items = [1,2,3,4] 49 | result = [] 50 | for i in items: 51 | result.append(i) 52 | """ 53 | ) 54 | 55 | with self.assertAddedMessage("use-list-copy"): 56 | self.walk(test_func) 57 | 58 | def test_simple_dict_assignment(self): 59 | test_func = astroid.extract_node( 60 | """ 61 | def test(): #@ 62 | result = {} 63 | fruit = ["apple", "pear", "orange"] 64 | for idx, name in enumerate(fruit): 65 | result[idx] = name 66 | """ 67 | ) 68 | 69 | with self.assertAddedMessage("use-dict-comprehension"): 70 | self.walk(test_func) 71 | 72 | def test_filtered_dict_assignment(self): 73 | test_func = astroid.extract_node( 74 | """ 75 | def test(): #@ 76 | result = {} 77 | fruit = ["apple", "pear", "orange"] 78 | for idx, name in enumerate(fruit): 79 | if idx % 2: 80 | result[idx] = name 81 | """ 82 | ) 83 | 84 | with self.assertAddedMessage("use-dict-comprehension"): 85 | self.walk(test_func) 86 | 87 | def test_complex_filtered_dict_assignment(self): 88 | test_func = astroid.extract_node( 89 | """ 90 | def test(): #@ 91 | result = {} 92 | fruit = ["apple", "pear", "orange"] 93 | for idx, name in enumerate(fruit): 94 | if idx % 2: 95 | result[idx] = name 96 | elif idx % 3: 97 | result[idx] = name 98 | else: 99 | result[idx] = name 100 | """ 101 | ) 102 | 103 | with self.assertNoMessages(): 104 | self.walk(test_func) 105 | 106 | def test_complex_filtered_dict_assignment2(self): 107 | test_func = astroid.extract_node( 108 | """ 109 | def test(): #@ 110 | result = {} 111 | fruit = ["apple", "pear", "orange"] 112 | for idx, name in enumerate(fruit): 113 | if idx % 2: 114 | result[idx] = name 115 | else: 116 | result[idx] = name 117 | """ 118 | ) 119 | 120 | with self.assertNoMessages(): 121 | self.walk(test_func) 122 | -------------------------------------------------------------------------------- /tests/functional/loop_invariance.py: -------------------------------------------------------------------------------- 1 | """ Test loop invariance """ 2 | import os 3 | 4 | 5 | def cook(item): 6 | ... 7 | 8 | 9 | def cook_pies(): 10 | pies = ("🥧", "🥧", "🥧", "🥧", "🥧", "🥧", "🥧") 11 | 12 | for i, pie in enumerate(pies): 13 | print("Cooking", i, "of", len(pies)) 14 | cook(pie) 15 | 16 | 17 | def foo(x): 18 | pass 19 | 20 | 21 | def loop_invariant_statement(): 22 | """Catch basic loop-invariant function call.""" 23 | x = (1, 2, 3, 4) 24 | 25 | for i in range(10_000): 26 | # x is never changed in this loop scope, 27 | # so this expression should be evaluated outside 28 | print(len(x) * i) 29 | 30 | 31 | def loop_invariant_statement_more_complex(): 32 | """Catch basic loop-invariant function call.""" 33 | x = (1, 2, 3, 4) 34 | i = 6 35 | 36 | for j in range(10_000): 37 | # x and i are never changed in this loop scope, 38 | # so this expression should be evaluated outside 39 | print(len(x) * i + j) 40 | 41 | 42 | def loop_invariant_statement_method_side_effect(): 43 | """Catch basic loop-invariant function call.""" 44 | x = [1, 2, 3, 4] 45 | i = 6 46 | 47 | for j in range(10_000): 48 | print(len(x) * i + j) 49 | x.clear() # x changes as a side-effect 50 | 51 | 52 | def loop_invariant_branching(): 53 | """Ensure node is walked up to find a loop-invariant branch""" 54 | x = [1, 2, 3, 4] 55 | i = 6 56 | 57 | for j in range(10_000): 58 | # Marks entire branch 59 | if len(x) > 2: 60 | print(x * i) 61 | 62 | # Marks comparator, but not print 63 | for j in range(10_000): 64 | if len(x) > 2: 65 | print(x * j) 66 | 67 | 68 | def loop_invariant_statement_side_effect_function(): 69 | """Catch basic loop-invariant function call.""" 70 | x = [1, 2, 3, 4] 71 | i = 6 72 | _len = len 73 | 74 | def len(x): # now with side effects! 75 | x.clear() 76 | return 0 77 | 78 | for j in range(10_000): 79 | # x is never changed in this loop scope, 80 | # so this expression should be evaluated outside 81 | print(len(x) + j) 82 | 83 | len = _len 84 | 85 | 86 | def loop_invariant_statement_but_name(): 87 | """Catch basic loop-invariant function call.""" 88 | i = 6 89 | 90 | for _ in range(10_000): 91 | i 92 | 93 | 94 | def loop_invariant_statement_while(): 95 | """Catch basic loop-invariant function call.""" 96 | x = (1, 2, 3, 4) 97 | i = 0 98 | while i < 100: 99 | i += 1 100 | # x is never changed in this loop scope, 101 | # so this expression should be evaluated outside 102 | print(len(x) * i) 103 | y = x[0] + x[1] 104 | foo(x=y) 105 | 106 | 107 | def loop_invariant_statement_more_complex_while(): 108 | """Catch basic loop-invariant function call.""" 109 | x = [1, 2, 3, 4] 110 | i = 6 111 | j = 0 112 | while j < 100: 113 | # x is never changed in this loop scope, 114 | # so this expression should be evaluated outside 115 | print(len(x) * i + j) 116 | 117 | 118 | def loop_invariant_statement_method_side_effect_while(): 119 | """Catch basic loop-invariant function call.""" 120 | x = [1, 2, 3, 4] 121 | i = 6 122 | j = 0 123 | while j < 10_000: 124 | j += 1 125 | print(len(x) * i + j) 126 | x.clear() # x changes as a side-effect 127 | 128 | 129 | def loop_invariant_branching_while(): 130 | """Ensure node is walked up to find a loop-invariant branch""" 131 | x = [1, 2, 3, 4] 132 | i = 6 133 | j = 0 134 | while j < 10_000: 135 | j += 1 136 | # Marks entire branch 137 | if len(x) > 2: 138 | print(x * i) 139 | 140 | # Marks comparator, but not print 141 | j = 0 142 | while j < 10_000: 143 | j += 1 144 | if len(x) > 2: 145 | print(x * j) 146 | 147 | 148 | def loop_invariant_statement_side_effect_function_while(): 149 | """Catch basic loop-invariant function call.""" 150 | x = [1, 2, 3, 4] 151 | i = 6 152 | _len = len 153 | 154 | def len(x): # now with side effects! 155 | x.clear() 156 | return 0 157 | 158 | j = 0 159 | while j < 10_000: 160 | j += 1 161 | # x is never changed in this loop scope, 162 | # so this expression should be evaluated outside 163 | print(len(x) + j) 164 | 165 | len = _len 166 | 167 | 168 | def loop_invariant_statement_but_name_while(): 169 | """Catch basic loop-invariant function call.""" 170 | i = 6 171 | 172 | for _ in range(10_000): 173 | i 174 | 175 | 176 | def test_dotted_import(items): 177 | for item in items: 178 | val = os.environ[item] 179 | 180 | 181 | def even_worse_dotted_import(items): 182 | for item in items: 183 | val = os.path.exists(item) 184 | 185 | 186 | def loop_invariance_in_self_assignment(): 187 | class Foo: 188 | n = 1 189 | 190 | def loop(self): 191 | i = 4 192 | for self.n in range(4): 193 | print(self.n) 194 | len(i) 195 | 196 | def test(): # @ 197 | f = Foo() 198 | f.loop() 199 | 200 | test() 201 | 202 | 203 | def invariant_fstrings(): 204 | i = 1 205 | for n in range(2): 206 | print(f"{i}") 207 | print(f"{n}") 208 | print(f"{i} + {n}") 209 | print(f"{n} + {i}") 210 | 211 | 212 | def invariant_literals(): 213 | i = 1 214 | for n in range(2): 215 | d = {"x": i} 216 | d2 = {"x": i, "j": n} 217 | d3 = {"j": n} 218 | 219 | for n in range(2): 220 | print("x" * n) 221 | 222 | for n in range(2): 223 | print("x" * i) 224 | 225 | 226 | def invariant_iteration_sub(): # @ 227 | items = (1, 2, 3, 4) 228 | 229 | for _ in items: 230 | x = print("There are ", len(items), "items") 231 | 232 | 233 | def invariant_consts(): 234 | for _ in range(4): 235 | len("BANANANANANAN") 236 | len((1, 2, 3, 4)) 237 | max((1, 2, 3, 4)) 238 | type(None) 239 | 240 | 241 | def invariant_slices(): # @ 242 | l = (1, 2, 3, 4) 243 | for n in range(1): 244 | _ = l[1:2] 245 | _ = l[n:3] 246 | _ = l[1] 247 | 248 | 249 | def variant_slices(): # @ 250 | fruits = ["apple", "banana", "pear"] 251 | for fruit in fruits: 252 | print(fruit) 253 | _ = fruit[1:] 254 | _ = fruit[-1] 255 | _ = fruit[::-1] 256 | 257 | 258 | def constant_expressions(): 259 | for i in range(10): 260 | _ = (0, 1, 2) 261 | _ = [0, 1, 2] 262 | _ = {"a": 1, "b": 2} 263 | 264 | 265 | def variant_f_string(): 266 | x = 1 267 | 268 | for n in range(10): 269 | result = len(f"I'm not sure about {x} {n}") 270 | 271 | 272 | def invariant_f_string(): 273 | x = 1 274 | 275 | for _ in range(10): 276 | result = len(f"I'm not sure about {x}") 277 | -------------------------------------------------------------------------------- /tests/test_loop_invariant.py: -------------------------------------------------------------------------------- 1 | import astroid 2 | import perflint.for_loop_checker 3 | from base import BaseCheckerTestCase 4 | 5 | 6 | class TestUniqueReturnChecker(BaseCheckerTestCase): 7 | CHECKER_CLASS = perflint.for_loop_checker.LoopInvariantChecker 8 | 9 | def test_basic_loop_invariant(self): 10 | test_node = astroid.extract_node( 11 | """ 12 | def test(): #@ 13 | items = (1,2,3,4) 14 | 15 | for item in list(items): 16 | x = print("There are ", len(items), "items") 17 | """ 18 | ) 19 | 20 | with self.assertAddedMessage("loop-invariant-statement"): 21 | self.walk(test_node) 22 | 23 | def test_basic_loop_invariant_while(self): 24 | test_node = astroid.extract_node( 25 | """ 26 | def test(): #@ 27 | items = (1,2,3,4) 28 | i = 0 29 | while i < len(items): 30 | x = print("There are ", len(items), "items") 31 | i += 1 32 | """ 33 | ) 34 | 35 | with self.assertAddedMessage("loop-invariant-statement"): 36 | self.walk(test_node) 37 | 38 | def test_basic_loop_variant_while(self): 39 | test_node = astroid.extract_node( 40 | """ 41 | def test(): #@ 42 | items = (1,2,3,4) 43 | i = 0 44 | while i < len(items): 45 | i += 1 46 | print(i) 47 | """ 48 | ) 49 | 50 | with self.assertAddedMessage("loop-invariant-statement"): 51 | self.walk(test_node) 52 | 53 | def test_kwarg_usage_in_while(self): 54 | test_node = astroid.extract_node( 55 | """ 56 | def foo(arg): 57 | pass 58 | 59 | def test(): #@ 60 | items = (1,2,3,4) 61 | i = 0 62 | while i < len(items): 63 | foo(arg=i) 64 | """ 65 | ) 66 | 67 | with self.assertNoMessages(): 68 | self.walk(test_node) 69 | 70 | def test_basic_loop_variant_by_method(self): 71 | test_node = astroid.extract_node( 72 | """ 73 | def test(): #@ 74 | items = [1,2,3,4] 75 | 76 | for item in items: 77 | x = print("There are ", len(items), "items") 78 | items.clear() 79 | """ 80 | ) 81 | 82 | with self.assertNoMessages(): 83 | self.walk(test_node) 84 | 85 | def test_global_in_for_loop(self): 86 | test_func = astroid.extract_node( 87 | """ 88 | glbl = 1 89 | 90 | def test(): #@ 91 | items = (1,2,3,4) 92 | 93 | for item in list(items): 94 | glbl 95 | """ 96 | ) 97 | 98 | with self.assertAddedMessage("loop-global-usage"): 99 | self.walk(test_func) 100 | 101 | def test_assigned_global_in_for_loop(self): 102 | test_func = astroid.extract_node( 103 | """ 104 | glbl = 1 105 | 106 | def test(): #@ 107 | items = (1,2,3,4) 108 | 109 | for glbl in list(items): 110 | glbl 111 | """ 112 | ) 113 | 114 | with self.assertNoMessages(): 115 | self.walk(test_func) 116 | 117 | def test_self_assignment_for_loop(self): 118 | test_func = astroid.extract_node( 119 | """ 120 | class Foo: 121 | n = 1 122 | 123 | def loop(self): 124 | for self.n in range(4): 125 | print(self.n) 126 | 127 | def test(): #@ 128 | f = Foo() 129 | f.loop() 130 | """ 131 | ) 132 | 133 | with self.assertNoMessages(): 134 | self.walk(test_func) 135 | 136 | def test_byte_slice(self): 137 | test_func = astroid.extract_node( 138 | """ 139 | def test(): #@ 140 | word = b'word' 141 | 142 | for i in range(10): 143 | word[0:i] 144 | """ 145 | ) 146 | 147 | with self.assertAddedMessage("memoryview-over-bytes"): 148 | self.walk(test_func) 149 | 150 | def test_byte_slice_as_arg(self): 151 | test_func = astroid.extract_node( 152 | """ 153 | def test(arg1: bytes): #@ 154 | for i in range(10): 155 | arg1[0:i] 156 | """ 157 | ) 158 | 159 | with self.assertAddedMessage("memoryview-over-bytes"): 160 | self.walk(test_func) 161 | 162 | def test_dotted_name_in_loop(self): 163 | test_func = astroid.extract_node( 164 | """ 165 | import os 166 | def test(): #@ 167 | for item in items: 168 | os.environ[item] 169 | """ 170 | ) 171 | 172 | with self.assertAddedMessage("dotted-import-in-loop"): 173 | self.walk(test_func) 174 | 175 | def test_worse_dotted_name_in_loop(self): 176 | test_func = astroid.extract_node( 177 | """ 178 | import os 179 | def test(): #@ 180 | for item in items: 181 | os.path.exists(item) 182 | """ 183 | ) 184 | 185 | with self.assertAddedMessage("dotted-import-in-loop"): 186 | self.walk(test_func) 187 | 188 | def test_variant_f_string_in_loop(self): 189 | test_func = astroid.extract_node( 190 | """ 191 | def test(): #@ 192 | n = 0 193 | for i in range(2): 194 | print(f"{n} {i}") 195 | """ 196 | ) 197 | 198 | with self.assertNoMessages(): 199 | self.walk(test_func) 200 | 201 | def test_invariant_f_string_in_loop(self): 202 | test_func = astroid.extract_node( 203 | """ 204 | def test(): #@ 205 | i = 2 206 | for _ in range(2): 207 | print(f"{i}") 208 | """ 209 | ) 210 | 211 | with self.assertAddedMessage("loop-invariant-statement"): 212 | self.walk(test_func) 213 | 214 | def test_call_side_effect(self): 215 | test_func = astroid.extract_node( 216 | """ 217 | class Foo: 218 | pass 219 | 220 | def test(): #@ 221 | for n in range(1): 222 | Foo() 223 | """ 224 | ) 225 | 226 | with self.assertNoMessages(): 227 | self.walk(test_func) 228 | 229 | def test_assign_const(self): 230 | test_func = astroid.extract_node( 231 | """ 232 | def test(): #@ 233 | for n in range(1): 234 | x = None 235 | """ 236 | ) 237 | 238 | with self.assertNoMessages(): 239 | self.walk(test_func) 240 | 241 | def test_slice_fragment(self): 242 | test_func = astroid.extract_node( 243 | """ 244 | def test(): #@ 245 | for n in range(1): 246 | x = None 247 | """ 248 | ) 249 | 250 | with self.assertNoMessages(): 251 | self.walk(test_func) 252 | 253 | def test_index_fragment(self): 254 | test_func = astroid.extract_node( 255 | """ 256 | def test(): #@ 257 | fruits = ["apple", "banana", "pear"] 258 | for fruit in fruits: 259 | print(fruit) 260 | _ = fruit[1:] 261 | _ = fruit[-1] 262 | _ = fruit[::-1] 263 | """ 264 | ) 265 | 266 | with self.assertNoMessages(): 267 | self.walk(test_func) 268 | 269 | def test_return(self): 270 | test_func = astroid.extract_node( 271 | """ 272 | def test(): #@ 273 | fruits = ["apple", "banana", "pear"] 274 | for _ in fruits: 275 | return fruits 276 | """ 277 | ) 278 | 279 | with self.assertNoMessages(): 280 | self.walk(test_func) 281 | 282 | def test_constants(self): 283 | test_func = astroid.extract_node( 284 | """ 285 | def test(): #@ 286 | for _ in fruits: 287 | fruits = ["apple", "banana", "pear"] 288 | fruits_tuple = ("apple", "banana", "pear") 289 | _ = [] 290 | _ = [1, 2, 3] 291 | _ = () 292 | _ = (1, 2, 3) 293 | """ 294 | ) 295 | 296 | with self.assertNoMessages(): 297 | self.walk(test_func) 298 | 299 | def test_constant_dictionaries(self): 300 | test_func = astroid.extract_node( 301 | """ 302 | def test(): #@ 303 | for _ in fruits: 304 | _ = {} 305 | fruits = {0: 1, 1: 2} 306 | """ 307 | ) 308 | 309 | with self.assertNoMessages(): 310 | self.walk(test_func) 311 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # perflint 2 | 3 | [![PyPI](https://img.shields.io/pypi/v/perflint)](https://pypi.org/project/perflint/) 4 | [![PyPI - Downloads](https://img.shields.io/pypi/dm/perflint)](https://pypi.org/project/perflint/) 5 | 6 | A Linter for performance anti-patterns 7 | 8 | This project is an early beta. It will likely raise many false-positives in your code. 9 | 10 | ## Installation 11 | 12 | ```console 13 | pip install perflint 14 | ``` 15 | 16 | ## Usage 17 | 18 | Perflint can be used as a standalone linter: 19 | 20 | ```console 21 | perflint your_code/ 22 | ``` 23 | 24 | Or as a `pylint` linter plugin: 25 | 26 | ```console 27 | pylint your_code/ --load-plugins=perflint 28 | ``` 29 | 30 | ### VS Code 31 | 32 | Add these configuration properties to your `.vscode/settings.json` file (create if it doesn't exist): 33 | 34 | ```javascript 35 | { 36 | "python.linting.pylintEnabled": true, 37 | "python.linting.enabled": true, 38 | "python.linting.pylintArgs": [ 39 | "--load-plugins", 40 | "perflint", 41 | "--rcfile", 42 | "${workspaceFolder}/.pylintrc" 43 | ], 44 | } 45 | ``` 46 | 47 | ## Rules 48 | 49 | ### W8101 : Unnecessary `list()` on already iterable type (`unnecessary-list-cast`) 50 | 51 | Using a `list()` call to eagerly iterate over an already iterable type is inefficient as a second list iterator is created, after first iterating the value: 52 | 53 | ```python 54 | def simple_static_tuple(): 55 | """Test warning for casting a tuple to a list.""" 56 | items = (1, 2, 3) 57 | for i in list(items): # [unnecessary-list-cast] 58 | print(i) 59 | ``` 60 | 61 | ### W8102: Incorrect iterator method for dictionary (`incorrect-dictionary-iterator`) 62 | 63 | Python dictionaries store keys and values in two separate tables. They can be individually iterated. Using `.items()` and discarding either the key or the value using `_` is inefficient, when `.keys()` or `.values()` can be used instead: 64 | 65 | ```python 66 | def simple_dict_keys(): 67 | """Check that dictionary .items() is being used correctly. """ 68 | fruit = { 69 | 'a': 'Apple', 70 | 'b': 'Banana', 71 | } 72 | 73 | for _, value in fruit.items(): # [incorrect-dictionary-iterator] 74 | print(value) 75 | 76 | for key, _ in fruit.items(): # [incorrect-dictionary-iterator] 77 | print(key) 78 | ``` 79 | 80 | ### W8201: Loop invariant statement (`loop-invariant-statement`) 81 | 82 | The body of loops will be inspected to determine statements, or expressions where the result is constant (invariant) for each iteration of a loop. This is based on named variables which are not modified during each iteration. 83 | 84 | For example: 85 | 86 | ```python 87 | def loop_invariant_statement(): 88 | """Catch basic loop-invariant function call.""" 89 | x = (1,2,3,4) 90 | 91 | for i in range(10_000): 92 | # x is never changed in this loop scope, 93 | # so this expression should be evaluated outside 94 | print(len(x) * i) # [loop-invariant-statement] 95 | # ^^^^^^ 96 | ``` 97 | 98 | `len(x)` should be evaluated outside the loop since `x` is not modified within the loop. 99 | 100 | ```python 101 | def loop_invariant_statement(): 102 | """Catch basic loop-invariant function call.""" 103 | x = (1,2,3,4) 104 | n = len(x) 105 | for i in range(10_000): 106 | print(n * i) # [loop-invariant-statement] 107 | ``` 108 | 109 | The loop-invariance checker will underline expressions and sub-expressions within the body using the same rules: 110 | 111 | ```python 112 | def loop_invariant_statement_more_complex(): 113 | """Catch basic loop-invariant function call.""" 114 | x = [1,2,3,4] 115 | i = 6 116 | 117 | for j in range(10_000): 118 | # x is never changed in this loop scope, 119 | # so this expression should be evaluated outside 120 | print(len(x) * i + j) 121 | # ^^^^^^^^^^ [loop-invariant-statement] 122 | ``` 123 | 124 | Methods are blindly considered side-effects, so if a method is called on a variable, it is assumed to have possibly changed in value and therefore not loop-invariant: 125 | 126 | ```python 127 | def loop_invariant_statement_method_side_effect(): 128 | """Catch basic loop-invariant function call.""" 129 | x = [1,2,3,4] 130 | i = 6 131 | 132 | for j in range(10_000): 133 | print(len(x) * i + j) 134 | x.clear() # x changes as a side-effect 135 | ``` 136 | 137 | The loop-invariant analysis will walk up the AST until it gets to the whole loop body, so an entire branch could be marked. 138 | For example, the expression `len(x) > 2` is invariant and therefore should be outside the loop. Also, because `x * i` is invariant, that statement should also be outside the loop, therefore the entire branch will be marked: 139 | 140 | ```python 141 | def loop_invariant_branching(): 142 | """Ensure node is walked up to find a loop-invariant branch""" 143 | x = [1,2,3,4] 144 | i = 6 145 | 146 | for j in range(10_000): 147 | # Marks entire branch 148 | if len(x) > 2: 149 | print(x * i) 150 | ``` 151 | 152 | #### Notes on loop invariance 153 | 154 | Functions can have side-effects (print is a good example), so the loop-invariant scanner may give some false-positives. 155 | 156 | It will also highlight dotted expressions, e.g. attribute lookups. This may seem noisy, but in some cases this is valid, e.g. 157 | 158 | ```python 159 | from os.path import exists 160 | import os 161 | 162 | def dotted_import(): 163 | for _ in range(100_000): 164 | return os.path.exists('/') 165 | 166 | def direct_import(): 167 | for _ in range(100_000): 168 | return exists('/') 169 | ``` 170 | 171 | `direct_import()` is 10-15% faster than `dotted_import()` because it doesn't need to load the `os` global, the `path` attribute and the `exists` method for each iteration. 172 | 173 | ### W8202: Global name usage in a loop (`loop-global-usage`) 174 | 175 | Loading globals is slower than loading "fast" local variables. The difference is marginal, but when propagated in a loop, there can be a noticeable speed improvement, e.g.: 176 | 177 | ```python 178 | d = { 179 | "x": 1234, 180 | "y": 5678, 181 | } 182 | 183 | def dont_copy_dict_key_to_fast(): 184 | for _ in range(100000): 185 | d["x"] + d["y"] 186 | d["x"] + d["y"] 187 | d["x"] + d["y"] 188 | d["x"] + d["y"] 189 | d["x"] + d["y"] 190 | 191 | def copy_dict_key_to_fast(): 192 | i = d["x"] 193 | j = d["y"] 194 | 195 | for _ in range(100000): 196 | i + j 197 | i + j 198 | i + j 199 | i + j 200 | i + j 201 | ``` 202 | 203 | `copy_dict_key_to_fast()` executes 65% faster than `dont_copy_dict_key_to_fast()` 204 | 205 | ### R8203 : Try..except blocks have a significant overhead. Avoid using them inside a loop (`loop-try-except-usage`). 206 | 207 | Up to Python 3.10, `try...except` blocks are computationally expensive compared with `if` statements. 208 | 209 | Avoid using them in a loop as they can cause significant overheads. Refactor your code to not require iteration specific details and put the entire loop in the body of a `try` block. 210 | 211 | ### W8204 : Looped slicing of bytes objects is inefficient. Use a memoryview() instead (`memoryview-over-bytes`) 212 | 213 | Slicing of `bytes` is slow as it creates a copy of the data within the requested window. Python has a builtin type, `memoryview` for [zero-copy interactions](https://effectivepython.com/2019/10/22/memoryview-bytearray-zero-copy-interactions): 214 | 215 | ```python 216 | def bytes_slice(): 217 | """Slice using normal bytes""" 218 | word = b'A' * 1000 219 | for i in range(1000): 220 | n = word[0:i] 221 | # ^^^^^^^^^ memoryview-over-bytes 222 | 223 | def memoryview_slice(): 224 | """Convert to a memoryview first.""" 225 | word = memoryview(b'A' * 1000) 226 | for i in range(1000): 227 | n = word[0:i] 228 | 229 | ``` 230 | 231 | `memoryview_slice()` is 30-40% faster than `bytes_slice()` 232 | 233 | ### W8205 : Importing the "%s" name directly is more efficient in this loop. (`dotted-import-in-loop`) 234 | 235 | In Python you can import a module and then access submodules as attributes. You can also access functions as attributes of that module. This keeps your import statements minimal, however, if you use this method in a loop it is inefficient because each loop iteration it will load global, load attribute and then load method. Because the name isn't an object, "load method" falls back to load attribute via a slow internal path. 236 | 237 | Importing the desired function directly is 10-15% faster: 238 | 239 | ```python 240 | import os # NOQA 241 | 242 | def test_dotted_import(items): 243 | for item in items: 244 | val = os.environ[item] # Use `from os import environ` 245 | 246 | def even_worse_dotted_import(items): 247 | for item in items: 248 | val = os.path.exists(item) # Use `from os.path import exists` instead 249 | ``` 250 | 251 | ### W8301 : Use tuple instead of list for a non-mutated sequence. (`use-tuple-over-list`) 252 | 253 | Constructing a tuple is faster than a list and indexing tuples is faster. When the sequence is not mutated, then a tuple should be used instead: 254 | 255 | ```python 256 | def index_mutated_list(): 257 | fruit = ["banana", "pear", "orange"] 258 | fruit[2] = "mandarin" 259 | len(fruit) 260 | for i in fruit: 261 | print(i) 262 | 263 | def index_non_mutated_list(): 264 | fruit = ["banana", "pear", "orange"] # Raises [use-tuple-over-list] 265 | print(fruit[2]) 266 | len(fruit) 267 | for i in fruit: 268 | print(i) 269 | ``` 270 | 271 | Mutation is determined by subscript assignment, slice assignment, or methods called on the list. 272 | 273 | ### W8401 : Use a list comprehension instead of a for-loop (`use-list-comprehension`) 274 | 275 | List comprehensions are 25% more efficient at creating new lists, with or without an if-statement: 276 | 277 | ```python 278 | def should_be_a_list_comprehension_filtered(): 279 | """A List comprehension would be more efficient.""" 280 | original = range(10_000) 281 | filtered = [] 282 | for i in original: 283 | if i % 2: 284 | filtered.append(i) 285 | ``` 286 | 287 | ### W8402 : Use a list copy instead of a for-loop (`use-list-copy`) 288 | 289 | Use either the `list()` constructor or `list.copy()` to copy a list, not another for loop: 290 | 291 | ```python 292 | def should_be_a_list_copy(): 293 | """Using the copy() method would be more efficient.""" 294 | original = range(10_000) 295 | filtered = [] 296 | for i in original: 297 | filtered.append(i) 298 | ``` 299 | 300 | ### W8403 : Use a dictionary comprehension instead of a for-loop (`use-dict-comprehension`) 301 | 302 | Dictionary comprehensions should be used in simple loops to construct dictionaries: 303 | 304 | ```python 305 | def should_be_a_dict_comprehension(): 306 | pairs = (("a", 1), ("b", 2)) 307 | result = {} 308 | for x, y in pairs: 309 | result[x] = y 310 | 311 | def should_be_a_dict_comprehension_filtered(): 312 | pairs = (("a", 1), ("b", 2)) 313 | result = {} 314 | for x, y in pairs: 315 | if y % 2: 316 | result[x] = y 317 | ``` 318 | -------------------------------------------------------------------------------- /perflint/for_loop_checker.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List, Set, Union 2 | from astroid import nodes 3 | from astroid.helpers import safe_infer 4 | from pylint.checkers import BaseChecker 5 | from pylint.checkers import utils as checker_utils 6 | from pylint.interfaces import INFERENCE 7 | 8 | 9 | iterable_types = ( 10 | nodes.Tuple, 11 | nodes.List, 12 | nodes.Set, 13 | ) 14 | iterable_type_names = ( 15 | "tuple", 16 | "list", 17 | "set", 18 | ) 19 | 20 | 21 | def get_children_recursive(node: nodes.NodeNG): 22 | """Get children of a node.""" 23 | for child in node.get_children(): 24 | yield child 25 | yield from get_children_recursive(child) 26 | 27 | 28 | def local_type(name: nodes.NodeNG) -> Union[None, nodes.Name]: 29 | if not isinstance(name, nodes.Name): 30 | return 31 | 32 | if name.name in name.frame().locals: 33 | vals = name.frame().locals[name.name] 34 | if len(vals) > 0: 35 | assigned = vals[0].assign_type() 36 | if isinstance(assigned, nodes.Arguments): 37 | for annotation, arg in zip(assigned.annotations, assigned.arguments): 38 | if arg.name == name.name: 39 | if isinstance(annotation, nodes.Name): 40 | return annotation 41 | elif isinstance(annotation, nodes.Subscript) and isinstance( 42 | annotation.value, nodes.Name 43 | ): 44 | return annotation.value 45 | else: 46 | return None 47 | else: 48 | return 49 | 50 | 51 | class ForLoopChecker(BaseChecker): 52 | """ 53 | Check for poor for-loop usage. 54 | """ 55 | 56 | name = "for-loop-checker" 57 | priority = -1 58 | msgs = { 59 | "W8101": ( 60 | "Unnecessary using of list() on an already iterable type.", 61 | "unnecessary-list-cast", 62 | "Eager iteration of an iterable is inefficient.", 63 | ), 64 | "W8102": ( 65 | "Incorrect iterator method for dictionary, use %s.", 66 | "incorrect-dictionary-iterator", 67 | "Incorrect use of .items() when not unpacking key and value.", 68 | ), 69 | } 70 | 71 | @checker_utils.only_required_for_messages( 72 | "unnecessary-list-cast", "incorrect-dictionary-iterator" 73 | ) 74 | def visit_for(self, node: nodes.For) -> None: 75 | """Visit for loops.""" 76 | if not node.iter: 77 | return 78 | if not isinstance(node.iter, nodes.Call): 79 | return 80 | if not node.iter.func: 81 | return 82 | 83 | # We have multiple types of checkers, function call, or method calls 84 | if isinstance(node.iter.func, nodes.Name): 85 | if not node.iter.args: 86 | return 87 | if node.iter.func.name != "list": 88 | return 89 | 90 | inferred_value = safe_infer(node.iter.args[0]) 91 | if inferred_value: 92 | if isinstance(inferred_value, iterable_types): 93 | self.add_message("unnecessary-list-cast", node=node.iter) 94 | else: 95 | loc = local_type(node.iter.args[0]) 96 | if loc and loc.name.lower() in iterable_type_names: 97 | self.add_message("unnecessary-list-cast", node=node.iter) 98 | 99 | elif isinstance(node.iter.func, nodes.Attribute): 100 | if node.iter.args: # items() never has a list of arguments! 101 | return 102 | if node.iter.func.attrname != "items": 103 | return 104 | if not isinstance(node.target, nodes.Tuple): 105 | return 106 | if not len(node.target.elts) == 2: 107 | return 108 | if ( 109 | isinstance(node.target.elts[0], nodes.AssignName) 110 | and node.target.elts[0].name == "_" 111 | ): 112 | self.add_message( 113 | "incorrect-dictionary-iterator", node=node.iter, args=("values()",) 114 | ) 115 | if ( 116 | isinstance(node.target.elts[1], nodes.AssignName) 117 | and node.target.elts[1].name == "_" 118 | ): 119 | self.add_message( 120 | "incorrect-dictionary-iterator", node=node.iter, args=("keys()",) 121 | ) 122 | 123 | else: 124 | return 125 | 126 | 127 | class LoopInvariantChecker(BaseChecker): 128 | """ 129 | Check for poor for-loop usage. 130 | """ 131 | 132 | name = "loop-invariant-checker" 133 | priority = -1 134 | msgs = { 135 | "W8201": ( 136 | "Consider moving this expression outside of the loop.", 137 | "loop-invariant-statement", 138 | "None of the variables referred to in this expression change within the loop.", 139 | ), 140 | "W8202": ( 141 | "Lookups of global names within a loop is inefficient, copy to a local variable outside of the loop first.", 142 | "loop-global-usage", 143 | "Global name lookups in Python are slower than local names.", 144 | ), 145 | "R8203": ( 146 | "Try..except blocks have an overhead. Avoid using them inside a loop unless you're using them for control-flow. Rule only applies to Python < 3.11.", 147 | "loop-try-except-usage", 148 | "Avoid using try..except within a loop.", 149 | ), 150 | "W8204": ( 151 | "Looped slicing of bytes objects is inefficient. Use a memoryview() instead", 152 | "memoryview-over-bytes", 153 | "Avoid using byte slicing in loops.", 154 | ), 155 | "W8205": ( 156 | 'Importing the "%s" name directly is more efficient in this loop.', 157 | "dotted-import-in-loop", 158 | "Dotted global names in loops are inefficient.", 159 | ), 160 | } 161 | 162 | def __init__(self, linter=None): 163 | super().__init__(linter) 164 | self._loop_level = 0 165 | self._loop_assignments: List[Set[str]] = [] 166 | self._loop_names: List[List[nodes.Name]] = [] 167 | self._loop_consts: List[List[nodes.Const]] = [] 168 | self._ignore: List[nodes.NodeNG] = [] 169 | 170 | @checker_utils.only_required_for_messages("loop-invariant-statement") 171 | def visit_for(self, node: nodes.For) -> None: 172 | """Visit for loop bodies.""" 173 | self._loop_level += 1 174 | if isinstance(node.target, nodes.Tuple): 175 | self._loop_assignments.append(set(el.name for el in node.target.elts)) 176 | elif isinstance(node.target, nodes.AssignName): 177 | self._loop_assignments.append({node.target.name}) 178 | else: 179 | self._loop_assignments.append(set()) 180 | self._loop_names.append([]) 181 | self._loop_consts.append([]) 182 | self._ignore.append(node.iter) 183 | 184 | @checker_utils.only_required_for_messages("loop-invariant-statement") 185 | def visit_while(self, node: nodes.While) -> None: 186 | """Visit while loop bodies.""" 187 | self._loop_level += 1 188 | self._loop_names.append([]) 189 | self._loop_consts.append([]) 190 | self._loop_assignments.append(set()) 191 | self._ignore.append(node.test) 192 | 193 | def _visit_sequence(self, node: Union[nodes.List, nodes.Tuple]) -> None: 194 | if not node.elts: 195 | self._ignore.append(node) 196 | elif all(isinstance(e, nodes.Const) for e in node.elts): 197 | self._ignore.append(node) 198 | 199 | def visit_list(self, node: nodes.List) -> None: 200 | self._visit_sequence(node) 201 | 202 | def visit_tuple(self, node: nodes.Tuple) -> None: 203 | self._visit_sequence(node) 204 | 205 | def visit_dict(self, node: nodes.Dict) -> None: 206 | if not node.items: 207 | self._ignore.append(node) 208 | elif all( 209 | isinstance(k, nodes.Const) and isinstance(v, nodes.Const) 210 | for k, v in node.items 211 | ): 212 | self._ignore.append(node) 213 | 214 | @checker_utils.only_required_for_messages("loop-invariant-statement") 215 | def leave_for(self, node: nodes.For) -> None: 216 | self._leave_loop(node) 217 | 218 | @checker_utils.only_required_for_messages("loop-invariant-statement") 219 | def leave_while(self, node: nodes.While) -> None: 220 | self._leave_loop(node) 221 | 222 | def _leave_loop(self, node: Union[nodes.For, nodes.While]) -> None: 223 | """Drop loop level.""" 224 | self._loop_level -= 1 225 | assigned_names = self._loop_assignments.pop() 226 | unassigned_names = [ 227 | name_node 228 | for name_node in self._loop_names.pop() 229 | if name_node.name not in assigned_names 230 | ] 231 | used_consts = self._loop_consts.pop() 232 | FRAGMENT_NODE_TYPES = ( 233 | nodes.FormattedValue, 234 | nodes.Attribute, 235 | nodes.Keyword, 236 | nodes.Slice, 237 | nodes.UnaryOp, 238 | ) 239 | NAME_NODES = (nodes.Name, nodes.AssignName) 240 | SIDE_EFFECT_NODES = (nodes.Yield, nodes.YieldFrom, nodes.Return, nodes.Raise) 241 | 242 | visited_nodes: Dict[nodes.NodeNG, bool] = dict() 243 | for name_node in [*unassigned_names, *used_consts]: 244 | cur_node = name_node.parent 245 | invariant_node = None 246 | while cur_node != node: 247 | # Walk down parent for variant components. 248 | is_variant = False 249 | if cur_node in visited_nodes: 250 | is_variant = visited_nodes[cur_node] 251 | else: 252 | if isinstance(cur_node, nodes.Call) and isinstance( 253 | cur_node.func, nodes.Name 254 | ): 255 | if cur_node.func in unassigned_names: 256 | is_variant = True 257 | elif ( 258 | cur_node.func.name == "print" 259 | ): # Treat print() as a side-effect 260 | is_variant = True 261 | elif isinstance(cur_node, SIDE_EFFECT_NODES): 262 | is_variant = True 263 | if not is_variant: 264 | for child in get_children_recursive(cur_node): 265 | if ( 266 | isinstance(child, NAME_NODES) 267 | and child.name in assigned_names 268 | ): 269 | is_variant = True 270 | visited_nodes[cur_node] = is_variant 271 | if not is_variant: 272 | invariant_node = cur_node 273 | cur_node = cur_node.parent 274 | else: 275 | break 276 | 277 | if ( 278 | invariant_node 279 | and invariant_node not in self._ignore 280 | and not isinstance(invariant_node, FRAGMENT_NODE_TYPES) 281 | ): 282 | self.add_message("loop-invariant-statement", node=invariant_node) 283 | 284 | def visit_assign(self, node: nodes.Assign) -> None: 285 | """Track assignments in loops.""" 286 | # we don't handle multiple assignment nor slice assignment 287 | if not self._loop_assignments: 288 | return # Skip when empty 289 | 290 | target = node.targets[0] 291 | if isinstance(target, nodes.AssignName): 292 | self._loop_assignments[-1].add(target.name) 293 | 294 | def visit_augassign(self, node: nodes.AugAssign) -> None: 295 | """Track assignments in loops.""" 296 | # we don't handle multiple assignment nor slice assignment 297 | if not self._loop_assignments: 298 | return # Skip when empty 299 | 300 | if isinstance(node.target, nodes.AssignName): 301 | self._loop_assignments[-1].add(node.target.name) 302 | 303 | @checker_utils.only_required_for_messages("loop-global-usage") 304 | def visit_name(self, node: nodes.Name) -> None: 305 | """Look for global names""" 306 | if self._loop_names: 307 | if not checker_utils.is_builtin(node.name) and node.name != "self": 308 | self._loop_names[-1].append(node) 309 | 310 | if checker_utils.is_builtin(node.name): 311 | return 312 | scope, _ = node.lookup(node.name) 313 | if not isinstance(scope, nodes.Module): 314 | return 315 | if ( 316 | node.name in scope.globals 317 | and len(scope.globals[node.name]) > 0 318 | and isinstance(scope.globals[node.name][0], nodes.AssignName) 319 | ): 320 | if self._loop_level > 0: 321 | self.add_message("loop-global-usage", node=node) 322 | 323 | def visit_const(self, node: nodes.Const) -> None: 324 | if self._loop_level == 0: 325 | return 326 | if self._loop_consts: 327 | self._loop_consts[-1].append(node) 328 | 329 | def visit_call(self, node: nodes.Call) -> None: 330 | """Look for method calls.""" 331 | if not isinstance(node.func, nodes.Attribute): 332 | return 333 | if not self._loop_assignments: 334 | return # Skip when empty 335 | if isinstance(node.func.expr, nodes.Name): 336 | self._loop_assignments[-1].add(node.func.expr.name) 337 | 338 | @checker_utils.only_required_for_messages("loop-try-except-usage") 339 | def visit_tryexcept(self, node: nodes.Try) -> None: 340 | if self._loop_level > 0: 341 | self.add_message("loop-try-except-usage", node=node, confidence=INFERENCE) 342 | 343 | @checker_utils.only_required_for_messages("memoryview-over-bytes") 344 | def visit_subscript(self, node: nodes.Subscript) -> None: 345 | if self._loop_level == 0: 346 | return 347 | inferred_value = safe_infer(node.value) 348 | if not inferred_value: 349 | inferred_value = local_type(node.value) 350 | if ( 351 | isinstance(inferred_value, nodes.Name) 352 | and inferred_value.name == "bytes" 353 | ): 354 | self.add_message("memoryview-over-bytes", node=node) 355 | if isinstance(inferred_value, nodes.Const) and isinstance( 356 | inferred_value.value, bytes 357 | ): 358 | self.add_message("memoryview-over-bytes", node=node) 359 | 360 | @checker_utils.only_required_for_messages("dotted-import-in-loop") 361 | def visit_attribute(self, node: nodes.Attribute) -> None: 362 | if self._loop_level == 0: 363 | return 364 | inferred_value = safe_infer(node.expr) 365 | if inferred_value and isinstance(inferred_value, nodes.Module): 366 | if isinstance(node.parent, nodes.Attribute): # TODO: Go higher in the chain 367 | self.add_message( 368 | "dotted-import-in-loop", 369 | node=node.parent, 370 | args=(node.parent.attrname,), 371 | ) 372 | self.add_message("dotted-import-in-loop", node=node, args=(node.attrname,)) 373 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | # A comma-separated list of package or module names from where C extensions may 4 | # be loaded. Extensions are loading into the active Python interpreter and may 5 | # run arbitrary code. 6 | extension-pkg-allow-list= 7 | 8 | # A comma-separated list of package or module names from where C extensions may 9 | # be loaded. Extensions are loading into the active Python interpreter and may 10 | # run arbitrary code. (This is an alternative name to extension-pkg-allow-list 11 | # for backward compatibility.) 12 | extension-pkg-whitelist= 13 | 14 | # Return non-zero exit code if any of these messages/categories are detected, 15 | # even if score is above --fail-under value. Syntax same as enable. Messages 16 | # specified are enabled, while categories only check already-enabled messages. 17 | fail-on= 18 | 19 | # Specify a score threshold to be exceeded before program exits with error. 20 | fail-under=10.0 21 | 22 | # Files or directories to be skipped. They should be base names, not paths. 23 | ignore=CVS 24 | 25 | # Add files or directories matching the regex patterns to the ignore-list. The 26 | # regex matches against paths and can be in Posix or Windows format. 27 | ignore-paths= 28 | 29 | # Files or directories matching the regex patterns are skipped. The regex 30 | # matches against base names, not paths. 31 | ignore-patterns= 32 | 33 | # Python code to execute, usually for sys.path manipulation such as 34 | # pygtk.require(). 35 | #init-hook= 36 | 37 | # Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the 38 | # number of processors available to use. 39 | jobs=1 40 | 41 | # Control the amount of potential inferred values when inferring a single 42 | # object. This can help the performance when dealing with large functions or 43 | # complex, nested conditions. 44 | limit-inference-results=100 45 | 46 | # List of plugins (as comma separated values of python module names) to load, 47 | # usually to register additional checkers. 48 | load-plugins= 49 | 50 | # Pickle collected data for later comparisons. 51 | persistent=yes 52 | 53 | # Minimum Python version to use for version dependent checks. Will default to 54 | # the version used to run pylint. 55 | py-version=3.10 56 | 57 | # When enabled, pylint would attempt to guess common misconfiguration and emit 58 | # user-friendly hints instead of false-positive error messages. 59 | suggestion-mode=yes 60 | 61 | # Allow loading of arbitrary C extensions. Extensions are imported into the 62 | # active Python interpreter and may run arbitrary code. 63 | unsafe-load-any-extension=no 64 | 65 | 66 | [MESSAGES CONTROL] 67 | 68 | # Only show warnings with the listed confidence levels. Leave empty to show 69 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED. 70 | confidence= 71 | 72 | # Disable the message, report, category or checker with the given id(s). You 73 | # can either give multiple identifiers separated by comma (,) or put this 74 | # option multiple times (only on the command line, not in the configuration 75 | # file where it should appear only once). You can also use "--disable=all" to 76 | # disable everything first and then reenable specific checks. For example, if 77 | # you want to run only the similarities checker, you can use "--disable=all 78 | # --enable=similarities". If you want to run only the classes checker, but have 79 | # no Warning level messages displayed, use "--disable=all --enable=classes 80 | # --disable=W". 81 | disable=raw-checker-failed, 82 | bad-inline-option, 83 | locally-disabled, 84 | file-ignored, 85 | suppressed-message, 86 | useless-suppression, 87 | deprecated-pragma, 88 | use-symbolic-message-instead, 89 | invalid-name, 90 | missing-class-docstring, 91 | missing-function-docstring, 92 | wrong-import-order 93 | 94 | # Enable the message, report, category or checker with the given id(s). You can 95 | # either give multiple identifier separated by comma (,) or put this option 96 | # multiple time (only on the command line, not in the configuration file where 97 | # it should appear only once). See also the "--disable" option for examples. 98 | enable=c-extension-no-member 99 | 100 | 101 | [REPORTS] 102 | 103 | # Python expression which should return a score less than or equal to 10. You 104 | # have access to the variables 'error', 'warning', 'refactor', and 'convention' 105 | # which contain the number of messages in each category, as well as 'statement' 106 | # which is the total number of statements analyzed. This score is used by the 107 | # global evaluation report (RP0004). 108 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 109 | 110 | # Template used to display messages. This is a python new-style format string 111 | # used to format the message information. See doc for all details. 112 | #msg-template= 113 | 114 | # Set the output format. Available formats are text, parseable, colorized, json 115 | # and msvs (visual studio). You can also give a reporter class, e.g. 116 | # mypackage.mymodule.MyReporterClass. 117 | output-format=text 118 | 119 | # Tells whether to display a full report or only the messages. 120 | reports=no 121 | 122 | # Activate the evaluation score. 123 | score=yes 124 | 125 | 126 | [REFACTORING] 127 | 128 | # Maximum number of nested blocks for function / method body 129 | max-nested-blocks=5 130 | 131 | # Complete name of functions that never returns. When checking for 132 | # inconsistent-return-statements if a never returning function is called then 133 | # it will be considered as an explicit return statement and no message will be 134 | # printed. 135 | never-returning-functions=sys.exit,argparse.parse_error 136 | 137 | 138 | [LOGGING] 139 | 140 | # The type of string formatting that logging methods do. `old` means using % 141 | # formatting, `new` is for `{}` formatting. 142 | logging-format-style=old 143 | 144 | # Logging modules to check that the string format arguments are in logging 145 | # function parameter format. 146 | logging-modules=logging 147 | 148 | 149 | [SPELLING] 150 | 151 | # Limits count of emitted suggestions for spelling mistakes. 152 | max-spelling-suggestions=4 153 | 154 | # Spelling dictionary name. Available dictionaries: none. To make it work, 155 | # install the 'python-enchant' package. 156 | spelling-dict= 157 | 158 | # List of comma separated words that should be considered directives if they 159 | # appear and the beginning of a comment and should not be checked. 160 | spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy: 161 | 162 | # List of comma separated words that should not be checked. 163 | spelling-ignore-words= 164 | 165 | # A path to a file that contains the private dictionary; one word per line. 166 | spelling-private-dict-file= 167 | 168 | # Tells whether to store unknown words to the private dictionary (see the 169 | # --spelling-private-dict-file option) instead of raising a message. 170 | spelling-store-unknown-words=no 171 | 172 | 173 | [MISCELLANEOUS] 174 | 175 | # List of note tags to take in consideration, separated by a comma. 176 | notes=FIXME, 177 | XXX, 178 | TODO 179 | 180 | # Regular expression of note tags to take in consideration. 181 | #notes-rgx= 182 | 183 | 184 | [TYPECHECK] 185 | 186 | # List of decorators that produce context managers, such as 187 | # contextlib.contextmanager. Add to this list to register other decorators that 188 | # produce valid context managers. 189 | contextmanager-decorators=contextlib.contextmanager 190 | 191 | # List of members which are set dynamically and missed by pylint inference 192 | # system, and so shouldn't trigger E1101 when accessed. Python regular 193 | # expressions are accepted. 194 | generated-members= 195 | 196 | # Tells whether missing members accessed in mixin class should be ignored. A 197 | # class is considered mixin if its name matches the mixin-class-rgx option. 198 | ignore-mixin-members=yes 199 | 200 | # Tells whether to warn about missing members when the owner of the attribute 201 | # is inferred to be None. 202 | ignore-none=yes 203 | 204 | # This flag controls whether pylint should warn about no-member and similar 205 | # checks whenever an opaque object is returned when inferring. The inference 206 | # can return multiple potential results while evaluating a Python object, but 207 | # some branches might not be evaluated, which results in partial inference. In 208 | # that case, it might be useful to still emit no-member and other checks for 209 | # the rest of the inferred objects. 210 | ignore-on-opaque-inference=yes 211 | 212 | # List of class names for which member attributes should not be checked (useful 213 | # for classes with dynamically set attributes). This supports the use of 214 | # qualified names. 215 | ignored-classes=optparse.Values,thread._local,_thread._local 216 | 217 | # List of module names for which member attributes should not be checked 218 | # (useful for modules/projects where namespaces are manipulated during runtime 219 | # and thus existing member attributes cannot be deduced by static analysis). It 220 | # supports qualified module names, as well as Unix pattern matching. 221 | ignored-modules= 222 | 223 | # Show a hint with possible names when a member name was not found. The aspect 224 | # of finding the hint is based on edit distance. 225 | missing-member-hint=yes 226 | 227 | # The minimum edit distance a name should have in order to be considered a 228 | # similar match for a missing member name. 229 | missing-member-hint-distance=1 230 | 231 | # The total number of similar names that should be taken in consideration when 232 | # showing a hint for a missing member. 233 | missing-member-max-choices=1 234 | 235 | # Regex pattern to define which classes are considered mixins ignore-mixin- 236 | # members is set to 'yes' 237 | mixin-class-rgx=.*[Mm]ixin 238 | 239 | # List of decorators that change the signature of a decorated function. 240 | signature-mutators= 241 | 242 | 243 | [VARIABLES] 244 | 245 | # List of additional names supposed to be defined in builtins. Remember that 246 | # you should avoid defining new builtins when possible. 247 | additional-builtins= 248 | 249 | # Tells whether unused global variables should be treated as a violation. 250 | allow-global-unused-variables=yes 251 | 252 | # List of names allowed to shadow builtins 253 | allowed-redefined-builtins= 254 | 255 | # List of strings which can identify a callback function by name. A callback 256 | # name must start or end with one of those strings. 257 | callbacks=cb_, 258 | _cb 259 | 260 | # A regular expression matching the name of dummy variables (i.e. expected to 261 | # not be used). 262 | dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ 263 | 264 | # Argument names that match this expression will be ignored. Default to name 265 | # with leading underscore. 266 | ignored-argument-names=_.*|^ignored_|^unused_ 267 | 268 | # Tells whether we should check for unused import in __init__ files. 269 | init-import=no 270 | 271 | # List of qualified module names which can have objects that can redefine 272 | # builtins. 273 | redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io 274 | 275 | 276 | [FORMAT] 277 | 278 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 279 | expected-line-ending-format= 280 | 281 | # Regexp for a line that is allowed to be longer than the limit. 282 | ignore-long-lines=^\s*(# )??$ 283 | 284 | # Number of spaces of indent required inside a hanging or continued line. 285 | indent-after-paren=4 286 | 287 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 288 | # tab). 289 | indent-string=' ' 290 | 291 | # Maximum number of characters on a single line. 292 | max-line-length=100 293 | 294 | # Maximum number of lines in a module. 295 | max-module-lines=1000 296 | 297 | # Allow the body of a class to be on the same line as the declaration if body 298 | # contains single statement. 299 | single-line-class-stmt=no 300 | 301 | # Allow the body of an if to be on the same line as the test if there is no 302 | # else. 303 | single-line-if-stmt=no 304 | 305 | 306 | [SIMILARITIES] 307 | 308 | # Comments are removed from the similarity computation 309 | ignore-comments=yes 310 | 311 | # Docstrings are removed from the similarity computation 312 | ignore-docstrings=yes 313 | 314 | # Imports are removed from the similarity computation 315 | ignore-imports=no 316 | 317 | # Signatures are removed from the similarity computation 318 | ignore-signatures=no 319 | 320 | # Minimum lines number of a similarity. 321 | min-similarity-lines=4 322 | 323 | 324 | [BASIC] 325 | 326 | # Naming style matching correct argument names. 327 | argument-naming-style=snake_case 328 | 329 | # Regular expression matching correct argument names. Overrides argument- 330 | # naming-style. 331 | #argument-rgx= 332 | 333 | # Naming style matching correct attribute names. 334 | attr-naming-style=snake_case 335 | 336 | # Regular expression matching correct attribute names. Overrides attr-naming- 337 | # style. 338 | #attr-rgx= 339 | 340 | # Bad variable names which should always be refused, separated by a comma. 341 | bad-names=foo, 342 | bar, 343 | baz, 344 | toto, 345 | tutu, 346 | tata 347 | 348 | # Bad variable names regexes, separated by a comma. If names match any regex, 349 | # they will always be refused 350 | bad-names-rgxs= 351 | 352 | # Naming style matching correct class attribute names. 353 | class-attribute-naming-style=any 354 | 355 | # Regular expression matching correct class attribute names. Overrides class- 356 | # attribute-naming-style. 357 | #class-attribute-rgx= 358 | 359 | # Naming style matching correct class constant names. 360 | class-const-naming-style=UPPER_CASE 361 | 362 | # Regular expression matching correct class constant names. Overrides class- 363 | # const-naming-style. 364 | #class-const-rgx= 365 | 366 | # Naming style matching correct class names. 367 | class-naming-style=PascalCase 368 | 369 | # Regular expression matching correct class names. Overrides class-naming- 370 | # style. 371 | #class-rgx= 372 | 373 | # Naming style matching correct constant names. 374 | const-naming-style=UPPER_CASE 375 | 376 | # Regular expression matching correct constant names. Overrides const-naming- 377 | # style. 378 | #const-rgx= 379 | 380 | # Minimum line length for functions/classes that require docstrings, shorter 381 | # ones are exempt. 382 | docstring-min-length=-1 383 | 384 | # Naming style matching correct function names. 385 | function-naming-style=snake_case 386 | 387 | # Regular expression matching correct function names. Overrides function- 388 | # naming-style. 389 | #function-rgx= 390 | 391 | # Good variable names which should always be accepted, separated by a comma. 392 | good-names=i, 393 | j, 394 | k, 395 | ex, 396 | Run, 397 | _ 398 | 399 | # Good variable names regexes, separated by a comma. If names match any regex, 400 | # they will always be accepted 401 | good-names-rgxs= 402 | 403 | # Include a hint for the correct naming format with invalid-name. 404 | include-naming-hint=no 405 | 406 | # Naming style matching correct inline iteration names. 407 | inlinevar-naming-style=any 408 | 409 | # Regular expression matching correct inline iteration names. Overrides 410 | # inlinevar-naming-style. 411 | #inlinevar-rgx= 412 | 413 | # Naming style matching correct method names. 414 | method-naming-style=snake_case 415 | 416 | # Regular expression matching correct method names. Overrides method-naming- 417 | # style. 418 | #method-rgx= 419 | 420 | # Naming style matching correct module names. 421 | module-naming-style=snake_case 422 | 423 | # Regular expression matching correct module names. Overrides module-naming- 424 | # style. 425 | #module-rgx= 426 | 427 | # Colon-delimited sets of names that determine each other's naming style when 428 | # the name regexes allow several styles. 429 | name-group= 430 | 431 | # Regular expression which should only match function or class names that do 432 | # not require a docstring. 433 | no-docstring-rgx=^_ 434 | 435 | # List of decorators that produce properties, such as abc.abstractproperty. Add 436 | # to this list to register other decorators that produce valid properties. 437 | # These decorators are taken in consideration only for invalid-name. 438 | property-classes=abc.abstractproperty 439 | 440 | # Naming style matching correct variable names. 441 | variable-naming-style=snake_case 442 | 443 | # Regular expression matching correct variable names. Overrides variable- 444 | # naming-style. 445 | #variable-rgx= 446 | 447 | 448 | [STRING] 449 | 450 | # This flag controls whether inconsistent-quotes generates a warning when the 451 | # character used as a quote delimiter is used inconsistently within a module. 452 | check-quote-consistency=no 453 | 454 | # This flag controls whether the implicit-str-concat should generate a warning 455 | # on implicit string concatenation in sequences defined over several lines. 456 | check-str-concat-over-line-jumps=no 457 | 458 | 459 | [IMPORTS] 460 | 461 | # List of modules that can be imported at any level, not just the top level 462 | # one. 463 | allow-any-import-level= 464 | 465 | # Allow wildcard imports from modules that define __all__. 466 | allow-wildcard-with-all=no 467 | 468 | # Analyse import fallback blocks. This can be used to support both Python 2 and 469 | # 3 compatible code, which means that the block might have code that exists 470 | # only in one or another interpreter, leading to false positives when analysed. 471 | analyse-fallback-blocks=no 472 | 473 | # Deprecated modules which should not be used, separated by a comma. 474 | deprecated-modules= 475 | 476 | # Output a graph (.gv or any supported image format) of external dependencies 477 | # to the given file (report RP0402 must not be disabled). 478 | ext-import-graph= 479 | 480 | # Output a graph (.gv or any supported image format) of all (i.e. internal and 481 | # external) dependencies to the given file (report RP0402 must not be 482 | # disabled). 483 | import-graph= 484 | 485 | # Output a graph (.gv or any supported image format) of internal dependencies 486 | # to the given file (report RP0402 must not be disabled). 487 | int-import-graph= 488 | 489 | # Force import order to recognize a module as part of the standard 490 | # compatibility libraries. 491 | known-standard-library= 492 | 493 | # Force import order to recognize a module as part of a third party library. 494 | known-third-party=enchant 495 | 496 | # Couples of modules and preferred modules, separated by a comma. 497 | preferred-modules= 498 | 499 | 500 | [CLASSES] 501 | 502 | # Warn about protected attribute access inside special methods 503 | check-protected-access-in-special-methods=no 504 | 505 | # List of method names used to declare (i.e. assign) instance attributes. 506 | defining-attr-methods=__init__, 507 | __new__, 508 | setUp, 509 | __post_init__ 510 | 511 | # List of member names, which should be excluded from the protected access 512 | # warning. 513 | exclude-protected=_asdict, 514 | _fields, 515 | _replace, 516 | _source, 517 | _make 518 | 519 | # List of valid names for the first argument in a class method. 520 | valid-classmethod-first-arg=cls 521 | 522 | # List of valid names for the first argument in a metaclass class method. 523 | valid-metaclass-classmethod-first-arg=cls 524 | 525 | 526 | [DESIGN] 527 | 528 | # List of regular expressions of class ancestor names to ignore when counting 529 | # public methods (see R0903) 530 | exclude-too-few-public-methods= 531 | 532 | # List of qualified class names to ignore when counting class parents (see 533 | # R0901) 534 | ignored-parents= 535 | 536 | # Maximum number of arguments for function / method. 537 | max-args=5 538 | 539 | # Maximum number of attributes for a class (see R0902). 540 | max-attributes=7 541 | 542 | # Maximum number of boolean expressions in an if statement (see R0916). 543 | max-bool-expr=5 544 | 545 | # Maximum number of branch for function / method body. 546 | max-branches=12 547 | 548 | # Maximum number of locals for function / method body. 549 | max-locals=15 550 | 551 | # Maximum number of parents for a class (see R0901). 552 | max-parents=7 553 | 554 | # Maximum number of public methods for a class (see R0904). 555 | max-public-methods=20 556 | 557 | # Maximum number of return / yield for function / method body. 558 | max-returns=6 559 | 560 | # Maximum number of statements in function / method body. 561 | max-statements=50 562 | 563 | # Minimum number of public methods for a class (see R0903). 564 | min-public-methods=2 565 | 566 | 567 | [EXCEPTIONS] 568 | 569 | # Exceptions that will emit a warning when being caught. Defaults to 570 | # "BaseException, Exception". 571 | overgeneral-exceptions=BaseException, 572 | Exception 573 | --------------------------------------------------------------------------------