Source code for osctiny.utils.changelog

# -*- coding: utf-8 -*-
"""
Changelog
^^^^^^^^^

Parser and generator for SUSE/OpenBuildService style changelog files.

This parser/generator is a reference implementation of the ``vc`` bash script
from the openSUSE:Tools ``build`` package. See:
`<https://build.opensuse.org/package/show/openSUSE:Tools/build>`_

.. versionadded:: 0.1.11
"""
from datetime import datetime
from io import TextIOBase
import re
import warnings

from dateutil.parser import parse
from pytz import _UTC


[docs]def is_aware(timestamp): """ Check whether timestamp is timezone aware :param timestamp: :py:class:`datetime.datetime` :return: ``True``, if timestamp is tz-aware """ return timestamp.tzinfo is not None \ and timestamp.tzinfo.utcoffset(timestamp) is not None
[docs]class Entry: """ Representation of a complete changelog entry .. py:attribute:: timestamp Timestamp of the entry as a :py:class:`datetime.datetime` object. If no value is provided during initialization, :py:func:`datetime.datetime.now` is used instead. .. py:attribute:: packager Email and name of the packager. Valid formats are: * ``email@example.com`` * ``<email@example.com>`` * ``full name <email@example.com>`` .. py:attribute:: content All lines until the beginning of the next entry; except empty lines at the beginning and end """ timestamp = None packager = None content = "" default_tz = _UTC() def __init__(self, timestamp=None, packager=None, content=""): if not isinstance(timestamp, datetime) and timestamp is not None: raise TypeError("`timestamp` needs to be a datetime object!") if timestamp and not is_aware(timestamp): raise ValueError("`timestamp` is not timezone-aware!") self.timestamp = timestamp or self.now() self.packager = packager self.content = content def __bool__(self): return bool(self.timestamp and self.packager and self.content) def __len__(self): return 1 if self.timestamp and self.packager and self.content else 0
[docs] def now(self): """ Return current UTC timestamp :return: :py:class:`datetime.datetime` """ return datetime.now(tz=self.default_tz)
@property def formatted_timestamp(self): """ Return properly formatted timestamp :return: str """ if not isinstance(self.timestamp, datetime): return self.timestamp return self.timestamp\ .astimezone(self.default_tz)\ .strftime("%a %b %d %H:%M:%S %Z %Y") def __str__(self): return "{sep}\n{self.formatted_timestamp} - {self.packager}\n\n" \ "{self.content}\n\n".format(sep="-" * 67, self=self) def __unicode__(self): return self.__str__()
[docs]class ChangeLog: """ Provider of capabilities to parse and write a ``.changes`` file Parsing and starting a new ``.changes`` file should be intuitive. In order to append or edit entries use this recipe: .. code:: python cl = ChangeLog.parse(path="/path/to/file", generative=False) cl.entries.append(Entry(...)) cl.write() .. py:attribute:: entry_factory :noindex: A class used to store entry data. **Example**: :py:class:`Entry` could be extended to provide a read-only property to find bug references inside the entry's content. """ additional_tzinfos = {} entry_factory = Entry entries = None patterns = { "init": re.compile(r"^-+$"), "header": re.compile( r"^(?P<timestamp>[A-Za-z]{3,} [A-Za-z]{3,} [0-9: ]+ [A-Z]+ \d{4,}) " r"- (?P<packager>.*)\s*$") } def __init__(self): self.entries = [] def _parse(self, handle): """ Actual method for parsing. .. important:: Please do not call this method directly. Use :py:meth:`parse` instead. :param handle: An open and iterable (file) handle :type handle: Any derived object of :py:class:`io.IOBase` """ # pylint: disable=too-many-branches,consider-using-with entry = self.entry_factory() if isinstance(handle, TextIOBase): handle.seek(0) elif isinstance(handle, (str, bytes)): handle = open(handle, "r") # pylint: disable=consider-using-with else: raise TypeError("Unexpected type for 'path': {}".format( type(handle))) try: for line in handle: match = self.patterns["init"].match(line) if match: if entry: # We are at the beginning of a new entry. Time to emit # the finished one. yield entry entry = self.entry_factory() continue match = self.patterns["header"].match(line) if match: try: entry.timestamp = parse(match.group("timestamp"), ignoretz=False, tzinfos=self.additional_tzinfos) except ValueError as error: warnings.warn( "Cannot parse changelog entry's timestamp: " "'{}'".format(error)) entry.timestamp = match.group("timestamp") else: # Assuming UTC may not be correct, but beats dealing # with a mix of tz-aware and naive datetime objects if not is_aware(entry.timestamp): entry.timestamp = entry.default_tz.localize( parse(match.group("timestamp"), ignoretz=True,) ) entry.packager = match.group("packager") continue if not line.strip(): continue if entry.content: entry.content += "\n" entry.content += line.rstrip() if entry: # The last entry of the file is emitted explicitly yield entry finally: handle.close()
[docs] @classmethod def parse(cls, path, generative=True): """ Parse a changes file The changes file to parse may be specified by it's path or as an already open file handle (aka. subclass of :py:class:`io.TextIOBase`). Use this method to initialize a new instance of :py:class:`ChangeLog` like this: .. code:: python cl = ChangeLog.parse(path="/path/to/file") for entry in cl.entries: print(entry) :param path: If a path is supplied as a string, the file will be opened for reading. If a subclass of :py:class:`io.TextIOBase` is provided, this method assumes that it is opened for reading. :type path: str or open file/stream handle :param bool generative: If set to ``True`` (default), changelog entries are parsed on the fly using a generator. Otherwise all entries are parsed immediately and stored in an iterable. :return: An instance of ``ChangeLog`` :raises TypeError: if ``path`` is not a string or a subclass of :py:class:`io.TextIOBase` """ new = cls() # pylint: disable=protected-access if generative: new.entries = new._parse(path) else: new.entries = list(new._parse(path)) return new
[docs] def write(self, path): """ Write entries to file/stream This method can write to files and any other stream derived from :py:class:`io.IOBase`. :param path: Path to file or open, writable handle :type path: str or :py:class:`io.IOBase` """ def _wrapped(handle): for entry in sorted(self.entries, key=lambda x: x.timestamp, reverse=True): handle.write(str(entry)) if isinstance(path, TextIOBase): _wrapped(path) elif isinstance(path, (str, bytes)): with open(path, "w") as handle: _wrapped(handle) else: raise TypeError("Unexpected type for 'path': {}".format(type(path)))