undersort 0.1.4__tar.gz → 0.1.6__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.
Files changed (28) hide show
  1. {undersort-0.1.4 → undersort-0.1.6}/PKG-INFO +134 -2
  2. {undersort-0.1.4 → undersort-0.1.6}/README.md +133 -1
  3. {undersort-0.1.4 → undersort-0.1.6}/pyproject.toml +5 -1
  4. undersort-0.1.6/tests/test_config.py +271 -0
  5. {undersort-0.1.4 → undersort-0.1.6}/tests/test_main.py +72 -0
  6. undersort-0.1.6/tests/test_module_sorter.py +375 -0
  7. undersort-0.1.6/undersort/config.py +144 -0
  8. undersort-0.1.6/undersort/deps.py +253 -0
  9. undersort-0.1.6/undersort/main.py +230 -0
  10. undersort-0.1.6/undersort/sorter.py +602 -0
  11. {undersort-0.1.4 → undersort-0.1.6}/uv.lock +1 -1
  12. undersort-0.1.4/tests/test_config.py +0 -115
  13. undersort-0.1.4/undersort/config.py +0 -67
  14. undersort-0.1.4/undersort/main.py +0 -128
  15. undersort-0.1.4/undersort/sorter.py +0 -281
  16. {undersort-0.1.4 → undersort-0.1.6}/.github/workflows/ci.yml +0 -0
  17. {undersort-0.1.4 → undersort-0.1.6}/.gitignore +0 -0
  18. {undersort-0.1.4 → undersort-0.1.6}/.pre-commit-config.yaml +0 -0
  19. {undersort-0.1.4 → undersort-0.1.6}/.pre-commit-hooks.yaml +0 -0
  20. {undersort-0.1.4 → undersort-0.1.6}/.python-version +0 -0
  21. {undersort-0.1.4 → undersort-0.1.6}/.vscode/settings.json +0 -0
  22. {undersort-0.1.4 → undersort-0.1.6}/LICENSE +0 -0
  23. {undersort-0.1.4 → undersort-0.1.6}/Makefile +0 -0
  24. {undersort-0.1.4 → undersort-0.1.6}/tests/__init__.py +0 -0
  25. {undersort-0.1.4 → undersort-0.1.6}/tests/test_nosort.py +0 -0
  26. {undersort-0.1.4 → undersort-0.1.6}/tests/test_sorter.py +0 -0
  27. {undersort-0.1.4 → undersort-0.1.6}/undersort/__init__.py +0 -0
  28. {undersort-0.1.4 → undersort-0.1.6}/undersort/logger.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: undersort
