├── .github └── workflows │ └── test.yaml ├── .gitignore ├── LICENSE ├── README.rst ├── flake8_alphabetize ├── __init__.py └── core.py ├── pyproject.toml └── test ├── cmd ├── case_app_name.py ├── case_blank.py └── case_standard_fail.py ├── conftest.py ├── test_alphabetize.py └── test_cmd.py /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Flake8 Alphabetize 2 | 3 | permissions: read-all 4 | 5 | on: [push] 6 | 7 | jobs: 8 | build: 9 | 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | - name: Set up Python ${{ matrix.python-version }} 18 | uses: actions/setup-python@v4 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install tox 25 | - name: Run tox 26 | run: | 27 | tox 28 | -------------------------------------------------------------------------------- /.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 | 131 | *.swp 132 | README.html 133 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT No Attribution 2 | 3 | Copyright The Contributors 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. 11 | 12 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 13 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 14 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 15 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 16 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 17 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 18 | SOFTWARE. 19 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ================== 2 | Flake8 Alphabetize 3 | ================== 4 | 5 | Alphabetize is a `Flake8 `_ plugin for checking the 6 | order of ``import`` statements, the ``__all__`` list and ``except`` lists. It is 7 | designed to work well with the 8 | `Black `_ formatting tool, in that 9 | Black never alters the 10 | `Abstract Syntax Tree `_ (AST), 11 | while Alphabetize is *only* interested in the AST, and so the two tools never conflict. 12 | In the spirit of Black, Alphabetize is an 'uncompromising import style checker' in that 13 | the style can't be configured, there's just one style (see below for the rules). 14 | 15 | Alphabetise is released under the `MIT-0 licence 16 | `_. It is tested on Python 3.7+. 17 | 18 | .. image:: https://github.com/tlocke/flake8-alphabetize/actions/workflows/test.yaml/badge.svg 19 | :alt: Build Status 20 | 21 | .. contents:: Table of Contents 22 | :depth: 1 23 | :local: 24 | 25 | 26 | Installation 27 | ------------ 28 | 29 | 1. Create a virtual environment: ``python3 -m venv venv`` 30 | 31 | #. Activate it: ``source venv/bin/activate`` 32 | 33 | 2. Install: ``pip install flake8-alphabetize`` 34 | 35 | 36 | Examples 37 | -------- 38 | 39 | Say we have a Python file ``myfile.py``: 40 | 41 | .. code:: python 42 | 43 | from datetime import time, date 44 | 45 | print(time(9, 39), date(2021, 4, 11)) 46 | 47 | 48 | by running the command ``flake8`` we'll get:: 49 | 50 | myfile.py:1:1: AZ200 Imported names are in the wrong order. Should be date, time 51 | 52 | We can tell Alphabetize what the package name is, and then it'll know that its imports 53 | should be in a group at the bottom of the imports. Here's an example: 54 | 55 | .. code:: python 56 | 57 | import uuid 58 | 59 | from myapp import myfunc 60 | 61 | print(uuid.UUID4(), myfunc()) 62 | 63 | 64 | by running the command ``flake8 --application-names myapp`` we won't get any errors. 65 | 66 | 67 | Usage 68 | ----- 69 | 70 | As you use Flake8 in the normal way, Alphabetize will report errors using the following 71 | codes: 72 | 73 | .. table:: Error Codes 74 | 75 | +-------+----------------------------------------------------------------+ 76 | | Code | Error Type | 77 | +=======+================================================================+ 78 | | AZ100 | Import statements are in the wrong order | 79 | +-------+----------------------------------------------------------------+ 80 | | AZ200 | The names in the ``import from`` are in the wrong order | 81 | +-------+----------------------------------------------------------------+ 82 | | AZ300 | Two ``import from`` statements must be combined. | 83 | +-------+----------------------------------------------------------------+ 84 | | AZ400 | The names in the ``__all__`` are in the wrong order | 85 | +-------+----------------------------------------------------------------+ 86 | | AZ500 | The names in the exception handler list are in the wrong order | 87 | +-------+----------------------------------------------------------------+ 88 | 89 | Alphabetize follows the Black formatter's uncompromising approach and so there's only 90 | one configuration option which is ``application-names``. This is a comma-separated list 91 | of top-level, package names that are to be treated as application imports, eg. 'myapp'. 92 | Since Alphabetize is a Flake8 plugin, this configuration option is set using 93 | `Flake8 configuration `_. 94 | 95 | 96 | Pre-Commit Configuration 97 | ------------------------ 98 | 99 | Alphabetize can be easily configured to run in your existing 100 | `pre-commit `_ hooks, as an additional dependency of Flake8: 101 | 102 | .. code:: YAML 103 | 104 | repos: 105 | - repo: https://github.com/pycqa/flake8 106 | rev: 6.0.0 107 | hooks: 108 | - id: flake8 109 | additional_dependencies: ['flake8-alphabetize'] 110 | 111 | 112 | 113 | Rules Of Import Ordering 114 | ------------------------ 115 | 116 | Here are the ordering rules that Alphabetize follows: 117 | 118 | 1. The special case ``from __future__`` import comes first. 119 | 120 | #. Imports from the standard library come next, followed by third party imports, 121 | followed by application imports. 122 | 123 | #. Relative imports are assumed to be application imports. 124 | 125 | #. The standard library group has ``import`` statements first (in alphabetical order), 126 | followed by ``from import`` statements (in alphabetical order). 127 | 128 | #. The third party group is further grouped by library name. Then each library subgroup 129 | has ``import`` statements first (in alphabetical order), followed by ``from import`` 130 | statements (in alphabetical order). 131 | 132 | #. The application group is further grouped by import level, with absolute imports first 133 | and then relative imports of increasing level. Within each level, the imports should 134 | be ordered by library name. Then each library subgroup has ``import`` statements 135 | first (in alphabetical order), followed by ``from import`` statements (in 136 | alphabetical order). 137 | 138 | #. ``from import`` statements for the same library must be combined. 139 | 140 | #. Alphabetize only looks at imports at the module level, any imports within the code 141 | are ignored. 142 | 143 | 144 | Running Tests 145 | ------------- 146 | 147 | Run `tox `_ to run the tests. 148 | 149 | * Install tox: ``pip install tox`` 150 | * Run tox: ``tox`` 151 | 152 | 153 | OpenSSF Scorecard 154 | ----------------- 155 | 156 | It might be worth running the `OpenSSF Scorecard `_:: 157 | 158 | sudo docker run -e GITHUB_AUTH_TOKEN= gcr.io/openssf/scorecard:stable \ 159 | --repo=github.com/tlocke/flake8-alphabetize 160 | 161 | 162 | Doing A Release Of Alphabetize 163 | ------------------------------ 164 | 165 | Run ``tox`` to make sure all tests pass, then update the release notes, then do:: 166 | 167 | git tag -a x.y.z -m "version x.y.z" 168 | rm -r dist 169 | python -m build 170 | twine upload dist/* 171 | 172 | 173 | Release Notes 174 | ------------- 175 | 176 | Version 0.0.21, 2023-04-13 177 | `````````````````````````` 178 | 179 | - Fixed a bug where it crashes on qualified names in an exception list. 180 | 181 | 182 | Version 0.0.20, 2023-04-02 183 | `````````````````````````` 184 | 185 | - Check the ordering of ``except`` handler lists. 186 | 187 | 188 | Version 0.0.19, 2022-11-24 189 | `````````````````````````` 190 | 191 | - Make Alphabetize compatible with Flake8 6.0.0 192 | 193 | 194 | Version 0.0.18, 2022-10-29 195 | `````````````````````````` 196 | 197 | - Fix bug where sub-packages (eg. ``collections.abc``) aren't recognised as being part 198 | of the standard library for versions of Python >= 3.10. 199 | 200 | 201 | Version 0.0.17, 2021-11-17 202 | `````````````````````````` 203 | 204 | - Handle the case of an ``__all__`` being a ``tuple``. 205 | 206 | 207 | Version 0.0.16, 2021-07-26 208 | `````````````````````````` 209 | 210 | * Don't perform any import order checks if there are multiple imports on a line, as 211 | this will be reported by Flake8. Once the Flake8 error has been fixed, checks can 212 | continue. 213 | 214 | 215 | Version 0.0.15, 2021-06-17 216 | `````````````````````````` 217 | 218 | * Fix bug where the ``--application-names`` command line option failed with a 219 | comma-separated list. 220 | 221 | 222 | Version 0.0.14, 2021-04-20 223 | `````````````````````````` 224 | 225 | * Fix bug where ``from . import logging`` appears in message as ``from .None import 226 | logging``. 227 | 228 | 229 | Version 0.0.13, 2021-04-20 230 | `````````````````````````` 231 | 232 | * Fix bug where it fails on a relative import such as ``from . import logging``. 233 | 234 | 235 | Version 0.0.12, 2021-04-12 236 | `````````````````````````` 237 | 238 | * Check the order of the elements of ``__all__``. 239 | 240 | 241 | Version 0.0.11, 2021-04-11 242 | `````````````````````````` 243 | 244 | * Order application imports by import level, absolute imports at the top. 245 | 246 | 247 | Version 0.0.10, 2021-04-11 248 | `````````````````````````` 249 | 250 | * Fix bug where potentially fails with > 2 imports. 251 | 252 | 253 | Version 0.0.9, 2021-04-11 254 | ````````````````````````` 255 | 256 | * There's a clash of option names, so now application imports can now be identified by 257 | setting the ``application-names`` configuration option. 258 | 259 | 260 | Version 0.0.8, 2021-04-11 261 | ````````````````````````` 262 | 263 | * Application imports can now be identified by setting the ``application-package-names`` 264 | configuration option. 265 | 266 | 267 | Version 0.0.7, 2021-04-10 268 | ````````````````````````` 269 | 270 | * Import of ``__future__``. Should always be first. 271 | 272 | 273 | Version 0.0.6, 2021-04-10 274 | ````````````````````````` 275 | 276 | * Third party libraries should be grouped by top-level name. 277 | 278 | 279 | Version 0.0.5, 2021-04-10 280 | ````````````````````````` 281 | 282 | * Take into account whether a module is in the standard library or not. 283 | 284 | 285 | Version 0.0.4, 2021-04-10 286 | ````````````````````````` 287 | 288 | * Make entry point AZ instead of ALP. 289 | 290 | 291 | Version 0.0.3, 2021-04-10 292 | ````````````````````````` 293 | 294 | * Check the order within ``from import`` statements. 295 | 296 | 297 | Version 0.0.2, 2021-04-09 298 | ````````````````````````` 299 | 300 | * Partially support ``from import`` statements. 301 | 302 | 303 | Version 0.0.1, 2021-04-09 304 | ````````````````````````` 305 | 306 | * Now partially supports ``import`` statements. 307 | 308 | 309 | Version 0.0.0, 2021-04-09 310 | ````````````````````````` 311 | 312 | * Initial release. Doesn't do much at this stage. 313 | -------------------------------------------------------------------------------- /flake8_alphabetize/__init__.py: -------------------------------------------------------------------------------- 1 | from flake8_alphabetize.core import Alphabetize, ver 2 | 3 | __version__ = ver 4 | Alphabetize.version = __version__ 5 | 6 | __all__ = ["Alphabetize", "__version__"] 7 | -------------------------------------------------------------------------------- /flake8_alphabetize/core.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from ast import ( 3 | Assign, 4 | Attribute, 5 | Constant, 6 | ExceptHandler, 7 | Import, 8 | ImportFrom, 9 | List, 10 | Module, 11 | Name, 12 | Str, 13 | Tuple, 14 | walk, 15 | ) 16 | from enum import IntEnum 17 | from functools import total_ordering 18 | 19 | 20 | try: 21 | from importlib.metadata import version 22 | except ImportError: 23 | from importlib_metadata import version 24 | 25 | ver = version("flake8-alphabetize") 26 | 27 | 28 | class AlphabetizeException(Exception): 29 | pass 30 | 31 | 32 | class Alphabetize: 33 | name = "alphabetize" 34 | 35 | def __init__(self, tree): 36 | self.tree = tree 37 | 38 | def __iter__(self): 39 | errors = _find_errors(Alphabetize.app_names, self.tree) 40 | return iter(errors) 41 | 42 | @staticmethod 43 | def add_options(option_manager): 44 | option_manager.add_option( 45 | "--application-names", 46 | metavar="APPLICATION_NAMES", 47 | default="", 48 | parse_from_config=True, 49 | comma_separated_list=True, 50 | help="Comma-separated list of package names. If an import is for a package " 51 | "in this list, it'll be in the application group of imports. Eg. 'myapp'.", 52 | ) 53 | 54 | @classmethod 55 | def parse_options(cls, options): 56 | cls.app_names = options.application_names 57 | 58 | 59 | def _make_error(node, code, message): 60 | return (node.lineno, node.col_offset, f"AZ{code} {message}", Alphabetize) 61 | 62 | 63 | class GroupEnum(IntEnum): 64 | FUTURE = 1 65 | STDLIB = 2 66 | THIRD_PARTY = 3 67 | APPLICATION = 4 68 | 69 | 70 | class NodeTypeEnum(IntEnum): 71 | IMPORT = 1 72 | IMPORT_FROM = 2 73 | 74 | 75 | def _is_in_stdlib(name): 76 | if hasattr(sys, "stdlib_module_names"): 77 | main_package = name.split(".")[0] 78 | return main_package in sys.stdlib_module_names 79 | else: 80 | from stdlib_list import in_stdlib 81 | 82 | return in_stdlib(name) 83 | 84 | 85 | @total_ordering 86 | class AzImport: 87 | def __init__(self, app_names, ast_node): 88 | self.node = ast_node 89 | self.error = None 90 | level = None 91 | group = None 92 | 93 | if isinstance(ast_node, Import): 94 | self.node_type = NodeTypeEnum.IMPORT 95 | names = ast_node.names 96 | 97 | self.module_name = names[0].name 98 | level = 0 99 | 100 | elif isinstance(ast_node, ImportFrom): 101 | module = ast_node.module 102 | self.module_name = "" if module is None else module 103 | self.node_type = NodeTypeEnum.IMPORT_FROM 104 | 105 | ast_names = ast_node.names 106 | names = [n.name for n in ast_names] 107 | expected_names = sorted(names) 108 | if names != expected_names: 109 | self.error = _make_error( 110 | self.node, 111 | 200, 112 | f"Imported names are in the wrong order. Should be " 113 | f"{', '.join(expected_names)}", 114 | ) 115 | level = ast_node.level 116 | 117 | else: 118 | raise AlphabetizeException(f"Node type {type(ast_node)} not recognized") 119 | 120 | if self.module_name == "__future__": 121 | group = GroupEnum.FUTURE 122 | elif _is_in_stdlib(self.module_name): 123 | group = GroupEnum.STDLIB 124 | elif level > 0: 125 | group = GroupEnum.APPLICATION 126 | else: 127 | group = GroupEnum.THIRD_PARTY 128 | for name in app_names: 129 | if name == self.module_name or self.module_name.startswith(f"{name}."): 130 | group = GroupEnum.APPLICATION 131 | break 132 | 133 | if group == GroupEnum.STDLIB: 134 | self.sorter = group, self.node_type, self.module_name 135 | else: 136 | m = self.module_name 137 | dot_idx = m.find(".") 138 | top_name = m if dot_idx == -1 else m[:dot_idx] 139 | self.sorter = group, level, top_name, self.node_type, m 140 | 141 | def __eq__(self, other): 142 | return self.sorter == other.sorter 143 | 144 | def __lt__(self, other): 145 | return self.sorter < other.sorter 146 | 147 | def __str__(self): 148 | if self.node_type == NodeTypeEnum.IMPORT: 149 | return f"import {self.module_name}" 150 | elif self.node_type == NodeTypeEnum.IMPORT_FROM: 151 | level = self.node.level 152 | level_str = "" if level == 0 else "." * level 153 | names = [ 154 | n.name + ("" if n.asname is None else f" as {n.asname}") 155 | for n in self.node.names 156 | ] 157 | return f"from {level_str}{self.module_name} import {', '.join(names)}" 158 | else: 159 | raise AlphabetizeException( 160 | f"The node type {self.node_type} is not recognized." 161 | ) 162 | 163 | 164 | IMPORT_TYPES = Import, ImportFrom 165 | 166 | 167 | def _find_elist_nodes(tree): 168 | nodes = [] 169 | 170 | for node in walk(tree): 171 | if isinstance(node, ExceptHandler): 172 | node_type = node.type 173 | if isinstance(node_type, (List, Tuple)): 174 | nodes.append(node_type) 175 | 176 | return nodes 177 | 178 | 179 | def _find_nodes(tree): 180 | import_nodes = [] 181 | alist_node = None 182 | elist_nodes = _find_elist_nodes(tree) 183 | 184 | if isinstance(tree, Module): 185 | body = tree.body 186 | 187 | for n in body: 188 | if isinstance(n, IMPORT_TYPES): 189 | import_nodes.append(n) 190 | 191 | elif isinstance(n, Assign): 192 | for t in n.targets: 193 | if isinstance(t, Name) and t.id == "__all__": 194 | value = n.value 195 | 196 | if isinstance(value, (List, Tuple)): 197 | alist_node = value 198 | 199 | return import_nodes, alist_node, elist_nodes 200 | 201 | 202 | def _find_dunder_all_error(node): 203 | if node is not None: 204 | actual_list = [] 205 | for el in node.elts: 206 | if isinstance(el, Constant): 207 | actual_list.append(el.value) 208 | elif isinstance(el, Str): 209 | actual_list.append(el.s) 210 | else: 211 | # Can't handle anything that isn't a string literal 212 | return 213 | 214 | expected_list = sorted(actual_list) 215 | if expected_list != actual_list: 216 | return _make_error( 217 | node, 218 | "400", 219 | f"The names in the __all__ are in the wrong order. The order should " 220 | f"be {', '.join(expected_list)}", 221 | ) 222 | 223 | 224 | def _find_elist_str(node): 225 | if isinstance(node, Name): 226 | return node.id 227 | elif isinstance(node, Attribute): 228 | return f"{_find_elist_str(node.value)}.{node.attr}" 229 | 230 | 231 | def _find_elist_errors(nodes): 232 | errors = [] 233 | 234 | for node in nodes: 235 | actual_list = [_find_elist_str(elt) for elt in node.elts] 236 | 237 | expected_list = sorted(actual_list) 238 | if expected_list != actual_list: 239 | errors.append( 240 | _make_error( 241 | node, 242 | "500", 243 | f"The names in the exception handler list are in the wrong order. " 244 | f"The order should be {', '.join(expected_list)}", 245 | ) 246 | ) 247 | return errors 248 | 249 | 250 | def _find_errors(app_names, tree): 251 | import_nodes, alist_node, elist_nodes = _find_nodes(tree) 252 | errors = [] 253 | 254 | dunder_all_error = _find_dunder_all_error(alist_node) 255 | if dunder_all_error is not None: 256 | errors.append(dunder_all_error) 257 | 258 | errors.extend(_find_elist_errors(elist_nodes)) 259 | 260 | imports = [] 261 | for imp in import_nodes: 262 | if isinstance(imp, Import) and len(imp.names) > 1: 263 | return errors 264 | else: 265 | imports.append(AzImport(app_names, imp)) 266 | 267 | len_imports = len(imports) 268 | if len_imports == 0: 269 | return errors 270 | 271 | p = imports[0] 272 | if p.error is not None: 273 | errors.append(p.error) 274 | 275 | if len_imports < 2: 276 | return errors 277 | 278 | for n in imports[1:]: 279 | if n.error is not None: 280 | errors.append(n.error) 281 | 282 | if n == p: 283 | errors.append( 284 | _make_error( 285 | n.node, 286 | "300", 287 | f"Import statements should be combined. '{p}' should be combined " 288 | f"with '{n}'", 289 | ) 290 | ) 291 | elif n < p: 292 | errors.append( 293 | _make_error( 294 | n.node, 295 | "100", 296 | f"Import statements are in the wrong order. '{n}' should be " 297 | f"before '{p}'", 298 | ) 299 | ) 300 | 301 | p = n 302 | 303 | return errors 304 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=65", 4 | "versioningit >= 2.1.0", 5 | ] 6 | build-backend = "setuptools.build_meta" 7 | 8 | [project] 9 | name = "flake8-alphabetize" 10 | description = "A Python style checker for alphabetizing import and __all__." 11 | readme = "README.rst" 12 | requires-python = ">=3.7" 13 | keywords = ["flake8"] 14 | license = {text = "MIT No Attribution"} 15 | classifiers = [ 16 | "Framework :: Flake8", 17 | "Environment :: Console", 18 | "Intended Audience :: Developers", 19 | "License :: OSI Approved :: MIT No Attribution License (MIT-0)", 20 | "Programming Language :: Python", 21 | "Programming Language :: Python :: 3", 22 | "Programming Language :: Python :: 3.7", 23 | "Programming Language :: Python :: 3.8", 24 | "Programming Language :: Python :: 3.9", 25 | "Programming Language :: Python :: 3.10", 26 | "Topic :: Software Development :: Libraries :: Python Modules", 27 | "Topic :: Software Development :: Quality Assurance", 28 | ] 29 | dependencies = [ 30 | "flake8 > 3.0.0", 31 | 'stdlib_list == 0.8.0 ; python_version < "3.10"', 32 | 'importlib-metadata >= 1.0 ; python_version < "3.8"', 33 | ] 34 | dynamic = ["version"] 35 | 36 | [project.urls] 37 | Homepage = "https://github.com/tlocke/flake8-alphabetize" 38 | 39 | [project.entry-points."flake8.extension"] 40 | AZ = "flake8_alphabetize:Alphabetize" 41 | 42 | [tool.versioningit] 43 | 44 | [tool.versioningit.vcs] 45 | method = "git" 46 | default-tag = "0.0.0" 47 | 48 | [tool.flake8] 49 | ignore = ['E203', 'W503'] 50 | max-line-length = 88 51 | exclude = ['.git', '__pycache__', 'build', 'dist', 'venv', '.tox', 'test/cmd'] 52 | application-names = ['flake8_alphabetize'] 53 | 54 | 55 | [tool.tox] 56 | legacy_tox_ini = """ 57 | [tox] 58 | isolated_build = True 59 | envlist = py 60 | [testenv] 61 | deps = 62 | flake8 63 | flake8-alphabetize 64 | Flake8-pyproject 65 | black 66 | pytest 67 | pytest-mock 68 | commands = 69 | black --check . 70 | flake8 . 71 | python -m pytest -x -v -W error test 72 | """ 73 | -------------------------------------------------------------------------------- /test/cmd/case_app_name.py: -------------------------------------------------------------------------------- 1 | import pg8000 2 | 3 | import scramp 4 | 5 | 6 | __all__ = [pg8000, scramp] 7 | -------------------------------------------------------------------------------- /test/cmd/case_blank.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tlocke/flake8-alphabetize/caff59d3829158aab40ac6198195f2fb940924fc/test/cmd/case_blank.py -------------------------------------------------------------------------------- /test/cmd/case_standard_fail.py: -------------------------------------------------------------------------------- 1 | from datetime import time, date 2 | 3 | print(time(9, 39), date(2021, 4, 11)) 4 | -------------------------------------------------------------------------------- /test/conftest.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import pytest 4 | 5 | 6 | @pytest.fixture(scope="module") 7 | def py_version(): 8 | version = sys.version_info 9 | return version.major, version.minor 10 | -------------------------------------------------------------------------------- /test/test_alphabetize.py: -------------------------------------------------------------------------------- 1 | from ast import List, Tuple, parse 2 | 3 | import pytest 4 | 5 | from flake8_alphabetize import Alphabetize 6 | from flake8_alphabetize.core import ( 7 | AzImport, 8 | _find_dunder_all_error, 9 | _find_elist_errors, 10 | _find_elist_nodes, 11 | _find_elist_str, 12 | _find_errors, 13 | _find_nodes, 14 | _is_in_stdlib, 15 | ) 16 | 17 | 18 | def test_is_in_stdlib(): 19 | assert _is_in_stdlib("collections.abc") 20 | 21 | 22 | @pytest.mark.parametrize( 23 | "pystr,elist_node_types", 24 | [ 25 | [ 26 | """try: 27 | pass 28 | except [Exception, BaseException]: 29 | pass""", 30 | [List], 31 | ], 32 | ], 33 | ) 34 | def test_find_elist_nodes(pystr, elist_node_types): 35 | elist_nodes = _find_elist_nodes(parse(pystr)) 36 | 37 | assert [type(n) for n in elist_nodes] == elist_node_types 38 | 39 | 40 | @pytest.mark.parametrize( 41 | "pystr,import_node_types,alist_type,elist_node_types", 42 | [ 43 | [ 44 | """ 45 | if True: 46 | import scramp 47 | """, 48 | [], 49 | None, 50 | [], 51 | ], 52 | [ 53 | "__all__ = []", 54 | [], 55 | List, 56 | [], 57 | ], 58 | [ 59 | "__all__ = ()", 60 | [], 61 | Tuple, 62 | [], 63 | ], 64 | [ 65 | """try: 66 | pass 67 | except [Exception, BaseException]: 68 | pass""", 69 | [], 70 | None, 71 | [List], 72 | ], 73 | ], 74 | ) 75 | def test_find_nodes(pystr, import_node_types, alist_type, elist_node_types): 76 | import_nodes, alist_node, elist_nodes = _find_nodes(parse(pystr)) 77 | 78 | assert [type(n) for n in import_nodes] == import_node_types 79 | 80 | if alist_type is None: 81 | assert alist_node is None 82 | else: 83 | assert type(alist_node) == alist_type 84 | 85 | assert [type(n) for n in elist_nodes] == elist_node_types 86 | 87 | 88 | @pytest.mark.parametrize( 89 | "pystr,error", 90 | [ 91 | [ 92 | "from pg8000.converters import BIGINT_ARRAY, BIGINT", 93 | ( 94 | 1, 95 | 0, 96 | "AZ200 Imported names are in the wrong order. Should be BIGINT, " 97 | "BIGINT_ARRAY", 98 | Alphabetize, 99 | ), 100 | ], 101 | [ 102 | "from . import logging", 103 | None, 104 | ], 105 | ], 106 | ) 107 | def test_AzImport_init(pystr, error): 108 | node = parse(pystr) 109 | az = AzImport([], node.body[0]) 110 | 111 | assert az.error == error 112 | 113 | 114 | @pytest.mark.parametrize( 115 | "app_names,pystr_a,pystr_b,is_lt", 116 | [ 117 | [[], "from pg8000.converters import BIGINT, BIGINT_ARRAY", "import pytz", True], 118 | [ 119 | [], 120 | "from pg8000.native import Connection", 121 | "from ._version import get_versions", 122 | True, 123 | ], 124 | [ 125 | [], 126 | "from ._version import get_versions", 127 | "from pg8000.native import Connection", 128 | False, 129 | ], 130 | [ 131 | [], 132 | "import uuid", 133 | "import scramp", 134 | True, 135 | ], 136 | [ 137 | [], 138 | "import time", 139 | "from collections import OrderedDict", 140 | True, 141 | ], 142 | [ 143 | [], 144 | "import pg8000.dbapi", 145 | "from pg8000.converters import pg_interval_in", 146 | True, 147 | ], 148 | [ 149 | [], 150 | "from __future__ import print_function", 151 | "import decimal", 152 | True, 153 | ], 154 | [ 155 | [], 156 | "from pg8000.converters import ARRAY", 157 | "from pg8000.converters import BIGINT", 158 | False, 159 | ], 160 | [ 161 | [], 162 | "from pg8000.converters import BIGINT", 163 | "from pg8000.converters import ARRAY", 164 | False, 165 | ], 166 | [ 167 | ["pg8000"], 168 | "import scramp", 169 | "import pg8000", 170 | True, 171 | ], 172 | [ 173 | [], 174 | "from . import scramp", 175 | "from .version import ver", 176 | True, 177 | ], 178 | [ # Test with a sub-package 179 | [], 180 | "from collections.abc import Map", 181 | "from decimal import Decimal", 182 | True, 183 | ], 184 | ], 185 | ) 186 | def test_AzImport_lt(app_names, pystr_a, pystr_b, is_lt): 187 | node_a = parse(pystr_a) 188 | az_a = AzImport(app_names, node_a.body[0]) 189 | 190 | node_b = parse(pystr_b) 191 | az_b = AzImport(app_names, node_b.body[0]) 192 | 193 | assert (az_a < az_b) == is_lt 194 | 195 | 196 | @pytest.mark.parametrize( 197 | "pystr", 198 | [ 199 | "from .version import version", 200 | "from . import version", 201 | ], 202 | ) 203 | def test_AzImport_str(pystr): 204 | node = parse(pystr) 205 | 206 | az = AzImport([], node.body[0]) 207 | 208 | assert str(az) == pystr 209 | 210 | 211 | @pytest.mark.parametrize( 212 | "pystr", 213 | [ 214 | "Exception", 215 | "scramp.Exception", 216 | "sqlalchemy.exceptions.BaseException", 217 | ], 218 | ) 219 | def test_find_elist_str(pystr): 220 | node = parse(pystr) 221 | assert _find_elist_str(node.body[0].value) == pystr 222 | 223 | 224 | @pytest.mark.parametrize( 225 | "pystrs,errors", 226 | [ 227 | [ 228 | ["[Exception, BaseException]"], 229 | [ 230 | ( 231 | 1, 232 | 0, 233 | "AZ500 The names in the exception handler list are in the wrong " 234 | "order. The order should be BaseException, Exception", 235 | Alphabetize, 236 | ) 237 | ], 238 | ], 239 | [ 240 | ["[scramp.Exception, sqlalchemy.exceptions.BaseException, Exception]"], 241 | [ 242 | ( 243 | 1, 244 | 0, 245 | "AZ500 The names in the exception handler list are in the wrong " 246 | "order. The order should be Exception, scramp.Exception, " 247 | "sqlalchemy.exceptions.BaseException", 248 | Alphabetize, 249 | ) 250 | ], 251 | ], 252 | ], 253 | ) 254 | def test_elist_errors(pystrs, errors, py_version): 255 | nodes = [parse(pystr).body[-1].value for pystr in pystrs] 256 | 257 | expected = [] 258 | 259 | for (line_offset, col_offset, msg, cls), node in zip(errors, nodes): 260 | if py_version < (3, 8) and isinstance(node, Tuple): 261 | col_offset = 1 262 | 263 | expected.append((line_offset, col_offset, msg, cls)) 264 | 265 | actual = _find_elist_errors(nodes) 266 | assert actual == expected 267 | 268 | 269 | @pytest.mark.parametrize( 270 | "pystr", 271 | [ 272 | "[]", 273 | "()", 274 | "[ScramServer]", 275 | "('ScramClient',)", 276 | "['ScramClient', 'ScramServer']", 277 | "('ScramClient', 'ScramServer')", 278 | ], 279 | ) 280 | def test_find_dunder_all_ok(pystr): 281 | node = parse(pystr) 282 | sequence_node = node.body[-1].value 283 | 284 | assert _find_dunder_all_error(sequence_node) is None 285 | 286 | 287 | @pytest.mark.parametrize( 288 | "pystr,error", 289 | [ 290 | [ 291 | "['ScramServer', 'ScramClient']", 292 | "AZ400 The names in the __all__ are in the wrong order. The order should " 293 | "be ScramClient, ScramServer", 294 | ], 295 | [ 296 | "('ScramServer', 'ScramClient')", 297 | "AZ400 The names in the __all__ are in the wrong order. The order should " 298 | "be ScramClient, ScramServer", 299 | ], 300 | ], 301 | ) 302 | def test_find_dunder_all_error(pystr, error, py_version): 303 | node = parse(pystr) 304 | sequence_node = node.body[-1].value 305 | if isinstance(sequence_node, Tuple): 306 | col_offset = 1 if py_version < (3, 8) else 0 307 | else: 308 | col_offset = 0 309 | expected = (1, col_offset, error, Alphabetize) 310 | 311 | assert _find_dunder_all_error(sequence_node) == expected 312 | 313 | 314 | @pytest.mark.parametrize( 315 | "app_names,pystr,errors", 316 | [ 317 | [[], "", []], 318 | [ 319 | [], 320 | """import decimal 321 | import os""", 322 | [], 323 | ], 324 | [ 325 | [], 326 | """import versioneer 327 | from os import path""", 328 | [ 329 | ( 330 | 2, 331 | 0, 332 | "AZ100 Import statements are in the wrong order. " 333 | "'from os import path' should be before 'import versioneer'", 334 | Alphabetize, 335 | ), 336 | ], 337 | ], 338 | [ 339 | [], 340 | "from datetime import timedelta, date", 341 | [ 342 | ( 343 | 1, 344 | 0, 345 | "AZ200 Imported names are in the wrong order. Should be date, " 346 | "timedelta", 347 | Alphabetize, 348 | ) 349 | ], 350 | ], 351 | [ 352 | [], 353 | """from pg8000 import BIGINT 354 | from pg8000 import ARRAY""", 355 | [ 356 | ( 357 | 2, 358 | 0, 359 | "AZ300 Import statements should be combined. 'from pg8000 import " 360 | "BIGINT' should be combined with 'from pg8000 import ARRAY'", 361 | Alphabetize, 362 | ) 363 | ], 364 | ], 365 | [ 366 | ["pg8000"], 367 | """import scramp 368 | from pg8000 import ARRAY""", 369 | [], 370 | ], 371 | [ 372 | ["pg8000"], 373 | """from pg8000 import ARRAY 374 | import scramp""", 375 | [ 376 | ( 377 | 2, 378 | 0, 379 | "AZ100 Import statements are in the wrong order. 'import scramp' " 380 | "should be before 'from pg8000 import ARRAY'", 381 | Alphabetize, 382 | ) 383 | ], 384 | ], 385 | [ 386 | [], 387 | """import socket 388 | import sys 389 | import struct 390 | """, 391 | [ 392 | ( 393 | 3, 394 | 0, 395 | "AZ100 Import statements are in the wrong order. 'import struct' " 396 | "should be before 'import sys'", 397 | Alphabetize, 398 | ) 399 | ], 400 | ], 401 | [ 402 | ["scramp"], 403 | """import scramp 404 | from ._version import vers 405 | """, 406 | [], 407 | ], 408 | [ # We can't check __all__ if the elements aren't literal strings 409 | [], 410 | """from scramp.core import ScramClient, ScramServer 411 | __all__ = [ScramServer, ScramClient] 412 | """, 413 | [], 414 | ], 415 | [ # Wait for Flake8 fixes to be made first 416 | [], 417 | """import time 418 | import datetime, scramp""", 419 | [], 420 | ], 421 | [ 422 | [], 423 | """try: 424 | pass 425 | except [Exception, BaseException]: 426 | pass""", 427 | [ 428 | ( 429 | 3, 430 | 7, 431 | "AZ500 The names in the exception handler list are in the wrong " 432 | "order. The order should be BaseException, Exception", 433 | Alphabetize, 434 | ), 435 | ], 436 | ], 437 | ], 438 | ) 439 | def test_find_errors(app_names, pystr, errors): 440 | tree = parse(pystr) 441 | 442 | actual_errors = _find_errors(app_names, tree) 443 | 444 | assert actual_errors == errors 445 | -------------------------------------------------------------------------------- /test/test_cmd.py: -------------------------------------------------------------------------------- 1 | import os 2 | from subprocess import CalledProcessError, run 3 | 4 | import pytest 5 | 6 | 7 | @pytest.mark.parametrize( 8 | "case,app_names", 9 | [ 10 | ["blank", None], 11 | ["app_name", None], 12 | ], 13 | ) 14 | def test_cmd_success(py_version, case, app_names): 15 | args = ["flake8"] 16 | 17 | if app_names is not None: 18 | args.append(f"--application-names {','.join(app_names)}") 19 | 20 | args.append(f"test/cmd/case_{case}.py") 21 | 22 | try: 23 | run(args, capture_output=True, check=True) 24 | except CalledProcessError as e: 25 | print(os.getcwd()) 26 | print(e.returncode, e.cmd, e.output, e.stdout) 27 | raise e 28 | 29 | 30 | @pytest.mark.parametrize( 31 | "case,app_names,error", 32 | [ 33 | [ 34 | "standard_fail", 35 | None, 36 | "test/cmd/case_standard_fail.py:1:1: AZ200 Imported names are in the " 37 | "wrong order. Should be date, time\n", 38 | ], 39 | [ 40 | "app_name", 41 | ["pg8000"], 42 | "test/cmd/case_app_name.py:3:1: AZ100 Import statements are in the wrong " 43 | "order. 'import scramp' should be before 'import pg8000'\n", 44 | ], 45 | [ 46 | "app_name", 47 | ["nm3434", "pg8000", "qq9000"], 48 | "test/cmd/case_app_name.py:3:1: AZ100 Import statements are in the wrong " 49 | "order. 'import scramp' should be before 'import pg8000'\n", 50 | ], 51 | ], 52 | ) 53 | def test_cmd_failure(py_version, case, app_names, error): 54 | parts = ["flake8"] 55 | 56 | if app_names is not None: 57 | parts.append(f"--application-names {','.join(app_names)}") 58 | 59 | parts.append(f"test/cmd/case_{case}.py") 60 | 61 | args = [" ".join(parts)] 62 | 63 | with pytest.raises(CalledProcessError) as excinfo: 64 | p = run(args, capture_output=True, check=True, shell=True, encoding="utf8") 65 | print(p.stdout, p.stderr) 66 | 67 | e = excinfo.value 68 | assert e.stdout == error 69 | # print(os.getcwd()) 70 | # print(e.returncode, e.cmd, e.output, e.stdout) 71 | --------------------------------------------------------------------------------