|
| 1 | +{% extends '_base.py.j2' %} |
| 2 | +{% block content %} |
| 3 | +import argparse |
| 4 | +import os |
| 5 | +import libcst as cst |
| 6 | +from typing import (Any, Callable, Dict, List, Sequence, Tuple) |
| 7 | + |
| 8 | + |
| 9 | +def partition( |
| 10 | + predicate: Callable[[Any], bool], |
| 11 | + iterator: Sequence[Any] |
| 12 | +) -> Tuple[List[Any], List[Any]]: |
| 13 | + """A stable, out-of-place partition.""" |
| 14 | + results = ([], []) |
| 15 | + |
| 16 | + for i in iterator: |
| 17 | + results[int(predicate(i))].append(i) |
| 18 | + |
| 19 | + # Returns trueList, falseList |
| 20 | + return results[1], results[0] |
| 21 | + |
| 22 | + |
| 23 | +class {{ service.client_name }}CallTransformer(cst.CSTTransformer): |
| 24 | + CTRL_PARAMS: Tuple[str] = ('retry', 'timeout', 'metadata') |
| 25 | + |
| 26 | + METHOD_TO_PARAMS: Dict[str, Tuple[str]] = { |
| 27 | + {% for method in service.methods.values() -%} |
| 28 | + '{{ method.name|snake_case }}': ({% for field in method.legacy_flattened_fields.values() %}'{{ field.name }}', {% endfor %}), |
| 29 | + {% endfor -%} |
| 30 | + } |
| 31 | + |
| 32 | + def leave_Call(self, original: cst.Call, updated: cst.Call) -> cst.CSTNode: |
| 33 | + try: |
| 34 | + key = original.func.attr.value |
| 35 | + kword_params = self.METHOD_TO_PARAMS[key] |
| 36 | + except (AttributeError, KeyError): |
| 37 | + # Either not a method from the API or too convoluted to be sure. |
| 38 | + return updated |
| 39 | + |
| 40 | + |
| 41 | + # If the existing code is valid, keyword args come after positional args. |
| 42 | + # Therefore, all positional args must map to the first parameters. |
| 43 | + args, kwargs = partition(lambda a: not bool(a.keyword), updated.args) |
| 44 | + if any(k.keyword.value == "request" for k in kwargs): |
| 45 | + # We've already fixed this file, don't fix it again. |
| 46 | + return updated |
| 47 | + |
| 48 | + kwargs, ctrl_kwargs = partition( |
| 49 | + lambda a: not a.keyword.value in self.CTRL_PARAMS, |
| 50 | + kwargs |
| 51 | + ) |
| 52 | + |
| 53 | + args, ctrl_args = args[:len(kword_params)], args[len(kword_params):] |
| 54 | + ctrl_kwargs.extend(cst.Arg(value=a.value, keyword=cst.Name(value=ctrl)) |
| 55 | + for a, ctrl in zip(ctrl_args, self.CTRL_PARAMS)) |
| 56 | + |
| 57 | + request_arg = cst.Arg( |
| 58 | + value=cst.Dict([ |
| 59 | + cst.DictElement( |
| 60 | + cst.SimpleString("'{}'".format(name)), |
| 61 | + {# Inline comments and formatting are currently stripped out. #} |
| 62 | + {# My current attempts at preverving comments and formatting #} |
| 63 | + {# keep the comments, but the formatting is run through a log #} |
| 64 | + {# chipper, and an extra comma gets added, which causes a #} |
| 65 | + {# parse error. #} |
| 66 | + cst.Element(value=arg.value) |
| 67 | + ) |
| 68 | + # Note: the args + kwargs looks silly, but keep in mind that |
| 69 | + # the control parameters had to be stripped out, and that |
| 70 | + # those could have been passed positionally or by keyword. |
| 71 | + for name, arg in zip(kword_params, args + kwargs)]), |
| 72 | + keyword=cst.Name("request") |
| 73 | + ) |
| 74 | + |
| 75 | + return updated.with_changes( |
| 76 | + args=[request_arg] + ctrl_kwargs |
| 77 | + ) |
| 78 | + |
| 79 | + |
| 80 | +def fix_files( |
| 81 | + dirs: Sequence[str], |
| 82 | + *, |
| 83 | + transformer={{ service.client_name }}CallTransformer(), |
| 84 | +): |
| 85 | + pyfile_gen = (os.path.join(root, f) |
| 86 | + for d in dirs |
| 87 | + for root, _, files in os.walk(d) |
| 88 | + for f in files if os.path.splitext(f)[1] == ".py") |
| 89 | + |
| 90 | + for fpath in pyfile_gen: |
| 91 | + with open(fpath, 'r+') as f: |
| 92 | + src = f.read() |
| 93 | + tree = cst.parse_module(src) |
| 94 | + updated = tree.visit(transformer) |
| 95 | + f.seek(0) |
| 96 | + f.truncate() |
| 97 | + f.write(updated.code) |
| 98 | + |
| 99 | + |
| 100 | +if __name__ == '__main__': |
| 101 | + parser = argparse.ArgumentParser( |
| 102 | + description="""Fix up source that uses the {{ service.name }} client library. |
| 103 | + |
| 104 | +Note: This tool operates at a best-effort level at converting positional |
| 105 | + parameters in client method calls to keyword based parameters. |
| 106 | + Cases where it WILL FAIL include |
| 107 | + A) * or ** expansion in a method call. |
| 108 | + B) Calls via function or method alias (includes free function calls) |
| 109 | + C) Indirect or dispatched calls (e.g. the method is looked up dynamically) |
| 110 | + |
| 111 | + These all constitute false negatives. The tool will also detect false |
| 112 | + positives when an API method shares a name with another method. |
| 113 | + |
| 114 | + Be sure to back up your source files before running this tool and to compare the diffs. |
| 115 | +""") |
| 116 | + parser.add_argument( |
| 117 | + '-d', |
| 118 | + metavar='dir', |
| 119 | + dest='dirs', |
| 120 | + action='append', |
| 121 | + help='a directory to walk for python files to fix up' |
| 122 | + ) |
| 123 | + args = parser.parse_args() |
| 124 | + fix_files(args.dirs or ['.']) |
| 125 | +{% endblock %} |
0 commit comments