#! /usr/bin/env python
# vim: set fileencoding=utf-8:
"""
The toplevel module for the pychangelog package.
"""
from docit import *
import re
import collections
import types
import datetime
import threading
import abc
#Rel 3 - v1.0.0.2 - 2014-05-18
release_header_re = re.compile(r'^rel\s+(?P<rel>[0-9]+)\s+(?:-\s+)?v(?P<vers>[\.0-9]+)\s+(?:-\s+)(?P<year>[0-9]{4})-(?P<month>[0-9]{1,2})-(?P<day>[0-9]{1,2})\s*$', re.I)
prerelease_header_re = re.compile(r'^pre[ -]?rel(?:ease)?\s+(?P<rel>[0-9]+)(?:\s*$|\s+(?:-\s+)?v(?:(?P<vers>[\.0-9]+)|[\.0-9\?]+)\s+(?:-\s+)(?:(?P<year>[0-9]{4})|\?\?\?\?)-(?:(?P<month>[0-9]{1,2}|\?\?))-(?:(?P<day>[0-9]{1,2})|\?\?)\s*$)', re.I)
[docs]class ChangeLog(collections.Sequence):
"""
A ``ChangeLog`` object is simply a sequence of `ReleaseInfo` objects, in order form oldest to newest.
There are rules used to validate the change log, for instance that each release must be numerically
next after the previous, version numbers must increase correctly, full-releases cannot follow pre-releases,
and dates must increase correctly.
"""
[docs] def __init__(self, *releases):
"""
Pass in the `ReleaseInfo` objects from oldest to newest.
"""
self.__releases = []
self.__last_release = None
self.__lock = threading.Lock()
for rel in releases:
self.append(rel)
[docs] def last_release(self):
"""
Returns the **index** in the sequence of the last **full** release (i.e.,
not a `~ReleaseInfo.pre_release`).
Returns ``None`` if no full releases are mentioned in the change log.
.. seealso::
`get_last_release` to get the actul corresponding `ReleaseInfo` object.
"""
return self.__last_release
[docs] def get_last_release(self):
"""
Returns the `ReleaseIngo` object for the last **full** release in the log
(i.e., not a `~ReleaseInfo.pre_release`).
Returns ``None`` if no full releases are mentioned in the change log.
.. seealso::
`last_release` to get just the index of the last release.
"""
if self.__last_release is None:
return None
return self.__releases[self.__last_release]
@docit
[docs] def __len__(self):
"""
Returns the number of releases in the change log.
"""
return len(self.__releases)
@docit
[docs] def __getitem__(self, idx):
"""
Get the `ReleaseInfo` object at the specified index, where 0 is the oldest.
"""
return self.__releases[idx]
[docs] def append(self, release):
"""
Add a new `ReleaseInfo` object to the end (top) of the change log. This should be
a release after the most recent.
"""
if len(self.__releases):
last = self.__releases[-1]
if release.release_num != last.release_num + 1:
raise ValueError("Releases are out of order. Expected %d next, found %d." % (last.release_num + 1, release.release_num))
if last.pre_release and not release.pre_release:
raise ValueError("Release %d cannot follow pre-release %d." % (release.release_num, last.release_num))
if last.date is None and release.date is None:
if release.date < last.date:
raise ValueError("Release %d cannot come before release %d." % (release.release_num, last.release_num))
if last.version is not None and release.version is not None:
vc_last = len(last.version)
vc_next = len(release.version)
vc = max(vc_last, vc_next)
for i in xrange(vc):
vl = last.version[i] if (i < vc_last) else 0
vn = release.version[i] if (i < vc_next) else 0
if vn > vl:
break
elif vn < vl:
raise ValueError("Version number decreases releative to previous release: %s < %s" % (
".".join(str(v) for f in release.version),
".".join(str(v) for f in last.version),
))
with self.__lock:
idx = len(self.__releases)
self.__releases.append(release)
if not release.pre_release:
self.__last_release = idx
[docs]class ReleaseInfo(collections.Sequence):
"""
Encapsulates information about a single release, usually a member of a `ChangeLog`.
A `ReleaseInfo` object acts as a `~python:collections.Sequence` over the change-lines it
mentions in the change log (i.e., the entries that describe the changes in the release
from the previous.
Each such line must have a type: usually one of `TYPE_MAJOR`, `TYPE_MINOR`, `TYPE_PATCH`,
or `TYPE_SEMANTIC`, specfiying the scope of the impact on the public interface. However,
for the first release (release #1), each line should instead be simple a `TYPE_STAR`
line, since there is no public interface prior to the first release.
In addition to iterating over all the lines in the release, you can get a `~ReleaseInfo.View`
ojbect which acts as a Sequence over just a particular type of line. One such View is created
during initialization for each of the five types of lines, and you can get a handle to these
View objects using the `major`, `minor`, `patch`, `semantic`, and `starred` properties.
"""
TYPE_STAR = 0
TYPE_MAJOR = 1
TYPE_MINOR = 2
TYPE_PATCH = 3
TYPE_SEMANTIC = 4
[docs] def __init__(self, release_num, version_numbers, year, month, day, pre_release=False, *change_lines):
def parse_int(value, name):
if isinstance(value, int):
return value
try:
return None if value is None else int(value, 10)
except ValueError, e:
raise ValueError("Invalid value for %s: %r" % (name, value,))
self.__release_num = parse_int(release_num, 'release_num')
self.__version = None if version_numbers is None else tuple(parse_int(v, 'version number') for v in version_numbers.split('.'))
self.__year = parse_int(year, 'year')
self.__month = parse_int(month, 'month')
self.__day = parse_int(day, 'day')
self.__pre_release = bool(pre_release)
if not self.__pre_release:
if any(x is None for x in (self.__version, self.__year, self.__month, self.__day)):
raise ValueError("Release info values can only be None for pre-releases.")
self.__date = None
date_components = (self.__year, self.__month, self.__day)
if all(x is not None for x in date_components):
self.__date = datetime.date(*date_components)
self.__majors = []
self.__minors = []
self.__patches = []
self.__semantics = []
self.__starred = []
self.__major_view = ReleaseInfo.View(self, self.major_count, self.get_major)
self.__minor_view = ReleaseInfo.View(self, self.minor_count, self.get_minor)
self.__patch_view = ReleaseInfo.View(self, self.patch_count, self.get_patch)
self.__semantic_view = ReleaseInfo.View(self, self.semantic_count, self.get_semantic)
self.__starred_view = ReleaseInfo.View(self, self.starred_count, self.get_starred)
self.__lock = threading.Lock()
self.__change_lines = []
for line in change_lines:
self.append(line)
[docs] def __str__(self):
s = 'r%d' % self.release_num
if self.pre_release:
s += '*'
vers = self.version
if vers is not None:
s += '-' + '.'.join(str(v) for v in self.version)
date = self.date
if date is not None:
s += ' (%s)' % date.strftime('%x')
return s
[docs] def __repr__(self):
return '<%s %s>' % (type(self).__name__, self.__str__(),)
[docs] class View(collections.Sequence):
[docs] def __init__(self, obj, length, getitem):
self.__obj = obj
self.__length = length
self.__getitem = getitem
[docs] def __len__(self):
return self.__length.__func__(self.__obj)
[docs] def __getitem__(self, idx):
return self.__getitem.__func__(self.__obj, idx)
@property
[docs] def major(self):
return self.__major_view
@property
[docs] def minor(self):
return self.__minor_view
@property
[docs] def patch(self):
return self.__patch_view
@property
[docs] def semantic(self):
return self.__semantic_view
@property
[docs] def starred(self):
return self.__starred_view
[docs] def major_count(self):
return len(self.__majors)
[docs] def get_major(self, idx):
return self.__change_lines[self.__majors[idx]]
[docs] def minor_count(self):
return len(self.__minors)
[docs] def get_minor(self, idx):
return self.__change_lines[self.__minors[idx]]
[docs] def patch_count(self):
return len(self.__patches)
[docs] def get_patch(self, idx):
return self.__change_lines[self.__patches[idx]]
[docs] def semantic_count(self):
return len(self.__semantics)
[docs] def get_semantic(self, idx):
return self.__change_lines[self.__semantics[idx]]
[docs] def starred_count(self):
return len(self.__starred)
[docs] def get_starred(self, idx):
return self.__change_lines[self.__starred[idx]]
@property
[docs] def pre_release(self):
return self.__pre_release
@property
def release_num(self):
return self.__release_num
@property
[docs] def version(self):
return self.__version
@property
[docs] def year(self):
return self.__year
@property
[docs] def month(self):
return self.__month
@property
[docs] def day(self):
return self.__day
@property
[docs] def date(self):
return self.__date
@property
[docs] def release_num(self):
return self.__release_num
[docs] def __len__(self):
return len(self.__change_lines)
[docs] def __getitem__(self, idx):
return self.__change_lines[idx]
[docs] def iter(self):
return iter(self.__change_lines)
[docs] def append(self, line):
ltype, pline = self.parse_line(line)
if ltype == self.TYPE_STAR:
if self.release_num != 1:
raise ValueError("Only the first release (release #1) can have starred changeline. All others must be properly qualified.")
seq = self.__starred
else:
if self.release_num == 1:
raise ValueError("First release (release #1) should have only starred changelines.")
if ltype == self.TYPE_MAJOR:
seq = self.__majors
elif ltype == self.TYPE_MINOR:
seq = self.__minors
elif ltype == self.TYPE_PATCH:
seq = self.__patches
elif ltype == self.TYPE_SEMANTIC:
seq = self.__semantics
else:
raise Exception("Unhandled line type: %r" % (ltype,))
with self.__lock:
idx = len(self.__change_lines)
self.__change_lines.append(line)
seq.append(idx)
@classmethod
[docs] def parse_line(cls, line):
if not isinstance(line, types.StringTypes):
raise TypeError("Lines must be string types: %r" % line)
if line.startswith('*'):
line = line[1:].lstrip()
return cls.TYPE_STAR, line
if line.startswith('['):
line = line[1:].lstrip()
ltype = None
if line[0] == 'M':
ltype = cls.TYPE_MAJOR
elif line[0] == 'n':
ltype = cls.TYPE_MINOR
elif line[0] == 'p':
ltype = cls.TYPE_PATCH
elif line[0] == 's':
ltype = cls.TYPE_SEMANTIC
elif line[0] == '*':
ltype = cls.TYPE_STAR
if ltype is not None:
line = line[1:].lstrip()
if line.startswith(']'):
line = line[1:].lstrip()
return ltype, line
raise SyntaxError("No qualifying bullet at start of change line: %r" % line)
[docs]def parse_plain_text(istream):
"""
Parses a change log in plain-text format, with newst release at the beginning,
and returns a `ChangeLog` object.
"""
releases = []
state = 0
change_line = None
indent_1 = None
indent_2 = None
linenum = 0
for line in istream:
linenum += 1
if state == 0:
if len(line):
if not line[0].isspace():
mobj = release_header_re.match(line)
if mobj is not None:
release_info = ReleaseInfo(
release_num = mobj.group('rel'),
version_numbers = mobj.group('vers'),
year = mobj.group('year'),
month = mobj.group('month'),
day = mobj.group('day'),
)
change_line = None
indent_1 = None
indent_2 = None
state = 1
else:
mobj = prerelease_header_re.match(line)
if mobj is not None:
release_info = ReleaseInfo(
release_num = mobj.group('rel'),
version_numbers = mobj.group('vers'),
year = mobj.group('year'),
month = mobj.group('month'),
day = mobj.group('day'),
pre_release = True,
)
change_line = None
indent_1 = None
indent_2 = None
state = 1
else:
raise SyntaxError("Invalid release header on line %d: %r" % (linenum, line))
elif len(line.strip()):
raise SyntaxError("Invalid syntax on line %d. Expected a release header line: %r" % (linenum, line))
elif state == 1:
oline = line
line = line.strip()
if len(line) == 0:
if change_line is not None:
release_info.append(change_line)
change_line = None
releases.append(release_info)
release_info = None
change_lines = []
state = 0
else:
indent_length = len(oline) - len(line)
indent = oline[:indent_length-1]
if indent_1 is None:
#First line
indent_1 = indent
change_line = line
elif indent == indent_1:
#Next line.
release_info.append(change_line)
change_line = line
else:
#Not the first-level indent.
if indent_2 is None:
#This should be the second level indent.
if not indent.startswith(indent_1):
raise SyntaxError("Invalid indent on line %d. Expected a second level indent beyond the first level indent." % (linenum,))
indent_2 = indent
change_line += line
elif indent == indent_2:
#Line continued
change_line += line
else:
#Invalid indent
raise SyntaxError("Invalid indent on line %d. Expected a first or second level indent." % (linenum,))
if state == 1:
if change_line is not None:
release_info.append(change_line)
change_line = None
releases.append(release_info)
release_info = None
state = 0
return ChangeLog(*reversed(releases))