code-towel 1.0.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.
@@ -0,0 +1,83 @@
1
+ # Copyright 2025 Eric Allen
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """
16
+ Utilities for AST visitor classes to ensure complete coverage.
17
+ """
18
+
19
+ import ast
20
+ import os
21
+
22
+
23
+ from typing import Any, Callable
24
+
25
+
26
+ def make_defensive_generic_visit(visitor_class_name: str) -> Callable[[Any, ast.AST], Any]:
27
+ """
28
+ Create a defensive generic_visit method that catches unhandled AST nodes.
29
+
30
+ In development mode (when DEBUG_AST_COVERAGE=1), this raises an error
31
+ when an AST node type is encountered that doesn't have a specific visitor method.
32
+ This helps catch missing visitor methods during testing.
33
+
34
+ In production mode, it delegates to the parent's generic_visit.
35
+
36
+ Args:
37
+ visitor_class_name: Name of the visitor class (for error messages)
38
+
39
+ Returns:
40
+ A generic_visit method suitable for use in an AST visitor class
41
+
42
+ Example:
43
+ class MyVisitor(ast.NodeVisitor):
44
+ generic_visit = make_defensive_generic_visit('MyVisitor')
45
+
46
+ def visit_Name(self, node):
47
+ # ... handle Name nodes
48
+ return node
49
+ """
50
+
51
+ def generic_visit(self: Any, node: ast.AST) -> Any:
52
+ """
53
+ Fallback for unhandled nodes. In DEBUG mode, raises an error.
54
+ In production, delegates to parent.
55
+ """
56
+ # Check if we're in debug mode
57
+ if os.getenv("DEBUG_AST_COVERAGE"):
58
+ # Get the list of visitor methods this class has
59
+ visitor_methods = {
60
+ name[6:]
61
+ for name in dir(self)
62
+ if name.startswith("visit_") and callable(getattr(self, name))
63
+ }
64
+
65
+ node_type = node.__class__.__name__
66
+
67
+ # Only raise if this is a "real" AST node (not auxiliary types)
68
+ # and we don't have a visitor for it
69
+ if node_type not in visitor_methods and hasattr(ast, node_type):
70
+ raise NotImplementedError(
71
+ f"Missing visitor method for {node_type} in {visitor_class_name}. "
72
+ f"Please implement visit_{node_type}() or verify that "
73
+ f"generic traversal is appropriate for this node type."
74
+ )
75
+
76
+ # In production or for nodes we intentionally don't handle,
77
+ # delegate to the parent's generic_visit
78
+ if isinstance(self, ast.NodeTransformer):
79
+ return ast.NodeTransformer.generic_visit(self, node)
80
+ else:
81
+ return ast.NodeVisitor.generic_visit(self, node)
82
+
83
+ return generic_visit
@@ -0,0 +1,377 @@
1
+ # Copyright 2025 Eric Allen
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """
16
+ Top-level AST visitors used by the refactoring engine.
17
+
18
+ These classes were extracted from nested definitions in refactor_engine.py to improve
19
+ readability and enable a clearer compiler-like pipeline structure.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import ast
25
+ from typing import Callable, List, Optional, Set, Tuple, Union, Literal, Sequence, TypeVar
26
+
27
+ MethodKind = Literal["instance", "classmethod", "staticmethod"]
28
+ T = TypeVar("T")
29
+
30
+
31
+ class ClassCollector(ast.NodeVisitor):
32
+ """Collect class metadata (qualname and base names) for a module tree.
33
+
34
+ Produces tuples of (qualname, bases) via the provided sink callback.
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ file_path: str,
40
+ base_resolver: Callable[[ast.expr], Optional[str]],
41
+ sink: Callable[[str, List[str]], None],
42
+ ) -> None:
43
+ self.file_path = file_path
44
+ self.base_resolver = base_resolver
45
+ self.sink = sink
46
+ self.class_stack: List[str] = []
47
+
48
+ def visit_ClassDef(self, node: ast.ClassDef) -> None: # noqa: N802 (ast API)
49
+ qualname = ".".join(self.class_stack + [node.name]) if self.class_stack else node.name
50
+ bases: List[str] = []
51
+ for base in node.bases:
52
+ resolved = self.base_resolver(base)
53
+ if resolved:
54
+ bases.append(resolved)
55
+ self.sink(qualname, bases)
56
+ _push_value_and_visit(self.class_stack, node.name, self, node)
57
+
58
+
59
+ class FunctionCollector(ast.NodeVisitor):
60
+ """Collect functions with enclosing class/function context for a module tree.
61
+
62
+ Calls sink with (node, class_name, enclosing_function, ancestry).
63
+ """
64
+
65
+ def __init__(
66
+ self,
67
+ sink: Callable[
68
+ [Union[ast.FunctionDef, ast.AsyncFunctionDef], Optional[str], Optional[str], List[str]],
69
+ None,
70
+ ],
71
+ ) -> None:
72
+ self.sink = sink
73
+ self.class_stack: List[Optional[str]] = [None]
74
+ self.func_stack: List[Optional[str]] = [None]
75
+
76
+ def visit_ClassDef(self, node: ast.ClassDef) -> None: # noqa: N802
77
+ _push_value_and_visit(self.class_stack, node.name, self, node)
78
+
79
+ def _record(self, node: Union[ast.FunctionDef, ast.AsyncFunctionDef]) -> None:
80
+ ancestry = [n for n in self.func_stack if n is not None]
81
+ self.sink(node, self.class_stack[-1], self.func_stack[-1], ancestry)
82
+
83
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> None: # noqa: N802
84
+ self._record(node)
85
+ _push_value_and_visit(self.func_stack, node.name, self, node)
86
+
87
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: # noqa: N802
88
+ self._record(node)
89
+ _push_value_and_visit(self.func_stack, node.name, self, node)
90
+
91
+
92
+ class MethodCallRewriter(ast.NodeTransformer):
93
+ """Rewrite calls to a helper function into proper method dispatch syntax.
94
+
95
+ This mirrors the engine's previous inner-class behavior.
96
+ """
97
+
98
+ def __init__(
99
+ self,
100
+ drop_positional: Callable[[List[ast.expr], str], List[ast.expr]],
101
+ drop_keyword: Callable[[List[ast.keyword], str], List[ast.keyword]],
102
+ *,
103
+ original_name: str,
104
+ new_name: str,
105
+ method_kind: Optional[MethodKind],
106
+ implicit_name: Optional[str],
107
+ class_name: Optional[str],
108
+ ) -> None:
109
+ self.drop_positional = drop_positional
110
+ self.drop_keyword = drop_keyword
111
+ self.original_name = original_name
112
+ self.new_name = new_name
113
+ self.method_kind = method_kind
114
+ self.implicit_name = implicit_name
115
+ self.class_name = class_name
116
+
117
+ def visit_Call(self, n: ast.Call) -> ast.AST:
118
+ self.generic_visit(n)
119
+ if isinstance(n.func, ast.Name) and n.func.id == self.original_name and self.method_kind:
120
+ implicit_name = self.implicit_name or (
121
+ "self" if self.method_kind == "instance" else "cls"
122
+ )
123
+ if self.method_kind in {"instance", "classmethod"}:
124
+ n.args = self.drop_positional(n.args, implicit_name)
125
+ n.keywords = self.drop_keyword(n.keywords, implicit_name)
126
+ attr = ast.Attribute(
127
+ value=ast.Name(id=implicit_name, ctx=ast.Load()),
128
+ attr=self.new_name,
129
+ ctx=ast.Load(),
130
+ )
131
+ ast.copy_location(attr, n.func)
132
+ n.func = attr
133
+ elif self.method_kind == "staticmethod" and self.class_name:
134
+ attr = ast.Attribute(
135
+ value=ast.Name(id=self.class_name, ctx=ast.Load()),
136
+ attr=self.new_name,
137
+ ctx=ast.Load(),
138
+ )
139
+ ast.copy_location(attr, n.func)
140
+ n.func = attr
141
+ return n
142
+
143
+
144
+ class LoopReturnFinder(ast.NodeVisitor):
145
+ """Detect whether a code block contains return statements inside loops.
146
+
147
+ Used to identify control flow patterns that may prevent safe refactoring.
148
+ """
149
+
150
+ def __init__(self) -> None:
151
+ self.has_loop_return = False
152
+ self.in_loop = False
153
+
154
+ def visit_For(self, node: ast.For) -> None: # noqa: N802
155
+ _visit_loop_and_restore_flag(self, node)
156
+
157
+ def visit_While(self, node: ast.While) -> None: # noqa: N802
158
+ _visit_loop_and_restore_flag(self, node)
159
+
160
+ def visit_Return(self, node: ast.Return) -> None: # noqa: N802
161
+ if self.in_loop:
162
+ self.has_loop_return = True
163
+
164
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> None: # noqa: N802
165
+ return None
166
+
167
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: # noqa: N802
168
+ return None
169
+
170
+
171
+ class NameCollector(ast.NodeVisitor):
172
+ """Collect all names referenced in Load context within a code block.
173
+
174
+ Stops at nested function boundaries to avoid capturing scopes outside the block.
175
+ """
176
+
177
+ def __init__(self) -> None:
178
+ self.used: Set[str] = set()
179
+
180
+ def visit_Name(self, n: ast.Name) -> None: # noqa: N802
181
+ _record_load_name_and_visit(n, self.used, self)
182
+
183
+ def visit_FunctionDef(self, n: ast.FunctionDef) -> None: # noqa: N802
184
+ return None
185
+
186
+ def visit_AsyncFunctionDef(self, n: ast.AsyncFunctionDef) -> None: # noqa: N802
187
+ return None
188
+
189
+
190
+ class AugAssignFinder(ast.NodeVisitor):
191
+ """Find all variables modified by augmented assignment operators (+=, -=, etc.).
192
+
193
+ Stops at nested function boundaries to avoid capturing scopes outside the block.
194
+ """
195
+
196
+ def __init__(self) -> None:
197
+ self.aug_assign_targets: Set[str] = set()
198
+
199
+ def visit_AugAssign(self, node: ast.AugAssign) -> None: # noqa: N802
200
+ _record_simple_assignment(node, self.aug_assign_targets, self)
201
+
202
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> None: # noqa: N802
203
+ return None
204
+
205
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: # noqa: N802
206
+ return None
207
+
208
+
209
+ class AssignTargetVisitor(ast.NodeVisitor):
210
+ """Collect all assignment targets and global/nonlocal declarations in a block.
211
+
212
+ Tracks simple name assignments from Assign, AugAssign, and AnnAssign nodes,
213
+ plus global and nonlocal declarations. Stops at nested function boundaries.
214
+ """
215
+
216
+ def __init__(self) -> None:
217
+ self.assigned_names: Set[str] = set()
218
+ self.declared_global_in_block: Set[str] = set()
219
+ self.declared_nonlocal_in_block: Set[str] = set()
220
+
221
+ def visit_Assign(self, node: ast.Assign) -> None: # noqa: N802
222
+ for t in node.targets:
223
+ _record_name_target(t, self.assigned_names)
224
+ self.generic_visit(node)
225
+
226
+ def visit_AugAssign(self, node: ast.AugAssign) -> None: # noqa: N802
227
+ _record_simple_assignment(node, self.assigned_names, self)
228
+
229
+ def visit_AnnAssign(self, node: ast.AnnAssign) -> None: # noqa: N802
230
+ _record_simple_assignment(node, self.assigned_names, self)
231
+
232
+ def visit_Global(self, node: ast.Global) -> None: # noqa: N802
233
+ for n in node.names:
234
+ self.declared_global_in_block.add(n)
235
+
236
+ def visit_Nonlocal(self, node: ast.Nonlocal) -> None: # noqa: N802
237
+ for n in node.names:
238
+ self.declared_nonlocal_in_block.add(n)
239
+
240
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> None: # noqa: N802
241
+ return None
242
+
243
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: # noqa: N802
244
+ return None
245
+
246
+
247
+ class ClassLocator(ast.NodeVisitor):
248
+ """Locate a class definition by name and determine its insertion point.
249
+
250
+ Returns the line number and indentation suitable for inserting a new method
251
+ at the end of the target class body.
252
+ """
253
+
254
+ def __init__(self, source: str, target_name: str) -> None:
255
+ self.source = source
256
+ self.target_name = target_name
257
+ self.result: Optional[Tuple[int, str]] = None
258
+
259
+ def visit_ClassDef(self, node: ast.ClassDef) -> None: # noqa: N802
260
+ if node.name == self.target_name and hasattr(node, "end_lineno"):
261
+ indent = _compute_indent(self.source, node.lineno)
262
+ end_lineno = getattr(node, "end_lineno", None)
263
+ if isinstance(end_lineno, int):
264
+ insert_line = end_lineno - 1
265
+ else:
266
+ insert_line = node.lineno
267
+ self.result = (insert_line, indent)
268
+ self.generic_visit(node)
269
+
270
+
271
+ class FuncLocator(ast.NodeVisitor):
272
+ """Locate a function definition by name and determine its helper insertion point.
273
+
274
+ Returns the line number and indentation suitable for inserting a new helper
275
+ function inside the target function, after any existing nested definitions
276
+ but before the first executable statement.
277
+ """
278
+
279
+ def __init__(self, source: str, function_name: str) -> None:
280
+ self.source = source
281
+ self.function_name = function_name
282
+ self.result: Optional[Tuple[int, str]] = None
283
+
284
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> None: # noqa: N802
285
+ self._maybe_capture_insertion_point(node)
286
+
287
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: # noqa: N802
288
+ self._maybe_capture_insertion_point(node)
289
+
290
+ def _maybe_capture_insertion_point(
291
+ self, node: Union[ast.FunctionDef, ast.AsyncFunctionDef]
292
+ ) -> None:
293
+ if node.name != self.function_name:
294
+ self.generic_visit(node)
295
+ return
296
+ indent = _compute_indent(self.source, node.lineno)
297
+ body = _body_without_docstring(node.body)
298
+ insert_line: Optional[int] = None
299
+ last_def_end: Optional[int] = None
300
+ for stmt in body:
301
+ if isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
302
+ stmt_end = getattr(stmt, "end_lineno", stmt.lineno)
303
+ if isinstance(stmt_end, int):
304
+ last_def_end = stmt_end
305
+ continue
306
+ insert_line = stmt.lineno - 1
307
+ break
308
+ if insert_line is None:
309
+ if last_def_end is not None:
310
+ insert_line = last_def_end
311
+ else:
312
+ insert_line = node.lineno
313
+ self.result = (insert_line, indent)
314
+
315
+
316
+ def _push_value_and_visit(
317
+ stack: List[T], value: T, visitor: ast.NodeVisitor, node: ast.AST
318
+ ) -> None:
319
+ stack.append(value)
320
+ try:
321
+ visitor.generic_visit(node)
322
+ finally:
323
+ stack.pop()
324
+
325
+
326
+ def _visit_loop_and_restore_flag(
327
+ visitor: "LoopReturnFinder", node: Union[ast.For, ast.While]
328
+ ) -> None:
329
+ previous_flag = visitor.in_loop
330
+ visitor.in_loop = True
331
+ visitor.generic_visit(node)
332
+ visitor.in_loop = previous_flag
333
+
334
+
335
+ def _record_load_name_and_visit(
336
+ node: ast.Name, destination: Set[str], visitor: ast.NodeVisitor
337
+ ) -> None:
338
+ if isinstance(node.ctx, ast.Load):
339
+ destination.add(node.id)
340
+ visitor.generic_visit(node)
341
+
342
+
343
+ def _record_name_target(target: ast.AST, destination: Set[str]) -> None:
344
+ if isinstance(target, ast.Name):
345
+ destination.add(target.id)
346
+
347
+
348
+ def _record_simple_assignment(
349
+ node: Union[ast.AnnAssign, ast.AugAssign], destination: Set[str], visitor: ast.AST
350
+ ) -> None:
351
+ target = getattr(node, "target", None)
352
+ if isinstance(target, ast.Name):
353
+ destination.add(target.id)
354
+ visitor.generic_visit(node)
355
+
356
+
357
+ def _body_without_docstring(body: Sequence[ast.stmt]) -> List[ast.stmt]:
358
+ body_list = list(body)
359
+ if not body_list:
360
+ return []
361
+
362
+ first_stmt = body_list[0]
363
+ if (
364
+ isinstance(first_stmt, ast.Expr)
365
+ and isinstance(first_stmt.value, ast.Constant)
366
+ and isinstance(first_stmt.value.value, str)
367
+ ):
368
+ return body_list[1:]
369
+
370
+ return body_list
371
+
372
+
373
+ def _compute_indent(source: str, lineno: int) -> str:
374
+ lines = source.splitlines()
375
+ index = max(0, min(len(lines) - 1, lineno - 1))
376
+ line = lines[index] if lines else ""
377
+ return line[: len(line) - len(line.lstrip())]