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,190 @@
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
+ """Data models for the unification-based refactoring system.
16
+
17
+ This module defines the core data structures used throughout the refactoring engine:
18
+ - Code block pairs for comparison
19
+ - Method information for class context
20
+ - Refactoring proposals
21
+ - Function context for analysis
22
+ """
23
+ from __future__ import annotations
24
+
25
+ from dataclasses import dataclass, field
26
+ from typing import TYPE_CHECKING, List, Literal, Optional, Tuple, Union
27
+ import ast
28
+
29
+
30
+ @dataclass
31
+ class CodeBlockPair:
32
+ """Represents a pair of potentially duplicate code blocks."""
33
+
34
+ file_path: str
35
+ function1_name: str
36
+ function2_name: str
37
+ block1_range: Tuple[int, int]
38
+ block2_range: Tuple[int, int]
39
+ block1_nodes: List[ast.AST]
40
+ block2_nodes: List[ast.AST]
41
+ file_path2: Optional[str] = None
42
+ class1_name: Optional[str] = None
43
+ class2_name: Optional[str] = None
44
+ enclosing_function1_name: Optional[str] = None
45
+ enclosing_function2_name: Optional[str] = None
46
+ function1_ancestry: Optional[List[str]] = None
47
+ function2_ancestry: Optional[List[str]] = None
48
+ scope_analyzer1: Optional["ScopeAnalyzer"] = None
49
+ scope_analyzer2: Optional["ScopeAnalyzer"] = None
50
+ root_scope1: Optional["Scope"] = None
51
+ root_scope2: Optional["Scope"] = None
52
+ source1: Optional[str] = None
53
+ source2: Optional[str] = None
54
+ function1_node: Optional[Union[ast.FunctionDef, ast.AsyncFunctionDef]] = None
55
+ function2_node: Optional[Union[ast.FunctionDef, ast.AsyncFunctionDef]] = None
56
+
57
+
58
+ @dataclass
59
+ class MethodInfo:
60
+ """Describes how a function participates as a method within a class."""
61
+
62
+ kind: Optional[Literal["instance", "classmethod", "staticmethod"]]
63
+ implicit_param: Optional[str]
64
+
65
+
66
+ @dataclass
67
+ class ClassInfo:
68
+ """Summarizes class definitions discovered during analysis."""
69
+
70
+ name: str
71
+ qualname: str
72
+ file_path: str
73
+ bases: List[str]
74
+
75
+
76
+ @dataclass
77
+ class ClassInsertionPlan:
78
+ """Describes where an extracted helper should be inserted within a class hierarchy."""
79
+
80
+ class_name: str
81
+ file_path: str
82
+ method_kind: Literal["instance", "classmethod", "staticmethod"]
83
+ implicit_param: Optional[str]
84
+
85
+
86
+ @dataclass
87
+ class Replacement:
88
+ """Represents a replacement call to the extracted function/method."""
89
+
90
+ line_range: Tuple[int, int]
91
+ node: ast.AST
92
+ file_path: Optional[str] = None
93
+ class_name: Optional[str] = None
94
+ method_kind: Optional[Literal["instance", "classmethod", "staticmethod"]] = None
95
+ implicit_param: Optional[str] = None
96
+
97
+
98
+ @dataclass
99
+ class RefactoringProposal:
100
+ """Proposed refactoring."""
101
+
102
+ file_path: str
103
+ extracted_function: ast.FunctionDef
104
+ replacements: List[Replacement]
105
+ description: str
106
+ parameters_count: int
107
+ return_variables: List[str] = field(default_factory=list)
108
+ insert_into_class: Optional[str] = None
109
+ insert_into_function: Optional[str] = None
110
+ method_kind: Optional[Literal["instance", "classmethod", "staticmethod"]] = None
111
+ method_param_name: Optional[str] = None
112
+
113
+ def __post_init__(self) -> None:
114
+ """Coerce legacy tuple replacements into :class:`Replacement` instances."""
115
+
116
+ coerced: List[Replacement] = []
117
+ for item in self.replacements:
118
+ if isinstance(item, Replacement):
119
+ coerced.append(item)
120
+ continue
121
+
122
+ if not isinstance(item, tuple):
123
+ raise TypeError(
124
+ "Replacement entries must be Replacement instances or tuples, "
125
+ f"got {type(item)!r}"
126
+ )
127
+
128
+ if len(item) == 4:
129
+ line_range, node, file_path, class_name = item
130
+ elif len(item) == 3:
131
+ line_range, node, file_path = item
132
+ class_name = None
133
+ elif len(item) == 2:
134
+ line_range, node = item
135
+ file_path = None
136
+ class_name = None
137
+ else:
138
+ raise ValueError(
139
+ "Replacement tuple must have length 2, 3, or 4 ("
140
+ "line_range, node[, file_path[, class_name]])"
141
+ )
142
+
143
+ method_kind: Optional[Literal["instance", "classmethod", "staticmethod"]] = None
144
+ implicit_param: Optional[str] = None
145
+ if class_name is not None:
146
+ method_kind = "instance"
147
+ implicit_param = "self"
148
+
149
+ coerced.append(
150
+ Replacement(
151
+ line_range=line_range,
152
+ node=node,
153
+ file_path=file_path,
154
+ class_name=class_name,
155
+ method_kind=method_kind,
156
+ implicit_param=implicit_param,
157
+ )
158
+ )
159
+
160
+ self.replacements = coerced
161
+
162
+
163
+ @dataclass
164
+ class ParsedModule:
165
+ """Container for parsed module data flowing through the pipeline."""
166
+
167
+ file_path: str
168
+ source: str
169
+ tree: ast.AST
170
+ scope_analyzer: Optional["ScopeAnalyzer"] = None
171
+ root_scope: Optional["Scope"] = None
172
+ class_infos: List[ClassInfo] = field(default_factory=list)
173
+
174
+
175
+ @dataclass
176
+ class FunctionArtifact:
177
+ """Represents a function (sync or async) discovered during analysis with context."""
178
+
179
+ file_path: str
180
+ node: Union[ast.FunctionDef, ast.AsyncFunctionDef]
181
+ source: str
182
+ scope_analyzer: "ScopeAnalyzer"
183
+ root_scope: "Scope"
184
+ class_name: Optional[str]
185
+ enclosing_function: Optional[str]
186
+ ancestry: List[str]
187
+
188
+
189
+ if TYPE_CHECKING: # pragma: no cover - imported for typing only
190
+ from .scope_analyzer import ScopeAnalyzer, Scope
@@ -0,0 +1,375 @@
1
+ #!/usr/bin/env python3
2
+ # Copyright 2025 Eric Allen
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+ """
17
+ Nominal Unification for Python Code Blocks
18
+
19
+ This module implements nominal unification to handle α-equivalence: code blocks
20
+ that differ only in the names of bound variables.
21
+
22
+ Based on the Nominal Unification algorithm:
23
+ M. J. Gabbay. Urban, C., Pitts, A. M., & Gabbay, M. J. (2003).
24
+ Nominal unification. In *Computer Science Logic* (pp. 513-527).
25
+ Springer, Berlin, Heidelberg.
26
+ Calvès, C., & Fernández, M. (2008). A polynomial nominal unification algorithm.
27
+ *Theoretical Computer Science*, *403*(2-3), 285-306.
28
+ Urban, C. (2010). Nominal Unification Revisited.
29
+ Electronic Proceedings in Theoretical Computer Science, 42, 1–11.
30
+
31
+ Example of α-equivalent blocks:
32
+ Block 1: user = get_user(); validate(user)
33
+ Block 2: admin = get_admin(); validate(admin)
34
+
35
+ These blocks are structurally identical - 'user' and 'admin' play the same role.
36
+ Nominal unification tracks this correspondence so that extracted function calls
37
+ use the correct variable names:
38
+ - Call in block 1: validate_func(user)
39
+ - Call in block 2: validate_func(admin) # NOT validate_func(user)!
40
+ """
41
+
42
+ import ast
43
+ from typing import Dict, List, Set, Optional, Tuple, cast
44
+ from dataclasses import dataclass, field
45
+
46
+ from .binding_detector import Binding, detect_bindings
47
+
48
+
49
+ @dataclass
50
+ class VariableCorrespondence:
51
+ """
52
+ Tracks how variables correspond across unified code blocks.
53
+
54
+ For α-equivalent blocks like:
55
+ Block 0: user = ...; process(user)
56
+ Block 1: admin = ...; process(admin)
57
+
58
+ The correspondence tracks:
59
+ canonical_name = 'user' # We pick one as the canonical name
60
+ block_to_original = {0: 'user', 1: 'admin'} # Original names per block
61
+ original_to_canonical = {('user', 0): 'user', ('admin', 1): 'user'}
62
+ """
63
+
64
+ canonical_name: str # The canonical/template variable name
65
+ block_to_original: Dict[int, str] = field(default_factory=dict) # block_idx → original name
66
+ original_to_canonical: Dict[Tuple[str, int], str] = field(
67
+ default_factory=dict
68
+ ) # (name, block_idx) → canonical
69
+
70
+
71
+ class NominalUnificationContext:
72
+ """
73
+ Context for nominal unification across multiple code blocks.
74
+
75
+ Tracks:
76
+ - Variable correspondences (which variables in different blocks correspond)
77
+ - Binding sites (where each variable is bound)
78
+ - Freshness constraints (which variables must not clash)
79
+ """
80
+
81
+ def __init__(self, num_blocks: int):
82
+ self.num_blocks = num_blocks
83
+
84
+ # Track variable correspondences across blocks
85
+ # Maps canonical variable name → VariableCorrespondence
86
+ self.correspondences: Dict[str, VariableCorrespondence] = {}
87
+
88
+ # Track which variables are bound in each block
89
+ # Maps block_idx → set of bound variable names
90
+ self.bound_variables: List[Set[str]] = [set() for _ in range(num_blocks)]
91
+
92
+ # Track binding sites for each variable in each block
93
+ # Maps (block_idx, var_name) → list of AST nodes where variable is bound
94
+ self.binding_sites: Dict[Tuple[int, str], List[ast.AST]] = {}
95
+
96
+ def add_correspondence(self, canonical_name: str, block_idx: int, original_name: str) -> None:
97
+ """
98
+ Record that 'original_name' in block 'block_idx' corresponds to 'canonical_name'.
99
+
100
+ Args:
101
+ canonical_name: The canonical variable name (from the template)
102
+ block_idx: Which code block this is
103
+ original_name: The actual variable name in this block
104
+ """
105
+ if canonical_name not in self.correspondences:
106
+ self.correspondences[canonical_name] = VariableCorrespondence(
107
+ canonical_name=canonical_name
108
+ )
109
+
110
+ corr = self.correspondences[canonical_name]
111
+ corr.block_to_original[block_idx] = original_name
112
+ corr.original_to_canonical[(original_name, block_idx)] = canonical_name
113
+
114
+ def get_original_name(self, canonical_name: str, block_idx: int) -> Optional[str]:
115
+ """
116
+ Get the original variable name in a specific block for a canonical variable.
117
+
118
+ Args:
119
+ canonical_name: The canonical variable name
120
+ block_idx: Which block to look up
121
+
122
+ Returns:
123
+ The original variable name in that block, or None if not found
124
+ """
125
+ if canonical_name not in self.correspondences:
126
+ return None
127
+ return self.correspondences[canonical_name].block_to_original.get(block_idx)
128
+
129
+ def get_canonical_name(self, original_name: str, block_idx: int) -> Optional[str]:
130
+ """
131
+ Get the canonical variable name for an original variable in a specific block.
132
+
133
+ Args:
134
+ original_name: The variable name in the block
135
+ block_idx: Which block this is
136
+
137
+ Returns:
138
+ The canonical variable name, or None if not found
139
+ """
140
+ for corr in self.correspondences.values():
141
+ if corr.original_to_canonical.get((original_name, block_idx)):
142
+ return corr.canonical_name
143
+ return None
144
+
145
+ def detect_bindings_in_blocks(self, blocks: List[List[ast.AST]]) -> None:
146
+ """
147
+ Analyze all code blocks to detect bound variables.
148
+
149
+ Args:
150
+ blocks: List of code blocks (each block is a list of AST nodes)
151
+ """
152
+ for block_idx, block in enumerate(blocks):
153
+ # Create a module to contain the block for analysis
154
+ module = ast.Module(body=cast(List[ast.stmt], block), type_ignores=[])
155
+
156
+ # Detect all bindings in this block
157
+ bindings = detect_bindings(module)
158
+
159
+ # Record bound variables
160
+ for binding in bindings:
161
+ self.bound_variables[block_idx].add(binding.name)
162
+
163
+ # Record binding site
164
+ _record_binding_sites(self.binding_sites, block_idx, binding)
165
+
166
+ def is_bound_in_block(self, var_name: str, block_idx: int) -> bool:
167
+ """Check if a variable is bound in a specific block."""
168
+ return var_name in self.bound_variables[block_idx]
169
+
170
+ def export_to_hygienic_renames(self) -> List[Dict[str, str]]:
171
+ """
172
+ Export correspondences to the hygienic_renames format.
173
+
174
+ Returns:
175
+ List of dictionaries, one per block, mapping original → canonical names
176
+ """
177
+ hygienic_renames: List[Dict[str, str]] = [{} for _ in range(self.num_blocks)]
178
+
179
+ for corr in self.correspondences.values():
180
+ for block_idx, original_name in corr.block_to_original.items():
181
+ if original_name != corr.canonical_name:
182
+ hygienic_renames[block_idx][original_name] = corr.canonical_name
183
+
184
+ return hygienic_renames
185
+
186
+
187
+ class NominalVariableMatcher:
188
+ """
189
+ Matches variables across blocks to identify α-equivalent patterns.
190
+
191
+ This is the core of nominal unification: given two code blocks with different
192
+ variable names, determine which variables correspond to each other.
193
+ """
194
+
195
+ def __init__(self, context: NominalUnificationContext):
196
+ self.context = context
197
+
198
+ def try_match_variables(self, var1: str, var2: str, block_idx1: int, block_idx2: int) -> bool:
199
+ """
200
+ Try to match two variables from different blocks.
201
+
202
+ Args:
203
+ var1: Variable name in block 1
204
+ var2: Variable name in block 2
205
+ block_idx1: Index of first block
206
+ block_idx2: Index of second block
207
+
208
+ Returns:
209
+ True if the variables can be matched (or are already matched), False otherwise
210
+ """
211
+ # Check if both variables are bound in their respective blocks
212
+ is_bound1 = self.context.is_bound_in_block(var1, block_idx1)
213
+ is_bound2 = self.context.is_bound_in_block(var2, block_idx2)
214
+
215
+ # If binding status differs, they can't match
216
+ if is_bound1 != is_bound2:
217
+ return False
218
+
219
+ # If neither is bound, they're free variables - must have same name
220
+ if not is_bound1 and not is_bound2:
221
+ return var1 == var2
222
+
223
+ # Both are bound - check if they're already in a correspondence
224
+ canonical1 = self.context.get_canonical_name(var1, block_idx1)
225
+ canonical2 = self.context.get_canonical_name(var2, block_idx2)
226
+
227
+ if canonical1 and canonical2:
228
+ # Both already have canonical names - must be the same
229
+ return canonical1 == canonical2
230
+
231
+ if canonical1:
232
+ # var1 has a canonical name, var2 doesn't - add var2 to the same correspondence
233
+ self.context.add_correspondence(canonical1, block_idx2, var2)
234
+ return True
235
+
236
+ if canonical2:
237
+ # var2 has a canonical name, var1 doesn't - add var1 to the same correspondence
238
+ self.context.add_correspondence(canonical2, block_idx1, var1)
239
+ return True
240
+
241
+ # Neither has a canonical name yet - create new correspondence
242
+ # Use var1 as the canonical name
243
+ canonical_name = var1
244
+ self.context.add_correspondence(canonical_name, block_idx1, var1)
245
+ self.context.add_correspondence(canonical_name, block_idx2, var2)
246
+ return True
247
+
248
+ def match_name_nodes(
249
+ self, node1: ast.Name, node2: ast.Name, block_idx1: int, block_idx2: int
250
+ ) -> bool:
251
+ """
252
+ Try to match two Name nodes from different blocks.
253
+
254
+ Args:
255
+ node1: Name node from block 1
256
+ node2: Name node from block 2
257
+ block_idx1: Index of first block
258
+ block_idx2: Index of second block
259
+
260
+ Returns:
261
+ True if the names can be matched, False otherwise
262
+ """
263
+ return self.try_match_variables(node1.id, node2.id, block_idx1, block_idx2)
264
+
265
+
266
+ def analyze_nominal_patterns(blocks: List[List[ast.AST]]) -> NominalUnificationContext:
267
+ """
268
+ Analyze code blocks to identify nominal (α-equivalence) patterns.
269
+
270
+ This is a simplified version that detects bound variables but doesn't
271
+ do full structural matching. Full structural matching will be integrated
272
+ into the existing Unifier class.
273
+
274
+ Args:
275
+ blocks: List of code blocks to analyze
276
+
277
+ Returns:
278
+ Context containing variable correspondences
279
+ """
280
+ context = NominalUnificationContext(num_blocks=len(blocks))
281
+
282
+ # Detect all bindings in all blocks
283
+ context.detect_bindings_in_blocks(blocks)
284
+
285
+ return context
286
+
287
+
288
+ def build_hygienic_renames_from_unification(
289
+ blocks: List[List[ast.AST]], canonical_block: List[ast.AST], canonical_idx: int = 0
290
+ ) -> List[Dict[str, str]]:
291
+ """
292
+ Build hygienic_renames mapping by comparing blocks to a canonical template.
293
+
294
+ This function is designed to be called after traditional unification has
295
+ succeeded, to populate the hygienic_renames with variable correspondences.
296
+
297
+ Args:
298
+ blocks: All unified code blocks
299
+ canonical_block: The canonical/template block (typically blocks[0])
300
+ canonical_idx: Index of the canonical block (default 0)
301
+
302
+ Returns:
303
+ List of dicts mapping original names → canonical names for each block
304
+ """
305
+ context = NominalUnificationContext(num_blocks=len(blocks))
306
+ context.detect_bindings_in_blocks(blocks)
307
+
308
+ # Get bound variables from canonical block
309
+ canonical_bound = context.bound_variables[canonical_idx]
310
+
311
+ # For each other block, find corresponding variables
312
+ for block_idx, block in enumerate(blocks):
313
+ if block_idx == canonical_idx:
314
+ # Canonical block: identity mapping
315
+ for var in canonical_bound:
316
+ context.add_correspondence(var, block_idx, var)
317
+ continue
318
+
319
+ # Match bound variables between canonical and this block
320
+ block_bound = context.bound_variables[block_idx]
321
+
322
+ # Simple heuristic: match variables in order of first occurrence
323
+ # This works for our use case where blocks have parallel structure
324
+ canonical_vars_ordered = _get_variables_in_order(canonical_block)
325
+ block_vars_ordered = _get_variables_in_order(block)
326
+
327
+ # Filter to bound variables only
328
+ canonical_bound_ordered = [v for v in canonical_vars_ordered if v in canonical_bound]
329
+ block_bound_ordered = [v for v in block_vars_ordered if v in block_bound]
330
+
331
+ # Match in order
332
+ for canonical_var, block_var in zip(canonical_bound_ordered, block_bound_ordered):
333
+ context.add_correspondence(canonical_var, canonical_idx, canonical_var)
334
+ context.add_correspondence(canonical_var, block_idx, block_var)
335
+
336
+ return context.export_to_hygienic_renames()
337
+
338
+
339
+ def _get_variables_in_order(block: List[ast.AST]) -> List[str]:
340
+ """
341
+ Get all variable names from a block in order of first occurrence.
342
+
343
+ Args:
344
+ block: List of AST statements
345
+
346
+ Returns:
347
+ List of variable names in order of first use
348
+ """
349
+ seen = set()
350
+ ordered = []
351
+
352
+ class VariableCollector(ast.NodeVisitor):
353
+ def visit_Name(self, node: ast.Name) -> None:
354
+ if node.id not in seen:
355
+ seen.add(node.id)
356
+ ordered.append(node.id)
357
+ self.generic_visit(node)
358
+
359
+ collector = VariableCollector()
360
+ return _gather_variables_in_order(block, collector, ordered)
361
+
362
+
363
+ def _record_binding_sites(
364
+ table: Dict[Tuple[int, str], List[ast.AST]], block_idx: int, binding: Binding
365
+ ) -> None:
366
+ key = (block_idx, binding.name)
367
+ table.setdefault(key, []).append(binding.node)
368
+
369
+
370
+ def _gather_variables_in_order(
371
+ block: List[ast.AST], collector: ast.NodeVisitor, ordered: List[str]
372
+ ) -> List[str]:
373
+ for stmt in block:
374
+ collector.visit(stmt)
375
+ return ordered