undersort 0.1.3__tar.gz → 0.1.5__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.
- {undersort-0.1.3 → undersort-0.1.5}/PKG-INFO +57 -2
- {undersort-0.1.3 → undersort-0.1.5}/README.md +56 -1
- {undersort-0.1.3 → undersort-0.1.5}/pyproject.toml +5 -1
- {undersort-0.1.3 → undersort-0.1.5}/tests/test_config.py +43 -7
- {undersort-0.1.3 → undersort-0.1.5}/tests/test_main.py +72 -0
- undersort-0.1.5/tests/test_nosort.py +190 -0
- {undersort-0.1.3 → undersort-0.1.5}/undersort/__init__.py +1 -1
- {undersort-0.1.3 → undersort-0.1.5}/undersort/config.py +9 -1
- {undersort-0.1.3 → undersort-0.1.5}/undersort/main.py +52 -3
- {undersort-0.1.3 → undersort-0.1.5}/undersort/sorter.py +68 -4
- {undersort-0.1.3 → undersort-0.1.5}/uv.lock +1 -1
- {undersort-0.1.3 → undersort-0.1.5}/.github/workflows/ci.yml +0 -0
- {undersort-0.1.3 → undersort-0.1.5}/.gitignore +0 -0
- {undersort-0.1.3 → undersort-0.1.5}/.pre-commit-config.yaml +0 -0
- {undersort-0.1.3 → undersort-0.1.5}/.pre-commit-hooks.yaml +0 -0
- {undersort-0.1.3 → undersort-0.1.5}/.python-version +0 -0
- {undersort-0.1.3 → undersort-0.1.5}/.vscode/settings.json +0 -0
- {undersort-0.1.3 → undersort-0.1.5}/LICENSE +0 -0
- {undersort-0.1.3 → undersort-0.1.5}/Makefile +0 -0
- {undersort-0.1.3 → undersort-0.1.5}/tests/__init__.py +0 -0
- {undersort-0.1.3 → undersort-0.1.5}/tests/test_sorter.py +0 -0
- {undersort-0.1.3 → undersort-0.1.5}/undersort/logger.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: undersort
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5
|
|
4
4
|
Summary: A tool to sort class methods by visibility (public, protected, private)
|
|
5
5
|
Project-URL: Homepage, https://github.com/kivicode/undersort
|
|
6
6
|
Project-URL: Repository, https://github.com/kivicode/undersort
|
|
@@ -68,6 +68,10 @@ order = ["public", "protected", "private"]
|
|
|
68
68
|
# Options: "class" (classmethod), "static" (staticmethod), "instance" (regular methods)
|
|
69
69
|
# Default: ["instance", "class", "static"]
|
|
70
70
|
method_type_order = ["instance", "class", "static"]
|
|
71
|
+
|
|
72
|
+
# Exclude files/directories matching these patterns (optional)
|
|
73
|
+
# Patterns support glob syntax (e.g., "tests/*", "migrations/*.py", "**/generated/*")
|
|
74
|
+
# exclude = ["tests/*", "migrations/*.py"]
|
|
71
75
|
```
|
|
72
76
|
|
|
73
77
|
### Method Visibility Rules
|
|
@@ -107,6 +111,51 @@ Example order with default configuration:
|
|
|
107
111
|
8. Private class methods
|
|
108
112
|
9. Private static methods
|
|
109
113
|
|
|
114
|
+
### Skipping Sorting with `# nosort`
|
|
115
|
+
|
|
116
|
+
You can prevent sorting at different levels using `# nosort` comments (case-insensitive):
|
|
117
|
+
|
|
118
|
+
**File-level**: Skip entire file
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
# nosort: file
|
|
122
|
+
class Example:
|
|
123
|
+
def _protected(self):
|
|
124
|
+
pass
|
|
125
|
+
def public(self):
|
|
126
|
+
pass # File won't be sorted
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
**Class-level**: Skip specific class
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
class Example: # nosort
|
|
133
|
+
def _protected(self):
|
|
134
|
+
pass
|
|
135
|
+
def public(self):
|
|
136
|
+
pass # This class won't be sorted
|
|
137
|
+
|
|
138
|
+
class Other:
|
|
139
|
+
def _protected(self):
|
|
140
|
+
pass
|
|
141
|
+
def public(self):
|
|
142
|
+
pass # This class WILL be sorted
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
**Method-level**: Keep method in its current position
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
class Example:
|
|
149
|
+
def public_a(self):
|
|
150
|
+
pass
|
|
151
|
+
|
|
152
|
+
def _protected(self): # nosort
|
|
153
|
+
pass # Stays here, between public methods
|
|
154
|
+
|
|
155
|
+
def public_b(self):
|
|
156
|
+
pass # Will move up, but _protected stays in place
|
|
157
|
+
```
|
|
158
|
+
|
|
110
159
|
## Usage
|
|
111
160
|
|
|
112
161
|
### Command Line
|
|
@@ -140,9 +189,15 @@ undersort --diff example.py
|
|
|
140
189
|
|
|
141
190
|
# Combine flags
|
|
142
191
|
undersort --check --diff src/
|
|
192
|
+
|
|
193
|
+
# Exclude specific files or directories
|
|
194
|
+
undersort --exclude "tests/*" --exclude "migrations/*.py" src/
|
|
195
|
+
|
|
196
|
+
# Multiple exclude patterns (can be combined with config file patterns)
|
|
197
|
+
undersort --exclude "test_*.py" --exclude "*/legacy/*" .
|
|
143
198
|
```
|
|
144
199
|
|
|
145
|
-
**Note**: By default, undersort excludes all dot-prefixed directories (e.g., `.venv`, `.git`, `.pytest_cache`) and common build directories (`venv`, `__pycache__`, `node_modules`) when scanning directories recursively.
|
|
200
|
+
**Note**: By default, undersort excludes all dot-prefixed directories (e.g., `.venv`, `.git`, `.pytest_cache`) and common build directories (`venv`, `__pycache__`, `node_modules`) when scanning directories recursively. You can add custom exclusions via CLI flags or the config file.
|
|
146
201
|
|
|
147
202
|
### Pre-commit Integration
|
|
148
203
|
|
|
@@ -41,6 +41,10 @@ order = ["public", "protected", "private"]
|
|
|
41
41
|
# Options: "class" (classmethod), "static" (staticmethod), "instance" (regular methods)
|
|
42
42
|
# Default: ["instance", "class", "static"]
|
|
43
43
|
method_type_order = ["instance", "class", "static"]
|
|
44
|
+
|
|
45
|
+
# Exclude files/directories matching these patterns (optional)
|
|
46
|
+
# Patterns support glob syntax (e.g., "tests/*", "migrations/*.py", "**/generated/*")
|
|
47
|
+
# exclude = ["tests/*", "migrations/*.py"]
|
|
44
48
|
```
|
|
45
49
|
|
|
46
50
|
### Method Visibility Rules
|
|
@@ -80,6 +84,51 @@ Example order with default configuration:
|
|
|
80
84
|
8. Private class methods
|
|
81
85
|
9. Private static methods
|
|
82
86
|
|
|
87
|
+
### Skipping Sorting with `# nosort`
|
|
88
|
+
|
|
89
|
+
You can prevent sorting at different levels using `# nosort` comments (case-insensitive):
|
|
90
|
+
|
|
91
|
+
**File-level**: Skip entire file
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
# nosort: file
|
|
95
|
+
class Example:
|
|
96
|
+
def _protected(self):
|
|
97
|
+
pass
|
|
98
|
+
def public(self):
|
|
99
|
+
pass # File won't be sorted
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
**Class-level**: Skip specific class
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
class Example: # nosort
|
|
106
|
+
def _protected(self):
|
|
107
|
+
pass
|
|
108
|
+
def public(self):
|
|
109
|
+
pass # This class won't be sorted
|
|
110
|
+
|
|
111
|
+
class Other:
|
|
112
|
+
def _protected(self):
|
|
113
|
+
pass
|
|
114
|
+
def public(self):
|
|
115
|
+
pass # This class WILL be sorted
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
**Method-level**: Keep method in its current position
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
class Example:
|
|
122
|
+
def public_a(self):
|
|
123
|
+
pass
|
|
124
|
+
|
|
125
|
+
def _protected(self): # nosort
|
|
126
|
+
pass # Stays here, between public methods
|
|
127
|
+
|
|
128
|
+
def public_b(self):
|
|
129
|
+
pass # Will move up, but _protected stays in place
|
|
130
|
+
```
|
|
131
|
+
|
|
83
132
|
## Usage
|
|
84
133
|
|
|
85
134
|
### Command Line
|
|
@@ -113,9 +162,15 @@ undersort --diff example.py
|
|
|
113
162
|
|
|
114
163
|
# Combine flags
|
|
115
164
|
undersort --check --diff src/
|
|
165
|
+
|
|
166
|
+
# Exclude specific files or directories
|
|
167
|
+
undersort --exclude "tests/*" --exclude "migrations/*.py" src/
|
|
168
|
+
|
|
169
|
+
# Multiple exclude patterns (can be combined with config file patterns)
|
|
170
|
+
undersort --exclude "test_*.py" --exclude "*/legacy/*" .
|
|
116
171
|
```
|
|
117
172
|
|
|
118
|
-
**Note**: By default, undersort excludes all dot-prefixed directories (e.g., `.venv`, `.git`, `.pytest_cache`) and common build directories (`venv`, `__pycache__`, `node_modules`) when scanning directories recursively.
|
|
173
|
+
**Note**: By default, undersort excludes all dot-prefixed directories (e.g., `.venv`, `.git`, `.pytest_cache`) and common build directories (`venv`, `__pycache__`, `node_modules`) when scanning directories recursively. You can add custom exclusions via CLI flags or the config file.
|
|
119
174
|
|
|
120
175
|
### Pre-commit Integration
|
|
121
176
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "undersort"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.5"
|
|
8
8
|
description = "A tool to sort class methods by visibility (public, protected, private)"
|
|
9
9
|
authors = [{ name = "KiviCode", email = "kivicode.dev@gmail.com" }]
|
|
10
10
|
readme = "README.md"
|
|
@@ -50,6 +50,10 @@ order = ["public", "protected", "private"]
|
|
|
50
50
|
# Default order if not specified: ["instance", "class", "static"]
|
|
51
51
|
method_type_order = ["instance", "class", "static"]
|
|
52
52
|
|
|
53
|
+
# Exclude files/directories matching these patterns (optional)
|
|
54
|
+
# Patterns support glob syntax (e.g., "tests/*", "migrations/*.py", "**/generated/*")
|
|
55
|
+
# exclude = ["tests/*", "migrations/*.py"]
|
|
56
|
+
|
|
53
57
|
[tool.ruff]
|
|
54
58
|
unsafe-fixes = true
|
|
55
59
|
exclude = [".venv"]
|
|
@@ -14,7 +14,7 @@ class TestConfigLoading:
|
|
|
14
14
|
"""Test that default config is returned when no pyproject.toml exists."""
|
|
15
15
|
monkeypatch.chdir(tmp_path)
|
|
16
16
|
config = load_config()
|
|
17
|
-
assert config == {"order": ["public", "protected", "private"], "method_type_order": None}
|
|
17
|
+
assert config == {"order": ["public", "protected", "private"], "method_type_order": None, "exclude": None}
|
|
18
18
|
|
|
19
19
|
def test_load_custom_order(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
20
20
|
"""Test loading custom order from pyproject.toml."""
|
|
@@ -27,7 +27,7 @@ order = ["private", "protected", "public"]
|
|
|
27
27
|
|
|
28
28
|
monkeypatch.chdir(tmp_path)
|
|
29
29
|
config = load_config()
|
|
30
|
-
assert config == {"order": ["private", "protected", "public"], "method_type_order": None}
|
|
30
|
+
assert config == {"order": ["private", "protected", "public"], "method_type_order": None, "exclude": None}
|
|
31
31
|
|
|
32
32
|
def test_invalid_order_values(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
33
33
|
"""Test that invalid order values fall back to default."""
|
|
@@ -40,7 +40,7 @@ order = ["public", "invalid", "private"]
|
|
|
40
40
|
|
|
41
41
|
monkeypatch.chdir(tmp_path)
|
|
42
42
|
config = load_config()
|
|
43
|
-
assert config == {"order": ["public", "protected", "private"], "method_type_order": None}
|
|
43
|
+
assert config == {"order": ["public", "protected", "private"], "method_type_order": None, "exclude": None}
|
|
44
44
|
|
|
45
45
|
def test_missing_order_key(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
46
46
|
"""Test that missing order key returns default."""
|
|
@@ -53,7 +53,7 @@ some_other_key = "value"
|
|
|
53
53
|
|
|
54
54
|
monkeypatch.chdir(tmp_path)
|
|
55
55
|
config = load_config()
|
|
56
|
-
assert config == {"order": ["public", "protected", "private"], "method_type_order": None}
|
|
56
|
+
assert config == {"order": ["public", "protected", "private"], "method_type_order": None, "exclude": None}
|
|
57
57
|
|
|
58
58
|
def test_missing_tool_section(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
59
59
|
"""Test that missing tool.undersort section returns default."""
|
|
@@ -66,7 +66,7 @@ name = "test"
|
|
|
66
66
|
|
|
67
67
|
monkeypatch.chdir(tmp_path)
|
|
68
68
|
config = load_config()
|
|
69
|
-
assert config == {"order": ["public", "protected", "private"], "method_type_order": None}
|
|
69
|
+
assert config == {"order": ["public", "protected", "private"], "method_type_order": None, "exclude": None}
|
|
70
70
|
|
|
71
71
|
def test_find_pyproject_in_parent(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
72
72
|
"""Test that pyproject.toml is found in parent directories."""
|
|
@@ -84,7 +84,7 @@ order = ["private", "public", "protected"]
|
|
|
84
84
|
monkeypatch.chdir(subdir)
|
|
85
85
|
|
|
86
86
|
config = load_config()
|
|
87
|
-
assert config == {"order": ["private", "public", "protected"], "method_type_order": None}
|
|
87
|
+
assert config == {"order": ["private", "public", "protected"], "method_type_order": None, "exclude": None}
|
|
88
88
|
|
|
89
89
|
def test_corrupted_toml(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
90
90
|
"""Test that corrupted TOML file falls back to default."""
|
|
@@ -97,7 +97,7 @@ order = ["public" # Invalid TOML
|
|
|
97
97
|
|
|
98
98
|
monkeypatch.chdir(tmp_path)
|
|
99
99
|
config = load_config()
|
|
100
|
-
assert config == {"order": ["public", "protected", "private"], "method_type_order": None}
|
|
100
|
+
assert config == {"order": ["public", "protected", "private"], "method_type_order": None, "exclude": None}
|
|
101
101
|
|
|
102
102
|
def test_find_pyproject_toml_not_found(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
103
103
|
"""Test _find_pyproject_toml returns None when not found."""
|
|
@@ -113,3 +113,39 @@ order = ["public" # Invalid TOML
|
|
|
113
113
|
monkeypatch.chdir(tmp_path)
|
|
114
114
|
result = _find_pyproject_toml()
|
|
115
115
|
assert result == pyproject_path
|
|
116
|
+
|
|
117
|
+
def test_load_exclude_patterns(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
118
|
+
"""Test loading exclude patterns from pyproject.toml."""
|
|
119
|
+
pyproject_content = """
|
|
120
|
+
[tool.undersort]
|
|
121
|
+
order = ["public", "protected", "private"]
|
|
122
|
+
exclude = ["tests/*", "migrations/*.py"]
|
|
123
|
+
"""
|
|
124
|
+
pyproject_path = tmp_path / "pyproject.toml"
|
|
125
|
+
pyproject_path.write_text(pyproject_content)
|
|
126
|
+
|
|
127
|
+
monkeypatch.chdir(tmp_path)
|
|
128
|
+
config = load_config()
|
|
129
|
+
assert config == {
|
|
130
|
+
"order": ["public", "protected", "private"],
|
|
131
|
+
"method_type_order": None,
|
|
132
|
+
"exclude": ["tests/*", "migrations/*.py"],
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
def test_invalid_exclude_type(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
136
|
+
"""Test that invalid exclude type falls back to None."""
|
|
137
|
+
pyproject_content = """
|
|
138
|
+
[tool.undersort]
|
|
139
|
+
order = ["public", "protected", "private"]
|
|
140
|
+
exclude = "invalid"
|
|
141
|
+
"""
|
|
142
|
+
pyproject_path = tmp_path / "pyproject.toml"
|
|
143
|
+
pyproject_path.write_text(pyproject_content)
|
|
144
|
+
|
|
145
|
+
monkeypatch.chdir(tmp_path)
|
|
146
|
+
config = load_config()
|
|
147
|
+
assert config == {
|
|
148
|
+
"order": ["public", "protected", "private"],
|
|
149
|
+
"method_type_order": None,
|
|
150
|
+
"exclude": None,
|
|
151
|
+
}
|
|
@@ -107,3 +107,75 @@ class TestCollectPythonFiles:
|
|
|
107
107
|
assert result[0].name == "a.py"
|
|
108
108
|
assert result[1].name == "b.py"
|
|
109
109
|
assert result[2].name == "c.py"
|
|
110
|
+
|
|
111
|
+
def test_exclude_patterns_simple(self, tmp_path: Path) -> None:
|
|
112
|
+
"""Test excluding files by simple pattern."""
|
|
113
|
+
(tmp_path / "main.py").write_text("# main")
|
|
114
|
+
(tmp_path / "test.py").write_text("# test")
|
|
115
|
+
(tmp_path / "setup.py").write_text("# setup")
|
|
116
|
+
|
|
117
|
+
result = collect_python_files(tmp_path, recursive=False, exclude_patterns=["test.py"])
|
|
118
|
+
|
|
119
|
+
assert len(result) == 2
|
|
120
|
+
assert any(f.name == "main.py" for f in result)
|
|
121
|
+
assert any(f.name == "setup.py" for f in result)
|
|
122
|
+
assert not any(f.name == "test.py" for f in result)
|
|
123
|
+
|
|
124
|
+
def test_exclude_patterns_wildcard(self, tmp_path: Path) -> None:
|
|
125
|
+
"""Test excluding files by wildcard pattern."""
|
|
126
|
+
(tmp_path / "main.py").write_text("# main")
|
|
127
|
+
(tmp_path / "test_one.py").write_text("# test")
|
|
128
|
+
(tmp_path / "test_two.py").write_text("# test")
|
|
129
|
+
|
|
130
|
+
result = collect_python_files(tmp_path, recursive=False, exclude_patterns=["test_*.py"])
|
|
131
|
+
|
|
132
|
+
assert len(result) == 1
|
|
133
|
+
assert result[0].name == "main.py"
|
|
134
|
+
|
|
135
|
+
def test_exclude_patterns_directory(self, tmp_path: Path) -> None:
|
|
136
|
+
"""Test excluding entire directories."""
|
|
137
|
+
(tmp_path / "main.py").write_text("# main")
|
|
138
|
+
(tmp_path / "tests").mkdir()
|
|
139
|
+
(tmp_path / "tests" / "test_one.py").write_text("# test")
|
|
140
|
+
(tmp_path / "tests" / "test_two.py").write_text("# test")
|
|
141
|
+
(tmp_path / "src").mkdir()
|
|
142
|
+
(tmp_path / "src" / "app.py").write_text("# app")
|
|
143
|
+
|
|
144
|
+
result = collect_python_files(tmp_path, recursive=True, exclude_patterns=["tests/*"])
|
|
145
|
+
|
|
146
|
+
assert len(result) == 2
|
|
147
|
+
assert any(f.name == "main.py" for f in result)
|
|
148
|
+
assert any(f.name == "app.py" for f in result)
|
|
149
|
+
assert not any("test" in f.name for f in result)
|
|
150
|
+
|
|
151
|
+
def test_exclude_patterns_multiple(self, tmp_path: Path) -> None:
|
|
152
|
+
"""Test excluding with multiple patterns."""
|
|
153
|
+
(tmp_path / "main.py").write_text("# main")
|
|
154
|
+
(tmp_path / "test.py").write_text("# test")
|
|
155
|
+
(tmp_path / "migrations").mkdir()
|
|
156
|
+
(tmp_path / "migrations" / "001.py").write_text("# migration")
|
|
157
|
+
(tmp_path / "src").mkdir()
|
|
158
|
+
(tmp_path / "src" / "app.py").write_text("# app")
|
|
159
|
+
|
|
160
|
+
result = collect_python_files(tmp_path, recursive=True, exclude_patterns=["test.py", "migrations/*"])
|
|
161
|
+
|
|
162
|
+
assert len(result) == 2
|
|
163
|
+
assert any(f.name == "main.py" for f in result)
|
|
164
|
+
assert any(f.name == "app.py" for f in result)
|
|
165
|
+
assert not any(f.name == "test.py" for f in result)
|
|
166
|
+
assert not any("001.py" in str(f) for f in result)
|
|
167
|
+
|
|
168
|
+
def test_exclude_patterns_nested_directories(self, tmp_path: Path) -> None:
|
|
169
|
+
"""Test excluding nested directories."""
|
|
170
|
+
(tmp_path / "main.py").write_text("# main")
|
|
171
|
+
(tmp_path / "tests").mkdir()
|
|
172
|
+
(tmp_path / "tests" / "unit").mkdir()
|
|
173
|
+
(tmp_path / "tests" / "unit" / "test_one.py").write_text("# test")
|
|
174
|
+
(tmp_path / "src").mkdir()
|
|
175
|
+
(tmp_path / "src" / "app.py").write_text("# app")
|
|
176
|
+
|
|
177
|
+
result = collect_python_files(tmp_path, recursive=True, exclude_patterns=["tests/*"])
|
|
178
|
+
|
|
179
|
+
assert len(result) == 2
|
|
180
|
+
assert any(f.name == "main.py" for f in result)
|
|
181
|
+
assert any(f.name == "app.py" for f in result)
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Tests for nosort comment directives."""
|
|
2
|
+
|
|
3
|
+
import tempfile
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from undersort.sorter import sort_file
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestNosortDirectives:
|
|
10
|
+
"""Tests for # nosort comment functionality."""
|
|
11
|
+
|
|
12
|
+
def test_file_level_nosort(self) -> None:
|
|
13
|
+
"""Test that file-level nosort prevents all sorting."""
|
|
14
|
+
source = """# nosort: file
|
|
15
|
+
class Example:
|
|
16
|
+
def _protected(self):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
def public(self):
|
|
20
|
+
pass
|
|
21
|
+
"""
|
|
22
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
|
|
23
|
+
f.write(source)
|
|
24
|
+
temp_path = Path(f.name)
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
order = ["public", "protected", "private"]
|
|
28
|
+
was_modified = sort_file(temp_path, order)
|
|
29
|
+
|
|
30
|
+
assert was_modified is False
|
|
31
|
+
|
|
32
|
+
with open(temp_path) as f:
|
|
33
|
+
result = f.read()
|
|
34
|
+
|
|
35
|
+
assert result == source
|
|
36
|
+
finally:
|
|
37
|
+
temp_path.unlink()
|
|
38
|
+
|
|
39
|
+
def test_class_level_nosort(self) -> None:
|
|
40
|
+
"""Test that class-level nosort prevents sorting that class."""
|
|
41
|
+
source = """class Example: # nosort
|
|
42
|
+
def _protected(self):
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
def public(self):
|
|
46
|
+
pass
|
|
47
|
+
"""
|
|
48
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
|
|
49
|
+
f.write(source)
|
|
50
|
+
temp_path = Path(f.name)
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
order = ["public", "protected", "private"]
|
|
54
|
+
was_modified = sort_file(temp_path, order)
|
|
55
|
+
|
|
56
|
+
assert was_modified is False
|
|
57
|
+
|
|
58
|
+
with open(temp_path) as f:
|
|
59
|
+
result = f.read()
|
|
60
|
+
|
|
61
|
+
assert result == source
|
|
62
|
+
finally:
|
|
63
|
+
temp_path.unlink()
|
|
64
|
+
|
|
65
|
+
def test_method_level_nosort(self) -> None:
|
|
66
|
+
"""Test that method-level nosort keeps that method in place."""
|
|
67
|
+
source = """class Example:
|
|
68
|
+
def public_a(self):
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
def _protected_x(self): # nosort
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
def public_b(self):
|
|
75
|
+
pass
|
|
76
|
+
"""
|
|
77
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
|
|
78
|
+
f.write(source)
|
|
79
|
+
temp_path = Path(f.name)
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
order = ["public", "protected", "private"]
|
|
83
|
+
was_modified = sort_file(temp_path, order)
|
|
84
|
+
|
|
85
|
+
assert was_modified is False
|
|
86
|
+
|
|
87
|
+
with open(temp_path) as f:
|
|
88
|
+
result = f.read()
|
|
89
|
+
|
|
90
|
+
protected_idx = result.find("def _protected_x")
|
|
91
|
+
public_a_idx = result.find("def public_a")
|
|
92
|
+
public_b_idx = result.find("def public_b")
|
|
93
|
+
|
|
94
|
+
assert public_a_idx < protected_idx < public_b_idx
|
|
95
|
+
finally:
|
|
96
|
+
temp_path.unlink()
|
|
97
|
+
|
|
98
|
+
def test_multiple_nosort_methods(self) -> None:
|
|
99
|
+
"""Test multiple methods with nosort."""
|
|
100
|
+
source = """class Example:
|
|
101
|
+
def public_a(self):
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
def _protected_x(self): # nosort
|
|
105
|
+
pass
|
|
106
|
+
|
|
107
|
+
def public_b(self): # nosort
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
def _protected_y(self):
|
|
111
|
+
pass
|
|
112
|
+
"""
|
|
113
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
|
|
114
|
+
f.write(source)
|
|
115
|
+
temp_path = Path(f.name)
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
order = ["public", "protected", "private"]
|
|
119
|
+
was_modified = sort_file(temp_path, order)
|
|
120
|
+
|
|
121
|
+
assert was_modified is False
|
|
122
|
+
|
|
123
|
+
with open(temp_path) as f:
|
|
124
|
+
result = f.read()
|
|
125
|
+
|
|
126
|
+
assert result == source
|
|
127
|
+
finally:
|
|
128
|
+
temp_path.unlink()
|
|
129
|
+
|
|
130
|
+
def test_nosort_case_insensitive(self) -> None:
|
|
131
|
+
"""Test that NOSORT and NoSort also work."""
|
|
132
|
+
source = """class Example: # NOSORT
|
|
133
|
+
def _protected(self):
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
def public(self):
|
|
137
|
+
pass
|
|
138
|
+
"""
|
|
139
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
|
|
140
|
+
f.write(source)
|
|
141
|
+
temp_path = Path(f.name)
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
order = ["public", "protected", "private"]
|
|
145
|
+
was_modified = sort_file(temp_path, order)
|
|
146
|
+
|
|
147
|
+
assert was_modified is False
|
|
148
|
+
finally:
|
|
149
|
+
temp_path.unlink()
|
|
150
|
+
|
|
151
|
+
def test_nosort_with_other_classes(self) -> None:
|
|
152
|
+
"""Test that nosort on one class doesn't affect others."""
|
|
153
|
+
source = """class First: # nosort
|
|
154
|
+
def _protected(self):
|
|
155
|
+
pass
|
|
156
|
+
|
|
157
|
+
def public(self):
|
|
158
|
+
pass
|
|
159
|
+
|
|
160
|
+
class Second:
|
|
161
|
+
def _protected(self):
|
|
162
|
+
pass
|
|
163
|
+
|
|
164
|
+
def public(self):
|
|
165
|
+
pass
|
|
166
|
+
"""
|
|
167
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
|
|
168
|
+
f.write(source)
|
|
169
|
+
temp_path = Path(f.name)
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
order = ["public", "protected", "private"]
|
|
173
|
+
was_modified = sort_file(temp_path, order)
|
|
174
|
+
|
|
175
|
+
assert was_modified is True
|
|
176
|
+
|
|
177
|
+
with open(temp_path) as f:
|
|
178
|
+
result = f.read()
|
|
179
|
+
|
|
180
|
+
first_protected_idx = result.find("class First")
|
|
181
|
+
first_protected_method_idx = result.find("def _protected", first_protected_idx)
|
|
182
|
+
first_public_idx = result.find("def public", first_protected_idx)
|
|
183
|
+
second_class_idx = result.find("class Second")
|
|
184
|
+
second_public_idx = result.find("def public", second_class_idx)
|
|
185
|
+
second_protected_idx = result.find("def _protected", second_class_idx)
|
|
186
|
+
|
|
187
|
+
assert first_protected_method_idx < first_public_idx
|
|
188
|
+
assert second_public_idx < second_protected_idx
|
|
189
|
+
finally:
|
|
190
|
+
temp_path.unlink()
|
|
@@ -10,11 +10,12 @@ def load_config() -> dict[str, list[str] | None]:
|
|
|
10
10
|
"""Load configuration from pyproject.toml.
|
|
11
11
|
|
|
12
12
|
Returns:
|
|
13
|
-
Dictionary with 'order' and '
|
|
13
|
+
Dictionary with 'order', 'method_type_order', and 'exclude' keys.
|
|
14
14
|
"""
|
|
15
15
|
default_config: dict[str, list[str] | None] = {
|
|
16
16
|
"order": ["public", "protected", "private"],
|
|
17
17
|
"method_type_order": None,
|
|
18
|
+
"exclude": None,
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
pyproject_path = _find_pyproject_toml()
|
|
@@ -50,6 +51,13 @@ def load_config() -> dict[str, list[str] | None]:
|
|
|
50
51
|
else:
|
|
51
52
|
result["method_type_order"] = method_type_order
|
|
52
53
|
|
|
54
|
+
if "exclude" in config:
|
|
55
|
+
exclude = config["exclude"]
|
|
56
|
+
if isinstance(exclude, list):
|
|
57
|
+
result["exclude"] = exclude
|
|
58
|
+
else:
|
|
59
|
+
logger.warning(f"Invalid exclude value in {pyproject_path}. Must be a list.")
|
|
60
|
+
|
|
53
61
|
return result
|
|
54
62
|
|
|
55
63
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Main entry point for undersort CLI."""
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
|
+
import fnmatch
|
|
4
5
|
import sys
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
|
|
@@ -9,12 +10,13 @@ from undersort.config import load_config
|
|
|
9
10
|
from undersort.sorter import sort_file
|
|
10
11
|
|
|
11
12
|
|
|
12
|
-
def collect_python_files(path: Path, recursive: bool = True) -> list[Path]:
|
|
13
|
+
def collect_python_files(path: Path, recursive: bool = True, exclude_patterns: list[str] | None = None) -> list[Path]:
|
|
13
14
|
"""Collect Python files from a path (file or directory).
|
|
14
15
|
|
|
15
16
|
Args:
|
|
16
17
|
path: File or directory path
|
|
17
18
|
recursive: If True, recursively search directories
|
|
19
|
+
exclude_patterns: List of glob patterns to exclude (e.g., ["tests/*", "migrations/*.py"])
|
|
18
20
|
|
|
19
21
|
Returns:
|
|
20
22
|
List of Python file paths
|
|
@@ -37,12 +39,47 @@ def collect_python_files(path: Path, recursive: bool = True) -> list[Path]:
|
|
|
37
39
|
if not any(part in exclude_dirs or (part.startswith(".") and part != ".") for part in f.parts)
|
|
38
40
|
]
|
|
39
41
|
|
|
42
|
+
if exclude_patterns:
|
|
43
|
+
filtered_files = [f for f in filtered_files if not _matches_any_pattern(f, exclude_patterns)]
|
|
44
|
+
|
|
40
45
|
return sorted(filtered_files)
|
|
41
46
|
|
|
42
47
|
return []
|
|
43
48
|
|
|
44
49
|
|
|
45
|
-
def
|
|
50
|
+
def _matches_any_pattern(file_path: Path, patterns: list[str]) -> bool:
|
|
51
|
+
"""Check if a file matches any of the exclusion patterns.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
file_path: Path to check
|
|
55
|
+
patterns: List of glob patterns
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
True if file matches any pattern, False otherwise
|
|
59
|
+
"""
|
|
60
|
+
path_str = str(file_path)
|
|
61
|
+
|
|
62
|
+
for pattern in patterns:
|
|
63
|
+
if fnmatch.fnmatch(path_str, pattern):
|
|
64
|
+
return True
|
|
65
|
+
|
|
66
|
+
if "/" not in pattern:
|
|
67
|
+
if fnmatch.fnmatch(file_path.name, pattern):
|
|
68
|
+
return True
|
|
69
|
+
continue
|
|
70
|
+
|
|
71
|
+
if fnmatch.fnmatch(path_str, f"*/{pattern}"):
|
|
72
|
+
return True
|
|
73
|
+
|
|
74
|
+
for part_idx in range(len(file_path.parts)):
|
|
75
|
+
subpath = str(Path(*file_path.parts[part_idx:]))
|
|
76
|
+
if fnmatch.fnmatch(subpath, pattern):
|
|
77
|
+
return True
|
|
78
|
+
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def main() -> int: # noqa: PLR0912
|
|
46
83
|
"""Main entry point for undersort."""
|
|
47
84
|
parser = argparse.ArgumentParser(description="Sort class methods by visibility (public, protected, private)")
|
|
48
85
|
parser.add_argument(
|
|
@@ -68,18 +105,30 @@ def main() -> int:
|
|
|
68
105
|
default=True,
|
|
69
106
|
help="Don't recursively search directories",
|
|
70
107
|
)
|
|
108
|
+
parser.add_argument(
|
|
109
|
+
"--exclude",
|
|
110
|
+
action="append",
|
|
111
|
+
help="Exclude files/directories matching pattern (can be used multiple times)",
|
|
112
|
+
)
|
|
71
113
|
|
|
72
114
|
args = parser.parse_args()
|
|
73
115
|
|
|
74
116
|
config = load_config()
|
|
75
117
|
|
|
118
|
+
exclude_patterns: list[str] = []
|
|
119
|
+
config_exclude = config.get("exclude")
|
|
120
|
+
if config_exclude:
|
|
121
|
+
exclude_patterns.extend(config_exclude)
|
|
122
|
+
if args.exclude:
|
|
123
|
+
exclude_patterns.extend(args.exclude)
|
|
124
|
+
|
|
76
125
|
all_files: list[Path] = []
|
|
77
126
|
for path in args.paths:
|
|
78
127
|
if not path.exists():
|
|
79
128
|
logger.error(f"Path not found: {path}")
|
|
80
129
|
continue
|
|
81
130
|
|
|
82
|
-
python_files = collect_python_files(path, args.recursive)
|
|
131
|
+
python_files = collect_python_files(path, args.recursive, exclude_patterns or None)
|
|
83
132
|
all_files.extend(python_files)
|
|
84
133
|
|
|
85
134
|
if not all_files:
|
|
@@ -9,6 +9,46 @@ import libcst as cst
|
|
|
9
9
|
from undersort import logger
|
|
10
10
|
|
|
11
11
|
|
|
12
|
+
def has_nosort_comment(node: cst.FunctionDef | cst.ClassDef) -> bool:
|
|
13
|
+
"""Check if a node has a nosort comment.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
node: The node to check
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
True if the node has a # nosort comment
|
|
20
|
+
"""
|
|
21
|
+
if hasattr(node, "leading_lines"):
|
|
22
|
+
for line in node.leading_lines:
|
|
23
|
+
if isinstance(line, cst.EmptyLine) and line.comment and "nosort" in line.comment.value.lower():
|
|
24
|
+
return True
|
|
25
|
+
|
|
26
|
+
return bool(
|
|
27
|
+
hasattr(node, "body")
|
|
28
|
+
and hasattr(node.body, "header")
|
|
29
|
+
and isinstance(node.body.header, cst.TrailingWhitespace)
|
|
30
|
+
and node.body.header.comment
|
|
31
|
+
and "nosort" in node.body.header.comment.value.lower()
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def file_has_nosort(module: cst.Module) -> bool:
|
|
36
|
+
"""Check if a file has a file-level nosort directive.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
module: The module to check
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
True if the file has a # nosort: file comment
|
|
43
|
+
"""
|
|
44
|
+
for line in module.header:
|
|
45
|
+
if isinstance(line, cst.EmptyLine) and line.comment:
|
|
46
|
+
comment_text = line.comment.value.lower()
|
|
47
|
+
if "nosort" in comment_text and "file" in comment_text:
|
|
48
|
+
return True
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
|
|
12
52
|
def get_method_visibility(method_name: str) -> Literal["public", "private", "protected"]:
|
|
13
53
|
"""Determine method visibility based on naming convention.
|
|
14
54
|
|
|
@@ -71,7 +111,9 @@ class MethodSorter(cst.CSTTransformer):
|
|
|
71
111
|
self.modified = False
|
|
72
112
|
|
|
73
113
|
def leave_ClassDef( # noqa: PLR0912, PLR0915
|
|
74
|
-
self,
|
|
114
|
+
self,
|
|
115
|
+
original_node: cst.ClassDef,
|
|
116
|
+
updated_node: cst.ClassDef, # noqa: ARG002
|
|
75
117
|
) -> cst.ClassDef:
|
|
76
118
|
"""Sort methods within a class definition.
|
|
77
119
|
|
|
@@ -82,6 +124,9 @@ class MethodSorter(cst.CSTTransformer):
|
|
|
82
124
|
Returns:
|
|
83
125
|
ClassDef with sorted methods
|
|
84
126
|
"""
|
|
127
|
+
if has_nosort_comment(updated_node):
|
|
128
|
+
return updated_node
|
|
129
|
+
|
|
85
130
|
methods = []
|
|
86
131
|
non_methods = []
|
|
87
132
|
|
|
@@ -94,7 +139,17 @@ class MethodSorter(cst.CSTTransformer):
|
|
|
94
139
|
if not methods:
|
|
95
140
|
return updated_node
|
|
96
141
|
|
|
97
|
-
|
|
142
|
+
sortable_methods = []
|
|
143
|
+
nosort_methods = []
|
|
144
|
+
|
|
145
|
+
for i, method in enumerate(methods):
|
|
146
|
+
if has_nosort_comment(method):
|
|
147
|
+
nosort_methods.append((i, method))
|
|
148
|
+
else:
|
|
149
|
+
sortable_methods.append((i, method))
|
|
150
|
+
|
|
151
|
+
if not sortable_methods:
|
|
152
|
+
return updated_node
|
|
98
153
|
|
|
99
154
|
method_groups: dict[str, dict[str, list[tuple[int, cst.FunctionDef]]]] = {
|
|
100
155
|
"public": {"class": [], "static": [], "instance": []},
|
|
@@ -102,7 +157,7 @@ class MethodSorter(cst.CSTTransformer):
|
|
|
102
157
|
"private": {"class": [], "static": [], "instance": []},
|
|
103
158
|
}
|
|
104
159
|
|
|
105
|
-
for idx, method in
|
|
160
|
+
for idx, method in sortable_methods:
|
|
106
161
|
visibility = get_method_visibility(method.name.value)
|
|
107
162
|
method_type = get_method_type(method)
|
|
108
163
|
method_groups[visibility][method_type].append((idx, method))
|
|
@@ -145,9 +200,15 @@ class MethodSorter(cst.CSTTransformer):
|
|
|
145
200
|
|
|
146
201
|
current_position += len(group)
|
|
147
202
|
|
|
148
|
-
|
|
203
|
+
all_sorted = sorted_methods[:]
|
|
204
|
+
for orig_idx, nosort_method in sorted(nosort_methods, key=lambda x: x[0]):
|
|
205
|
+
all_sorted.insert(orig_idx, nosort_method)
|
|
206
|
+
|
|
207
|
+
if methods != all_sorted:
|
|
149
208
|
self.modified = True
|
|
150
209
|
|
|
210
|
+
sorted_methods = all_sorted
|
|
211
|
+
|
|
151
212
|
leading_non_methods = []
|
|
152
213
|
trailing_non_methods = []
|
|
153
214
|
found_method = False
|
|
@@ -193,6 +254,9 @@ def sort_file(
|
|
|
193
254
|
except cst.ParserSyntaxError as e:
|
|
194
255
|
raise ValueError(f"Syntax error in {file_path}: {e}")
|
|
195
256
|
|
|
257
|
+
if file_has_nosort(tree):
|
|
258
|
+
return False
|
|
259
|
+
|
|
196
260
|
sorter = MethodSorter(order, method_type_order)
|
|
197
261
|
new_tree = tree.visit(sorter)
|
|
198
262
|
|
|
@@ -905,7 +905,7 @@ wheels = [
|
|
|
905
905
|
|
|
906
906
|
[[package]]
|
|
907
907
|
name = "undersort"
|
|
908
|
-
version = "0.1.
|
|
908
|
+
version = "0.1.5"
|
|
909
909
|
source = { editable = "." }
|
|
910
910
|
dependencies = [
|
|
911
911
|
{ name = "libcst", version = "1.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|