#!/usr/bin/env python
"""Maintain OpenCSW changelog.

cswch can maintain two styles of changelog files, namely Debian style
changelog files and Dam style changelog files.

Debian style changelog files are modeled after the Debian changelog

 http://www.debian.org/doc/debian-policy/ch-source.html#s-dpkgchangelog

except that the header line for each entry is somewhat
simplyfied. Instead of using

 package (version) distribution(s); urgency=urgency

it uses

 package (version,REV=revision)


Dam style changelog files are similar to Debian style changelog files,
except for the header line, which looks like

 package (timestamp)


Creating a new changelog file is done by

 cswch create [--dam-style|--deb-style --version <version>] <pkg> \
  --log-item <item1> .. <itemN> [--log-paragraph <par1> .. <parN>]

This will create a changelog.CSW file in the files/ directory and add
it to SVN working copy.


After creating the changelog file, adding a new changelog entry is a
matter of

 cswch new [--version <version>] --log-item <item1> .. <itemN> \
  [--log-paragraph <par1> .. <parN>]

where `--version' is only required for Debian style changelogs.

See

 cswch --help

for more information.

$Id: cswch 23777 2014-06-10 07:35:18Z guengel $

"""
import ConfigParser
import argparse
import datetime
import dateutil.tz
import email.utils
import errno
import logging
import os
import pwd
import pysvn
import re
import sys
import time
import unittest

### T E S T S ###

class TestOpenCSWRev(unittest.TestCase):
    """Test opencsw_rev()"""

    def test_opencsw_rev(self):
        """revision string from date"""
        rev = ChangeLogHeader.opencsw_rev(datetime.date(1979, 1, 1))
        self.assertEqual(rev, '1979.01.01')

    def test_opencsw_rev_default(self):
        """revision from current date"""
        rev = ChangeLogHeader.opencsw_rev()
        self.assertEqual(rev, datetime.date.today().strftime('%Y.%m.%d'))


class TestChangeLogHeader(unittest.TestCase):
    """Test ChangeLogHeader"""

    def test_deb_changelogheader(self):
        """initialize debian changelog header with keywords"""
        chlg = ChangeLogHeader(package='test', version='0.1', rev='1991.09.04')
        self.assertEqual(ChangeLogHeader.STYLE_DEB, chlg.style)
        self.assertEqual('test (0.1,REV=1991.09.04)', str(chlg))

    def test_deb_parser(self):
        """initialize debian changelog header from line"""
        chlg = ChangeLogHeader(line='test (0.1,REV=1991.09.04)')
        self.assertEqual(ChangeLogHeader.STYLE_DEB, chlg.style)
        self.assertEqual('test (0.1,REV=1991.09.04)', str(chlg))

    def test_dam_changelogheader(self):
        """initialize dam changelog header with keywords"""
        ts = '2014-01-01T00:01:02+0000'
        package = 'testdam'
        header_str = ChangeLogHeader.format_str_dam % {'package': package,
                                                       'timestamp': ts}
        chlg = ChangeLogHeader(package=package, timestamp=ts)
        self.assertEqual(ChangeLogHeader.STYLE_DAM, chlg.style)
        self.assertEqual(header_str, str(chlg))

    def test_dam_parser(self):
        """initialize dam changelog header from line"""
        line = 'testdam (2014-01-01T00:01:02+0300)'
        chlg = ChangeLogHeader(line=line)
        self.assertEqual(ChangeLogHeader.STYLE_DAM, chlg.style)

    def test_garbled(self):
        """test garbled changelog header"""
        self.assertRaises(GarbledChangeLogHeader,
                          ChangeLogHeader, line='test (0,REV=1991.9.4)')


class TestChangeLogFooter(unittest.TestCase):
    """Test ChangeLogFooter"""

    def test_changelogfooter(self):
        """initialize changelog footer from arguments"""
        chlg = ChangeLogFooter(maintainer="Rafael Ostertag",
                               email="raos@opencsw.org",
                               timestamp=datetime.datetime(1979, 1, 1,
                                                           0, 0, 0))
        self.assertEqual(' -- Rafael Ostertag <raos@opencsw.org>  Mon, 01 Jan 1979 00:00:00 +0100', str(chlg))

    def test_parser(self):
        """initialize changelog footer from line"""
        test_footer = ' -- Rafael Ostertag <raos@opencsw.org>  Sun, 16 Mar 2014 10:10:33 +0100'
        chlg = ChangeLogFooter(line=test_footer)
        self.assertEqual(test_footer, str(chlg))

    def test_garbled(self):
        """test garbled changelog footer"""
        self.assertRaises(GarbledChangeLogFooter,
                          ChangeLogFooter, line=' -- Rafael Ostertag <raos@opencsw.org>  Sun, 16 Mar 2014 10:10:33 +010')


