flake8-stepdown 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flake8_stepdown-0.1.0/.github/workflows/ci.yml +18 -0
- flake8_stepdown-0.1.0/.github/workflows/publish.yml +16 -0
- flake8_stepdown-0.1.0/.gitignore +17 -0
- flake8_stepdown-0.1.0/.pre-commit-config.yaml +21 -0
- flake8_stepdown-0.1.0/.pre-commit-hooks.yaml +11 -0
- flake8_stepdown-0.1.0/.python-version +1 -0
- flake8_stepdown-0.1.0/.vscode/settings.json +9 -0
- flake8_stepdown-0.1.0/CONTRIBUTING.md +16 -0
- flake8_stepdown-0.1.0/PKG-INFO +126 -0
- flake8_stepdown-0.1.0/README.md +108 -0
- flake8_stepdown-0.1.0/flake8_stepdown/__init__.py +5 -0
- flake8_stepdown-0.1.0/flake8_stepdown/cli.py +168 -0
- flake8_stepdown-0.1.0/flake8_stepdown/core/__init__.py +1 -0
- flake8_stepdown-0.1.0/flake8_stepdown/core/bindings.py +148 -0
- flake8_stepdown-0.1.0/flake8_stepdown/core/graph.py +156 -0
- flake8_stepdown-0.1.0/flake8_stepdown/core/ordering.py +184 -0
- flake8_stepdown-0.1.0/flake8_stepdown/core/parser.py +102 -0
- flake8_stepdown-0.1.0/flake8_stepdown/core/references.py +260 -0
- flake8_stepdown-0.1.0/flake8_stepdown/flake8_plugin.py +42 -0
- flake8_stepdown-0.1.0/flake8_stepdown/py.typed +0 -0
- flake8_stepdown-0.1.0/flake8_stepdown/reporter.py +75 -0
- flake8_stepdown-0.1.0/flake8_stepdown/rewriter.py +99 -0
- flake8_stepdown-0.1.0/flake8_stepdown/types.py +78 -0
- flake8_stepdown-0.1.0/lint.sh +4 -0
- flake8_stepdown-0.1.0/pyproject.toml +92 -0
- flake8_stepdown-0.1.0/tests/__init__.py +1 -0
- flake8_stepdown-0.1.0/tests/fixtures/already_correct.py +9 -0
- flake8_stepdown-0.1.0/tests/fixtures/constant_docstrings.py +19 -0
- flake8_stepdown-0.1.0/tests/fixtures/constants_between_functions.py +14 -0
- flake8_stepdown-0.1.0/tests/fixtures/decorator_ordering.py +7 -0
- flake8_stepdown-0.1.0/tests/fixtures/main_guard.py +10 -0
- flake8_stepdown-0.1.0/tests/fixtures/mutual_recursion.py +6 -0
- flake8_stepdown-0.1.0/tests/fixtures/simple_bottomup.py +10 -0
- flake8_stepdown-0.1.0/tests/fixtures/simple_topdown.py +10 -0
- flake8_stepdown-0.1.0/tests/snapshots/already_correct.py +9 -0
- flake8_stepdown-0.1.0/tests/snapshots/constant_docstrings.py +19 -0
- flake8_stepdown-0.1.0/tests/snapshots/constants_between_functions.py +14 -0
- flake8_stepdown-0.1.0/tests/snapshots/decorator_ordering.py +7 -0
- flake8_stepdown-0.1.0/tests/snapshots/main_guard.py +10 -0
- flake8_stepdown-0.1.0/tests/snapshots/mutual_recursion.py +6 -0
- flake8_stepdown-0.1.0/tests/snapshots/simple_bottomup.py +10 -0
- flake8_stepdown-0.1.0/tests/snapshots/simple_topdown.py +10 -0
- flake8_stepdown-0.1.0/tests/test_bindings.py +251 -0
- flake8_stepdown-0.1.0/tests/test_cli.py +179 -0
- flake8_stepdown-0.1.0/tests/test_flake8_plugin.py +79 -0
- flake8_stepdown-0.1.0/tests/test_graph.py +304 -0
- flake8_stepdown-0.1.0/tests/test_ordering.py +343 -0
- flake8_stepdown-0.1.0/tests/test_parser.py +246 -0
- flake8_stepdown-0.1.0/tests/test_references.py +410 -0
- flake8_stepdown-0.1.0/tests/test_reporter.py +97 -0
- flake8_stepdown-0.1.0/tests/test_rewriter.py +347 -0
- flake8_stepdown-0.1.0/uv.lock +515 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
ci:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
- uses: astral-sh/setup-uv@v5
|
|
14
|
+
- run: uv sync --dev
|
|
15
|
+
- run: uv run ruff check .
|
|
16
|
+
- run: uv run ruff format --check .
|
|
17
|
+
- run: uv run ty check .
|
|
18
|
+
- run: uv run pytest
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
publish:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
permissions:
|
|
11
|
+
id-token: write # Required for trusted publishing
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v4
|
|
14
|
+
- uses: astral-sh/setup-uv@v5
|
|
15
|
+
- run: uv build
|
|
16
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
repos:
|
|
2
|
+
- repo: https://github.com/astral-sh/ruff-pre-commit
|
|
3
|
+
rev: v0.15.6
|
|
4
|
+
hooks:
|
|
5
|
+
- id: ruff-check
|
|
6
|
+
args: [--fix]
|
|
7
|
+
- id: ruff-format
|
|
8
|
+
- repo: local
|
|
9
|
+
hooks:
|
|
10
|
+
- id: ty
|
|
11
|
+
name: ty type checker
|
|
12
|
+
entry: uvx ty check .
|
|
13
|
+
language: system
|
|
14
|
+
pass_filenames: false
|
|
15
|
+
types: [python]
|
|
16
|
+
- id: pytest
|
|
17
|
+
name: pytest
|
|
18
|
+
entry: uv run pytest
|
|
19
|
+
language: system
|
|
20
|
+
pass_filenames: false
|
|
21
|
+
types: [python]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.13
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
## Code style
|
|
4
|
+
|
|
5
|
+
Ruff enforces many rules automatically.
|
|
6
|
+
Run `./lint.sh` to auto-fix and format.
|
|
7
|
+
|
|
8
|
+
## Commands reference
|
|
9
|
+
|
|
10
|
+
| Task | Command |
|
|
11
|
+
|------|---------|
|
|
12
|
+
| Install deps | `uv sync` |
|
|
13
|
+
| Lint + format | `./lint.sh` |
|
|
14
|
+
| Type check | `uvx ty check .` |
|
|
15
|
+
| Run tests | `uv run pytest` |
|
|
16
|
+
| Pre-commit (manual) | `uv run pre-commit run --all-files` |
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flake8-stepdown
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Enforce top-down / newspaper-style function ordering in Python
|
|
5
|
+
Project-URL: Homepage, https://github.com/<owner>/<project-name>
|
|
6
|
+
Project-URL: Repository, https://github.com/<owner>/<project-name>
|
|
7
|
+
Project-URL: Issues, https://github.com/<owner>/<project-name>/issues
|
|
8
|
+
Author-email: Didier Marin <mail@didiermarin.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Requires-Python: >=3.12
|
|
14
|
+
Requires-Dist: libcst>=1.1.0
|
|
15
|
+
Provides-Extra: flake8
|
|
16
|
+
Requires-Dist: flake8>=6.0; extra == 'flake8'
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# flake8-stepdown
|
|
20
|
+
|
|
21
|
+

