undersort 0.1.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.
undersort/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """undersort - Sort class methods by visibility."""
2
+
3
+ __version__ = "0.1.0"
undersort/config.py ADDED
@@ -0,0 +1,67 @@
1
+ """Configuration loading for undersort."""
2
+
3
+ import tomllib
4
+ from pathlib import Path
5
+
6
+ from undersort import logger
7
+
8
+
9
+ def load_config() -> dict[str, list[str] | None]:
10
+ """Load configuration from pyproject.toml.
11
+
12
+ Returns:
13
+ Dictionary with 'order' and 'method_type_order' keys.
14
+ """
15
+ default_config: dict[str, list[str] | None] = {
16
+ "order": ["public", "protected", "private"],
17
+ "method_type_order": None,
18
+ }
19
+
20
+ pyproject_path = _find_pyproject_toml()
21
+ if not pyproject_path:
22
+ return default_config
23
+
24
+ try:
25
+ with open(pyproject_path, "rb") as f:
26
+ data = tomllib.load(f)
27
+ except Exception as e:
28
+ logger.warning(f"Could not load {pyproject_path}: {e}")
29
+ return default_config
30
+
31
+ if "tool" not in data or "undersort" not in data["tool"]:
32
+ return default_config
33
+
34
+ config = data["tool"]["undersort"]
35
+ result: dict[str, list[str] | None] = default_config.copy()
36
+
37
+ if "order" in config:
38
+ order = config["order"]
39
+ valid_visibility_values = {"public", "protected", "private"}
40
+ if not all(v in valid_visibility_values for v in order):
41
+ logger.warning(f"Invalid order values in {pyproject_path}. Using default order.")
42
+ else:
43
+ result["order"] = order
44
+
45
+ if "method_type_order" in config:
46
+ method_type_order = config["method_type_order"]
47
+ valid_method_types = {"class", "static", "instance"}
48
+ if not all(v in valid_method_types for v in method_type_order):
49
+ logger.warning(f"Invalid method_type_order values in {pyproject_path}. Using default.")
50
+ else:
51
+ result["method_type_order"] = method_type_order
52
+
53
+ return result
54
+
55
+
56
+ def _find_pyproject_toml() -> Path | None:
57
+ """Find pyproject.toml in current or parent directories.
58
+
59
+ Returns:
60
+ Path to pyproject.toml if found, None otherwise.
61
+ """
62
+ current_dir = Path.cwd()
63
+ for directory in [current_dir, *current_dir.parents]:
64
+ pyproject_path = directory / "pyproject.toml"
65
+ if pyproject_path.exists():
66
+ return pyproject_path
67
+ return None
undersort/logger.py ADDED
@@ -0,0 +1,32 @@
1
+ """Logging configuration for undersort."""
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)
undersort/main.py ADDED
@@ -0,0 +1,130 @@
1
+ """Main entry point for undersort CLI."""
2
+
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from undersort import logger
8
+ from undersort.config import load_config
9
+ from undersort.sorter import sort_file
10
+
11
+
12
+ def collect_python_files(path: Path, recursive: bool = True) -> list[Path]:
13
+ """Collect Python files from a path (file or directory).
14
+
15
+ Args:
16
+ path: File or directory path
17
+ recursive: If True, recursively search directories
18
+
19
+ Returns:
20
+ List of Python file paths
21
+ """
22
+ # Common directories to exclude
23
+ exclude_dirs = {
24
+ ".venv",
25
+ "venv",
26
+ ".git",
27
+ "__pycache__",
28
+ ".pytest_cache",
29
+ ".mypy_cache",
30
+ ".ruff_cache",
31
+ "node_modules",
32
+ }
33
+
34
+ if path.is_file():
35
+ return [path] if path.suffix == ".py" else []
36
+
37
+ if path.is_dir():
38
+ pattern = "**/*.py" if recursive else "*.py"
39
+ all_files = path.glob(pattern)
40
+ filtered_files = [f for f in all_files if not any(excluded in f.parts for excluded in exclude_dirs)]
41
+
42
+ return sorted(filtered_files)
43
+
44
+ return []
45
+
46
+
47
+ def main() -> int:
48
+ """Main entry point for undersort."""
49
+ parser = argparse.ArgumentParser(description="Sort class methods by visibility (public, protected, private)")
50
+ parser.add_argument(
51
+ "paths",
52
+ nargs="+",
53
+ type=Path,
54
+ help="Python files or directories to sort",
55
+ )
56
+ parser.add_argument(
57
+ "--check",
58
+ action="store_true",
59
+ help="Check if files need sorting without modifying them",
60
+ )
61
+ parser.add_argument(
62
+ "--diff",
63
+ action="store_true",
64
+ help="Show diff of changes",
65
+ )
66
+ parser.add_argument(
67
+ "--no-recursive",
68
+ dest="recursive",
69
+ action="store_false",
70
+ default=True,
71
+ help="Don't recursively search directories",
72
+ )
73
+
74
+ args = parser.parse_args()
75
+
76
+ config = load_config()
77
+
78
+ all_files: list[Path] = []
79
+ for path in args.paths:
80
+ if not path.exists():
81
+ logger.error(f"Path not found: {path}")
82
+ continue
83
+
84
+ python_files = collect_python_files(path, args.recursive)
85
+ all_files.extend(python_files)
86
+
87
+ if not all_files:
88
+ logger.warning("No Python files found")
89
+ return 0
90
+
91
+ modified_files: list[Path] = []
92
+ errors = False
93
+
94
+ for file_path in all_files:
95
+ try:
96
+ was_modified = sort_file(
97
+ file_path,
98
+ config["order"],
99
+ method_type_order=config.get("method_type_order"),
100
+ check_only=args.check,
101
+ show_diff=args.diff,
102
+ )
103
+
104
+ if not was_modified:
105
+ continue
106
+
107
+ modified_files.append(file_path)
108
+ if not args.check:
109
+ logger.success(f"Sorted {file_path}")
110
+
111
+ except Exception as e:
112
+ logger.error(f"Error processing {file_path}: {e}")
113
+ errors = True
114
+
115
+ if args.check and modified_files:
116
+ logger.warning(f"Files that need sorting: {len(modified_files)}")
117
+ for f in modified_files:
118
+ logger.console.print(f" - {f}")
119
+ return 1
120
+
121
+ if not modified_files and not errors:
122
+ logger.info("All files are already sorted correctly")
123
+ elif modified_files and not args.check:
124
+ logger.success(f"Sorted {len(modified_files)} file(s) successfully")
125
+
126
+ return 1 if errors else 0
127
+
128
+
129
+ if __name__ == "__main__":
130
+ sys.exit(main())
undersort/sorter.py ADDED
@@ -0,0 +1,179 @@
1
+ """Core sorting logic for class methods."""
2
+
3
+ import difflib
4
+ from pathlib import Path
5
+
6
+ import libcst as cst
7
+
8
+ from undersort import logger
9
+
10
+
11
+ def get_method_visibility(method_name: str) -> str:
12
+ """Determine method visibility based on naming convention.
13
+
14
+ Args:
15
+ method_name: The name of the method
16
+
17
+ Returns:
18
+ 'public' for method (no underscore prefix) or magic methods (__method__),
19
+ 'protected' for _method (single underscore),
20
+ 'private' for __method (dunder prefix, not magic method)
21
+ """
22
+ if method_name.startswith("__") and method_name.endswith("__"):
23
+ return "public"
24
+
25
+ if method_name.startswith("__"):
26
+ return "private"
27
+
28
+ if method_name.startswith("_"):
29
+ return "protected"
30
+
31
+ return "public"
32
+
33
+
34
+ def get_method_type(method: cst.FunctionDef) -> str:
35
+ """Determine method type based on decorators.
36
+
37
+ Args:
38
+ method: The FunctionDef node
39
+
40
+ Returns:
41
+ 'class' for @classmethod,
42
+ 'static' for @staticmethod,
43
+ 'instance' for regular instance methods (default)
44
+ """
45
+ for decorator in method.decorators:
46
+ decorator_name = decorator.decorator
47
+ if isinstance(decorator_name, cst.Name):
48
+ if decorator_name.value == "classmethod":
49
+ return "class"
50
+ if decorator_name.value == "staticmethod":
51
+ return "static"
52
+ return "instance"
53
+
54
+
55
+ class MethodSorter(cst.CSTTransformer):
56
+ """Transformer to sort class methods by visibility and type."""
57
+
58
+ def __init__(self, order: list[str], method_type_order: list[str] | None = None):
59
+ """Initialize the transformer.
60
+
61
+ Args:
62
+ order: List specifying the desired order of visibility levels
63
+ (e.g., ["public", "protected", "private"])
64
+ method_type_order: Optional list specifying the order of method types
65
+ within each visibility level
66
+ (e.g., ["class", "static", "instance"])
67
+ """
68
+ self.order = order
69
+ self.method_type_order = method_type_order or ["class", "static", "instance"]
70
+ self.modified = False
71
+
72
+ def leave_ClassDef(self, original_node: cst.ClassDef, updated_node: cst.ClassDef) -> cst.ClassDef:
73
+ """Sort methods within a class definition.
74
+
75
+ Args:
76
+ original_node: Original class definition node
77
+ updated_node: Updated class definition node
78
+
79
+ Returns:
80
+ ClassDef with sorted methods
81
+ """
82
+ methods = []
83
+ non_methods = []
84
+
85
+ for item in updated_node.body.body:
86
+ if isinstance(item, cst.FunctionDef):
87
+ methods.append(item)
88
+ else:
89
+ non_methods.append(item)
90
+
91
+ if not methods:
92
+ return updated_node
93
+
94
+ method_groups: dict[str, dict[str, list[cst.FunctionDef]]] = {
95
+ "public": {"class": [], "static": [], "instance": []},
96
+ "protected": {"class": [], "static": [], "instance": []},
97
+ "private": {"class": [], "static": [], "instance": []},
98
+ }
99
+
100
+ for method in methods:
101
+ visibility = get_method_visibility(method.name.value)
102
+ method_type = get_method_type(method)
103
+ method_groups[visibility][method_type].append(method)
104
+
105
+ sorted_methods = []
106
+ for visibility in self.order:
107
+ for method_type in self.method_type_order:
108
+ sorted_methods.extend(method_groups[visibility][method_type])
109
+
110
+ if methods != sorted_methods:
111
+ self.modified = True
112
+
113
+ leading_non_methods = []
114
+ trailing_non_methods = []
115
+ found_method = False
116
+ original_items = list(updated_node.body.body)
117
+
118
+ for item in original_items:
119
+ if isinstance(item, cst.FunctionDef):
120
+ found_method = True
121
+ elif not found_method:
122
+ leading_non_methods.append(item)
123
+ else:
124
+ trailing_non_methods.append(item)
125
+
126
+ new_body = leading_non_methods + sorted_methods + trailing_non_methods
127
+
128
+ return updated_node.with_changes(body=updated_node.body.with_changes(body=new_body))
129
+
130
+
131
+ def sort_file(
132
+ file_path: Path,
133
+ order: list[str],
134
+ method_type_order: list[str] | None = None,
135
+ check_only: bool = False,
136
+ show_diff: bool = False,
137
+ ) -> bool:
138
+ """Sort methods in a Python file.
139
+
140
+ Args:
141
+ file_path: Path to the Python file
142
+ order: Method visibility ordering configuration
143
+ method_type_order: Optional method type ordering within each visibility level
144
+ check_only: If True, only check if file needs sorting
145
+ show_diff: If True, show diff of changes
146
+
147
+ Returns:
148
+ True if file was modified (or needs modification in check mode)
149
+ """
150
+ with open(file_path, encoding="utf-8") as f:
151
+ source_code = f.read()
152
+
153
+ try:
154
+ tree = cst.parse_module(source_code)
155
+ except cst.ParserSyntaxError as e:
156
+ raise ValueError(f"Syntax error in {file_path}: {e}")
157
+
158
+ sorter = MethodSorter(order, method_type_order)
159
+ new_tree = tree.visit(sorter)
160
+
161
+ if not sorter.modified:
162
+ return False
163
+
164
+ new_code = new_tree.code
165
+
166
+ if show_diff:
167
+ diff = difflib.unified_diff(
168
+ source_code.splitlines(keepends=True),
169
+ new_code.splitlines(keepends=True),
170
+ fromfile=str(file_path),
171
+ tofile=str(file_path),
172
+ )
173
+ logger.diff("".join(diff))
174
+
175
+ if not check_only:
176
+ with open(file_path, "w", encoding="utf-8") as f:
177
+ f.write(new_code)
178
+
179
+ return True
@@ -0,0 +1,241 @@
1
+ Metadata-Version: 2.4
2
+ Name: undersort
3
+ Version: 0.1.0
4
+ Summary: A tool to sort class methods by visibility (public, protected, private)
5
+ Project-URL: Homepage, https://github.com/kivicode/undersort
6
+ Project-URL: Repository, https://github.com/kivicode/undersort
7
+ Project-URL: Issues, https://github.com/kivicode/undersort/issues
8
+ Author-email: KiviCode <kivicode.dev@gmail.com>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: class-methods,code-formatter,linter,python,sorting
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
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
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Classifier: Topic :: Software Development :: Quality Assurance
23
+ Requires-Python: >=3.8
24
+ Requires-Dist: libcst>=1.1.0
25
+ Requires-Dist: rich>=13.0.0
26
+ Description-Content-Type: text/markdown
27
+
28
+ # undersort
29
+
30
+ A Python tool that automatically sorts class methods by visibility (public, protected, private) and type (class, static, instance).
31
+
32
+ ## Features
33
+
34
+ - Automatically reorders class methods based on visibility and method type
35
+ - Two-level sorting: primary by visibility, secondary by method type
36
+ - Fully configurable ordering via `pyproject.toml`
37
+ - Pre-commit hook integration
38
+ - Colored output for better readability
39
+ - Check mode for CI/CD validation
40
+ - Diff mode to preview changes
41
+
42
+ ## Installation
43
+
44
+ ```bash
45
+ # Using uv (recommended)
46
+ uv pip install -e .
47
+
48
+ # Using pip
49
+ pip install -e .
50
+ ```
51
+
52
+ ## Configuration
53
+
54
+ Configure the method ordering in your `pyproject.toml`:
55
+
56
+ ```toml
57
+ [tool.undersort]
58
+ # Method visibility ordering (primary sort)
59
+ # Options: "public", "protected", "private"
60
+ order = ["public", "protected", "private"]
61
+
62
+ # Method type ordering within each visibility level (secondary sort, optional)
63
+ # Options: "class" (classmethod), "static" (staticmethod), "instance" (regular methods)
64
+ # Default: ["class", "static", "instance"]
65
+ method_type_order = ["class", "static", "instance"]
66
+ ```
67
+
68
+ ### Method Visibility Rules
69
+
70
+ - **Public methods**: No underscore prefix (e.g., `def method()`) or magic methods (e.g., `__init__`, `__str__`)
71
+ - **Protected methods**: Single underscore prefix (e.g., `def _method()`)
72
+ - **Private methods**: Double underscore prefix, not magic (e.g., `def __method()`)
73
+
74
+ ### Method Type Rules
75
+
76
+ - **Class methods**: Decorated with `@classmethod`
77
+ - **Static methods**: Decorated with `@staticmethod`
78
+ - **Instance methods**: Regular methods (no special decorator)
79
+
80
+ ### Sorting Behavior
81
+
82
+ Methods are sorted in two levels:
83
+
84
+ 1. **Primary**: By visibility (public → protected → private)
85
+ 2. **Secondary**: Within each visibility level, by method type (class → static → instance by default)
86
+
87
+ Example: With default configuration, the order would be:
88
+
89
+ 1. Public class methods
90
+ 2. Public static methods
91
+ 3. Public instance methods
92
+ 4. Protected class methods
93
+ 5. Protected static methods
94
+ 6. Protected instance methods
95
+ 7. Private class methods
96
+ 8. Private static methods
97
+ 9. Private instance methods
98
+
99
+ ## Usage
100
+
101
+ ### Command Line
102
+
103
+ ```bash
104
+ # Sort a single file
105
+ undersort example.py
106
+
107
+ # Sort multiple files
108
+ undersort file1.py file2.py file3.py
109
+
110
+ # Sort all Python files in a directory (recursive by default)
111
+ undersort src/
112
+
113
+ # Sort all Python files in current directory and subdirectories
114
+ undersort .
115
+
116
+ # Non-recursive directory sorting (only files in the directory, not subdirectories)
117
+ undersort src/ --no-recursive
118
+
119
+ # Wildcards work too (expanded by shell)
120
+ undersort *.py
121
+ undersort src/**/*.py
122
+
123
+ # Check if files need sorting (useful for CI)
124
+ undersort --check example.py
125
+ undersort --check src/
126
+
127
+ # Show diff of changes
128
+ undersort --diff example.py
129
+
130
+ # Combine flags
131
+ undersort --check --diff src/
132
+ ```
133
+
134
+ **Note**: By default, undersort excludes common directories like `.venv`, `.git`, `__pycache__`, `.pytest_cache`, `.mypy_cache`, `.ruff_cache`, and `node_modules` when scanning directories.
135
+
136
+ ### Pre-commit Integration
137
+
138
+ Add to your `.pre-commit-config.yaml`:
139
+
140
+ ```yaml
141
+ repos:
142
+ - repo: local
143
+ hooks:
144
+ - id: undersort
145
+ name: undersort
146
+ entry: uv run undersort
147
+ language: system
148
+ types: [python]
149
+ ```
150
+
151
+ Then install the hook:
152
+
153
+ ```bash
154
+ pip install pre-commit
155
+ pre-commit install
156
+ ```
157
+
158
+ ## Example
159
+
160
+ ### Before
161
+
162
+ ```python
163
+ class Example:
164
+ def _protected_instance(self):
165
+ pass
166
+
167
+ @staticmethod
168
+ def public_static():
169
+ pass
170
+
171
+ def __init__(self):
172
+ pass
173
+
174
+ @classmethod
175
+ def _protected_class(cls):
176
+ pass
177
+
178
+ def public_instance(self):
179
+ pass
180
+
181
+ def __private_method(self):
182
+ pass
183
+
184
+ @classmethod
185
+ def public_class(cls):
186
+ pass
187
+ ```
188
+
189
+ ### After (with default config)
190
+
191
+ ```python
192
+ class Example:
193
+ @classmethod
194
+ def public_class(cls):
195
+ pass
196
+
197
+ @staticmethod
198
+ def public_static():
199
+ pass
200
+
201
+ def __init__(self):
202
+ pass
203
+
204
+ def public_instance(self):
205
+ pass
206
+
207
+ @classmethod
208
+ def _protected_class(cls):
209
+ pass
210
+
211
+ def _protected_instance(self):
212
+ pass
213
+
214
+ def __private_method(self):
215
+ pass
216
+ ```
217
+
218
+ The methods are now organized by:
219
+
220
+ 1. **Visibility**: public (including `__init__`) → protected → private
221
+ 2. **Type** (within each visibility): class → static → instance
222
+
223
+ ## Development
224
+
225
+ ```bash
226
+ # Install dependencies
227
+ uv sync
228
+
229
+ # Run on example file
230
+ uv run undersort example.py
231
+
232
+ # Test with check mode
233
+ uv run undersort --check example.py
234
+
235
+ # View diff
236
+ uv run undersort --diff example.py
237
+ ```
238
+
239
+ ## License
240
+
241
+ MIT
@@ -0,0 +1,10 @@
1
+ undersort/__init__.py,sha256=CkdkH89paBK7UDcOe3qKMCmrdffwFLXlBOjRy6O5PUI,75
2
+ undersort/config.py,sha256=k4B0Nwn96bZI0IdgXr44B81VL1KwA9Kzky8zX18Tduc,2124
3
+ undersort/logger.py,sha256=KYBTO3jzT-0trh3mzumWGhEDxBTrEb-TXbDX__q716E,832
4
+ undersort/main.py,sha256=5uNQhc0uw2apT690AJaeiiN2Gpgx6GHE77X8k1lz4Xk,3457
5
+ undersort/sorter.py,sha256=7J8tAQZo24ax7FYi6mwIEK9Ba4cZ1I5jmMWm-RHtnhg,5598
6
+ undersort-0.1.0.dist-info/METADATA,sha256=rRmI4tP1nTCAbCIfEGujFMl6Gl9lfvRnSxKRiF9GPWI,5719
7
+ undersort-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
8
+ undersort-0.1.0.dist-info/entry_points.txt,sha256=CwO819_Zbd6xM-I557Ztr0qAfgTvBFFVajD69BZn_QQ,50
9
+ undersort-0.1.0.dist-info/licenses/LICENSE,sha256=xwugI0v9V_GNs5ZwtKo57TI0qByufJy6P9lHtB88nm4,1075
10
+ undersort-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ undersort = undersort.main:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Vladimir Kirilenko
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.