undersort 0.1.6__tar.gz → 0.1.7__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.6 → undersort-0.1.7}/.github/workflows/ci.yml +4 -6
- {undersort-0.1.6 → undersort-0.1.7}/PKG-INFO +47 -7
- {undersort-0.1.6 → undersort-0.1.7}/README.md +43 -1
- {undersort-0.1.6 → undersort-0.1.7}/pyproject.toml +3 -5
- undersort-0.1.7/tests/test_dunder_order.py +201 -0
- {undersort-0.1.6 → undersort-0.1.7}/tests/test_sorter.py +21 -0
- {undersort-0.1.6 → undersort-0.1.7}/undersort/__init__.py +1 -1
- {undersort-0.1.6 → undersort-0.1.7}/undersort/config.py +36 -3
- {undersort-0.1.6 → undersort-0.1.7}/undersort/deps.py +19 -0
- {undersort-0.1.6 → undersort-0.1.7}/undersort/sorter.py +99 -9
- undersort-0.1.7/uv.lock +451 -0
- undersort-0.1.6/uv.lock +0 -936
- {undersort-0.1.6 → undersort-0.1.7}/.gitignore +0 -0
- {undersort-0.1.6 → undersort-0.1.7}/.pre-commit-config.yaml +0 -0
- {undersort-0.1.6 → undersort-0.1.7}/.pre-commit-hooks.yaml +0 -0
- {undersort-0.1.6 → undersort-0.1.7}/.python-version +0 -0
- {undersort-0.1.6 → undersort-0.1.7}/.vscode/settings.json +0 -0
- {undersort-0.1.6 → undersort-0.1.7}/LICENSE +0 -0
- {undersort-0.1.6 → undersort-0.1.7}/Makefile +0 -0
- {undersort-0.1.6 → undersort-0.1.7}/tests/__init__.py +0 -0
- {undersort-0.1.6 → undersort-0.1.7}/tests/test_config.py +0 -0
- {undersort-0.1.6 → undersort-0.1.7}/tests/test_main.py +0 -0
- {undersort-0.1.6 → undersort-0.1.7}/tests/test_module_sorter.py +0 -0
- {undersort-0.1.6 → undersort-0.1.7}/tests/test_nosort.py +0 -0
- {undersort-0.1.6 → undersort-0.1.7}/undersort/logger.py +0 -0
- {undersort-0.1.6 → undersort-0.1.7}/undersort/main.py +0 -0
|
@@ -39,7 +39,7 @@ jobs:
|
|
|
39
39
|
runs-on: ubuntu-latest
|
|
40
40
|
strategy:
|
|
41
41
|
matrix:
|
|
42
|
-
python-version: ["3.
|
|
42
|
+
python-version: ["3.11", "3.12", "3.13"]
|
|
43
43
|
steps:
|
|
44
44
|
- uses: actions/checkout@v4
|
|
45
45
|
|
|
@@ -49,15 +49,13 @@ jobs:
|
|
|
49
49
|
enable-cache: true
|
|
50
50
|
|
|
51
51
|
- name: Set up Python ${{ matrix.python-version }}
|
|
52
|
-
|
|
53
|
-
with:
|
|
54
|
-
python-version: ${{ matrix.python-version }}
|
|
52
|
+
run: uv python install ${{ matrix.python-version }}
|
|
55
53
|
|
|
56
54
|
- name: Install dependencies
|
|
57
|
-
run: uv sync
|
|
55
|
+
run: uv sync --python ${{ matrix.python-version }}
|
|
58
56
|
|
|
59
57
|
- name: Run tests
|
|
60
|
-
run: uv run pytest -v --cov=undersort --cov-report=term-missing
|
|
58
|
+
run: uv run --python ${{ matrix.python-version }} pytest -v --cov=undersort --cov-report=term-missing
|
|
61
59
|
|
|
62
60
|
publish:
|
|
63
61
|
name: Publish to PyPI
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: undersort
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.7
|
|
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
|
|
@@ -13,14 +13,12 @@ Classifier: Development Status :: 4 - Beta
|
|
|
13
13
|
Classifier: Intended Audience :: Developers
|
|
14
14
|
Classifier: License :: OSI Approved :: MIT License
|
|
15
15
|
Classifier: Programming Language :: Python :: 3
|
|
16
|
-
Classifier: Programming Language :: Python :: 3.8
|
|
17
|
-
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
19
16
|
Classifier: Programming Language :: Python :: 3.11
|
|
20
17
|
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
19
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
20
|
Classifier: Topic :: Software Development :: Quality Assurance
|
|
23
|
-
Requires-Python: >=3.
|
|
21
|
+
Requires-Python: >=3.11
|
|
24
22
|
Requires-Dist: libcst>=1.1.0
|
|
25
23
|
Requires-Dist: rich>=13.0.0
|
|
26
24
|
Description-Content-Type: text/markdown
|
|
@@ -63,7 +61,9 @@ Configure the method ordering in your `pyproject.toml`:
|
|
|
63
61
|
```toml
|
|
64
62
|
[tool.undersort]
|
|
65
63
|
# Method visibility ordering (primary sort)
|
|
66
|
-
#
|
|
64
|
+
# Required groups: "public", "protected", "private"
|
|
65
|
+
# Optional groups: "init" (creational dunders), "dunder" (all other magic methods)
|
|
66
|
+
# Omit the optional groups to keep magic methods inside "public" (the default).
|
|
67
67
|
order = ["public", "protected", "private"]
|
|
68
68
|
|
|
69
69
|
# Method type ordering within each visibility level (secondary sort, optional)
|
|
@@ -93,6 +93,46 @@ method_type_order = ["instance", "class", "static"]
|
|
|
93
93
|
- **Protected methods**: Single underscore prefix (e.g., `def _method()`)
|
|
94
94
|
- **Private methods**: Double underscore prefix, not magic (e.g., `def __method()`)
|
|
95
95
|
|
|
96
|
+
#### Separating dunder methods
|
|
97
|
+
|
|
98
|
+
By default magic methods count as **public**, which leaves `__init__` mixed in with
|
|
99
|
+
ordinary public methods. Add either of the two optional groups to `order` to pull
|
|
100
|
+
them out:
|
|
101
|
+
|
|
102
|
+
- **`init`** — creational dunders: `__new__`, `__init__`, `__init_subclass__`, `__post_init__`
|
|
103
|
+
- **`dunder`** — every other magic method: `__str__`, `__get__`, `__eq__`, ...
|
|
104
|
+
|
|
105
|
+
```toml
|
|
106
|
+
[tool.undersort]
|
|
107
|
+
order = ["init", "dunder", "public", "protected", "private"]
|
|
108
|
+
method_type_order = ["static", "class", "instance"]
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
# before # after
|
|
113
|
+
class C: class C:
|
|
114
|
+
@classmethod def __init__(self) -> None: ...
|
|
115
|
+
def default(cls): ...
|
|
116
|
+
def __str__(self) -> str: ...
|
|
117
|
+
def __init__(self) -> None: ...
|
|
118
|
+
@classmethod
|
|
119
|
+
def random_public(self): ... def default(cls): ...
|
|
120
|
+
|
|
121
|
+
def __str__(self) -> str: ... def random_public(self): ...
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The groups are positional, so `order = ["public", "protected", "private", "init", "dunder"]`
|
|
125
|
+
puts the magic methods last instead.
|
|
126
|
+
|
|
127
|
+
Notes:
|
|
128
|
+
|
|
129
|
+
- `public`, `protected` and `private` must always be present, so no method can be
|
|
130
|
+
silently dropped. `init` and `dunder` are optional.
|
|
131
|
+
- Using `init` without `dunder` leaves non-creational magic methods in `public`.
|
|
132
|
+
- Name-mangled methods (`__method`, no trailing underscores) remain **private** —
|
|
133
|
+
they are not dunders.
|
|
134
|
+
- An existing `order` that does not mention the new groups behaves exactly as before.
|
|
135
|
+
|
|
96
136
|
### Method Type Rules
|
|
97
137
|
|
|
98
138
|
- **Class methods**: Decorated with `@classmethod`
|
|
@@ -36,7 +36,9 @@ Configure the method ordering in your `pyproject.toml`:
|
|
|
36
36
|
```toml
|
|
37
37
|
[tool.undersort]
|
|
38
38
|
# Method visibility ordering (primary sort)
|
|
39
|
-
#
|
|
39
|
+
# Required groups: "public", "protected", "private"
|
|
40
|
+
# Optional groups: "init" (creational dunders), "dunder" (all other magic methods)
|
|
41
|
+
# Omit the optional groups to keep magic methods inside "public" (the default).
|
|
40
42
|
order = ["public", "protected", "private"]
|
|
41
43
|
|
|
42
44
|
# Method type ordering within each visibility level (secondary sort, optional)
|
|
@@ -66,6 +68,46 @@ method_type_order = ["instance", "class", "static"]
|
|
|
66
68
|
- **Protected methods**: Single underscore prefix (e.g., `def _method()`)
|
|
67
69
|
- **Private methods**: Double underscore prefix, not magic (e.g., `def __method()`)
|
|
68
70
|
|
|
71
|
+
#### Separating dunder methods
|
|
72
|
+
|
|
73
|
+
By default magic methods count as **public**, which leaves `__init__` mixed in with
|
|
74
|
+
ordinary public methods. Add either of the two optional groups to `order` to pull
|
|
75
|
+
them out:
|
|
76
|
+
|
|
77
|
+
- **`init`** — creational dunders: `__new__`, `__init__`, `__init_subclass__`, `__post_init__`
|
|
78
|
+
- **`dunder`** — every other magic method: `__str__`, `__get__`, `__eq__`, ...
|
|
79
|
+
|
|
80
|
+
```toml
|
|
81
|
+
[tool.undersort]
|
|
82
|
+
order = ["init", "dunder", "public", "protected", "private"]
|
|
83
|
+
method_type_order = ["static", "class", "instance"]
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
# before # after
|
|
88
|
+
class C: class C:
|
|
89
|
+
@classmethod def __init__(self) -> None: ...
|
|
90
|
+
def default(cls): ...
|
|
91
|
+
def __str__(self) -> str: ...
|
|
92
|
+
def __init__(self) -> None: ...
|
|
93
|
+
@classmethod
|
|
94
|
+
def random_public(self): ... def default(cls): ...
|
|
95
|
+
|
|
96
|
+
def __str__(self) -> str: ... def random_public(self): ...
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The groups are positional, so `order = ["public", "protected", "private", "init", "dunder"]`
|
|
100
|
+
puts the magic methods last instead.
|
|
101
|
+
|
|
102
|
+
Notes:
|
|
103
|
+
|
|
104
|
+
- `public`, `protected` and `private` must always be present, so no method can be
|
|
105
|
+
silently dropped. `init` and `dunder` are optional.
|
|
106
|
+
- Using `init` without `dunder` leaves non-creational magic methods in `public`.
|
|
107
|
+
- Name-mangled methods (`__method`, no trailing underscores) remain **private** —
|
|
108
|
+
they are not dunders.
|
|
109
|
+
- An existing `order` that does not mention the new groups behaves exactly as before.
|
|
110
|
+
|
|
69
111
|
### Method Type Rules
|
|
70
112
|
|
|
71
113
|
- **Class methods**: Decorated with `@classmethod`
|
|
@@ -4,11 +4,11 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "undersort"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.7"
|
|
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"
|
|
11
|
-
requires-python = ">=3.
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
12
|
license = { text = "MIT" }
|
|
13
13
|
keywords = ["sorting", "code-formatter", "linter", "python", "class-methods"]
|
|
14
14
|
classifiers = [
|
|
@@ -16,11 +16,9 @@ classifiers = [
|
|
|
16
16
|
"Intended Audience :: Developers",
|
|
17
17
|
"License :: OSI Approved :: MIT License",
|
|
18
18
|
"Programming Language :: Python :: 3",
|
|
19
|
-
"Programming Language :: Python :: 3.8",
|
|
20
|
-
"Programming Language :: Python :: 3.9",
|
|
21
|
-
"Programming Language :: Python :: 3.10",
|
|
22
19
|
"Programming Language :: Python :: 3.11",
|
|
23
20
|
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
24
22
|
"Topic :: Software Development :: Quality Assurance",
|
|
25
23
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
26
24
|
]
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Tests for splitting dunder methods out of the public group (issue #1)."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import libcst as cst
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from undersort.config import load_config
|
|
9
|
+
from undersort.sorter import MethodSorter, get_method_visibility, is_dunder, sort_module_definitions
|
|
10
|
+
|
|
11
|
+
FULL = ["init", "dunder", "public", "protected", "private"]
|
|
12
|
+
LEGACY = ["public", "protected", "private"]
|
|
13
|
+
TYPE_ORDER = ["static", "class", "instance"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def method_names(source: str) -> list[str]:
|
|
17
|
+
"""List method names of the first class, in order.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
source: The source code
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
The method names
|
|
24
|
+
"""
|
|
25
|
+
module = cst.parse_module(source)
|
|
26
|
+
class_def = next(s for s in module.body if isinstance(s, cst.ClassDef))
|
|
27
|
+
return [item.name.value for item in class_def.body.body if isinstance(item, cst.FunctionDef)]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def sort_class(source: str, order: list[str], method_type_order: list[str] | None = None) -> str:
|
|
31
|
+
"""Sort methods of a class snippet with the given ordering.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
source: The source code
|
|
35
|
+
order: Visibility ordering
|
|
36
|
+
method_type_order: Optional method type ordering
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
The sorted source
|
|
40
|
+
"""
|
|
41
|
+
module = cst.parse_module(source)
|
|
42
|
+
return module.visit(MethodSorter(order, method_type_order)).code
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
SAMPLE = """class C:
|
|
46
|
+
def _helper(self): ...
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def default(cls): ...
|
|
50
|
+
|
|
51
|
+
def __init__(self) -> None: ...
|
|
52
|
+
|
|
53
|
+
def public_a(self): ...
|
|
54
|
+
|
|
55
|
+
def __str__(self) -> str: ...
|
|
56
|
+
|
|
57
|
+
def __private(self): ...
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class TestVisibilityClassification:
|
|
62
|
+
"""Tests for the order-aware visibility classifier."""
|
|
63
|
+
|
|
64
|
+
def test_dunder_detection(self) -> None:
|
|
65
|
+
"""Only names wrapped in double underscores are dunders."""
|
|
66
|
+
assert is_dunder("__init__") is True
|
|
67
|
+
assert is_dunder("__str__") is True
|
|
68
|
+
assert is_dunder("__private") is False
|
|
69
|
+
assert is_dunder("_protected") is False
|
|
70
|
+
assert is_dunder("public") is False
|
|
71
|
+
|
|
72
|
+
def test_dunders_are_public_without_configuration(self) -> None:
|
|
73
|
+
"""Legacy behaviour: magic methods count as public."""
|
|
74
|
+
assert get_method_visibility("__init__") == "public"
|
|
75
|
+
assert get_method_visibility("__str__", LEGACY) == "public"
|
|
76
|
+
|
|
77
|
+
def test_dunder_group_captures_magic_methods(self) -> None:
|
|
78
|
+
"""With a dunder group, magic methods leave the public group."""
|
|
79
|
+
order = ["dunder", "public", "protected", "private"]
|
|
80
|
+
assert get_method_visibility("__str__", order) == "dunder"
|
|
81
|
+
assert get_method_visibility("__init__", order) == "dunder"
|
|
82
|
+
|
|
83
|
+
def test_init_group_captures_creational_dunders(self) -> None:
|
|
84
|
+
"""Creational dunders form their own group when requested."""
|
|
85
|
+
assert get_method_visibility("__init__", FULL) == "init"
|
|
86
|
+
assert get_method_visibility("__new__", FULL) == "init"
|
|
87
|
+
assert get_method_visibility("__init_subclass__", FULL) == "init"
|
|
88
|
+
assert get_method_visibility("__post_init__", FULL) == "init"
|
|
89
|
+
assert get_method_visibility("__str__", FULL) == "dunder"
|
|
90
|
+
|
|
91
|
+
def test_init_group_without_dunder_group(self) -> None:
|
|
92
|
+
"""Non-creational dunders fall back to public when no dunder group exists."""
|
|
93
|
+
order = ["init", "public", "protected", "private"]
|
|
94
|
+
assert get_method_visibility("__init__", order) == "init"
|
|
95
|
+
assert get_method_visibility("__str__", order) == "public"
|
|
96
|
+
|
|
97
|
+
def test_non_dunder_underscores_unaffected(self) -> None:
|
|
98
|
+
"""Name-mangled and protected names keep their existing groups."""
|
|
99
|
+
assert get_method_visibility("__private", FULL) == "private"
|
|
100
|
+
assert get_method_visibility("_protected", FULL) == "protected"
|
|
101
|
+
assert get_method_visibility("public", FULL) == "public"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class TestIssueScenarios:
|
|
105
|
+
"""The two cases reported in issue #1."""
|
|
106
|
+
|
|
107
|
+
def test_init_no_longer_trails_a_classmethod(self) -> None:
|
|
108
|
+
"""__init__ can be pulled ahead of a public classmethod."""
|
|
109
|
+
source = "class C:\n @classmethod\n def default(cls): ...\n\n def __init__(self) -> None: ...\n"
|
|
110
|
+
assert method_names(sort_class(source, FULL, TYPE_ORDER)) == ["__init__", "default"]
|
|
111
|
+
|
|
112
|
+
def test_dunders_group_together(self) -> None:
|
|
113
|
+
"""__init__ and __str__ end up adjacent instead of split by a public method."""
|
|
114
|
+
source = (
|
|
115
|
+
"class C:\n def __init__(self) -> None: ...\n\n"
|
|
116
|
+
" def random_public(self): ...\n\n"
|
|
117
|
+
" def __str__(self) -> str: ...\n"
|
|
118
|
+
)
|
|
119
|
+
assert method_names(sort_class(source, FULL, TYPE_ORDER)) == ["__init__", "__str__", "random_public"]
|
|
120
|
+
|
|
121
|
+
def test_legacy_order_is_unchanged(self) -> None:
|
|
122
|
+
"""An order without dunder groups keeps magic methods in the public group."""
|
|
123
|
+
assert method_names(sort_class(SAMPLE, LEGACY, TYPE_ORDER)) == [
|
|
124
|
+
"default",
|
|
125
|
+
"__init__",
|
|
126
|
+
"public_a",
|
|
127
|
+
"__str__",
|
|
128
|
+
"_helper",
|
|
129
|
+
"__private",
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
def test_full_order_groups_everything(self) -> None:
|
|
133
|
+
"""Creational, generic dunder, public, protected and private all separate."""
|
|
134
|
+
assert method_names(sort_class(SAMPLE, FULL, TYPE_ORDER)) == [
|
|
135
|
+
"__init__",
|
|
136
|
+
"__str__",
|
|
137
|
+
"default",
|
|
138
|
+
"public_a",
|
|
139
|
+
"_helper",
|
|
140
|
+
"__private",
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
def test_dunders_can_be_placed_last(self) -> None:
|
|
144
|
+
"""The groups are positional, so dunders can also go at the end."""
|
|
145
|
+
order = ["public", "protected", "private", "init", "dunder"]
|
|
146
|
+
assert method_names(sort_class(SAMPLE, order, TYPE_ORDER))[-2:] == ["__init__", "__str__"]
|
|
147
|
+
|
|
148
|
+
def test_blank_lines_stay_sane(self) -> None:
|
|
149
|
+
"""Moving a dunder to the front does not collapse the spacing."""
|
|
150
|
+
source = "class C:\n @classmethod\n def default(cls): ...\n\n def __init__(self) -> None: ...\n"
|
|
151
|
+
result = sort_class(source, FULL, TYPE_ORDER)
|
|
152
|
+
assert (
|
|
153
|
+
result == "class C:\n def __init__(self) -> None: ...\n\n @classmethod\n def default(cls): ...\n"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class TestOrderValidation:
|
|
158
|
+
"""Tests for validating the extended order option."""
|
|
159
|
+
|
|
160
|
+
@pytest.mark.parametrize(
|
|
161
|
+
"order",
|
|
162
|
+
[
|
|
163
|
+
["init", "dunder", "public", "protected", "private"],
|
|
164
|
+
["dunder", "public", "protected", "private"],
|
|
165
|
+
["public", "protected", "private"],
|
|
166
|
+
["private", "protected", "public"],
|
|
167
|
+
],
|
|
168
|
+
)
|
|
169
|
+
def test_valid_orders_accepted(self, order: list[str], tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
170
|
+
"""Orders containing all required groups are accepted."""
|
|
171
|
+
(tmp_path / "pyproject.toml").write_text(f"[tool.undersort]\norder = {order!r}\n".replace("'", '"'))
|
|
172
|
+
monkeypatch.chdir(tmp_path)
|
|
173
|
+
assert load_config()["order"] == order
|
|
174
|
+
|
|
175
|
+
@pytest.mark.parametrize(
|
|
176
|
+
"order",
|
|
177
|
+
[
|
|
178
|
+
["init", "dunder", "public"],
|
|
179
|
+
["public", "protected", "private", "bogus"],
|
|
180
|
+
["public", "public", "protected", "private"],
|
|
181
|
+
"notalist",
|
|
182
|
+
],
|
|
183
|
+
)
|
|
184
|
+
def test_invalid_orders_fall_back(self, order: object, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
185
|
+
"""Incomplete, unknown, duplicated or malformed orders fall back to the default."""
|
|
186
|
+
rendered = repr(order).replace("'", '"')
|
|
187
|
+
(tmp_path / "pyproject.toml").write_text(f"[tool.undersort]\norder = {rendered}\n")
|
|
188
|
+
monkeypatch.chdir(tmp_path)
|
|
189
|
+
assert load_config()["order"] == ["public", "protected", "private"]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class TestModuleLevelDunders:
|
|
193
|
+
"""Dunder grouping also applies to module-level definitions."""
|
|
194
|
+
|
|
195
|
+
def test_module_dunder_grouped(self) -> None:
|
|
196
|
+
"""A module-level __getattr__ follows the configured dunder group."""
|
|
197
|
+
source = "def _helper():\n pass\n\n\ndef __getattr__(name):\n pass\n"
|
|
198
|
+
module = cst.parse_module(source)
|
|
199
|
+
new_module, modified = sort_module_definitions(module, FULL, True)
|
|
200
|
+
assert modified is True
|
|
201
|
+
assert new_module.code.index("__getattr__") < new_module.code.index("_helper")
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import tempfile
|
|
2
2
|
from pathlib import Path
|
|
3
3
|
|
|
4
|
+
import libcst as cst
|
|
4
5
|
import pytest
|
|
5
6
|
|
|
7
|
+
from undersort.deps import eager_names
|
|
6
8
|
from undersort.sorter import get_method_visibility, sort_file
|
|
7
9
|
|
|
8
10
|
|
|
@@ -486,3 +488,22 @@ class Example:
|
|
|
486
488
|
assert protected_instance_idx < protected_class_idx < protected_static_idx
|
|
487
489
|
finally:
|
|
488
490
|
temp_path.unlink()
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def test_long_implicit_string_concatenation_does_not_recurse(tmp_path: Path) -> None:
|
|
494
|
+
pieces = "\n".join(f' "chunk{i}"' for i in range(3000))
|
|
495
|
+
source = (
|
|
496
|
+
f"PACKED = (\n{pieces}\n)\n\n\nclass A:\n BLOB = (\n{pieces}\n )\n\n"
|
|
497
|
+
" def _b(self):\n pass\n\n def a(self):\n pass\n"
|
|
498
|
+
)
|
|
499
|
+
path = tmp_path / "packed.py"
|
|
500
|
+
path.write_text(source)
|
|
501
|
+
|
|
502
|
+
assert sort_file(path, ["public", "protected", "private"], sort_module_level=True)
|
|
503
|
+
assert path.read_text().index("def a(") < path.read_text().index("def _b(")
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def test_eager_names_walks_long_concatenation() -> None:
|
|
507
|
+
pieces = " ".join(f'"c{i}"' for i in range(3000))
|
|
508
|
+
module = cst.parse_module(f"class A(Base):\n X = ({pieces})\n")
|
|
509
|
+
assert "Base" in eager_names(module.body[0], eager_annotations=False)
|
|
@@ -7,7 +7,9 @@ from typing import Any
|
|
|
7
7
|
from undersort import logger
|
|
8
8
|
from undersort.deps import parse_python_version
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
REQUIRED_VISIBILITIES = {"public", "protected", "private"}
|
|
11
|
+
OPTIONAL_VISIBILITIES = {"init", "dunder"}
|
|
12
|
+
VALID_VISIBILITIES = REQUIRED_VISIBILITIES | OPTIONAL_VISIBILITIES
|
|
11
13
|
VALID_METHOD_TYPES = {"class", "static", "instance"}
|
|
12
14
|
|
|
13
15
|
|
|
@@ -69,8 +71,9 @@ def _load_orderings(config: dict[str, Any], result: dict[str, Any], pyproject_pa
|
|
|
69
71
|
"""
|
|
70
72
|
if "order" in config:
|
|
71
73
|
order = config["order"]
|
|
72
|
-
|
|
73
|
-
|
|
74
|
+
problem = _order_problem(order)
|
|
75
|
+
if problem:
|
|
76
|
+
logger.warning(f"Invalid order in {pyproject_path}: {problem}. Using default order.")
|
|
74
77
|
else:
|
|
75
78
|
result["order"] = order
|
|
76
79
|
|
|
@@ -82,6 +85,36 @@ def _load_orderings(config: dict[str, Any], result: dict[str, Any], pyproject_pa
|
|
|
82
85
|
result["method_type_order"] = method_type_order
|
|
83
86
|
|
|
84
87
|
|
|
88
|
+
def _order_problem(order: Any) -> str | None:
|
|
89
|
+
"""Validate a configured visibility order.
|
|
90
|
+
|
|
91
|
+
``public``, ``protected`` and ``private`` must all appear, so no method can be
|
|
92
|
+
silently dropped. The dunder groups are optional: leaving them out keeps magic
|
|
93
|
+
methods in ``public``, which is how earlier versions behaved.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
order: The configured value
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
A description of the problem, or None if the order is usable
|
|
100
|
+
"""
|
|
101
|
+
if not isinstance(order, list) or not all(isinstance(value, str) for value in order):
|
|
102
|
+
return "must be a list of strings"
|
|
103
|
+
|
|
104
|
+
unknown = [value for value in order if value not in VALID_VISIBILITIES]
|
|
105
|
+
if unknown:
|
|
106
|
+
return f"unknown group(s) {sorted(unknown)}; valid groups are {sorted(VALID_VISIBILITIES)}"
|
|
107
|
+
|
|
108
|
+
if len(set(order)) != len(order):
|
|
109
|
+
return "contains duplicate groups"
|
|
110
|
+
|
|
111
|
+
missing = REQUIRED_VISIBILITIES - set(order)
|
|
112
|
+
if missing:
|
|
113
|
+
return f"missing required group(s) {sorted(missing)}"
|
|
114
|
+
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
85
118
|
def _load_module_level_options(config: dict[str, Any], result: dict[str, Any], pyproject_path: Path) -> None:
|
|
86
119
|
"""Read the module-level sorting options into the result.
|
|
87
120
|
|
|
@@ -146,6 +146,25 @@ class _EagerNameCollector(cst.CSTVisitor):
|
|
|
146
146
|
self.names: set[str] = set()
|
|
147
147
|
self.eager_annotations = eager_annotations
|
|
148
148
|
|
|
149
|
+
def visit_ConcatenatedString(self, node: cst.ConcatenatedString) -> bool:
|
|
150
|
+
"""Walk an implicit string concatenation without recursing per literal.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
node: The concatenated string
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
False -- parts are visited manually
|
|
157
|
+
"""
|
|
158
|
+
# libcst nests each extra literal one level deeper; long chains exceed the recursion limit.
|
|
159
|
+
stack: list[cst.BaseExpression] = [node]
|
|
160
|
+
while stack:
|
|
161
|
+
part = stack.pop()
|
|
162
|
+
if isinstance(part, cst.ConcatenatedString):
|
|
163
|
+
stack.extend((part.right, part.left))
|
|
164
|
+
else:
|
|
165
|
+
part.visit(self)
|
|
166
|
+
return False
|
|
167
|
+
|
|
149
168
|
def visit_FunctionDef(self, node: cst.FunctionDef) -> bool:
|
|
150
169
|
"""Collect eager references of a function definition, skipping its body.
|
|
151
170
|
|