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.
- code_towel-1.0.0.dist-info/METADATA +722 -0
- code_towel-1.0.0.dist-info/RECORD +27 -0
- code_towel-1.0.0.dist-info/WHEEL +5 -0
- code_towel-1.0.0.dist-info/entry_points.txt +4 -0
- code_towel-1.0.0.dist-info/licenses/LICENSE +201 -0
- code_towel-1.0.0.dist-info/top_level.txt +1 -0
- towel/__init__.py +20 -0
- towel/cli.py +813 -0
- towel/unification/__init__.py +24 -0
- towel/unification/assignment_analyzer.py +403 -0
- towel/unification/ast_normalizer.py +223 -0
- towel/unification/ast_pretty_printer.py +133 -0
- towel/unification/binding_detector.py +381 -0
- towel/unification/block_signature.py +123 -0
- towel/unification/builtins.py +209 -0
- towel/unification/exceptions.py +113 -0
- towel/unification/extractor.py +884 -0
- towel/unification/models.py +190 -0
- towel/unification/nominal_unifier.py +375 -0
- towel/unification/orphan_detector.py +187 -0
- towel/unification/pipeline.py +524 -0
- towel/unification/project_layout.py +201 -0
- towel/unification/refactor_engine.py +3381 -0
- towel/unification/scope_analyzer.py +597 -0
- towel/unification/unifier.py +2128 -0
- towel/unification/visitor_utils.py +83 -0
- towel/unification/visitors.py +377 -0
|
@@ -0,0 +1,201 @@
|
|
|
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 understanding project/package layout to generate robust import paths
|
|
17
|
+
for cross-file refactorings.
|
|
18
|
+
|
|
19
|
+
This module is runtime-dependency-free and uses only the standard library.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any, Dict, Iterable, List, Optional
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _load_pyproject(project_root: Path) -> Dict[str, Any]:
|
|
30
|
+
"""Best-effort load of pyproject.toml with stdlib only.
|
|
31
|
+
|
|
32
|
+
Returns an empty dict if parsing fails or file does not exist.
|
|
33
|
+
"""
|
|
34
|
+
pyproject_path = project_root / "pyproject.toml"
|
|
35
|
+
if not pyproject_path.exists():
|
|
36
|
+
return {}
|
|
37
|
+
|
|
38
|
+
# Python 3.11+: tomllib in stdlib; earlier versions won't have it.
|
|
39
|
+
try:
|
|
40
|
+
import tomllib
|
|
41
|
+
|
|
42
|
+
with pyproject_path.open("rb") as f:
|
|
43
|
+
return tomllib.load(f)
|
|
44
|
+
except Exception:
|
|
45
|
+
# Silently ignore - we don't want a hard runtime dependency
|
|
46
|
+
return {}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _is_package_dir(path: Path, pep420: bool) -> bool:
|
|
50
|
+
"""Determine if a directory is a Python package root.
|
|
51
|
+
|
|
52
|
+
- Traditional packages: must contain __init__.py
|
|
53
|
+
- Namespace packages (PEP 420): any directory is a potential package component
|
|
54
|
+
"""
|
|
55
|
+
if not path.is_dir():
|
|
56
|
+
return False
|
|
57
|
+
if pep420:
|
|
58
|
+
return True
|
|
59
|
+
return (path / "__init__.py").exists()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class ProjectLayout:
|
|
64
|
+
"""Represents the directory structure and import configuration of a Python project.
|
|
65
|
+
|
|
66
|
+
Discovers project root markers (pyproject.toml, setup.py, .git) and source
|
|
67
|
+
roots from package configuration to generate correct import paths for refactored code.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
project_root: Path
|
|
71
|
+
source_roots: List[Path]
|
|
72
|
+
prefer_absolute_imports: bool = True
|
|
73
|
+
pep420_namespace_packages: bool = True
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def discover(
|
|
77
|
+
cls,
|
|
78
|
+
start_path: Path,
|
|
79
|
+
*,
|
|
80
|
+
prefer_absolute_imports: Optional[bool] = None,
|
|
81
|
+
pep420_namespace_packages: Optional[bool] = None,
|
|
82
|
+
) -> "ProjectLayout":
|
|
83
|
+
"""Discover project layout from a starting path.
|
|
84
|
+
|
|
85
|
+
Heuristics:
|
|
86
|
+
- Project root is the nearest ancestor containing pyproject.toml, setup.cfg, setup.py, or .git
|
|
87
|
+
- Source roots come from pyproject [tool.setuptools.package-dir] (e.g., {"": "src"})
|
|
88
|
+
or default to [project_root] (flat layout). If mapping exists, add each mapped directory.
|
|
89
|
+
"""
|
|
90
|
+
project_root = _find_project_root(start_path)
|
|
91
|
+
data = _load_pyproject(project_root)
|
|
92
|
+
|
|
93
|
+
# Default settings
|
|
94
|
+
prefer_abs = True if prefer_absolute_imports is None else prefer_absolute_imports
|
|
95
|
+
pep420 = True if pep420_namespace_packages is None else pep420_namespace_packages
|
|
96
|
+
|
|
97
|
+
# Determine source roots
|
|
98
|
+
source_roots: List[Path] = []
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
mapping = data.get("tool", {}).get("setuptools", {}).get("package-dir", {})
|
|
102
|
+
# mapping: {"" : "src"} or {"mypkg": "src/mypkg"}
|
|
103
|
+
candidates: Iterable[str] = mapping.values() if isinstance(mapping, dict) else []
|
|
104
|
+
for rel in candidates:
|
|
105
|
+
root = (project_root / rel).resolve()
|
|
106
|
+
if root.exists() and root.is_dir():
|
|
107
|
+
source_roots.append(root)
|
|
108
|
+
except Exception:
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
start_resolved = start_path.resolve()
|
|
112
|
+
start_dir = start_resolved.parent if start_resolved.is_file() else start_resolved
|
|
113
|
+
|
|
114
|
+
# If no explicit mapping was found, default to treating the entire project root
|
|
115
|
+
# as the import anchor. We intentionally DO NOT auto-add conventional directories
|
|
116
|
+
# like "src" or "lib" as source roots when pyproject mapping is absent, because
|
|
117
|
+
# test scenarios import modules with sys.path pointing at the project root.
|
|
118
|
+
# In that context, absolute module names should include the top-level directory
|
|
119
|
+
# component (e.g., "src.data_processor"), which fails if we strip it as a source root.
|
|
120
|
+
if not source_roots:
|
|
121
|
+
source_roots = [project_root]
|
|
122
|
+
|
|
123
|
+
# Prefer source roots that actually contain the starting directory. When none of the
|
|
124
|
+
# discovered roots include the path we're analyzing (common for test fixtures copied
|
|
125
|
+
# outside the main package tree), fall back to treating the starting directory as the
|
|
126
|
+
# root so relative imports remain valid.
|
|
127
|
+
filtered_roots: List[Path] = []
|
|
128
|
+
for root in source_roots:
|
|
129
|
+
try:
|
|
130
|
+
start_dir.relative_to(root)
|
|
131
|
+
filtered_roots.append(root)
|
|
132
|
+
except ValueError:
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
if filtered_roots:
|
|
136
|
+
source_roots = filtered_roots
|
|
137
|
+
elif start_dir != project_root:
|
|
138
|
+
project_root = start_dir
|
|
139
|
+
source_roots = [start_dir]
|
|
140
|
+
|
|
141
|
+
return cls(
|
|
142
|
+
project_root=project_root,
|
|
143
|
+
source_roots=source_roots,
|
|
144
|
+
prefer_absolute_imports=prefer_abs,
|
|
145
|
+
pep420_namespace_packages=pep420,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def module_name_for(self, file_path: Path) -> Optional[str]:
|
|
149
|
+
"""Compute a dotted module name for a given file path.
|
|
150
|
+
|
|
151
|
+
Returns None if file is outside all known source roots.
|
|
152
|
+
"""
|
|
153
|
+
file_path = file_path.resolve()
|
|
154
|
+
for src_root in self.source_roots:
|
|
155
|
+
try:
|
|
156
|
+
rel = file_path.relative_to(src_root)
|
|
157
|
+
if rel.suffix != ".py":
|
|
158
|
+
return None
|
|
159
|
+
parts = list(rel.with_suffix("").parts)
|
|
160
|
+
# Validate package path components
|
|
161
|
+
if not parts:
|
|
162
|
+
return None
|
|
163
|
+
if not self.pep420_namespace_packages:
|
|
164
|
+
# Ensure every directory in the chain is a traditional package
|
|
165
|
+
cursor = src_root
|
|
166
|
+
for comp in parts[:-1]:
|
|
167
|
+
cursor = cursor / comp
|
|
168
|
+
if not _is_package_dir(cursor, pep420=False):
|
|
169
|
+
# Not a classic package path; fall back to absolute-from-project
|
|
170
|
+
break
|
|
171
|
+
return ".".join(parts)
|
|
172
|
+
except ValueError:
|
|
173
|
+
continue
|
|
174
|
+
|
|
175
|
+
# Fallback: compute relative to project_root
|
|
176
|
+
try:
|
|
177
|
+
rel = file_path.relative_to(self.project_root)
|
|
178
|
+
if rel.suffix != ".py":
|
|
179
|
+
return None
|
|
180
|
+
return ".".join(rel.with_suffix("").parts)
|
|
181
|
+
except Exception:
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _find_project_root(start_path: Path) -> Path:
|
|
186
|
+
"""Find nearest ancestor that looks like the project root."""
|
|
187
|
+
start = start_path.resolve()
|
|
188
|
+
base_dir = start.parent if start.is_file() else start
|
|
189
|
+
|
|
190
|
+
# Prefer a nearby directory containing packaging markers, but fall back to
|
|
191
|
+
# the provided directory when none are found while walking upward.
|
|
192
|
+
markers = {"pyproject.toml", "setup.cfg", "setup.py"}
|
|
193
|
+
current = base_dir
|
|
194
|
+
while True:
|
|
195
|
+
if any((current / m).exists() for m in markers) or (current / ".git").exists():
|
|
196
|
+
return current
|
|
197
|
+
parent = current.parent
|
|
198
|
+
if parent == current:
|
|
199
|
+
break
|
|
200
|
+
current = parent
|
|
201
|
+
return base_dir
|