patchahead 0.3.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.
- patchahead/__init__.py +8 -0
- patchahead/analysis/__init__.py +52 -0
- patchahead/analysis/edits.py +143 -0
- patchahead/analysis/index.py +203 -0
- patchahead/analysis/python_ast.py +457 -0
- patchahead/apidiff/__init__.py +23 -0
- patchahead/apidiff/compare.py +366 -0
- patchahead/apidiff/download.py +95 -0
- patchahead/apidiff/surface.py +337 -0
- patchahead/ci.py +301 -0
- patchahead/cli.py +627 -0
- patchahead/config.py +284 -0
- patchahead/demo/__init__.py +256 -0
- patchahead/demo/fixtures/changes/field-rename.md +14 -0
- patchahead/demo/fixtures/changes/invoice-field-rename.md +21 -0
- patchahead/demo/fixtures/changes/kwarg-rename.md +14 -0
- patchahead/demo/fixtures/changes/method-rename.md +12 -0
- patchahead/demo/fixtures/changes/pagination-cursor.json +24 -0
- patchahead/demo/fixtures/changes/pagination-cursor.md +20 -0
- patchahead/demo/fixtures/changes/sdk-v2.md +31 -0
- patchahead/demo/fixtures/orders-service/README.md +51 -0
- patchahead/demo/fixtures/orders-service/app/__init__.py +0 -0
- patchahead/demo/fixtures/orders-service/app/client.py +15 -0
- patchahead/demo/fixtures/orders-service/app/models.py +10 -0
- patchahead/demo/fixtures/orders-service/app/order_report.py +24 -0
- patchahead/demo/fixtures/orders-service/app/order_sync.py +21 -0
- patchahead/demo/fixtures/orders-service/conftest.py +6 -0
- patchahead/demo/fixtures/orders-service/pyproject.toml +16 -0
- patchahead/demo/fixtures/orders-service/tests/test_client.py +14 -0
- patchahead/demo/fixtures/orders-service/tests/test_order_report.py +24 -0
- patchahead/demo/fixtures/orders-service/tests/test_order_sync.py +11 -0
- patchahead/demo/fixtures/orders-service/upstream/__init__.py +0 -0
- patchahead/demo/fixtures/orders-service/upstream/api_v1.py +34 -0
- patchahead/demo/fixtures/orders-service/upstream/api_v2.py +56 -0
- patchahead/demo/serve.py +189 -0
- patchahead/domain/__init__.py +67 -0
- patchahead/domain/change.py +269 -0
- patchahead/domain/completeness.py +91 -0
- patchahead/domain/impact.py +248 -0
- patchahead/domain/patch.py +81 -0
- patchahead/domain/plan.py +170 -0
- patchahead/domain/result.py +210 -0
- patchahead/domain/validation.py +200 -0
- patchahead/engine.py +609 -0
- patchahead/handlers/__init__.py +35 -0
- patchahead/handlers/base.py +211 -0
- patchahead/handlers/field_rename.py +425 -0
- patchahead/handlers/kwarg_rename.py +201 -0
- patchahead/handlers/method_rename.py +608 -0
- patchahead/handlers/pagination.py +582 -0
- patchahead/ingest/__init__.py +32 -0
- patchahead/ingest/base.py +102 -0
- patchahead/ingest/markdown.py +1138 -0
- patchahead/ingest/structured.py +218 -0
- patchahead/llm/__init__.py +28 -0
- patchahead/llm/client.py +152 -0
- patchahead/llm/proposer.py +620 -0
- patchahead/observability.py +223 -0
- patchahead/reporting.py +451 -0
- patchahead/testing/__init__.py +22 -0
- patchahead/testing/discovery.py +113 -0
- patchahead/testing/runner.py +138 -0
- patchahead/validation/__init__.py +5 -0
- patchahead/validation/completeness.py +265 -0
- patchahead/validation/engine.py +531 -0
- patchahead/web/__init__.py +13 -0
- patchahead/web/server.py +279 -0
- patchahead/web/static/index.html +650 -0
- patchahead/workspace.py +382 -0
- patchahead-0.3.0.dist-info/METADATA +368 -0
- patchahead-0.3.0.dist-info/RECORD +75 -0
- patchahead-0.3.0.dist-info/WHEEL +5 -0
- patchahead-0.3.0.dist-info/entry_points.txt +2 -0
- patchahead-0.3.0.dist-info/licenses/LICENSE +21 -0
- patchahead-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
"""Method/function rename: ``client.fetch_orders()`` -> ``client.list_orders()``.
|
|
2
|
+
|
|
3
|
+
A call is a more specific construct than a bare word, so ``fetch_orders`` used
|
|
4
|
+
as a method call is decent evidence on its own. But "decent on its own" stops
|
|
5
|
+
mattering the moment the change document names a receiver: ``client`` and
|
|
6
|
+
``analytics`` are different objects, and only one of them got the new method.
|
|
7
|
+
|
|
8
|
+
======================================== ========== ====================
|
|
9
|
+
Site (change document names ``client``) Confidence Patched by default?
|
|
10
|
+
======================================== ========== ====================
|
|
11
|
+
``client.fetch_orders()`` HIGH yes
|
|
12
|
+
``self.client.fetch_orders()`` HIGH yes
|
|
13
|
+
``analytics.fetch_orders()`` LOW no -- reported
|
|
14
|
+
``fetch_orders()`` (bare, no receiver) LOW no -- reported
|
|
15
|
+
======================================== ========== ====================
|
|
16
|
+
|
|
17
|
+
A ``from sdk import fetch_orders`` is renamed with the calls it serves --
|
|
18
|
+
otherwise the renamed calls would meet an import of a name that no longer
|
|
19
|
+
exists. An ``as`` alias is kept, so ``fo()`` in ``from sdk import fetch_orders
|
|
20
|
+
as fo`` needs no edit. An import of the repository's own definition, a relative
|
|
21
|
+
import, or one in a change that asserts a receiver is reported, not rewritten.
|
|
22
|
+
|
|
23
|
+
With no declared owner, a call is graded MEDIUM and patched only when the
|
|
24
|
+
name itself is evidence. Three things take that evidence away, and each turns
|
|
25
|
+
the site into a LOW finding that is reported rather than rewritten:
|
|
26
|
+
|
|
27
|
+
- The name is also a method of a Python built-in type -- ``get``, ``update``,
|
|
28
|
+
``items``, ``copy``. ``SETTINGS.get("timeout")`` and ``os.environ.get(...)``
|
|
29
|
+
are dict calls, not SDK calls, and a shared name says nothing about which is
|
|
30
|
+
which. Only a receiver matching the document's example is patched.
|
|
31
|
+
- A bare call to a Python built-in -- ``dict(...)`` for a ``dict`` ->
|
|
32
|
+
``model_dump`` rename -- that the module does not import from anywhere.
|
|
33
|
+
- The repository defines a function or method with that name in another
|
|
34
|
+
module, so a call on an unrecognised receiver may be to its own code.
|
|
35
|
+
|
|
36
|
+
The edit replaces the callee name token and nothing else, so arguments,
|
|
37
|
+
formatting, and any chained call are preserved exactly.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import ast
|
|
43
|
+
import builtins
|
|
44
|
+
import logging
|
|
45
|
+
import sys
|
|
46
|
+
from dataclasses import dataclass
|
|
47
|
+
|
|
48
|
+
from patchahead.analysis import MODULE_SCOPE, SourceRange, receiver_matches_owner
|
|
49
|
+
from patchahead.analysis.index import RepoIndex, is_test_path
|
|
50
|
+
from patchahead.config import Config
|
|
51
|
+
from patchahead.domain.change import BreakingChange, ChangeKind, Confidence
|
|
52
|
+
from patchahead.domain.impact import AccessKind, CodeReference, ImpactFinding, ImpactReport
|
|
53
|
+
from patchahead.domain.plan import MigrationPlan, Risk, TextEdit, Transformation
|
|
54
|
+
from patchahead.handlers.base import MigrationHandler, analyzed_paths, register
|
|
55
|
+
|
|
56
|
+
log = logging.getLogger(__name__)
|
|
57
|
+
|
|
58
|
+
# Method names a call on *any* object might have: every public method of the
|
|
59
|
+
# built-in types. A rename of `get` matches `os.environ.get` and `config.get`
|
|
60
|
+
# as readily as `client.get`, so the name alone proves nothing.
|
|
61
|
+
_BUILTIN_METHOD_NAMES = frozenset(
|
|
62
|
+
name
|
|
63
|
+
for kind in (dict, list, tuple, set, frozenset, str, bytes, int, float, object)
|
|
64
|
+
for name in dir(kind)
|
|
65
|
+
if not name.startswith("_")
|
|
66
|
+
)
|
|
67
|
+
_BUILTIN_NAMES = frozenset(name for name in dir(builtins) if not name.startswith("_"))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class MethodRenameHandler(MigrationHandler):
|
|
71
|
+
"""Renames a called method or function at its call sites."""
|
|
72
|
+
|
|
73
|
+
name = "method_rename"
|
|
74
|
+
kinds = (ChangeKind.METHOD_RENAME,)
|
|
75
|
+
summary = "Rename a called method or function: obj.old() -> obj.new()"
|
|
76
|
+
limitations = (
|
|
77
|
+
"Only call sites. A bare reference used as a value "
|
|
78
|
+
"(`callback = client.fetch_orders`) is reported but not rewritten.",
|
|
79
|
+
"Does not rename the definition. This family is for calls into an "
|
|
80
|
+
"upstream SDK, not for renaming a function the repository owns.",
|
|
81
|
+
"When the change names a receiver, only that receiver is patched. "
|
|
82
|
+
"`analytics.fetch_orders()` is reported but never rewritten for a "
|
|
83
|
+
"`client.fetch_orders` rename.",
|
|
84
|
+
"A module that defines a function with the same name locally is left "
|
|
85
|
+
"alone entirely -- those calls are to its own code.",
|
|
86
|
+
"Only `from x import name` imports are renamed, and only when `x` is not "
|
|
87
|
+
"the repository's own code and no receiver is asserted.",
|
|
88
|
+
"With no receiver named, a name shared with a built-in type's method "
|
|
89
|
+
"(`get`, `update`, `items`), a bare built-in call (`dict()`), or a name "
|
|
90
|
+
"the repository defines elsewhere is reported but not rewritten.",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def supports(self, change: BreakingChange) -> bool:
|
|
94
|
+
return change.kind in self.kinds and change.target.is_rename
|
|
95
|
+
|
|
96
|
+
def analyze(self, change: BreakingChange, index: RepoIndex, config: Config) -> ImpactReport:
|
|
97
|
+
old = change.target.symbol
|
|
98
|
+
# Only an *asserted* receiver constrains which call sites may be patched.
|
|
99
|
+
owner = change.target.owner if change.target.owner_is_explicit else ""
|
|
100
|
+
hint = "" if change.target.owner_is_explicit else change.target.owner
|
|
101
|
+
findings: list[ImpactFinding] = []
|
|
102
|
+
definers = [path for path in index.non_test_paths() if _defines(index.modules[path], old)]
|
|
103
|
+
# Definitions in test code -- a fake, a shared base class's shim for
|
|
104
|
+
# `assert_` -- govern test code. They must not stop the application from
|
|
105
|
+
# migrating, but a test calling a method its own base class overrides
|
|
106
|
+
# is calling the project's code, exactly as in the application.
|
|
107
|
+
test_definers = [path for path in index.test_paths() if _defines(index.modules[path], old)]
|
|
108
|
+
|
|
109
|
+
for path in analyzed_paths(index, config):
|
|
110
|
+
module = index.modules[path]
|
|
111
|
+
in_test = is_test_path(path)
|
|
112
|
+
relevant = definers + test_definers if in_test else definers
|
|
113
|
+
|
|
114
|
+
# A repository that *defines* this name owns it; renaming calls to
|
|
115
|
+
# its own function would break the code rather than migrate it.
|
|
116
|
+
defines_locally = path in relevant
|
|
117
|
+
stdlib = _stdlib_bindings(module)
|
|
118
|
+
ambiguity = _ambiguity(old, module, [p for p in relevant if p != path])
|
|
119
|
+
|
|
120
|
+
for call in module.calls:
|
|
121
|
+
if call.name != old:
|
|
122
|
+
continue
|
|
123
|
+
# `requests[0].dict()` is a method call whose receiver has no
|
|
124
|
+
# name; it is not a bare `dict()` call.
|
|
125
|
+
is_method = (
|
|
126
|
+
isinstance(call.node.func, ast.Attribute) if call.node else bool(call.receiver)
|
|
127
|
+
)
|
|
128
|
+
confidence, reason, patchable, blocked = self._grade(
|
|
129
|
+
call.receiver,
|
|
130
|
+
owner,
|
|
131
|
+
defines_locally,
|
|
132
|
+
hint,
|
|
133
|
+
ambiguity,
|
|
134
|
+
is_method,
|
|
135
|
+
stdlib.get(_root(call.receiver), ""),
|
|
136
|
+
)
|
|
137
|
+
findings.append(
|
|
138
|
+
ImpactFinding(
|
|
139
|
+
reference=CodeReference(
|
|
140
|
+
path=path,
|
|
141
|
+
line=call.name_range.line,
|
|
142
|
+
col=call.name_range.col,
|
|
143
|
+
end_line=call.name_range.end_line,
|
|
144
|
+
end_col=call.name_range.end_col,
|
|
145
|
+
snippet=module.line_text(call.range.line),
|
|
146
|
+
),
|
|
147
|
+
symbol=call.symbol,
|
|
148
|
+
matched_contract=f"{call.receiver + '.' if call.receiver else ''}{old}()",
|
|
149
|
+
access=AccessKind.CALL,
|
|
150
|
+
reason=reason,
|
|
151
|
+
confidence=confidence,
|
|
152
|
+
source_text=old,
|
|
153
|
+
patchable=patchable,
|
|
154
|
+
unpatchable_reason=blocked,
|
|
155
|
+
other_object=bool(owner)
|
|
156
|
+
and not receiver_matches_owner(call.receiver, owner),
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# `from sdk import fetch_orders`: renamed with the calls it serves.
|
|
161
|
+
for node in ast.walk(module.tree):
|
|
162
|
+
if not isinstance(node, ast.ImportFrom):
|
|
163
|
+
continue
|
|
164
|
+
for alias in node.names:
|
|
165
|
+
if alias.name != old:
|
|
166
|
+
continue
|
|
167
|
+
confidence, reason, patchable, blocked = self._grade_import(
|
|
168
|
+
node, owner, definers
|
|
169
|
+
)
|
|
170
|
+
name = _alias_name_range(alias, module.columns)
|
|
171
|
+
findings.append(
|
|
172
|
+
ImpactFinding(
|
|
173
|
+
reference=CodeReference(
|
|
174
|
+
path=path,
|
|
175
|
+
line=name.line,
|
|
176
|
+
col=name.col,
|
|
177
|
+
end_line=name.end_line,
|
|
178
|
+
end_col=name.end_col,
|
|
179
|
+
snippet=module.line_text(name.line),
|
|
180
|
+
),
|
|
181
|
+
symbol=MODULE_SCOPE,
|
|
182
|
+
matched_contract=f"from {'.' * node.level}{node.module or ''} "
|
|
183
|
+
f"import {old}",
|
|
184
|
+
access=AccessKind.IMPORT,
|
|
185
|
+
reason=reason,
|
|
186
|
+
confidence=confidence,
|
|
187
|
+
source_text=old,
|
|
188
|
+
patchable=patchable,
|
|
189
|
+
unpatchable_reason=blocked,
|
|
190
|
+
)
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
# Bare references: `cb = client.fetch_orders` (no call parentheses).
|
|
194
|
+
for attribute in module.attributes:
|
|
195
|
+
if attribute.attr != old:
|
|
196
|
+
continue
|
|
197
|
+
if in_test and _configures_a_mock(module, attribute.receiver, old):
|
|
198
|
+
# `client.fetch_orders.return_value = []` sets up the method
|
|
199
|
+
# a test double stands in for; it moves with the calls.
|
|
200
|
+
confidence, reason, patchable, blocked = self._grade(
|
|
201
|
+
attribute.receiver,
|
|
202
|
+
owner,
|
|
203
|
+
defines_locally,
|
|
204
|
+
hint,
|
|
205
|
+
ambiguity,
|
|
206
|
+
True,
|
|
207
|
+
stdlib.get(_root(attribute.receiver), ""),
|
|
208
|
+
)
|
|
209
|
+
findings.append(
|
|
210
|
+
ImpactFinding(
|
|
211
|
+
reference=CodeReference(
|
|
212
|
+
path=path,
|
|
213
|
+
line=attribute.attr_range.line,
|
|
214
|
+
col=attribute.attr_range.col,
|
|
215
|
+
end_line=attribute.attr_range.end_line,
|
|
216
|
+
end_col=attribute.attr_range.end_col,
|
|
217
|
+
snippet=module.line_text(attribute.range.line),
|
|
218
|
+
),
|
|
219
|
+
symbol=attribute.symbol,
|
|
220
|
+
matched_contract=f"{attribute.receiver}.{old} (a mock's setup)",
|
|
221
|
+
access=AccessKind.ATTRIBUTE,
|
|
222
|
+
reason=f"configures a mock of the renamed method; {reason}",
|
|
223
|
+
confidence=confidence,
|
|
224
|
+
source_text=old,
|
|
225
|
+
patchable=patchable,
|
|
226
|
+
unpatchable_reason=blocked,
|
|
227
|
+
other_object=bool(owner)
|
|
228
|
+
and not receiver_matches_owner(attribute.receiver, owner),
|
|
229
|
+
)
|
|
230
|
+
)
|
|
231
|
+
continue
|
|
232
|
+
findings.append(
|
|
233
|
+
ImpactFinding(
|
|
234
|
+
reference=CodeReference(
|
|
235
|
+
path=path,
|
|
236
|
+
line=attribute.attr_range.line,
|
|
237
|
+
col=attribute.attr_range.col,
|
|
238
|
+
end_line=attribute.attr_range.end_line,
|
|
239
|
+
end_col=attribute.attr_range.end_col,
|
|
240
|
+
snippet=module.line_text(attribute.range.line),
|
|
241
|
+
),
|
|
242
|
+
symbol=attribute.symbol,
|
|
243
|
+
matched_contract=f"{attribute.receiver or '<expr>'}.{old}",
|
|
244
|
+
access=AccessKind.ATTRIBUTE,
|
|
245
|
+
reason=(
|
|
246
|
+
"the renamed method is referenced without being called; "
|
|
247
|
+
"rewriting a bare reference can change behavior if it is "
|
|
248
|
+
"passed somewhere that expects the old name"
|
|
249
|
+
),
|
|
250
|
+
confidence=Confidence.MEDIUM,
|
|
251
|
+
source_text=old,
|
|
252
|
+
patchable=False,
|
|
253
|
+
unpatchable_reason="bare method reference, not a call site",
|
|
254
|
+
)
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
findings.sort(key=lambda f: (f.reference.path, f.reference.line, f.reference.col))
|
|
258
|
+
return ImpactReport(
|
|
259
|
+
change=change,
|
|
260
|
+
findings=findings,
|
|
261
|
+
related_tests=_related_tests(index, findings),
|
|
262
|
+
files_scanned=index.file_count,
|
|
263
|
+
skipped_files=dict(index.skipped),
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
def _grade(
|
|
267
|
+
self,
|
|
268
|
+
receiver: str,
|
|
269
|
+
owner: str,
|
|
270
|
+
defines_locally: bool,
|
|
271
|
+
hint: str = "",
|
|
272
|
+
ambiguity: _Ambiguity | None = None,
|
|
273
|
+
is_method: bool | None = None,
|
|
274
|
+
stdlib_source: str = "",
|
|
275
|
+
) -> tuple[Confidence, str, bool, str]:
|
|
276
|
+
"""Grade one call site.
|
|
277
|
+
|
|
278
|
+
Returns ``(confidence, reason, patchable, blocked_reason)``.
|
|
279
|
+
"""
|
|
280
|
+
ambiguity = ambiguity or _Ambiguity()
|
|
281
|
+
is_method = bool(receiver) if is_method is None else is_method
|
|
282
|
+
if defines_locally:
|
|
283
|
+
return (
|
|
284
|
+
Confidence.LOW,
|
|
285
|
+
"this module also defines a function with the renamed name, so "
|
|
286
|
+
"these calls are probably to local code, not to the upstream SDK",
|
|
287
|
+
False,
|
|
288
|
+
"the module defines a function with this name locally",
|
|
289
|
+
)
|
|
290
|
+
if owner and receiver_matches_owner(receiver, owner):
|
|
291
|
+
return (
|
|
292
|
+
Confidence.HIGH,
|
|
293
|
+
f"method call on `{owner}`, the receiver the change document names",
|
|
294
|
+
True,
|
|
295
|
+
"",
|
|
296
|
+
)
|
|
297
|
+
if owner:
|
|
298
|
+
return (
|
|
299
|
+
Confidence.LOW,
|
|
300
|
+
f"call to the renamed method, but on "
|
|
301
|
+
f"`{receiver or '<no receiver>'}` rather than `{owner}`, which the "
|
|
302
|
+
f"change document names as the receiver",
|
|
303
|
+
False,
|
|
304
|
+
f"receiver is `{receiver or '<no receiver>'}`, not the declared receiver `{owner}`",
|
|
305
|
+
)
|
|
306
|
+
if stdlib_source:
|
|
307
|
+
# `patch.dict(...)` with `patch` from `unittest.mock`: a standard
|
|
308
|
+
# library object is not the upgraded library's, whatever its methods
|
|
309
|
+
# are called. Found replaying a real pydantic migration.
|
|
310
|
+
return (
|
|
311
|
+
Confidence.LOW,
|
|
312
|
+
f"call on `{receiver}`, which is `{stdlib_source}` from the Python "
|
|
313
|
+
f"standard library, not an object of the upgraded library",
|
|
314
|
+
False,
|
|
315
|
+
f"the receiver is `{stdlib_source}`, from the standard library",
|
|
316
|
+
)
|
|
317
|
+
if hint and receiver_matches_owner(receiver, hint):
|
|
318
|
+
return (
|
|
319
|
+
Confidence.HIGH,
|
|
320
|
+
f"method call on `{receiver}`, matching the receiver used in the "
|
|
321
|
+
f"change document's example",
|
|
322
|
+
True,
|
|
323
|
+
"",
|
|
324
|
+
)
|
|
325
|
+
blocked = ambiguity.method if is_method else ambiguity.bare
|
|
326
|
+
if blocked:
|
|
327
|
+
shown = f"{receiver or '<expression>'}." if is_method else ""
|
|
328
|
+
return (
|
|
329
|
+
Confidence.LOW,
|
|
330
|
+
f"call to `{shown}{blocked.name}()`, but "
|
|
331
|
+
f"{blocked.why}; name the receiver in the change document to migrate it",
|
|
332
|
+
False,
|
|
333
|
+
blocked.short,
|
|
334
|
+
)
|
|
335
|
+
if is_method:
|
|
336
|
+
return (
|
|
337
|
+
Confidence.MEDIUM,
|
|
338
|
+
f"method call with the renamed name on `{receiver or '<expression>'}`; the change "
|
|
339
|
+
f"document asserts no receiver, so this could not be narrowed",
|
|
340
|
+
True,
|
|
341
|
+
"",
|
|
342
|
+
)
|
|
343
|
+
return (
|
|
344
|
+
Confidence.MEDIUM,
|
|
345
|
+
"bare function call with the renamed name and no receiver to check",
|
|
346
|
+
True,
|
|
347
|
+
"",
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
def _grade_import(
|
|
351
|
+
self, node: ast.ImportFrom, owner: str, definers: list[str]
|
|
352
|
+
) -> tuple[Confidence, str, bool, str]:
|
|
353
|
+
"""Grade one ``from x import name``. Returns the same tuple as :meth:`_grade`."""
|
|
354
|
+
source = f"{'.' * node.level}{node.module or ''}"
|
|
355
|
+
if node.level or (node.module and _is_repo_module(node.module, definers)):
|
|
356
|
+
return (
|
|
357
|
+
Confidence.LOW,
|
|
358
|
+
f"imports `{source}`'s own definition of the name, which is the "
|
|
359
|
+
f"repository's code rather than the upstream SDK",
|
|
360
|
+
False,
|
|
361
|
+
"imports the repository's own definition",
|
|
362
|
+
)
|
|
363
|
+
if owner:
|
|
364
|
+
return (
|
|
365
|
+
Confidence.LOW,
|
|
366
|
+
f"imports a function with the renamed name, but the change document "
|
|
367
|
+
f"names `{owner}` as the receiver; an importable function may be a "
|
|
368
|
+
f"different thing",
|
|
369
|
+
False,
|
|
370
|
+
f"the change asserts the receiver `{owner}`; a module-level import may differ",
|
|
371
|
+
)
|
|
372
|
+
return (
|
|
373
|
+
Confidence.MEDIUM,
|
|
374
|
+
f"imports the renamed name from `{source}`; renamed together with its calls",
|
|
375
|
+
True,
|
|
376
|
+
"",
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
def plan(
|
|
380
|
+
self,
|
|
381
|
+
change: BreakingChange,
|
|
382
|
+
report: ImpactReport,
|
|
383
|
+
index: RepoIndex,
|
|
384
|
+
config: Config,
|
|
385
|
+
) -> MigrationPlan:
|
|
386
|
+
old, new = change.target.symbol, change.target.replacement
|
|
387
|
+
plan = MigrationPlan(
|
|
388
|
+
change=change,
|
|
389
|
+
handler=self.name,
|
|
390
|
+
expected_tests=list(report.related_tests),
|
|
391
|
+
risk=Risk.LOW,
|
|
392
|
+
rationale=(
|
|
393
|
+
f"Rename calls to `{old}` to `{new}`. Only the callee name token is "
|
|
394
|
+
f"replaced, so arguments and formatting are untouched. The signature "
|
|
395
|
+
f"is unchanged by this migration."
|
|
396
|
+
),
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
for finding in report.findings:
|
|
400
|
+
if not finding.patchable:
|
|
401
|
+
plan.skipped.append(
|
|
402
|
+
f"{finding.reference} ({finding.matched_contract}): "
|
|
403
|
+
f"{finding.unpatchable_reason}"
|
|
404
|
+
)
|
|
405
|
+
continue
|
|
406
|
+
if finding.confidence < config.min_confidence:
|
|
407
|
+
plan.skipped.append(
|
|
408
|
+
f"{finding.reference} ({finding.matched_contract}): confidence "
|
|
409
|
+
f"{finding.confidence.value} is below the "
|
|
410
|
+
f"`{config.min_confidence.value}` threshold"
|
|
411
|
+
)
|
|
412
|
+
continue
|
|
413
|
+
reference = finding.reference
|
|
414
|
+
is_import = finding.access in (AccessKind.IMPORT, AccessKind.ATTRIBUTE)
|
|
415
|
+
plan.transformations.append(
|
|
416
|
+
Transformation(
|
|
417
|
+
reference=reference,
|
|
418
|
+
old=old if is_import else f"{old}(",
|
|
419
|
+
new=new if is_import else f"{new}(",
|
|
420
|
+
symbol=finding.symbol,
|
|
421
|
+
confidence=finding.confidence,
|
|
422
|
+
edit=TextEdit(
|
|
423
|
+
line=reference.line,
|
|
424
|
+
col=reference.col,
|
|
425
|
+
end_line=reference.end_line or reference.line,
|
|
426
|
+
end_col=reference.end_col or reference.col,
|
|
427
|
+
new_text=new,
|
|
428
|
+
description=f"rename {'import' if is_import else 'call'} "
|
|
429
|
+
f"`{old}` to `{new}`",
|
|
430
|
+
),
|
|
431
|
+
)
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
if not plan.transformations:
|
|
435
|
+
plan.blocked_reason = (
|
|
436
|
+
f"found {len(report.findings)} reference(s) to `{old}` but none were "
|
|
437
|
+
f"rewritable call sites above the `{config.min_confidence.value}` "
|
|
438
|
+
f"confidence threshold"
|
|
439
|
+
if report.findings
|
|
440
|
+
else f"no calls to `{old}` were found"
|
|
441
|
+
)
|
|
442
|
+
return plan
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
@dataclass(frozen=True)
|
|
446
|
+
class _Reason:
|
|
447
|
+
"""Why a name match is not evidence on its own."""
|
|
448
|
+
|
|
449
|
+
name: str
|
|
450
|
+
why: str
|
|
451
|
+
short: str
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
@dataclass(frozen=True)
|
|
455
|
+
class _Ambiguity:
|
|
456
|
+
"""What, in one module, makes a call to the renamed name unconvincing.
|
|
457
|
+
|
|
458
|
+
``method`` applies to ``obj.name()`` calls, ``bare`` to ``name()`` calls.
|
|
459
|
+
``None`` means the name is distinctive enough to act on.
|
|
460
|
+
"""
|
|
461
|
+
|
|
462
|
+
method: _Reason | None = None
|
|
463
|
+
bare: _Reason | None = None
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _ambiguity(name: str, module, other_definers: list[str]) -> _Ambiguity:
|
|
467
|
+
elsewhere = None
|
|
468
|
+
if other_definers:
|
|
469
|
+
shown = ", ".join(f"`{path}`" for path in other_definers[:3])
|
|
470
|
+
elsewhere = _Reason(
|
|
471
|
+
name,
|
|
472
|
+
f"the repository defines its own `{name}` in {shown}, so this may be a "
|
|
473
|
+
f"call to that code rather than to the upstream SDK",
|
|
474
|
+
f"the repository defines its own `{name}` elsewhere",
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
method = elsewhere
|
|
478
|
+
if name in _BUILTIN_METHOD_NAMES:
|
|
479
|
+
method = _Reason(
|
|
480
|
+
name,
|
|
481
|
+
f"`{name}` is also a method of Python's built-in types, so the name "
|
|
482
|
+
f"alone cannot tell this call apart from, say, a dict's",
|
|
483
|
+
f"`{name}` is also a built-in type's method; receiver not recognised",
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
sources = _import_sources(module, name)
|
|
487
|
+
if sources:
|
|
488
|
+
# `from sdk import fetch_orders` is the evidence a bare call otherwise
|
|
489
|
+
# lacks -- unless what it imports is the repository's own definition.
|
|
490
|
+
local = [
|
|
491
|
+
source
|
|
492
|
+
for source in sources
|
|
493
|
+
if source is None or _is_repo_module(source, other_definers)
|
|
494
|
+
]
|
|
495
|
+
bare = elsewhere if local else None
|
|
496
|
+
elif name in _BUILTIN_NAMES:
|
|
497
|
+
bare = _Reason(
|
|
498
|
+
name,
|
|
499
|
+
f"`{name}` here is Python's built-in -- the module does not import a "
|
|
500
|
+
f"`{name}` from anywhere",
|
|
501
|
+
f"`{name}()` is the Python built-in, not an imported SDK function",
|
|
502
|
+
)
|
|
503
|
+
else:
|
|
504
|
+
bare = elsewhere
|
|
505
|
+
return _Ambiguity(method=method, bare=bare)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
#: What a test reads or sets on a mocked method: `client.fetch_orders.return_value`.
|
|
509
|
+
_MOCK_ATTRIBUTES = frozenset(
|
|
510
|
+
{
|
|
511
|
+
"return_value",
|
|
512
|
+
"side_effect",
|
|
513
|
+
"call_count",
|
|
514
|
+
"called",
|
|
515
|
+
"call_args",
|
|
516
|
+
"call_args_list",
|
|
517
|
+
"mock_calls",
|
|
518
|
+
"await_count",
|
|
519
|
+
"reset_mock",
|
|
520
|
+
"assert_called",
|
|
521
|
+
"assert_called_once",
|
|
522
|
+
"assert_called_with",
|
|
523
|
+
"assert_called_once_with",
|
|
524
|
+
"assert_any_call",
|
|
525
|
+
"assert_has_calls",
|
|
526
|
+
"assert_not_called",
|
|
527
|
+
"assert_awaited",
|
|
528
|
+
"assert_awaited_once",
|
|
529
|
+
"assert_awaited_with",
|
|
530
|
+
"assert_awaited_once_with",
|
|
531
|
+
"assert_not_awaited",
|
|
532
|
+
}
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _root(receiver: str) -> str:
|
|
537
|
+
return receiver.split(".", 1)[0].removesuffix("()") if receiver else ""
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _stdlib_bindings(module) -> dict[str, str]:
|
|
541
|
+
"""Names a module binds to standard-library imports: ``patch`` -> ``unittest.mock.patch``."""
|
|
542
|
+
bound: dict[str, str] = {}
|
|
543
|
+
for node in ast.walk(module.tree):
|
|
544
|
+
if isinstance(node, ast.Import):
|
|
545
|
+
for alias in node.names:
|
|
546
|
+
if alias.name.split(".", 1)[0] in sys.stdlib_module_names:
|
|
547
|
+
bound[alias.asname or alias.name.split(".", 1)[0]] = alias.name
|
|
548
|
+
elif (
|
|
549
|
+
isinstance(node, ast.ImportFrom)
|
|
550
|
+
and not node.level
|
|
551
|
+
and node.module
|
|
552
|
+
and node.module.split(".", 1)[0] in sys.stdlib_module_names
|
|
553
|
+
):
|
|
554
|
+
for alias in node.names:
|
|
555
|
+
bound[alias.asname or alias.name] = f"{node.module}.{alias.name}"
|
|
556
|
+
return bound
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _configures_a_mock(module, receiver: str, name: str) -> bool:
|
|
560
|
+
"""Whether ``receiver.name`` is used as a mock: ``.return_value``, ``.assert_called...``."""
|
|
561
|
+
if not receiver:
|
|
562
|
+
return False
|
|
563
|
+
mocked = f"{receiver}.{name}"
|
|
564
|
+
return any(
|
|
565
|
+
a.receiver == mocked and a.attr in _MOCK_ATTRIBUTES for a in module.attributes
|
|
566
|
+
) or any(c.receiver == mocked and c.name in _MOCK_ATTRIBUTES for c in module.calls)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _alias_name_range(alias: ast.alias, columns) -> SourceRange:
|
|
570
|
+
"""The range of the imported name alone -- ``fetch_orders`` in ``fetch_orders as fo``."""
|
|
571
|
+
start = SourceRange.of(alias, columns)
|
|
572
|
+
return SourceRange(
|
|
573
|
+
line=start.line, col=start.col, end_line=start.line, end_col=start.col + len(alias.name)
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _import_sources(module, name: str) -> list[str | None]:
|
|
578
|
+
"""Modules a module imports ``name`` from; ``None`` for a relative import."""
|
|
579
|
+
return [
|
|
580
|
+
None if node.level else node.module
|
|
581
|
+
for node in ast.walk(module.tree)
|
|
582
|
+
if isinstance(node, ast.ImportFrom)
|
|
583
|
+
for alias in node.names
|
|
584
|
+
if (alias.asname or alias.name) == name
|
|
585
|
+
]
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _is_repo_module(dotted: str, definers: list[str]) -> bool:
|
|
589
|
+
"""Whether ``dotted`` names one of the repository files that define the name."""
|
|
590
|
+
modules = (path.removesuffix(".py").replace("/", ".") for path in definers)
|
|
591
|
+
return any(module == dotted or module.endswith(f".{dotted}") for module in modules)
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _defines(module, name: str) -> bool:
|
|
595
|
+
"""Whether a module defines a function or method with this name."""
|
|
596
|
+
return any(
|
|
597
|
+
isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == name
|
|
598
|
+
for node in ast.walk(module.tree)
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _related_tests(index: RepoIndex, findings: list[ImpactFinding]) -> list[str]:
|
|
603
|
+
from patchahead.testing import discovery
|
|
604
|
+
|
|
605
|
+
return discovery.tests_for_paths(index, [f.path for f in findings])
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
register(MethodRenameHandler())
|