wexample-wex-addon-dev-python 12.2.0__py3-none-any.whl → 13.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.
- wexample_wex_addon_dev_python/commands/code/rename.py +41 -19
- wexample_wex_addon_dev_python/file/python_pyproject_toml_file.py +11 -11
- wexample_wex_addon_dev_python/python_addon_manager.py +1 -1
- wexample_wex_addon_dev_python/refactor/abstract_python_path_rename_handler.py +104 -0
- wexample_wex_addon_dev_python/refactor/abstract_python_rename_handler.py +89 -0
- wexample_wex_addon_dev_python/refactor/abstract_python_symbol_rename_handler.py +183 -0
- wexample_wex_addon_dev_python/refactor/applier.py +49 -8
- wexample_wex_addon_dev_python/refactor/python_class_rename_handler.py +19 -0
- wexample_wex_addon_dev_python/refactor/python_common.py +426 -0
- wexample_wex_addon_dev_python/refactor/python_constant_rename_handler.py +37 -0
- wexample_wex_addon_dev_python/refactor/python_function_rename_handler.py +21 -0
- wexample_wex_addon_dev_python/refactor/python_module_rename_handler.py +74 -0
- wexample_wex_addon_dev_python/refactor/python_package_rename_handler.py +83 -219
- wexample_wex_addon_dev_python/refactor/rename_plan.py +58 -17
- wexample_wex_addon_dev_python/refactor/symbol_kind.py +21 -6
- wexample_wex_addon_dev_python/services/python/commands/service/setup.py +2 -2
- wexample_wex_addon_dev_python/workdir/python_package_workdir.py +8 -8
- wexample_wex_addon_dev_python/workdir/python_workdir.py +6 -6
- {wexample_wex_addon_dev_python-12.2.0.dist-info → wexample_wex_addon_dev_python-13.0.0.dist-info}/METADATA +10 -10
- {wexample_wex_addon_dev_python-12.2.0.dist-info → wexample_wex_addon_dev_python-13.0.0.dist-info}/RECORD +24 -16
- /wexample_wex_addon_dev_python/{helpers → helper}/__init__.py +0 -0
- /wexample_wex_addon_dev_python/{helpers → helper}/pdm.py +0 -0
- {wexample_wex_addon_dev_python-12.2.0.dist-info → wexample_wex_addon_dev_python-13.0.0.dist-info}/WHEEL +0 -0
- {wexample_wex_addon_dev_python-12.2.0.dist-info → wexample_wex_addon_dev_python-13.0.0.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import libcst as cst
|
|
8
|
+
|
|
9
|
+
# Directories never scanned for source files (build/venv artefacts, VCS).
|
|
10
|
+
SKIP_DIR_NAMES: frozenset[str] = frozenset({
|
|
11
|
+
"__pycache__",
|
|
12
|
+
".venv",
|
|
13
|
+
"venv",
|
|
14
|
+
"env",
|
|
15
|
+
".git",
|
|
16
|
+
"node_modules",
|
|
17
|
+
".pytest_cache",
|
|
18
|
+
".mypy_cache",
|
|
19
|
+
".ruff_cache",
|
|
20
|
+
".pdm-build",
|
|
21
|
+
"dist",
|
|
22
|
+
"build",
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def dotted_to_parts(node) -> tuple[str, ...] | None:
|
|
27
|
+
"""Flatten a chained `cst.Attribute` / `cst.Name` into ('a', 'b', 'c')."""
|
|
28
|
+
parts: list[str] = []
|
|
29
|
+
cur = node
|
|
30
|
+
while isinstance(cur, cst.Attribute):
|
|
31
|
+
if not isinstance(cur.attr, cst.Name):
|
|
32
|
+
return None
|
|
33
|
+
parts.append(cur.attr.value)
|
|
34
|
+
cur = cur.value
|
|
35
|
+
if not isinstance(cur, cst.Name):
|
|
36
|
+
return None
|
|
37
|
+
parts.append(cur.value)
|
|
38
|
+
parts.reverse()
|
|
39
|
+
return tuple(parts)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def file_to_package_parts(py_file: Path) -> tuple[str, ...] | None:
|
|
43
|
+
"""Return the dotted parts of the Python package containing `py_file`.
|
|
44
|
+
|
|
45
|
+
Walks up from the file's directory through every parent that has an
|
|
46
|
+
`__init__.py`; the walk stops at the first ancestor without one, which
|
|
47
|
+
marks the package root. Returns None when the file isn't inside any
|
|
48
|
+
Python package (no `__init__.py` chain). Used to resolve relative
|
|
49
|
+
imports to their absolute equivalent.
|
|
50
|
+
"""
|
|
51
|
+
parts: list[str] = []
|
|
52
|
+
current = py_file.parent
|
|
53
|
+
while (current / "__init__.py").is_file():
|
|
54
|
+
parts.append(current.name)
|
|
55
|
+
up = current.parent
|
|
56
|
+
if up == current:
|
|
57
|
+
break
|
|
58
|
+
current = up
|
|
59
|
+
if not parts:
|
|
60
|
+
return None
|
|
61
|
+
parts.reverse()
|
|
62
|
+
return tuple(parts)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def iter_python_files(scope_roots: list[Path]) -> Iterator[Path]:
|
|
66
|
+
"""Iterate every .py file under any scope root, skipping build/VCS dirs."""
|
|
67
|
+
seen: set[str] = set()
|
|
68
|
+
for root in scope_roots:
|
|
69
|
+
stack: list[Path] = [root]
|
|
70
|
+
while stack:
|
|
71
|
+
current = stack.pop()
|
|
72
|
+
try:
|
|
73
|
+
for entry in current.iterdir():
|
|
74
|
+
if entry.is_dir():
|
|
75
|
+
if entry.name not in SKIP_DIR_NAMES:
|
|
76
|
+
stack.append(entry)
|
|
77
|
+
elif entry.suffix == ".py":
|
|
78
|
+
key = str(entry.resolve())
|
|
79
|
+
if key in seen:
|
|
80
|
+
continue
|
|
81
|
+
seen.add(key)
|
|
82
|
+
yield entry
|
|
83
|
+
except (PermissionError, FileNotFoundError):
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def parts_to_dotted(parts: tuple[str, ...]):
|
|
88
|
+
node: cst.BaseExpression = cst.Name(parts[0])
|
|
89
|
+
for part in parts[1:]:
|
|
90
|
+
node = cst.Attribute(value=node, attr=cst.Name(part))
|
|
91
|
+
return node
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def rewrite_imports(
|
|
95
|
+
py_file: Path,
|
|
96
|
+
old_parts: tuple[str, ...],
|
|
97
|
+
new_parts: tuple[str, ...],
|
|
98
|
+
) -> str | None:
|
|
99
|
+
"""Return rewritten source, or None when no import touched this file."""
|
|
100
|
+
source = py_file.read_text(encoding="utf-8")
|
|
101
|
+
try:
|
|
102
|
+
module = cst.parse_module(source)
|
|
103
|
+
except cst.ParserSyntaxError:
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
transformer = _ImportRenameTransformer(
|
|
107
|
+
old_parts=old_parts,
|
|
108
|
+
new_parts=new_parts,
|
|
109
|
+
file_pkg=file_to_package_parts(py_file),
|
|
110
|
+
)
|
|
111
|
+
new_module = module.visit(transformer)
|
|
112
|
+
if not transformer.changed:
|
|
113
|
+
return None
|
|
114
|
+
return new_module.code
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def rewrite_symbol_references(
|
|
118
|
+
py_file: Path,
|
|
119
|
+
parent_parts: tuple[str, ...],
|
|
120
|
+
old_name: str,
|
|
121
|
+
new_name: str,
|
|
122
|
+
*,
|
|
123
|
+
is_definition_file: bool = False,
|
|
124
|
+
) -> str | None:
|
|
125
|
+
"""Return rewritten source, or None when no occurrence touched this file.
|
|
126
|
+
|
|
127
|
+
Handles imports (`from <parent> import <old_name>`, with or without `as`),
|
|
128
|
+
dotted imports (`import <parent>.<old_name>`), attribute access
|
|
129
|
+
(`<parent>.<old_name>`), and bare references whose qualified name resolves
|
|
130
|
+
to the external dotted name. In the definition file, locally-bound
|
|
131
|
+
`<old_name>` references are renamed too.
|
|
132
|
+
"""
|
|
133
|
+
source = py_file.read_text(encoding="utf-8")
|
|
134
|
+
# Cheap pre-filter: QualifiedNameProvider scope analysis is expensive,
|
|
135
|
+
# most files don't even mention the symbol. The substring check matches
|
|
136
|
+
# def statements, imports, attribute access and bare references in one go.
|
|
137
|
+
if old_name not in source:
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
module = cst.parse_module(source)
|
|
142
|
+
except cst.ParserSyntaxError:
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
wrapper = cst.metadata.MetadataWrapper(module)
|
|
146
|
+
transformer = _SymbolRenameTransformer(
|
|
147
|
+
parent_parts=parent_parts,
|
|
148
|
+
old_name=old_name,
|
|
149
|
+
new_name=new_name,
|
|
150
|
+
is_definition_file=is_definition_file,
|
|
151
|
+
)
|
|
152
|
+
new_module = wrapper.visit(transformer)
|
|
153
|
+
if not transformer.changed:
|
|
154
|
+
return None
|
|
155
|
+
return new_module.code
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass
|
|
159
|
+
class RenameReport:
|
|
160
|
+
"""Outcome of a rename run.
|
|
161
|
+
|
|
162
|
+
`source_path`/`target_path` describe the move (or proposed move in dry-run);
|
|
163
|
+
they may point to a directory (PACKAGE) or to a .py file (MODULE).
|
|
164
|
+
`files_changed` lists every .py whose imports were rewritten.
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
files_changed: list[Path] = field(default_factory=list)
|
|
168
|
+
files_scanned: int = 0
|
|
169
|
+
source_path: Path | None = None
|
|
170
|
+
target_path: Path | None = None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class _ImportRenameTransformer(cst.CSTTransformer):
|
|
174
|
+
"""Rewrites import statements that target the renamed package/module.
|
|
175
|
+
|
|
176
|
+
Covers four shapes:
|
|
177
|
+
A. `from <old>.<...> import X` / `import <old>.<...>` — absolute path
|
|
178
|
+
starts with the renamed entity.
|
|
179
|
+
B. `from <parent-of-old> import <old.last_part>` — the entity is
|
|
180
|
+
imported by name (alias position) under its direct parent.
|
|
181
|
+
C. Relative variants of A: `from .<x>.<...> import Y` whose resolved
|
|
182
|
+
absolute path starts with the renamed entity. Needs the current
|
|
183
|
+
file's dotted package (`file_pkg`) to resolve the dots.
|
|
184
|
+
D. Relative variants of B: `from .<...> import <old.last_part>` whose
|
|
185
|
+
resolved module path equals the old entity's direct parent.
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
def __init__(
|
|
189
|
+
self,
|
|
190
|
+
old_parts: tuple[str, ...],
|
|
191
|
+
new_parts: tuple[str, ...],
|
|
192
|
+
file_pkg: tuple[str, ...] | None = None,
|
|
193
|
+
) -> None:
|
|
194
|
+
super().__init__()
|
|
195
|
+
self.old_parts = old_parts
|
|
196
|
+
self.new_parts = new_parts
|
|
197
|
+
self.file_pkg = file_pkg
|
|
198
|
+
self._n = len(old_parts)
|
|
199
|
+
self.changed = False
|
|
200
|
+
|
|
201
|
+
def leave_Import(
|
|
202
|
+
self,
|
|
203
|
+
original_node: cst.Import,
|
|
204
|
+
updated_node: cst.Import,
|
|
205
|
+
) -> cst.Import:
|
|
206
|
+
new_names = []
|
|
207
|
+
touched = False
|
|
208
|
+
for alias in updated_node.names:
|
|
209
|
+
parts = dotted_to_parts(alias.name)
|
|
210
|
+
if parts is None or not self._starts_with(parts):
|
|
211
|
+
new_names.append(alias)
|
|
212
|
+
continue
|
|
213
|
+
new_parts = self.new_parts + parts[self._n:]
|
|
214
|
+
new_names.append(alias.with_changes(name=parts_to_dotted(new_parts)))
|
|
215
|
+
touched = True
|
|
216
|
+
if not touched:
|
|
217
|
+
return updated_node
|
|
218
|
+
self.changed = True
|
|
219
|
+
return updated_node.with_changes(names=new_names)
|
|
220
|
+
|
|
221
|
+
def leave_ImportFrom(
|
|
222
|
+
self,
|
|
223
|
+
original_node: cst.ImportFrom,
|
|
224
|
+
updated_node: cst.ImportFrom,
|
|
225
|
+
) -> cst.ImportFrom:
|
|
226
|
+
rel_count = len(updated_node.relative)
|
|
227
|
+
resolved_pkg = self._resolve_relative_prefix(rel_count)
|
|
228
|
+
if rel_count > 0 and resolved_pkg is None:
|
|
229
|
+
# Relative import we can't resolve (no file_pkg or depth too deep) →
|
|
230
|
+
# leave it alone instead of guessing.
|
|
231
|
+
return updated_node
|
|
232
|
+
|
|
233
|
+
module_parts = (
|
|
234
|
+
dotted_to_parts(updated_node.module)
|
|
235
|
+
if updated_node.module is not None
|
|
236
|
+
else ()
|
|
237
|
+
)
|
|
238
|
+
if updated_node.module is not None and module_parts is None:
|
|
239
|
+
return updated_node
|
|
240
|
+
|
|
241
|
+
absolute_module = (resolved_pkg or ()) + (module_parts or ())
|
|
242
|
+
|
|
243
|
+
# Case A/C — the module side IS the renamed entity (or below it).
|
|
244
|
+
if self._starts_with(absolute_module):
|
|
245
|
+
new_absolute = self.new_parts + absolute_module[self._n:]
|
|
246
|
+
new_module_parts = new_absolute[len(resolved_pkg or ()):]
|
|
247
|
+
self.changed = True
|
|
248
|
+
new_module_node = (
|
|
249
|
+
parts_to_dotted(new_module_parts) if new_module_parts else None
|
|
250
|
+
)
|
|
251
|
+
return updated_node.with_changes(module=new_module_node)
|
|
252
|
+
|
|
253
|
+
# Case B/D — the module is exactly the direct parent of the renamed
|
|
254
|
+
# entity, and the entity appears as an imported name.
|
|
255
|
+
if (
|
|
256
|
+
self._n >= 1
|
|
257
|
+
and len(absolute_module) == self._n - 1
|
|
258
|
+
and absolute_module == self.old_parts[:-1]
|
|
259
|
+
):
|
|
260
|
+
old_last = self.old_parts[-1]
|
|
261
|
+
new_last = self.new_parts[-1]
|
|
262
|
+
new_names = []
|
|
263
|
+
touched = False
|
|
264
|
+
for alias in updated_node.names:
|
|
265
|
+
if (
|
|
266
|
+
isinstance(alias.name, cst.Name)
|
|
267
|
+
and alias.name.value == old_last
|
|
268
|
+
):
|
|
269
|
+
new_names.append(alias.with_changes(name=cst.Name(new_last)))
|
|
270
|
+
touched = True
|
|
271
|
+
else:
|
|
272
|
+
new_names.append(alias)
|
|
273
|
+
if touched:
|
|
274
|
+
self.changed = True
|
|
275
|
+
return updated_node.with_changes(names=new_names)
|
|
276
|
+
|
|
277
|
+
return updated_node
|
|
278
|
+
|
|
279
|
+
def _resolve_relative_prefix(
|
|
280
|
+
self, rel_count: int
|
|
281
|
+
) -> tuple[str, ...] | None:
|
|
282
|
+
"""Convert a relative-import dot count into the absolute package
|
|
283
|
+
prefix it resolves to. Returns `()` for an absolute (non-relative)
|
|
284
|
+
import, the resolved prefix for a relative one, or None when
|
|
285
|
+
resolution is impossible (no file_pkg or depth overflow)."""
|
|
286
|
+
if rel_count == 0:
|
|
287
|
+
return ()
|
|
288
|
+
if self.file_pkg is None:
|
|
289
|
+
return None
|
|
290
|
+
# `from . import X` ← parent_count 0 (current package)
|
|
291
|
+
# `from .. import X` ← parent_count 1 (one level up)
|
|
292
|
+
parent_count = rel_count - 1
|
|
293
|
+
if parent_count > len(self.file_pkg):
|
|
294
|
+
return None
|
|
295
|
+
if parent_count == 0:
|
|
296
|
+
return self.file_pkg
|
|
297
|
+
return self.file_pkg[:-parent_count]
|
|
298
|
+
|
|
299
|
+
def _starts_with(self, parts: tuple[str, ...]) -> bool:
|
|
300
|
+
return len(parts) >= self._n and parts[: self._n] == self.old_parts
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
class _SymbolRenameTransformer(cst.CSTTransformer):
|
|
304
|
+
"""Rewrites references to one specific symbol (parent.old_name → parent.new_name).
|
|
305
|
+
|
|
306
|
+
Two cases handled uniformly via libcst's QualifiedNameProvider:
|
|
307
|
+
- Consumer file: matches the external IMPORT-sourced qualified name
|
|
308
|
+
`<parent>.<old_name>` on Name/Attribute usages, and rewrites
|
|
309
|
+
`from <parent> import <old_name>` / `import <parent>.<old_name>` lines.
|
|
310
|
+
- Definition file: when `is_definition_file=True`, additionally rewrites
|
|
311
|
+
Name nodes whose qualified name is the bare `<old_name>` with LOCAL
|
|
312
|
+
source — that covers the def statement itself plus same-module references.
|
|
313
|
+
|
|
314
|
+
Alias preservation: `from M import Old as X` keeps `X` untouched because
|
|
315
|
+
`original.value == 'X' != 'Old'`; only the import's `name` part is
|
|
316
|
+
rewritten via `leave_ImportFrom`.
|
|
317
|
+
"""
|
|
318
|
+
|
|
319
|
+
METADATA_DEPENDENCIES = (cst.metadata.QualifiedNameProvider,)
|
|
320
|
+
|
|
321
|
+
def __init__(
|
|
322
|
+
self,
|
|
323
|
+
parent_parts: tuple[str, ...],
|
|
324
|
+
old_name: str,
|
|
325
|
+
new_name: str,
|
|
326
|
+
*,
|
|
327
|
+
is_definition_file: bool = False,
|
|
328
|
+
) -> None:
|
|
329
|
+
super().__init__()
|
|
330
|
+
self.parent_parts = parent_parts
|
|
331
|
+
self.old_name = old_name
|
|
332
|
+
self.new_name = new_name
|
|
333
|
+
self.external_qname = ".".join(parent_parts) + "." + old_name
|
|
334
|
+
self.is_definition_file = is_definition_file
|
|
335
|
+
self.changed = False
|
|
336
|
+
|
|
337
|
+
def leave_Attribute(
|
|
338
|
+
self,
|
|
339
|
+
original_node: cst.Attribute,
|
|
340
|
+
updated_node: cst.Attribute,
|
|
341
|
+
) -> cst.Attribute:
|
|
342
|
+
if (
|
|
343
|
+
not isinstance(original_node.attr, cst.Name)
|
|
344
|
+
or original_node.attr.value != self.old_name
|
|
345
|
+
):
|
|
346
|
+
return updated_node
|
|
347
|
+
if not self._qname_matches_external(original_node):
|
|
348
|
+
return updated_node
|
|
349
|
+
self.changed = True
|
|
350
|
+
return updated_node.with_changes(attr=cst.Name(self.new_name))
|
|
351
|
+
|
|
352
|
+
def leave_Import(
|
|
353
|
+
self,
|
|
354
|
+
original_node: cst.Import,
|
|
355
|
+
updated_node: cst.Import,
|
|
356
|
+
) -> cst.Import:
|
|
357
|
+
new_names: list[cst.ImportAlias] = []
|
|
358
|
+
touched = False
|
|
359
|
+
full_parts = self.parent_parts + (self.old_name,)
|
|
360
|
+
for alias in updated_node.names:
|
|
361
|
+
parts = dotted_to_parts(alias.name)
|
|
362
|
+
if parts == full_parts:
|
|
363
|
+
new_parts = self.parent_parts + (self.new_name,)
|
|
364
|
+
new_names.append(alias.with_changes(name=parts_to_dotted(new_parts)))
|
|
365
|
+
touched = True
|
|
366
|
+
else:
|
|
367
|
+
new_names.append(alias)
|
|
368
|
+
if not touched:
|
|
369
|
+
return updated_node
|
|
370
|
+
self.changed = True
|
|
371
|
+
return updated_node.with_changes(names=new_names)
|
|
372
|
+
|
|
373
|
+
def leave_ImportFrom(
|
|
374
|
+
self,
|
|
375
|
+
original_node: cst.ImportFrom,
|
|
376
|
+
updated_node: cst.ImportFrom,
|
|
377
|
+
) -> cst.ImportFrom:
|
|
378
|
+
if updated_node.module is None:
|
|
379
|
+
return updated_node
|
|
380
|
+
module_parts = dotted_to_parts(updated_node.module)
|
|
381
|
+
if module_parts != self.parent_parts:
|
|
382
|
+
return updated_node
|
|
383
|
+
if not isinstance(updated_node.names, (list, tuple)):
|
|
384
|
+
return updated_node
|
|
385
|
+
new_names: list[cst.ImportAlias] = []
|
|
386
|
+
touched = False
|
|
387
|
+
for alias in updated_node.names:
|
|
388
|
+
if (
|
|
389
|
+
isinstance(alias.name, cst.Name)
|
|
390
|
+
and alias.name.value == self.old_name
|
|
391
|
+
):
|
|
392
|
+
new_names.append(alias.with_changes(name=cst.Name(self.new_name)))
|
|
393
|
+
touched = True
|
|
394
|
+
else:
|
|
395
|
+
new_names.append(alias)
|
|
396
|
+
if not touched:
|
|
397
|
+
return updated_node
|
|
398
|
+
self.changed = True
|
|
399
|
+
return updated_node.with_changes(names=new_names)
|
|
400
|
+
|
|
401
|
+
def leave_Name(
|
|
402
|
+
self,
|
|
403
|
+
original_node: cst.Name,
|
|
404
|
+
updated_node: cst.Name,
|
|
405
|
+
) -> cst.Name:
|
|
406
|
+
if original_node.value != self.old_name:
|
|
407
|
+
return updated_node
|
|
408
|
+
quals = self.get_metadata(
|
|
409
|
+
cst.metadata.QualifiedNameProvider, original_node, set()
|
|
410
|
+
)
|
|
411
|
+
for q in quals:
|
|
412
|
+
if q.name == self.external_qname:
|
|
413
|
+
self.changed = True
|
|
414
|
+
return updated_node.with_changes(value=self.new_name)
|
|
415
|
+
if (
|
|
416
|
+
self.is_definition_file
|
|
417
|
+
and q.name == self.old_name
|
|
418
|
+
and q.source == cst.metadata.QualifiedNameSource.LOCAL
|
|
419
|
+
):
|
|
420
|
+
self.changed = True
|
|
421
|
+
return updated_node.with_changes(value=self.new_name)
|
|
422
|
+
return updated_node
|
|
423
|
+
|
|
424
|
+
def _qname_matches_external(self, node: cst.CSTNode) -> bool:
|
|
425
|
+
quals = self.get_metadata(cst.metadata.QualifiedNameProvider, node, set())
|
|
426
|
+
return any(q.name == self.external_qname for q in quals)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import libcst as cst
|
|
4
|
+
from wexample_helpers.decorator.base_class import base_class
|
|
5
|
+
|
|
6
|
+
from wexample_wex_addon_dev_python.refactor.abstract_python_symbol_rename_handler import (
|
|
7
|
+
AbstractPythonSymbolRenameHandler,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@base_class
|
|
12
|
+
class PythonConstantRenameHandler(AbstractPythonSymbolRenameHandler):
|
|
13
|
+
"""Rename a top-level constant — a module-level assignment whose target is
|
|
14
|
+
a single bare name. Catches both annotated (`FOO: int = 1`) and plain
|
|
15
|
+
(`FOO = 1`) forms. Tuple/multi-target assignments are intentionally
|
|
16
|
+
skipped to keep the predicate unambiguous."""
|
|
17
|
+
|
|
18
|
+
def _is_definition_node(self, node: cst.CSTNode, name: str) -> bool:
|
|
19
|
+
if isinstance(node, cst.SimpleStatementLine):
|
|
20
|
+
for small in node.body:
|
|
21
|
+
if self._statement_assigns(small, name):
|
|
22
|
+
return True
|
|
23
|
+
return False
|
|
24
|
+
|
|
25
|
+
def _kind_label(self) -> str:
|
|
26
|
+
return "constant"
|
|
27
|
+
|
|
28
|
+
def _statement_assigns(self, small: cst.BaseSmallStatement, name: str) -> bool:
|
|
29
|
+
if isinstance(small, cst.Assign):
|
|
30
|
+
if len(small.targets) != 1:
|
|
31
|
+
return False
|
|
32
|
+
target = small.targets[0].target
|
|
33
|
+
return isinstance(target, cst.Name) and target.value == name
|
|
34
|
+
if isinstance(small, cst.AnnAssign):
|
|
35
|
+
target = small.target
|
|
36
|
+
return isinstance(target, cst.Name) and target.value == name
|
|
37
|
+
return False
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import libcst as cst
|
|
4
|
+
from wexample_helpers.decorator.base_class import base_class
|
|
5
|
+
|
|
6
|
+
from wexample_wex_addon_dev_python.refactor.abstract_python_symbol_rename_handler import (
|
|
7
|
+
AbstractPythonSymbolRenameHandler,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@base_class
|
|
12
|
+
class PythonFunctionRenameHandler(AbstractPythonSymbolRenameHandler):
|
|
13
|
+
"""Rename a top-level function declared with `def foo(...)` at module level.
|
|
14
|
+
Async-def is covered too. Methods on classes are out of scope here — they
|
|
15
|
+
need type-aware dispatch resolution."""
|
|
16
|
+
|
|
17
|
+
def _is_definition_node(self, node: cst.CSTNode, name: str) -> bool:
|
|
18
|
+
return isinstance(node, cst.FunctionDef) and node.name.value == name
|
|
19
|
+
|
|
20
|
+
def _kind_label(self) -> str:
|
|
21
|
+
return "function"
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from wexample_helpers.decorator.base_class import base_class
|
|
6
|
+
|
|
7
|
+
from wexample_wex_addon_dev_python.refactor.abstract_python_path_rename_handler import (
|
|
8
|
+
AbstractPythonPathRenameHandler,
|
|
9
|
+
)
|
|
10
|
+
from wexample_wex_addon_dev_python.refactor.python_common import SKIP_DIR_NAMES
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@base_class
|
|
14
|
+
class PythonModuleRenameHandler(AbstractPythonPathRenameHandler):
|
|
15
|
+
"""Rename a single Python module (a `.py` file) and propagate the
|
|
16
|
+
import-rewrite. Unlike a package, the move can cross parent directories
|
|
17
|
+
— the target's parent must already exist (we don't create new packages
|
|
18
|
+
implicitly; the preflight surfaces a missing parent as a clean error)."""
|
|
19
|
+
|
|
20
|
+
def __attrs_post_init__(self) -> None:
|
|
21
|
+
super().__attrs_post_init__()
|
|
22
|
+
# A module dotted name needs at least one parent package — a bare
|
|
23
|
+
# top-level name has nowhere to live and `_derive_target_path`
|
|
24
|
+
# couldn't compute a target for it.
|
|
25
|
+
if "." not in self.source or "." not in self.target:
|
|
26
|
+
raise ValueError(
|
|
27
|
+
"module source/target must be fully dotted (e.g. 'pkg.sub.mod')"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
def _derive_target_path(self, source_path: Path) -> Path:
|
|
31
|
+
n = len(self._source_parts)
|
|
32
|
+
# Strip the .py extension to align with `_source_parts`, swap the
|
|
33
|
+
# trailing N parts for `_target_parts`, then re-attach the extension.
|
|
34
|
+
file_parts_stripped = source_path.parts[:-1] + (source_path.stem,)
|
|
35
|
+
if file_parts_stripped[-n:] != self._source_parts:
|
|
36
|
+
raise RuntimeError(
|
|
37
|
+
f"Source file {source_path} doesn't end with parts {self._source_parts}"
|
|
38
|
+
)
|
|
39
|
+
prefix = file_parts_stripped[:-n]
|
|
40
|
+
target_with_ext = self._target_parts[:-1] + (self._target_parts[-1] + ".py",)
|
|
41
|
+
return Path(*prefix, *target_with_ext)
|
|
42
|
+
|
|
43
|
+
def _find_source_path(self) -> Path:
|
|
44
|
+
parent_parts = self._source_parts[:-1]
|
|
45
|
+
file_name = self._source_parts[-1] + ".py"
|
|
46
|
+
n_parent = len(parent_parts)
|
|
47
|
+
|
|
48
|
+
matches: list[Path] = []
|
|
49
|
+
for root in self.scope_roots:
|
|
50
|
+
for init_file in root.rglob("__init__.py"):
|
|
51
|
+
if any(part in SKIP_DIR_NAMES for part in init_file.parent.parts):
|
|
52
|
+
continue
|
|
53
|
+
pkg_dir = init_file.parent
|
|
54
|
+
if len(pkg_dir.parts) < n_parent:
|
|
55
|
+
continue
|
|
56
|
+
if pkg_dir.parts[-n_parent:] != parent_parts:
|
|
57
|
+
continue
|
|
58
|
+
candidate = pkg_dir / file_name
|
|
59
|
+
if candidate.is_file():
|
|
60
|
+
matches.append(candidate)
|
|
61
|
+
|
|
62
|
+
unique = list({str(m): m for m in matches}.values())
|
|
63
|
+
|
|
64
|
+
if not unique:
|
|
65
|
+
roots_str = ", ".join(str(r) for r in self.scope_roots)
|
|
66
|
+
raise FileNotFoundError(
|
|
67
|
+
f"No module file matching '{self.source}' under: {roots_str}"
|
|
68
|
+
)
|
|
69
|
+
if len(unique) > 1:
|
|
70
|
+
joined = "\n ".join(str(m) for m in unique)
|
|
71
|
+
raise ValueError(
|
|
72
|
+
f"Multiple modules match '{self.source}', refine the dotted name:\n {joined}"
|
|
73
|
+
)
|
|
74
|
+
return unique[0]
|