Skip to content

Commit 7483cff

Browse files
authored
Add template for a flattening conversion script (#258)
The generated method flattening semantics change between the the original monolithic GAPIC generator and this GAPIC generator (aka the Python Microgenerator). User code that calls methods from GAPICs generated by the monolith will need to be converted to use the new flattening semantics. This change describes a template for an automated per-service conversion script. Cases the conversion script will NOT handle correctly include * Dynamic method dispatch * Method calls via an alias or as a free function * Star arg exapnsion in a method call (*args or **kwargs) Plain, normal, vanilla calls are the target for conversion.
1 parent 21d3c8f commit 7483cff

2 files changed

Lines changed: 135 additions & 0 deletions

File tree

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
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 %}

packages/gapic-generator/gapic/templates/setup.py.j2

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,16 @@ setuptools.setup(
2121
'grpcio >= 1.10.0',
2222
'proto-plus >= 0.4.0',
2323
),
24+
setup_requires=[
25+
'libcst >= 0.2.5',
26+
],
27+
scripts=[
28+
{% for proto in api.all_protos.values() -%}
29+
{% for service in proto.services.values() -%}
30+
'scripts/fixup_{{ service.module_name }}_keywords.py',
31+
{% endfor -%}
32+
{% endfor -%}
33+
],
2434
classifiers=[
2535
'Development Status :: 3 - Alpha',
2636
'Intended Audience :: Developers',

0 commit comments

Comments
 (0)