├── .mdlrc ├── .mdlrc.rb ├── flake8_class_attributes_order ├── __init__.py ├── checker.py ├── ordering_errors.py ├── node_type_weights.py └── model_parts_info.py ├── tests ├── test_files │ ├── async_def.py │ ├── late_docstring.py │ ├── ok.py │ ├── private_ok.py │ ├── private_errored.py │ ├── configurable.py │ ├── private_strict_ok.py │ ├── strict_errored.py │ ├── private_strict_errored.py │ ├── errored.py │ ├── strict_ok.py │ ├── private_custom_order_ok.py │ ├── private_custom_order_errored.py │ ├── special_method.py │ └── properties.py ├── conftest.py └── test_class_attributes_order.py ├── .flake_master ├── .codeclimate.yml ├── .editorconfig ├── Makefile ├── CHANGELOG.md ├── setup.cfg ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ ├── build_pr.yml │ ├── build.yml │ └── publish.yml ├── requirements_dev.txt ├── LICENSE ├── .gitignore ├── setup.py └── README.md /.mdlrc: -------------------------------------------------------------------------------- 1 | style '.mdlrc.rb' 2 | -------------------------------------------------------------------------------- /.mdlrc.rb: -------------------------------------------------------------------------------- 1 | all 2 | rule 'MD013', :line_length => 120 3 | -------------------------------------------------------------------------------- /flake8_class_attributes_order/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.3.0' 2 | -------------------------------------------------------------------------------- /tests/test_files/async_def.py: -------------------------------------------------------------------------------- 1 | class A: 2 | def foo(self): 3 | pass 4 | 5 | async def bar(self): 6 | pass 7 | -------------------------------------------------------------------------------- /tests/test_files/late_docstring.py: -------------------------------------------------------------------------------- 1 | 2 | class Foo: 3 | CONSTANT = 42 4 | 5 | def bar(): 6 | ... 7 | 8 | """Oh, really?""" 9 | -------------------------------------------------------------------------------- /.flake_master: -------------------------------------------------------------------------------- 1 | {"name": "rose", "revision": "4", "url": "https://raw.githubusercontent.com/Melevir/flake_master_presets/master/presets/rose.cfg", "filepath": null} -------------------------------------------------------------------------------- /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | checks: 3 | file-lines: 4 | config: 5 | threshold: 500 6 | complex-logic: 7 | config: 8 | threshold: 8 9 | -------------------------------------------------------------------------------- /tests/test_files/ok.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | class A: 4 | @classmethod 5 | def _get_favicon_path(cls, object_name: str): 6 | pass 7 | 8 | def get_tabs_info(self): 9 | pass 10 | -------------------------------------------------------------------------------- /tests/test_files/private_ok.py: -------------------------------------------------------------------------------- 1 | class A: 2 | @classmethod 3 | def __get_favicon_path(cls, object_name: str): 4 | pass 5 | 6 | def get_tabs_info(self): 7 | pass 8 | 9 | def _get_tabs_info(self): 10 | pass 11 | -------------------------------------------------------------------------------- /tests/test_files/private_errored.py: -------------------------------------------------------------------------------- 1 | class A: 2 | def get_tabs_info(self): 3 | pass 4 | 5 | @classmethod 6 | def __get_favicon_path(cls, object_name: str): 7 | pass 8 | 9 | def _get_tabs_info(self): 10 | pass 11 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | test: 2 | python -m pytest 3 | 4 | coverage: 5 | python -m pytest --cov=flake8_class_attributes_order --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 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Version 0.3.0 (2025-03-20) 4 | 5 | - Add support for setter/deleter methods 6 | - Drop Python 3.8 support 7 | 8 | ## Version 0.2.0 (2024-02-21) 9 | 10 | - Add protected functions support 11 | 12 | ## Version 0.1.3 (2022-02-24) 13 | 14 | - create valid error message for special method 15 | - add python 3.9, 3.10 support 16 | - gitlab actions instead of travis 17 | - fixed flake8 errors 18 | -------------------------------------------------------------------------------- /tests/test_files/configurable.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | class Foo: 4 | CONSTANT = 42 5 | 6 | field = 17 7 | 8 | class Meta: 9 | a = 3 10 | 11 | def __init__(self): 12 | ... 13 | 14 | @property 15 | def _egg(self): 16 | ... 17 | 18 | @property 19 | def egg(self): 20 | ... 21 | 22 | def bar(self): 23 | ... 24 | 25 | def _bar(self): 26 | ... 27 | 28 | def __str__(self): 29 | ... 30 | -------------------------------------------------------------------------------- /tests/test_files/private_strict_ok.py: -------------------------------------------------------------------------------- 1 | class Foo: 2 | CONSTANT = True 3 | 4 | @property 5 | def _bar(self): 6 | ... 7 | 8 | @property 9 | def __bar(self): 10 | ... 11 | 12 | @staticmethod 13 | def _egg(): 14 | ... 15 | 16 | @staticmethod 17 | def __egg(): 18 | ... 19 | 20 | @classmethod 21 | def foobar(cls): 22 | ... 23 | 24 | @classmethod 25 | def __foobar(cls): 26 | ... 27 | -------------------------------------------------------------------------------- /tests/test_files/strict_errored.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | class Foo: 4 | CONSTANT = True 5 | 6 | @property 7 | def _bar(self): 8 | ... 9 | 10 | @property 11 | def bar(self): 12 | ... 13 | 14 | # It's ok with static methods 15 | @staticmethod 16 | def egg(): 17 | ... 18 | 19 | @staticmethod 20 | def _egg(): 21 | ... 22 | 23 | 24 | @classmethod 25 | def _foobar(cls): 26 | ... 27 | 28 | @classmethod 29 | def foobar(cls): 30 | ... 31 | -------------------------------------------------------------------------------- /tests/test_files/private_strict_errored.py: -------------------------------------------------------------------------------- 1 | class Foo: 2 | CONSTANT = True 3 | 4 | @property 5 | def __bar(self): 6 | ... 7 | 8 | @property 9 | def _bar(self): 10 | ... 11 | 12 | # It's ok with static methods 13 | @staticmethod 14 | def __egg(): 15 | ... 16 | 17 | @staticmethod 18 | def _egg(): 19 | ... 20 | 21 | 22 | @classmethod 23 | def __foobar(cls): 24 | ... 25 | 26 | @classmethod 27 | def foobar(cls): 28 | ... 29 | -------------------------------------------------------------------------------- /tests/test_files/errored.py: -------------------------------------------------------------------------------- 1 | DEBUG = True 2 | 3 | 4 | def bar(): 5 | pass 6 | 7 | 8 | def foobar(): 9 | return 1, 2 10 | 11 | 12 | class Foo: 13 | bar() 14 | var1, var2 = foobar() 15 | 16 | 17 | class User: 18 | def fetch_info_from_crm(self): 19 | pass 20 | 21 | LOGIN_FIELD = 'email' # wtf? this should on top of class definition! 22 | 23 | 24 | class UserNode: 25 | class Meta: 26 | model = User 27 | 28 | if DEBUG: # not great idea at all 29 | def is_synced_with_crm(self): 30 | pass 31 | -------------------------------------------------------------------------------- /tests/test_files/strict_ok.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | class Foo: 4 | 5 | class Meta: 6 | a = 3 7 | 8 | CONSTANT = True 9 | 10 | def __init__(): 11 | ... 12 | 13 | @property 14 | def bar(self): 15 | ... 16 | 17 | @property 18 | def _bar(self): 19 | ... 20 | 21 | 22 | @staticmethod 23 | def egg(): 24 | ... 25 | 26 | @staticmethod 27 | def _egg(): 28 | ... 29 | 30 | 31 | @classmethod 32 | def foobar(cls): 33 | ... 34 | 35 | @classmethod 36 | def _foobar(cls): 37 | ... 38 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-complexity = 8 3 | max-line-length = 120 4 | ignore = W503, P103, D 5 | exclude = node_modules,env,venv,venv36,tests/test_files/ 6 | max-annotations-complexity = 4 7 | var_names_exclude_pathes = node_modules,env,venv,venv36 8 | assert_allowed_in_pathes = tests,migrations,env,venv,venv36 9 | adjustable-default-max-complexity = 8 10 | per-file-ignores = 11 | __init__.py: F401 12 | tests/*: TAE001 13 | ban-relative-imports = True 14 | min-coverage-percents = 100 15 | max-expression-complexity = 8 16 | 17 | [mypy] 18 | ignore_missing_imports = True 19 | warn_no_return = False 20 | exclude = build|env|venv.*|migrations|tests 21 | -------------------------------------------------------------------------------- /tests/test_files/private_custom_order_ok.py: -------------------------------------------------------------------------------- 1 | class Foo: 2 | CONSTANT = True 3 | 4 | def __init__(self): 5 | pass 6 | 7 | def __eq__(self, other): 8 | super(Foo, self).__eq__(other) 9 | 10 | @property 11 | def foo(self): 12 | pass 13 | 14 | @property 15 | def _foo(self): 16 | pass 17 | 18 | @classmethod 19 | def _foobar2(cls): 20 | pass 21 | 22 | def _barfoo(self): 23 | pass 24 | 25 | @property 26 | def __foo(self): 27 | pass 28 | 29 | @classmethod 30 | def __foobar(cls): 31 | pass 32 | 33 | def __barfoo(self): 34 | pass 35 | -------------------------------------------------------------------------------- /tests/test_files/private_custom_order_errored.py: -------------------------------------------------------------------------------- 1 | class Foo: 2 | CONSTANT = True 3 | 4 | def __init__(self): 5 | pass 6 | 7 | def __eq__(self, other): 8 | super(Foo, self).__eq__(other) 9 | 10 | @property 11 | def foo(self): 12 | pass 13 | 14 | @property 15 | def _foo(self): 16 | pass 17 | 18 | @property 19 | def __foo(self): 20 | pass 21 | 22 | @classmethod 23 | def foobar(cls): 24 | pass 25 | 26 | @classmethod 27 | def __foobar(cls): 28 | pass 29 | 30 | def _barfoo(self): 31 | pass 32 | 33 | def __barfoo(self): 34 | pass 35 | -------------------------------------------------------------------------------- /tests/test_files/special_method.py: -------------------------------------------------------------------------------- 1 | class A: 2 | 3 | def foo(self): 4 | pass 5 | 6 | def __new__(self): 7 | pass 8 | 9 | class B: 10 | 11 | def foo(self): 12 | pass 13 | 14 | def __init__(self): 15 | pass 16 | 17 | class C: 18 | 19 | def foo(self): 20 | pass 21 | 22 | def __post_init__(self): 23 | pass 24 | 25 | class D: 26 | 27 | def foo(self): 28 | pass 29 | 30 | def __str__(self): 31 | pass 32 | 33 | class E: 34 | 35 | def foo(self): 36 | pass 37 | 38 | def save(self): 39 | pass 40 | 41 | class F: 42 | 43 | def foo(self): 44 | pass 45 | 46 | def delete(self): 47 | pass 48 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /tests/test_files/properties.py: -------------------------------------------------------------------------------- 1 | class Foo: 2 | def __str__(self): 3 | ... 4 | 5 | @property 6 | def bar(self): 7 | ... 8 | 9 | @bar.setter 10 | def bar(self, value): 11 | ... 12 | 13 | @bar.deleter 14 | def bar(self): 15 | ... 16 | 17 | @property 18 | def _bar(self): 19 | ... 20 | 21 | @_bar.setter 22 | def _bar(self, value): 23 | ... 24 | 25 | @_bar.deleter 26 | def _bar(self): 27 | ... 28 | 29 | @property 30 | def __bar(self): 31 | ... 32 | 33 | @__bar.setter 34 | def __bar(self, value): 35 | ... 36 | 37 | @__bar.deleter 38 | def __bar(self): 39 | ... 40 | 41 | @staticmethod 42 | def _egg(): 43 | ... 44 | 45 | -------------------------------------------------------------------------------- /.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.9', '3.10', '3.11', '3.12', '3.13'] 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v5 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 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | pytest==8.3.4 2 | pytest-cov==6.0.0 3 | pydocstyle==6.3.0 4 | flake8==7.1.1 5 | flake8-2020==1.8.1 6 | flake8-blind-except==0.2.1 7 | flake8-bugbear==24.10.31 8 | flake8-builtins==2.5.0 9 | flake8-comprehensions==3.16.0 10 | flake8-debugger==4.1.2 11 | flake8-docstrings==1.7.0 12 | flake8-polyfill==1.0.2 13 | flake8-print==5.0.0 14 | flake8-quotes==3.4.0 15 | flake8-string-format==0.3.0 16 | flake8-todo==0.7 17 | mypy==1.13.0 18 | safety==3.2.13 19 | flake8-eradicate==1.5.0 20 | flake8-fixme==1.1.1 21 | flake8-annotations-complexity==0.1.0 22 | flake8-variables-names==0.0.6 23 | -e . 24 | flake8-broken-line==1.0.0 25 | flake8-tidy-imports==4.11.0 26 | flake8-typing-imports==1.16.0 27 | dlint==0.16.0 28 | flake8-if-statements==1.0.0 29 | flake8-functions==0.0.8 30 | flake8-annotations-coverage==0.0.6 31 | flake8-expression-complexity==0.0.11 32 | packaging==24.2 33 | -------------------------------------------------------------------------------- /.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.9', '3.10', '3.11', '3.12', '3.13'] 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v5 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/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 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import os 3 | from argparse import Namespace 4 | 5 | from flake8_class_attributes_order.checker import ClassAttributesOrderChecker 6 | 7 | 8 | def run_validator_for_test_file(filename, max_annotations_complexity=None, 9 | strict_mode=False, attributes_order=None): 10 | test_file_path = os.path.join( 11 | os.path.dirname(os.path.abspath(__file__)), 12 | 'test_files', 13 | filename, 14 | ) 15 | with open(test_file_path, 'r') as file_handler: 16 | raw_content = file_handler.read() 17 | tree = ast.parse(raw_content) 18 | 19 | options = Namespace() 20 | options.use_class_attributes_order_strict_mode = strict_mode 21 | options.class_attributes_order = attributes_order 22 | ClassAttributesOrderChecker.parse_options(options) 23 | 24 | checker = ClassAttributesOrderChecker(tree=tree, filename=filename) 25 | if max_annotations_complexity: 26 | checker.max_annotations_complexity = max_annotations_complexity 27 | 28 | return list(checker.run()) 29 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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.9', '3.10', '3.11', '3.12', '3.13'] 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Set up Python ${{ matrix.python-version }} 17 | uses: actions/setup-python@v5 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 | environment: 28 | name: pypi 29 | url: https://pypi.org/p/flake8-class-attributes-order 30 | permissions: 31 | id-token: write 32 | steps: 33 | - uses: actions/checkout@v4 34 | - name: Set up Python ${{ matrix.python-version }} 35 | uses: actions/setup-python@v5 36 | - name: Install dependencies 37 | run: pip install -r requirements_dev.txt 38 | - name: Build and publish 39 | run: | 40 | python setup.py sdist bdist_wheel 41 | - name: Publish package distributions to PyPI 42 | uses: pypa/gh-action-pypi-publish@release/v1 43 | 44 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from setuptools import setup, find_packages 4 | 5 | 6 | package_name = 'flake8_class_attributes_order' 7 | 8 | 9 | def get_version() -> Optional[str]: 10 | with open('flake8_class_attributes_order/__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 | 16 | 17 | def get_long_description() -> str: 18 | with open('README.md') as f: 19 | return f.read() 20 | 21 | 22 | setup( 23 | name=package_name, 24 | description='A flake8 extension that checks classes attributes order', 25 | classifiers=[ 26 | 'Environment :: Console', 27 | 'Framework :: Flake8', 28 | 'Operating System :: OS Independent', 29 | 'Topic :: Software Development :: Documentation', 30 | 'Topic :: Software Development :: Libraries :: Python Modules', 31 | 'Topic :: Software Development :: Quality Assurance', 32 | 'Programming Language :: Python', 33 | 'Programming Language :: Python :: 3.9', 34 | 'Programming Language :: Python :: 3.10', 35 | 'Programming Language :: Python :: 3.11', 36 | 'Programming Language :: Python :: 3.12', 37 | 'Programming Language :: Python :: 3.13', 38 | ], 39 | long_description=get_long_description(), 40 | long_description_content_type='text/markdown', 41 | packages=find_packages(), 42 | python_requires='>=3.9', 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=['flake8', 'typing-extensions'], 49 | entry_points={ 50 | 'flake8.extension': [ 51 | 'CCE = flake8_class_attributes_order.checker:ClassAttributesOrderChecker', 52 | ], 53 | }, 54 | url='https://github.com/best-doctor/flake8-class-attributes-order', 55 | license='MIT', 56 | py_modules=[package_name], 57 | zip_safe=False, 58 | ) 59 | -------------------------------------------------------------------------------- /flake8_class_attributes_order/checker.py: -------------------------------------------------------------------------------- 1 | import ast 2 | from typing import Generator, Tuple, List 3 | 4 | from flake8_class_attributes_order import __version__ as version 5 | from flake8_class_attributes_order.node_type_weights import get_node_weights 6 | from flake8_class_attributes_order.model_parts_info import get_model_parts_info 7 | from flake8_class_attributes_order.ordering_errors import get_ordering_errors 8 | 9 | 10 | class ClassAttributesOrderChecker: 11 | 12 | name = 'flake8-class-attributes-order' 13 | version = version 14 | options = None 15 | 16 | def __init__(self, tree, filename: str): 17 | self.filename = filename 18 | self.tree = tree 19 | 20 | @classmethod 21 | def add_options(cls, parser) -> None: 22 | parser.add_option( 23 | '--use-class-attributes-order-strict-mode', 24 | action='store_true', 25 | parse_from_config=True, 26 | help='Require more strict order of private class members', 27 | ) 28 | parser.add_option( 29 | '--class-attributes-order', 30 | comma_separated_list=True, 31 | parse_from_config=True, 32 | help='Comma-separated list of class attributes to ' 33 | 'configure order manually', 34 | ) 35 | parser.add_option( 36 | '--ignore-docstring', 37 | action='store_true', 38 | parse_from_config=True, 39 | help='Ignore docstring errors whenever they appear', 40 | ) 41 | 42 | @classmethod 43 | def parse_options(cls, options: str) -> None: 44 | cls.options = options 45 | 46 | def run(self) -> Generator[Tuple[int, int, str, type], None, None]: 47 | weight_info = get_node_weights(self.options) 48 | classes = [n for n in ast.walk(self.tree) if isinstance(n, ast.ClassDef)] 49 | errors: List[Tuple[int, int, str]] = [] 50 | 51 | for class_def in classes: 52 | model_parts_info = get_model_parts_info(class_def, weight_info) 53 | errors += get_ordering_errors(model_parts_info) 54 | 55 | for lineno, col_offset, error_msg in errors: 56 | yield lineno, col_offset, error_msg, type(self) 57 | -------------------------------------------------------------------------------- /flake8_class_attributes_order/ordering_errors.py: -------------------------------------------------------------------------------- 1 | import ast 2 | from typing import Tuple, List, Union 3 | 4 | 5 | def get_ordering_errors(model_parts_info) -> List[Tuple[int, int, str]]: 6 | errors = [] 7 | for model_part, next_model_part in zip(model_parts_info, model_parts_info[1:] + [None]): 8 | if ( 9 | next_model_part 10 | and model_part['model_name'] == next_model_part['model_name'] 11 | and model_part['weight'] > next_model_part['weight'] 12 | ): 13 | errors.append(( 14 | model_part['node'].lineno, 15 | model_part['node'].col_offset, 16 | 'CCE001 {0}.{1} should be after {0}.{2}'.format( 17 | model_part['model_name'], 18 | get_node_name(model_part['node'], model_part['type']), 19 | get_node_name(next_model_part['node'], next_model_part['type']), 20 | ), 21 | )) 22 | if model_part['type'] in ['expression', 'if']: 23 | errors.append(( 24 | model_part['node'].lineno, 25 | model_part['node'].col_offset, 26 | 'CCE002 Class level expression detected in class {0}, line {1}'.format( 27 | model_part['model_name'], 28 | model_part['node'].lineno, 29 | ), 30 | )) 31 | return errors 32 | 33 | 34 | def get_node_name(node, node_type: str): 35 | special_methods_names = ( 36 | '__new__', 37 | '__init__', 38 | '__post_init__', 39 | '__str__', 40 | 'save', 41 | 'delete', 42 | ) 43 | name_getters_by_type = [ 44 | ('docstring', lambda n: 'docstring'), 45 | ('meta_class', lambda n: 'Meta'), 46 | ('constant', lambda n: n.target.id if isinstance(n, ast.AnnAssign) else n.targets[0].id), # type: ignore 47 | ('field', get_name_for_field_node_type), 48 | (('method',) + special_methods_names, lambda n: n.name), 49 | ('nested_class', lambda n: n.name), 50 | ('expression', lambda n: ''), 51 | ('if', lambda n: 'if ...'), 52 | ] 53 | for type_postfix, name_getter in name_getters_by_type: 54 | if node_type.endswith(type_postfix): # type: ignore 55 | return name_getter(node) 56 | 57 | 58 | def get_name_for_field_node_type(node: Union[ast.Assign, ast.AnnAssign]) -> str: 59 | name = '' 60 | if isinstance(node, ast.AnnAssign): 61 | name = node.target.id if isinstance(node.target, ast.Name) else name 62 | elif isinstance(node.targets[0], ast.Name): 63 | name = node.targets[0].id 64 | elif hasattr(node.targets[0], 'attr'): 65 | name = node.targets[0].attr # type: ignore 66 | elif isinstance(node.targets[0], ast.Tuple): 67 | name = ', '.join([e.id for e in node.targets[0].elts if isinstance(e, ast.Name)]) 68 | 69 | return name 70 | -------------------------------------------------------------------------------- /flake8_class_attributes_order/node_type_weights.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | from typing import List, Mapping, Dict 4 | from typing_extensions import Final 5 | 6 | 7 | NON_STRICT_NODE_TYPE_WEIGHTS: Final[Dict[str, int]] = { 8 | 'docstring': 0, 9 | 'pass': 1, 10 | 'meta_class': 2, 11 | 'nested_class': 3, 12 | 13 | 'constant': 4, 14 | 'field': 5, 15 | 'outer_field': 6, 16 | 17 | 'if': 7, 18 | 'expression': 8, 19 | 20 | '__new__': 9, 21 | '__init__': 10, 22 | '__post_init__': 11, 23 | '__str__': 12, 24 | 25 | 'save': 13, 26 | 'delete': 14, 27 | 28 | 'property_method': 20, 29 | 'protected_property_method': 20, 30 | 'private_property_method': 20, 31 | 'static_method': 22, 32 | 'protected_static_method': 22, 33 | 'private_static_method': 22, 34 | 'class_method': 24, 35 | 'protected_class_method': 24, 36 | 'private_class_method': 24, 37 | 'method': 26, 38 | 'magic_method': 27, 39 | 'protected_method': 27, 40 | 'private_method': 27, 41 | } 42 | 43 | STRICT_NODE_TYPE_WEIGHTS: Final[Dict[str, int]] = { 44 | 'docstring': 0, 45 | 'pass': 1, 46 | 'meta_class': 2, 47 | 'nested_class': 3, 48 | 49 | 'constant': 4, 50 | 'field': 5, 51 | 'outer_field': 6, 52 | 53 | 'if': 7, 54 | 'expression': 8, 55 | 56 | '__new__': 9, 57 | '__init__': 10, 58 | '__post_init__': 11, 59 | '__str__': 12, 60 | 61 | 'save': 13, 62 | 'delete': 14, 63 | 64 | 'property_method': 20, 65 | 'protected_property_method': 21, 66 | 'private_property_method': 22, 67 | 'static_method': 23, 68 | 'protected_static_method': 24, 69 | 'private_static_method': 25, 70 | 'class_method': 26, 71 | 'protected_class_method': 27, 72 | 'private_class_method': 28, 73 | 'method': 29, 74 | 'protected_method': 30, 75 | 'magic_method': 31, 76 | 'private_method': 32, 77 | } 78 | 79 | FIXED_NODE_TYPE_WEIGHTS: Final[Dict[str, int]] = { 80 | 'docstring': 0, 81 | 'pass': 1, 82 | 'expression': 2, 83 | 'if': 3, 84 | } 85 | 86 | CONFIGURABLE_NODE_TYPES: Final[Mapping[str, List[str]]] = { 87 | 'nested_class': ['nested_class'], 88 | 'meta_class': ['meta_class', 'nested_class'], 89 | 90 | 'field': ['field'], 91 | 'constant': ['constant', 'field'], 92 | 'outer_field': ['outer_field', 'field'], 93 | 94 | 'method': ['method'], 95 | 'magic_method': ['magic_method', 'method'], 96 | '__new__': ['__new__', 'magic_method', 'method'], 97 | '__init__': ['__init__', 'magic_method', 'method'], 98 | '__post_init__': ['__post_init__', 'magic_method', 'method'], 99 | '__str__': ['__str__', 'magic_method', 'method'], 100 | 101 | 'protected_method': ['protected_method', 'method'], 102 | 'private_method': ['private_method', 'method'], 103 | 104 | 'save': ['save', 'method'], 105 | 'delete': ['delete', 'method'], 106 | 107 | 'property_method': ['property_method', 'method'], 108 | 'protected_property_method': ['protected_property_method', 'property_method', 'method'], 109 | 'private_property_method': ['private_property_method', 'property_method', 'method'], 110 | 'static_method': ['static_method', 'method'], 111 | 'protected_static_method': ['protected_static_method', 'static_method', 'method'], 112 | 'private_static_method': ['private_static_method', 'static_method', 'method'], 113 | 'class_method': ['class_method', 'method'], 114 | 'protected_class_method': ['protected_class_method', 'class_method', 'method'], 115 | 'private_class_method': ['private_class_method', 'class_method', 'method'], 116 | } 117 | 118 | 119 | def get_node_weights(options=None) -> Mapping[str, int]: 120 | use_strict_mode = bool(options.use_class_attributes_order_strict_mode) 121 | class_attributes_order = options.class_attributes_order 122 | ignore_docstring = hasattr(options, 'ignore_docstring') and bool(options.ignore_docstring) 123 | 124 | if use_strict_mode and class_attributes_order: 125 | warnings.warn( # noqa: B028 126 | 'Both options that are exclusive provided: --use-class-attributes-order-strict-mode ' 127 | 'and --class-attributes-order. Order defined in --class-attributes-order will be used ' 128 | 'to check against.', 129 | Warning, 130 | ) 131 | 132 | if class_attributes_order: 133 | node_type_weights = FIXED_NODE_TYPE_WEIGHTS.copy() 134 | node_to_configured_weight = { 135 | node_type: weight for weight, node_type in enumerate( 136 | class_attributes_order, 137 | start=len(node_type_weights)) 138 | } 139 | 140 | for node_type, node_type_path in CONFIGURABLE_NODE_TYPES.items(): 141 | for node_type_or_supertype in node_type_path: 142 | if node_type_or_supertype in node_to_configured_weight: 143 | node_type_weights[node_type] = node_to_configured_weight[node_type_or_supertype] 144 | break 145 | 146 | result = node_type_weights 147 | 148 | elif use_strict_mode: 149 | 150 | result = STRICT_NODE_TYPE_WEIGHTS 151 | else: 152 | result = NON_STRICT_NODE_TYPE_WEIGHTS 153 | 154 | if ignore_docstring: 155 | result.pop('docstring') 156 | return result 157 | -------------------------------------------------------------------------------- /flake8_class_attributes_order/model_parts_info.py: -------------------------------------------------------------------------------- 1 | import ast 2 | from typing import Dict, Mapping, Optional, Set, Union 3 | 4 | 5 | def get_model_parts_info(model_ast, weights: Mapping[str, int]): 6 | parts_info = [] 7 | for child_node in model_ast.body: 8 | node_type = get_model_node_type(child_node) 9 | if node_type and node_type in weights: 10 | parts_info.append({ 11 | 'model_name': model_ast.name, 12 | 'node': child_node, 13 | 'type': node_type, 14 | 'weight': weights[node_type], 15 | }) 16 | return parts_info 17 | 18 | 19 | def get_model_node_type(child_node) -> Optional[str]: 20 | direct_node_types_mapping = [ 21 | (ast.If, lambda n: 'if'), 22 | (ast.Pass, lambda n: 'pass'), 23 | ((ast.Assign, ast.AnnAssign), lambda n: get_assighment_type(n)), 24 | ((ast.FunctionDef, ast.AsyncFunctionDef), lambda n: get_funcdef_type(n)), 25 | (ast.Expr, lambda n: 'docstring' if isinstance(n.value, ast.Constant) else 'expression'), 26 | (ast.ClassDef, lambda n: 'meta_class' if child_node.name == 'Meta' else 'nested_class'), 27 | ] 28 | for type_or_type_tuple, type_getter in direct_node_types_mapping: 29 | if isinstance(child_node, type_or_type_tuple): # type: ignore 30 | return type_getter(child_node) 31 | 32 | 33 | def get_assighment_type(child_node) -> str: 34 | assignee_node = child_node.target if isinstance(child_node, ast.AnnAssign) else child_node.targets[0] 35 | assighment_type = 'field' 36 | if isinstance(assignee_node, ast.Subscript): 37 | assighment_type = 'expression' 38 | if isinstance(assignee_node, ast.Name) and is_caps_lock_str(assignee_node.id): 39 | assighment_type = 'constant' 40 | if isinstance(child_node.value, ast.Call): 41 | dump_callable = ast.dump(child_node.value.func) 42 | if ( 43 | 'ForeignKey' in dump_callable 44 | or 'ManyToManyField' in dump_callable 45 | or 'OneToOneField' in dump_callable 46 | or 'GenericRelation' in dump_callable 47 | ): 48 | assighment_type = 'outer_field' 49 | return assighment_type 50 | 51 | 52 | def get_funcdef_type(child_node: Union[ast.FunctionDef, ast.AsyncFunctionDef]) -> str: 53 | special_methods_names = { 54 | '__new__', 55 | '__init__', 56 | '__post_init__', 57 | '__str__', 58 | 'save', 59 | 'delete', 60 | } 61 | decorator_names_to_types_map = { 62 | 'property': 'property_method', 63 | 'cached_property': 'property_method', 64 | 'setter': 'property_method', 65 | 'deleter': 'property_method', 66 | 'staticmethod': 'static_method', 67 | 'classmethod': 'class_method', 68 | 69 | 'protected_property': 'protected_property_method', 70 | 'protected_cached_property': 'protected_property_method', 71 | 'protected_setter': 'protected_property_method', 72 | 'protected_deleter': 'protected_property_method', 73 | 'protected_staticmethod': 'protected_static_method', 74 | 'protected_classmethod': 'protected_class_method', 75 | 76 | 'private_property': 'private_property_method', 77 | 'private_cached_property': 'private_property_method', 78 | 'private_setter': 'private_property_method', 79 | 'private_deleter': 'private_property_method', 80 | 'private_staticmethod': 'private_static_method', 81 | 'private_classmethod': 'private_class_method', 82 | } 83 | funcdef = get_funcdef_type_by_decorator_info(child_node, decorator_names_to_types_map) 84 | if not funcdef: 85 | funcdef = get_funcdef_type_by_node_name(child_node, special_methods_names) 86 | return funcdef 87 | 88 | 89 | def get_funcdef_type_by_decorator_info( # noqa: CFQ004 90 | node: Union[ast.FunctionDef, ast.AsyncFunctionDef], 91 | decorator_names_to_types_map: Dict[str, str], 92 | ) -> Union[str, None]: 93 | for decorator_info in node.decorator_list: 94 | if isinstance(decorator_info, ast.Name): 95 | decorator_id = decorator_info.id 96 | elif isinstance(decorator_info, ast.Attribute): 97 | decorator_id = decorator_info.attr 98 | else: 99 | continue 100 | 101 | if decorator_id in decorator_names_to_types_map: 102 | if node.name.startswith('__'): 103 | return decorator_names_to_types_map[f'private_{decorator_id}'] 104 | if node.name.startswith('_'): 105 | return decorator_names_to_types_map[f'protected_{decorator_id}'] 106 | return decorator_names_to_types_map[decorator_id] 107 | return None 108 | 109 | 110 | def get_funcdef_type_by_node_name( # noqa: CFQ004 111 | node: Union[ast.FunctionDef, ast.AsyncFunctionDef], 112 | special_methods_names: Set[str], 113 | default_type: str = 'method', 114 | ) -> str: 115 | if node.name in special_methods_names: 116 | return node.name 117 | if node.name.startswith('__') and node.name.endswith('__'): 118 | return 'magic_method' 119 | if node.name.startswith('__'): 120 | return 'private_method' 121 | if node.name.startswith('_'): 122 | return 'protected_method' 123 | return default_type 124 | 125 | 126 | def is_caps_lock_str(var_name: str) -> bool: 127 | return var_name.upper() == var_name 128 | -------------------------------------------------------------------------------- /tests/test_class_attributes_order.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | from conftest import run_validator_for_test_file 4 | 5 | 6 | def test_file_with_improper_default_order(): 7 | errors = run_validator_for_test_file('errored.py') 8 | assert len(errors) == 4 9 | 10 | 11 | def test_async_def_not_breaks_validator(): 12 | assert not run_validator_for_test_file('async_def.py') 13 | 14 | 15 | def test_ok_cases_produces_no_errors(): 16 | assert not run_validator_for_test_file('ok.py') 17 | 18 | 19 | def test_properties_produces_no_errors(): 20 | assert not run_validator_for_test_file('properties.py') 21 | 22 | 23 | def test_strict_mode_improper_order(): 24 | errors = run_validator_for_test_file( 25 | 'strict_errored.py', strict_mode=True 26 | ) 27 | assert len(errors) == 2 28 | 29 | 30 | def test_strict_mode_no_errors(): 31 | assert not run_validator_for_test_file( 32 | 'strict_ok.py', strict_mode=True 33 | ) 34 | 35 | 36 | def test_private_errored(): 37 | errors = run_validator_for_test_file( 38 | 'private_errored.py', 39 | ) 40 | assert len(errors) == 1 41 | assert errors[0][2] == 'CCE001 A.get_tabs_info should be after A.__get_favicon_path' 42 | 43 | 44 | def test_private_ok(): 45 | assert not run_validator_for_test_file('private_ok.py') 46 | 47 | 48 | def test_strict_mode_with_private_errored(): 49 | errors = run_validator_for_test_file( 50 | 'private_strict_errored.py', strict_mode=True 51 | ) 52 | assert len(errors) == 3 53 | 54 | 55 | def test_strict_mode_with_private_no_errors(): 56 | assert not run_validator_for_test_file( 57 | 'private_strict_ok.py', strict_mode=True 58 | ) 59 | 60 | 61 | def test_configurable_order_with_private_order_no_errors(): 62 | assert not run_validator_for_test_file( 63 | 'private_custom_order_ok.py', 64 | attributes_order=[ 65 | 'constant', 66 | 'field', 67 | 'meta_class', 68 | '__new__', 69 | '__init__', 70 | 'magic_method', 71 | 'property_method', 72 | 'static_method', 73 | 'class_method', 74 | 'method', 75 | 'protected_property_method', 76 | 'protected_static_method', 77 | 'protected_class_method', 78 | 'protected_method', 79 | 'private_property_method', 80 | 'private_static_method', 81 | 'private_class_method', 82 | 'private_method' 83 | ], 84 | ) 85 | 86 | 87 | def test_configurable_order_with_private_order_errored(): 88 | errors = run_validator_for_test_file( 89 | 'private_custom_order_errored.py', 90 | attributes_order=[ 91 | 'constant', 92 | 'field', 93 | 'meta_class', 94 | '__new__', 95 | '__init__', 96 | 'magic_method', 97 | 'property_method', 98 | 'static_method', 99 | 'class_method', 100 | 'method', 101 | 'protected_property_method', 102 | 'protected_static_method', 103 | 'protected_class_method', 104 | 'protected_method', 105 | 'private_property_method', 106 | 'private_static_method', 107 | 'private_class_method', 108 | 'private_method', 109 | ], 110 | ) 111 | assert len(errors) == 2 112 | 113 | 114 | def test_configurable_order_correct_order(): 115 | assert not run_validator_for_test_file( 116 | 'configurable.py', 117 | attributes_order=[ 118 | 'constant', 119 | 'field', 120 | 'meta_class', 121 | 'magic_method', 122 | 'property_method', 123 | 'method', 124 | 'private_method', 125 | '__str__', 126 | ], 127 | ) 128 | 129 | 130 | def test_configurable_order_wrong_order(): 131 | errors = run_validator_for_test_file( 132 | 'configurable.py', 133 | attributes_order=[ 134 | 'field', 135 | 'constant', 136 | 'meta_class', 137 | 'nested_class', 138 | 'magic_method', 139 | 'property_method', 140 | 'method', 141 | '__str__', 142 | 'protected_method', 143 | 'private_method', 144 | ], 145 | ) 146 | assert len(errors) == 2 147 | 148 | 149 | def test_child_attributes_fallback_to_parent_if_not_configured(): 150 | assert not run_validator_for_test_file( 151 | 'configurable.py', 152 | attributes_order=['field', 'nested_class', 'method'], 153 | ) 154 | errors = run_validator_for_test_file( 155 | 'configurable.py', 156 | attributes_order=['nested_class', 'field', 'method'], 157 | ) 158 | assert len(errors) == 1 159 | 160 | 161 | def test_ignore_base_attribute_and_subattributes_if_not_configured(): 162 | errors = run_validator_for_test_file( 163 | 'configurable.py', 164 | attributes_order=['property_method', 'protected_property_method', 'private_property_method'], 165 | ) 166 | assert len(errors) == 1 167 | 168 | 169 | def test_always_require_fixed_attributes(): 170 | errors = run_validator_for_test_file( 171 | 'late_docstring.py', 172 | attributes_order=['field', 'method'], 173 | ) 174 | assert len(errors) == 1 175 | 176 | 177 | def test_warning_if_both_strict_mode_and_configurable_order_defined(): 178 | with warnings.catch_warnings(record=True) as w: 179 | run_validator_for_test_file( 180 | 'ok.py', strict_mode=True, attributes_order=['nested_class', 'field', 'method'] 181 | ) 182 | assert len(w) == 1 183 | 184 | 185 | def test_save_delete(): 186 | errors = run_validator_for_test_file( 187 | 'special_method.py', 188 | ) 189 | assert len(errors) == 6 190 | assert errors[0][2] == 'CCE001 A.foo should be after A.__new__' 191 | assert errors[1][2] == 'CCE001 B.foo should be after B.__init__' 192 | assert errors[2][2] == 'CCE001 C.foo should be after C.__post_init__' 193 | assert errors[3][2] == 'CCE001 D.foo should be after D.__str__' 194 | assert errors[4][2] == 'CCE001 E.foo should be after E.save' 195 | assert errors[5][2] == 'CCE001 F.foo should be after F.delete' 196 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # flake8-class-attributes-order 2 | 3 | [![Build Status](https://github.com/best-doctor/flake8-class-attributes-order/actions/workflows/build.yml/badge.svg?branch=master)](https://github.com/best-doctor/flake8-class-attributes-order/actions/workflows/build.yml) 4 | [![Maintainability](https://api.codeclimate.com/v1/badges/28b7cd9d0714ec4b93a3/maintainability)](https://codeclimate.com/github/best-doctor/flake8-class-attributes-order/maintainability) 5 | [![Test Coverage](https://api.codeclimate.com/v1/badges/28b7cd9d0714ec4b93a3/test_coverage)](https://codeclimate.com/github/best-doctor/flake8-class-attributes-order/test_coverage) 6 | ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/flake8-class-attributes-order) 7 | 8 | An extension for flake8 to report on wrong class attributes order and 9 | class level logic. 10 | 11 | The validator can extract class attribute type: docstring, property, 12 | nested class, `GLOBAL_VAR`, etc. 13 | If django model fields are detected, the validator can detect, 14 | if the field is link to another table (foreign key, generic key, etc) 15 | or not. 16 | 17 | After resolving each attribute type, validator checks attributes order. 18 | 19 | Default configuration checks for the following order of attributes: 20 | 21 | - `__new__` 22 | - `__init__` 23 | - `__post_init__` 24 | - other magic methods 25 | - `@property` 26 | - `@staticmethod` 27 | - `@classmethod` 28 | - other methods 29 | - protected methods 30 | - private methods 31 | 32 | If the order is broken, validator will report on it. 33 | 34 | Besides methods, the validator checks other attributes methods: 35 | docstrings, nested classes, constants, attributes, and so on. 36 | 37 | Also validator checks, if class has no class level logic and report 38 | if any. Here is an example: 39 | 40 | ```python 41 | class PhoneForm(forms.Form): 42 | phone = forms.CharField(17, label='Телефон'.upper()) 43 | 44 | # this should happen in __init__! 45 | phone.widget.attrs.update({'class': 'form-control phone'}) 46 | 47 | ``` 48 | 49 | ## Installation 50 | 51 | ```terminal 52 | pip install flake8-class-attributes-order 53 | ``` 54 | 55 | ## Configuration 56 | 57 | ### Strict mode 58 | 59 | There is another preconfigured order that is more strict on private subtypes: 60 | 61 | - `__new__` 62 | - `__init__` 63 | - `__post_init__` 64 | - other magic method 65 | - `@property` 66 | - `@staticmethod` 67 | - `@classmethod` 68 | - other methods 69 | - protected `@property` 70 | - protected `@staticmethod` 71 | - protected `@classmethod` 72 | - other protected methods 73 | - private `@property` 74 | - private `@staticmethod` 75 | - private `@classmethod` 76 | - other private methods 77 | 78 | To enable strict validation, please set the flag in your config file: 79 | 80 | ```ini 81 | [flake8] 82 | use_class_attributes_order_strict_mode = True 83 | ``` 84 | 85 | ### Manual order configuration 86 | 87 | Order can be manually configured via `class_attributes_order` config setting. 88 | 89 | For example, if you prefer to put `class Meta` after constants and fields: 90 | 91 | ```ini 92 | [flake8] 93 | class_attributes_order = 94 | field, 95 | meta_class, 96 | nested_class, 97 | magic_method, 98 | property_method, 99 | static_method, 100 | class_method, 101 | method, 102 | protected_method, 103 | private_method 104 | ``` 105 | 106 | Configurable options: 107 | 108 | | Option | Description | Fallbacks to\* | 109 | |:-------------------------:|:--------------------------------------:|:---------------:| 110 | | meta_class | class Meta: (e.g. in Django projects) | nested_class | 111 | | nested_class | Other nested classes | None\* | 112 | | constant | SOME_CONSTANTS | field | 113 | | outer_field | some = models.ForeignKey etc. | field | 114 | | field | Other fields | None | 115 | | protected field | Other field starting with _ | field | 116 | | private field | Other field starting with __ | field | 117 | | `__new__` | `__new__` | magic_method | 118 | | `__init__` | `__init__` | magic_method | 119 | | `__post_init__` | `__post_init__` | magic_method | 120 | | `__str__` | `__str__` | magic_method | 121 | | magic_method | Other magic methods | method | 122 | | save | def save(...) | method | 123 | | delete | def delete(...) | method | 124 | | property_method | @property/@cached_property etc. | method | 125 | | protected_property_method | @property/@cached_property etc. with _ | property_method | 126 | | private_property_method | @property/@cached_property with __ | property_method | 127 | | static_method | @staticmethod | method | 128 | | protected_static_method | @staticmethod beginning with _ | static_method | 129 | | private_static_method | @staticmethod beginning with __ | static_method | 130 | | class_method | @classmethod | method | 131 | | protected_class_method | @classmethod beginning with _ | class_method | 132 | | private_class_method | @classmethod beginning with __ | class_method | 133 | | private_method | other methods beginning with __ | method | 134 | | protected_method | other methods beginning with _ | method | 135 | | method | other methods | None | 136 | 137 | \* if not provided, will use its supertype order 138 | 139 | \*\* if not defined, such base types and all their subtypes (unless defined) 140 | will be ignored during validation. It's recommended 141 | to set at least `nested_class`, `field` and `method` 142 | 143 | You choose how detailed your configuration is. 144 | For example, you can define order of each supported magic method 145 | (`__new__`, `__str__`, etc.), or set `magic_method` 146 | to allow any order among them or even just use `method` 147 | 148 | ## Example 149 | 150 | ```python 151 | DEBUG = True 152 | 153 | 154 | class User: 155 | def fetch_info_from_crm(self): 156 | pass 157 | 158 | LOGIN_FIELD = 'email' # wtf? this should be on top of class definition! 159 | 160 | 161 | class UserNode: 162 | class Meta: 163 | model = User 164 | 165 | if DEBUG: # not great idea at all 166 | def is_synced_with_crm(self): 167 | pass 168 | 169 | ``` 170 | 171 | Usage: 172 | 173 | ```terminal 174 | $ flake8 test.py 175 | test.py:5:5: CCE001 User.fetch_info_from_crm should be after User.LOGIN_FIELD 176 | test.py:15:5: CCE002 Class level expression detected model UserNode, line 15 177 | ``` 178 | 179 | Tested on Python 3.9.x and flake8 3.7.5. 180 | 181 | ## Error codes 182 | 183 | | Error code | Description | 184 | |:----------:|:--------------------------------------------------------:| 185 | | CCE001 | Wrong class attributes order (`XXX should be after YYY`) | 186 | | CCE002 | Class level expression detected | 187 | 188 | ## Contributing 189 | 190 | We would love you to contribute to our project. It's simple: 191 | 192 | - Create an issue with bug you found or proposal you have. 193 | Wait for approve from maintainer. 194 | - Create a pull request. Make sure all checks are green. 195 | - Fix review comments if any. 196 | - Be awesome. 197 | 198 | Here are useful tips: 199 | 200 | - You can run all checks and tests with `make check`. Please do it 201 | before TravisCI does. 202 | - We use 203 | [BestDoctor python styleguide](https://github.com/best-doctor/guides/blob/master/guides/en/python_styleguide.md). 204 | - We respect [Django CoC](https://www.djangoproject.com/conduct/). 205 | Make soft, not bullshit. 206 | --------------------------------------------------------------------------------