class TestChangeLogParagraph(unittest.TestCase):
    """Test ChangeLogParagraph."""

    def test_init_single_line_bullet(self):
        """initialize ChangeLog paragraph with one line"""
        line = '  * Lorem ipsum dolor sit amet, consectetur adipiscing'
        chpg = ChangeLogParagraph(line)
        self.assertEqual(line, str(chpg))
        self.assertEqual('Lorem ipsum dolor sit amet, consectetur adipiscing',
                         repr(chpg))

    def test_init_single_line_plain(self):
        """initialize ChangeLog paragraph with one line"""
        line = '  Lorem ipsum dolor sit amet, consectetur adipiscing'
        chpg = ChangeLogParagraph(line)
        self.assertEqual(line, str(chpg))
        self.assertEqual('Lorem ipsum dolor sit amet, consectetur adipiscing',
                         repr(chpg))

    def test_line_add_bullet(self):
        """Test adding a new line to an existing paragraph"""
        line = "  * short line"
        chpg = ChangeLogParagraph(line)

        chpg.add_line("and another line appended")

        self.assertEqual('short line and another line appended', repr(chpg))
        self.assertEqual('  * short line and another line appended',
                         str(chpg))

    def test_line_add_plain(self):
        """Test adding a new line to an existing paragraph"""
        line = "  short line"
        chpg = ChangeLogParagraph(line)

        chpg.add_line("and another line appended")

        self.assertEqual('short line and another line appended', repr(chpg))
        self.assertEqual('  short line and another line appended',
                         str(chpg))


    def test_newline_handling_bullet(self):
        """test newline handling of ChangeLogParagraph"""
        lines = """  * Lorem ipsum dolor sit amet,
    consectetur adipiscing"""
        chpg = ChangeLogParagraph(lines)
        self.assertEqual('  * Lorem ipsum dolor sit amet, consectetur adipiscing', str(chpg))
        self.assertEqual('Lorem ipsum dolor sit amet, consectetur adipiscing', repr(chpg))

    def test_newline_handling_plain(self):
        """test newline handling of ChangeLogParagraph"""
        lines = """  Lorem ipsum dolor sit amet,
  consectetur adipiscing"""
        chpg = ChangeLogParagraph(lines)
        self.assertEqual('  Lorem ipsum dolor sit amet, consectetur adipiscing', str(chpg))
        self.assertEqual('Lorem ipsum dolor sit amet, consectetur adipiscing', repr(chpg))

    def test_init_from_multiple_lines(self):
        """Test initialization from multiple lines"""

        lines = ['  * Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent posuere est a pellentesque imperdiet.',
                  'Vivamus dapibus enim eu magna vehicula posuere. Pellentesque adipiscing purus id diam auctor gravida. Sed leo odio,',
                  'molestie quis ante sed, commodo consequat nulla. Vivamus interdum vel lorem eget faucibus. Suspendisse dignissim orci sit amet dolor venenatis convallis.',
                  'Suspendisse fringilla tortor vitae dolor blandit ullamcorper. Cras sed mauris eu lorem scelerisque blandit quis vel mauris. Nam a urna aliquet, cursus erat a, fermentum justo. Sed hendrerit dui magna, ac tempus est vestibulum at.',
                  'Duis in massa ut nisl euismod auctor nec ac odio. Phasellus suscipit neque quis metus vestibulum molestie. Phasellus eget molestie elit, et fringilla purus. Morbi augue lectus, mattis ut mauris onvallis, eleifend condimentum nunc. Phasellus sit amet facilisis massa. Cras non porttitor turpis.']
        formatted = """  * Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent
    posuere est a pellentesque imperdiet. Vivamus dapibus enim eu magna
    vehicula posuere. Pellentesque adipiscing purus id diam auctor
    gravida. Sed leo odio, molestie quis ante sed, commodo consequat
    nulla. Vivamus interdum vel lorem eget faucibus. Suspendisse
    dignissim orci sit amet dolor venenatis convallis. Suspendisse
    fringilla tortor vitae dolor blandit ullamcorper. Cras sed mauris eu
    lorem scelerisque blandit quis vel mauris. Nam a urna aliquet,
    cursus erat a, fermentum justo. Sed hendrerit dui magna, ac tempus
    est vestibulum at. Duis in massa ut nisl euismod auctor nec ac odio.
    Phasellus suscipit neque quis metus vestibulum molestie. Phasellus
    eget molestie elit, et fringilla purus. Morbi augue lectus, mattis
    ut mauris onvallis, eleifend condimentum nunc. Phasellus sit amet
    facilisis massa. Cras non porttitor turpis."""
        chpg = ChangeLogParagraph(*lines)
        self.assertEqual(formatted, str(chpg))


