1#!/usr/bin/env python3
2# SPDX-License-Identifier: GPL-2.0+
3#
4# Copyright 2021 Google LLC
5#
6
7"""Changes the functions and class methods in a file to use snake case, updating
8other tools which use them"""
9
10from argparse import ArgumentParser
11import glob
12import os
13import re
14import subprocess
15
16import camel_case
17
18# Exclude functions with these names
19EXCLUDE_NAMES = set(['setUp', 'tearDown', 'setUpClass', 'tearDownClass'])
20
21# Find function definitions in a file
22RE_FUNC = re.compile(r' *def (\w+)\(')
23
24# Where to find files that might call the file being converted
25FILES_GLOB = 'tools/**/*.py'
26
27def collect_funcs(fname):
28    """Collect a list of functions in a file
29
30    Args:
31        fname (str): Filename to read
32
33    Returns:
34        tuple:
35            str: contents of file
36            list of str: List of function names
37    """
38    with open(fname, encoding='utf-8') as inf:
39        data = inf.read()
40        funcs = RE_FUNC.findall(data)
41    return data, funcs
42
43def get_module_name(fname):
44    """Convert a filename to a module name
45
46    Args:
47        fname (str): Filename to convert, e.g. 'tools/patman/command.py'
48
49    Returns:
50        tuple:
51            str: Full module name, e.g. 'patman.command'
52            str: Leaf module name, e.g. 'command'
53            str: Program name, e.g. 'patman'
54    """
55    parts = os.path.splitext(fname)[0].split('/')[1:]
56    module_name = '.'.join(parts)
57    return module_name, parts[-1], parts[0]
58
59def process_caller(data, conv, module_name, leaf):
60    """Process a file that might call another module
61
62    This converts all the camel-case references in the provided file contents
63    with the corresponding snake-case references.
64
65    Args:
66        data (str): Contents of file to convert
67        conv (dict): Identifies to convert
68            key: Current name in camel case, e.g. 'DoIt'
69            value: New name in snake case, e.g. 'do_it'
70        module_name: Name of module as referenced by the file, e.g.
71            'patman.command'
72        leaf: Leaf module name, e.g. 'command'
73
74    Returns:
75        str: New file contents, or None if it was not modified
76    """
77    total = 0
78
79    # Update any simple functions calls into the module
80    for name, new_name in conv.items():
81        newdata, count = re.subn(fr'{leaf}.{name}\(',
82                                 f'{leaf}.{new_name}(', data)
83        total += count
84        data = newdata
85
86    # Deal with files that import symbols individually
87    imports = re.findall(fr'from {module_name} import (.*)\n', data)
88    for item in imports:
89        #print('item', item)
90        names = [n.strip() for n in item.split(',')]
91        new_names = [conv.get(n) or n for n in names]
92        new_line = f"from {module_name} import {', '.join(new_names)}\n"
93        data = re.sub(fr'from {module_name} import (.*)\n', new_line, data)
94        for name in names:
95            new_name = conv.get(name)
96            if new_name:
97                newdata = re.sub(fr'\b{name}\(', f'{new_name}(', data)
98                data = newdata
99
100    # Deal with mocks like:
101    # unittest.mock.patch.object(module, 'Function', ...
102    for name, new_name in conv.items():
103        newdata, count = re.subn(fr"{leaf}, '{name}'",
104                                 f"{leaf}, '{new_name}'", data)
105        total += count
106        data = newdata
107
108    if total or imports:
109        return data
110    return None
111
112def process_file(srcfile, do_write, commit):
113    """Process a file to rename its camel-case functions
114
115    This renames the class methods and functions in a file so that they use
116    snake case. Then it updates other modules that call those functions.
117
118    Args:
119        srcfile (str): Filename to process
120        do_write (bool): True to write back to files, False to do a dry run
121        commit (bool): True to create a commit with the changes
122    """
123    data, funcs = collect_funcs(srcfile)
124    module_name, leaf, prog = get_module_name(srcfile)
125    #print('module_name', module_name)
126    #print(len(funcs))
127    #print(funcs[0])
128    conv = {}
129    for name in funcs:
130        if name not in EXCLUDE_NAMES:
131            conv[name] = camel_case.to_snake(name)
132
133    # Convert name to new_name in the file
134    for name, new_name in conv.items():
135        #print(name, new_name)
136        # Don't match if it is preceded by a '.', since that indicates that
137        # it is calling this same function name but in a different module
138        newdata = re.sub(fr'(?<!\.){name}\(', f'{new_name}(', data)
139        data = newdata
140
141        # But do allow self.xxx
142        newdata = re.sub(fr'self.{name}\(', f'self.{new_name}(', data)
143        data = newdata
144    if do_write:
145        with open(srcfile, 'w', encoding='utf-8') as out:
146            out.write(data)
147
148    # Now find all files which use these functions and update them
149    for fname in glob.glob(FILES_GLOB, recursive=True):
150        with open(fname, encoding='utf-8') as inf:
151            data = inf.read()
152        newdata = process_caller(fname, conv, module_name, leaf)
153        if do_write and newdata:
154            with open(fname, 'w', encoding='utf-8') as out:
155                out.write(newdata)
156
157    if commit:
158        subprocess.call(['git', 'add', '-u'])
159        subprocess.call([
160            'git', 'commit', '-s', '-m',
161            f'''{prog}: Convert camel case in {os.path.basename(srcfile)}
162
163Convert this file to snake case and update all files which use it.
164'''])
165
166
167def main():
168    """Main program"""
169    epilog = 'Convert camel case function names to snake in a file and callers'
170    parser = ArgumentParser(epilog=epilog)
171    parser.add_argument('-c', '--commit', action='store_true',
172                        help='Add a commit with the changes')
173    parser.add_argument('-n', '--dry_run', action='store_true',
174                        help='Dry run, do not write back to files')
175    parser.add_argument('-s', '--srcfile', type=str, required=True, help='Filename to convert')
176    args = parser.parse_args()
177    process_file(args.srcfile, not args.dry_run, args.commit)
178
179if __name__ == '__main__':
180    main()
181