undersort 0.1.5__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 (26) hide show
  1. {undersort-0.1.5 → undersort-0.1.6}/PKG-INFO +123 -1
  2. {undersort-0.1.5 → undersort-0.1.6}/README.md +122 -0
  3. {undersort-0.1.5 → undersort-0.1.6}/pyproject.toml +1 -1
  4. {undersort-0.1.5 → undersort-0.1.6}/tests/test_config.py +127 -7
  5. undersort-0.1.6/tests/test_module_sorter.py +375 -0
  6. undersort-0.1.6/undersort/config.py +144 -0
  7. undersort-0.1.6/undersort/deps.py +253 -0
  8. {undersort-0.1.5 → undersort-0.1.6}/undersort/main.py +57 -4
  9. undersort-0.1.6/undersort/sorter.py +602 -0
  10. undersort-0.1.5/undersort/config.py +0 -75
  11. undersort-0.1.5/undersort/sorter.py +0 -281
  12. {undersort-0.1.5 → undersort-0.1.6}/.github/workflows/ci.yml +0 -0
  13. {undersort-0.1.5 → undersort-0.1.6}/.gitignore +0 -0
  14. {undersort-0.1.5 → undersort-0.1.6}/.pre-commit-config.yaml +0 -0
  15. {undersort-0.1.5 → undersort-0.1.6}/.pre-commit-hooks.yaml +0 -0
  16. {undersort-0.1.5 → undersort-0.1.6}/.python-version +0 -0
  17. {undersort-0.1.5 → undersort-0.1.6}/.vscode/settings.json +0 -0
  18. {undersort-0.1.5 → undersort-0.1.6}/LICENSE +0 -0
  19. {undersort-0.1.5 → undersort-0.1.6}/Makefile +0 -0
  20. {undersort-0.1.5 → undersort-0.1.6}/tests/__init__.py +0 -0
  21. {undersort-0.1.5 → undersort-0.1.6}/tests/test_main.py +0 -0
  22. {undersort-0.1.5 → undersort-0.1.6}/tests/test_nosort.py +0 -0
  23. {undersort-0.1.5 → undersort-0.1.6}/tests/test_sorter.py +0 -0
  24. {undersort-0.1.5 → undersort-0.1.6}/undersort/__init__.py +0 -0
  25. {undersort-0.1.5 → undersort-0.1.6}/undersort/logger.py +0 -0
  26. {undersort-0.1.5 → undersort-0.1.6}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: undersort
3
- Version: 0.1.5
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
@@ -72,6 +74,17 @@ method_type_order = ["instance", "class", "static"]
72
74
  # Exclude files/directories matching these patterns (optional)
73
75
  # Patterns support glob syntax (e.g., "tests/*", "migrations/*.py", "**/generated/*")
74
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"
75
88
  ```
76
89
 
77
90
  ### Method Visibility Rules
@@ -111,6 +124,103 @@ Example order with default configuration:
111
124
  8. Private class methods
112
125
  9. Private static methods
113
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
+
114
224
  ### Skipping Sorting with `# nosort`
115
225
 
116
226
  You can prevent sorting at different levels using `# nosort` comments (case-insensitive):
@@ -195,6 +305,18 @@ undersort --exclude "tests/*" --exclude "migrations/*.py" src/
195
305
 
196
306
  # Multiple exclude patterns (can be combined with config file patterns)
197
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/
198
320
  ```
199
321
 
200
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.
@@ -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
@@ -45,6 +47,17 @@ method_type_order = ["instance", "class", "static"]
45
47
  # Exclude files/directories matching these patterns (optional)
46
48
  # Patterns support glob syntax (e.g., "tests/*", "migrations/*.py", "**/generated/*")
47
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"
48
61
  ```
49
62
 
50
63
  ### Method Visibility Rules
@@ -84,6 +97,103 @@ Example order with default configuration:
84
97
  8. Private class methods
85
98
  9. Private static methods
