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,582 @@
|
|
|
1
|
+
"""Page-based -> cursor-based pagination.
|
|
2
|
+
|
|
3
|
+
This is the family the prototype got most wrong. Its "migration" was a literal
|
|
4
|
+
string containing the demo's own function, pasted over whatever function it
|
|
5
|
+
found; pointed at another repository it renamed the user's function, changed its
|
|
6
|
+
signature, and swapped its response key (``docs/assessment.md`` §2.1).
|
|
7
|
+
|
|
8
|
+
This implementation recognizes a *shape* and rewrites four specific spans inside
|
|
9
|
+
it, leaving everything else -- the function name, the signature, the accumulator,
|
|
10
|
+
any logging, the formatting -- exactly as the author wrote it.
|
|
11
|
+
|
|
12
|
+
The recognized shape
|
|
13
|
+
--------------------
|
|
14
|
+
|
|
15
|
+
.. code-block:: python
|
|
16
|
+
|
|
17
|
+
page = 1 # (A) initializer
|
|
18
|
+
while True: # (B) unconditional loop
|
|
19
|
+
response = client.get_orders(page=page) # (C) call passing the page
|
|
20
|
+
...
|
|
21
|
+
if page >= response["total_pages"]: # (D) guard on the page count
|
|
22
|
+
break
|
|
23
|
+
...
|
|
24
|
+
page += 1 # (E) advance
|
|
25
|
+
|
|
26
|
+
becomes
|
|
27
|
+
|
|
28
|
+
.. code-block:: python
|
|
29
|
+
|
|
30
|
+
cursor = None # (A)
|
|
31
|
+
while True: # (B) untouched
|
|
32
|
+
response = client.get_orders(cursor=cursor) # (C)
|
|
33
|
+
...
|
|
34
|
+
if not response.get("has_more"): # (D)
|
|
35
|
+
break
|
|
36
|
+
...
|
|
37
|
+
cursor = response.get("next_cursor") # (E)
|
|
38
|
+
|
|
39
|
+
Statements between the numbered lines are not touched. Only the four spans
|
|
40
|
+
change, so a 40-line sync function produces a four-line diff.
|
|
41
|
+
|
|
42
|
+
Failing closed
|
|
43
|
+
--------------
|
|
44
|
+
|
|
45
|
+
If any of (A)-(E) is missing, or the page variable is used anywhere *else* in
|
|
46
|
+
the function (logged, returned, stored), the handler refuses: it reports what it
|
|
47
|
+
found, states which part of the shape it could not match, and produces no patch.
|
|
48
|
+
A pagination loop that does something unusual is exactly the case where a
|
|
49
|
+
confident wrong answer is most expensive, and it is the case
|
|
50
|
+
``--use-llm`` exists for.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
from __future__ import annotations
|
|
54
|
+
|
|
55
|
+
import ast
|
|
56
|
+
import logging
|
|
57
|
+
from dataclasses import dataclass, field
|
|
58
|
+
|
|
59
|
+
from patchahead.analysis.index import RepoIndex
|
|
60
|
+
from patchahead.analysis.python_ast import (
|
|
61
|
+
ColumnMap,
|
|
62
|
+
ModuleAnalysis,
|
|
63
|
+
SourceRange,
|
|
64
|
+
iter_own_scope,
|
|
65
|
+
)
|
|
66
|
+
from patchahead.config import Config
|
|
67
|
+
from patchahead.domain.change import BreakingChange, ChangeKind, Confidence, PaginationContract
|
|
68
|
+
from patchahead.domain.impact import AccessKind, CodeReference, ImpactFinding, ImpactReport
|
|
69
|
+
from patchahead.domain.plan import MigrationPlan, Risk, TextEdit, Transformation
|
|
70
|
+
from patchahead.handlers.base import MigrationHandler, register
|
|
71
|
+
|
|
72
|
+
log = logging.getLogger(__name__)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class PageLoop:
|
|
77
|
+
"""A recognized page-based pagination loop, with every span to rewrite."""
|
|
78
|
+
|
|
79
|
+
#: Name of the integer page counter, e.g. ``page``.
|
|
80
|
+
page_var: str
|
|
81
|
+
#: Name the paginated response is bound to, e.g. ``response``.
|
|
82
|
+
response_var: str
|
|
83
|
+
#: Enclosing function.
|
|
84
|
+
symbol: str
|
|
85
|
+
#: (A) ``page = 1``
|
|
86
|
+
init_range: SourceRange
|
|
87
|
+
#: (C) the ``page=page`` keyword inside the call
|
|
88
|
+
call_keyword_range: SourceRange
|
|
89
|
+
#: (D) the ``if`` test expression guarding the break
|
|
90
|
+
guard_range: SourceRange
|
|
91
|
+
#: (E) ``page += 1``
|
|
92
|
+
advance_range: SourceRange
|
|
93
|
+
#: Line of the ``while`` statement, for reporting.
|
|
94
|
+
loop_line: int
|
|
95
|
+
#: Names already bound in the enclosing function, for collision avoidance.
|
|
96
|
+
bound_names: set[str] = field(default_factory=set)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class LoopRejection:
|
|
101
|
+
"""A ``while True`` loop that looked like pagination but was not migratable."""
|
|
102
|
+
|
|
103
|
+
symbol: str
|
|
104
|
+
line: int
|
|
105
|
+
reason: str
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _is_true_literal(node: ast.expr) -> bool:
|
|
109
|
+
return isinstance(node, ast.Constant) and node.value is True
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _response_key_access(node: ast.AST, key: str) -> str:
|
|
113
|
+
"""The variable name in ``<name>[key]`` or ``<name>.get(key)``, else ``""``."""
|
|
114
|
+
if (
|
|
115
|
+
isinstance(node, ast.Subscript)
|
|
116
|
+
and isinstance(node.slice, ast.Constant)
|
|
117
|
+
and node.slice.value == key
|
|
118
|
+
and isinstance(node.value, ast.Name)
|
|
119
|
+
):
|
|
120
|
+
return node.value.id
|
|
121
|
+
if (
|
|
122
|
+
isinstance(node, ast.Call)
|
|
123
|
+
and isinstance(node.func, ast.Attribute)
|
|
124
|
+
and node.func.attr == "get"
|
|
125
|
+
and isinstance(node.func.value, ast.Name)
|
|
126
|
+
and node.args
|
|
127
|
+
and isinstance(node.args[0], ast.Constant)
|
|
128
|
+
and node.args[0].value == key
|
|
129
|
+
):
|
|
130
|
+
return node.func.value.id
|
|
131
|
+
return ""
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _contains_break(statements: list[ast.stmt]) -> bool:
|
|
135
|
+
for statement in statements:
|
|
136
|
+
for node in ast.walk(statement):
|
|
137
|
+
if isinstance(node, ast.Break):
|
|
138
|
+
return True
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _is_increment(node: ast.AST, name: str) -> bool:
|
|
143
|
+
"""``name += 1`` or ``name = name + 1``."""
|
|
144
|
+
if (
|
|
145
|
+
isinstance(node, ast.AugAssign)
|
|
146
|
+
and isinstance(node.op, ast.Add)
|
|
147
|
+
and isinstance(node.target, ast.Name)
|
|
148
|
+
and node.target.id == name
|
|
149
|
+
and isinstance(node.value, ast.Constant)
|
|
150
|
+
and node.value.value == 1
|
|
151
|
+
):
|
|
152
|
+
return True
|
|
153
|
+
return (
|
|
154
|
+
isinstance(node, ast.Assign)
|
|
155
|
+
and len(node.targets) == 1
|
|
156
|
+
and isinstance(node.targets[0], ast.Name)
|
|
157
|
+
and node.targets[0].id == name
|
|
158
|
+
and isinstance(node.value, ast.BinOp)
|
|
159
|
+
and isinstance(node.value.op, ast.Add)
|
|
160
|
+
and isinstance(node.value.left, ast.Name)
|
|
161
|
+
and node.value.left.id == name
|
|
162
|
+
and isinstance(node.value.right, ast.Constant)
|
|
163
|
+
and node.value.right.value == 1
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _functions(tree: ast.Module):
|
|
168
|
+
"""Every function definition, with its dotted name."""
|
|
169
|
+
|
|
170
|
+
def walk(node: ast.AST, prefix: list[str]):
|
|
171
|
+
for child in ast.iter_child_nodes(node):
|
|
172
|
+
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
173
|
+
name = ".".join(prefix + [child.name])
|
|
174
|
+
yield name, child
|
|
175
|
+
yield from walk(child, prefix + [child.name])
|
|
176
|
+
elif isinstance(child, ast.ClassDef):
|
|
177
|
+
yield from walk(child, prefix + [child.name])
|
|
178
|
+
else:
|
|
179
|
+
yield from walk(child, prefix)
|
|
180
|
+
|
|
181
|
+
yield from walk(tree, [])
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def find_page_loops(
|
|
185
|
+
module: ModuleAnalysis, contract: PaginationContract
|
|
186
|
+
) -> tuple[list[PageLoop], list[LoopRejection]]:
|
|
187
|
+
"""Find every migratable page loop, and every near-miss with its reason."""
|
|
188
|
+
loops: list[PageLoop] = []
|
|
189
|
+
rejections: list[LoopRejection] = []
|
|
190
|
+
|
|
191
|
+
columns = module.columns or ColumnMap(module.source)
|
|
192
|
+
|
|
193
|
+
for symbol, function in _functions(module.tree):
|
|
194
|
+
# Names bound anywhere at or below this function, so a generated cursor
|
|
195
|
+
# variable cannot collide with one a nested scope already uses.
|
|
196
|
+
bound = {
|
|
197
|
+
node.id
|
|
198
|
+
for node in ast.walk(function)
|
|
199
|
+
if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store)
|
|
200
|
+
}
|
|
201
|
+
bound |= {argument.arg for argument in function.args.args}
|
|
202
|
+
|
|
203
|
+
# Own scope only. A loop inside a nested `def` belongs to that function,
|
|
204
|
+
# and `_functions` yields it separately -- walking into it here would
|
|
205
|
+
# match the same loop twice and emit overlapping edits for it.
|
|
206
|
+
for loop in (n for n in iter_own_scope(function) if isinstance(n, ast.While)):
|
|
207
|
+
if not _is_true_literal(loop.test):
|
|
208
|
+
continue
|
|
209
|
+
|
|
210
|
+
match, reason = _match_loop(function, loop, symbol, contract, bound, columns)
|
|
211
|
+
if match is not None:
|
|
212
|
+
loops.append(match)
|
|
213
|
+
elif reason:
|
|
214
|
+
rejections.append(LoopRejection(symbol=symbol, line=loop.lineno, reason=reason))
|
|
215
|
+
|
|
216
|
+
return loops, rejections
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _match_loop(
|
|
220
|
+
function: ast.AST,
|
|
221
|
+
loop: ast.While,
|
|
222
|
+
symbol: str,
|
|
223
|
+
contract: PaginationContract,
|
|
224
|
+
bound: set[str],
|
|
225
|
+
columns: ColumnMap,
|
|
226
|
+
) -> tuple[PageLoop | None, str]:
|
|
227
|
+
"""Match one ``while True`` loop against the recognized shape."""
|
|
228
|
+
# (C) a call passing the page parameter, assigned to a name.
|
|
229
|
+
call_keyword: ast.keyword | None = None
|
|
230
|
+
page_var = ""
|
|
231
|
+
response_var = ""
|
|
232
|
+
for node in ast.walk(loop):
|
|
233
|
+
if not isinstance(node, ast.Assign) or len(node.targets) != 1:
|
|
234
|
+
continue
|
|
235
|
+
if not isinstance(node.targets[0], ast.Name) or not isinstance(node.value, ast.Call):
|
|
236
|
+
continue
|
|
237
|
+
for keyword in node.value.keywords:
|
|
238
|
+
if keyword.arg == contract.page_param and isinstance(keyword.value, ast.Name):
|
|
239
|
+
call_keyword = keyword
|
|
240
|
+
page_var = keyword.value.id
|
|
241
|
+
response_var = node.targets[0].id
|
|
242
|
+
break
|
|
243
|
+
if call_keyword is not None:
|
|
244
|
+
break
|
|
245
|
+
|
|
246
|
+
if call_keyword is None:
|
|
247
|
+
# Not a page loop at all; this `while True` is something else entirely.
|
|
248
|
+
return None, ""
|
|
249
|
+
|
|
250
|
+
# (D) an `if` guarding a break, testing the total-pages key on the response.
|
|
251
|
+
guard: ast.expr | None = None
|
|
252
|
+
for node in ast.walk(loop):
|
|
253
|
+
if not isinstance(node, ast.If) or not _contains_break(node.body):
|
|
254
|
+
continue
|
|
255
|
+
for inner in ast.walk(node.test):
|
|
256
|
+
if _response_key_access(inner, contract.total_pages_key) == response_var:
|
|
257
|
+
guard = node.test
|
|
258
|
+
break
|
|
259
|
+
if guard is not None:
|
|
260
|
+
break
|
|
261
|
+
|
|
262
|
+
if guard is None:
|
|
263
|
+
return None, (
|
|
264
|
+
f"found a `{contract.page_param}=` paginated call but no `if ...: break` "
|
|
265
|
+
f'guarded by `{response_var}["{contract.total_pages_key}"]`, so the loop\'s '
|
|
266
|
+
f"termination condition could not be identified"
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
# (E) the page advance.
|
|
270
|
+
advance: ast.stmt | None = None
|
|
271
|
+
for node in ast.walk(loop):
|
|
272
|
+
if isinstance(node, (ast.AugAssign, ast.Assign)) and _is_increment(node, page_var):
|
|
273
|
+
advance = node
|
|
274
|
+
break
|
|
275
|
+
|
|
276
|
+
if advance is None:
|
|
277
|
+
return None, (
|
|
278
|
+
f"found a `{contract.page_param}=` paginated call and a "
|
|
279
|
+
f"`{contract.total_pages_key}` guard, but no `{page_var} += 1` advance"
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
# (A) the initializer, before the loop, in the enclosing function.
|
|
283
|
+
init: ast.stmt | None = None
|
|
284
|
+
for node in iter_own_scope(function):
|
|
285
|
+
if (
|
|
286
|
+
isinstance(node, ast.Assign)
|
|
287
|
+
and len(node.targets) == 1
|
|
288
|
+
and isinstance(node.targets[0], ast.Name)
|
|
289
|
+
and node.targets[0].id == page_var
|
|
290
|
+
and isinstance(node.value, ast.Constant)
|
|
291
|
+
and isinstance(node.value.value, int)
|
|
292
|
+
and node.lineno < loop.lineno
|
|
293
|
+
):
|
|
294
|
+
init = node
|
|
295
|
+
break
|
|
296
|
+
|
|
297
|
+
if init is None:
|
|
298
|
+
return None, (
|
|
299
|
+
f"found a page loop over `{page_var}` but no integer initializer "
|
|
300
|
+
f"(`{page_var} = 1`) before the loop"
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
# The page variable must be used *only* in the four spans we rewrite. If it
|
|
304
|
+
# is logged, returned, or stored anywhere else, replacing it with a cursor
|
|
305
|
+
# would silently change behavior.
|
|
306
|
+
allowed: set[int] = set()
|
|
307
|
+
for node in ast.walk(init):
|
|
308
|
+
allowed.add(id(node))
|
|
309
|
+
for node in ast.walk(call_keyword):
|
|
310
|
+
allowed.add(id(node))
|
|
311
|
+
for node in ast.walk(guard):
|
|
312
|
+
allowed.add(id(node))
|
|
313
|
+
for node in ast.walk(advance):
|
|
314
|
+
allowed.add(id(node))
|
|
315
|
+
|
|
316
|
+
stray = [
|
|
317
|
+
node
|
|
318
|
+
for node in ast.walk(function)
|
|
319
|
+
if isinstance(node, ast.Name) and node.id == page_var and id(node) not in allowed
|
|
320
|
+
]
|
|
321
|
+
if stray:
|
|
322
|
+
lines = ", ".join(str(node.lineno) for node in sorted(stray, key=lambda n: n.lineno)[:4])
|
|
323
|
+
return None, (
|
|
324
|
+
f"`{page_var}` is also used outside the pagination loop's own "
|
|
325
|
+
f"bookkeeping (line(s) {lines}); replacing it with a cursor could change "
|
|
326
|
+
f"behavior, so this loop is left for a human"
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
return (
|
|
330
|
+
PageLoop(
|
|
331
|
+
page_var=page_var,
|
|
332
|
+
response_var=response_var,
|
|
333
|
+
symbol=symbol,
|
|
334
|
+
init_range=SourceRange.of(init, columns),
|
|
335
|
+
call_keyword_range=SourceRange.of(call_keyword, columns),
|
|
336
|
+
guard_range=SourceRange.of(guard, columns),
|
|
337
|
+
advance_range=SourceRange.of(advance, columns),
|
|
338
|
+
loop_line=loop.lineno,
|
|
339
|
+
bound_names=bound,
|
|
340
|
+
),
|
|
341
|
+
"",
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def choose_cursor_name(preferred: str, taken: set[str]) -> str:
|
|
346
|
+
"""Pick a cursor variable name that does not collide with an existing one."""
|
|
347
|
+
if preferred not in taken:
|
|
348
|
+
return preferred
|
|
349
|
+
for suffix in ("_token", "_value", "2"):
|
|
350
|
+
candidate = f"{preferred}{suffix}"
|
|
351
|
+
if candidate not in taken:
|
|
352
|
+
return candidate
|
|
353
|
+
index = 2
|
|
354
|
+
while f"{preferred}{index}" in taken:
|
|
355
|
+
index += 1
|
|
356
|
+
return f"{preferred}{index}"
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
class PaginationHandler(MigrationHandler):
|
|
360
|
+
"""Migrates a page-based pagination loop to a cursor-based one."""
|
|
361
|
+
|
|
362
|
+
name = "pagination_page_to_cursor"
|
|
363
|
+
kinds = (ChangeKind.PAGINATION_PAGE_TO_CURSOR,)
|
|
364
|
+
summary = "Rewrite a page-based pagination loop to use a cursor"
|
|
365
|
+
limitations = (
|
|
366
|
+
"Only the documented `while True` + guarded-`break` shape. A `while page "
|
|
367
|
+
"<= total_pages:` loop, a recursive pager, or a generator is reported and "
|
|
368
|
+
"refused.",
|
|
369
|
+
"The page variable must be used only by the loop's own bookkeeping. If it "
|
|
370
|
+
"is logged or returned, the loop is left for a human.",
|
|
371
|
+
"Assumes the new API exposes `has_more` and `next_cursor` (configurable "
|
|
372
|
+
"per change document). It cannot verify that from the code.",
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
def analyze(self, change: BreakingChange, index: RepoIndex, config: Config) -> ImpactReport:
|
|
376
|
+
contract = change.pagination
|
|
377
|
+
findings: list[ImpactFinding] = []
|
|
378
|
+
|
|
379
|
+
for path in index.non_test_paths():
|
|
380
|
+
module = index.modules[path]
|
|
381
|
+
loops, rejections = find_page_loops(module, contract)
|
|
382
|
+
migratable_lines = {loop.loop_line for loop in loops}
|
|
383
|
+
|
|
384
|
+
for loop in loops:
|
|
385
|
+
findings.append(
|
|
386
|
+
ImpactFinding(
|
|
387
|
+
reference=CodeReference(
|
|
388
|
+
path=path,
|
|
389
|
+
line=loop.loop_line,
|
|
390
|
+
snippet=module.line_text(loop.loop_line),
|
|
391
|
+
),
|
|
392
|
+
symbol=loop.symbol,
|
|
393
|
+
matched_contract=(
|
|
394
|
+
f"while True: ... {contract.page_param}={loop.page_var} ... "
|
|
395
|
+
f'{loop.response_var}["{contract.total_pages_key}"] ... '
|
|
396
|
+
f"{loop.page_var} += 1"
|
|
397
|
+
),
|
|
398
|
+
access=AccessKind.PAGE_LOOP,
|
|
399
|
+
reason=(
|
|
400
|
+
f"page-based pagination loop over `{loop.page_var}`, "
|
|
401
|
+
f"terminating on `{contract.total_pages_key}`, which the "
|
|
402
|
+
f"new API no longer returns"
|
|
403
|
+
),
|
|
404
|
+
confidence=Confidence.HIGH,
|
|
405
|
+
source_text="",
|
|
406
|
+
patchable=True,
|
|
407
|
+
)
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
for rejection in rejections:
|
|
411
|
+
findings.append(
|
|
412
|
+
ImpactFinding(
|
|
413
|
+
reference=CodeReference(
|
|
414
|
+
path=path,
|
|
415
|
+
line=rejection.line,
|
|
416
|
+
snippet=module.line_text(rejection.line),
|
|
417
|
+
),
|
|
418
|
+
symbol=rejection.symbol,
|
|
419
|
+
matched_contract=f"while True: ... {contract.page_param}=...",
|
|
420
|
+
access=AccessKind.PAGE_LOOP,
|
|
421
|
+
reason=rejection.reason,
|
|
422
|
+
confidence=Confidence.MEDIUM,
|
|
423
|
+
source_text="",
|
|
424
|
+
patchable=False,
|
|
425
|
+
unpatchable_reason=rejection.reason,
|
|
426
|
+
)
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
# Any remaining read of the removed key is a real break even when it
|
|
430
|
+
# is not inside a loop we can rewrite -- report it so nothing is lost.
|
|
431
|
+
for access in module.subscripts + module.get_calls: # type: ignore[operator]
|
|
432
|
+
if access.key != contract.total_pages_key:
|
|
433
|
+
continue
|
|
434
|
+
if any(abs(access.range.line - line) < 12 for line in migratable_lines):
|
|
435
|
+
continue # already covered by a migratable loop
|
|
436
|
+
findings.append(
|
|
437
|
+
ImpactFinding(
|
|
438
|
+
reference=CodeReference(
|
|
439
|
+
path=path,
|
|
440
|
+
line=access.range.line,
|
|
441
|
+
col=access.range.col,
|
|
442
|
+
end_line=access.range.end_line,
|
|
443
|
+
end_col=access.range.end_col,
|
|
444
|
+
snippet=module.line_text(access.range.line),
|
|
445
|
+
),
|
|
446
|
+
symbol=access.symbol,
|
|
447
|
+
matched_contract=(
|
|
448
|
+
f'{access.receiver or "<expr>"}["{contract.total_pages_key}"]'
|
|
449
|
+
),
|
|
450
|
+
access=AccessKind.SUBSCRIPT,
|
|
451
|
+
reason=(
|
|
452
|
+
f"reads `{contract.total_pages_key}`, which the new API no "
|
|
453
|
+
f"longer returns, but not inside a pagination loop this "
|
|
454
|
+
f"handler recognizes"
|
|
455
|
+
),
|
|
456
|
+
confidence=Confidence.HIGH,
|
|
457
|
+
source_text="",
|
|
458
|
+
patchable=False,
|
|
459
|
+
unpatchable_reason=(
|
|
460
|
+
"not part of a recognized page loop; rewriting it in "
|
|
461
|
+
"isolation would need the surrounding logic to change too"
|
|
462
|
+
),
|
|
463
|
+
)
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
findings.sort(key=lambda f: (f.reference.path, f.reference.line))
|
|
467
|
+
return ImpactReport(
|
|
468
|
+
change=change,
|
|
469
|
+
findings=findings,
|
|
470
|
+
related_tests=_related_tests(index, findings),
|
|
471
|
+
files_scanned=index.file_count,
|
|
472
|
+
skipped_files=dict(index.skipped),
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
def plan(
|
|
476
|
+
self,
|
|
477
|
+
change: BreakingChange,
|
|
478
|
+
report: ImpactReport,
|
|
479
|
+
index: RepoIndex,
|
|
480
|
+
config: Config,
|
|
481
|
+
) -> MigrationPlan:
|
|
482
|
+
contract = change.pagination
|
|
483
|
+
plan = MigrationPlan(
|
|
484
|
+
change=change,
|
|
485
|
+
handler=self.name,
|
|
486
|
+
expected_tests=list(report.related_tests),
|
|
487
|
+
risk=Risk.MEDIUM,
|
|
488
|
+
rationale=(
|
|
489
|
+
f"Rewrite each recognized page loop to iterate on "
|
|
490
|
+
f"`{contract.cursor_param}`/`{contract.next_cursor_key}` and terminate "
|
|
491
|
+
f"on `{contract.has_more_key}`. Four spans change per loop: the "
|
|
492
|
+
f"initializer, the call's `{contract.page_param}=` argument, the break "
|
|
493
|
+
f"guard, and the advance. The enclosing function's name, signature, "
|
|
494
|
+
f"and every other statement are untouched."
|
|
495
|
+
),
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
# Planning rebuilds the loop structures from the index analysis used, so
|
|
499
|
+
# the spans it edits are exactly the spans analysis matched.
|
|
500
|
+
for path in report.affected_files:
|
|
501
|
+
module = index.modules.get(path)
|
|
502
|
+
if module is None:
|
|
503
|
+
continue
|
|
504
|
+
loops, _ = find_page_loops(module, contract)
|
|
505
|
+
for loop in loops:
|
|
506
|
+
cursor_var = choose_cursor_name(contract.cursor_param, loop.bound_names)
|
|
507
|
+
specs = [
|
|
508
|
+
(
|
|
509
|
+
loop.init_range,
|
|
510
|
+
f"{loop.page_var} = <int>",
|
|
511
|
+
f"{cursor_var} = None",
|
|
512
|
+
"start from no cursor instead of page 1",
|
|
513
|
+
),
|
|
514
|
+
(
|
|
515
|
+
loop.call_keyword_range,
|
|
516
|
+
f"{contract.page_param}={loop.page_var}",
|
|
517
|
+
f"{contract.cursor_param}={cursor_var}",
|
|
518
|
+
"pass the cursor instead of the page number",
|
|
519
|
+
),
|
|
520
|
+
(
|
|
521
|
+
loop.guard_range,
|
|
522
|
+
f'{loop.page_var} ... {loop.response_var}["{contract.total_pages_key}"]',
|
|
523
|
+
f'not {loop.response_var}.get("{contract.has_more_key}")',
|
|
524
|
+
"terminate on has_more instead of the page count",
|
|
525
|
+
),
|
|
526
|
+
(
|
|
527
|
+
loop.advance_range,
|
|
528
|
+
f"{loop.page_var} += 1",
|
|
529
|
+
f'{cursor_var} = {loop.response_var}.get("{contract.next_cursor_key}")',
|
|
530
|
+
"advance by cursor instead of incrementing the page",
|
|
531
|
+
),
|
|
532
|
+
]
|
|
533
|
+
for source_range, old, new, description in specs:
|
|
534
|
+
plan.transformations.append(
|
|
535
|
+
Transformation(
|
|
536
|
+
reference=CodeReference(
|
|
537
|
+
path=path,
|
|
538
|
+
line=source_range.line,
|
|
539
|
+
col=source_range.col,
|
|
540
|
+
end_line=source_range.end_line,
|
|
541
|
+
end_col=source_range.end_col,
|
|
542
|
+
snippet=module.line_text(source_range.line),
|
|
543
|
+
),
|
|
544
|
+
old=old,
|
|
545
|
+
new=new,
|
|
546
|
+
symbol=loop.symbol,
|
|
547
|
+
confidence=Confidence.HIGH,
|
|
548
|
+
edit=TextEdit(
|
|
549
|
+
line=source_range.line,
|
|
550
|
+
col=source_range.col,
|
|
551
|
+
end_line=source_range.end_line,
|
|
552
|
+
end_col=source_range.end_col,
|
|
553
|
+
new_text=new,
|
|
554
|
+
description=description,
|
|
555
|
+
),
|
|
556
|
+
)
|
|
557
|
+
)
|
|
558
|
+
log.debug("planned cursor migration for %s in %s", loop.symbol, path)
|
|
559
|
+
|
|
560
|
+
for finding in report.findings:
|
|
561
|
+
if not finding.patchable:
|
|
562
|
+
plan.skipped.append(
|
|
563
|
+
f"{finding.reference} ({finding.symbol}): {finding.unpatchable_reason}"
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
if not plan.transformations:
|
|
567
|
+
plan.blocked_reason = "no pagination loop matching the supported shape was found. " + (
|
|
568
|
+
"PatchAhead found page-based code it could not safely rewrite; "
|
|
569
|
+
"see the skipped list for why, and consider `--use-llm`."
|
|
570
|
+
if report.findings
|
|
571
|
+
else "No page-based pagination was found in this repository."
|
|
572
|
+
)
|
|
573
|
+
return plan
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _related_tests(index: RepoIndex, findings: list[ImpactFinding]) -> list[str]:
|
|
577
|
+
from patchahead.testing import discovery
|
|
578
|
+
|
|
579
|
+
return discovery.tests_for_paths(index, [f.path for f in findings])
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
register(PaginationHandler())
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Change ingestion: turning upstream change documents into BreakingChanges.
|
|
2
|
+
|
|
3
|
+
Importing this package registers the built-in parsers. Adding a format (OpenAPI
|
|
4
|
+
spec diffs, a vendor changelog API) means adding a module here that subclasses
|
|
5
|
+
:class:`~patchahead.ingest.base.ChangeParser` and calls
|
|
6
|
+
:func:`~patchahead.ingest.base.register`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from patchahead.ingest.base import (
|
|
10
|
+
ChangeDocument,
|
|
11
|
+
ChangeParser,
|
|
12
|
+
IngestError,
|
|
13
|
+
parse_document,
|
|
14
|
+
parse_file,
|
|
15
|
+
register,
|
|
16
|
+
registered,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
# Import for the registration side effect. Order matters only in that later
|
|
20
|
+
# registrations are tried first; the two built-ins select on disjoint suffixes.
|
|
21
|
+
from patchahead.ingest import markdown as markdown # noqa: E402,F401 isort:skip
|
|
22
|
+
from patchahead.ingest import structured as structured # noqa: E402,F401 isort:skip
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"ChangeDocument",
|
|
26
|
+
"ChangeParser",
|
|
27
|
+
"IngestError",
|
|
28
|
+
"parse_document",
|
|
29
|
+
"parse_file",
|
|
30
|
+
"register",
|
|
31
|
+
"registered",
|
|
32
|
+
]
|