3
- Version: 0.1.4
3
+ Version: 0.1.6
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
@@ -33,6 +33,8 @@ A Python tool that automatically sorts class methods by visibility (public, prot
33
33
 
34
34
  - Automatically reorders class methods based on visibility and method type
35
35
  - Two-level sorting: primary by visibility, secondary by method type
36
+ - Optional module-level mode that sorts top-level functions and classes, with
37
+ dependency analysis so decorators, base classes, and annotations keep working
36
38
  - Fully configurable ordering via `pyproject.toml`
37
39
  - Pre-commit hook integration
38
40
  - Colored output for better readability
@@ -68,6 +70,21 @@ order = ["public", "protected", "private"]
68
70
  # Options: "class" (classmethod), "static" (staticmethod), "instance" (regular methods)
69
71
  # Default: ["instance", "class", "static"]
70
72
  method_type_order = ["instance", "class", "static"]
73
+
74
+ # Exclude files/directories matching these patterns (optional)
75
+ # Patterns support glob syntax (e.g., "tests/*", "migrations/*.py", "**/generated/*")
76
+ # exclude = ["tests/*", "migrations/*.py"]
77
+
78
+ # Also sort module-level functions and classes (optional, default: false)
79
+ # sort_module_level = true
80
+
81
+ # Allow decorated module-level definitions to move (optional, default: false)
82
+ # Leave off unless your decorators have no order-dependent import-time side effects
83
+ # sort_decorated = true
84
+
85
+ # Target Python version, used to decide whether annotations are evaluated eagerly
86
+ # (optional; inferred from [project] requires-python when not set)
87
+ # python_version = "3.12"
71
88
  ```
72
89
 
73
90
  ### Method Visibility Rules
@@ -107,6 +124,103 @@ Example order with default configuration:
107
124
  8. Private class methods
108
125
  9. Private static methods
109
126
 
127
+ ## Module-Level Sorting (optional)
128
+
129
+ By default undersort only reorders methods inside classes. Enable `sort_module_level`
130
+ to apply the same visibility ordering to top-level functions and classes:
131
+
132
+ ```toml
133
+ [tool.undersort]
134
+ sort_module_level = true
135
+ ```
136
+
137
+ Or from the command line: `undersort --sort-module-level src/` (use
138
+ `--no-sort-module-level` to override the config file for one run).
139
+
140
+ Classes and functions are ordered in a single stream by the same naming rules
141
+ (`public` → `protected` → `private`).
142
+
143
+ ### Safety Rules
144
+
145
+ Unlike methods in a class body, module-level definitions execute in order, so
146
+ reordering them can break a module. undersort only moves a definition when it is
147
+ provably safe:
148
+
149
+ **Non-definition statements are barriers.** Definitions are only reordered within
150
+ runs of consecutive `def`/`class` statements. An import, assignment, or `if` block
151
+ between them splits the file into independent runs, so nothing moves across it:
152
+
153
+ ```python
154
+ def _helper(): ...
155
+ def public_a(): ... # these two are sorted together
156
+
157
+ CONSTANT = compute() # barrier -- nothing crosses this line
158
+
159
+ def _other(): ...
160
+ def public_b(): ... # these two are sorted together
161
+ ```
162
+
163
+ **Definition-time references are respected.** A definition never moves above
164
+ something it needs when it is defined. That includes decorators, base classes and
165
+ `metaclass=` keywords, default argument values, and class bodies (which run
166
+ eagerly, though method bodies inside them do not):
167
+
168
+ ```python
169
+ class _Base: ...
170
+
171
+ class Public(_Base): # stays below _Base despite being public
172
+ ...
173
+ ```
174
+
175
+ References made inside a function or method body impose no constraint, since they
176
+ resolve when the function is called rather than when it is defined.
177
+
178
+ **Annotations are handled according to the effective evaluation semantics.**
179
+ Annotations are eagerly evaluated -- and therefore constrain ordering -- unless
180
+ either of the following applies, in which case annotated types are free to move:
181
+
182
+ - the module starts with `from __future__ import annotations` (PEP 563)
183
+ - the target Python version is 3.14 or newer, where annotations are lazy by
184
+ default (PEP 649)
185
+
186
+ The target version comes from `python_version`, or the project's
187
+ `requires-python` lower bound, or the running interpreter, in that order.
188
+
189
+ **Redefined names never move.** If a name is defined more than once at the top
190
+ level (`@overload` blocks, `@singledispatch` registrations, conditional
191
+ redefinitions), all of its definitions stay put.
192
+
193
+ **Decorated definitions are pinned by default.** A decorator runs at import time
194
+ and can have side effects whose *order* matters — `@app.route`, `@cli.command`,
195
+ `@register` and friends append to a registry as the module loads. No static
196
+ analysis can tell a registering decorator from a pure one, so decorated
197
+ definitions keep their position unless you opt in:
198
+
199
+ ```toml
200
+ [tool.undersort]
201
+ sort_decorated = true # only if you know your decorators are order-independent
202
+ ```
203
+
204
+ **`# nosort` still applies**, both file-level and on individual definitions.
205
+ Note that at module level a pinned definition acts as a hard anchor: nothing is
206
+ reordered across it. This is stricter than the class-method behaviour, because
207
+ top-level statements execute in order.
208
+
209
+ ### Known Limitation
210
+
211
+ undersort guarantees that reordering never breaks *name resolution* — nothing
212
+ moves above a name it needs at definition time. It cannot fully guarantee
213
+ *side-effect order*. Pinning decorated definitions covers the common registry
214
+ pattern, but two cases remain outside static reach:
215
+
216
+ - classes whose creation has side effects through a base class defined in another
217
+ module (`__init_subclass__` hooks, registering metaclasses)
218
+ - any definition whose mere position is load-bearing for reasons not visible in
219
+ the file
220
+
221
+ If your module has import-time ordering semantics like these, mark the affected
222
+ definitions with `# nosort`, or leave `sort_module_level` off for that file.
223
+
110
224
  ### Skipping Sorting with `# nosort`
111
225
 
112
226
  You can prevent sorting at different levels using `# nosort` comments (case-insensitive):
@@ -185,9 +299,27 @@ undersort --diff example.py
185
299
 
186
300
  # Combine flags
187
301
  undersort --check --diff src/
302
+
303
+ # Exclude specific files or directories
304
+ undersort --exclude "tests/*" --exclude "migrations/*.py" src/
305
+
306
+ # Multiple exclude patterns (can be combined with config file patterns)
307
+ undersort --exclude "test_*.py" --exclude "*/legacy/*" .
308
+
309
+ # Also sort module-level functions and classes
310
+ undersort --sort-module-level src/
311
+
312
+ # Override the config file for a single run
313
+ undersort --no-sort-module-level src/
314
+
315
+ # Tell undersort which Python version to assume for annotation semantics
316
+ undersort --sort-module-level --python-version 3.14 src/
317
+
318
+ # Also reorder decorated definitions (off by default, see Known Limitation)
319
+ undersort --sort-module-level --sort-decorated src/
188
320
  ```
189
321
 
190
- **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.
322
+ **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.
191
323
 
192
324
  ### Pre-commit Integration
193
325
 
@@ -6,6 +6,8 @@ A Python tool that automatically sorts class methods by visibility (public, prot
6
6
 
7
7
  - Automatically reorders class methods based on visibility and method type
8
8
  - Two-level sorting: primary by visibility, secondary by method type
9
+ - Optional module-level mode that sorts top-level functions and classes, with
10
+ dependency analysis so decorators, base classes, and annotations keep working
9
11
  - Fully configurable ordering via `pyproject.toml`
10
12
  - Pre-commit hook integration
11
13
  - Colored output for better readability
@@ -41,6 +43,21 @@ order = ["public", "protected", "private"]
41
43
  # Options: "class" (classmethod), "static" (staticmethod), "instance" (regular methods)
42
44
  # Default: ["instance", "class", "static"]
43
45
  method_type_order = ["instance", "class", "static"]
46
+
47
+ # Exclude files/directories matching these patterns (optional)
48
+ # Patterns support glob syntax (e.g., "tests/*", "migrations/*.py", "**/generated/*")
49
+ # exclude = ["tests/*", "migrations/*.py"]
50
+
51
+ # Also sort module-level functions and classes (optional, default: false)
52
+ # sort_module_level = true
53
+
54
+ # Allow decorated module-level definitions to move (optional, default: false)
55
+ # Leave off unless your decorators have no order-dependent import-time side effects
56
+ # sort_decorated = true
57
+
58
+ # Target Python version, used to decide whether annotations are evaluated eagerly
59
+ # (optional; inferred from [project] requires-python when not set)
60
+ # python_version = "3.12"
44
61
  ```
45
62
 
46
63
  ### Method Visibility Rules
@@ -80,6 +97,103 @@ Example order with default configuration:
80
97
  8. Private class methods
81
98
  9. Private static methods
82
99
 
100
+ ## Module-Level Sorting (optional)
101
+
102
+ By default undersort only reorders methods inside classes. Enable `sort_module_level`
103
+ to apply the same visibility ordering to top-level functions and classes:
104
+
105
+ ```toml
106
+ [tool.undersort]
107
+ sort_module_level = true
108
+ ```
109
+
110
+ Or from the command line: `undersort --sort-module-level src/` (use
111
+ `--no-sort-module-level` to override the config file for one run).
112
+
113
+ Classes and functions are ordered in a single stream by the same naming rules
114
+ (`public` → `protected` → `private`).
115
+
116
+ ### Safety Rules
117
+
118
+ Unlike methods in a class body, module-level definitions execute in order, so
119
+ reordering them can break a module. undersort only moves a definition when it is
120
+ provably safe:
121
+
122
+ **Non-definition statements are barriers.** Definitions are only reordered within
123
+ runs of consecutive `def`/`class` statements. An import, assignment, or `if` block
124
+ between them splits the file into independent runs, so nothing moves across it:
125
+
126
+ ```python
127
+ def _helper(): ...
128
+ def public_a(): ... # these two are sorted together
129
+
130
+ CONSTANT = compute() # barrier -- nothing crosses this line
131
+
132
+ def _other(): ...
133
+ def public_b(): ... # these two are sorted together
134
+ ```
135
+
136
+ **Definition-time references are respected.** A definition never moves above
137
+ something it needs when it is defined. That includes decorators, base classes and
138
+ `metaclass=` keywords, default argument values, and class bodies (which run
139
+ eagerly, though method bodies inside them do not):
140
+
141
+ ```python
142
+ class _Base: ...
143
+
144
+ class Public(_Base): # stays below _Base despite being public
145
+ ...
146
+ ```
147
+
148
+ References made inside a function or method body impose no constraint, since they
149
+ resolve when the function is called rather than when it is defined.
150
+
151
+ **Annotations are handled according to the effective evaluation semantics.**
152
+ Annotations are eagerly evaluated -- and therefore constrain ordering -- unless
153
+ either of the following applies, in which case annotated types are free to move:
154
+
155
+ - the module starts with `from __future__ import annotations` (PEP 563)
156
+ - the target Python version is 3.14 or newer, where annotations are lazy by
157
+ default (PEP 649)
158
+
159
+ The target version comes from `python_version`, or the project's
160
+ `requires-python` lower bound, or the running interpreter, in that order.
161
+
162
+ **Redefined names never move.** If a name is defined more than once at the top
163
+ level (`@overload` blocks, `@singledispatch` registrations, conditional
164
+ redefinitions), all of its definitions stay put.
165
+
166
+ **Decorated definitions are pinned by default.** A decorator runs at import time
167
+ and can have side effects whose *order* matters — `@app.route`, `@cli.command`,
168
+ `@register` and friends append to a registry as the module loads. No static
169
+ analysis can tell a registering decorator from a pure one, so decorated
170
+ definitions keep their position unless you opt in:
171
+
172
+ ```toml
173
+ [tool.undersort]
174
+ sort_decorated = true # only if you know your decorators are order-independent
175
+ ```
176
+
177
+ **`# nosort` still applies**, both file-level and on individual definitions.
178
+ Note that at module level a pinned definition acts as a hard anchor: nothing is
179
+ reordered across it. This is stricter than the class-method behaviour, because
180
+ top-level statements execute in order.
181
+
182
+ ### Known Limitation
183
+
184
+ undersort guarantees that reordering never breaks *name resolution* — nothing
185
+ moves above a name it needs at definition time. It cannot fully guarantee
186
+ *side-effect order*. Pinning decorated definitions covers the common registry
187
+ pattern, but two cases remain outside static reach:
188
+
189
+ - classes whose creation has side effects through a base class defined in another
190
+ module (`__init_subclass__` hooks, registering metaclasses)
191
+ - any definition whose mere position is load-bearing for reasons not visible in
192
+ the file
193
+
194
+ If your module has import-time ordering semantics like these, mark the affected
195
+ definitions with `# nosort`, or leave `sort_module_level` off for that file.
196
+
83
197
  ### Skipping Sorting with `# nosort`
84
198
 
85
199
  You can prevent sorting at different levels using `# nosort` comments (case-insensitive):
@@ -158,9 +272,27 @@ undersort --diff example.py
158
272
 
159
273
  # Combine flags
160
274
  undersort --check --diff src/
275
+
276
+ # Exclude specific files or directories
277
+ undersort --exclude "tests/*" --exclude "migrations/*.py" src/
278
+
279
+ # Multiple exclude patterns (can be combined with config file patterns)
280
+ undersort --exclude "test_*.py" --exclude "*/legacy/*" .
281
+
282
+ # Also sort module-level functions and classes
283
+ undersort --sort-module-level src/
284
+
285
+ # Override the config file for a single run
286
+ undersort --no-sort-module-level src/
287
+
288
+ # Tell undersort which Python version to assume for annotation semantics
289
+ undersort --sort-module-level --python-version 3.14 src/
290
+
291
+ # Also reorder decorated definitions (off by default, see Known Limitation)
292
+ undersort --sort-module-level --sort-decorated src/
161
293
  ```
162
294
 
163
- **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.
295
+ **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.
164
296
 
165
297
  ### Pre-commit Integration
166
298
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "undersort"
7
- version = "0.1.4"
7
+ version = "0.1.6"
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"]
@@ -0,0 +1,271 @@
1
+ """Tests for configuration loading."""
2
+
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+ from undersort.config import _find_pyproject_toml, load_config
8
+
9
+
10
+ class TestConfigLoading:
11
+ """Tests for configuration loading from pyproject.toml."""
12
+
13
+ def test_default_config(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
14
+ """Test that default config is returned when no pyproject.toml exists."""
15
+ monkeypatch.chdir(tmp_path)
16
+ config = load_config()
17
+ assert config == {
18
+ "order": ["public", "protected", "private"],
19
+ "method_type_order": None,
20
+ "exclude": None,
21
+ "sort_module_level": False,
22
+ "sort_decorated": False,
23
+ "python_version": None,
24
+ }
25
+
26
+ def test_load_custom_order(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
27
+ """Test loading custom order from pyproject.toml."""
28
+ pyproject_content = """
29
+ [tool.undersort]
30
+ order = ["private", "protected", "public"]
31
+ """
32
+ pyproject_path = tmp_path / "pyproject.toml"
33
+ pyproject_path.write_text(pyproject_content)
34
+
35
+ monkeypatch.chdir(tmp_path)
36
+ config = load_config()
37
+ assert config == {
38
+ "order": ["private", "protected", "public"],
39
+ "method_type_order": None,
40
+ "exclude": None,
41
+ "sort_module_level": False,
42
+ "sort_decorated": False,
43
+ "python_version": None,
44
+ }
45
+
46
+ def test_invalid_order_values(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
47
+ """Test that invalid order values fall back to default."""
48
+ pyproject_content = """
49
+ [tool.undersort]
50
+ order = ["public", "invalid", "private"]
51
+ """
52
+ pyproject_path = tmp_path / "pyproject.toml"
53
+ pyproject_path.write_text(pyproject_content)
54
+
55
+ monkeypatch.chdir(tmp_path)
56
+ config = load_config()
57
+ assert config == {
58
+ "order": ["public", "protected", "private"],
59
+ "method_type_order": None,
60
+ "exclude": None,
61
+ "sort_module_level": False,
62
+ "sort_decorated": False,
63
+ "python_version": None,
64
+ }
65
+
66
+ def test_missing_order_key(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
67
+ """Test that missing order key returns default."""
68
+ pyproject_content = """
69
+ [tool.undersort]
70
+ some_other_key = "value"
71
+ """
72
+ pyproject_path = tmp_path / "pyproject.toml"
73
+ pyproject_path.write_text(pyproject_content)
74
+
75
+ monkeypatch.chdir(tmp_path)
76
+ config = load_config()
77
+ assert config == {
78
+ "order": ["public", "protected", "private"],
79
+ "method_type_order": None,
80
+ "exclude": None,
81
+ "sort_module_level": False,
82
+ "sort_decorated": False,
83
+ "python_version": None,
84
+ }
85
+
86
+ def test_missing_tool_section(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
87
+ """Test that missing tool.undersort section returns default."""
88
+ pyproject_content = """
89
+ [project]
90
+ name = "test"
91
+ """
92
+ pyproject_path = tmp_path / "pyproject.toml"
93
+ pyproject_path.write_text(pyproject_content)
94
+
95
+ monkeypatch.chdir(tmp_path)
96
+ config = load_config()
97
+ assert config == {
98
+ "order": ["public", "protected", "private"],
99
+ "method_type_order": None,
100
+ "exclude": None,
101
+ "sort_module_level": False,
102
+ "sort_decorated": False,
103
+ "python_version": None,
104
+ }
105
+
106
+ def test_find_pyproject_in_parent(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
107
+ """Test that pyproject.toml is found in parent directories."""
108
+ # Create pyproject.toml in parent
109
+ pyproject_content = """
110
+ [tool.undersort]
111
+ order = ["private", "public", "protected"]
112
+ """
113
+ pyproject_path = tmp_path / "pyproject.toml"
114
+ pyproject_path.write_text(pyproject_content)
115
+
116
+ # Change to subdirectory
117
+ subdir = tmp_path / "subdir" / "nested"
118
+ subdir.mkdir(parents=True)
119
+ monkeypatch.chdir(subdir)
120
+
121
+ config = load_config()
122
+ assert config == {
123
+ "order": ["private", "public", "protected"],
124
+ "method_type_order": None,
125
+ "exclude": None,
126
+ "sort_module_level": False,
127
+ "sort_decorated": False,
128
+ "python_version": None,
129
+ }
130
+
131
+ def test_corrupted_toml(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
132
+ """Test that corrupted TOML file falls back to default."""
133
+ pyproject_content = """
134
+ [tool.undersort
135
+ order = ["public" # Invalid TOML
136
+ """
137
+ pyproject_path = tmp_path / "pyproject.toml"
138
+ pyproject_path.write_text(pyproject_content)
139
+
140
+ monkeypatch.chdir(tmp_path)
141
+ config = load_config()
142
+ assert config == {
143
+ "order": ["public", "protected", "private"],
144
+ "method_type_order": None,
145
+ "exclude": None,
146
+ "sort_module_level": False,
147
+ "sort_decorated": False,
148
+ "python_version": None,
149
+ }
150
+
151
+ def test_find_pyproject_toml_not_found(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
152
+ """Test _find_pyproject_toml returns None when not found."""
153
+ monkeypatch.chdir(tmp_path)
154
+ result = _find_pyproject_toml()
155
+ assert result is None
156
+
157
+ def test_find_pyproject_toml_found(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
158
+ """Test _find_pyproject_toml returns path when found."""
159
+ pyproject_path = tmp_path / "pyproject.toml"
160
+ pyproject_path.write_text("[project]\nname = 'test'")
161
+
162
+ monkeypatch.chdir(tmp_path)
163
+ result = _find_pyproject_toml()
164
+ assert result == pyproject_path
165
+
166
+ def test_load_exclude_patterns(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
167
+ """Test loading exclude patterns from pyproject.toml."""
168
+ pyproject_content = """
169
+ [tool.undersort]
170
+ order = ["public", "protected", "private"]
171
+ exclude = ["tests/*", "migrations/*.py"]
172
+ """
173
+ pyproject_path = tmp_path / "pyproject.toml"
174
+ pyproject_path.write_text(pyproject_content)
175
+
176
+ monkeypatch.chdir(tmp_path)
177
+ config = load_config()
178
+ assert config == {
179
+ "order": ["public", "protected", "private"],
180
+ "method_type_order": None,
181
+ "exclude": ["tests/*", "migrations/*.py"],
182
+ "sort_module_level": False,
183
+ "sort_decorated": False,
184
+ "python_version": None,
185
+ }
186
+
187
+ def test_invalid_exclude_type(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
188
+ """Test that invalid exclude type falls back to None."""
189
+ pyproject_content = """
190
+ [tool.undersort]
191
+ order = ["public", "protected", "private"]
192
+ exclude = "invalid"
193
+ """
194
+ pyproject_path = tmp_path / "pyproject.toml"
195
+ pyproject_path.write_text(pyproject_content)
196
+
197
+ monkeypatch.chdir(tmp_path)
198
+ config = load_config()
199
+ assert config == {
200
+ "order": ["public", "protected", "private"],
201
+ "method_type_order": None,
202
+ "exclude": None,
203
+ "sort_module_level": False,
204
+ "sort_decorated": False,
205
+ "python_version": None,
206
+ }
207
+
208
+ def test_sort_module_level_enabled(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
209
+ """Test that sort_module_level is read from pyproject.toml."""
210
+ pyproject_content = """
211
+ [tool.undersort]
212
+ sort_module_level = true
213
+ """
214
+ pyproject_path = tmp_path / "pyproject.toml"
215
+ pyproject_path.write_text(pyproject_content)
216
+
217
+ monkeypatch.chdir(tmp_path)
218
+ assert load_config()["sort_module_level"] is True
219
+
220
+ def test_invalid_sort_module_level(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
221
+ """Test that a non-boolean sort_module_level falls back to the default."""
222
+ pyproject_content = """
223
+ [tool.undersort]
224
+ sort_module_level = "yes"
225
+ """
226
+ pyproject_path = tmp_path / "pyproject.toml"
227
+ pyproject_path.write_text(pyproject_content)
228
+
229
+ monkeypatch.chdir(tmp_path)
230
+ assert load_config()["sort_module_level"] is False
231
+
232
+ def test_python_version_from_requires_python(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
233
+ """Test that the target version is inferred from the project's requires-python."""
234
+ pyproject_content = """
235
+ [project]
236
+ name = "test"
237
+ requires-python = ">=3.10,<4.0"
238
+ """
239
+ pyproject_path = tmp_path / "pyproject.toml"
240
+ pyproject_path.write_text(pyproject_content)
241
+
242
+ monkeypatch.chdir(tmp_path)
243
+ assert load_config()["python_version"] == (3, 10)
244
+
245
+ def test_explicit_python_version_wins(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
246
+ """Test that an explicit python_version overrides requires-python."""
247
+ pyproject_content = """
248
+ [project]
249
+ name = "test"
250
+ requires-python = ">=3.10"
251
+
252
+ [tool.undersort]
253
+ python_version = "3.14"
254
+ """
255
+ pyproject_path = tmp_path / "pyproject.toml"
256
+ pyproject_path.write_text(pyproject_content)
257
+
258
+ monkeypatch.chdir(tmp_path)
259
+ assert load_config()["python_version"] == (3, 14)
260
+
261
+ def test_invalid_python_version_ignored(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
262
+ """Test that an unparseable python_version is ignored."""
263
+ pyproject_content = """
264
+ [tool.undersort]
265
+ python_version = "nonsense"
266
+ """
267
+ pyproject_path = tmp_path / "pyproject.toml"
268
+ pyproject_path.write_text(pyproject_content)
269
+
270
+ monkeypatch.chdir(tmp_path)
271
+ assert load_config()["python_version"] is None