Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ Configure the method ordering in your `pyproject.toml`:
```toml
[tool.undersort]
# Method visibility ordering (primary sort)
# Options: "public", "protected", "private"
# Required groups: "public", "protected", "private"
# Optional groups: "init" (creational dunders), "dunder" (all other magic methods)
# Omit the optional groups to keep magic methods inside "public" (the default).
order = ["public", "protected", "private"]

# Method type ordering within each visibility level (secondary sort, optional)
Expand Down Expand Up @@ -66,6 +68,46 @@ method_type_order = ["instance", "class", "static"]
- **Protected methods**: Single underscore prefix (e.g., `def _method()`)
- **Private methods**: Double underscore prefix, not magic (e.g., `def __method()`)

#### Separating dunder methods

By default magic methods count as **public**, which leaves `__init__` mixed in with
ordinary public methods. Add either of the two optional groups to `order` to pull
them out:

- **`init`** — creational dunders: `__new__`, `__init__`, `__init_subclass__`, `__post_init__`
- **`dunder`** — every other magic method: `__str__`, `__get__`, `__eq__`, ...

```toml
[tool.undersort]
order = ["init", "dunder", "public", "protected", "private"]
method_type_order = ["static", "class", "instance"]
```

```python
# before # after
class C: class C:
@classmethod def __init__(self) -> None: ...
def default(cls): ...
def __str__(self) -> str: ...
def __init__(self) -> None: ...
@classmethod
def random_public(self): ... def default(cls): ...