class TestChangeLogEntry(unittest.TestCase):
    """Test ChangeLogEntry class"""

    def test_entry(self):
        """Test a simple changelog entry"""
        clg_entry = ChangeLogEntry()
        clg_header = ChangeLogHeader(package='cswch', version='0.1')
        clg_paragraph1 = ChangeLogParagraph("  * One Line")
        clg_paragraph2 = ChangeLogParagraph("  * Two Lines")
        clg_footer = ChangeLogFooter()

        clg_entry.add_header(clg_header)
        clg_entry.add_footer(clg_footer)
        clg_entry.add_paragraph(clg_paragraph1)
        clg_entry.add_paragraph(clg_paragraph2)

        expected_string = str(clg_header) + '\n\n' +\
                          str(clg_paragraph1) + '\n\n' +\
                          str(clg_paragraph2) + '\n\n' +\
                          str(clg_footer) + '\n\n\n'

        self.assertEqual(expected_string, str(clg_entry))


### C O D E ###

class ChangeLogException(Exception):
    """Exception Base class"""
    def __init__(self):
        super(ChangeLogException, self).__init__()


class GarbledChangeLogHeader(ChangeLogException):
    """Garbled ChangeLog header

    Raised if parsing of ChangeLog header fails.

    """
    def __init__(self, header):
        super(GarbledChangeLogHeader, self).__init__()
        self.header = header

    def __str__(self):
        return "Garbled ChangeLog header: %s" % self.header

    def __repr__(self):
        return str(self)


class GarbledChangeLogFooter(ChangeLogException):
    """Garbled ChangeLog footer

    Raised if parsing of ChangeLog footer fails.

    """
    def __init__(self, footer):
        super(GarbledChangeLogFooter, self).__init__()
        self.footer = footer

    def __str__(self):
        return "Garbled ChangeLog footer: %s" % self.footer

    def __repr__(self):
        return str(self)


class EmptyChangeLogFile(ChangeLogException):
    """Empty ChangeLog File

    Raised if operation expect existing entries in the ChangeLog File
    but none is found.

    """
    def __init__(self, filename):
        super(EmptyChangeLogFile, self).__init__()
        self.filename = filename

    def __str__(self):
        return "Empty ChangeLog: %s" % (self.filename,)

    def __repr__(self):
        return str(self)


class OperationNotApplicable(ChangeLogException):
    """Operation is not applicable to the given changelog file style"""
    def __init__(self, style):
        super(OperationNotApplicable, self).__init__()
        self.style = style

    def __str__(self):
        return "Operation not applicable to %s style change logs" % (self.style,)

    def __repr__(self):
        return str(self)


class Maintainer(object):
    """
    """
    def __init__(self):
        self.fullname, self.email = Maintainer._get_maintainer_fallback()
        try:
            cfg_defaults = { 'fullname': self.fullname,
                             'email': self.email }
            cfgfile = ConfigParser.ConfigParser(defaults=cfg_defaults)
            cfgfile.read(os.path.expanduser('~/.cswch.rc'))

            self.fullname = cfgfile.get('general', 'fullname')
            self.email = cfgfile.get('general', 'email')
        except ConfigParser.Error:
            pass


    def __str__(self):
        return self.fullname + '<' + self.email + '>'

    @classmethod
    def _get_maintainer_fallback(cls):
        """Derive maintainer name and email.

        Return maintainer and email as tuple.

        """
        return (pwd.getpwnam(Maintainer._logname())[4],
                "%s@opencsw.org" % Maintainer._logname())

    @classmethod
    def _logname(cls):
        if 'LOGNAME' in os.environ:
            return os.environ['LOGNAME']
        else:
            return os.getlogin()


