| #!/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() |