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,201 @@
1
+ """Keyword argument rename: ``fetch(timeout_seconds=30)`` -> ``fetch(timeout=30)``.
2
+
3
+ The most mechanically reliable of the four families, because the construct is
4
+ unambiguous: a keyword argument is a keyword argument, and ``ast`` hands back the
5
+ exact range of the name token. The edit replaces that token and leaves the value
6
+ expression, the other arguments, and the formatting exactly as written.
7
+
8
+ The only real judgement is *which calls*, and it is the whole safety story for
9
+ this family. When the change document names the function (``timeout_seconds`` on
10
+ ``fetch_orders``), only calls to that function are rewritten; a
11
+ ``timeout_seconds=`` passed to some unrelated function keeps its name and is
12
+ reported for review instead.
13
+
14
+ When the document names *no* function there is nothing to narrow by, and this
15
+ handler rewrites nothing. Keyword names are ordinary words -- ``retries``,
16
+ ``timeout``, ``limit`` -- and two libraries sharing one is a coincidence rather
17
+ than a relationship: rewriting on the name alone turns a note about one SDK into
18
+ a ``send_email(max_retries=5)`` that some other library has never heard of.
19
+ Nothing in the source says which calls belong to the upstream being migrated, so
20
+ every site is reported and none is patched. Under-patching costs a hand-edit;
21
+ this kind of over-patching costs a working call site.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+
28
+ from patchahead.analysis.index import RepoIndex
29
+ from patchahead.config import Config
30
+ from patchahead.domain.change import BreakingChange, ChangeKind, Confidence
31
+ from patchahead.domain.impact import AccessKind, CodeReference, ImpactFinding, ImpactReport
32
+ from patchahead.domain.plan import MigrationPlan, Risk, TextEdit, Transformation
33
+ from patchahead.handlers.base import MigrationHandler, analyzed_paths, register
34
+
35
+ log = logging.getLogger(__name__)
36
+
37
+
38
+ class KwargRenameHandler(MigrationHandler):
39
+ """Renames a keyword argument at call sites."""
40
+
41
+ name = "kwarg_rename"
42
+ kinds = (ChangeKind.KWARG_RENAME,)
43
+ summary = "Rename a keyword argument: f(old_name=x) -> f(new_name=x)"
44
+ limitations = (
45
+ "Only explicit keyword arguments. A value passed positionally, or "
46
+ "expanded from `**kwargs`, is invisible to this handler.",
47
+ "Does not rename the parameter in a function definition; this family is "
48
+ "for calls into an upstream SDK.",
49
+ "When the change document names no function, nothing is rewritten: there "
50
+ "is no way to tell the renamed keyword from another library's keyword of "
51
+ "the same name. The sites are reported for a hand-edit.",
52
+ )
53
+
54
+ def supports(self, change: BreakingChange) -> bool:
55
+ return change.kind in self.kinds and change.target.is_rename
56
+
57
+ def analyze(self, change: BreakingChange, index: RepoIndex, config: Config) -> ImpactReport:
58
+ old = change.target.symbol
59
+ # For this family the owner is the *called function*, not a receiver.
60
+ target_function = change.target.owner.rsplit(".", 1)[-1]
61
+ findings: list[ImpactFinding] = []
62
+
63
+ for path in analyzed_paths(index, config):
64
+ module = index.modules[path]
65
+ for call in module.calls:
66
+ keyword_range = call.keywords.get(old)
67
+ if keyword_range is None:
68
+ continue
69
+
70
+ confidence, reason, patchable, blocked = self._grade(call.name, target_function)
71
+ receiver = f"{call.receiver}." if call.receiver else ""
72
+ findings.append(
73
+ ImpactFinding(
74
+ reference=CodeReference(
75
+ path=path,
76
+ line=keyword_range.line,
77
+ col=keyword_range.col,
78
+ end_line=keyword_range.end_line,
79
+ end_col=keyword_range.end_col,
80
+ snippet=module.line_text(keyword_range.line),
81
+ ),
82
+ symbol=call.symbol,
83
+ matched_contract=f"{receiver}{call.name}({old}=...)",
84
+ access=AccessKind.KEYWORD_ARG,
85
+ reason=reason,
86
+ confidence=confidence,
87
+ source_text=old,
88
+ patchable=patchable,
89
+ unpatchable_reason=blocked,
90
+ other_object=bool(target_function) and call.name != target_function,
91
+ )
92
+ )
93
+
94
+ findings.sort(key=lambda f: (f.reference.path, f.reference.line, f.reference.col))
95
+ return ImpactReport(
96
+ change=change,
97
+ findings=findings,
98
+ related_tests=_related_tests(index, findings),
99
+ files_scanned=index.file_count,
100
+ skipped_files=dict(index.skipped),
101
+ )
102
+
103
+ def _grade(self, called: str, target_function: str) -> tuple[Confidence, str, bool, str]:
104
+ if target_function and called == target_function:
105
+ return (
106
+ Confidence.HIGH,
107
+ f"keyword argument on a call to `{target_function}`, the function "
108
+ f"the change document names",
109
+ True,
110
+ "",
111
+ )
112
+ if target_function:
113
+ return (
114
+ Confidence.LOW,
115
+ f"keyword argument with the renamed name, but on a call to "
116
+ f"`{called}` rather than `{target_function}`; probably a different "
117
+ f"function that happens to share the parameter name",
118
+ False,
119
+ f"call is to `{called}`, not the renamed function `{target_function}`",
120
+ )
121
+ return (
122
+ Confidence.LOW,
123
+ f"keyword argument with the renamed name on a call to `{called}`, but "
124
+ f"the change document names no function, so there is nothing to check "
125
+ f"`{called}` against",
126
+ False,
127
+ "the change document names no function, so this keyword cannot be "
128
+ "attributed to the renamed one",
129
+ )
130
+
131
+ def plan(
132
+ self,
133
+ change: BreakingChange,
134
+ report: ImpactReport,
135
+ index: RepoIndex,
136
+ config: Config,
137
+ ) -> MigrationPlan:
138
+ old, new = change.target.symbol, change.target.replacement
139
+ plan = MigrationPlan(
140
+ change=change,
141
+ handler=self.name,
142
+ expected_tests=list(report.related_tests),
143
+ risk=Risk.LOW,
144
+ rationale=(
145
+ f"Rename the `{old}=` keyword argument to `{new}=` at each matching "
146
+ f"call site. Only the argument name token is replaced; the value "
147
+ f"expression is untouched, so this migration cannot change what is "
148
+ f"passed, only what it is called."
149
+ ),
150
+ )
151
+
152
+ for finding in report.findings:
153
+ if not finding.patchable:
154
+ plan.skipped.append(
155
+ f"{finding.reference} ({finding.matched_contract}): "
156
+ f"{finding.unpatchable_reason}"
157
+ )
158
+ continue
159
+ if finding.confidence < config.min_confidence:
160
+ plan.skipped.append(
161
+ f"{finding.reference} ({finding.matched_contract}): confidence "
162
+ f"{finding.confidence.value} is below the "
163
+ f"`{config.min_confidence.value}` threshold"
164
+ )
165
+ continue
166
+ reference = finding.reference
167
+ plan.transformations.append(
168
+ Transformation(
169
+ reference=reference,
170
+ old=f"{old}=",
171
+ new=f"{new}=",
172
+ symbol=finding.symbol,
173
+ confidence=finding.confidence,
174
+ edit=TextEdit(
175
+ line=reference.line,
176
+ col=reference.col,
177
+ end_line=reference.end_line or reference.line,
178
+ end_col=reference.end_col or reference.col,
179
+ new_text=new,
180
+ description=f"rename keyword argument `{old}` to `{new}`",
181
+ ),
182
+ )
183
+ )
184
+
185
+ if not plan.transformations:
186
+ plan.blocked_reason = (
187
+ f"found {len(report.findings)} use(s) of `{old}=` but none were on a "
188
+ f"call this migration should rewrite"
189
+ if report.findings
190
+ else f"no calls passing `{old}=` were found"
191
+ )
192
+ return plan
193
+
194
+
195
+ def _related_tests(index: RepoIndex, findings: list[ImpactFinding]) -> list[str]:
196
+ from patchahead.testing import discovery
197
+
198
+ return discovery.tests_for_paths(index, [f.path for f in findings])
199
+
200
+
201
+ register(KwargRenameHandler())