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.
Files changed (26) hide show
  1. {undersort-0.1.6 → undersort-0.1.7}/.github/workflows/ci.yml +4 -6
  2. {undersort-0.1.6 → undersort-0.1.7}/PKG-INFO +47 -7
  3. {undersort-0.1.6 → undersort-0.1.7}/README.md +43 -1
  4. {undersort-0.1.6 → undersort-0.1.7}/pyproject.toml +3 -5
  5. undersort-0.1.7/tests/test_dunder_order.py +201 -0
  6. {undersort-0.1.6 → undersort-0.1.7}/tests/test_sorter.py +21 -0
  7. {undersort-0.1.6 → undersort-0.1.7}/undersort/__init__.py +1 -1
  8. {undersort-0.1.6 → undersort-0.1.7}/undersort/config.py +36 -3
  9. {undersort-0.1.6 → undersort-0.1.7}/undersort/deps.py +19 -0
  10. {undersort-0.1.6 → undersort-0.1.7}/undersort/sorter.py +99 -9
  11. undersort-0.1.7/uv.lock +451 -0
  12. undersort-0.1.6/uv.lock +0 -936
  13. {undersort-0.1.6 → undersort-0.1.7}/.gitignore +0 -0
  14. {undersort-0.1.6 → undersort-0.1.7}/.pre-commit-config.yaml +0 -0
  15. {undersort-0.1.6 → undersort-0.1.7}/.pre-commit-hooks.yaml +0 -0
  16. {undersort-0.1.6 → undersort-0.1.7}/.python-version +0 -0
  17. {undersort-0.1.6 → undersort-0.1.7}/.vscode/settings.json +0 -0
  18. {undersort-0.1.6 → undersort-0.1.7}/LICENSE +0 -0
  19. {undersort-0.1.6 → undersort-0.1.7}/Makefile +0 -0
  20. {undersort-0.1.6 → undersort-0.1.7}/tests/__init__.py +0 -0
  21. {undersort-0.1.6 → undersort-0.1.7}/tests/test_config.py +0 -0
  22. {undersort-0.1.6 → undersort-0.1.7}/tests/test_main.py +0 -0
  23. {undersort-0.1.6 → undersort-0.1.7}/tests/test_module_sorter.py +0 -0
  24. {undersort-0.1.6 → undersort-0.1.7}/tests/test_nosort.py +0 -0
  25. {undersort-0.1.6 → undersort-0.1.7}/undersort/logger.py +0 -0
  26. {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.8", "3.9", "3.10", "3.11", "3.12"]
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
- uses: actions/setup-python@v5
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.4
1
+ Metadata-Version: 2.5
2
2
  Name: undersort
3
- Version: 0.1.6
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.8
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
- # Options: "public", "protected", "private"
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
- # Options: "public", "protected", "private"
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.6"
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.8"
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)
@@ -1,3 +1,3 @@
1
1
  """undersort - Sort class methods by visibility."""
2
2
 
3
- __version__ = "0.1.4"
3
+ __version__ = "0.1.7"
@@ -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
- VALID_VISIBILITIES = {"public", "protected", "private"}
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
- if sorted(order) != sorted(VALID_VISIBILITIES):
73
- logger.warning(f"Invalid order values in {pyproject_path}. Using default order.")
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