Statistics
| Branch: | Tag: | Revision:

root / snf-deploy / snfdeploy / massedit.py @ f92dffed

History | View | Annotate | Download (10.6 kB)

1
#!/usr/bin/env python
2
# encoding='cp1252'
3

    
4
"""A python bulk editor class to apply the same code to many files."""
5

    
6
# Copyright (c) 2012 Jerome Lecomte
7
#
8
# Permission is hereby granted, free of charge, to any person obtaining a copy
9
# of this software and associated documentation files (the "Software"), to deal
10
# in the Software without restriction, including without limitation the rights
11
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
12
# copies of the Software, and to permit persons to whom the Software is
13
# furnished to do so, subject to the following conditions:
14
#
15
# The above copyright notice and this permission notice shall be included in
16
# all copies or substantial portions of the Software.
17
#
18
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
21
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
24
# THE SOFTWARE.
25

    
26

    
27
__version__ = '0.61'  # UPDATE setup.py when changing version.
28
__author__ = 'Jerome Lecomte'
29
__license__ = 'MIT'
30

    
31

    
32
import os
33
import sys
34
import logging
35
import argparse
36
import difflib
37
# Most manip will involve re so we include it here for convenience.
38
import re  # pylint: disable=W0611
39
import fnmatch
40

    
41

    
42
logger = logging.getLogger(__name__)
43

    
44

    
45
class EditorError(RuntimeError):
46
    """Error raised by the Editor class."""
47
    pass
48

    
49

    
50
class Editor(object):
51
    """Processes input file or input line.
52

53
    Named arguments:
54
    code -- code expression to process the input with.
55
    """
56

    
57
    def __init__(self, **kwds):
58
        self.code_objs = dict()
59
        self._codes = []
60
        self.dry_run = None
61
        if 'module' in kwds:
62
            self.import_module(kwds['module'])
63
        if 'code' in kwds:
64
            self.append_code_expr(kwds['code'])
65
        if 'dry_run' in kwds:
66
            self.dry_run = kwds['dry_run']
67

    
68
    def __edit_line(self, line, code, code_obj):  # pylint: disable=R0201
69
        """Edit a line with one code object built in the ctor."""
70
        try:
71
            result = eval(code_obj, globals(), locals())
72
        except TypeError as ex:
73
            message = "failed to execute {0}: {1}".format(code, ex)
74
            logger.warning(message)
75
            raise EditorError(message)
76
        if not result:
77
            raise EditorError("cannot process line '{0}' with {1}".format(
78
                              line, code))
79
        elif isinstance(result, list) or isinstance(result, tuple):
80
            line = ' '.join([str(res_element) for res_element in result])
81
        else:
82
            line = str(result)
83
        return line
84

    
85
    def edit_line(self, line):
86
        """Edits a single line using the code expression."""
87
        for code, code_obj in self.code_objs.items():
88
            line = self.__edit_line(line, code, code_obj)
89
        return line
90

    
91
    def edit_file(self, file_name):
92
        """Edit file in place, returns a list of modifications (unified diff).
93

94
        Arguments:
95
        file_name -- The name of the file.
96
        dry_run -- only return differences, but do not edit the file.
97
        """
98
        with open(file_name, "r") as from_file:
99
            from_lines = from_file.readlines()
100
            to_lines = [self.edit_line(line) for line in from_lines]
101
            diffs = difflib.unified_diff(from_lines, to_lines,
102
                                         fromfile=file_name, tofile='<new>')
103
        if not self.dry_run:
104
            bak_file_name = file_name + ".bak"
105
            if os.path.exists(bak_file_name):
106
                raise EditorError("{0} already exists".format(bak_file_name))
107
            try:
108
                os.rename(file_name, bak_file_name)
109
                with open(file_name, "w") as new_file:
110
                    new_file.writelines(to_lines)
111
                os.unlink(bak_file_name)
112
            except:
113
                os.rename(bak_file_name, file_name)
114
                raise
115
        return list(diffs)
116

    
117
    def append_code_expr(self, code):
118
        """Compiles argument and adds it to the list of code objects."""
119
        assert(isinstance(code, str))  # expect a string.
120
        logger.debug("compiling code {0}...".format(code))
121
        try:
122
            code_obj = compile(code, '<string>', 'eval')
123
            self.code_objs[code] = code_obj
124
        except SyntaxError as syntax_err:
125
            logger.error("cannot compile {0}: {1}".format(
126
                code, syntax_err))
127
            raise
128
        logger.debug("compiled code {0}".format(code))
129

    
130
    def set_code_expr(self, codes):
131
        """Convenience: sets all the code expressions at once."""
132
        self.code_objs = dict()
133
        self._codes = []
134
        for code in codes:
135
            self.append_code_expr(code)
136

    
137
    def import_module(self, module):  # pylint: disable=R0201
138
        """Imports module that are needed for the code expr to compile.
139

140
        Argument:
141
        module -- can be scalar string or a list of strings.
142
        """
143
        if isinstance(module, list):
144
            all_modules = module
145
        else:
146
            all_modules = [module]