def __str__(self) -> str: ... def random_public(self): ...
```

The groups are positional, so `order = ["public", "protected", "private", "init", "dunder"]`
puts the magic methods last instead.

Notes:

- `public`, `protected` and `private` must always be present, so no method can be
silently dropped. `init` and `dunder` are optional.
- Using `init` without `dunder` leaves non-creational magic methods in `public`.
- Name-mangled methods (`__method`, no trailing underscores) remain **private** —
they are not dunders.
- An existing `order` that does not mention the new groups behaves exactly as before.

### Method Type Rules

- **Class methods**: Decorated with `@classmethod`
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "undersort"
version = "0.1.6"
version = "0.1.7"
description = "A tool to sort class methods by visibility (public, protected, private)"
authors = [{ name = "KiviCode", email = "kivicode.dev@gmail.com" }]
readme = "README.md"
Expand Down
201 changes: 201 additions & 0 deletions tests/test_dunder_order.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
"""Tests for splitting dunder methods out of the public group (issue #1)."""

from pathlib import Path

import libcst as cst
import pytest

from undersort.config import load_config
from undersort.sorter import MethodSorter, get_method_visibility, is_dunder, sort_module_definitions

FULL = ["init", "dunder", "public", "protected", "private"]
LEGACY = ["public", "protected", "private"]
TYPE_ORDER = ["static", "class", "instance"]


def method_names(source: str) -> list[str]:
"""List method names of the first class, in order.

Args:
source: The source code

Returns:
The method names
"""
module = cst.parse_module(source)
class_def = next(s for s in module.body if isinstance(s, cst.ClassDef))
return [item.name.value for item in class_def.body.body if isinstance(item, cst.FunctionDef)]


def sort_class(source: str, order: list[str], method_type_order: list[str] | None = None) -> str:
"""Sort methods of a class snippet with the given ordering.

Args:
source: The source code
order: Visibility ordering
method_type_order: Optional method type ordering

Returns:
The sorted source
"""
module = cst.parse_module(source)
return module.visit(MethodSorter(order, method_type_order)).code


SAMPLE = """class C:
def _helper(self): ...

@classmethod
def default(cls): ...

def __init__(self) -> None: ...

def public_a(self): ...

def __str__(self) -> str: ...

def __private(self): ...
"""


class TestVisibilityClassification:
"""Tests for the order-aware visibility classifier."""

def test_dunder_detection(self) -> None:
"""Only names wrapped in double underscores are dunders."""
assert is_dunder("__init__") is True
assert is_dunder("__str__") is True
assert is_dunder("__private") is False
assert is_dunder("_protected") is False
assert is_dunder("public") is False

def test_dunders_are_public_without_configuration(self) -> None:
"""Legacy behaviour: magic methods count as public."""
assert get_method_visibility("__init__") == "public"
assert get_method_visibility("__str__", LEGACY) == "public"

def test_dunder_group_captures_magic_methods(self) -> None:
"""With a dunder group, magic methods leave the public group."""
order = ["dunder", "public", "protected", "private"]
assert get_method_visibility("__str__", order) == "dunder"
assert get_method_visibility("__init__", order) == "dunder"

def test_init_group_captures_creational_dunders(self) -> None:
"""Creational dunders form their own group when requested."""
assert get_method_visibility("__init__", FULL) == "init"
assert get_method_visibility("__new__", FULL) == "init"
assert get_method_visibility("__init_subclass__", FULL) == "init"
assert get_method_visibility("__post_init__", FULL) == "init"
assert get_method_visibility("__str__", FULL) == "dunder"

def test_init_group_without_dunder_group(self) -> None:
"""Non-creational dunders fall back to public when no dunder group exists."""
order = ["init", "public", "protected", "private"]
assert get_method_visibility("__init__", order) == "init"
assert get_method_visibility("__str__", order) == "public"

def test_non_dunder_underscores_unaffected(self) -> None:
"""Name-mangled and protected names keep their existing groups."""
assert get_method_visibility("__private", FULL) == "private"
assert get_method_visibility("_protected", FULL) == "protected"
assert get_method_visibility("public", FULL) == "public"


class TestIssueScenarios:
"""The two cases reported in issue #1."""

def test_init_no_longer_trails_a_classmethod(self) -> None:
"""__init__ can be pulled ahead of a public classmethod."""
source = "class C:\n @classmethod\n def default(cls): ...\n\n def __init__(self) -> None: ...\n"
assert method_names(sort_class(source, FULL, TYPE_ORDER)) == ["__init__", "default"]

def test_dunders_group_together(self) -> None:
"""__init__ and __str__ end up adjacent instead of split by a public method."""
source = (
"class C:\n def __init__(self) -> None: ...\n\n"
" def random_public(self): ...\n\n"
" def __str__(self) -> str: ...\n"
)
assert method_names(sort_class(source, FULL, TYPE_ORDER)) == ["__init__", "__str__", "random_public"]

def test_legacy_order_is_unchanged(self) -> None:
"""An order without dunder groups keeps magic methods in the public group."""
assert method_names(sort_class(SAMPLE, LEGACY, TYPE_ORDER)) == [
"default",
"__init__",
"public_a",
"__str__",
"_helper",
"__private",
]

def test_full_order_groups_everything(self) -> None:
"""Creational, generic dunder, public, protected and private all separate."""
assert method_names(sort_class(SAMPLE, FULL, TYPE_ORDER)) == [
"__init__",
"__str__",
"default",
"public_a",
"_helper",
"__private",
]

def test_dunders_can_be_placed_last(self) -> None:
"""The groups are positional, so dunders can also go at the end."""
order = ["public", "protected", "private", "init", "dunder"]
assert method_names(sort_class(SAMPLE, order, TYPE_ORDER))[-2:] == ["__init__", "__str__"]

def test_blank_lines_stay_sane(self) -> None:
"""Moving a dunder to the front does not collapse the spacing."""
source = "class C:\n @classmethod\n def default(cls): ...\n\n def __init__(self) -> None: ...\n"
result = sort_class(source, FULL, TYPE_ORDER)
assert (
result == "class C:\n def __init__(self) -> None: ...\n\n @classmethod\n def default(cls): ...\n"
)


class TestOrderValidation:
"""Tests for validating the extended order option."""

@pytest.mark.parametrize(
"order",
[
["init", "dunder", "public", "protected", "private"],
["dunder", "public", "protected", "private"],
["public", "protected", "private"],
["private", "protected", "public"],
],
)
def test_valid_orders_accepted(self, order: list[str], tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Orders containing all required groups are accepted."""
(tmp_path / "pyproject.toml").write_text(f"[tool.undersort]\norder = {order!r}\n".replace("'", '"'))
monkeypatch.chdir(tmp_path)
assert load_config()["order"] == order

@pytest.mark.parametrize(
"order",
[
["init", "dunder", "public"],
["public", "protected", "private", "bogus"],
["public", "public", "protected", "private"],
"notalist",
],
)
def test_invalid_orders_fall_back(self, order: object, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Incomplete, unknown, duplicated or malformed orders fall back to the default."""
rendered = repr(order).replace("'", '"')
(tmp_path / "pyproject.toml").write_text(f"[tool.undersort]\norder = {rendered}\n")
monkeypatch.chdir(tmp_path)
assert load_config()["order"] == ["public", "protected", "private"]


class TestModuleLevelDunders:
"""Dunder grouping also applies to module-level definitions."""

def test_module_dunder_grouped(self) -> None:
"""A module-level __getattr__ follows the configured dunder group."""
source = "def _helper():\n pass\n\n\ndef __getattr__(name):\n pass\n"
module = cst.parse_module(source)
new_module, modified = sort_module_definitions(module, FULL, True)
assert modified is True
assert new_module.code.index("__getattr__") < new_module.code.index("_helper")
39 changes: 36 additions & 3 deletions undersort/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
from undersort import logger
from undersort.deps import parse_python_version

VALID_VISIBILITIES = {"public", "protected", "private"}
REQUIRED_VISIBILITIES = {"public", "protected", "private"}
OPTIONAL_VISIBILITIES = {"init", "dunder"}
VALID_VISIBILITIES = REQUIRED_VISIBILITIES | OPTIONAL_VISIBILITIES
VALID_METHOD_TYPES = {"class", "static", "instance"}


Expand Down Expand Up @@ -69,8 +71,9 @@ def _load_orderings(config: dict[str, Any], result: dict[str, Any], pyproject_pa
"""
if "order" in config:
order = config["order"]
if sorted(order) != sorted(VALID_VISIBILITIES):
logger.warning(f"Invalid order values in {pyproject_path}. Using default order.")
problem = _order_problem(order)
if problem:
logger.warning(f"Invalid order in {pyproject_path}: {problem}. Using default order.")
else:
result["order"] = order

Expand All @@ -82,6 +85,36 @@ def _load_orderings(config: dict[str, Any], result: dict[str, Any], pyproject_pa
result["method_type_order"] = method_type_order


def _order_problem(order: Any) -> str | None:
"""Validate a configured visibility order.

``public``, ``protected`` and ``private`` must all appear, so no method can be
silently dropped. The dunder groups are optional: leaving them out keeps magic
methods in ``public``, which is how earlier versions behaved.

Args:
order: The configured value

Returns:
A description of the problem, or None if the order is usable
"""
if not isinstance(order, list) or not all(isinstance(value, str) for value in order):
return "must be a list of strings"

unknown = [value for value in order if value not in VALID_VISIBILITIES]
if unknown:
return f"unknown group(s) {sorted(unknown)}; valid groups are {sorted(VALID_VISIBILITIES)}"

if len(set(order)) != len(order):
return "contains duplicate groups"

missing = REQUIRED_VISIBILITIES - set(order)
if missing:
return f"missing required group(s) {sorted(missing)}"

return None


def _load_module_level_options(config: dict[str, Any], result: dict[str, Any], pyproject_path: Path) -> None:
"""Read the module-level sorting options into the result.

Expand Down
Loading
Loading