|
| 1 | +#! /usr/bin/env python3 |
| 2 | +# -*- coding: utf-8 -*- |
| 3 | +# Copyright 2025 Google LLC |
| 4 | +# |
| 5 | +# Licensed under the Apache License, Version 2.0 (the "License"); |
| 6 | +# you may not use this file except in compliance with the License. |
| 7 | +# You may obtain a copy of the License at |
| 8 | +# |
| 9 | +# http://www.apache.org/licenses/LICENSE-2.0 |
| 10 | +# |
| 11 | +# Unless required by applicable law or agreed to in writing, software |
| 12 | +# distributed under the License is distributed on an "AS IS" BASIS, |
| 13 | +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 14 | +# See the License for the specific language governing permissions and |
| 15 | +# limitations under the License. |
| 16 | +# |
| 17 | +import argparse |
| 18 | +import os |
| 19 | +import libcst as cst |
| 20 | +import pathlib |
| 21 | +import sys |
| 22 | +from typing import (Any, Callable, Dict, List, Sequence, Tuple) |
| 23 | + |
| 24 | + |
| 25 | +def partition( |
| 26 | + predicate: Callable[[Any], bool], |
| 27 | + iterator: Sequence[Any] |
| 28 | +) -> Tuple[List[Any], List[Any]]: |
| 29 | + """A stable, out-of-place partition.""" |
| 30 | + results = ([], []) |
| 31 | + |
| 32 | + for i in iterator: |
| 33 | + results[int(predicate(i))].append(i) |
| 34 | + |
| 35 | + # Returns trueList, falseList |
| 36 | + return results[1], results[0] |
| 37 | + |
| 38 | + |
| 39 | +class bigtable_adminCallTransformer(cst.CSTTransformer): |
| 40 | + CTRL_PARAMS: Tuple[str] = ('retry', 'timeout', 'metadata') |
| 41 | + METHOD_TO_PARAMS: Dict[str, Tuple[str]] = { |
| 42 | + 'check_consistency': ('name', 'consistency_token', 'standard_read_remote_writes', 'data_boost_read_local_writes', ), |
| 43 | + 'copy_backup': ('parent', 'backup_id', 'source_backup', 'expire_time', ), |
| 44 | + 'create_app_profile': ('parent', 'app_profile_id', 'app_profile', 'ignore_warnings', ), |
| 45 | + 'create_authorized_view': ('parent', 'authorized_view_id', 'authorized_view', ), |
| 46 | + 'create_backup': ('parent', 'backup_id', 'backup', ), |
| 47 | + 'create_cluster': ('parent', 'cluster_id', 'cluster', ), |
| 48 | + 'create_instance': ('parent', 'instance_id', 'instance', 'clusters', ), |
| 49 | + 'create_logical_view': ('parent', 'logical_view_id', 'logical_view', ), |
| 50 | + 'create_materialized_view': ('parent', 'materialized_view_id', 'materialized_view', ), |
| 51 | + 'create_schema_bundle': ('parent', 'schema_bundle_id', 'schema_bundle', ), |
| 52 | + 'create_table': ('parent', 'table_id', 'table', 'initial_splits', ), |
| 53 | + 'create_table_from_snapshot': ('parent', 'table_id', 'source_snapshot', ), |
| 54 | + 'delete_app_profile': ('name', 'ignore_warnings', ), |
| 55 | + 'delete_authorized_view': ('name', 'etag', ), |
| 56 | + 'delete_backup': ('name', ), |
| 57 | + 'delete_cluster': ('name', ), |
| 58 | + 'delete_instance': ('name', ), |
| 59 | + 'delete_logical_view': ('name', 'etag', ), |
| 60 | + 'delete_materialized_view': ('name', 'etag', ), |
| 61 | + 'delete_schema_bundle': ('name', 'etag', ), |
| 62 | + 'delete_snapshot': ('name', ), |
| 63 | + 'delete_table': ('name', ), |
| 64 | + 'drop_row_range': ('name', 'row_key_prefix', 'delete_all_data_from_table', ), |
| 65 | + 'generate_consistency_token': ('name', ), |
| 66 | + 'get_app_profile': ('name', ), |
| 67 | + 'get_authorized_view': ('name', 'view', ), |
| 68 | + 'get_backup': ('name', ), |
| 69 | + 'get_cluster': ('name', ), |
| 70 | + 'get_iam_policy': ('resource', 'options', ), |
| 71 | + 'get_instance': ('name', ), |
| 72 | + 'get_logical_view': ('name', ), |
| 73 | + 'get_materialized_view': ('name', ), |
| 74 | + 'get_schema_bundle': ('name', ), |
| 75 | + 'get_snapshot': ('name', ), |
| 76 | + 'get_table': ('name', 'view', ), |
| 77 | + 'list_app_profiles': ('parent', 'page_size', 'page_token', ), |
| 78 | + 'list_authorized_views': ('parent', 'page_size', 'page_token', 'view', ), |
| 79 | + 'list_backups': ('parent', 'filter', 'order_by', 'page_size', 'page_token', ), |
| 80 | + 'list_clusters': ('parent', 'page_token', ), |
| 81 | + 'list_hot_tablets': ('parent', 'start_time', 'end_time', 'page_size', 'page_token', ), |
| 82 | + 'list_instances': ('parent', 'page_token', ), |
| 83 | + 'list_logical_views': ('parent', 'page_size', 'page_token', ), |
| 84 | + 'list_materialized_views': ('parent', 'page_size', 'page_token', ), |
| 85 | + 'list_schema_bundles': ('parent', 'page_size', 'page_token', ), |
| 86 | + 'list_snapshots': ('parent', 'page_size', 'page_token', ), |
| 87 | + 'list_tables': ('parent', 'view', 'page_size', 'page_token', ), |
| 88 | + 'modify_column_families': ('name', 'modifications', 'ignore_warnings', ), |
| 89 | + 'partial_update_cluster': ('cluster', 'update_mask', ), |
| 90 | + 'partial_update_instance': ('instance', 'update_mask', ), |
| 91 | + 'restore_table': ('parent', 'table_id', 'backup', ), |
| 92 | + 'set_iam_policy': ('resource', 'policy', 'update_mask', ), |
| 93 | + 'snapshot_table': ('name', 'cluster', 'snapshot_id', 'ttl', 'description', ), |
| 94 | + 'test_iam_permissions': ('resource', 'permissions', ), |
| 95 | + 'undelete_table': ('name', ), |
| 96 | + 'update_app_profile': ('app_profile', 'update_mask', 'ignore_warnings', ), |
| 97 | + 'update_authorized_view': ('authorized_view', 'update_mask', 'ignore_warnings', ), |
| 98 | + 'update_backup': ('backup', 'update_mask', ), |
| 99 | + 'update_cluster': ('name', 'location', 'state', 'serve_nodes', 'node_scaling_factor', 'cluster_config', 'default_storage_type', 'encryption_config', ), |
| 100 | + 'update_instance': ('display_name', 'name', 'state', 'type_', 'labels', 'create_time', 'satisfies_pzs', 'satisfies_pzi', 'tags', ), |
| 101 | + 'update_logical_view': ('logical_view', 'update_mask', ), |
| 102 | + 'update_materialized_view': ('materialized_view', 'update_mask', ), |
| 103 | + 'update_schema_bundle': ('schema_bundle', 'update_mask', 'ignore_warnings', ), |
| 104 | + 'update_table': ('table', 'update_mask', 'ignore_warnings', ), |
| 105 | + } |
| 106 | + |
| 107 | + def leave_Call(self, original: cst.Call, updated: cst.Call) -> cst.CSTNode: |
| 108 | + try: |
| 109 | + key = original.func.attr.value |
| 110 | + kword_params = self.METHOD_TO_PARAMS[key] |
| 111 | + except (AttributeError, KeyError): |
| 112 | + # Either not a method from the API or too convoluted to be sure. |
| 113 | + return updated |
| 114 | + |
| 115 | + # If the existing code is valid, keyword args come after positional args. |
| 116 | + # Therefore, all positional args must map to the first parameters. |
| 117 | + args, kwargs = partition(lambda a: not bool(a.keyword), updated.args) |
| 118 | + if any(k.keyword.value == "request" for k in kwargs): |
| 119 | + # We've already fixed this file, don't fix it again. |
| 120 | + return updated |
| 121 | + |
| 122 | + kwargs, ctrl_kwargs = partition( |
| 123 | + lambda a: a.keyword.value not in self.CTRL_PARAMS, |
| 124 | + kwargs |
| 125 | + ) |
| 126 | + |
| 127 | + args, ctrl_args = args[:len(kword_params)], args[len(kword_params):] |
| 128 | + ctrl_kwargs.extend(cst.Arg(value=a.value, keyword=cst.Name(value=ctrl)) |
| 129 | + for a, ctrl in zip(ctrl_args, self.CTRL_PARAMS)) |
| 130 | + |
| 131 | + request_arg = cst.Arg( |
| 132 | + value=cst.Dict([ |
| 133 | + cst.DictElement( |
| 134 | + cst.SimpleString("'{}'".format(name)), |
| 135 | +cst.Element(value=arg.value) |
| 136 | + ) |
| 137 | + # Note: the args + kwargs looks silly, but keep in mind that |
| 138 | + # the control parameters had to be stripped out, and that |
| 139 | + # those could have been passed positionally or by keyword. |
| 140 | + for name, arg in zip(kword_params, args + kwargs)]), |
| 141 | + keyword=cst.Name("request") |
| 142 | + ) |
| 143 | + |
| 144 | + return updated.with_changes( |
| 145 | + args=[request_arg] + ctrl_kwargs |
| 146 | + ) |
| 147 | + |
| 148 | + |
| 149 | +def fix_files( |
| 150 | + in_dir: pathlib.Path, |
| 151 | + out_dir: pathlib.Path, |
| 152 | + *, |
| 153 | + transformer=bigtable_adminCallTransformer(), |
| 154 | +): |
| 155 | + """Duplicate the input dir to the output dir, fixing file method calls. |
| 156 | +
|
| 157 | + Preconditions: |
| 158 | + * in_dir is a real directory |
| 159 | + * out_dir is a real, empty directory |
| 160 | + """ |
| 161 | + pyfile_gen = ( |
| 162 | + pathlib.Path(os.path.join(root, f)) |
| 163 | + for root, _, files in os.walk(in_dir) |
| 164 | + for f in files if os.path.splitext(f)[1] == ".py" |
| 165 | + ) |
| 166 | + |
| 167 | + for fpath in pyfile_gen: |
| 168 | + with open(fpath, 'r') as f: |
| 169 | + src = f.read() |
| 170 | + |
| 171 | + # Parse the code and insert method call fixes. |
| 172 | + tree = cst.parse_module(src) |
| 173 | + updated = tree.visit(transformer) |
| 174 | + |
| 175 | + # Create the path and directory structure for the new file. |
| 176 | + updated_path = out_dir.joinpath(fpath.relative_to(in_dir)) |
| 177 | + updated_path.parent.mkdir(parents=True, exist_ok=True) |
| 178 | + |
| 179 | + # Generate the updated source file at the corresponding path. |
| 180 | + with open(updated_path, 'w') as f: |
| 181 | + f.write(updated.code) |
| 182 | + |
| 183 | + |
| 184 | +if __name__ == '__main__': |
| 185 | + parser = argparse.ArgumentParser( |
| 186 | + description="""Fix up source that uses the bigtable_admin client library. |
| 187 | +
|
| 188 | +The existing sources are NOT overwritten but are copied to output_dir with changes made. |
| 189 | +
|
| 190 | +Note: This tool operates at a best-effort level at converting positional |
| 191 | + parameters in client method calls to keyword based parameters. |
| 192 | + Cases where it WILL FAIL include |
| 193 | + A) * or ** expansion in a method call. |
| 194 | + B) Calls via function or method alias (includes free function calls) |
| 195 | + C) Indirect or dispatched calls (e.g. the method is looked up dynamically) |
| 196 | +
|
| 197 | + These all constitute false negatives. The tool will also detect false |
| 198 | + positives when an API method shares a name with another method. |
| 199 | +""") |
| 200 | + parser.add_argument( |
| 201 | + '-d', |
| 202 | + '--input-directory', |
| 203 | + required=True, |
| 204 | + dest='input_dir', |
| 205 | + help='the input directory to walk for python files to fix up', |
| 206 | + ) |
| 207 | + parser.add_argument( |
| 208 | + '-o', |
| 209 | + '--output-directory', |
| 210 | + required=True, |
| 211 | + dest='output_dir', |
| 212 | + help='the directory to output files fixed via un-flattening', |
| 213 | + ) |
| 214 | + args = parser.parse_args() |
| 215 | + input_dir = pathlib.Path(args.input_dir) |
| 216 | + output_dir = pathlib.Path(args.output_dir) |
| 217 | + if not input_dir.is_dir(): |
| 218 | + print( |
| 219 | + f"input directory '{input_dir}' does not exist or is not a directory", |
| 220 | + file=sys.stderr, |
| 221 | + ) |
| 222 | + sys.exit(-1) |
| 223 | + |
| 224 | + if not output_dir.is_dir(): |
| 225 | + print( |
| 226 | + f"output directory '{output_dir}' does not exist or is not a directory", |
| 227 | + file=sys.stderr, |
| 228 | + ) |
| 229 | + sys.exit(-1) |
| 230 | + |
| 231 | + if os.listdir(output_dir): |
| 232 | + print( |
| 233 | + f"output directory '{output_dir}' is not empty", |
| 234 | + file=sys.stderr, |
| 235 | + ) |
| 236 | + sys.exit(-1) |
| 237 | + |
| 238 | + fix_files(input_dir, output_dir) |
0 commit comments