openrewrite-migrate-python 0.18.0__py3-none-any.whl → 0.20.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.
@@ -14,7 +14,8 @@ unnecessary operators, literals, or indirection:
14
14
  See: https://peps.python.org/pep-0008/#programming-recommendations
15
15
  """
16
16
 
17
- from typing import Any, List
17
+ from typing import AbstractSet, Any, Dict, List, NamedTuple, Tuple
18
+ from uuid import UUID
18
19
 
19
20
  from rewrite import ExecutionContext, Preconditions, Recipe, TreeVisitor
20
21
  from rewrite.markers import Markers
@@ -28,11 +29,11 @@ from rewrite.python.preconditions import uses_method
28
29
  from rewrite.python.scope_utils import scope_of
29
30
  from rewrite.python.type_utils import is_of_class_type
30
31
  from rewrite.python.visitor import PythonVisitor
31
- from rewrite.python.template import pattern, template, capture
32
+ from rewrite.python.template import Capture, Template, pattern, template, capture
32
33
  from rewrite.python.template.pattern import MatchResult
33
34
  from rewrite.java.tree import (
34
35
  Binary as JBinary, Block, ControlParentheses, Empty, Expression, If as JIf, J,
35
- Literal, MethodInvocation, Return, Ternary as JTernary, Unary,
36
+ Literal, MethodInvocation, Parentheses, Return, Ternary as JTernary, Unary,
36
37
  WhileLoop as JWhileLoop,
37
38
  )
38
39
  from rewrite.java.support_types import JavaType, Space
@@ -80,22 +81,43 @@ _lc_not_x_template = template("not {lc_x}", lc_x=_lc_x)
80
81
 
81
82
 
82
83
  # --- DeMorgan patterns/templates ---
84
+ class _Distributed(NamedTuple):
85
+ """A De Morgan result, written alone or continuing a chain of its own operator."""
86
+
87
+ alone: Template
88
+ chained: Template
89
+ """``{dm_z} and`` the result, which the parser nests to the left as it does ``z and a and b``."""
90
+ operator: JBinary.Type
91
+
92
+
93
+ _dm_z = capture('dm_z')
94
+
95
+
96
+ def _distributed(operator: str, left: str, right: str, captures: Dict[str, Capture]) -> _Distributed:
97
+ return _Distributed(
98
+ Template(f"{left} {operator} {right}", captures),
99
+ Template(f"{{dm_z}} {operator} {left} {operator} {right}", {'dm_z': _dm_z, **captures}),
100
+ JBinary.Type.And if operator == "and" else JBinary.Type.Or,
101
+ )
102
+
103
+
83
104
  _dm_x = capture('dm_x')
84
105
  _dm_y = capture('dm_y')
106
+ _dm_xy = {'dm_x': _dm_x, 'dm_y': _dm_y}
85
107
  _not_and_pattern = pattern("not ({dm_x} and {dm_y})", dm_x=_dm_x, dm_y=_dm_y)
86
108
  _not_or_pattern = pattern("not ({dm_x} or {dm_y})", dm_x=_dm_x, dm_y=_dm_y)
87
- _not_x_or_not_y_template = template("not {dm_x} or not {dm_y}", dm_x=_dm_x, dm_y=_dm_y)
88
- _not_x_and_not_y_template = template("not {dm_x} and not {dm_y}", dm_x=_dm_x, dm_y=_dm_y)
109
+ _not_x_or_not_y = _distributed("or", "not {dm_x}", "not {dm_y}", _dm_xy)
110
+ _not_x_and_not_y = _distributed("and", "not {dm_x}", "not {dm_y}", _dm_xy)
89
111
  # Partial-negation templates: one operand already negated by operator flip (in→not in)
90
- _not_x_or_y_template = template("not {dm_x} or {dm_y}", dm_x=_dm_x, dm_y=_dm_y)
91
- _x_or_not_y_template = template("{dm_x} or not {dm_y}", dm_x=_dm_x, dm_y=_dm_y)
92
- _not_x_and_y_template = template("not {dm_x} and {dm_y}", dm_x=_dm_x, dm_y=_dm_y)
93
- _x_and_not_y_template = template("{dm_x} and not {dm_y}", dm_x=_dm_x, dm_y=_dm_y)
112
+ _not_x_or_y = _distributed("or", "not {dm_x}", "{dm_y}", _dm_xy)
113
+ _x_or_not_y = _distributed("or", "{dm_x}", "not {dm_y}", _dm_xy)
114
+ _not_x_and_y = _distributed("and", "not {dm_x}", "{dm_y}", _dm_xy)
115
+ _x_and_not_y = _distributed("and", "{dm_x}", "not {dm_y}", _dm_xy)
94
116
  # Fused DeMorgan + double-negation: not (not x and not y) -> x or y
95
117
  _not_notx_and_noty_pattern = pattern("not (not {dm_x} and not {dm_y})", dm_x=_dm_x, dm_y=_dm_y)
96
- _x_or_y_template = template("{dm_x} or {dm_y}", dm_x=_dm_x, dm_y=_dm_y)
118
+ _x_or_y = _distributed("or", "{dm_x}", "{dm_y}", _dm_xy)
97
119
  _not_notx_or_noty_pattern = pattern("not (not {dm_x} or not {dm_y})", dm_x=_dm_x, dm_y=_dm_y)
98
- _x_and_y_template = template("{dm_x} and {dm_y}", dm_x=_dm_x, dm_y=_dm_y)
120
+ _x_and_y = _distributed("and", "{dm_x}", "{dm_y}", _dm_xy)
99
121
 
100
122
  # DeMorgan with comparison operators: not (p == q and r == s) -> p != q or r != s
101
123
  _dm_p = capture('dm_p')
@@ -105,8 +127,8 @@ _dm_s = capture('dm_s')
105
127
  _not_eq_and_eq_pattern = pattern(
106
128
  "not ({dm_p} == {dm_q} and {dm_r} == {dm_s})", dm_p=_dm_p, dm_q=_dm_q, dm_r=_dm_r, dm_s=_dm_s
107
129
  )
108
- _neq_or_neq_template = template(
109
- "{dm_p} != {dm_q} or {dm_r} != {dm_s}", dm_p=_dm_p, dm_q=_dm_q, dm_r=_dm_r, dm_s=_dm_s
130
+ _neq_or_neq = _distributed(
131
+ "or", "{dm_p} != {dm_q}", "{dm_r} != {dm_s}", {'dm_p': _dm_p, 'dm_q': _dm_q, 'dm_r': _dm_r, 'dm_s': _dm_s}
110
132
  )
111
133
 
112
134
  def _is_bool_literal(node, expected_source: str) -> bool:
@@ -299,8 +321,11 @@ class RemoveRedundantBoolean(Recipe):
299
321
  return Visitor()
300
322
 
301
323
 
302
- def _apply_demorgan(cursor, match, truth_value, both_not, left_not, right_not, neither_not):
303
- """Apply a DeMorgan template, folding negations where possible.
324
+ _Distribution = Tuple[_Distributed, MatchResult]
325
+
326
+
327
+ def _choose_demorgan(match, truth_value, both_not, left_not, right_not, neither_not) -> _Distribution | None:
328
+ """Choose a DeMorgan template and its values, folding negations where possible.
304
329
 
305
330
  For each operand, tries to absorb the ``not`` introduced by DeMorgan:
306
331
 
@@ -319,19 +344,58 @@ def _apply_demorgan(cursor, match, truth_value, both_not, left_not, right_not, n
319
344
  y_negated = negated_without_not(dm_y_val, truth_value)
320
345
 
321
346
  if x_negated and y_negated:
322
- values = MatchResult({'dm_x': x_negated, 'dm_y': y_negated})
323
- return neither_not.apply(cursor, values=values)
347
+ return neither_not, MatchResult({'dm_x': x_negated, 'dm_y': y_negated})
324
348
  if x_negated:
325
- values = MatchResult({'dm_x': x_negated, 'dm_y': dm_y_val})
326
- return right_not.apply(cursor, values=values)
349
+ return right_not, MatchResult({'dm_x': x_negated, 'dm_y': dm_y_val})
327
350
  if y_negated:
328
- values = MatchResult({'dm_x': dm_x_val, 'dm_y': y_negated})
329
- return left_not.apply(cursor, values=values)
330
- return both_not.apply(cursor, values=match)
351
+ return left_not, MatchResult({'dm_x': dm_x_val, 'dm_y': y_negated})
352
+ return both_not, match
353
+
354
+
355
+ def _negated_chain(unary: Unary, operator: JBinary.Type) -> JBinary | None:
356
+ """The parenthesized chain of three or more ``operator`` operands that ``unary`` negates, or None."""
357
+ inner = unary.expression
358
+ if isinstance(inner, Parentheses):
359
+ inner = inner.tree
360
+ if isinstance(inner, JBinary) and inner.operator == operator \
361
+ and isinstance(inner.left, JBinary) and inner.left.operator == operator:
362
+ return inner
363
+ return None
364
+
365
+
366
+ def _operands(chain: Expression, operator: JBinary.Type) -> List[Expression]:
367
+ """The operands of ``chain``, a chain of ``operator`` nested to the left."""
368
+ operands = []
369
+ while isinstance(chain, JBinary) and chain.operator == operator:
370
+ operands.append(chain.right)
371
+ chain = chain.left
372
+ return [chain, *operands]
373
+
374
+
375
+ def _distributes(unary: Unary, written: Unary, made: AbstractSet[UUID], operator: JBinary.Type,
376
+ truth_value: bool) -> bool:
377
+ """Whether ``unary``'s ``not`` distributes over its ``operator``: never over a chain ``written`` spells, unless
378
+ ``made`` holds it, and over a chain a rewrite made only where each operand can be negated."""
379
+ spelled = _negated_chain(written, operator)
380
+ if spelled is not None and spelled.id not in made:
381
+ return False
382
+ chain = _negated_chain(unary, operator)
383
+ return chain is None or truth_value or not any(is_not(operand) for operand in _operands(chain, operator))
384
+
331
385
 
386
+ def _and_head(made: AbstractSet[UUID], unary: Unary, distributed: _Distributed) -> AbstractSet[UUID]:
387
+ """``made`` and the left part of the chain ``unary`` negates, which ``distributed`` negates whole."""
388
+ negated = JBinary.Type.And if distributed.operator == JBinary.Type.Or else JBinary.Type.Or
389
+ chain = _negated_chain(unary, negated)
390
+ return made if chain is None else made | {chain.left.id}
332
391
 
333
- def _distribute_not(unary: Unary, cursor: Cursor) -> J:
334
- """``unary`` with its ``not`` distributed one level in, or ``unary`` itself."""
392
+
393
+ def _distribution(unary: Unary, cursor: Cursor, written: Unary, made: AbstractSet[UUID]) -> _Distribution | None:
394
+ """How ``unary``'s ``not`` distributes one level in, or None where it does not.
395
+
396
+ ``written`` is ``unary`` as the code spells it, before its operands were rewritten, and ``made`` holds the ids
397
+ of the chains that rewrites made.
398
+ """
335
399
  truth_value = is_read_as_truth_value(cursor)
336
400
 
337
401
  # not (p == q and r == s) -> p != q or r != s
@@ -340,46 +404,52 @@ def _distribute_not(unary: Unary, cursor: Cursor) -> J:
340
404
  isinstance(operand, Expression) and is_comparison(operand)
341
405
  for operand in (match[k] for k in ('dm_p', 'dm_q', 'dm_r', 'dm_s'))
342
406
  ):
343
- if not truth_value:
344
- return unary
345
- return _neq_or_neq_template.apply(cursor, values=match) or unary
407
+ return (_neq_or_neq, match) if truth_value else None
346
408
 
347
409
  if truth_value:
348
410
  # Fused: not (not x and not y) -> x or y
349
411
  match = _not_notx_and_noty_pattern.match(unary, cursor)
350
412
  if match:
351
- return _x_or_y_template.apply(cursor, values=match) or unary
413
+ return _x_or_y, match
352
414
 
353
415
  # Fused: not (not x or not y) -> x and y
354
416
  match = _not_notx_or_noty_pattern.match(unary, cursor)
355
417
  if match:
356
- return _x_and_y_template.apply(cursor, values=match) or unary
418
+ return _x_and_y, match
357
419
 
358
420
  # not (x and y) -> not x or not y
359
- # Skip multi-way chains: not (a and b and c) where dm_x
360
- # captures (a and b) — partial expansion is messy.
421
+ # A chain the code spells, as not (a and b and c), is left alone, but one that rewriting an operand
422
+ # made distributes: not (a or not (b and c)) has become not (a or not b or not c) by now.
361
423
  match = _not_and_pattern.match(unary, cursor)
362
- if match:
363
- dm_x_val = match['dm_x']
364
- if not (isinstance(dm_x_val, JBinary) and dm_x_val.operator == JBinary.Type.And):
365
- return _apply_demorgan(
366
- cursor, match, truth_value, _not_x_or_not_y_template,
367
- _not_x_or_y_template, _x_or_not_y_template,
368
- _x_or_y_template,
369
- ) or unary
424
+ if match and _distributes(unary, written, made, JBinary.Type.And, truth_value):
425
+ return _choose_demorgan(match, truth_value, _not_x_or_not_y, _not_x_or_y, _x_or_not_y, _x_or_y)
370
426
 
371
427
  # not (x or y) -> not x and not y
372
428
  match = _not_or_pattern.match(unary, cursor)
373
- if match:
374
- dm_x_val = match['dm_x']
375
- if not (isinstance(dm_x_val, JBinary) and dm_x_val.operator == JBinary.Type.Or):
376
- return _apply_demorgan(
377
- cursor, match, truth_value, _not_x_and_not_y_template,
378
- _not_x_and_y_template, _x_and_not_y_template,
379
- _x_and_y_template,
380
- ) or unary
429
+ if match and _distributes(unary, written, made, JBinary.Type.Or, truth_value):
430
+ return _choose_demorgan(match, truth_value, _not_x_and_not_y, _not_x_and_y, _x_and_not_y, _x_and_y)
431
+
432
+ return None
433
+
434
+
435
+ def _ends_chain(cursor: Cursor, operator: JBinary.Type) -> bool:
436
+ """Whether the value at ``cursor`` is the right operand of an ``and``/``or`` of ``operator``."""
437
+ node, parent = cursor.value, cursor.parent_tree_cursor().value
438
+ return isinstance(node, J) and isinstance(parent, JBinary) and parent.operator == operator \
439
+ and parent.right.id == node.id
440
+
381
441
 
382
- return unary
442
+ def _respaced(chain: J, binary: JBinary) -> J:
443
+ """``chain``, written by a template in place of ``binary``, with the spaces and comments ``binary`` had
444
+ around its left operand and its operator."""
445
+ head = chain.left if isinstance(chain, JBinary) else None
446
+ if not isinstance(head, JBinary):
447
+ return chain
448
+ return chain.replace(_left=head.replace(
449
+ _left=head.left.replace(_prefix=binary.left.prefix),
450
+ _operator=binary.padding.operator,
451
+ _right=head.right.replace(_prefix=binary.right.prefix),
452
+ ))
383
453
 
384
454
 
385
455
  @categorize(_Cleanup)
@@ -423,6 +493,11 @@ class DeMorgan(Recipe):
423
493
 
424
494
  def editor(self) -> TreeVisitor[Any, ExecutionContext]:
425
495
  class Visitor(PythonVisitor[ExecutionContext]):
496
+ def __init__(self, made: AbstractSet[UUID] = frozenset()):
497
+ super().__init__()
498
+ # The ids of chains that rewrites made, which this visit meets again in what a template wrote.
499
+ self._made = made
500
+
426
501
  def visit_unary(
427
502
  self, unary: Unary, p: ExecutionContext
428
503
  ) -> J:
@@ -431,11 +506,38 @@ class DeMorgan(Recipe):
431
506
  visited.replace(_prefix=Space.EMPTY)
432
507
  ):
433
508
  return visited
434
- result = _distribute_not(visited, Cursor(self.cursor.parent, visited))
435
- if result is visited:
509
+ cursor = Cursor(self.cursor.parent, visited)
510
+ distribution = _distribution(visited, cursor, unary, self._made)
511
+ # The `and` holding `x and not (a or b)` rewrites it whole, in visit_binary.
512
+ if distribution is None or _ends_chain(cursor, distribution[0].operator):
513
+ return visited
514
+ distributed, values = distribution
515
+ result = distributed.alone.apply(cursor, values=values)
516
+ if result is None:
436
517
  return visited
437
518
  # The negations a rewrite creates may distribute in turn.
438
- return Visitor().visit(result, p, self.cursor.parent_or_throw) or result
519
+ made = _and_head(self._made, visited, distributed)
520
+ return Visitor(made).visit(result, p, self.cursor.parent_or_throw) or result
521
+
522
+ def visit_binary(self, binary: JBinary, p: ExecutionContext) -> J:
523
+ visited = super().visit_binary(binary, p)
524
+ if not isinstance(visited, JBinary) or not isinstance(visited.right, Unary) or has_comments(
525
+ visited.right.replace(_prefix=Space.EMPTY)
526
+ ):
527
+ return visited
528
+ cursor = Cursor(self.cursor.parent, visited)
529
+ # Read under the unvisited `and`/`or`, as visit_unary read it before leaving it here.
530
+ written = binary.right if isinstance(binary.right, Unary) else visited.right
531
+ distribution = _distribution(visited.right, Cursor(self.cursor, visited.right), written, self._made)
532
+ if distribution is None or distribution[0].operator != visited.operator:
533
+ return visited
534
+ distributed, values = distribution
535
+ result = distributed.chained.apply(cursor, values={**values.as_dict(), 'dm_z': visited.left})
536
+ if result is None:
537
+ return visited
538
+ result = _respaced(result, visited)
539
+ made = _and_head(self._made, visited.right, distributed)
540
+ return Visitor(made).visit(result, p, self.cursor.parent_or_throw) or result
439
541
 
440
542
  return Visitor()
441
543
 
@@ -9,6 +9,7 @@ Both fire only where the receiver's pandas type resolves.
9
9
  See: https://pandas.pydata.org/docs/whatsnew/v1.4.0.html#deprecations
10
10
  """