class ChangeLogHeader(object):
    """Constitutes a changelog header.

    It has the form

     package (version,REV=rev)

    for Debian style ChangeLog files, or

     package (timestamp)

    for Dam style ChangeLog files.

    In any case, it marks the start of a changelog entry.

    """
    def __init__(self, **kwargs):
        """Initialize ChangeLogHeader

        :param line: parses the given line

        :param package: the package name

        :param version: version of the upstream packages

        :param rev: revision of the opencsw package

        """
        if 'line' in kwargs:
            self.parse_line(kwargs['line'])
            return

        assert 'package' in kwargs

        if 'version' in kwargs and kwargs['version'] is not None:
            # Must be Debian style changelog
            self.package = kwargs['package']
            self.version = kwargs['version']

            if 'rev' not in kwargs:
                self.rev = ChangeLogHeader.opencsw_rev()
            else:
                self.rev = kwargs['rev']

            self.style = ChangeLogHeader.STYLE_DEB
        else:
            # dam style changelog
            self.package = kwargs['package']
            if 'timestamp' in kwargs:
                self.timestamp = kwargs['timestamp']
            else:
                self.timestamp = ChangeLogHeader.get_rfc3339timestamp()

            self.style = ChangeLogHeader.STYLE_DAM

    def __str__(self):
        """Get the ChangeLog header as string"""
        if self.style == ChangeLogHeader.STYLE_DEB:
            return ChangeLogHeader.format_str_deb % {'package': self.package,
                                                     'version': self.version,
                                                     'rev': self.rev}
        return ChangeLogHeader.format_str_dam % {'package': self.package,
                                                 'timestamp': self.timestamp}

    def parse_line(self, line):
        """Parse ChangeLog header

        Dissect a ChangeLog header into package, version, and
        revision, or package, and timestamp, depending on the style of
        the ChangeLog.

        It will try to parse Debian style and Dam style header lines
        and set self.style according to the style.

        """
        matches = ChangeLogHeader.compiled_re_deb.match(line)

        if matches is None:
            # Now, try dam style
            matches = ChangeLogHeader.compiled_re_dam.match(line)
            if matches is None:
                raise GarbledChangeLogHeader(line)

            self.package = matches.group('package')
            self.timestamp = matches.group('timestamp')
            self.style = ChangeLogHeader.STYLE_DAM
            ## Leave method!
            return

        # Debian style log
        self.package = matches.group('package')
        self.version = matches.group('version')
        self.rev = matches.group('rev')
        self.style = ChangeLogHeader.STYLE_DEB

    def refresh_timestamp(self):
        """Set timestamp to current date/time

        Just a convenience method.
        """
        if self.style == ChangeLogHeader.STYLE_DEB:
            self.rev = ChangeLogHeader.opencsw_rev()
        else:
            self.timestamp = ChangeLogHeader.get_rfc3339timestamp()

    @classmethod
    def opencsw_rev(cls, date=None):
        """Convert a date to as string suitable for REV=

        If no date is specified, the current date is used.

        """
        if date is None:
            date = datetime.date.today()

        return date.strftime('%Y.%m.%d')

    @classmethod
    def get_rfc3339timestamp(cls, date_obj=None):
        """Return a timestamp in the format specified in RFC3339, section 5.6

        :param date_obj: datetime object to be formatted. If None, the
        current time will be taken.

        :retval: datetime formatted as RFC3339 timestamp

        """
        if date_obj is None:
            timezone = dateutil.tz.tzlocal()
            date_obj = datetime.datetime.now(timezone)

        return date_obj.strftime('%Y-%m-%dT%H:%M:%S%z')

    format_str_deb = r'%(package)s (%(version)s,REV=%(rev)s)'
    parse_re_deb = r'(?P<package>[\w-]+) \((?P<version>[\d\.]+),REV=(?P<rev>\d{4}\.\d{2}\.\d{2})\)'
    compiled_re_deb = re.compile(parse_re_deb)

    format_str_dam = r'%(package)s (%(timestamp)s)'
    parse_re_dam = r'(?P<package>[\w-]+) \((?P<timestamp>[\d]{4}-[\d]{2}-[\d]{2}T[\d]{2}:[\d]{2}:[\d]{2}(?:\+|-)[\d]{4})\)'
    compiled_re_dam = re.compile(parse_re_dam)

    STYLE_DEB = 'debian'
    STYLE_DAM = 'dam'


class ChangeLogFooter(object):
    """End a changelog entry.

    It has the form

    [one space]-- maintainer name <email address>[two spaces]  date
    """
    def __init__(self, **kwargs):
        if len(kwargs) == 0:
            maintainer = Maintainer()
            self.maintainer = maintainer.fullname
            self.email = maintainer.email
            self.timestamp = ChangeLogFooter.get_rfc2822timestamp()

            return

        if 'line' in kwargs:
            self.parse_line(kwargs['line'])
            return

        assert 'maintainer' in kwargs and 'email' in kwargs

        self.maintainer = kwargs['maintainer']
        self.email = kwargs['email']

        if 'timestamp' in kwargs:
            self.timestamp = ChangeLogFooter.get_rfc2822timestamp(kwargs['timestamp'])
        else:
            self.timestamp = ChangeLogFooter.get_rfc2822timestamp()

    def __str__(self):
        return ChangeLogFooter.format_str_deb % {'maintainer': self.maintainer,
                                             'email': self.email,
                                             'timestamp': self.timestamp}

    def parse_line(self, line):
        """Parse ChangeLog footer

        Dissect a ChangeLog footer into maintainer, email, and
        timestamp.

        """
        matches = ChangeLogFooter.compiled_re_deb.match(line)

        if matches is None:
            raise GarbledChangeLogFooter(line)

        self.maintainer = matches.group('maintainer')
        self.email = matches.group('email')
        self.timestamp = matches.group('timestamp')

    def refresh_timestamp(self):
        """Set timestamp to current date/time

        Just a convenience method.
        """
        self.timestamp = ChangeLogFooter.get_rfc2822timestamp()

    @classmethod
    def get_rfc2822timestamp(cls, date_obj=None):
        """Return a timestamp in the proper format.

        The proper format is a timestamp in the format specified in RFC
        2822.

        :param date_obj: datetime object or none, in which case the current time
        will be taken.

        """
        if date_obj is None:
            timezone = dateutil.tz.tzlocal()
            date_obj = datetime.datetime.now(timezone)

        return email.utils.formatdate(time.mktime(date_obj.timetuple()), True)

    format_str_deb = r' -- %(maintainer)s <%(email)s>  %(timestamp)s'
    parse_re_deb = r' -- (?P<maintainer>[\w\d ]+) <(?P<email>[\w\d@ \.]+)>  (?P<timestamp>\w{3}, \d{1,2} \w{3} \d{4} \d{2}:\d{2}:\d{2} (\+|-)\d{4})'
    compiled_re_deb = re.compile(parse_re_deb)


