undersort 0.1.2__tar.gz → 0.1.4__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: undersort
3
- Version: 0.1.2
3
+ Version: 0.1.4
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
@@ -43,10 +43,15 @@ A Python tool that automatically sorts class methods by visibility (public, prot
43
43
 
44
44
  ```bash
45
45
  # Using uv (recommended)
46
- uv pip install -e .
46
+ uv add undersort
47
47
 
48
48
  # Using pip
49
- pip install -e .
49
+ pip install undersort
50
+
51
+ # For development
52
+ git clone https://github.com/kivicode/undersort
53
+ cd undersort
54
+ uv sync
50
55
  ```
51
56
 
52
57
  ## Configuration
@@ -85,6 +90,7 @@ Methods are sorted in two levels:
85
90
  2. **Secondary**: Within each visibility level, by method type (instance → class → static by default)
86
91
 
87
92
  The sorting algorithm **minimizes movement** to preserve the original order as much as possible:
93
+
88
94
  - Methods that need to move DOWN (to a later section) are placed at the **beginning** of their target section
89
95
  - Methods that need to move UP (to an earlier section) are placed at the **end** of their target section
90
96
  - Methods already in the correct section maintain their relative order
@@ -101,6 +107,51 @@ Example order with default configuration:
101
107
  8. Private class methods
102
108
  9. Private static methods
103
109
 
110
+ ### Skipping Sorting with `# nosort`
111
+
112
+ You can prevent sorting at different levels using `# nosort` comments (case-insensitive):
113
+
114
+ **File-level**: Skip entire file
115
+
116
+ ```python
117
+ # nosort: file
118
+ class Example:
119
+ def _protected(self):
120
+ pass
121
+ def public(self):
122
+ pass # File won't be sorted
123
+ ```
124
+
125
+ **Class-level**: Skip specific class
126
+
127
+ ```python
128
+ class Example: # nosort
129
+ def _protected(self):
130
+ pass
131
+ def public(self):
132
+ pass # This class won't be sorted
133
+
134
+ class Other:
135
+ def _protected(self):
136
+ pass
137
+ def public(self):
138
+ pass # This class WILL be sorted
139
+ ```
140
+
141
+ **Method-level**: Keep method in its current position
142
+
143
+ ```python
144
+ class Example:
145
+ def public_a(self):
146
+ pass
147
+
148
+ def _protected(self): # nosort
149
+ pass # Stays here, between public methods
150
+
151
+ def public_b(self):
152
+ pass # Will move up, but _protected stays in place
153
+ ```
154
+
104
155
  ## Usage
105
156
 
106
157
  ### Command Line
@@ -148,9 +199,10 @@ repos:
148
199
  hooks:
149
200
  - id: undersort
150
201
  name: undersort
151
- entry: uv run undersort
152
- language: system
202
+ entry: undersort
203
+ language: python
153
204
  types: [python]
205
+ additional_dependencies: ["undersort"]
154
206
  ```
155
207
 
156
208
  Then install the hook:
@@ -16,10 +16,15 @@ A Python tool that automatically sorts class methods by visibility (public, prot
16
16
 
17
17
  ```bash
18
18
  # Using uv (recommended)
19
- uv pip install -e .
19
+ uv add undersort
20
20
 
21
21
  # Using pip
22
- pip install -e .
22
+ pip install undersort
23
+
24
+ # For development
25
+ git clone https://github.com/kivicode/undersort
26
+ cd undersort
27
+ uv sync
23
28
  ```
24
29
 
25
30
  ## Configuration
@@ -58,6 +63,7 @@ Methods are sorted in two levels:
58
63
  2. **Secondary**: Within each visibility level, by method type (instance → class → static by default)
59
64
 
60
65
  The sorting algorithm **minimizes movement** to preserve the original order as much as possible:
66
+
61
67
  - Methods that need to move DOWN (to a later section) are placed at the **beginning** of their target section
62
68
  - Methods that need to move UP (to an earlier section) are placed at the **end** of their target section
63
69
  - Methods already in the correct section maintain their relative order
@@ -74,6 +80,51 @@ Example order with default configuration:
74
80
  8. Private class methods
75
81
  9. Private static methods
76
82
 
83
+ ### Skipping Sorting with `# nosort`
84
+
85
+ You can prevent sorting at different levels using `# nosort` comments (case-insensitive):
86
+
87
+ **File-level**: Skip entire file
88
+
89
+ ```python
90
+ # nosort: file
91
+ class Example:
92
+ def _protected(self):
93
+ pass
94
+ def public(self):
95
+ pass # File won't be sorted
96
+ ```
97
+
98
+ **Class-level**: Skip specific class
99
+
100
+ ```python
101
+ class Example: # nosort
102
+ def _protected(self):
103
+ pass
104
+ def public(self):
105
+ pass # This class won't be sorted
106
+
107
+ class Other:
108
+ def _protected(self):
109
+ pass
110
+ def public(self):
111
+ pass # This class WILL be sorted
112
+ ```
113
+
114
+ **Method-level**: Keep method in its current position
115
+
116
+ ```python
117
+ class Example:
118
+ def public_a(self):
119
+ pass
120
+
121
+ def _protected(self): # nosort
122
+ pass # Stays here, between public methods
123
+
124
+ def public_b(self):
125
+ pass # Will move up, but _protected stays in place
126
+ ```
127
+
77
128
  ## Usage
78
129
 
79
130
  ### Command Line
@@ -121,9 +172,10 @@ repos:
121
172
  hooks:
122
173
  - id: undersort
123
174
  name: undersort
124
- entry: uv run undersort
125
- language: system
175
+ entry: undersort
176
+ language: python
126
177
  types: [python]
178
+ additional_dependencies: ["undersort"]
127
179
  ```
128
180
 
129
181
  Then install the hook:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "undersort"
7
- version = "0.1.2"
7
+ version = "0.1.4"
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"
@@ -0,0 +1,190 @@
1
+ """Tests for nosort comment directives."""
2
+
3
+ import tempfile
4
+ from pathlib import Path
5
+
6
+ from undersort.sorter import sort_file
7
+
8
+
9
+ class TestNosortDirectives:
10
+ """Tests for # nosort comment functionality."""
11
+
12
+ def test_file_level_nosort(self) -> None:
13
+ """Test that file-level nosort prevents all sorting."""
14
+ source = """# nosort: file
15
+ class Example:
16
+ def _protected(self):
17
+ pass
18
+
19
+ def public(self):
20
+ pass
21
+ """
22
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
23
+ f.write(source)
24
+ temp_path = Path(f.name)
25
+
26
+ try:
27
+ order = ["public", "protected", "private"]
28
+ was_modified = sort_file(temp_path, order)
29
+
30
+ assert was_modified is False
31
+
32
+ with open(temp_path) as f:
33
+ result = f.read()
34
+
35
+ assert result == source
36
+ finally:
37
+ temp_path.unlink()
38
+
39
+ def test_class_level_nosort(self) -> None:
40
+ """Test that class-level nosort prevents sorting that class."""
41
+ source = """class Example: # nosort
42
+ def _protected(self):
43
+ pass
44
+
45
+ def public(self):
46
+ pass
47
+ """
48
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
49
+ f.write(source)
50
+ temp_path = Path(f.name)
51
+
52
+ try:
53
+ order = ["public", "protected", "private"]
54
+ was_modified = sort_file(temp_path, order)
55
+
56
+ assert was_modified is False
57
+
58
+ with open(temp_path) as f:
59
+ result = f.read()
60
+
61
+ assert result == source
62
+ finally:
63
+ temp_path.unlink()
64
+
65
+ def test_method_level_nosort(self) -> None:
66
+ """Test that method-level nosort keeps that method in place."""
67
+ source = """class Example:
68
+ def public_a(self):
69
+ pass
70
+
71
+ def _protected_x(self): # nosort
72
+ pass
73
+
74
+ def public_b(self):
75
+ pass
76
+ """
77
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
78
+ f.write(source)
79
+ temp_path = Path(f.name)
80
+
81
+ try:
82
+ order = ["public", "protected", "private"]
83
+ was_modified = sort_file(temp_path, order)
84
+
85
+ assert was_modified is False
86
+
87
+ with open(temp_path) as f:
88
+ result = f.read()
89
+
90
+ protected_idx = result.find("def _protected_x")
91
+ public_a_idx = result.find("def public_a")
92
+ public_b_idx = result.find("def public_b")
93
+
94
+ assert public_a_idx < protected_idx < public_b_idx
95
+ finally:
96
+ temp_path.unlink()
97
+
98
+ def test_multiple_nosort_methods(self) -> None:
99
+ """Test multiple methods with nosort."""
100
+ source = """class Example:
101
+ def public_a(self):
102
+ pass
103
+
104
+ def _protected_x(self): # nosort
105
+ pass
106
+
107
+ def public_b(self): # nosort
108
+ pass
109
+
110
+ def _protected_y(self):
111
+ pass
112
+ """
113
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
114
+ f.write(source)
115
+ temp_path = Path(f.name)
116
+
117
+ try:
118
+ order = ["public", "protected", "private"]
119
+ was_modified = sort_file(temp_path, order)
120
+
121
+ assert was_modified is False
122
+
123
+ with open(temp_path) as f:
124
+ result = f.read()
125
+
126
+ assert result == source
127
+ finally:
128
+ temp_path.unlink()
129
+
130
+ def test_nosort_case_insensitive(self) -> None:
131
+ """Test that NOSORT and NoSort also work."""
132
+ source = """class Example: # NOSORT
133
+ def _protected(self):
134
+ pass
135
+
136
+ def public(self):
137
+ pass
138
+ """
139
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
140
+ f.write(source)
141
+ temp_path = Path(f.name)
142
+
143
+ try:
144
+ order = ["public", "protected", "private"]
145
+ was_modified = sort_file(temp_path, order)
146
+
147
+ assert was_modified is False
148
+ finally:
149
+ temp_path.unlink()
150
+
151
+ def test_nosort_with_other_classes(self) -> None:
152
+ """Test that nosort on one class doesn't affect others."""
153
+ source = """class First: # nosort
154
+ def _protected(self):
155
+ pass
156
+
157
+ def public(self):
158
+ pass
159
+
160
+ class Second:
161
+ def _protected(self):
162
+ pass
163
+
164
+ def public(self):
165
+ pass
166
+ """
167
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
168
+ f.write(source)
169
+ temp_path = Path(f.name)
170
+
171
+ try:
172
+ order = ["public", "protected", "private"]
173
+ was_modified = sort_file(temp_path, order)
174
+
175
+ assert was_modified is True
176
+
177
+ with open(temp_path) as f:
178
+ result = f.read()
179
+
180
+ first_protected_idx = result.find("class First")
181
+ first_protected_method_idx = result.find("def _protected", first_protected_idx)
182
+ first_public_idx = result.find("def public", first_protected_idx)
183
+ second_class_idx = result.find("class Second")
184
+ second_public_idx = result.find("def public", second_class_idx)
185
+ second_protected_idx = result.find("def _protected", second_class_idx)
186
+
187
+ assert first_protected_method_idx < first_public_idx
188
+ assert second_public_idx < second_protected_idx
189
+ finally:
190
+ temp_path.unlink()
@@ -1,3 +1,3 @@
1
1
  """undersort - Sort class methods by visibility."""
2
2
 
3
- __version__ = "0.1.2"
3
+ __version__ = "0.1.4"
@@ -9,6 +9,46 @@ import libcst as cst
9
9
  from undersort import logger
10
10
 
11
11
 
12
+ def has_nosort_comment(node: cst.FunctionDef | cst.ClassDef) -> bool:
13
+ """Check if a node has a nosort comment.
14
+
15
+ Args:
16
+ node: The node to check
17
+
18
+ Returns:
19
+ True if the node has a # nosort comment
20
+ """
21
+ if hasattr(node, "leading_lines"):
22
+ for line in node.leading_lines:
23
+ if isinstance(line, cst.EmptyLine) and line.comment and "nosort" in line.comment.value.lower():
24
+ return True
25
+
26
+ return bool(
27
+ hasattr(node, "body")
28
+ and hasattr(node.body, "header")
29
+ and isinstance(node.body.header, cst.TrailingWhitespace)
30
+ and node.body.header.comment
31
+ and "nosort" in node.body.header.comment.value.lower()
32
+ )
33
+
34
+
35
+ def file_has_nosort(module: cst.Module) -> bool:
36
+ """Check if a file has a file-level nosort directive.
37
+
38
+ Args:
39
+ module: The module to check
40
+
41
+ Returns:
42
+ True if the file has a # nosort: file comment
43
+ """
44
+ for line in module.header:
45
+ if isinstance(line, cst.EmptyLine) and line.comment:
46
+ comment_text = line.comment.value.lower()
47
+ if "nosort" in comment_text and "file" in comment_text:
48
+ return True
49
+ return False
50
+
51
+
12
52
  def get_method_visibility(method_name: str) -> Literal["public", "private", "protected"]:
13
53
  """Determine method visibility based on naming convention.
14
54
 
@@ -71,7 +111,9 @@ class MethodSorter(cst.CSTTransformer):
71
111
  self.modified = False
72
112
 
73
113
  def leave_ClassDef( # noqa: PLR0912, PLR0915
74
- self, original_node: cst.ClassDef, updated_node: cst.ClassDef
114
+ self,
115
+ original_node: cst.ClassDef,
116
+ updated_node: cst.ClassDef, # noqa: ARG002
75
117
  ) -> cst.ClassDef:
76
118
  """Sort methods within a class definition.
77
119
 
@@ -82,6 +124,9 @@ class MethodSorter(cst.CSTTransformer):
82
124
  Returns:
83
125
  ClassDef with sorted methods
84
126
  """
127
+ if has_nosort_comment(updated_node):
128
+ return updated_node
129
+
85
130
  methods = []
86
131
  non_methods = []
87
132
 
@@ -94,7 +139,17 @@ class MethodSorter(cst.CSTTransformer):
94
139
  if not methods:
95
140
  return updated_node
96
141
 
97
- method_with_index = [(i, method) for i, method in enumerate(methods)]
142
+ sortable_methods = []
143
+ nosort_methods = []
144
+
145
+ for i, method in enumerate(methods):
146
+ if has_nosort_comment(method):
147
+ nosort_methods.append((i, method))
148
+ else:
149
+ sortable_methods.append((i, method))
150
+
151
+ if not sortable_methods:
152
+ return updated_node
98
153
 
99
154
  method_groups: dict[str, dict[str, list[tuple[int, cst.FunctionDef]]]] = {
100
155
  "public": {"class": [], "static": [], "instance": []},
@@ -102,7 +157,7 @@ class MethodSorter(cst.CSTTransformer):
102
157
  "private": {"class": [], "static": [], "instance": []},
103
158
  }
104
159
 
105
- for idx, method in method_with_index:
160
+ for idx, method in sortable_methods:
106
161
  visibility = get_method_visibility(method.name.value)
107
162
  method_type = get_method_type(method)
108
163
  method_groups[visibility][method_type].append((idx, method))
@@ -145,9 +200,15 @@ class MethodSorter(cst.CSTTransformer):
145
200
 
146
201
  current_position += len(group)
147
202
 
148
- if methods != sorted_methods:
203
+ all_sorted = sorted_methods[:]
204
+ for orig_idx, nosort_method in sorted(nosort_methods, key=lambda x: x[0]):
205
+ all_sorted.insert(orig_idx, nosort_method)
206
+
207
+ if methods != all_sorted:
149
208
  self.modified = True
150
209
 
210
+ sorted_methods = all_sorted
211
+
151
212
  leading_non_methods = []
152
213
  trailing_non_methods = []
153
214
  found_method = False
@@ -193,6 +254,9 @@ def sort_file(
193
254
  except cst.ParserSyntaxError as e:
194
255
  raise ValueError(f"Syntax error in {file_path}: {e}")
195
256
 
257
+ if file_has_nosort(tree):
258
+ return False
259
+
196
260
  sorter = MethodSorter(order, method_type_order)
197
261
  new_tree = tree.visit(sorter)
198
262
 
@@ -905,7 +905,7 @@ wheels = [
905
905
 
906
906
  [[package]]
907
907
  name = "undersort"
908
- version = "0.1.2"
908
+ version = "0.1.4"
909
909
  source = { editable = "." }
910
910
  dependencies = [
911
911
  { name = "libcst", version = "1.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes