├── setup.py ├── requirements.txt ├── .pre-commit-hooks.yaml ├── Makefile ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .editorconfig ├── LICENSE ├── .pre-commit-config.yaml ├── README.rst ├── setup.cfg ├── .gitignore ├── tests └── test_basic.py └── sort_all.py /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -e . 2 | pre-commit==4.2.0 3 | pytest==8.3.4 4 | pytest-cov==6.1.1 5 | -------------------------------------------------------------------------------- /.pre-commit-hooks.yaml: -------------------------------------------------------------------------------- 1 | - id: sort-all 2 | name: Sort __all__ records 3 | description: Automatically Sort __all__ records alphabetically. 4 | entry: sort-all 5 | language: python 6 | types: [python] 7 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: init 2 | init: 3 | pip install -r requirements.txt 4 | pre-commit install 5 | 6 | 7 | .PHONY: fmt 8 | fmt: 9 | ifdef CI_LINT_RUN 10 | pre-commit run --all-files --show-diff-on-failure 11 | else 12 | pre-commit run --all-files 13 | endif 14 | 15 | 16 | .PHONY: test 17 | test: 18 | pytest -vvv 19 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | updates: 4 | - package-ecosystem: pip 5 | directory: / 6 | schedule: 7 | interval: daily 8 | open-pull-requests-limit: 10 9 | target-branch: master 10 | - package-ecosystem: github-actions 11 | directory: / 12 | schedule: 13 | interval: daily 14 | open-pull-requests-limit: 10 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | indent_style = space 11 | indent_size = 4 12 | trim_trailing_whitespace = true 13 | charset = utf-8 14 | 15 | [Makefile] 16 | indent_style = tab 17 | 18 | [*.{yml,yaml}] 19 | indent_size = 2 20 | 21 | [*.py] 22 | max_line_length = 88 23 | 24 | [*.rst] 25 | max_line_length = 79 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2020 aio-libs collaboration 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/hhatto/autopep8 3 | rev: v2.3.2 4 | hooks: 5 | - id: autopep8 6 | - repo: https://github.com/PyCQA/isort 7 | rev: '6.0.1' 8 | hooks: 9 | - id: isort 10 | - repo: https://github.com/psf/black 11 | rev: '25.1.0' 12 | hooks: 13 | - id: black 14 | language_version: python3 # Should be a command that runs python3.6+ 15 | - repo: https://github.com/asottile/pyupgrade 16 | rev: v3.20.0 17 | hooks: 18 | - id: pyupgrade 19 | args: [--py39-plus] 20 | - repo: https://github.com/asottile/setup-cfg-fmt 21 | rev: v2.8.0 22 | hooks: 23 | - id: setup-cfg-fmt 24 | - repo: https://github.com/pre-commit/pre-commit-hooks 25 | rev: v5.0.0 26 | hooks: 27 | - id: trailing-whitespace 28 | - id: end-of-file-fixer 29 | - id: check-docstring-first 30 | - id: check-yaml 31 | - id: debug-statements 32 | - id: requirements-txt-fixer 33 | - repo: https://github.com/PyCQA/flake8 34 | rev: 7.3.0 35 | hooks: 36 | - id: flake8 37 | - repo: https://github.com/pre-commit/mirrors-mypy 38 | rev: v1.16.1 39 | hooks: 40 | - id: mypy 41 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | sort-all 3 | ======== 4 | 5 | Sort ``__all__`` lists alphabetically 6 | 7 | Usage 8 | ===== 9 | 10 | Install the package, e.g. ``pip install sort-all `` 11 | 12 | Run the tool: ``sort-all`` 13 | 14 | Command line options 15 | ==================== 16 | 17 | Options:: 18 | 19 | usage: sort-all [-h] [--check] [--no-error-on-fix] [filenames ...] 20 | 21 | Sort __all__ records alphabetically. 22 | 23 | positional arguments: 24 | filenames Files to process 25 | 26 | options: 27 | -h, --help show this help message and exit 28 | --check check the file for unsorted / unformatted imports and print them to the command line without modifying the file; return 0 29 | when nothing would change and return 1 when the file would be reformatted. 30 | --no-error-on-fix return 0 even if errors are occurred during processing files 31 | 32 | 33 | Usage with pre-commit 34 | ===================== 35 | 36 | 37 | sort-all can be used as a hook for pre-commit_. 38 | 39 | To add sort-all as a plugin, add this repo definition to your configuration: 40 | 41 | .. code-block:: yaml 42 | 43 | repos: 44 | - repo: https://github.com/aio-libs/sort-all 45 | rev: ... # select the tag or revision you want, or run `pre-commit autoupdate` 46 | hooks: 47 | - id: sort-all 48 | 49 | .. _`pre-commit`: https://pre-commit.com 50 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = sort_all 3 | version = 1.3.0 4 | description = Automatically Sort __all__ records alphabetically 5 | long_description = file: README.rst 6 | long_description_content_type = text/x-rst 7 | url = https://github.com/aio-libs/sort-all 8 | author = Andrew Svetlov 9 | author_email = andrew.svetlov@gmail.com 10 | license = Apache-2.0 11 | license_files = LICENSE 12 | classifiers = 13 | Programming Language :: Python :: 3 14 | Programming Language :: Python :: 3 :: Only 15 | 16 | [options] 17 | py_modules = sort_all 18 | install_requires = 19 | tokenize-rt>=3.0.1 20 | python_requires = >=3.9 21 | 22 | [options.entry_points] 23 | console_scripts = 24 | sort-all = sort_all:main 25 | 26 | [flake8] 27 | exclude = .git,.env,__pycache__,.eggs 28 | max-line-length = 88 29 | ignore = N801,N802,N803,E252,W503,E133,E203,F541 30 | 31 | [isort] 32 | profile = black 33 | 34 | [tool:pytest] 35 | addopts = --cov=sort_all 36 | testpaths = tests/ 37 | junit_suite_name = sort_all_test_suite 38 | junit_family = xunit2 39 | 40 | [mypy] 41 | check_untyped_defs = True 42 | disallow_any_generics = True 43 | disallow_untyped_defs = True 44 | follow_imports = silent 45 | strict_optional = True 46 | warn_redundant_casts = True 47 | warn_unused_ignores = True 48 | warn_unused_configs = True 49 | disallow_incomplete_defs = True 50 | no_implicit_optional = True 51 | 52 | [mypy-testing.*] 53 | disallow_untyped_defs = false 54 | 55 | [mypy-tests.*] 56 | disallow_untyped_defs = false 57 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'master' 7 | - '[0-9].[0-9]+' # matches to backport branches, e.g. 3.6 8 | tags: [ 'v*' ] 9 | pull_request: 10 | branches: 11 | - 'master' 12 | - '[0-9].[0-9]+' 13 | schedule: 14 | - cron: '0 6 * * *' # Daily 6AM UTC build 15 | 16 | 17 | jobs: 18 | lint: 19 | name: Linter 20 | runs-on: ubuntu-latest 21 | timeout-minutes: 5 22 | steps: 23 | - name: Checkout 24 | uses: actions/checkout@v4 25 | - name: Setup Python 26 | uses: actions/setup-python@v5 27 | - name: Get pip cache dir 28 | id: pip-cache 29 | run: | 30 | echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT # - name: Cache 31 | shell: bash 32 | - name: Cache PyPI 33 | uses: actions/cache@v4 34 | with: 35 | key: pip-lint-${{ hashFiles('requirements.txt') }} 36 | path: ${{ steps.pip-cache.outputs.dir }} 37 | restore-keys: | 38 | pip-lint- 39 | - name: Install dependencies 40 | uses: py-actions/py-dependency-install@v4 41 | with: 42 | path: requirements.txt 43 | - name: Run linters 44 | run: | 45 | make fmt 46 | env: 47 | CI_LINT_RUN: 1 48 | 49 | test: 50 | name: Test 51 | needs: [lint] 52 | strategy: 53 | matrix: 54 | pyver: ['3.9', '3.10', '3.11', '3.12', '3.13'] 55 | fail-fast: false 56 | runs-on: ubuntu-latest 57 | timeout-minutes: 15 58 | steps: 59 | - name: Checkout 60 | uses: actions/checkout@v4 61 | - name: Setup Python ${{ matrix.pyver }} 62 | uses: actions/setup-python@v5 63 | with: 64 | python-version: ${{ matrix.pyver }} 65 | - name: Get pip cache dir 66 | id: pip-cache 67 | run: | 68 | echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT # - name: Cache 69 | shell: bash 70 | - name: Cache PyPI 71 | uses: actions/cache@v4 72 | with: 73 | key: pip-ci-${{ matrix.pyver }}-${{ hashFiles('requirements.txt') }} 74 | path: ${{ steps.pip-cache.outputs.dir }} 75 | restore-keys: | 76 | pip-ci-${{ matrix.pyver }}- 77 | - name: Install dependencies 78 | uses: py-actions/py-dependency-install@v4 79 | with: 80 | path: requirements.txt 81 | - name: Run unittests 82 | run: | 83 | make test 84 | python -m coverage xml 85 | - name: Upload coverage 86 | uses: codecov/codecov-action@v5 87 | with: 88 | token: ${{ secrets.CODECOV_TOKEN }} 89 | file: ./coverage.xml 90 | flags: unit 91 | fail_ci_if_error: false 92 | 93 | test-summary: 94 | if: always() 95 | needs: [lint, test] 96 | runs-on: ubuntu-latest 97 | steps: 98 | - name: Test matrix status 99 | uses: re-actors/alls-green@release/v1 100 | with: 101 | jobs: ${{ toJSON(needs) }} 102 | 103 | deploy: 104 | name: Deploy 105 | runs-on: ubuntu-latest 106 | needs: test-summary 107 | # Run only on pushing a tag 108 | if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') 109 | steps: 110 | - name: Checkout 111 | uses: actions/checkout@v4 112 | - name: Setup Python 113 | uses: actions/setup-python@v5 114 | - name: Install dependencies 115 | run: 116 | python -m pip install -U pip wheel twine build 117 | - name: Make dists 118 | run: 119 | python -m build 120 | - name: PyPI upload 121 | env: 122 | TWINE_USERNAME: __token__ 123 | TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} 124 | run: | 125 | twine upload dist/* 126 | -------------------------------------------------------------------------------- /tests/test_basic.py: -------------------------------------------------------------------------------- 1 | from textwrap import dedent 2 | 3 | import sort_all 4 | 5 | 6 | def test_sort_onliner_tuple() -> None: 7 | txt = dedent( 8 | """\ 9 | from mod import name1, name2 10 | 11 | __all__ = ("name2", "name1") 12 | """ 13 | ) 14 | 15 | expected = dedent( 16 | """\ 17 | from mod import name1, name2 18 | 19 | __all__ = ("name1", "name2") 20 | """ 21 | ) 22 | 23 | assert expected == sort_all._fix_src(txt, "") 24 | 25 | 26 | def test_sort_onliner_list() -> None: 27 | txt = dedent( 28 | """\ 29 | from mod import name1, name2 30 | 31 | __all__ = ["name2", "name1"] 32 | """ 33 | ) 34 | 35 | expected = dedent( 36 | """\ 37 | from mod import name1, name2 38 | 39 | __all__ = ["name1", "name2"] 40 | """ 41 | ) 42 | 43 | assert expected == sort_all._fix_src(txt, "") 44 | 45 | 46 | def test_sort_onliner_set() -> None: 47 | txt = dedent( 48 | """\ 49 | from mod import name1, name2 50 | 51 | __all__ = {"name2", "name1"} 52 | """ 53 | ) 54 | 55 | expected = dedent( 56 | """\ 57 | from mod import name1, name2 58 | 59 | __all__ = {"name1", "name2"} 60 | """ 61 | ) 62 | 63 | assert expected == sort_all._fix_src(txt, "") 64 | 65 | 66 | def test_sort_multiline_tuple() -> None: 67 | txt = dedent( 68 | """\ 69 | from mod import name1, name2 70 | 71 | __all__ = ( 72 | "name2", 73 | "name1", 74 | ) 75 | """ 76 | ) 77 | 78 | expected = dedent( 79 | """\ 80 | from mod import name1, name2 81 | 82 | __all__ = ( 83 | "name1", 84 | "name2", 85 | ) 86 | """ 87 | ) 88 | 89 | assert expected == sort_all._fix_src(txt, "") 90 | 91 | 92 | def test_sort_multiline_list() -> None: 93 | txt = dedent( 94 | """\ 95 | from mod import name1, name2 96 | 97 | __all__ = [ 98 | "name2", 99 | "name1", 100 | ] 101 | """ 102 | ) 103 | 104 | expected = dedent( 105 | """\ 106 | from mod import name1, name2 107 | 108 | __all__ = [ 109 | "name1", 110 | "name2", 111 | ] 112 | """ 113 | ) 114 | 115 | assert expected == sort_all._fix_src(txt, "") 116 | 117 | 118 | def test_sort_multiline_set() -> None: 119 | txt = dedent( 120 | """\ 121 | from mod import name1, name2 122 | 123 | __all__ = { 124 | "name2", 125 | "name1", 126 | } 127 | """ 128 | ) 129 | 130 | expected = dedent( 131 | """\ 132 | from mod import name1, name2 133 | 134 | __all__ = { 135 | "name1", 136 | "name2", 137 | } 138 | """ 139 | ) 140 | 141 | assert expected == sort_all._fix_src(txt, "") 142 | 143 | 144 | def test_sort_skip_nonconst() -> None: 145 | txt = dedent( 146 | """\ 147 | from mod import name1, name2 148 | 149 | __all__ = (name2, name1) 150 | """ 151 | ) 152 | 153 | expected = dedent( 154 | """\ 155 | from mod import name1, name2 156 | 157 | __all__ = (name2, name1) 158 | """ 159 | ) 160 | 161 | assert expected == sort_all._fix_src(txt, "") 162 | 163 | 164 | def test_sort_skip_non_list_set_tuple() -> None: 165 | txt = dedent( 166 | """\ 167 | from mod import name1, name2 168 | 169 | __all__ = ['name1'] + ['name2'] 170 | """ 171 | ) 172 | 173 | expected = dedent( 174 | """\ 175 | from mod import name1, name2 176 | 177 | __all__ = ['name1'] + ['name2'] 178 | """ 179 | ) 180 | 181 | assert expected == sort_all._fix_src(txt, "") 182 | 183 | 184 | def test_sort_skip_non_unknown_type() -> None: 185 | txt = dedent( 186 | """\ 187 | from mod import name1, name2 188 | 189 | __all__ = 123 190 | """ 191 | ) 192 | 193 | expected = dedent( 194 | """\ 195 | from mod import name1, name2 196 | 197 | __all__ = 123 198 | """ 199 | ) 200 | 201 | assert expected == sort_all._fix_src(txt, "") 202 | 203 | 204 | def test_skip_multiple_all() -> None: 205 | txt = dedent( 206 | """\ 207 | from mod import name1, name2 208 | 209 | __all__ = ("name1") 210 | __all__ = ("name2") 211 | """ 212 | ) 213 | 214 | expected = dedent( 215 | """\ 216 | from mod import name1, name2 217 | 218 | __all__ = ("name1") 219 | __all__ = ("name2") 220 | """ 221 | ) 222 | 223 | assert expected == sort_all._fix_src(txt, "") 224 | 225 | 226 | def test_sort_ann_assign_simple() -> None: 227 | txt = dedent( 228 | """\ 229 | from typing import Tuple 230 | from mod import name1, name2 231 | 232 | __all__: Tuple[str, ...] 233 | __all__ = ("name2", "name1") 234 | """ 235 | ) 236 | 237 | expected = dedent( 238 | """\ 239 | from typing import Tuple 240 | from mod import name1, name2 241 | 242 | __all__: Tuple[str, ...] 243 | __all__ = ("name1", "name2") 244 | """ 245 | ) 246 | 247 | assert expected == sort_all._fix_src(txt, "") 248 | 249 | 250 | def test_sort_ann_assign_real() -> None: 251 | txt = dedent( 252 | """\ 253 | from typing import Tuple 254 | from mod import name1, name2 255 | 256 | __all__: Tuple[str, ...] = ('name2', 'name1') 257 | """ 258 | ) 259 | 260 | expected = dedent( 261 | """\ 262 | from typing import Tuple 263 | from mod import name1, name2 264 | 265 | __all__: Tuple[str, ...] = ("name1", "name2") 266 | """ 267 | ) 268 | 269 | assert expected == sort_all._fix_src(txt, "") 270 | 271 | 272 | def test_sort_aug_assign_real() -> None: 273 | txt = dedent( 274 | """\ 275 | from mod import name1, name2 276 | 277 | __all__ = ('a', 'b') 278 | __all__ += ('name2', 'name1') 279 | """ 280 | ) 281 | 282 | expected = dedent( 283 | """\ 284 | from mod import name1, name2 285 | 286 | __all__ = ("a", "b") 287 | __all__ += ("name1", "name2") 288 | """ 289 | ) 290 | 291 | assert expected == sort_all._fix_src(txt, "") 292 | 293 | 294 | def test_sort_empty_tuple() -> None: 295 | txt = dedent( 296 | """\ 297 | from mod import name1, name2 298 | 299 | __all__ = () 300 | """ 301 | ) 302 | 303 | expected = dedent( 304 | """\ 305 | from mod import name1, name2 306 | 307 | __all__ = () 308 | """ 309 | ) 310 | 311 | assert expected == sort_all._fix_src(txt, "") 312 | 313 | 314 | def test_sort_empty_list() -> None: 315 | txt = dedent( 316 | """\ 317 | from mod import name1, name2 318 | 319 | __all__ = [] 320 | """ 321 | ) 322 | 323 | expected = dedent( 324 | """\ 325 | from mod import name1, name2 326 | 327 | __all__ = [] 328 | """ 329 | ) 330 | 331 | assert expected == sort_all._fix_src(txt, "") 332 | -------------------------------------------------------------------------------- /sort_all.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import ast 3 | import sys 4 | import warnings 5 | from collections.abc import Sequence 6 | from operator import attrgetter 7 | from typing import Optional 8 | 9 | from tokenize_rt import Offset, Token, src_to_tokens, tokens_to_src 10 | 11 | 12 | def ast_parse(contents_text: str) -> ast.Module: 13 | with warnings.catch_warnings(): 14 | warnings.simplefilter("ignore") 15 | return ast.parse(contents_text.encode()) 16 | 17 | 18 | class BaseVisitor: 19 | def visit(self, root: ast.AST) -> None: 20 | nodes: Sequence[ast.AST] 21 | if isinstance(root, ast.Module): 22 | nodes = root.body 23 | else: 24 | nodes = [root] 25 | for node in nodes: 26 | method = "visit_" + node.__class__.__name__ 27 | visitor = getattr(self, method, None) 28 | if visitor is not None: 29 | visitor(node) 30 | 31 | 32 | class ValueVisitor(BaseVisitor): 33 | def __init__(self, fname: str) -> None: 34 | self._fname = fname 35 | self._elts: list[list[ast.Constant]] = [] 36 | 37 | def _visit_elems(self, elts: list[ast.expr]) -> None: 38 | new_elts: list[ast.Constant] = [] 39 | for elt in elts: 40 | if not isinstance(elt, ast.Constant): 41 | print( 42 | f"{self._fname}:__all__ found " 43 | f"but it has non-const element {ast.dump(elt)}, skip sorting", 44 | ) 45 | return 46 | elif not isinstance(elt.value, str): 47 | # `__all__` has non-constant element in the container 48 | # Cannot process it 49 | print( 50 | f"{self._fname}:__all__ found " 51 | f"but it has non-string element {elt.value!r}, skip sorting", 52 | ) 53 | return 54 | else: 55 | new_elts.append(elt) 56 | self._elts.append(new_elts) 57 | 58 | def visit_List(self, node: ast.List) -> None: 59 | self._visit_elems(node.elts) 60 | 61 | def visit_Tuple(self, node: ast.Tuple) -> None: 62 | self._visit_elems(node.elts) 63 | 64 | def visit_Set(self, node: ast.Set) -> None: 65 | self._visit_elems(node.elts) 66 | 67 | 68 | class Visitor(BaseVisitor): 69 | def __init__(self, fname: str) -> None: 70 | self._elts: list[list[ast.Constant]] = [] 71 | self._fname = fname 72 | 73 | def visit_ass(self, value: ast.AST, targets: list[ast.expr]) -> None: 74 | found = False 75 | for tgt in targets: 76 | if isinstance(tgt, ast.Name) and tgt.id == "__all__": 77 | found = True 78 | break 79 | if found: 80 | visitor = ValueVisitor(self._fname) 81 | visitor.visit(value) 82 | self._elts.extend(visitor._elts) 83 | 84 | def visit_Assign(self, node: ast.Assign) -> None: 85 | self.visit_ass(node.value, node.targets) 86 | 87 | def visit_AnnAssign(self, node: ast.AnnAssign) -> None: 88 | if node.value is not None: 89 | self.visit_ass(node.value, [node.target]) 90 | 91 | def visit_AugAssign(self, node: ast.AugAssign) -> None: 92 | self.visit_ass(node.value, [node.target]) 93 | 94 | 95 | def consume(tokens: list[Token], start: int, pos: Offset) -> tuple[str, int]: 96 | toks: list[Token] = [] 97 | for idx, tok in enumerate(tokens[start:]): 98 | if tok.offset == pos: 99 | break 100 | else: 101 | toks.append(tok) 102 | return tokens_to_src(toks), start + idx 103 | 104 | 105 | def scan(tokens: list[Token], start: int, pos: Offset) -> int: 106 | for idx, tok in enumerate(tokens[start:]): 107 | if tok.offset == pos: 108 | break 109 | return start + idx 110 | 111 | 112 | def _fix_src(contents_text: str, fname: str) -> str: 113 | try: 114 | ast_obj = ast_parse(contents_text) 115 | except SyntaxError: 116 | return contents_text 117 | 118 | visitor = Visitor(fname) 119 | visitor.visit(ast_obj) 120 | if not visitor._elts: 121 | return contents_text 122 | 123 | tokens = src_to_tokens(contents_text) 124 | chunks = [] 125 | idx = 0 126 | 127 | for elts in visitor._elts: 128 | if not elts: 129 | continue 130 | 131 | start = Offset(elts[0].lineno, elts[0].col_offset) 132 | chunk, idx = consume(tokens, idx, start) 133 | chunks.append(chunk) 134 | 135 | end = Offset(elts[-1].end_lineno, elts[-1].end_col_offset) 136 | idx2 = scan(tokens, idx, end) 137 | 138 | if start.line == end.line: 139 | chunk = ", ".join( 140 | f'"{elt.value}"' for elt in sorted(elts, key=attrgetter("value")) 141 | ) 142 | else: 143 | for tok in tokens[idx:idx2]: 144 | if tok.name in ("INDENT", "UNIMPORTANT_WS"): 145 | indent = tok.src 146 | break 147 | else: 148 | indent = "" 149 | chunk = ("\n" + indent).join( 150 | f'"{elt.value}",' for elt in sorted(elts, key=attrgetter("value")) 151 | ) 152 | 153 | if chunk.endswith(",") and tokens[idx2].src.startswith(","): 154 | # drop double comma 155 | chunk = chunk[:-1] 156 | 157 | chunks.append(chunk) 158 | idx = idx2 159 | 160 | chunk, idx = consume(tokens, idx, Offset(sys.maxsize, 0)) 161 | chunks.append(chunk) 162 | return "".join(chunks) 163 | 164 | 165 | def fix_file(filename: str, write: bool = True, error_on_fix: bool = True) -> int: 166 | with open(filename, "rb") as f: 167 | contents_bytes = f.read() 168 | 169 | try: 170 | contents_text = contents_bytes.decode() 171 | except UnicodeDecodeError: 172 | print(f"{filename} is non-utf8 (not supported)") 173 | return 1 174 | 175 | new_content = _fix_src(contents_text, filename) 176 | if new_content == contents_text: 177 | return 0 178 | 179 | retv = 1 if error_on_fix else 0 180 | 181 | if not write: 182 | print(f"Found unsorted {filename}") 183 | return retv 184 | 185 | print(f"Rewriting {filename}") 186 | with open(filename, "wb") as f: 187 | f.write(new_content.encode()) 188 | 189 | return retv 190 | 191 | 192 | def main(argv: Optional[Sequence[str]] = None) -> int: 193 | parser = argparse.ArgumentParser(description="Sort __all__ records alphabetically.") 194 | # add --check flag 195 | parser.add_argument( 196 | "--check", 197 | action="store_true", 198 | help="check the file for unsorted / unformatted imports and " 199 | "print them to the command line without modifying the file; " 200 | "return 0 when nothing would change and " 201 | "return 1 when the file would be reformatted.", 202 | ) 203 | parser.add_argument( 204 | "--no-error-on-fix", 205 | action="store_true", 206 | help="return 0 even if errors are occurred during processing files", 207 | ) 208 | parser.add_argument("filenames", nargs="*", help="Files to process") 209 | args = parser.parse_args(argv) 210 | 211 | retv = 0 212 | for filename in args.filenames: 213 | if not filename.endswith((".py", ".pyx", ".pyi", ".pyd")): 214 | continue 215 | retv |= fix_file( 216 | filename, 217 | write=not args.check, 218 | error_on_fix=not args.no_error_on_fix, 219 | ) 220 | return retv 221 | 222 | 223 | if __name__ == "__main__": 224 | exit(main()) 225 | --------------------------------------------------------------------------------