class ChangeLogParagraph(object):
    """A changelog paragraph

    It has the form

    [two spaces]* change details
                  more change details

    or

    [two spaces]line1
                line2

    The first line added is analyzed to find out which type of
    paragraph we're dealing with.

    """
    def __init__(self, *args, **kwargs):
        """Initialize ChangeLog paragraph.

        Initialize ChangeLog paragraph from one ore several
        lines. Optionally, maxcol can be specified.

        """
        assert len(args) > 0

        if 'maxcol' in kwargs:
            self.maxcol = kwargs['maxcol']
        else:
            self.maxcol = 72

        # will be set according to the style of the first line
        self.bullet_style = False

        self.paragraph = ""
        [self.add_line(l) for l in args]

    def add_line(self, line):
        """Add another line to the paragraph"""

        # Analyze the first line in order to find out the type of
        # paragraph, i.e. bullet style or plain style
        if self.paragraph == "" and ChangeLogParagraph.sanitize_re_c.match(line):
            # it's a bullet style paragraph
            self.bullet_style = True

        # Get rid of all leading '\s+\*?\s+' and concatenate all
        # arguments.
        self.paragraph = " ".join([self.paragraph, ChangeLogParagraph.sanitize_re_c.sub("", line)])

        # Some sanitation
        self.paragraph = self.paragraph.strip()
        self.paragraph = ChangeLogParagraph.whitespace_re_c.sub(' ',
                                                                self.paragraph)

    def __repr__(self):
        return self.paragraph

    def __str__(self):
        words = self.paragraph.split()

        if self.bullet_style:
            # line length starts at 4, because we have to take ' * ' into
            # account for the first line.
            line_indent = 4
            continuation_indent = 4
            formatted_paragraph = '  * '
        else:
            line_indent = 2
            continuation_indent = 2
            formatted_paragraph = '  '

        line_length = line_indent

        for word in words:
            if len(word) + line_length < self.maxcol:
                formatted_paragraph = formatted_paragraph + word + ' '
                line_length = line_length + len(word) + 1
            else:
                # chop off the last space
                formatted_paragraph = formatted_paragraph.rstrip()
                # add newline and reset line length counter
                formatted_paragraph = formatted_paragraph + '\n' +\
                                      ' ' * continuation_indent + word + ' '
                line_length = continuation_indent + len(word)

        formatted_paragraph = formatted_paragraph.rstrip()
        return formatted_paragraph

    sanitize_re = r'^\s+\*\s+'
    sanitize_re_c = re.compile(sanitize_re)
    whitespace_re = r'\s{2,}|\n+'
    whitespace_re_c = re.compile(whitespace_re)


class ChangeLogEntry(object):
    """A ChangeLogEntry

    A ChangeLogEntry is comprised of a ChangeLogHeader,
    ChangeLogFooter, and one or more ChangeLogParagraph's.

    """

    def __init__(self, *args):
        if len(args) == 3:
            assert len(args) == 3
            assert isinstance(args[0], ChangeLogHeader)
            assert isinstance(args[1], list)
            assert isinstance(args[2], ChangeLogFooter)
            self.header = args[0]
            self.paragraphs = args[1]
            self.footer = args[2]
        else:
            self.header = None
            self.footer = None
            self.paragraphs = list()

    def add_header(self, cl_header):
        """Add a ChangeLog Entry header.

        Add or replace the ChangeLog Entry header.

        """
        assert cl_header is not None
        self.header = cl_header

    def add_footer(self, cl_footer):
        """Add a ChangeLog Entry footer.

        Add or replace the ChangeLog Entry footer.

        """
        assert cl_footer is not None
        self.footer = cl_footer

    def add_paragraph(self, cl_paragraph, infront=False):
        """Add a ChangeLog Entry paragraph.

        Add another ChangeLog Entry paragraph.

        If :param infront: is True, the paragraph will be prepended to
        the paragraph list. Else it will be appended.

        """
        assert cl_paragraph is not None

        if infront:
            self.paragraphs.insert(0, cl_paragraph)
        else:
            self.paragraphs.append(cl_paragraph)

    def remove_paragraph(self, index):
        """Remove a ChangeLog paragraph

        Remove a ChangeLog paragraph from the list of paragraphs. The
        removed paragraph will be returned to the caller.

        """
        chlogp = self.paragraphs[index]
        del self.paragraphs[index]
        return chlogp

    def __str__(self):
        return str(self.header) + '\n\n' +\
            "\n\n".join([str(paragraph) for paragraph in
                         self.paragraphs]) + '\n\n' +\
            str(self.footer) + '\n\n\n'


