funcsort 0.2.0__py3-none-any.whl
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.
- funcsort/__init__.py +3 -0
- funcsort/config.py +158 -0
- funcsort/config_types.py +77 -0
- funcsort/groups.py +184 -0
- funcsort/logger.py +32 -0
- funcsort/main.py +171 -0
- funcsort/sorter.py +338 -0
- funcsort-0.2.0.dist-info/METADATA +384 -0
- funcsort-0.2.0.dist-info/RECORD +12 -0
- funcsort-0.2.0.dist-info/WHEEL +4 -0
- funcsort-0.2.0.dist-info/entry_points.txt +2 -0
- funcsort-0.2.0.dist-info/licenses/LICENSE +21 -0
funcsort/__init__.py
ADDED
funcsort/config.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Configuration loading for funcsort.
|
|
2
|
+
|
|
3
|
+
Configuration is read with confkit from a dedicated ``funcsort.toml`` (preferred) or,
|
|
4
|
+
as a fallback, the ``[tool.funcsort]`` table of ``pyproject.toml``. Both files use the
|
|
5
|
+
same ``[tool.funcsort]`` section and keys. The values are resolved into a single
|
|
6
|
+
immutable :class:`Settings` value object that drives the engine.
|
|
7
|
+
|
|
8
|
+
The confkit container class ``tool.funcsort`` is the single source of truth for the
|
|
9
|
+
config shape; values are read directly off it (no parallel schema definition).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from enum import StrEnum
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from .config_types import GroupTable, TomlList
|
|
21
|
+
from confkit import Config
|
|
22
|
+
|
|
23
|
+
from funcsort.groups import (
|
|
24
|
+
Group,
|
|
25
|
+
MemberKind,
|
|
26
|
+
MethodKind,
|
|
27
|
+
Scope,
|
|
28
|
+
compile_matcher,
|
|
29
|
+
default_groups,
|
|
30
|
+
)
|
|
31
|
+
from . import logger
|
|
32
|
+
|
|
33
|
+
_CONFIG_FILENAMES = ("funcsort.toml", "pyproject.toml")
|
|
34
|
+
_DEFAULT_METHOD_TYPE_ORDER = (MethodKind.INSTANCE, MethodKind.CLASS, MethodKind.STATIC)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Settings:
|
|
39
|
+
"""Resolved funcsort configuration that drives the sorter.
|
|
40
|
+
|
|
41
|
+
Attributes:
|
|
42
|
+
groups: Ordered groups; output order and membership rules for members.
|
|
43
|
+
method_type_order: Secondary ordering of method types within each group.
|
|
44
|
+
exclude: Glob patterns of files/directories to skip.
|
|
45
|
+
sort_module: Whether module-level functions are sorted.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
groups: list[Group]
|
|
49
|
+
method_type_order: list[MethodKind] = field(
|
|
50
|
+
default_factory=lambda: list(_DEFAULT_METHOD_TYPE_ORDER),
|
|
51
|
+
)
|
|
52
|
+
exclude: tuple[str, ...] = ()
|
|
53
|
+
sort_module: bool = True
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def load_settings() -> Settings:
|
|
57
|
+
"""Load and resolve configuration into a :class:`Settings`.
|
|
58
|
+
|
|
59
|
+
Falls back to built-in defaults when no config file or section is present, or when
|
|
60
|
+
the configured groups are invalid.
|
|
61
|
+
"""
|
|
62
|
+
path = find_config_file()
|
|
63
|
+
if path is None:
|
|
64
|
+
return Settings(groups=default_groups())
|
|
65
|
+
|
|
66
|
+
class _Cfg(Config[Any]): ...
|
|
67
|
+
|
|
68
|
+
_Cfg.write_on_edit = False
|
|
69
|
+
_Cfg.set_file(path)
|
|
70
|
+
|
|
71
|
+
# confkit derives the section name from the class qualname, so the nested classes
|
|
72
|
+
# must be named ``tool`` -> ``funcsort`` to address ``[tool.funcsort]``. This class
|
|
73
|
+
# is the single definition of the config shape.
|
|
74
|
+
class tool: # noqa: N801 - intentional: forms the "tool.funcsort" section path
|
|
75
|
+
class funcsort: # noqa: N801
|
|
76
|
+
method_type_order = _Cfg(TomlList([str(t) for t in _DEFAULT_METHOD_TYPE_ORDER]))
|
|
77
|
+
exclude = _Cfg(TomlList([]))
|
|
78
|
+
sort_module = _Cfg(True)
|
|
79
|
+
groups = _Cfg(GroupTable([]))
|
|
80
|
+
|
|
81
|
+
cfg = tool.funcsort
|
|
82
|
+
return Settings(
|
|
83
|
+
groups=_build_groups(cfg.groups, path) if cfg.groups else default_groups(),
|
|
84
|
+
method_type_order=_parse_method_type_order(cfg.method_type_order, path),
|
|
85
|
+
exclude=tuple(cfg.exclude),
|
|
86
|
+
sort_module=cfg.sort_module,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def find_config_file() -> Path | None:
|
|
91
|
+
"""Find ``funcsort.toml`` (preferred) or ``pyproject.toml`` in cwd or a parent."""
|
|
92
|
+
current_dir = Path.cwd()
|
|
93
|
+
for directory in [current_dir, *current_dir.parents]:
|
|
94
|
+
for filename in _CONFIG_FILENAMES:
|
|
95
|
+
candidate = directory / filename
|
|
96
|
+
if candidate.exists():
|
|
97
|
+
return candidate
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _build_groups(raw_groups: list[dict[str, Any]], path: Path) -> list[Group]:
|
|
102
|
+
"""Build groups from a user-defined ``[[tool.funcsort.groups]]`` block.
|
|
103
|
+
|
|
104
|
+
On any malformed entry, warn and fall back to the built-in default groups so a
|
|
105
|
+
broken config never silently drops members.
|
|
106
|
+
"""
|
|
107
|
+
try:
|
|
108
|
+
return [_build_group(entry) for entry in raw_groups]
|
|
109
|
+
except (KeyError, ValueError, TypeError, re.error) as exc:
|
|
110
|
+
logger.warning(f"Invalid groups in {path}: {exc}. Using default groups.")
|
|
111
|
+
return default_groups()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _build_group(entry: dict[str, Any]) -> Group:
|
|
115
|
+
"""Build a single :class:`Group` from a raw config table."""
|
|
116
|
+
name = entry["name"]
|
|
117
|
+
tokens = _as_tokens(entry["match"])
|
|
118
|
+
if not tokens:
|
|
119
|
+
msg = f"group {name!r} has an empty 'match'"
|
|
120
|
+
raise ValueError(msg)
|
|
121
|
+
decorator_tokens = _as_tokens(entry.get("decorator"))
|
|
122
|
+
decorators = tuple(compile_matcher(token) for token in decorator_tokens) if decorator_tokens else None
|
|
123
|
+
default_kinds = frozenset({MemberKind.FUNCTION})
|
|
124
|
+
return Group(
|
|
125
|
+
name=name,
|
|
126
|
+
matchers=tuple(compile_matcher(token) for token in tokens),
|
|
127
|
+
kinds=_parse_enum_set(entry.get("kind"), MemberKind, default_kinds) or default_kinds,
|
|
128
|
+
types=_parse_enum_set(entry.get("type"), MethodKind, None),
|
|
129
|
+
scopes=_parse_enum_set(entry.get("scope"), Scope, None),
|
|
130
|
+
decorators=decorators,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _as_tokens(value: Any) -> list[str]: # noqa: ANN401 - TOML scalar or list
|
|
135
|
+
"""Normalise a string-or-list config value into a list of strings."""
|
|
136
|
+
if value is None:
|
|
137
|
+
return []
|
|
138
|
+
return [value] if isinstance(value, str) else list(value)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _parse_method_type_order(raw: list[str], path: Path) -> list[MethodKind]:
|
|
142
|
+
"""Parse and validate the method type order, falling back to the default."""
|
|
143
|
+
try:
|
|
144
|
+
return [MethodKind(value) for value in raw]
|
|
145
|
+
except ValueError:
|
|
146
|
+
logger.warning(f"Invalid method_type_order values in {path}. Using default.")
|
|
147
|
+
return list(_DEFAULT_METHOD_TYPE_ORDER)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _parse_enum_set[E: StrEnum](value: Any, enum: type[E], default: frozenset[E] | None) -> frozenset[E] | None: # noqa: ANN401
|
|
151
|
+
"""Parse a string/list config value into a frozenset of enum members.
|
|
152
|
+
|
|
153
|
+
``None`` or an ``"any"`` token resolves to ``default`` (typically "no filter").
|
|
154
|
+
"""
|
|
155
|
+
if value is None:
|
|
156
|
+
return default
|
|
157
|
+
members = {enum(item) for item in _as_tokens(value) if item != "any"}
|
|
158
|
+
return frozenset(members) if members else default
|
funcsort/config_types.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Custom confkit data types for funcsort.
|
|
2
|
+
|
|
3
|
+
These subclass confkit's public :class:`~confkit.BaseDataType` so funcsort can store
|
|
4
|
+
*native* TOML arrays and array-of-tables (``[[tool.funcsort.groups]]``) instead of
|
|
5
|
+
confkit's default stringified representation.
|
|
6
|
+
|
|
7
|
+
confkit's TOML parser stores a value natively only when ``str(native) == str(value)``
|
|
8
|
+
and always hands ``convert`` the stringified form on read. By making ``__str__`` the
|
|
9
|
+
Python ``repr`` of the native value, these types round-trip cleanly: confkit writes
|
|
10
|
+
idiomatic TOML, and ``convert`` parses the repr back with :func:`ast.literal_eval`.
|
|
11
|
+
|
|
12
|
+
This module depends only on confkit's public API and never modifies the confkit package.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import ast
|
|
18
|
+
from collections.abc import Iterable
|
|
19
|
+
from typing import Any, cast, override
|
|
20
|
+
|
|
21
|
+
from confkit import BaseDataType
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TomlList(BaseDataType[list[Any]]):
|
|
25
|
+
"""A confkit value that is a native TOML array (round-trips as a Python list)."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, default: list[Any] | None = None) -> None:
|
|
28
|
+
"""Initialise with a default list (empty when omitted)."""
|
|
29
|
+
super().__init__(default if default is not None else [])
|
|
30
|
+
|
|
31
|
+
@override
|
|
32
|
+
def __str__(self) -> str:
|
|
33
|
+
"""Return a repr so confkit stores the value as a native TOML array."""
|
|
34
|
+
return repr(self.value)
|
|
35
|
+
|
|
36
|
+
@override
|
|
37
|
+
def convert(self, value: Any) -> list[Any]: # noqa: ANN401 - confkit hands us str or native
|
|
38
|
+
"""Parse a stored value back into a list, tolerating native and legacy forms."""
|
|
39
|
+
if isinstance(value, list):
|
|
40
|
+
return value
|
|
41
|
+
if not isinstance(value, str):
|
|
42
|
+
return [value]
|
|
43
|
+
try:
|
|
44
|
+
parsed = ast.literal_eval(value)
|
|
45
|
+
except (ValueError, SyntaxError):
|
|
46
|
+
return [item.strip() for item in value.split(",") if item.strip()]
|
|
47
|
+
if isinstance(parsed, Iterable) and not isinstance(parsed, (str, bytes)):
|
|
48
|
+
return list(parsed) # pyright: ignore[reportUnknownArgumentType]
|
|
49
|
+
return [parsed]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class GroupTable(BaseDataType[list[dict[str, Any]]]):
|
|
53
|
+
"""A confkit value holding a list of group tables (``[[tool.funcsort.groups]]``)."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, default: list[dict[str, Any]] | None = None) -> None:
|
|
56
|
+
"""Initialise with a default list of group dicts (empty when omitted)."""
|
|
57
|
+
super().__init__(default if default is not None else [])
|
|
58
|
+
|
|
59
|
+
@override
|
|
60
|
+
def __str__(self) -> str:
|
|
61
|
+
"""Return a repr so confkit stores the value as native TOML tables."""
|
|
62
|
+
return repr(self.value)
|
|
63
|
+
|
|
64
|
+
@override
|
|
65
|
+
def convert(self, value: Any) -> list[dict[str, Any]]: # noqa: ANN401 - str or native
|
|
66
|
+
"""Parse a stored value back into a list of group dicts."""
|
|
67
|
+
if isinstance(value, list):
|
|
68
|
+
return value
|
|
69
|
+
if not isinstance(value, str):
|
|
70
|
+
return []
|
|
71
|
+
try:
|
|
72
|
+
parsed = ast.literal_eval(value)
|
|
73
|
+
except (ValueError, SyntaxError):
|
|
74
|
+
return []
|
|
75
|
+
if not isinstance(parsed, list):
|
|
76
|
+
return []
|
|
77
|
+
return cast("list[dict[str, Any]]", parsed)
|
funcsort/groups.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Generic, configuration-driven sorting model for funcsort.
|
|
2
|
+
|
|
3
|
+
This module is the pure engine: it knows nothing about libcst or TOML. It defines
|
|
4
|
+
the value objects (:class:`Group`, :class:`Member`, :class:`Classification`) and the
|
|
5
|
+
:func:`classify` placement rule that drive the sorter, plus the built-in
|
|
6
|
+
:func:`default_groups` used when no configuration is supplied.
|
|
7
|
+
|
|
8
|
+
A :class:`Group` is matched against a member's *name* by regular expression
|
|
9
|
+
(first-match-wins down an ordered list), optionally narrowed by member kind, method
|
|
10
|
+
type, scope and decorators. Users replace the default group list via configuration to
|
|
11
|
+
take full control of what is sorted and in which order.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from enum import StrEnum
|
|
19
|
+
|
|
20
|
+
# Curated set of object/class lifecycle dunders treated as the "creational" group by
|
|
21
|
+
# default. Every other magic method (``__x__``) falls into the generic "dunder" group.
|
|
22
|
+
DEFAULT_CREATIONAL_DUNDERS: tuple[str, ...] = (
|
|
23
|
+
"__new__",
|
|
24
|
+
"__init__",
|
|
25
|
+
"__init_subclass__",
|
|
26
|
+
"__post_init__",
|
|
27
|
+
"__set_name__",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Scope(StrEnum):
|
|
32
|
+
"""Where a member lives."""
|
|
33
|
+
|
|
34
|
+
CLASS = "class"
|
|
35
|
+
MODULE = "module"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class MemberKind(StrEnum):
|
|
39
|
+
"""What kind of statement a sortable member is."""
|
|
40
|
+
|
|
41
|
+
FUNCTION = "function"
|
|
42
|
+
ASSIGNMENT = "assignment"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class MethodKind(StrEnum):
|
|
46
|
+
"""Secondary classification of a function by its binding."""
|
|
47
|
+
|
|
48
|
+
INSTANCE = "instance"
|
|
49
|
+
CLASS = "class"
|
|
50
|
+
STATIC = "static"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class Member:
|
|
55
|
+
"""A single sortable statement within a class or module body.
|
|
56
|
+
|
|
57
|
+
Attributes:
|
|
58
|
+
index: Original position within the body (used to minimise movement).
|
|
59
|
+
node: The underlying libcst node (kept opaque to this module).
|
|
60
|
+
kind: Whether the member is a function or an assignment.
|
|
61
|
+
name: The function name or the assignment's target name.
|
|
62
|
+
method_type: Binding of a function; :attr:`MethodKind.INSTANCE` for assignments.
|
|
63
|
+
scope: Whether the member lives in a class or module body.
|
|
64
|
+
decorators: Normalised, dotted decorator names applied to the member.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
index: int
|
|
68
|
+
node: object
|
|
69
|
+
kind: MemberKind
|
|
70
|
+
name: str
|
|
71
|
+
method_type: MethodKind
|
|
72
|
+
scope: Scope
|
|
73
|
+
decorators: tuple[str, ...] = ()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class Group:
|
|
78
|
+
"""An ordered bucket members are placed into, matched by name regex.
|
|
79
|
+
|
|
80
|
+
Attributes:
|
|
81
|
+
name: Human-readable identifier (used in diagnostics and bucket keys).
|
|
82
|
+
matchers: Pre-compiled patterns; a member matches if *any* one searches its name.
|
|
83
|
+
kinds: Member kinds this group accepts (defaults to functions only).
|
|
84
|
+
types: Method types this group accepts; ``None`` means any.
|
|
85
|
+
scopes: Scopes this group accepts; ``None`` means any.
|
|
86
|
+
decorators: If set, the member must carry a decorator whose name matches one of
|
|
87
|
+
these patterns; ``None`` means no decorator requirement.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
name: str
|
|
91
|
+
matchers: tuple[re.Pattern[str], ...]
|
|
92
|
+
kinds: frozenset[MemberKind] = field(default_factory=lambda: frozenset({MemberKind.FUNCTION}))
|
|
93
|
+
types: frozenset[MethodKind] | None = None
|
|
94
|
+
scopes: frozenset[Scope] | None = None
|
|
95
|
+
decorators: tuple[re.Pattern[str], ...] | None = None
|
|
96
|
+
|
|
97
|
+
def accepts(self, member: Member) -> bool:
|
|
98
|
+
"""Return whether ``member`` belongs to this group."""
|
|
99
|
+
if member.kind not in self.kinds:
|
|
100
|
+
return False
|
|
101
|
+
if self.types is not None and member.method_type not in self.types:
|
|
102
|
+
return False
|
|
103
|
+
if self.scopes is not None and member.scope not in self.scopes:
|
|
104
|
+
return False
|
|
105
|
+
if self.decorators is not None and not self._matches_decorator(member):
|
|
106
|
+
return False
|
|
107
|
+
return any(matcher.search(member.name) for matcher in self.matchers)
|
|
108
|
+
|
|
109
|
+
def targets_assignments(self) -> bool:
|
|
110
|
+
"""Return whether this group can ever match an assignment member."""
|
|
111
|
+
return MemberKind.ASSIGNMENT in self.kinds
|
|
112
|
+
|
|
113
|
+
def _matches_decorator(self, member: Member) -> bool:
|
|
114
|
+
"""Return whether any of the member's decorators match this group's filter."""
|
|
115
|
+
assert self.decorators is not None # noqa: S101 - guarded by caller
|
|
116
|
+
return any(pattern.search(name) for name in member.decorators for pattern in self.decorators)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True)
|
|
120
|
+
class Classification:
|
|
121
|
+
"""The result of placing a member into a group.
|
|
122
|
+
|
|
123
|
+
Using a value object instead of an ``Optional[str]`` keeps call sites explicit
|
|
124
|
+
and lets the placement result grow fields without churn.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
member: Member
|
|
128
|
+
group: Group | None
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def is_matched(self) -> bool:
|
|
132
|
+
"""Return whether the member was matched by any group."""
|
|
133
|
+
return self.group is not None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def exact(name: str) -> re.Pattern[str]:
|
|
137
|
+
"""Compile a matcher that matches ``name`` exactly (whole string)."""
|
|
138
|
+
return re.compile(r"\A" + re.escape(name) + r"\Z")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def compile_matcher(token: str) -> re.Pattern[str]:
|
|
142
|
+
"""Compile a configured ``match``/``decorator`` token.
|
|
143
|
+
|
|
144
|
+
A bare Python identifier is treated as an exact-name match; anything else is a regex.
|
|
145
|
+
"""
|
|
146
|
+
if token.isidentifier():
|
|
147
|
+
return exact(token)
|
|
148
|
+
return re.compile(token)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def default_groups(creational_dunders: tuple[str, ...] | list[str] = DEFAULT_CREATIONAL_DUNDERS) -> list[Group]:
|
|
152
|
+
"""Return the built-in groups, in output order.
|
|
153
|
+
|
|
154
|
+
The matchers are deliberately *mutually exclusive* so membership is independent of
|
|
155
|
+
the groups' order (only the output order changes when a user reorders them):
|
|
156
|
+
creational (exact names) -> dunder (other ``__x__``) -> public (no leading
|
|
157
|
+
underscore) -> protected (single leading underscore) -> private (leading ``__``,
|
|
158
|
+
non-magic).
|
|
159
|
+
"""
|
|
160
|
+
return [
|
|
161
|
+
Group("creational", tuple(exact(name) for name in creational_dunders)),
|
|
162
|
+
Group("dunder", (_dunder_matcher(creational_dunders),)),
|
|
163
|
+
Group("public", (re.compile(r"^[^_]"),)),
|
|
164
|
+
Group("protected", (re.compile(r"^_([^_]|$)"),)),
|
|
165
|
+
# Leading ``__`` that is not a magic method. Excludes exactly the dunder pattern
|
|
166
|
+
# so creational/dunder/private together cover every ``__``-prefixed name.
|
|
167
|
+
Group("private", (re.compile(r"^(?!__.+__$)__"),)),
|
|
168
|
+
]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def classify(member: Member, groups: list[Group]) -> Classification:
|
|
172
|
+
"""Place ``member`` into the first group that accepts it (first-match-wins)."""
|
|
173
|
+
for group in groups:
|
|
174
|
+
if group.accepts(member):
|
|
175
|
+
return Classification(member, group)
|
|
176
|
+
return Classification(member, None)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _dunder_matcher(creational_dunders: tuple[str, ...] | list[str]) -> re.Pattern[str]:
|
|
180
|
+
"""Match any magic method (``__x__``) that is not one of the creational names."""
|
|
181
|
+
if not creational_dunders:
|
|
182
|
+
return re.compile(r"^__.+__$")
|
|
183
|
+
alternation = "|".join(re.escape(name) for name in creational_dunders)
|
|
184
|
+
return re.compile(rf"^(?!(?:{alternation})$)__.+__$")
|
funcsort/logger.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Logging configuration for funcsort."""
|
|
2
|
+
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from rich.syntax import Syntax
|
|
5
|
+
|
|
6
|
+
console = Console()
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def info(message: str) -> None:
|
|
10
|
+
"""Log an info message."""
|
|
11
|
+
console.print(f"[blue][INFO][/blue] {message}")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def success(message: str) -> None:
|
|
15
|
+
"""Log a success message."""
|
|
16
|
+
console.print(f"[green][OK][/green] {message}")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def warning(message: str) -> None:
|
|
20
|
+
"""Log a warning message."""
|
|
21
|
+
console.print(f"[yellow][WARNING][/yellow] {message}", highlight=False)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def error(message: str) -> None:
|
|
25
|
+
"""Log an error message."""
|
|
26
|
+
console.print(f"[red][ERROR][/red] {message}", highlight=False)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def diff(content: str) -> None:
|
|
30
|
+
"""Print a diff with syntax highlighting."""
|
|
31
|
+
syntax = Syntax(content, "diff", theme="monokai", line_numbers=False)
|
|
32
|
+
console.print(syntax)
|
funcsort/main.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Main entry point for funcsort CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fnmatch
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from herogold.argparse import Actions, Argument, parser
|
|
10
|
+
|
|
11
|
+
from funcsort.config import Settings, load_settings
|
|
12
|
+
from funcsort.sorter import sort_file
|
|
13
|
+
|
|
14
|
+
from . import logger
|
|
15
|
+
|
|
16
|
+
_EXCLUDE_DIRS = {"venv", "__pycache__", "node_modules"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _Cli:
|
|
20
|
+
"""Declarative CLI flags; defining them registers the options on the shared parser."""
|
|
21
|
+
|
|
22
|
+
check = Argument("check", action=Actions.STORE_BOOL, default=False, help="Check without modifying files")
|
|
23
|
+
diff = Argument("diff", action=Actions.STORE_BOOL, default=False, help="Show a diff of the changes")
|
|
24
|
+
recursive = Argument("recursive", action=Actions.STORE_BOOL, default=True, help="Recurse into directories")
|
|
25
|
+
sort_module = Argument("sort-module", action=Actions.STORE_BOOL, default=True, help="Sort module-level functions")
|
|
26
|
+
exclude = Argument[list[str]](
|
|
27
|
+
"exclude",
|
|
28
|
+
action=Actions.APPEND,
|
|
29
|
+
default=[],
|
|
30
|
+
help="Exclude files/dirs matching a glob pattern",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# Defining ``_Cli`` registers the Argument descriptors on the shared parser via
|
|
35
|
+
# ``__set_name__``; the class itself is not needed afterwards.
|
|
36
|
+
del _Cli
|
|
37
|
+
|
|
38
|
+
# STORE_BOOL registers a --flag/--no-flag pair without an explicit default, so pin the
|
|
39
|
+
# real defaults here. sort_module defaults to None so the config value wins unless the
|
|
40
|
+
# user passes the flag explicitly.
|
|
41
|
+
parser.description = "Sort class methods and module-level functions into configurable groups"
|
|
42
|
+
parser.set_defaults(check=False, diff=False, recursive=True, sort_module=None)
|
|
43
|
+
parser.add_argument("paths", nargs="+", type=Path, help="Python files or directories to sort")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def collect_python_files(path: Path, recursive: bool = True, exclude_patterns: list[str] | None = None) -> list[Path]:
|
|
47
|
+
"""Collect Python files from a file or directory path.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
path: File or directory to search.
|
|
51
|
+
recursive: Whether to descend into subdirectories.
|
|
52
|
+
exclude_patterns: Glob patterns to skip.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
Sorted list of matching ``.py`` file paths.
|
|
56
|
+
"""
|
|
57
|
+
if path.is_file():
|
|
58
|
+
return [path] if path.suffix == ".py" else []
|
|
59
|
+
|
|
60
|
+
if not path.is_dir():
|
|
61
|
+
return []
|
|
62
|
+
|
|
63
|
+
pattern = "**/*.py" if recursive else "*.py"
|
|
64
|
+
files = [
|
|
65
|
+
f
|
|
66
|
+
for f in path.glob(pattern)
|
|
67
|
+
if not any(part in _EXCLUDE_DIRS or (part.startswith(".") and part != ".") for part in f.parts)
|
|
68
|
+
]
|
|
69
|
+
if exclude_patterns:
|
|
70
|
+
files = [f for f in files if not _matches_any_pattern(f, exclude_patterns)]
|
|
71
|
+
return sorted(files)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def main() -> int:
|
|
75
|
+
"""Run the funcsort CLI."""
|
|
76
|
+
args = parser.parse_args()
|
|
77
|
+
settings = load_settings()
|
|
78
|
+
|
|
79
|
+
sort_module = settings.sort_module if args.sort_module is None else args.sort_module
|
|
80
|
+
exclude_patterns = _resolve_exclude(settings, args.exclude)
|
|
81
|
+
|
|
82
|
+
all_files: list[Path] = []
|
|
83
|
+
for path in args.paths:
|
|
84
|
+
if not path.exists():
|
|
85
|
+
logger.error(f"Path not found: {path}")
|
|
86
|
+
continue
|
|
87
|
+
all_files.extend(collect_python_files(path, args.recursive, exclude_patterns))
|
|
88
|
+
|
|
89
|
+
if not all_files:
|
|
90
|
+
logger.warning("No Python files found")
|
|
91
|
+
return 0
|
|
92
|
+
|
|
93
|
+
modified_files: list[Path] = []
|
|
94
|
+
unmatched_names: set[str] = set()
|
|
95
|
+
errors = False
|
|
96
|
+
for file_path in all_files:
|
|
97
|
+
try:
|
|
98
|
+
result = sort_file(
|
|
99
|
+
file_path,
|
|
100
|
+
groups=settings.groups,
|
|
101
|
+
method_type_order=settings.method_type_order,
|
|
102
|
+
sort_module=sort_module,
|
|
103
|
+
check_only=args.check,
|
|
104
|
+
show_diff=args.diff,
|
|
105
|
+
)
|
|
106
|
+
except Exception as e: # noqa: BLE001 - report and continue across files
|
|
107
|
+
logger.error(f"Error processing {file_path}: {e}")
|
|
108
|
+
errors = True
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
unmatched_names.update(member.name for member in result.unmatched)
|
|
112
|
+
if not result.modified:
|
|
113
|
+
continue
|
|
114
|
+
modified_files.append(file_path)
|
|
115
|
+
if not args.check:
|
|
116
|
+
logger.success(f"Sorted {file_path}")
|
|
117
|
+
|
|
118
|
+
_report_unmatched(unmatched_names)
|
|
119
|
+
return _report(modified_files, errors, check_only=args.check)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _matches_any_pattern(file_path: Path, patterns: list[str]) -> bool:
|
|
123
|
+
"""Return whether ``file_path`` matches any of the glob ``patterns``."""
|
|
124
|
+
path_str = str(file_path)
|
|
125
|
+
for pattern in patterns:
|
|
126
|
+
if fnmatch.fnmatch(path_str, pattern):
|
|
127
|
+
return True
|
|
128
|
+
if "/" not in pattern:
|
|
129
|
+
if fnmatch.fnmatch(file_path.name, pattern):
|
|
130
|
+
return True
|
|
131
|
+
continue
|
|
132
|
+
if fnmatch.fnmatch(path_str, f"*/{pattern}"):
|
|
133
|
+
return True
|
|
134
|
+
for part_idx in range(len(file_path.parts)):
|
|
135
|
+
subpath = str(Path(*file_path.parts[part_idx:]))
|
|
136
|
+
if fnmatch.fnmatch(subpath, pattern):
|
|
137
|
+
return True
|
|
138
|
+
return False
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _resolve_exclude(settings: Settings, cli_exclude: list[str] | None) -> list[str] | None:
|
|
142
|
+
"""Merge config and CLI exclusion patterns."""
|
|
143
|
+
patterns = [*settings.exclude, *(cli_exclude or [])]
|
|
144
|
+
return patterns or None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _report_unmatched(unmatched_names: set[str]) -> None:
|
|
148
|
+
"""Warn about members that matched no group and were moved to the end."""
|
|
149
|
+
if unmatched_names:
|
|
150
|
+
names = ", ".join(sorted(unmatched_names))
|
|
151
|
+
logger.warning(f"Members matched no group and were moved to the end: {names}. Configure a group for them.")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _report(modified_files: list[Path], errors: bool, *, check_only: bool) -> int:
|
|
155
|
+
"""Emit a summary and return the process exit code."""
|
|
156
|
+
if check_only and modified_files:
|
|
157
|
+
logger.warning(f"Files that need sorting: {len(modified_files)}")
|
|
158
|
+
for f in modified_files:
|
|
159
|
+
logger.console.print(f" - {f}")
|
|
160
|
+
return 1
|
|
161
|
+
|
|
162
|
+
if not modified_files and not errors:
|
|
163
|
+
logger.info("All files are already sorted correctly")
|
|
164
|
+
elif modified_files and not check_only:
|
|
165
|
+
logger.success(f"Sorted {len(modified_files)} file(s) successfully")
|
|
166
|
+
|
|
167
|
+
return 1 if errors else 0
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
if __name__ == "__main__":
|
|
171
|
+
sys.exit(main())
|