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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: undersort
3
- Version: 0.1.3
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.3"
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()
@@ -1,3 +1,3 @@
1
1
  """undersort - Sort class methods by visibility."""
2
2
 
3
- __version__ = "0.1.3"
3
+ __version__ = "0.1.4"
@@ -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 'method_type_order' keys.
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 main() -> int:
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, original_node: cst.ClassDef, updated_node: cst.ClassDef
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
- method_with_index = [(i, method) for i, method in enumerate(methods)]
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 method_with_index:
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
- if methods != sorted_methods:
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.3"
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