11
11
 
12
+ import dataclasses
12
13
  from typing import Any, List
13
14
 
14
15
  from rewrite import ExecutionContext, Preconditions, Recipe, TreeVisitor
@@ -103,12 +104,19 @@ class DataframeAppendToConcat(Recipe):
103
104
  ):
104
105
  return m
105
106
  try:
106
- return _concat_template.apply(
107
+ concat = _concat_template.apply(
107
108
  self.cursor, visitor=self, values={'df': select, 'x': args[0]}
108
- ) or m
109
+ )
109
110
  except ValueError:
110
111
  # The template refuses a file where `pd` is bound to something else.
111
112
  return m
113
+ if not isinstance(concat, MethodInvocation):
114
+ return concat or m
115
+ # Typed as the receiver, so that an `append` on this result or taking it is migrated too.
116
+ method_type = concat.method_type
117
+ if method_type is None:
118
+ return concat
119
+ return concat.replace(_method_type=dataclasses.replace(method_type, _return_type=select.type))
112
120
 
113
121
  # `append` does not resolve under pandas 2, so the gate names the receiver.
114
122
  return Preconditions.check(uses_type(_DATAFRAME), Visitor())
@@ -0,0 +1,31 @@
1
+ """Grouping an ``and`` or ``or`` chain the way the parser groups it."""
2
+
3
+ from rewrite.java.support_types import Space
4
+ from rewrite.java.tree import Binary
5
+ from rewrite.utils import random_id
6
+
7
+ from openrewrite_code_quality_python.codequality._trivia import concat
8
+
9
+ _CHAINED = (Binary.Type.And, Binary.Type.Or)
10
+
11
+
12
+ def grouped_left(binary: Binary) -> Binary:
13
+ """``binary`` with a chain of its own operator on its right, unparenthesized, regrouped to the left.
14
+
15
+ ``a and (b and c)`` without the parentheses prints as ``a and b and c``, which the parser
16
+ groups as ``(a and b) and c``. Both evaluate the same, but a recipe matching the tree sees
17
+ other operands in each.
18
+ """
19
+ right = binary.right
20
+ if binary.operator not in _CHAINED or not isinstance(right, Binary) or right.operator != binary.operator:
21
+ return binary
22
+ left = binary.replace(
23
+ _id=random_id(),
24
+ _prefix=Space.EMPTY,
25
+ _right=right.left.replace(_prefix=concat(right.prefix, right.left.prefix)),
26
+ )
27
+ return grouped_left(binary.replace(
28
+ _left=grouped_left(left),
29
+ _operator=right.padding.operator,
30
+ _right=right.right,
31
+ ))
@@ -28,6 +28,7 @@ from rewrite.java.tree import (
28
28
  )
