| # copyright 2003-2011 LOGILAB S.A. (Paris, FRANCE), all rights reserved. |
| # contact http://www.logilab.fr/ -- mailto:contact@logilab.fr |
| # |
| # This file is part of logilab-common. |
| # |
| # logilab-common is free software: you can redistribute it and/or modify it under |
| # the terms of the GNU Lesser General Public License as published by the Free |
| # Software Foundation, either version 2.1 of the License, or (at your option) any |
| # later version. |
| # |
| # logilab-common is distributed in the hope that it will be useful, but WITHOUT |
| # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS |
| # FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more |
| # details. |
| # |
| # You should have received a copy of the GNU Lesser General Public License along |
| # with logilab-common. If not, see <http://www.gnu.org/licenses/>. |
| """Manipulation of upstream change log files. |
| |
| The upstream change log files format handled is simpler than the one |
| often used such as those generated by the default Emacs changelog mode. |
| |
| Sample ChangeLog format:: |
| |
| Change log for project Yoo |
| ========================== |
| |
| -- |
| * add a new functionality |
| |
| 2002-02-01 -- 0.1.1 |
| * fix bug #435454 |
| * fix bug #434356 |
| |
| 2002-01-01 -- 0.1 |
| * initial release |
| |
| |
| There is 3 entries in this change log, one for each released version and one |
| for the next version (i.e. the current entry). |
| Each entry contains a set of messages corresponding to changes done in this |
| release. |
| All the non empty lines before the first entry are considered as the change |
| log title. |
| """ |
| |
| __docformat__ = "restructuredtext en" |
| |
| import sys |
| from stat import S_IWRITE |
| |
| BULLET = '*' |
| SUBBULLET = '-' |
| INDENT = ' ' * 4 |
| |
| class NoEntry(Exception): |
| """raised when we are unable to find an entry""" |
| |
| class EntryNotFound(Exception): |
| """raised when we are unable to find a given entry""" |
| |
| class Version(tuple): |
| """simple class to handle soft version number has a tuple while |
| correctly printing it as X.Y.Z |
| """ |
| def __new__(cls, versionstr): |
| if isinstance(versionstr, basestring): |
| versionstr = versionstr.strip(' :') # XXX (syt) duh? |
| parsed = cls.parse(versionstr) |
| else: |
| parsed = versionstr |
| return tuple.__new__(cls, parsed) |
| |
| @classmethod |
| def parse(cls, versionstr): |
| versionstr = versionstr.strip(' :') |
| try: |
| return [int(i) for i in versionstr.split('.')] |
| except ValueError, ex: |
| raise ValueError("invalid literal for version '%s' (%s)"%(versionstr, ex)) |
| |
| def __str__(self): |
| return '.'.join([str(i) for i in self]) |
| |
| # upstream change log ######################################################### |
| |
| class ChangeLogEntry(object): |
| """a change log entry, i.e. a set of messages associated to a version and |
| its release date |
| """ |
| version_class = Version |
| |
| def __init__(self, date=None, version=None, **kwargs): |
| self.__dict__.update(kwargs) |
| if version: |
| self.version = self.version_class(version) |
| else: |
| self.version = None |
| self.date = date |
| self.messages = [] |
| |
| def add_message(self, msg): |
| """add a new message""" |
| self.messages.append(([msg], [])) |
| |
| def complete_latest_message(self, msg_suite): |
| """complete the latest added message |
| """ |
| if not self.messages: |
| raise ValueError('unable to complete last message as there is no previous message)') |
| if self.messages[-1][1]: # sub messages |
| self.messages[-1][1][-1].append(msg_suite) |
| else: # message |
| self.messages[-1][0].append(msg_suite) |
| |
| def add_sub_message(self, sub_msg, key=None): |
| if not self.messages: |
| raise ValueError('unable to complete last message as there is no previous message)') |
| if key is None: |
| self.messages[-1][1].append([sub_msg]) |
| else: |
| raise NotImplementedError("sub message to specific key are not implemented yet") |
| |
| def write(self, stream=sys.stdout): |
| """write the entry to file """ |
| stream.write('%s -- %s\n' % (self.date or '', self.version or '')) |
| for msg, sub_msgs in self.messages: |
| stream.write('%s%s %s\n' % (INDENT, BULLET, msg[0])) |
| stream.write(''.join(msg[1:])) |
| if sub_msgs: |
| stream.write('\n') |
| for sub_msg in sub_msgs: |
| stream.write('%s%s %s\n' % (INDENT * 2, SUBBULLET, sub_msg[0])) |
| stream.write(''.join(sub_msg[1:])) |
| stream.write('\n') |
| |
| stream.write('\n\n') |
| |
| class ChangeLog(object): |
| """object representation of a whole ChangeLog file""" |
| |
| entry_class = ChangeLogEntry |
| |
| def __init__(self, changelog_file, title=''): |
| self.file = changelog_file |
| self.title = title |
| self.additional_content = '' |
| self.entries = [] |
| self.load() |
| |
| def __repr__(self): |
| return '<ChangeLog %s at %s (%s entries)>' % (self.file, id(self), |
| len(self.entries)) |
| |
| def add_entry(self, entry): |
| """add a new entry to the change log""" |
| self.entries.append(entry) |
| |
| def get_entry(self, version='', create=None): |
| """ return a given changelog entry |
| if version is omitted, return the current entry |
| """ |
| if not self.entries: |
| if version or not create: |
| raise NoEntry() |
| self.entries.append(self.entry_class()) |
| if not version: |
| if self.entries[0].version and create is not None: |
| self.entries.insert(0, self.entry_class()) |
| return self.entries[0] |
| version = self.version_class(version) |
| for entry in self.entries: |
| if entry.version == version: |
| return entry |
| raise EntryNotFound() |
| |
| def add(self, msg, create=None): |
| """add a new message to the latest opened entry""" |
| entry = self.get_entry(create=create) |
| entry.add_message(msg) |
| |
| def load(self): |
| """ read a logilab's ChangeLog from file """ |
| try: |
| stream = open(self.file) |
| except IOError: |
| return |
| last = None |
| expect_sub = False |
| for line in stream.readlines(): |
| sline = line.strip() |
| words = sline.split() |
| # if new entry |
| if len(words) == 1 and words[0] == '--': |
| expect_sub = False |
| last = self.entry_class() |
| self.add_entry(last) |
| # if old entry |
| elif len(words) == 3 and words[1] == '--': |
| expect_sub = False |
| last = self.entry_class(words[0], words[2]) |
| self.add_entry(last) |
| # if title |
| elif sline and last is None: |
| self.title = '%s%s' % (self.title, line) |
| # if new entry |
| elif sline and sline[0] == BULLET: |
| expect_sub = False |
| last.add_message(sline[1:].strip()) |
| # if new sub_entry |
| elif expect_sub and sline and sline[0] == SUBBULLET: |
| last.add_sub_message(sline[1:].strip()) |
| # if new line for current entry |
| elif sline and last.messages: |
| last.complete_latest_message(line) |
| else: |
| expect_sub = True |
| self.additional_content += line |
| stream.close() |
| |
| def format_title(self): |
| return '%s\n\n' % self.title.strip() |
| |
| def save(self): |
| """write back change log""" |
| # filetutils isn't importable in appengine, so import locally |
| from logilab.common.fileutils import ensure_fs_mode |
| ensure_fs_mode(self.file, S_IWRITE) |
| self.write(open(self.file, 'w')) |
| |
| def write(self, stream=sys.stdout): |
| """write changelog to stream""" |
| stream.write(self.format_title()) |
| for entry in self.entries: |
| entry.write(stream) |
| |