class ChangeLogFile(object):
    """A ChangeLog file"""

    def __init__(self, filename):
        self.filename = filename
        self.changelog_entries = list()

    def read(self):
        """Read the ChangeLogFile

        Raises an exception 'ChangeLogFileEmpty()' if the file is
        empty, or no ChangeLogEnties could be extracted.

        """
        changelog_entry = None
        current_paragraph = None
        with open(self.filename, "r") as fobj:
            only_whitespace_re = re.compile(r'^\s+$')
            entry_start_re = re.compile(r'^[\w\d\.=(),]+')

            for line in fobj:
                line = line.rstrip('\n')

                if only_whitespace_re.match(line) or line == "":
                    if current_paragraph is not None:
                        # This ends the current paragraph
                        changelog_entry.add_paragraph(current_paragraph)
                        current_paragraph = None

                    continue

                # is it the start of an entry?
                if entry_start_re.match(line):
                    assert changelog_entry is None

                    changelog_entry = ChangeLogEntry()
                    changelog_entry.add_header(ChangeLogHeader(line=line))
                    continue

                # is it the footer, and thus marks the end of a entry?
                if line.startswith(' -- '):
                    # is there a paragraph pending?
                    if current_paragraph is not None:
                        changelog_entry.add_paragraph(current_paragraph)

                    changelog_entry.add_footer(ChangeLogFooter(line=line))
                    # append entry to the list
                    self.changelog_entries.append(changelog_entry)

                    changelog_entry = None
                    current_paragraph = None
                    continue

                if current_paragraph is None:
                    current_paragraph = ChangeLogParagraph(line)
                else:
                    current_paragraph.add_line(line)

        if len(self.changelog_entries) < 1:
            raise EmptyChangeLogFile(self.filename)

    def save(self):
        """Save ChangeLog entries to file"""
        with open(self.filename, 'w') as fobj:
            [fobj.write(str(changelog_entry)) for changelog_entry in
             self.changelog_entries]

    def versions(self):
        """Return a list of all versions in the ChangeLog file

        When called on a Dam style changelog file, it throws an
        exception, since Dam style changelog files do not have a
        version.

        """

        if len(self.changelog_entries) < 1:
            return None

        if self.changelog_entries[0].header.style == ChangeLogHeader.STYLE_DAM:
            raise OperationNotApplicable(ChangeLogHeader.STYLE_DAM)

        return [changelog_entry.header.version for changelog_entry in
                self.changelog_entries]

    def count_entries(self):
        """Return the number of ChangeLog entries"""
        return len(self.changelog_entries)

    def get_package(self):
        """Get the package name

        Package name is returned as defined by the first entry of the
        ChangeLog file.

        None is returned if changelog file is empty.

        """
        if len(self.changelog_entries) < 1:
            return None

        return self.changelog_entries[0].header.package

    def add_entry(self, *args):
        """Add an entry to the ChangeLog.

        If only one argument is passed, it has to be a
        ChangeLogEntry. Else three arguments are expected, which have
        to be of the type ChangeLogHeader, ChangeLogParagraphs list,
        ChangeLogFooter

        """
        if len(args) == 1:
            assert args[0] is ChangeLogEntry
            changelog_entry = args[0]
        else:
            assert len(args) == 3
            assert isinstance(args[0], ChangeLogHeader)
            assert isinstance(args[1], list)
            assert isinstance(args[2], ChangeLogFooter)
            changelog_entry = ChangeLogEntry(args[0], args[1],
                                             args[2])

        self.changelog_entries.insert(0, changelog_entry)

    def create(self, *args):
        """Create a new ChangeLog file

        Create a new ChangeLog file. Any existing entries will be
        overwritten.

        """

        self.changelog_entries = list()
        self.add_entry(*args)
        self.save()

    def update_latest(self, paragraph):
        """Add paragraph(s) to the latest entry.

        :param paragraph: can be a list of ChangeLogParagraph or a
        single ChangeLogParagrph.

        """

        assert len(self.changelog_entries) > 0

        if paragraph is list:
            [self.changelog_entries[0].add_paragraph(par, True) for par
             in paragraph]
        else:
            self.changelog_entries[0].add_paragraph(paragraph, True)

        self.refresh_timestamps()

    def refresh_timestamps(self):
        """Set the timestamps of the latest entry to the current date

        Timestamps of ChangeLogHeader and ChangeLogFooter will updated.
        """
        assert len(self.changelog_entries) > 0

        self.changelog_entries[0].header.refresh_timestamp()
        self.changelog_entries[0].footer.refresh_timestamp()

    def svn_add(self):
        """Add file to svn repository

        Has only effect if the directory is a subversion working
        directory.  Else it does nothing.

        """
        # First of all, make sure the file exists
        if not os.path.exists(self.filename):
            raise OSError((errno.ENOENT, 'No such file: %s' % self.filename))

        svnclient = pysvn.Client()

        # see if directory holding the file is part of a SVN
        # repo
        directory = os.path.dirname(self.filename)
        if directory == "":
            directory = './'

        try:
            svnentry = svnclient.info(directory)
        except pysvn.ClientError:
            # Most likely it is not a working directory, so we bail out
            logging.warning("'%s' is not a SVN working directory. Cannot add file '%s'.",
                            directory, self.filename)
            return

        svnclient.add([self.filename])
        logging.info("Scheduled file '%s' for addition to SVN repository", self.filename)

    def in_svn(self):
        """Checks if ChangeLogFile is added to SVN working directory.

        :returns: True if the file is added to the SVN working
        directory, else False.

        """
        # First of all, make sure the file exists
        if not os.path.exists(self.filename):
            raise OSError((errno.ENOENT, 'No such file: %s' % self.filename))

        svnclient = pysvn.Client()

        try:
            svnclient.info(self.filename)
        except pysvn.ClientError:
            return False

        return True

    def changelog_style(self):
        """Get the type of the changelog

        Determines the style of the changelog by reading the first line.
        """
        with open(self.filename, "r") as changelog_file:
            # read and parse the first line
            changelog_header = ChangeLogHeader(line=changelog_file.readline())
            return changelog_header.style