29
29
  from rewrite.utils import random_id
30
30
 
31
+ from openrewrite_code_quality_python.codequality._grouping import grouped_left
31
32
  from openrewrite_code_quality_python.codequality._reindent import (
32
33
  line_indent,
33
34
  reindent,
@@ -190,8 +191,12 @@ class CollapsibleIfStatements(Recipe):
190
191
  ),
191
192
  _type=None,
192
193
  )
193
- combined = combined.replace(
194
+ # An `and` chain in the inner condition continues the merged one, nested to the left
195
+ # as the parser nests `a and b and c`, so only its last operand is left to parenthesize.
196
+ combined = grouped_left(combined.replace(
194
197
  _left=maybe_parenthesize(combined, combined.left.id, combined.left),
198
+ ))
199
+ combined = combined.replace(
195
200
  _right=maybe_parenthesize(combined, combined.right.id, combined.right),
196
201
  )
197
202
 
@@ -27,6 +27,7 @@ from rewrite.java.tree import (
27
27
  If,
28
28
  Statement,
29
29
  )
30
+ from openrewrite_code_quality_python.codequality._grouping import grouped_left
30
31
  from openrewrite_code_quality_python.codequality._semantically_equal import SemanticallyEqual
31
32
  from rewrite.utils import random_id
32
33
 
@@ -180,8 +181,12 @@ def _merge_conditions(first: If, second: If) -> If:
180
181
  _right=_or_operand(second_cond, Space.SINGLE_SPACE),
181
182
  _type=None,
182
183
  )
