├── .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 |
--------------------------------------------------------------------------------