| #!/usr/bin/python | |
| # Licensed to the Apache Software Foundation (ASF) under one | |
| # or more contributor license agreements. See the NOTICE file | |
| # distributed with this work for additional information | |
| # regarding copyright ownership. The ASF licenses this file | |
| # to you under the Apache License, Version 2.0 (the | |
| # "License"); you may not use this file except in compliance | |
| # with the License. You may obtain a copy of the License at | |
| # | |
| # http://www.apache.org/licenses/LICENSE-2.0 | |
| # | |
| # Unless required by applicable law or agreed to in writing, | |
| # software distributed under the License is distributed on an | |
| # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | |
| # KIND, either express or implied. See the License for the | |
| # specific language governing permissions and limitations | |
| # under the License. | |
| import logging | |
| import re | |
| import shutil | |
| import os | |
| class LineEdit(object): | |
| """Helper for LineEditingFile that keeps track of one edit.""" | |
| def __init__(self, search, sub, *sub_args, **kwargs): | |
| if len(sub_args) > 0: | |
| sub = sub % sub_args | |
| flags = kwargs.get('flags', 0) | |
| self.pattern = re.compile(search, flags=flags) | |
| self.sub = sub | |
| self.count = kwargs.get('count', 0) # max subs to make | |
| self.subs = 0 # subs made so far | |
| class LineEditingFile(object): | |
| """ | |
| Atomic, conservative, by-line editing of configuration files. | |
| Will not touch the file if there are no changes to do. | |
| Reasonably efficient for large files, though files with a long time | |
| before their first match will use memory. | |
| Given a vhosts file such as: | |
| >>> with open('doctest-vhosts.conf', 'w') as f: | |
| ... f.write(''' | |
| ... Listen foo:80 | |
| ... <VirtualHost foo:80> | |
| ... DocRoot /var/www | |
| ... </VirtualHost> | |
| ... | |
| ... Listen other:80 | |
| ... <VirtualHost other:80> | |
| ... DocRoot /var/www | |
| ... </VirtualHost> | |
| ... ''') | |
| ... | |
| To replace the hostname for the first virtualhost entry: | |
| >>> new_hostname = 'fooooo' | |
| >>> with LineEditingFile('doctest-vhosts.conf') as f: | |
| ... f.replace(r'<VirtualHost .*?:80>', '<VirtualHost %s:80>', new_hostname, count=1, flags=re.I) | |
| ... f.replace(r'Listen .*?:80', 'Listen %s:80', new_hostname, count=1, flags=re.I) | |
| ... | |
| Be careful with the matches! | |
| A second invocation of the same rule will edit the second vhost: | |
| >>> new_hostname = 'fooooo' | |
| >>> with LineEditingFile('doctest-vhosts.conf') as f: | |
| ... f.replace(r'<VirtualHost .*?:80>', '<VirtualHost %s:80>', new_hostname, count=1, flags=re.I) | |
| ... | |
| To move all hosts from port 80 to port 8080: | |
| >>> with LineEditingFile('doctest-vhosts.conf') as f: | |
| ... f.replace(r'<VirtualHost (.*?):80>', '<VirtualHost \\\\1:8080>', flags=re.I) | |
| ... f.replace(r'Listen (.*?):80', 'Listen \\\\1:80', flags=re.I) | |
| ... | |
| (please note in this example there's a double escape of the backreference | |
| \\\\1, to make the example work with doctest) | |
| Since this example already matched all files, a second invocation does nothing: | |
| >>> with LineEditingFile('doctest-vhosts.conf') as f: | |
| ... f.replace(r'<VirtualHost (.*?):80>', '<VirtualHost \\\\1:8080>', flags=re.I) | |
| ... | |
| It's also acceptable to not make any edits at all: | |
| >>> with LineEditingFile('doctest-vhosts.conf') as f: | |
| ... pass | |
| ... | |
| You don't _have_ to use a with statement: | |
| >>> f = LineEditingFile('doctest-vhosts.conf') | |
| >>> f.replace(r'DocRoot /var/www', 'DocRoot /var/www/html', flags=re.I) | |
| >>> changes = f.commit() | |
| >>> print changes | |
| 2 | |
| >>> | |
| Cleanup of the example vhosts.conf: | |
| >>> # noinspection PyBroadException | |
| >>> try: | |
| ... os.unlink('doctest-vhosts.conf') | |
| ... os.unlink('doctest-vhosts.conf.bak') | |
| ... os.unlink('doctest-vhosts.conf.new') | |
| ... except: | |
| ... pass | |
| ... | |
| """ | |
| def __init__(self, filename): | |
| self.filename = filename | |
| self.changed = False | |
| self.edits = [] | |
| def __enter__(self): | |
| return self | |
| def replace(self, search, sub, *sub_args, **kwargs): | |
| edit = LineEdit(search, sub, *sub_args, **kwargs) | |
| self.edits.append(edit) | |
| # noinspection PyUnusedLocal | |
| def __exit__(self, exc, value, traceback): | |
| if exc is not None: | |
| return False # return false results in re-raise | |
| self.commit() | |
| def commit(self): | |
| changes = 0 | |
| changed_file = None | |
| changed_filename = self.filename + '.new' | |
| try: | |
| lines = [] | |
| backup_filename = self.filename + '.bak' | |
| # noinspection PyUnusedLocal | |
| stat = None | |
| with open(self.filename, 'r') as orig: | |
| stat = os.fstat(orig.fileno()) | |
| for line in orig: | |
| changed_line = line | |
| for edit in self.edits: | |
| remaining_count = 0 | |
| if edit.count != 0: | |
| remaining_count = edit.count - edit.subs | |
| if remaining_count < 0: | |
| raise Exception("Made too many edits") | |
| elif remaining_count == 0: | |
| continue | |
| changed_line, subs = edit.pattern.subn( | |
| edit.sub, line, remaining_count) | |
| if changed_line != line: | |
| if changed_file is None: | |
| logging.debug("Editing file %s" % self.filename) | |
| logging.debug(" - %s" % line[:-1]) | |
| logging.debug(" + %s" % changed_line[:-1]) | |
| changes += subs | |
| edit.subs += subs | |
| if changes == 0: # buffer until we find a change | |
| lines.append(changed_line) | |
| elif changed_file is None: # found first change, flush buffer | |
| changed_file = open(changed_filename, 'w') | |
| if hasattr(os, 'fchmod'): | |
| os.fchmod(changed_file.fileno(), # can cause OSError which aborts | |
| stat.st_mode) | |
| if hasattr(os, 'fchown'): | |
| os.fchown(changed_file.fileno(), # can cause OSError which aborts | |
| stat.st_uid, stat.st_gid) | |
| changed_file.writelines(lines) | |
| changed_file.write(changed_line) | |
| del lines # reclaim buffer memory | |
| else: # already flushed, just write | |
| changed_file.write(changed_line) | |
| if changes == 0: | |
| logging.info("No edits need for file %s" % | |
| self.filename) | |
| else: | |
| changed_file.close() | |
| changed_file = None | |
| if os.path.exists(backup_filename): # back up the original | |
| os.unlink(backup_filename) | |
| shutil.copy(self.filename, backup_filename) | |
| os.rename(changed_filename, self.filename) # the swap | |
| logging.info("Edited file %s (%d changes)" % | |
| (self.filename, changes)) | |
| finally: | |
| if changed_file is not None: # failed, clean up | |
| changed_file.close() | |
| os.unlink(changed_filename) | |
| return changes | |
| if __name__ == "__main__": | |
| logging.basicConfig(level=logging.DEBUG) | |
| import doctest | |
| doctest.testmod() |