calc/changelog.py

Summary

Maintainability
A
0 mins
Test Coverage
A
98%
import re
import datetime
import pathlib
from typing import Match
from django.conf import settings
from django.shortcuts import render
from django.utils.safestring import mark_safe
import markdown


CHANGELOG = 'CHANGELOG.md'

PATH = pathlib.Path(__file__).resolve().parent.parent / CHANGELOG

ENCODING = 'utf-8'


class FriendlyRegex:
    '''
    Like a regular expression, but raises a friendly error on
    failure.
    '''

    def __init__(self, name: str, pattern: str) -> None:
        self.name = name
        self.regex = re.compile(pattern, re.MULTILINE)

    def search(self, contents: str, filename=CHANGELOG) -> Match:
        match = self.regex.search(contents)
        if match is None:
            raise ValueError(f"{self.name} not found in {filename}!")
        return match


RELEASE_HEADER_RE = FriendlyRegex(
    'Release header line (e.g. "## 1.2.3 - 2017-01-01")',
    r'^## \[?([0-9.]+)[ \]]?'
)

UNRELEASED_LINK_RE = FriendlyRegex(
    'Unreleased link (e.g. "[unreleased]: http://compare/v1.0.0...HEAD")',
    r'^\[unreleased\]: (.+\.\.\.HEAD)$',
)

UNRELEASED_HEADER = '## [Unreleased][unreleased]'

GH_ISSUE_RE = re.compile(r'(?P<issue_link>#(?P<issue_id>\d+))')
GH_ISSUE_LINK = f'{settings.BASE_GITHUB_URL}/issues/'


def django_view(request):
    contents = strip_preamble(get_contents())
    contents = link_github_issues(contents)
    html = mark_safe(markdown.markdown(contents))  # nosec
    return render(request, 'changelog.html', dict(html=html))


def get_contents():
    '''
    Return the contents of CHANGELOG.md.
    '''

    with PATH.open('r', encoding=ENCODING) as f:
        return f.read()


def link_github_issues(contents):
    '''
    Replaces all matches of GitHub-like issue/PR numbers (starting with '#')
    with Markdown links to issues/PRs in CALC's GitHub repository.

    Examples:

    >>> link_github_issues('abcdef #123 hijklm')
    'abcdef [#123](https://github.com/18F/calc/issues/123) hijklm'

    >>> link_github_issues('abc #12 hij #34') # doctest: +NORMALIZE_WHITESPACE
    'abc [#12](https://github.com/18F/calc/issues/12)
     hij [#34](https://github.com/18F/calc/issues/34)'
    '''
    return GH_ISSUE_RE.sub(
        f'[\g<issue_link>]({GH_ISSUE_LINK}\g<issue_id>)', contents)


def get_unreleased_link(contents):
    '''
    Returns the URL that the "Unreleased" section is hyperlinked to, e.g.:

        >>> get_unreleased_link('[unreleased]: http://compare/v1.0.0...HEAD')
        'http://compare/v1.0.0...HEAD'
    '''

    return UNRELEASED_LINK_RE.search(contents).group(1)


def get_latest_release(contents):
    '''
    Return the latest release of the given changelog, e.g.:

        >>> get_latest_release('## 1.2.3 - 2017-01-01')
        '1.2.3'
    '''

    return RELEASE_HEADER_RE.search(contents).group(1)


def strip_preamble(contents):
    '''
    Removes any expository text before the beginning of the
    "Unreleased" section, if it's non-empty.  If the "Unreleased" section
    is empty, it is removed as well.
    '''

    if get_unreleased_notes(contents).strip():
        result = contents[contents.index(UNRELEASED_HEADER):]
    else:
        result = contents[RELEASE_HEADER_RE.search(contents).start():]
    return result


def bump_version(contents, new_version, date=None):
    '''
    Move the "Unreleased" section of the given changelog to a new release
    with the given version number.

    The new release will be hyperlinked to a GitHub diff between it and the
    previous release, while the "Unreleased" section will be emptied and
    hyperlinked to a GitHub diff between the new release and HEAD.

    Return the contents of the new changelog.
    '''

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

    unreleased_diff_link = get_unreleased_link(contents)
    old_version = get_latest_release(contents)
    new_version_header = '## [{}][] - {}'.format(new_version, date)
    new_unreleased_diff_link = unreleased_diff_link.replace(
        'v' + old_version,
        'v' + new_version)
    new_version_diff_link = '[{}]: {}'.format(
        new_version,
        unreleased_diff_link.replace('HEAD', 'v' + new_version))

    contents = contents.replace(
        UNRELEASED_HEADER,
        UNRELEASED_HEADER + '\n\n' + new_version_header)

    contents = contents.replace(
        unreleased_diff_link,
        new_unreleased_diff_link + '\n' + new_version_diff_link)

    return contents


def replace_heading_leaders(contents, char='/'):
    '''
    Replace leading Markdown heading characters ('#') with a different
    character so they're not interpreted as comments in git
    log files (e.g. tag messages).
    '''

    def hashrepl(matchobj):
        return char * len(matchobj.group(0))

    return re.sub(r'^(\#+)', hashrepl, contents, 0, re.MULTILINE)


def get_unreleased_notes(contents):
    '''
    Return the content of the "Unreleased" section of the given changelog.
    '''

    start = contents.index(UNRELEASED_HEADER) + len(UNRELEASED_HEADER) + 1
    end = RELEASE_HEADER_RE.search(contents).start() - 1

    return contents[start:end]