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.
Files changed (75) hide show
  1. patchahead/__init__.py +8 -0
  2. patchahead/analysis/__init__.py +52 -0
  3. patchahead/analysis/edits.py +143 -0
  4. patchahead/analysis/index.py +203 -0
  5. patchahead/analysis/python_ast.py +457 -0
  6. patchahead/apidiff/__init__.py +23 -0
  7. patchahead/apidiff/compare.py +366 -0
  8. patchahead/apidiff/download.py +95 -0
  9. patchahead/apidiff/surface.py +337 -0
  10. patchahead/ci.py +301 -0
  11. patchahead/cli.py +627 -0
  12. patchahead/config.py +284 -0
  13. patchahead/demo/__init__.py +256 -0
  14. patchahead/demo/fixtures/changes/field-rename.md +14 -0
  15. patchahead/demo/fixtures/changes/invoice-field-rename.md +21 -0
  16. patchahead/demo/fixtures/changes/kwarg-rename.md +14 -0
  17. patchahead/demo/fixtures/changes/method-rename.md +12 -0
  18. patchahead/demo/fixtures/changes/pagination-cursor.json +24 -0
  19. patchahead/demo/fixtures/changes/pagination-cursor.md +20 -0
  20. patchahead/demo/fixtures/changes/sdk-v2.md +31 -0
  21. patchahead/demo/fixtures/orders-service/README.md +51 -0
  22. patchahead/demo/fixtures/orders-service/app/__init__.py +0 -0
  23. patchahead/demo/fixtures/orders-service/app/client.py +15 -0
  24. patchahead/demo/fixtures/orders-service/app/models.py +10 -0
  25. patchahead/demo/fixtures/orders-service/app/order_report.py +24 -0
  26. patchahead/demo/fixtures/orders-service/app/order_sync.py +21 -0
  27. patchahead/demo/fixtures/orders-service/conftest.py +6 -0
  28. patchahead/demo/fixtures/orders-service/pyproject.toml +16 -0
  29. patchahead/demo/fixtures/orders-service/tests/test_client.py +14 -0
  30. patchahead/demo/fixtures/orders-service/tests/test_order_report.py +24 -0
  31. patchahead/demo/fixtures/orders-service/tests/test_order_sync.py +11 -0
  32. patchahead/demo/fixtures/orders-service/upstream/__init__.py +0 -0
  33. patchahead/demo/fixtures/orders-service/upstream/api_v1.py +34 -0
  34. patchahead/demo/fixtures/orders-service/upstream/api_v2.py +56 -0
  35. patchahead/demo/serve.py +189 -0
  36. patchahead/domain/__init__.py +67 -0
  37. patchahead/domain/change.py +269 -0
  38. patchahead/domain/completeness.py +91 -0
  39. patchahead/domain/impact.py +248 -0
  40. patchahead/domain/patch.py +81 -0
  41. patchahead/domain/plan.py +170 -0
  42. patchahead/domain/result.py +210 -0
  43. patchahead/domain/validation.py +200 -0
  44. patchahead/engine.py +609 -0
  45. patchahead/handlers/__init__.py +35 -0
  46. patchahead/handlers/base.py +211 -0
  47. patchahead/handlers/field_rename.py +425 -0
  48. patchahead/handlers/kwarg_rename.py +201 -0
  49. patchahead/handlers/method_rename.py +608 -0
  50. patchahead/handlers/pagination.py +582 -0
  51. patchahead/ingest/__init__.py +32 -0
  52. patchahead/ingest/base.py +102 -0
  53. patchahead/ingest/markdown.py +1138 -0
  54. patchahead/ingest/structured.py +218 -0
  55. patchahead/llm/__init__.py +28 -0
  56. patchahead/llm/client.py +152 -0
  57. patchahead/llm/proposer.py +620 -0
  58. patchahead/observability.py +223 -0
  59. patchahead/reporting.py +451 -0
  60. patchahead/testing/__init__.py +22 -0
  61. patchahead/testing/discovery.py +113 -0
  62. patchahead/testing/runner.py +138 -0
  63. patchahead/validation/__init__.py +5 -0
  64. patchahead/validation/completeness.py +265 -0
  65. patchahead/validation/engine.py +531 -0
  66. patchahead/web/__init__.py +13 -0
  67. patchahead/web/server.py +279 -0
  68. patchahead/web/static/index.html +650 -0
  69. patchahead/workspace.py +382 -0
  70. patchahead-0.3.0.dist-info/METADATA +368 -0
  71. patchahead-0.3.0.dist-info/RECORD +75 -0
  72. patchahead-0.3.0.dist-info/WHEEL +5 -0
  73. patchahead-0.3.0.dist-info/entry_points.txt +2 -0
  74. patchahead-0.3.0.dist-info/licenses/LICENSE +21 -0
  75. 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())