root / snf-deploy / snfdeploy / massedit.py @ 4b36944e
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() |