183
- combined = combined.replace(
184
+ # An `or` chain in the second condition continues the merged one, nested to the left as the
185
+ # parser nests `a or b or c`, so only its last operand is left to parenthesize.
186
+ combined = grouped_left(combined.replace(
184
187
  _left=maybe_parenthesize(combined, combined.left.id, combined.left),
188
+ ))
189
+ combined = combined.replace(
185
190
  _right=maybe_parenthesize(combined, combined.right.id, combined.right),
186
191
  )
187
192
 
@@ -19,16 +19,22 @@ def rename_methods(
19
19
  method gives it. The patterns match overrides, since a call through a subclass to a member
20
20
  typeshed no longer declares resolves to the subclass.
21
21
  """
22
- patterns = {old: f"{declaring_type} {old}(..)" for old in renames}
23
- calls = {old: MethodMatcher.create(pattern, match_overrides=True) for old, pattern in patterns.items()}
22
+ # Where Python still declares `old` as an alias, `old = new`, the call resolves to `new`.
23
+ patterns = {
24
+ old: [f"{declaring_type} {old}(..)", f"{declaring_type} {new}(..)"] for old, new in renames.items()
25
+ }
26
+ calls = {
27
+ old: [MethodMatcher.create(pattern, match_overrides=True) for pattern in alternatives]
28
+ for old, alternatives in patterns.items()
29
+ }
24
30
 
25
31
  class Visitor(PythonVisitor[ExecutionContext]):
26
32
  def visit_method_invocation(self, method: MethodInvocation, p: ExecutionContext) -> J:
27
33
  m = super().visit_method_invocation(method, p)
28
34
  if not isinstance(m, MethodInvocation):
29
35
  return m
30
- call = calls.get(m.name.simple_name)
31
- if call is None or not call.matches(m, cursor=self.cursor):
36
+ matchers = calls.get(m.name.simple_name, [])
37
+ if not any(matcher.matches(m, cursor=self.cursor) for matcher in matchers):
32
38
  return m
33
39
  # The old method type names the old method, which no longer describes the call.
34
40
  m = m.replace(
@@ -41,7 +47,9 @@ def rename_methods(
41
47
  ]
42
48
  return m.padding.replace(_arguments=arguments.padding.replace(elements=elements))
43
49
 
44
- gates = [uses_method(pattern, match_overrides=True) for pattern in patterns.values()]
50
+ gates = [
51
+ uses_method(pattern, match_overrides=True) for alternatives in patterns.values() for pattern in alternatives
52
+ ]
45
53
  return Preconditions.check(gates[0] if len(gates) == 1 else Preconditions.or_(*gates), Visitor())
46
54
 
47
55
 
@@ -55,7 +55,7 @@ from rewrite.java.tree import (
55
55
  Annotation, Assignment, Binary, ClassDeclaration, Empty, Expression, FieldAccess,
56
56
  Identifier, Import, J,
57
57
  JLeftPadded, JRightPadded, Literal, Lambda, MethodDeclaration, MethodInvocation, Modifier,
58
- Space, Statement,
58
+ Parentheses, Space, Statement,
59
59
  )
60
60
  from rewrite.rpc import RpcRecipe
61
61
 
@@ -485,18 +485,18 @@ class _Settings:
485
485
  dict in another module, so nothing can be read off the decorator alone.
486
486
  """
487
487
 
488
- # module name -> dict name -> keyword -> value expression.
489
- dicts: Dict[str, Dict[str, Dict[str, Expression]]] = field(default_factory=dict)
488
+ # module name -> dict name -> the dict literal's text, which a lookup parses again.
489
+ dicts: Dict[str, Dict[str, str]] = field(default_factory=dict)
490
490
  # Names assigned more than once, or mutated, whose contents nothing can rely on.
491
491
  unsafe: Set[Tuple[str, str]] = field(default_factory=set)
492
492
 
493
- def record(self, module: str, name: str, entries: Dict[str, Expression] | None) -> None:
493
+ def record(self, module: str, name: str, source: str | None) -> None:
494
494
  known = self.dicts.setdefault(module, {})
495
- if name in known or entries is None:
495
+ if name in known or source is None:
496
496
  # A second binding of the name, or one whose value is not a plain literal dict.
497
497
  self.unsafe.add((module, name))
498
498
  return
499
- known[name] = entries
499
+ known[name] = source
500
500
 
501
501
  def mutated(self, module: str, name: str) -> None:
502
502
  self.unsafe.add((module, name))
@@ -504,7 +504,9 @@ class _Settings:
504
504
  def lookup(self, module: str, name: str) -> Dict[str, Expression] | None:
505
505
  if (module, name) in self.unsafe:
506
506
  return None
507
- return self.dicts.get(module, {}).get(name)
507
+ source = self.dicts.get(module, {}).get(name)
508
+ parsed = _parsed_expression(source) if source is not None else None
509
+ return _dict_entries(parsed) if parsed is not None else None
508
510
 
509
511
 
510
512
  def _module_name(path: PurePosixPath) -> str:
@@ -543,15 +545,13 @@ class _Compat:
543
545
  package_dirs: Set[str] = field(default_factory=set)
544
546
  # Files already holding a compat module, so a second run does not write it again.
545
547
  existing: Set[str] = field(default_factory=set)
546
- # The helpers each file's converted decorators call, worked out in `generate`: a
547
- # decorator splatting a dict from another module cannot be read until every file is in.
548
+ # The helpers each file's converted decorators call, read off each decorator in the scan
549
+ # except one splatting a dict, which `generate` reads once every module's dicts are in.
548
550
  needed: Dict[str, Set[str]] = field(default_factory=dict)
549
- # The decorators each file holds, with what its imports bound when they were seen.
550
- pending: Dict[str, List[Tuple[Annotation, "_Bindings", bool]]] = field(
551
+ # Those splatting decorators, each as the member invoked, the text of its call, what the
552
+ # file's imports bound and whether it decorates an `async def`.
553
+ splatted: Dict[str, List[Tuple[str | None, str, "_Bindings", bool]]] = field(
551
554
  default_factory=dict)
552
- # The same for the ones written as a call rather than with an `@`, with the member invoked.
553
- pending_calls: Dict[str, List[Tuple[str, MethodInvocation, "_Bindings", bool]]] \
554
- = field(default_factory=dict)
555
555
  settings: _Settings = field(default_factory=_Settings)
556
556
  # Filled in by `generate`, read by the editor: the module a file imports its helpers from.
557
557
  module_of: Dict[str, str] = field(default_factory=dict)
@@ -584,6 +584,32 @@ def _parsed_module(source: str, path: PurePosixPath) -> SourceFile:
584
584
  return cu.replace(_source_path=Path(str(path)))
585
585
 
586
586
 
587
+ def _source_of(tree: J, cursor: Cursor) -> str:
588
+ """``tree``'s text without its prefix or the markup of its markers, which an accumulator keeps
589
+ in place of the tree."""
590
+ return tree.replace(prefix=Space.EMPTY).print(cursor, PrintOutputCapture(
591
+ 0, PrintOutputCapture.MarkerPrinter.SANITIZED)) # ty: ignore[too-many-positional-arguments]
592
+
593
+
594
+ def _call_source(call: Annotation | MethodInvocation, cursor: Cursor) -> str:
595
+ """The text of the call a decorator makes, ``@`` or not, which parses as a call either way."""
596
+ source = _source_of(call, cursor)
597
+ return source.removeprefix("@").lstrip() if isinstance(call, Annotation) else source
598
+
599
+
600
+ def _parsed_expression(source: str) -> Expression | None:
601
+ """The expression ``source`` spells, parsed on its own in parentheses, which let it span lines
602
+ as the brackets around it in its file did."""
603
+ from rewrite.python._parser_visitor import ParserVisitor
604
+
605
+ text = f"({source})"
606
+ statements = ParserVisitor(text, None, None).visit_Module(ast.parse(text)).statements
607
+ statement = statements[0] if len(statements) == 1 else None
608
+ expression = statement.expression if isinstance(statement, ExpressionStatement) else statement
609
+ inner = expression.tree if isinstance(expression, Parentheses) else None
610
+ return inner if isinstance(inner, Expression) else None
611
+
612
+
587
613
  _COMPAT_DOCSTRING = """The parts of backoff that tenacity has no equivalent for.
588
614
 
589
615
  Written by org.openrewrite.python.migrate.MigrateBackoffToTenacity.
@@ -819,6 +845,11 @@ def _split_arguments(
819
845
  return positional, named
820
846
 
821
847
 
848
+ def _splats(call: Annotation | MethodInvocation) -> bool:
849
+ """Whether ``call`` has a ``**`` argument, which only the run's settings dicts can read."""
850
+ return any(isinstance(a, Star) and a.kind == Star.Kind.DICT for a in call.arguments or [])
851
+
852
+
822
853
  def _splat_entries(
823
854
  expression: Expression, bindings: "_Bindings", settings: _Settings,
824
855
  ) -> Dict[str, Expression] | None:
@@ -1300,6 +1331,7 @@ class _VendorMembers(PythonVisitor[None]):
1300
1331
 
1301
1332
  def _collect_settings(cu: CompilationUnit, settings: _Settings, module: str) -> None:
1302
1333
  """Record this module's top-level dict literals, and what it rules out relying on."""
1334
+ cursor = Cursor(Cursor(None, Cursor.ROOT_VALUE), cu)
1303
1335
  for statement in cu.statements:
1304
1336
  expression = statement.expression if isinstance(statement, ExpressionStatement) \
1305
1337
  else statement
@@ -1312,7 +1344,9 @@ def _collect_settings(cu: CompilationUnit, settings: _Settings, module: str) ->
1312
1344
  # Only a module-level constant is worth reading: anything else is far likelier
1313
1345
  # to be built up or rebound somewhere this never looks.
1314
1346
  continue
1315
- settings.record(module, name, _dict_entries(expression.assignment))
1347
+ value = expression.assignment
1348
+ settings.record(module, name,
1349
+ _source_of(value, cursor) if _dict_entries(value) is not None else None)
1316
1350
 
1317
1351
  # A mutation anywhere in the file, or a rebinding below the top level, means the dict
1318
1352
  # a decorator splats is not the dict seen here.
@@ -1670,11 +1704,12 @@ class ReplaceBackoffDecoratorsWithTenacity(ScanningRecipe[_Compat]):
1670
1704
  visited = super().visit_annotation(annotation, p)
1671
1705
  if not isinstance(visited, Annotation):
1672
1706
  return visited
1673
- # Only recorded here: a decorator cannot be read until every module's
1674
- # settings dicts are in, which is not until the whole scan is done.
1675
- if self._bindings.binds_anything() and self.is_backoff(visited):
1676
- acc.pending.setdefault(self._path, []).append(
1677
- (visited, self._bindings, _decorates_async(self.cursor)))
1707
+ outcome = self.outcome(visited)
1708
+ if outcome is not None and _splats(visited):
1709
+ self._defer(self._bindings.resolve(visited.annotation_type), visited,
1710
+ _decorates_async(self.cursor))
1711
+ else:
1712
+ ReplaceBackoffDecoratorsWithTenacity._record_helpers(acc, self._path, outcome)
1678
1713
  return visited
1679
1714
 
1680
1715
  # A member used as a value or a type is rewritten without any analysis, but the
@@ -1692,28 +1727,34 @@ class ReplaceBackoffDecoratorsWithTenacity(ScanningRecipe[_Compat]):
1692
1727
  self, method: MethodInvocation, p: ExecutionContext,
1693
1728
  ) -> J:
1694
1729
  self._record(self.vendored_call(method))
1695
- decorator = _invoked_member(method, self._bindings)
1696
- if decorator in _DECORATORS and not self._shadowed_call(method) \
1697
- and self.cursor.first_enclosing(Annotation) is None:
1698
- acc.pending_calls.setdefault(self._path, []).append(
1699
- (decorator, method, self._bindings, self._wraps_async()))
1730
+ outcome = self.call_outcome(method)
1731
+ if outcome is not None and _splats(method):
1732
+ self._defer(_invoked_member(method, self._bindings), method, self._wraps_async())
1733
+ else:
1734
+ ReplaceBackoffDecoratorsWithTenacity._record_helpers(acc, self._path, outcome)
1700
1735
  return super().visit_method_invocation(method, p)
1701
1736
 
1702
1737
  def _record(self, found: Tuple[str, str, bool] | None) -> None:
1703
1738
  if found is not None:
1704
1739
  acc.needed.setdefault(self._path, set()).add(found[0])
1705
1740
 
1741
+ def _defer(
1742
+ self, decorator: str | None, call: Annotation | MethodInvocation, is_async: bool,
1743
+ ) -> None:
1744
+ # The dict a `**` names may be in a module the scan has not reached yet, so the
1745
+ # decorator is read in `generate`, from its text.
1746
+ acc.splatted.setdefault(self._path, []).append(
1747
+ (decorator, _call_source(call, self.cursor), self._bindings, is_async))
1748
+
1706
1749
  return Scanner()
1707
1750
 
1708
1751
  def generate(self, acc: _Compat, ctx: ExecutionContext) -> List[SourceFile]:
1709
- for path, decorators in acc.pending.items():
1710
- for annotation, bindings, is_async in decorators:
1711
- self._record_helpers(
1712
- acc, path, _analyze(annotation, bindings, is_async, acc.settings))
1713
- for path, calls in acc.pending_calls.items():
1714
- for decorator, call, bindings, is_async in calls:
1715
- self._record_helpers(acc, path, _analyze_call(
1716
- decorator, call, bindings, is_async, acc.settings))
1752
+ for path, calls in acc.splatted.items():
1753
+ for decorator, source, bindings, is_async in calls:
1754
+ call = _parsed_expression(source)
1755
+ if isinstance(call, MethodInvocation):
1756
+ self._record_helpers(acc, path, _analyze_call(
1757
+ decorator, call, bindings, is_async, acc.settings))
1717
1758
 
1718
1759
  helpers: Dict[PurePosixPath, Set[str]] = {}
1719
1760
  for path, needed in sorted(acc.needed.items()):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openrewrite-migrate-python
3
- Version: 0.18.0
3
+ Version: 0.20.0
4
4
  Summary: OpenRewrite recipes for migrating Python codebases to newer Python versions.
5
5
  Author-email: "Moderne Inc." <support@moderne.io>
6
6
  License: Moderne Proprietary
@@ -19,7 +19,7 @@ Classifier: Typing :: Typed
19
19
  Requires-Python: >=3.12
20
20
  Description-Content-Type: text/markdown
21
21
  Provides-Extra: dev
22
- Requires-Dist: openrewrite>=8.94.0; extra == "dev"
22
+ Requires-Dist: openrewrite>=8.96.3; extra == "dev"
23
23
  Requires-Dist: pydantic>=2.11; extra == "dev"
24
24
  Requires-Dist: pytest>=8.0.0; extra == "dev"
25
25
  Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
@@ -4,7 +4,7 @@ openrewrite_code_quality_python/cleanup/_literals.py,sha256=bv-dN0v-FOLBIHEqxNHw
4
4
  openrewrite_code_quality_python/cleanup/_negation.py,sha256=dMIzjStSrDKK80txkClEklWBMr5a1-boT5WjWRwtQp4,1704
5
5
  openrewrite_code_quality_python/cleanup/assign_return.py,sha256=y10YG2MkcZoE0Ded_0UPBLt-mg7omkRS4D3AP67v06I,5352
6
6
  openrewrite_code_quality_python/cleanup/attr_access.py,sha256=V3gRlZkXac34q4t2aAKIRc8xRzl3t8GAqPpY69883cg,8491
7
- openrewrite_code_quality_python/cleanup/boolean_simplification.py,sha256=esv83CBNIc25jkKM2bZisjmpMdsb69z4t7d0pzvaVUY,33924
7
+ openrewrite_code_quality_python/cleanup/boolean_simplification.py,sha256=8CpM8CPUDvy2BfilgHL7BkeJ7bbhvoDR6m6EpeWNoF4,39167
8
8
  openrewrite_code_quality_python/cleanup/collection_literals.py,sha256=uFFIOZ_PFqRXkyesrXf368xVGKuGb7hK94Zf24rH3wo,15866
9
9
  openrewrite_code_quality_python/cleanup/comparison_simplification.py,sha256=lhXIy_UMWeDRGyBZJnLFzN0em_ij6cRuWFgm6dS050A,13114
10
10
  openrewrite_code_quality_python/cleanup/complex_transforms.py,sha256=oEKdO9bzvvJipFzfVDANtGqnYoFSs7J_vuZd5TZjUHo,10690
@@ -22,7 +22,7 @@ openrewrite_code_quality_python/cleanup/naming_conventions.py,sha256=qqZuF5b8lVr
22
22
  openrewrite_code_quality_python/cleanup/negated_operators.py,sha256=DOXtB249IlPJovRNpCJPYHcv79u8bnNlwesqWRfJePg,5097
23
23
  openrewrite_code_quality_python/cleanup/none_and_identity.py,sha256=QVdFo9MdsF1RHGWLmR4179qAeYCEn99a-OJ8rEn9PBM,12092
24
24
  openrewrite_code_quality_python/cleanup/object_inheritance.py,sha256=7hy16CBEUNTRoXYuWqe0_ZZS5Ig4Yft6VTErkWpIfkc,3099
25
- openrewrite_code_quality_python/cleanup/pandas_cleanup.py,sha256=-A5IdSOO4AC1GTtt1nPXfRspatHkkW0TZV2gFU_2FY4,8080
25
+ openrewrite_code_quality_python/cleanup/pandas_cleanup.py,sha256=2eq7R5Cux9RTFCChaclSr0KIfAdn_SmW1wwB0Bmqj7g,8536
26
26
  openrewrite_code_quality_python/cleanup/python_best_practices.py,sha256=AkJa4Wr02cqKhlAsfr7yDKOUpSHkw8ljuj6uxLrF9-A,9309
27
27
  openrewrite_code_quality_python/cleanup/range_cleanup.py,sha256=9KFNSiiSc2pl_S6KdpUW_Hhtw9zweOAT393Zth72ids,5287
28
28
  openrewrite_code_quality_python/cleanup/redundant_code.py,sha256=aMPAAOO5uhBZy1j5o2LvX3lKD6Q3KuDKx1G0b_Mv5uk,6812
@@ -33,6 +33,7 @@ openrewrite_code_quality_python/cleanup/use_items.py,sha256=_ifgqhSga2uXhOBYcGgi
33
33
  openrewrite_code_quality_python/cleanup/use_list_clear.py,sha256=XXfAbF6suxfvby4QbqWtiCtw6yMIH__6mSleNomoiXQ,4294
34
34
  openrewrite_code_quality_python/cleanup/yoda_conditions.py,sha256=hk3nNoUPSJK4NoX18F5wggvmHbrVjHUNUL9WMqqJR8Y,4551
35
35
  openrewrite_code_quality_python/codequality/__init__.py,sha256=ctQ0xAuLLSuLZoVajxaW2zn0RPm3VD-iCrLGx0XFZZY,1124
36
+ openrewrite_code_quality_python/codequality/_grouping.py,sha256=9WPdgX_9RELDoz4wRw0uO-yB6cO2snb6RTlBermYWUQ,1165
36
37
  openrewrite_code_quality_python/codequality/_reindent.py,sha256=ZeD2DOqQtYAKc0v21JtBg_jqxKpg39ur9kcVV8gWtt4,3514
37
38
  openrewrite_code_quality_python/codequality/_semantically_equal.py,sha256=tdkYMvtxoOQuoqY1Jx1we5NiCCPFV_5oKtEDpSJejkI,1649
38
39
  openrewrite_code_quality_python/codequality/_side_effects.py,sha256=p-xvunfN8bUGXpTmt1DHOA1P2moPSkR1aVgqg52hUEc,1227
@@ -41,9 +42,9 @@ openrewrite_code_quality_python/codequality/_truth_value.py,sha256=T52Aznr8ZUnL7
41
42
  openrewrite_code_quality_python/codequality/all_branches_identical.py,sha256=Qx4oBBE_MJvCpZVIlmMmP_9USL8SLUznnFheqEoZdbY,7943
42
43
  openrewrite_code_quality_python/codequality/boolean_checks_not_inverted.py,sha256=cGqCcP1e5FChahREpJ6YBHhwEVwlQDWWE9ukOzaKTi4,6582
43
44
  openrewrite_code_quality_python/codequality/code_quality.py,sha256=qO_et2buzzQi2yVZtA2ncIaZ129PuaruPOWyB3GgwMQ,2284
44
- openrewrite_code_quality_python/codequality/collapsible_if_statements.py,sha256=1uTVpE-gX6KUNSKyAbWfzouWfq0FXi0FNPP1WjNdwqE,9197
45
+ openrewrite_code_quality_python/codequality/collapsible_if_statements.py,sha256=Dc2U5jxxwb38Er7GbVMKd8iJ__eT1K3Agu2-F7q4wvM,9559
45
46
  openrewrite_code_quality_python/codequality/find_suspicious_chained_comparison.py,sha256=oe3YEtM6FgiHl9nX2jq0rmZzQ_M7BcOaD13PHlSCGM8,3720
46
- openrewrite_code_quality_python/codequality/merge_identical_branches.py,sha256=VLFzk7BH_YSvUy5JTbwNRiN0su4ViysUwHEUKqWiQng,6847
47
+ openrewrite_code_quality_python/codequality/merge_identical_branches.py,sha256=fIVc1EHEo6tCIfF4YyU_CdBOaE-QMyhj_OfUd0vHM1I,7159
47
48
  openrewrite_code_quality_python/codequality/remove_duplicate_conditions.py,sha256=DgoD3w3zqEIrcHukYEsXOlp5ksG5EhVxa1zW0LeczdM,6588
48
49
  openrewrite_code_quality_python/codequality/remove_self_assignment.py,sha256=8jyrmh5P9kXwaR9f8Nb0ZapwrksWSXXlhMpMQY_kabE,7519
49
50
  openrewrite_code_quality_python/codequality/remove_unconditional_value_overwrite.py,sha256=t9AUeuJr--iQnfKOv65ewxTFXPQxPsjA7YOcV7cAGRo,11982
@@ -55,7 +56,7 @@ openrewrite_migrate_python/migrate/_decorators.py,sha256=03DZoAa3LQ9fXZx3FFm_RSm
55
56
  openrewrite_migrate_python/migrate/_finders.py,sha256=RVmkMUOWLxxCWx4Ig8SUEbqPTVGqrAP2VVhiazBsCyM,4960
56
57
  openrewrite_migrate_python/migrate/_langchain_tables.py,sha256=TF2A2iwkULa5uybo1rBxp_o2weziCxAmn5NLUcSAXqs,55153
57
58
  openrewrite_migrate_python/migrate/_markers.py,sha256=EggMUANYXPh0Q6GbKlhWpthC0ac5VtgEbyHVLGyeH4Y,1126
58
- openrewrite_migrate_python/migrate/_method_renames.py,sha256=mmsaSugVo2gi9dA4-IlTbKZo6PZQ7251ngVFsKHAlfw,2587
59
+ openrewrite_migrate_python/migrate/_method_renames.py,sha256=-6xpdU5u8xkvAO65TjVnPXblqYi8jA-Pl2_Mm6f6480,2869
59
60
  openrewrite_migrate_python/migrate/_module_imports.py,sha256=kSuccPhiCcrVYhGelQOWJ2UZkN2YHNDiMP-QDK5En98,2844
60
61
  openrewrite_migrate_python/migrate/_slots.py,sha256=WYiAMYa7_tZE3vI_r55kMBojHXdxE58CzvhyAOJSZ48,634
61
62
  openrewrite_migrate_python/migrate/aifc_migrations.py,sha256=FxEUdhNFGm-7oNTrvk76eDy6wTjA9uRSjdOrwQ5oT-I,8900
@@ -64,7 +65,7 @@ openrewrite_migrate_python/migrate/ast_deprecations.py,sha256=fudnnAwQfBU5YCOe37
64
65
  openrewrite_migrate_python/migrate/asyncio_coroutine_to_async.py,sha256=l4HOVWAaVY_7Deunjr8cGLWQ07CLyZqclFjiPDJSz_I,9471
65
66
  openrewrite_migrate_python/migrate/asyncio_deprecations.py,sha256=6lliBj-lvRthCs1CuN4A-Z5iQlkQQ9C6vKxi59ErCwg,11327
66
67
  openrewrite_migrate_python/migrate/avro_migrations.py,sha256=6wb2_zFYTXEiK_DZ_Jl3awsMMG6GpZ6PJWZuer0DqG4,2331
67
- openrewrite_migrate_python/migrate/backoff_to_tenacity.py,sha256=5QA8gSWhY05pwLrkxQhjrCT19FlxpA1BN4h1up-y41s,91995
68
+ openrewrite_migrate_python/migrate/backoff_to_tenacity.py,sha256=Ob6s32hwxL5XU4_yidyX0PlOp_z-mJWUAdBwIeos4uA,94033
68
69
  openrewrite_migrate_python/migrate/builtins_deprecations.py,sha256=cWFmqa2GPUTZgk6O_sDDDMVfT1PfBCU615FkTQmtAQw,5625
69
70
  openrewrite_migrate_python/migrate/calendar_deprecations.py,sha256=EPDRqN7Uf6U41CjfZu6BiJDZ4ehaNlBgzZD4YxGFzWs,2417
70
71
  openrewrite_migrate_python/migrate/cgi_escape_deprecations.py,sha256=Alevj5BrJ59wOd2dQRlorKypLZSS4b1FG9YV8FteCR4,8595
@@ -144,8 +145,8 @@ openrewrite_migrate_python/migrate/wave_deprecations.py,sha256=QcjcFNpb2mQ4v8VdY
144
145
  openrewrite_migrate_python/migrate/webbrowser_deprecations.py,sha256=2ENvVuz5fcKAxnoMQVWIC62ipFeRy2_Ah_WEUushASM,2205
145
146
  openrewrite_migrate_python/migrate/xdrlib_migrations.py,sha256=khHPyP1TctExRMl1w_sR9d2fh5_QD04WQDNCqN3wUh4,2140
146
147
  openrewrite_migrate_python/migrate/xml_deprecations.py,sha256=mvyEUhVGPQTsjE81OkxBqKQCmis5xqTNg6LcUX1ipp4,7613
147
- openrewrite_migrate_python-0.18.0.dist-info/METADATA,sha256=BAh0BAwNRR5vRf6LA3Mu14HttZlvfI6IUH_6x0Hzd9Q,1450
148
- openrewrite_migrate_python-0.18.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
149
- openrewrite_migrate_python-0.18.0.dist-info/entry_points.txt,sha256=SuygzlMJiKIW19FYJVxx0hhRyzW6HTDCweY7584XpXw,138
150
- openrewrite_migrate_python-0.18.0.dist-info/top_level.txt,sha256=y8dKeC9kb5GUzFraB-OhJGxjPemM3GaPdrAygz2RP0U,59
151
- openrewrite_migrate_python-0.18.0.dist-info/RECORD,,
148
+ openrewrite_migrate_python-0.20.0.dist-info/METADATA,sha256=DAE-EZVWsrzQUdG46PKrWloODLgLzRMjsHoVt7L-Nzk,1450
149
+ openrewrite_migrate_python-0.20.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
150
+ openrewrite_migrate_python-0.20.0.dist-info/entry_points.txt,sha256=SuygzlMJiKIW19FYJVxx0hhRyzW6HTDCweY7584XpXw,138
151
+ openrewrite_migrate_python-0.20.0.dist-info/top_level.txt,sha256=y8dKeC9kb5GUzFraB-OhJGxjPemM3GaPdrAygz2RP0U,59
152
+ openrewrite_migrate_python-0.20.0.dist-info/RECORD,,