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 ADDED
@@ -0,0 +1,3 @@
1
+ """funcsort - Sort class methods and module-level functions into configurable groups."""
2
+
3
+ __version__ = "0.2.0"
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
@@ -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())