def new_changelog_entry(filename, version, log):
    """Create a new change log entry

    :param version: has to be None for Dam style log files.
    """
    changelog_file = ChangeLogFile(filename)

    style = changelog_file.changelog_style()
    if style == ChangeLogHeader.STYLE_DAM and\
       version is not None:
        logging.error("Changelog '%s' is Dam style. Version does not apply.",
                      filename)
        sys.exit(1)

    changelog_file.read()

    # get the package from the first entry
    package = changelog_file.get_package()

    header = ChangeLogHeader(package=package,
                             version=version)

    paragraphs = [ChangeLogParagraph(log_para) for log_para in log]
    footer = ChangeLogFooter()

    changelog_file.add_entry(header, paragraphs, footer)
    changelog_file.save()

def update_changelog_entry(filename, log):

    changelog_file = ChangeLogFile(filename)
    changelog_file.read()

    [changelog_file.update_latest(ChangeLogParagraph(l)) for l in
     log]
    changelog_file.save()

def refresh_changelog(filename):
    changelog_file = ChangeLogFile(filename)
    changelog_file.read()

    changelog_file.refresh_timestamps()

    changelog_file.save()

def create_changelog_file(filename, style, overwrite, package,
                          version, log, register_svn):
    # For Dam style changelogs, version is expected to be None
    header = ChangeLogHeader(package=package, version=version)

    footer = ChangeLogFooter()
    paragraphs = [ChangeLogParagraph(log_para) for log_para in log]

    if os.path.exists(filename) and not overwrite:
        logging.error("Changelog '%s' already exists. Won't overwrite", filename)
        sys.exit(1)

    changelog_file = ChangeLogFile(filename)
    changelog_file.create(header, paragraphs, footer)

    if (register_svn):
        changelog_file.svn_add()

def get_version(logfile, allversions):
    changelog_file = ChangeLogFile(logfile)
    changelog_file.read()

    if allversions:
        print("\n".join(changelog_file.versions()))
    else:
        print(changelog_file.versions()[0])

def compile_log(logitem, logparagraph):
    """Create a list of strings having the proper format to be fed to
    ChangeLogParagraph

    Returns a list of strings that is suitable for feeding to
    ChangeLogParagraph, i.e. item style entries are prepended with ' *
    ' and plain paragraphs are left alone.

    """
    log = list()
    if logitem:
        log = ['  * ' + item for item in logitem]

    if logparagraph:
        log.extend(logparagraph)

    return log

def cond_complain_svn(filename, complain):
    """Conditionally complain about changelog file not registered in SVN

    :param complain: if True and file is not in SVN, complain. Else do nothing.
    """
    changelogfile = ChangeLogFile(filename)
    if complain and not changelogfile.in_svn():
        logging.warn("%s is not under version control", changelogfile.filename)