86
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
+
87
197
  ### Skipping Sorting with `# nosort`
88
198
 
89
199
  You can prevent sorting at different levels using `# nosort` comments (case-insensitive):
@@ -168,6 +278,18 @@ undersort --exclude "tests/*" --exclude "migrations/*.py" src/
168
278
 
169
279
  # Multiple exclude patterns (can be combined with config file patterns)
170
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/
171
293
  ```
172
294
 
173
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.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "undersort"
7
- version = "0.1.5"
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"
@@ -14,7 +14,14 @@ 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, "exclude": None}
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
+ }
18
25
 
19
26
  def test_load_custom_order(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
20
27
  """Test loading custom order from pyproject.toml."""
@@ -27,7 +34,14 @@ order = ["private", "protected", "public"]
27
34
 
28
35
  monkeypatch.chdir(tmp_path)
29
36
  config = load_config()
30
- assert config == {"order": ["private", "protected", "public"], "method_type_order": None, "exclude": None}
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
+ }
31
45
 
32
46
  def test_invalid_order_values(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
33
47
  """Test that invalid order values fall back to default."""
@@ -40,7 +54,14 @@ order = ["public", "invalid", "private"]
40
54
 
41
55
  monkeypatch.chdir(tmp_path)
42
56
  config = load_config()
43
- assert config == {"order": ["public", "protected", "private"], "method_type_order": None, "exclude": None}
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
+ }
44
65
 
45
66
  def test_missing_order_key(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
46
67
  """Test that missing order key returns default."""
@@ -53,7 +74,14 @@ some_other_key = "value"
53
74
 
54
75
  monkeypatch.chdir(tmp_path)
55
76
  config = load_config()
56
- assert config == {"order": ["public", "protected", "private"], "method_type_order": None, "exclude": None}
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
+ }
57
85
 
58
86
  def test_missing_tool_section(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
59
87
  """Test that missing tool.undersort section returns default."""
@@ -66,7 +94,14 @@ name = "test"
66
94
 
67
95
  monkeypatch.chdir(tmp_path)
68
96
  config = load_config()
69
- assert config == {"order": ["public", "protected", "private"], "method_type_order": None, "exclude": None}
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
+ }
70
105
 
71
106
  def test_find_pyproject_in_parent(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
72
107
  """Test that pyproject.toml is found in parent directories."""
@@ -84,7 +119,14 @@ order = ["private", "public", "protected"]
84
119
  monkeypatch.chdir(subdir)
85
120
 
86
121
  config = load_config()
87
- assert config == {"order": ["private", "public", "protected"], "method_type_order": None, "exclude": None}
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
+ }
88
130
 
89
131
  def test_corrupted_toml(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
90
132
  """Test that corrupted TOML file falls back to default."""
@@ -97,7 +139,14 @@ order = ["public" # Invalid TOML
97
139
 
98
140
  monkeypatch.chdir(tmp_path)
99
141
  config = load_config()
100
- assert config == {"order": ["public", "protected", "private"], "method_type_order": None, "exclude": None}
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
+ }
101
150
 
102
151
  def test_find_pyproject_toml_not_found(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
103
152
  """Test _find_pyproject_toml returns None when not found."""
@@ -130,6 +179,9 @@ exclude = ["tests/*", "migrations/*.py"]
130
179
  "order": ["public", "protected", "private"],
131
180
  "method_type_order": None,
132
181
  "exclude": ["tests/*", "migrations/*.py"],
182
+ "sort_module_level": False,
183
+ "sort_decorated": False,
184
+ "python_version": None,
133
185
  }
134
186
 
135
187
  def test_invalid_exclude_type(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
@@ -148,4 +200,72 @@ exclude = "invalid"
148
200
  "order": ["public", "protected", "private"],
149
201
  "method_type_order": None,
150
202
  "exclude": None,
203
+ "sort_module_level": False,
204
+ "sort_decorated": False,
205
+ "python_version": None,
151
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