|
|
22
|
+
|
|
23
|
+
A [flake8](https://flake8.pycqa.org/) plugin that enforces **top-down (newspaper-style) function ordering** in Python modules.
|
|
24
|
+
|
|
25
|
+
This is inspired by Robert C. Martin's "Clean Code" stepdown rule: High-level logic first, details later. When reading a module, callers should appear before callees, so you can read the code from top to bottom like a newspaper article.
|
|
26
|
+
|
|
27
|
+
## Violation codes
|
|
28
|
+
|
|
29
|
+
| Code | Meaning |
|
|
30
|
+
|---------|---------|
|
|
31
|
+
| TDP001 | Function is defined in the wrong order (should appear after another function) |
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install flake8-stepdown
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The plugin registers itself with flake8 automatically. Verify it's installed:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
flake8 --version
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
You should see `flake8-stepdown` in the list of installed plugins.
|
|
46
|
+
|
|
47
|
+
## Usage
|
|
48
|
+
|
|
49
|
+
### As a flake8 plugin
|
|
50
|
+
|
|
51
|
+
Just run flake8 as usual, the plugin will report `TDP001` violations:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
flake8 your_module.py
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Standalone CLI
|
|
58
|
+
|
|
59
|
+
The package also provides a `stepdown` command with three subcommands:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
# Report violations
|
|
63
|
+
stepdown check your_module.py
|
|
64
|
+
|
|
65
|
+
# Show a unified diff of the proposed reordering
|
|
66
|
+
stepdown diff your_module.py
|
|
67
|
+
|
|
68
|
+
# Rewrite files in place
|
|
69
|
+
stepdown fix your_module.py
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Use `-v` / `--verbose` to show mutual recursion info on stderr.
|
|
73
|
+
|
|
74
|
+
## Development
|
|
75
|
+
|
|
76
|
+
### Prerequisites
|
|
77
|
+
|
|
78
|
+
- [UV](https://docs.astral.sh/uv/) (Python package manager)
|
|
79
|
+
|
|
80
|
+
### Setup
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
uv sync
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Linting
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
./lint.sh
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Testing
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
uv run pytest
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Tests are organized by pipeline stage (`test_parser.py`, `test_graph.py`, `test_rewriter.py`, etc.). The rewriter uses **snapshot testing**: `tests/fixtures/` contains input Python files exercising various scenarios (bottom-up ordering, mutual recursion, decorators, etc.), and `tests/snapshots/` contains the expected output after rewriting. The test suite asserts correctness against snapshots, idempotency, and syntax validity.
|
|
99
|
+
|
|
100
|
+
### Pre-commit hooks
|
|
101
|
+
|
|
102
|
+
Pre-commit hooks are installed automatically. To run manually:
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
uv run pre-commit run --all-files
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## CI
|
|
109
|
+
|
|
110
|
+
GitHub Actions runs linting, type checking, and tests on every push to `main` and on pull requests.
|
|
111
|
+
|
|
112
|
+
## Publishing to PyPI
|
|
113
|
+
|
|
114
|
+
This project uses [trusted publishing](https://docs.pypi.org/trusted-publishers/) via GitHub Actions.
|
|
115
|
+
|
|
116
|
+
To publish a new version:
|
|
117
|
+
|
|
118
|
+
1. Bump the version with `uv version --bump patch` (or `minor`/`major`)
|
|
119
|
+
2. Create a GitHub release with a tag matching the version (e.g., `v0.1.0`)
|
|
120
|
+
3. The publish workflow will automatically build and upload to PyPI
|
|
121
|
+
|
|
122
|
+
> **First-time setup:** Configure a trusted publisher on PyPI under your project's settings (Publishing tab). Use `publish.yml` as the workflow name and `publish` as the environment name.
|
|
123
|
+
|
|
124
|
+
## Contributing
|
|
125
|
+
|
|
126
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for code style, conventions, and development workflow.
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# flake8-stepdown
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
A [flake8](https://flake8.pycqa.org/) plugin that enforces **top-down (newspaper-style) function ordering** in Python modules.
|
|
6
|
+
|
|
7
|
+
This is inspired by Robert C. Martin's "Clean Code" stepdown rule: High-level logic first, details later. When reading a module, callers should appear before callees, so you can read the code from top to bottom like a newspaper article.
|
|
8
|
+
|
|
9
|
+
## Violation codes
|
|
10
|
+
|
|
11
|
+
| Code | Meaning |
|
|
12
|
+
|---------|---------|
|
|
13
|
+
| TDP001 | Function is defined in the wrong order (should appear after another function) |
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install flake8-stepdown
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The plugin registers itself with flake8 automatically. Verify it's installed:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
flake8 --version
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
You should see `flake8-stepdown` in the list of installed plugins.
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
### As a flake8 plugin
|
|
32
|
+
|
|
33
|
+
Just run flake8 as usual, the plugin will report `TDP001` violations:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
flake8 your_module.py
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Standalone CLI
|
|
40
|
+
|
|
41
|
+
The package also provides a `stepdown` command with three subcommands:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
# Report violations
|
|
45
|
+
stepdown check your_module.py
|
|
46
|
+
|
|
47
|
+
# Show a unified diff of the proposed reordering
|
|
48
|
+
stepdown diff your_module.py
|
|
49
|
+
|
|
50
|
+
# Rewrite files in place
|
|
51
|
+
stepdown fix your_module.py
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Use `-v` / `--verbose` to show mutual recursion info on stderr.
|
|
55
|
+
|
|
56
|
+
## Development
|
|
57
|
+
|
|
58
|
+
### Prerequisites
|
|
59
|
+
|
|
60
|
+
- [UV](https://docs.astral.sh/uv/) (Python package manager)
|
|
61
|
+
|
|
62
|
+
### Setup
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
uv sync
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Linting
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
./lint.sh
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Testing
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
uv run pytest
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Tests are organized by pipeline stage (`test_parser.py`, `test_graph.py`, `test_rewriter.py`, etc.). The rewriter uses **snapshot testing**: `tests/fixtures/` contains input Python files exercising various scenarios (bottom-up ordering, mutual recursion, decorators, etc.), and `tests/snapshots/` contains the expected output after rewriting. The test suite asserts correctness against snapshots, idempotency, and syntax validity.
|
|
81
|
+
|
|
82
|
+
### Pre-commit hooks
|
|
83
|
+
|
|
84
|
+
Pre-commit hooks are installed automatically. To run manually:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
uv run pre-commit run --all-files
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## CI
|
|
91
|
+
|
|
92
|
+
GitHub Actions runs linting, type checking, and tests on every push to `main` and on pull requests.
|
|
93
|
+
|
|
94
|
+
## Publishing to PyPI
|
|
95
|
+
|
|
96
|
+
This project uses [trusted publishing](https://docs.pypi.org/trusted-publishers/) via GitHub Actions.
|
|
97
|
+
|
|
98
|
+
To publish a new version:
|
|
99
|
+
|
|
100
|
+
1. Bump the version with `uv version --bump patch` (or `minor`/`major`)
|
|
101
|
+
2. Create a GitHub release with a tag matching the version (e.g., `v0.1.0`)
|
|
102
|
+
3. The publish workflow will automatically build and upload to PyPI
|
|
103
|
+
|
|
104
|
+
> **First-time setup:** Configure a trusted publisher on PyPI under your project's settings (Publishing tab). Use `publish.yml` as the workflow name and `publish` as the environment name.
|
|
105
|
+
|
|
106
|
+
## Contributing
|
|
107
|
+
|
|
108
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for code style, conventions, and development workflow.
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""CLI entry point for flake8-stepdown."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from fnmatch import fnmatch
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from flake8_stepdown.core.ordering import order_module
|
|
11
|
+
from flake8_stepdown.reporter import format_diff, format_violations
|
|
12
|
+
|
|
13
|
+
EXIT_OK = 0
|
|
14
|
+
EXIT_VIOLATIONS = 1
|
|
15
|
+
EXIT_ERROR = 2
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def main(argv: list[str] | None = None) -> int:
|
|
19
|
+
"""CLI entry point.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
Exit code: 0 (clean), 1 (violations/changes), 2 (error).
|
|
23
|
+
|
|
24
|
+
"""
|
|
25
|
+
parser = _build_parser()
|
|
26
|
+
args = parser.parse_args(argv)
|
|
27
|
+
|
|
28
|
+
# Handle stdin
|
|
29
|
+
if args.stdin_filename:
|
|
30
|
+
source = sys.stdin.read()
|
|
31
|
+
code, output = _process_source(source, args.stdin_filename, args)
|
|
32
|
+
if output:
|
|
33
|
+
_write_output(output)
|
|
34
|
+
return code
|
|
35
|
+
|
|
36
|
+
if not args.files:
|
|
37
|
+
sys.stderr.write("Error: no files specified\n")
|
|
38
|
+
return EXIT_ERROR
|
|
39
|
+
|
|
40
|
+
filepaths = _resolve_paths(args.files, args.exclude)
|
|
41
|
+
if not filepaths:
|
|
42
|
+
return EXIT_OK
|
|
43
|
+
|
|
44
|
+
exit_code = EXIT_OK
|
|
45
|
+
for filepath in filepaths:
|
|
46
|
+
code = _process_file(filepath, args)
|
|
47
|
+
if code == EXIT_ERROR:
|
|
48
|
+
return EXIT_ERROR
|
|
49
|
+
exit_code = max(exit_code, code)
|
|
50
|
+
|
|
51
|
+
return exit_code
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
55
|
+
"""Build the argument parser."""
|
|
56
|
+
parser = argparse.ArgumentParser(
|
|
57
|
+
prog="stepdown",
|
|
58
|
+
description="Enforce top-down function ordering in Python",
|
|
59
|
+
)
|
|
60
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
61
|
+
|
|
62
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
63
|
+
common.add_argument("files", nargs="*", help="Files or directories to check")
|
|
64
|
+
common.add_argument("--exclude", action="append", default=[], help="Glob patterns to exclude")
|
|
65
|
+
common.add_argument(
|
|
66
|
+
"-v",
|
|
67
|
+
"--verbose",
|
|
68
|
+
action="store_true",
|
|
69
|
+
help="Show debug info (mutual recursion info on stderr)",
|
|
70
|
+
)
|
|
71
|
+
common.add_argument(
|
|
72
|
+
"--stdin-filename",
|
|
73
|
+
help="Read from stdin, use this filename for output",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
check_parser = subparsers.add_parser("check", parents=[common], help="Report violations")
|
|
77
|
+
check_parser.add_argument(
|
|
78
|
+
"--format",
|
|
79
|
+
dest="fmt",
|
|
80
|
+
choices=["text", "json"],
|
|
81
|
+
default="text",
|
|
82
|
+
help="Output format",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
subparsers.add_parser("diff", parents=[common], help="Show unified diff")
|
|
86
|
+
subparsers.add_parser("fix", parents=[common], help="Rewrite files in place")
|
|
87
|
+
|
|
88
|
+
return parser
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _resolve_paths(paths: list[str], exclude: list[str]) -> list[str]:
|
|
92
|
+
"""Expand directories to .py files and apply exclude patterns."""
|
|
93
|
+
resolved: list[str] = []
|
|
94
|
+
for entry in paths:
|
|
95
|
+
p = Path(entry)
|
|
96
|
+
if p.is_dir():
|
|
97
|
+
for py_file in sorted(p.rglob("*.py")):
|
|
98
|
+
filepath = str(py_file)
|
|
99
|
+
if not any(fnmatch(filepath, pat) for pat in exclude):
|
|
100
|
+
resolved.append(filepath)
|
|
101
|
+
elif not any(fnmatch(entry, pat) for pat in exclude):
|
|
102
|
+
resolved.append(entry)
|
|
103
|
+
return resolved
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _process_file(filepath: str, args: argparse.Namespace) -> int:
|
|
107
|
+
"""Process a single file and handle output."""
|
|
108
|
+
path = Path(filepath)
|
|
109
|
+
if not path.exists():
|
|
110
|
+
sys.stderr.write(f"Error: {filepath} not found\n")
|
|
111
|
+
return EXIT_ERROR
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
source = path.read_text()
|
|
115
|
+
except (OSError, UnicodeDecodeError) as e:
|
|
116
|
+
sys.stderr.write(f"Error reading {filepath}: {e}\n")
|
|
117
|
+
return EXIT_ERROR
|
|
118
|
+
|
|
119
|
+
code, output = _process_source(source, filepath, args)
|
|
120
|
+
|
|
121
|
+
if args.command == "fix" and code == EXIT_VIOLATIONS and output:
|
|
122
|
+
path.write_text(output)
|
|
123
|
+
elif output:
|
|
124
|
+
_write_output(output)
|
|
125
|
+
|
|
126
|
+
return code
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _process_source(
|
|
130
|
+
source: str,
|
|
131
|
+
filename: str,
|
|
132
|
+
args: argparse.Namespace,
|
|
133
|
+
) -> tuple[int, str]:
|
|
134
|
+
"""Process a single source file and return (exit_code, output)."""
|
|
135
|
+
compute_rewrite = args.command != "check"
|
|
136
|
+
result = order_module(source, compute_rewrite=compute_rewrite)
|
|
137
|
+
|
|
138
|
+
if args.verbose and result.mutual_recursion_groups:
|
|
139
|
+
for group in result.mutual_recursion_groups:
|
|
140
|
+
sys.stderr.write(
|
|
141
|
+
f"{filename}: mutual recursion between {', '.join(group)}; original order preserved\n"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
if args.command == "check":
|
|
145
|
+
output = format_violations(result.violations, filename=filename, fmt=args.fmt)
|
|
146
|
+
return (EXIT_VIOLATIONS if result.violations else EXIT_OK), output
|
|
147
|
+
|
|
148
|
+
if args.command == "diff":
|
|
149
|
+
if result.reordered_source is not None:
|
|
150
|
+
output = format_diff(source, result.reordered_source, filename=filename)
|
|
151
|
+
return EXIT_VIOLATIONS, output
|
|
152
|
+
return EXIT_OK, ""
|
|
153
|
+
|
|
154
|
+
# fix command
|
|
155
|
+
if result.reordered_source is not None:
|
|
156
|
+
return EXIT_VIOLATIONS, result.reordered_source
|
|
157
|
+
return EXIT_OK, ""
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _write_output(output: str) -> None:
|
|
161
|
+
"""Write output to stdout with trailing newline if needed."""
|
|
162
|
+
sys.stdout.write(output)
|
|
163
|
+
if not output.endswith("\n"):
|
|
164
|
+
sys.stdout.write("\n")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
if __name__ == "__main__":
|
|
168
|
+
sys.exit(main())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core analysis modules for flake8-stepdown."""
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Extract bindings (defined names) from module-level statements."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
import libcst as cst
|
|
8
|
+
import libcst.matchers as m
|
|
9
|
+
|
|
10
|
+
from flake8_stepdown.types import Statement
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from collections.abc import Mapping
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def extract_bindings(
|
|
17
|
+
statements: list[cst.CSTNode],
|
|
18
|
+
positions: Mapping[cst.CSTNode, cst.metadata.CodeRange],
|
|
19
|
+
) -> list[Statement]:
|
|
20
|
+
"""Extract bindings from module-level statements.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
statements: The module-level CST nodes to analyze (functions, classes,
|
|
24
|
+
and assignments from the reorderable zone between preamble and postamble).
|
|
25
|
+
positions: Position mapping from MetadataWrapper.resolve(PositionProvider).
|
|
26
|
+
|
|
27
|
+
Groups consecutive @overload stubs with their implementation into a single Statement.
|
|
28
|
+
Returns Statement objects with empty refs (to be populated by references module).
|
|
29
|
+
|
|
30
|
+
"""
|
|
31
|
+
result: list[Statement] = []
|
|
32
|
+
i = 0
|
|
33
|
+
nodes = list(statements)
|
|
34
|
+
|
|
35
|
+
while i < len(nodes):
|
|
36
|
+
node = nodes[i]
|
|
37
|
+
|
|
38
|
+
# Check for @overload grouping
|
|
39
|
+
if isinstance(node, cst.FunctionDef) and _has_overload_decorator(node):
|
|
40
|
+
func_name = node.name.value
|
|
41
|
+
group_nodes: list[cst.CSTNode] = [node]
|
|
42
|
+
|
|
43
|
+
# Collect consecutive same-name functions
|
|
44
|
+
j = i + 1
|
|
45
|
+
while j < len(nodes):
|
|
46
|
+
next_node = nodes[j]
|
|
47
|
+
if isinstance(next_node, cst.FunctionDef) and next_node.name.value == func_name:
|
|
48
|
+
group_nodes.append(next_node)
|
|
49
|
+
if not _has_overload_decorator(next_node):
|
|
50
|
+
j += 1
|
|
51
|
+
break
|
|
52
|
+
j += 1
|
|
53
|
+
else:
|
|
54
|
+
break
|
|
55
|
+
|
|
56
|
+
# Merge if stubs + implementation (>1 node and last is not overload)
|
|
57
|
+
last_node = group_nodes[-1]
|
|
58
|
+
if (
|
|
59
|
+
len(group_nodes) > 1
|
|
60
|
+
and isinstance(last_node, cst.FunctionDef)
|
|
61
|
+
and not _has_overload_decorator(last_node)
|
|
62
|
+
):
|
|
63
|
+
first_pos = positions.get(group_nodes[0])
|
|
64
|
+
last_pos = positions.get(last_node)
|
|
65
|
+
result.append(
|
|
66
|
+
Statement(
|
|
67
|
+
node=last_node,
|
|
68
|
+
start_line=first_pos.start.line if first_pos else 0,
|
|
69
|
+
end_line=last_pos.end.line if last_pos else 0,
|
|
70
|
+
bindings=frozenset({func_name}),
|
|
71
|
+
immediate_refs=frozenset(),
|
|
72
|
+
deferred_refs=frozenset(),
|
|
73
|
+
is_overload_group=True,
|
|
74
|
+
),
|
|
75
|
+
)
|
|
76
|
+
i = j
|
|
77
|
+
continue
|
|
78
|
+
|
|
79
|
+
# Not a complete overload group — fall through to normal handling
|
|
80
|
+
|
|
81
|
+
# Normal statement
|
|
82
|
+
pos = positions.get(node)
|
|
83
|
+
start_line = pos.start.line if pos else 0
|
|
84
|
+
end_line = pos.end.line if pos else 0
|
|
85
|
+
|
|
86
|
+
bindings = (
|
|
87
|
+
_extract_binding_names(node) if isinstance(node, cst.BaseStatement) else frozenset()
|
|
88
|
+
)
|
|
89
|
+
result.append(
|
|
90
|
+
Statement(
|
|
91
|
+
node=node,
|
|
92
|
+
start_line=start_line,
|
|
93
|
+
end_line=end_line,
|
|
94
|
+
bindings=bindings,
|
|
95
|
+
immediate_refs=frozenset(),
|
|
96
|
+
deferred_refs=frozenset(),
|
|
97
|
+
is_overload_group=False,
|
|
98
|
+
),
|
|
99
|
+
)
|
|
100
|
+
i += 1
|
|
101
|
+
|
|
102
|
+
return result
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _extract_binding_names(node: cst.BaseStatement) -> frozenset[str]:
|
|
106
|
+
"""Extract the names defined by a single statement."""
|
|
107
|
+
if isinstance(node, cst.FunctionDef):
|
|
108
|
+
return frozenset({node.name.value})
|
|
109
|
+
|
|
110
|
+
if isinstance(node, cst.ClassDef):
|
|
111
|
+
return frozenset({node.name.value})
|
|
112
|
+
|
|
113
|
+
if isinstance(node, cst.SimpleStatementLine):
|
|
114
|
+
names: set[str] = set()
|
|
115
|
+
for stmt in node.body:
|
|
116
|
+
if isinstance(stmt, cst.Assign):
|
|
117
|
+
for target in stmt.targets:
|
|
118
|
+
names |= _collect_names(target.target)
|
|
119
|
+
elif isinstance(stmt, cst.AnnAssign) and stmt.value is not None:
|
|
120
|
+
names |= _collect_names(stmt.target)
|
|
121
|
+
return frozenset(names)
|
|
122
|
+
|
|
123
|
+
return frozenset()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _collect_names(target: cst.BaseExpression) -> set[str]:
|
|
127
|
+
"""Recursively collect all Name identifiers from an assignment target."""
|
|
128
|
+
if isinstance(target, cst.Name):
|
|
129
|
+
return {target.value}
|
|
130
|
+
if isinstance(target, cst.Tuple):
|
|
131
|
+
names: set[str] = set()
|
|
132
|
+
for element in target.elements:
|
|
133
|
+
names |= _collect_names(element.value)
|
|
134
|
+
return names
|
|
135
|
+
if isinstance(target, cst.StarredElement):
|
|
136
|
+
return _collect_names(target.value)
|
|
137
|
+
return set()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _has_overload_decorator(node: cst.FunctionDef) -> bool:
|
|
141
|
+
"""Check if a FunctionDef has @typing.overload or @overload."""
|
|
142
|
+
for decorator in node.decorators:
|
|
143
|
+
dec = decorator.decorator
|
|
144
|
+
if m.matches(dec, m.Name("overload")):
|
|
145
|
+
return True
|
|
146
|
+
if m.matches(dec, m.Attribute(value=m.Name("typing"), attr=m.Name("overload"))):
|
|
147
|
+
return True
|
|
148
|
+
return False
|