def cmdline_parse():
    """Parse the command line

    Returns the result of argparse.parse_args()

    """
    parser = argparse.ArgumentParser(description="Maintain OpenCSW changelogs")
    parser.add_argument('--logfile',
                        help='filename to use (default: %(default)s)',
                        default='files/changelog.CSW')
    parser.add_argument('--no-svn', help="Do not add file to SVN repository or stop complaining about file not registerd with svn.",
                        default=False, action='store_const',
                        const=True)
    subparser = parser.add_subparsers(help='sub-command help',
                                      dest='command')

    parser_create = subparser.add_parser('create',
                                         help="create new changelog.CSW file. Existing file will not be overwritten.")
    group = parser_create.add_mutually_exclusive_group()
    group.add_argument('--deb-style',
                       help='Create a Debian style changelog',
                       action='store_const',
                       dest='style', const=ChangeLogHeader.STYLE_DEB)
    group.add_argument('--dam-style',
                       help='Create a Dam style changelog',
                       action='store_const',
                       dest='style', const=ChangeLogHeader.STYLE_DAM)
    parser_create.add_argument('package',
                               help="package name")
    parser_create.add_argument('--version',
                               help="package version (only required for Debian style changelog)",
                               required=False)
    parser_create.add_argument('--log-item',
                               help="the log entry to be recorded as list item",
                               nargs='*')
    parser_create.add_argument('--log-paragraph',
                               help="the log entry to be recorded as plain paragraph",
                               nargs='*')
    parser_create.add_argument('--force', help="force creation of file. Overwrite existing file.",
                               default=False, action='store_const',
                               const=True)

    parser_new = subparser.add_parser('new',
                                      help="new changelog entry")
    parser_new.add_argument('--version',
                            help="new version (only required for Debian style changelog)",
                            required=False)
    parser_new.add_argument('--log-item',
                               help="the log entry to be recorded as list item",
                               nargs='*')
    parser_new.add_argument('--log-paragraph',
                               help="the log entry to be recorded as plain paragraph",
                               nargs='*')


    parser_update = subparser.add_parser('update',
                                         help="update the latest changelog entry")
    parser_update.add_argument('--log-item',
                               help="the log entry to be recorded as list item",
                               nargs='*')
    parser_update.add_argument('--log-paragraph',
                               help="the log entry to be recorded as plain paragraph",
                               nargs='*')

    parser_refresh = subparser.add_parser('refresh',
                                          help="refresh header and footer of latest changelog entry")

    parser_version = subparser.add_parser('version',
                                          help="retrieve version from changelog.CSW")
    parser_version.add_argument('--all-versions',
                                help="retrieve all versions",
                                default=False, action='store_const',
                                const=True)

    parser_useage = subparser.add_parser('usage', help="Show a brief usage description")

    parser_test = subparser.add_parser('test', help="test cswch")

    arguments = parser.parse_args()

    if arguments.command in ['create', 'new', 'update'] and\
       arguments.log_paragraph is None and\
       arguments.log_item is None:
        parser.error("'%s' requires either '--log-paragraph' or '--log-item'" % arguments.command)

    if arguments.command == "create" and\
       arguments.style == ChangeLogHeader.STYLE_DAM and\
       arguments.version is not None:
        parser.error("Dam style changelogs do not take version")

    return arguments


if __name__ == '__main__':
    arguments = cmdline_parse()

    if arguments.command == "test":
        # cheat unittest into thinking that there are no command line
        # arguments.
        del sys.argv[1:]
        unittest.main()

    if arguments.command == "usage":
        print(__doc__)
        exit(0)

    try:
        if arguments.command == "create":
            log = compile_log(arguments.log_item, arguments.log_paragraph)
            create_changelog_file(arguments.logfile,
                                  arguments.style,
                                  arguments.force,
                                  arguments.package,
                                  arguments.version,
                                  log,
                                  not arguments.no_svn)

        if arguments.command == "new":
            log = compile_log(arguments.log_item, arguments.log_paragraph)
            new_changelog_entry(arguments.logfile,
                                arguments.version,
                                log)
            cond_complain_svn(arguments.logfile, not arguments.no_svn)

        if arguments.command == "update":
            log = compile_log(arguments.log_item, arguments.log_paragraph)
            update_changelog_entry(arguments.logfile, log)
            cond_complain_svn(arguments.logfile, not arguments.no_svn)

        if arguments.command == "refresh":
            refresh_changelog(arguments.logfile)
            cond_complain_svn(arguments.logfile, not arguments.no_svn)

        if arguments.command == "version":
            get_version(arguments.logfile, arguments.all_versions)
    except ChangeLogException as ex:
        logging.error(str(ex))