147
        for mod in all_modules:
148
            globals()[mod] = __import__(mod.strip())
149

    
150

    
151
def parse_command_line(argv):
152
    """Parses command line argument. See -h option
153

154
    argv -- arguments on the command line including the caller file.
155
    """
156
    example = """
157
    example: {0} -e "re.sub('failIf', 'assertFalse', line)" *.py
158
    """
159
    example = example.format(os.path.basename(argv[0]))
160
    if sys.version_info[0] < 3:
161
        parser = argparse.ArgumentParser(description="Python mass editor",
162
                                         version=__version__,
163
                                         epilog=example)
164
    else:
165
        parser = argparse.ArgumentParser(description="Python mass editor",
166
                                         epilog=example)
167
        parser.add_argument("-v", "--version", action="version",
168
                            version="%(prog)s {}".format(__version__))
169
    parser.add_argument("-w", "--write", dest="dry_run",
170
                        action="store_false", default=True,
171
                        help="modify target file(s) in place. "
172
                        "Shows diff otherwise.")
173
    parser.add_argument("-V", "--verbose", dest="verbose_count",
174
                        action="count", default=0,
175
                        help="increases log verbosity (can be specified "
176
                        "multiple times)")
177
    parser.add_argument('-e', "--expression", dest="expressions", nargs=1,
178
                        help="Python expressions to be applied on all files. "
179
                        "Use the line variable to reference the current line.")
180
    parser.add_argument("-s", "--start", dest="start_dir",
181
                        help="Starting directory in which to look for the "
182
                        "files. If there is one pattern only and it includes "
183
                        "a directory, the start dir will be that directory "
184
                        "and the max depth level will be set to 1.")
185
    parser.add_argument('-m', "--max-depth-level", type=int, dest="max_depth",
186
                        help="Maximum depth when walking subdirectories.")
187
    parser.add_argument('-o', '--output', metavar="output",
188
                        type=argparse.FileType('w'), default=sys.stdout,
189
                        help="redirect output to a file")
190
    parser.add_argument('patterns', metavar="pattern", nargs='+',
191
                        help="file patterns to process.")
192
    arguments = parser.parse_args(argv[1:])
193
    # Sets log level to WARN going more verbose for each new -V.
194
    logger.setLevel(max(3 - arguments.verbose_count, 0) * 10)
195
    return arguments
196

    
197

    
198
def edit_files(patterns, expressions,  # pylint: disable=R0913, R0914
199
               start_dir=None, max_depth=1, dry_run=True,
200
               output=sys.stdout):
201
    """Edits the files that match patterns with python expressions. Each
202
    expression is run (using eval()) line by line on each input file.
203

204
    Keyword Arguments:
205
    max_depth -- maximum recursion level when looking for file matches.
206
    start_dir -- directory where to start the file search.
207
    dry_run -- only display differences if True. Save modified file otherwise.
208
    output -- handle where the output should be redirected.
209
    """
210
    # Makes for a better diagnostic because str are also iterable.
211
    assert not isinstance(patterns, str), "patterns should be a list"
212
    assert not isinstance(expressions, str), "expressions should be a list"
213

    
214
    # Shortcut: if there is only one pattern, make sure we process just that.
215
    if len(patterns) == 1 and not start_dir:
216
        pattern = patterns[0]
217
        directory = os.path.dirname(pattern)
218
        if directory:
219
            patterns = [os.path.basename(pattern)]
220
            start_dir = directory
221
            max_depth = 1
222

    
223
    processed_paths = []
224
    editor = Editor(dry_run=dry_run)
225
    if expressions:
226
        editor.set_code_expr(expressions)
227
    if not start_dir:
228
        start_dir = os.getcwd()
229
    for root, dirs, files in os.walk(start_dir):  # pylint: disable=W0612
230
        if max_depth is not None:
231
            relpath = os.path.relpath(root, start=start_dir)
232
            depth = len(relpath.split(os.sep))
233
            if depth > max_depth:
234
                continue
235
        names = []
236
        for pattern in patterns:
237
            names += fnmatch.filter(files, pattern)
238
        for name in names:
239
            path = os.path.join(root, name)
240
            processed_paths.append(os.path.abspath(path))
241
            diffs = editor.edit_file(path)
242
            if dry_run:
243
                output.write("".join(diffs))
244
    if output != sys.stdout:
245
        output.close()
246
    return processed_paths
247

    
248

    
249
def command_line(argv):
250
    """Instantiate an editor and process arguments.
251

252
    Optional argument:
253
    processed_paths -- paths processed are appended to the list.
254
    """
255
    arguments = parse_command_line(argv)
256
    return edit_files(arguments.patterns, arguments.expressions,
257
                      start_dir=arguments.start_dir,
258
                      max_depth=arguments.max_depth,
259
                      dry_run=arguments.dry_run,
260
                      output=arguments.output)
261

    
262

    
263
if __name__ == "__main__":
264
    logging.basicConfig(stream=sys.stderr, level=logging.DEBUG)
265
    try:
266
        command_line(sys.argv)
267
    finally:
268
        logging.shutdown()