mirror of
https://github.com/vale981/releases
synced 2025-03-04 17:21:43 -05:00
Merge branch 'master' into 61-int
This commit is contained in:
commit
3e9710e587
19 changed files with 470 additions and 120 deletions
|
@ -1,14 +1,19 @@
|
|||
language: python
|
||||
python:
|
||||
- "2.6"
|
||||
- "2.7"
|
||||
- "3.3"
|
||||
- "3.4"
|
||||
- "3.5"
|
||||
- "3.6"
|
||||
- "pypy"
|
||||
#- "pypy3" # Looks like Sphinx (as of 1.4.1) is not pypy3 compat
|
||||
env:
|
||||
- SPHINX=">=1.3,<1.4"
|
||||
- SPHINX=">=1.4,<1.5"
|
||||
- SPHINX=">=1.5,<1.6"
|
||||
- SPHINX=">=1.6,<1.7"
|
||||
install:
|
||||
- pip install -r dev-requirements.txt
|
||||
- pip install "sphinx$SPHINX"
|
||||
script:
|
||||
# Primary test suite
|
||||
- inv test
|
||||
|
|
2
LICENSE
2
LICENSE
|
@ -1,4 +1,4 @@
|
|||
Copyright (c) 2016, Jeff Forcier
|
||||
Copyright (c) 2018, Jeff Forcier
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
|
|
11
README.rst
11
README.rst
|
@ -4,9 +4,9 @@
|
|||
What is Releases?
|
||||
=================
|
||||
|
||||
Releases is a Python 2+3 compatible `Sphinx <http://sphinx-doc.org>`_ extension
|
||||
designed to help you keep a source control friendly, merge friendly changelog
|
||||
file & turn it into useful, human readable HTML output.
|
||||
Releases is a Python (2.7, 3.4+) compatible `Sphinx <http://sphinx-doc.org>`_
|
||||
(1.3+) extension designed to help you keep a source control friendly, merge
|
||||
friendly changelog file & turn it into useful, human readable HTML output.
|
||||
|
||||
Specifically:
|
||||
|
||||
|
@ -26,6 +26,5 @@ Some background on why this tool was created can be found in `this blog post
|
|||
For more documentation, please see http://releases.readthedocs.io.
|
||||
|
||||
.. note::
|
||||
You can install the `development version
|
||||
<https://github.com/bitprophet/releases/tarball/master#egg=releases-dev>`_
|
||||
via ``pip install releases==dev``.
|
||||
You can install the development version via ``pip install -e
|
||||
git+https://github.com/bitprophet/releases/#egg=releases``.
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
# Task runner
|
||||
invoke>=0.6.0,<2.0
|
||||
invocations>=0.4.1,<2.0
|
||||
invocations>=0.14,<2.0
|
||||
# Tests (N.B. integration suite also uses Invoke as above)
|
||||
spec>=0.11.3,<2.0
|
||||
mock==1.0.1
|
||||
|
@ -8,8 +8,7 @@ mock==1.0.1
|
|||
six>=1.4.1,<2.0
|
||||
# Docs
|
||||
-e .
|
||||
sphinx>=1.1,<2.0
|
||||
sphinx_rtd_theme>=0.1.5,<2.0
|
||||
# Builds
|
||||
wheel==0.24
|
||||
twine==1.5
|
||||
twine==1.11.0
|
||||
|
|
|
@ -4,6 +4,37 @@ Changelog
|
|||
|
||||
* :feature:`59` Allow multiple changelog files. Thanks to William Minchin for
|
||||
the patch.
|
||||
* :release:`1.4.2 <2018-04-27>`
|
||||
* :support:`74` We never pulled our README into our ``setup.py`` metadata,
|
||||
resulting in a rather sparse PyPI page! This has been fixed. Thanks to Peter
|
||||
Demin for the report.
|
||||
* :release:`1.4.1 <2018-03-28>`
|
||||
* :support:`73` Sphinx 1.7.x changed some semi-public APIs; given this is the
|
||||
second minor release in a row to do so, we're explicitly bracketing our
|
||||
``setup.py`` dependencies to Sphinx >= 1.3 and < 1.7. We expect to bump this
|
||||
up one minor release at a time as we add compatibility back in.
|
||||
* :release:`1.4.0 <2017-10-20>`
|
||||
* :support:`-` Drop Python 2.6 and 3.3 support, to correspond with earlier
|
||||
changes in Sphinx and most other public Python projects.
|
||||
* :bug:`- major` Identified a handful of issues with our Sphinx pin &
|
||||
subsequently, internal changes in Sphinx 1.6 which broke (and/or appear to
|
||||
break, such as noisy warnings) our own behavior. These have (hopefully) all
|
||||
been fixed.
|
||||
* :release:`1.3.2 <2017-10-19>`
|
||||
* :support:`68 backported` Update packaging requirements to allow for
|
||||
``sphinx>=1.3,<2``. Thanks to William Minchin.
|
||||
* :release:`1.3.1 <2017-05-18>`
|
||||
* :bug:`60` Report extension version to Sphinx for improved Sphinx debug
|
||||
output. Credit: William Minchin.
|
||||
* :bug:`66` (via :issue:`67`) Deal with some Sphinx 1.6.1 brokenness causing
|
||||
``AttributeError`` by leveraging ``getattr()``'s default-value argument.
|
||||
Thanks to Ian Cordasco for catch & patch.
|
||||
* :release:`1.3.0 <2016-12-09>`
|
||||
* :feature:`-` Add ``releases.util``, exposing (among other things) a highly
|
||||
useful ``parse_changelog(path)`` function that returns a user-facing dict
|
||||
representing a parsed changelog. Allows users to examine their changelogs
|
||||
programmatically and answer questions like "do I have any outstanding bugs in
|
||||
the 1.1 release line?".
|
||||
* :release:`1.2.1 <2016-07-25>`
|
||||
* :support:`51 backported` Modernize release management so PyPI trove
|
||||
classifiers are more accurate, wheel archives are universal instead of Python
|
||||
|
|
14
integration/_support/unreleased_bugs/changelog.rst
Normal file
14
integration/_support/unreleased_bugs/changelog.rst
Normal file
|
@ -0,0 +1,14 @@
|
|||
=========
|
||||
Changelog
|
||||
=========
|
||||
|
||||
.. Notably, bug #3 hasn't yet been released in the 1.1 line (but has in 1.0).
|
||||
.. This means it should appear in the '1.1' line bucket after parse_changelog.
|
||||
|
||||
* :release:`1.0.2 <2016-10-18>`
|
||||
* :bug:`3` Aw jeez
|
||||
* :release:`1.1.0 <2016-10-17>`
|
||||
* :feature:`2` Woo
|
||||
* :release:`1.0.1 <2014-01-02>`
|
||||
* :bug:`1` Fix a bug.
|
||||
* :release:`1.0.0 <2014-01-01>`
|
6
integration/_support/unreleased_bugs/index.rst
Normal file
6
integration/_support/unreleased_bugs/index.rst
Normal file
|
@ -0,0 +1,6 @@
|
|||
====
|
||||
Test
|
||||
====
|
||||
|
||||
.. toctree::
|
||||
changelog
|
|
@ -16,7 +16,7 @@ class integration(Spec):
|
|||
# Dynamic sphinx opt overrides
|
||||
if opts:
|
||||
pairs = map(lambda x: '='.join(x), (opts or {}).items())
|
||||
flags = map(lambda x: '-D {0}'.format(x), pairs)
|
||||
flags = map(lambda x: '-D {}'.format(x), pairs)
|
||||
flagstr = ' '.join(flags)
|
||||
else:
|
||||
flagstr = ''
|
||||
|
@ -25,8 +25,8 @@ class integration(Spec):
|
|||
build = os.path.join(folder, '_build')
|
||||
try:
|
||||
# Build
|
||||
cmd = 'sphinx-build {2} -c {3} -W {0} {1}'.format(
|
||||
folder, build, flagstr, conf)
|
||||
cmd = 'sphinx-build {} -c {} -W {} {}'.format(
|
||||
flagstr, conf, folder, build)
|
||||
result = run(cmd, warn=True, hide=True)
|
||||
if callable(asserts):
|
||||
asserts(result, build, target)
|
||||
|
@ -40,10 +40,10 @@ class integration(Spec):
|
|||
|
||||
def _basic_asserts(self, result, build, target):
|
||||
# Check for errors
|
||||
msg = "Build failed w/ stderr: {0}"
|
||||
msg = "Build failed w/ stderr: {}"
|
||||
assert result.ok, msg.format(result.stderr)
|
||||
# Check for vaguely correct output
|
||||
changelog = os.path.join(build, '{0}.html'.format(target))
|
||||
changelog = os.path.join(build, '{}.html'.format(target))
|
||||
with open(changelog) as fd:
|
||||
text = fd.read()
|
||||
assert "1.0.1" in text
|
||||
|
|
73
integration/util.py
Normal file
73
integration/util.py
Normal file
|
@ -0,0 +1,73 @@
|
|||
"""
|
||||
Tests for the ``releases.util`` module.
|
||||
|
||||
These are in the integration suite because they deal with on-disk files.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from docutils.nodes import document
|
||||
from spec import Spec, ok_, eq_
|
||||
from sphinx.application import Sphinx
|
||||
|
||||
from releases.models import Release, Issue
|
||||
from releases.util import get_doctree, parse_changelog
|
||||
|
||||
|
||||
support = os.path.join(os.path.dirname(__file__), '_support')
|
||||
vanilla = os.path.join(support, 'vanilla', 'changelog.rst')
|
||||
unreleased_bugs = os.path.join(support, 'unreleased_bugs', 'changelog.rst')
|
||||
|
||||
class get_doctree_(Spec):
|
||||
def obtains_app_and_doctree_from_filepath(self):
|
||||
app, doctree = get_doctree(vanilla)
|
||||
# Expect doctree & app
|
||||
ok_(doctree)
|
||||
ok_(app)
|
||||
ok_(isinstance(doctree, document))
|
||||
ok_(isinstance(app, Sphinx))
|
||||
# Sanity checks of internal nodes, which should be Releases objects
|
||||
entries = doctree[0][2]
|
||||
ok_(isinstance(entries[0][0][0], Release))
|
||||
bug = entries[1][0][0]
|
||||
ok_(isinstance(bug, Issue))
|
||||
eq_(bug.type, 'bug')
|
||||
eq_(bug.number, '1')
|
||||
|
||||
|
||||
class parse_changelog_(Spec):
|
||||
def yields_releases_dict_from_changelog_path(self):
|
||||
changelog = parse_changelog(vanilla)
|
||||
ok_(changelog)
|
||||
ok_(isinstance(changelog, dict))
|
||||
eq_(
|
||||
set(changelog.keys()),
|
||||
{'1.0.0', '1.0.1', '1.0', 'unreleased_1_feature'},
|
||||
)
|
||||
eq_(len(changelog['1.0.0']), 0)
|
||||
eq_(len(changelog['unreleased_1_feature']), 0)
|
||||
eq_(len(changelog['1.0.1']), 1)
|
||||
issue = changelog['1.0.1'][0]
|
||||
eq_(issue.type, 'bug')
|
||||
eq_(issue.number, '1')
|
||||
eq_(changelog['1.0'], []) # emptied into 1.0.1
|
||||
|
||||
def unreleased_bugfixes_accounted_for(self):
|
||||
changelog = parse_changelog(unreleased_bugs)
|
||||
# Basic assertions
|
||||
v101 = changelog['1.0.1']
|
||||
eq_(len(v101), 1)
|
||||
eq_(v101[0].number, '1')
|
||||
v110 = changelog['1.1.0']
|
||||
eq_(len(v110), 1)
|
||||
eq_(v110[0].number, '2')
|
||||
v102 = changelog['1.0.2']
|
||||
eq_(len(v102), 1)
|
||||
eq_(v102[0].number, '3')
|
||||
# The crux of the matter: 1.0 bucket empty, 1.1 bucket still has bug 3
|
||||
line_10 = changelog['1.0']
|
||||
eq_(len(line_10), 0)
|
||||
line_11 = changelog['1.1']
|
||||
eq_(len(line_11), 1)
|
||||
eq_(line_11[0].number, '3')
|
||||
ok_(line_11[0] is v102[0])
|
|
@ -4,10 +4,12 @@ import sys
|
|||
from functools import partial
|
||||
|
||||
from docutils import nodes, utils
|
||||
from docutils.parsers.rst import roles
|
||||
import six
|
||||
|
||||
from .models import Issue, ISSUE_TYPES, Release, Version, Spec
|
||||
from .line_manager import LineManager
|
||||
from ._version import __version__
|
||||
|
||||
|
||||
def _log(txt, config):
|
||||
|
@ -44,7 +46,7 @@ def scan_for_spec(keyword):
|
|||
# First, test for intermediate '1.2+' style
|
||||
matches = release_line_re.findall(keyword)
|
||||
if matches:
|
||||
return Spec(">={0}".format(matches[0]))
|
||||
return Spec(">={}".format(matches[0]))
|
||||
# Failing that, see if Spec can make sense of it
|
||||
try:
|
||||
return Spec(keyword)
|
||||
|
@ -78,7 +80,7 @@ def issues_role(name, rawtext, text, lineno, inliner, options={}, content=[]):
|
|||
# TODO: deal with % vs .format()
|
||||
ref = config.releases_issue_uri % issue_no
|
||||
elif config.releases_github_path:
|
||||
ref = "https://github.com/{0}/issues/{1}".format(
|
||||
ref = "https://github.com/{}/issues/{}".format(
|
||||
config.releases_github_path, issue_no)
|
||||
# Only generate a reference/link if we were able to make a URI
|
||||
if ref:
|
||||
|
@ -109,7 +111,7 @@ def issues_role(name, rawtext, text, lineno, inliner, options={}, content=[]):
|
|||
if part in ('backported', 'major'):
|
||||
keyword = part
|
||||
else:
|
||||
err = "Gave unknown keyword {0!r} for issue no. {1}"
|
||||
err = "Gave unknown keyword {!r} for issue no. {}"
|
||||
raise ValueError(err.format(keyword, issue_no))
|
||||
# Create temporary node w/ data & final nodes to publish
|
||||
node = Issue(
|
||||
|
@ -136,17 +138,17 @@ def release_nodes(text, slug, date, config):
|
|||
# TODO: % vs .format()
|
||||
uri = config.releases_release_uri % slug
|
||||
elif config.releases_github_path:
|
||||
uri = "https://github.com/{0}/tree/{1}".format(
|
||||
uri = "https://github.com/{}/tree/{}".format(
|
||||
config.releases_github_path, slug)
|
||||
# Only construct link tag if user actually configured release URIs somehow
|
||||
if uri:
|
||||
link = '<a class="reference external" href="{0}">{1}</a>'.format(uri, text)
|
||||
link = '<a class="reference external" href="{}">{}</a>'.format(uri, text)
|
||||
else:
|
||||
link = text
|
||||
datespan = ''
|
||||
if date:
|
||||
datespan = ' <span style="font-size: 75%;">{0}</span>'.format(date)
|
||||
header = '<h2 style="margin-bottom: 0.3em;">{0}{1}</h2>'.format(
|
||||
datespan = ' <span style="font-size: 75%;">{}</span>'.format(date)
|
||||
header = '<h2 style="margin-bottom: 0.3em;">{}{}</h2>'.format(
|
||||
link, datespan)
|
||||
return nodes.section('',
|
||||
nodes.raw(rawtext='', text=header, format='html'),
|
||||
|
@ -188,7 +190,7 @@ def generate_unreleased_entry(header, line, issues, manager, app):
|
|||
None,
|
||||
app.config
|
||||
)]
|
||||
log("Creating {0!r} faux-release with {1!r}".format(line, issues))
|
||||
log("Creating {!r} faux-release with {!r}".format(line, issues))
|
||||
return {
|
||||
'obj': Release(number=line, date=None, nodelist=nodelist),
|
||||
'entries': issues,
|
||||
|
@ -205,13 +207,13 @@ def append_unreleased_entries(app, manager, releases):
|
|||
"""
|
||||
for family, lines in six.iteritems(manager):
|
||||
for type_ in ('bugfix', 'feature'):
|
||||
bucket = 'unreleased_{0}'.format(type_)
|
||||
bucket = 'unreleased_{}'.format(type_)
|
||||
if bucket not in lines: # Implies unstable prehistory + 0.x fam
|
||||
continue
|
||||
issues = lines[bucket]
|
||||
fam_prefix = "{0}.x ".format(family) if len(manager) > 1 else ""
|
||||
header = "Next {0}{1} release".format(fam_prefix, type_)
|
||||
line = "unreleased_{0}.x_{1}".format(family, type_)
|
||||
fam_prefix = "{}.x ".format(family) if len(manager) > 1 else ""
|
||||
header = "Next {}{} release".format(fam_prefix, type_)
|
||||
line = "unreleased_{}.x_{}".format(family, type_)
|
||||
releases.append(
|
||||
generate_unreleased_entry(header, line, issues, manager, app)
|
||||
)
|
||||
|
@ -246,7 +248,7 @@ def construct_entry_with_release(focus, issues, manager, log, releases, rest):
|
|||
missing = [i for i in explicit if i not in issues]
|
||||
if missing:
|
||||
raise ValueError(
|
||||
"Couldn't find issue(s) #{0} in the changelog!".format(
|
||||
"Couldn't find issue(s) #{} in the changelog!".format(
|
||||
', '.join(missing)))
|
||||
# Obtain the explicitly named issues from global list
|
||||
entries = []
|
||||
|
@ -357,9 +359,9 @@ def construct_entry_without_release(focus, issues, manager, log, rest):
|
|||
buried = focus.traverse(Issue)
|
||||
if buried:
|
||||
msg = """
|
||||
Found issue node ({0!r}) buried inside another node:
|
||||
Found issue node ({!r}) buried inside another node:
|
||||
|
||||
{1}
|
||||
{}
|
||||
|
||||
Please double-check your ReST syntax! There is probably text in the above
|
||||
output that will show you which part of your changelog to look at.
|
||||
|
@ -529,7 +531,7 @@ def construct_releases(entries, app):
|
|||
|
||||
reorder_release_entries(releases)
|
||||
|
||||
return releases
|
||||
return releases, manager
|
||||
|
||||
|
||||
def construct_nodes(releases):
|
||||
|
@ -588,7 +590,7 @@ class BulletListVisitor(nodes.NodeVisitor):
|
|||
if not self.found_changelog:
|
||||
self.found_changelog = True
|
||||
# Walk + parse into release mapping
|
||||
releases = construct_releases(node.children, self.app)
|
||||
releases, _ = construct_releases(node.children, self.app)
|
||||
# Construct new set of nodes to replace the old, and we're done
|
||||
node.replace_self(construct_nodes(releases))
|
||||
|
||||
|
@ -627,7 +629,7 @@ def setup(app):
|
|||
('unstable_prehistory', False),
|
||||
):
|
||||
app.add_config_value(
|
||||
name='releases_{0}'.format(key), default=default, rebuild='html'
|
||||
name='releases_{}'.format(key), default=default, rebuild='html'
|
||||
)
|
||||
# if a string is given for `document_name`, convert it to a list
|
||||
# done to maintain backwards compatibility
|
||||
|
@ -643,7 +645,19 @@ def setup(app):
|
|||
|
||||
# Register intermediate roles
|
||||
for x in list(ISSUE_TYPES) + ['issue']:
|
||||
app.add_role(x, issues_role)
|
||||
app.add_role('release', release_role)
|
||||
add_role(app, x, issues_role)
|
||||
add_role(app, 'release', release_role)
|
||||
# Hook in our changelog transmutation at appropriate step
|
||||
app.connect('doctree-read', generate_changelog)
|
||||
|
||||
# identifies the version of our extension
|
||||
return {'version': __version__}
|
||||
|
||||
|
||||
def add_role(app, name, role_obj):
|
||||
# This (introspecting docutils.parser.rst.roles._roles) is the same trick
|
||||
# Sphinx uses to emit warnings about double-registering; it's a PITA to try
|
||||
# and configure the app early on so it doesn't emit those warnings, so we
|
||||
# instead just...don't double-register. Meh.
|
||||
if name not in roles._roles:
|
||||
app.add_role(name, role_obj)
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
__version_info__ = (1, 2, 1)
|
||||
__version_info__ = (1, 4, 2)
|
||||
__version__ = '.'.join(map(str, __version_info__))
|
||||
|
|
|
@ -34,10 +34,7 @@ class LineManager(dict):
|
|||
if major_number == 0 and self.config.releases_unstable_prehistory:
|
||||
keys = ['unreleased']
|
||||
# Either way, the buckets default to an empty list
|
||||
empty = {}
|
||||
for key in keys:
|
||||
empty[key] = []
|
||||
self[major_number] = empty
|
||||
self[major_number] = {key: [] for key in keys}
|
||||
|
||||
@property
|
||||
def unstable_prehistory(self):
|
||||
|
|
|
@ -59,7 +59,7 @@ class Issue(nodes.Element):
|
|||
|
||||
def __eq__(self, other):
|
||||
for attr in self._cmp_keys:
|
||||
if getattr(self, attr) != getattr(other, attr):
|
||||
if getattr(self, attr, None) != getattr(other, attr, None):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
@ -110,18 +110,18 @@ class Issue(nodes.Element):
|
|||
# Make sure truly-default spec skips 0.x if prehistory was unstable.
|
||||
stable_families = manager.stable_families
|
||||
if manager.config.releases_unstable_prehistory and stable_families:
|
||||
specstr = ">={0}".format(min(stable_families))
|
||||
specstr = ">={}".format(min(stable_families))
|
||||
if self.is_featurelike:
|
||||
# TODO: if app->config-><releases_always_forwardport_features or
|
||||
# w/e
|
||||
if True:
|
||||
specstr = ">={0}".format(max(manager.keys()))
|
||||
specstr = ">={}".format(max(manager.keys()))
|
||||
else:
|
||||
# Can only meaningfully limit to minor release buckets if they
|
||||
# actually exist yet.
|
||||
buckets = self.minor_releases(manager)
|
||||
if buckets:
|
||||
specstr = ">={0}".format(max(buckets))
|
||||
specstr = ">={}".format(max(buckets))
|
||||
return Spec(specstr) if specstr else Spec()
|
||||
|
||||
def add_to_manager(self, manager):
|
||||
|
@ -178,7 +178,7 @@ class Issue(nodes.Element):
|
|||
elif self.spec:
|
||||
flag = self.spec
|
||||
if flag:
|
||||
flag = ' ({0})'.format(flag)
|
||||
flag = ' ({})'.format(flag)
|
||||
return '<{issue.type} #{issue.number}{flag}>'.format(issue=self,
|
||||
flag=flag)
|
||||
|
||||
|
@ -200,4 +200,4 @@ class Release(nodes.Element):
|
|||
return int(self.number.split('.')[0])
|
||||
|
||||
def __repr__(self):
|
||||
return '<release {0}>'.format(self.number)
|
||||
return '<release {}>'.format(self.number)
|
||||
|
|
263
releases/util.py
Normal file
263
releases/util.py
Normal file
|
@ -0,0 +1,263 @@
|
|||
"""
|
||||
Utility functions, such as helpers for standalone changelog parsing.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from tempfile import mkdtemp
|
||||
|
||||
import sphinx
|
||||
from docutils.core import Publisher
|
||||
from docutils.io import NullOutput
|
||||
from docutils.nodes import bullet_list
|
||||
from sphinx.application import Sphinx # not exposed at top level
|
||||
# NOTE: importing these from environment for backwards compat with Sphinx 1.3
|
||||
from sphinx.environment import (
|
||||
SphinxStandaloneReader, SphinxFileInput, SphinxDummyWriter,
|
||||
)
|
||||
# sphinx_domains is only in Sphinx 1.5+, but is presumably necessary from then
|
||||
# onwards.
|
||||
try:
|
||||
from sphinx.util.docutils import sphinx_domains
|
||||
except ImportError:
|
||||
# Just dummy it up.
|
||||
from contextlib import contextmanager
|
||||
@contextmanager
|
||||
def sphinx_domains(env):
|
||||
yield
|
||||
|
||||
from . import construct_releases, setup
|
||||
|
||||
|
||||
def parse_changelog(path):
|
||||
"""
|
||||
Load and parse changelog file from ``path``, returning data structures.
|
||||
|
||||
This function does not alter any files on disk; it is solely for
|
||||
introspecting a Releases ``changelog.rst`` and programmatically answering
|
||||
questions like "are there any unreleased bugfixes for the 2.3 line?" or
|
||||
"what was included in release 1.2.1?".
|
||||
|
||||
For example, answering the above questions is as simple as::
|
||||
|
||||
changelog = parse_changelog("/path/to/changelog")
|
||||
print("Unreleased issues for 2.3.x: {}".format(changelog['2.3']))
|
||||
print("Contents of v1.2.1: {}".format(changelog['1.2.1']))
|
||||
|
||||
:param str path: A relative or absolute file path string.
|
||||
|
||||
:returns:
|
||||
A dict whose keys map to lists of ``releases.models.Issue`` objects, as
|
||||
follows:
|
||||
|
||||
- Actual releases are full version number keys, such as ``"1.2.1"`` or
|
||||
``"2.0.0"``.
|
||||
- Unreleased bugs (or bug-like issues; see the Releases docs) are
|
||||
stored in minor-release buckets, e.g. ``"1.2"`` or ``"2.0"``.
|
||||
- Unreleased features (or feature-like issues) are found in
|
||||
``"unreleased_N_feature"``, where ``N`` is one of the major release
|
||||
families (so, a changelog spanning only 1.x will only have
|
||||
``unreleased_1_feature``, whereas one with 1.x and 2.x releases will
|
||||
have ``unreleased_1_feature`` and ``unreleased_2_feature``, etc).
|
||||
"""
|
||||
app, doctree = get_doctree(path)
|
||||
# Have to semi-reproduce the 'find first bullet list' bit from main code,
|
||||
# which is unfortunately side-effect-heavy (thanks to Sphinx plugin
|
||||
# design).
|
||||
first_list = None
|
||||
for node in doctree[0]:
|
||||
if isinstance(node, bullet_list):
|
||||
first_list = node
|
||||
break
|
||||
# Initial parse into the structures Releases finds useful internally
|
||||
releases, manager = construct_releases(first_list.children, app)
|
||||
ret = changelog2dict(releases)
|
||||
# Stitch them together into something an end-user would find better:
|
||||
# - nuke unreleased_N.N_Y as their contents will be represented in the
|
||||
# per-line buckets
|
||||
for key in ret.copy():
|
||||
if key.startswith('unreleased'):
|
||||
del ret[key]
|
||||
for family in manager:
|
||||
# - remove unreleased_bugfix, as they are accounted for in the per-line
|
||||
# buckets too. No need to store anywhere.
|
||||
manager[family].pop('unreleased_bugfix', None)
|
||||
# - bring over each major family's unreleased_feature as
|
||||
# unreleased_N_feature
|
||||
unreleased = manager[family].pop('unreleased_feature', None)
|
||||
if unreleased is not None:
|
||||
ret['unreleased_{}_feature'.format(family)] = unreleased
|
||||
# - bring over all per-line buckets from manager (flattening)
|
||||
# Here, all that's left in the per-family bucket should be lines, not
|
||||
# unreleased_*
|
||||
ret.update(manager[family])
|
||||
return ret
|
||||
|
||||
|
||||
def get_doctree(path):
|
||||
"""
|
||||
Obtain a Sphinx doctree from the RST file at ``path``.
|
||||
|
||||
Performs no Releases-specific processing; this code would, ideally, be in
|
||||
Sphinx itself, but things there are pretty tightly coupled. So we wrote
|
||||
this.
|
||||
|
||||
:param str path: A relative or absolute file path string.
|
||||
|
||||
:returns:
|
||||
A two-tuple of the generated ``sphinx.application.Sphinx`` app and the
|
||||
doctree (a ``docutils.document`` object).
|
||||
"""
|
||||
root, filename = os.path.split(path)
|
||||
docname, _ = os.path.splitext(filename)
|
||||
# TODO: this only works for top level changelog files (i.e. ones where
|
||||
# their dirname is the project/doc root)
|
||||
app = make_app(srcdir=root)
|
||||
# Create & init a BuildEnvironment. Mm, tasty side effects.
|
||||
app._init_env(freshenv=True)
|
||||
env = app.env
|
||||
# More arity/API changes: Sphinx 1.3/1.4-ish require one to pass in the app
|
||||
# obj in BuildEnvironment.update(); modern Sphinx performs that inside
|
||||
# Application._init_env() (which we just called above) and so that kwarg is
|
||||
# removed from update(). EAFP.
|
||||
kwargs = dict(
|
||||
config=app.config,
|
||||
srcdir=root,
|
||||
doctreedir=app.doctreedir,
|
||||
app=app,
|
||||
)
|
||||
try:
|
||||
env.update(**kwargs)
|
||||
except TypeError:
|
||||
# Assume newer Sphinx w/o an app= kwarg
|
||||
del kwargs['app']
|
||||
env.update(**kwargs)
|
||||
# Code taken from sphinx.environment.read_doc; easier to manually call
|
||||
# it with a working Environment object, instead of doing more random crap
|
||||
# to trick the higher up build system into thinking our single changelog
|
||||
# document was "updated".
|
||||
env.temp_data['docname'] = docname
|
||||
env.app = app
|
||||
# NOTE: SphinxStandaloneReader API changed in 1.4 :(
|
||||
reader_kwargs = {
|
||||
'app': app,
|
||||
'parsers': env.config.source_parsers,
|
||||
#'parsers': app.registry.get_source_parsers()
|
||||
}
|
||||
if sphinx.version_info[:2] < (1, 4):
|
||||
del reader_kwargs['app']
|
||||
# This monkeypatches (!!!) docutils to 'inject' all registered Sphinx
|
||||
# domains' roles & so forth. Without this, rendering the doctree lacks
|
||||
# almost all Sphinx magic, including things like :ref: and :doc:!
|
||||
with sphinx_domains(env):
|
||||
reader = SphinxStandaloneReader(**reader_kwargs)
|
||||
pub = Publisher(reader=reader,
|
||||
writer=SphinxDummyWriter(),
|
||||
destination_class=NullOutput)
|
||||
pub.set_components(None, 'restructuredtext', None)
|
||||
pub.process_programmatic_settings(None, env.settings, None)
|
||||
# NOTE: docname derived higher up, from our given path
|
||||
src_path = env.doc2path(docname)
|
||||
source = SphinxFileInput(
|
||||
app,
|
||||
env,
|
||||
source=None,
|
||||
source_path=src_path,
|
||||
encoding=env.config.source_encoding,
|
||||
)
|
||||
pub.source = source
|
||||
pub.settings._source = src_path
|
||||
pub.set_destination(None, None)
|
||||
pub.publish()
|
||||
return app, pub.document
|
||||
|
||||
|
||||
def make_app(**kwargs):
|
||||
"""
|
||||
Create a dummy Sphinx app, filling in various hardcoded assumptions.
|
||||
|
||||
For example, Sphinx assumes the existence of various source/dest
|
||||
directories, even if you're only calling internals that never generate (or
|
||||
sometimes, even read!) on-disk files. This function creates safe temp
|
||||
directories for these instances.
|
||||
|
||||
It also neuters Sphinx's internal logging, which otherwise causes verbosity
|
||||
in one's own test output and/or debug logs.
|
||||
|
||||
All args are stored in a single ``**kwargs``. Aside from the params listed
|
||||
below (all of which are optional), all kwargs given are turned into
|
||||
'releases_xxx' config settings; e.g. ``make_app(foo='bar')`` is like
|
||||
setting ``releases_foo = 'bar'`` in ``conf.py``.
|
||||
|
||||
:param str docname:
|
||||
Override the document name used (mostly for internal testing).
|
||||
|
||||
:param str srcdir:
|
||||
Sphinx source directory path.
|
||||
|
||||
:param str dstdir:
|
||||
Sphinx dest directory path.
|
||||
|
||||
:param str doctreedir:
|
||||
Sphinx doctree directory path.
|
||||
|
||||
:returns: A Sphinx ``Application`` instance.
|
||||
"""
|
||||
srcdir = kwargs.pop('srcdir', mkdtemp())
|
||||
dstdir = kwargs.pop('dstdir', mkdtemp())
|
||||
doctreedir = kwargs.pop('doctreedir', mkdtemp())
|
||||
try:
|
||||
# Sphinx <1.6ish
|
||||
Sphinx._log = lambda self, message, wfile, nonl=False: None
|
||||
# Sphinx >=1.6ish. Technically still lets Very Bad Things through,
|
||||
# unlike the total muting above, but probably OK.
|
||||
logging.getLogger('sphinx').setLevel(logging.ERROR)
|
||||
# App API seems to work on all versions so far.
|
||||
app = Sphinx(
|
||||
srcdir=srcdir,
|
||||
confdir=None,
|
||||
outdir=dstdir,
|
||||
doctreedir=doctreedir,
|
||||
buildername='html',
|
||||
)
|
||||
finally:
|
||||
for d in (srcdir, dstdir, doctreedir):
|
||||
# Only remove empty dirs; non-empty dirs are implicitly something
|
||||
# that existed before we ran, and should not be touched.
|
||||
try:
|
||||
os.rmdir(d)
|
||||
except OSError:
|
||||
pass
|
||||
setup(app)
|
||||
# Mock out the config within. More assumptions by Sphinx :(
|
||||
config = {
|
||||
'releases_release_uri': 'foo_%s',
|
||||
'releases_issue_uri': 'bar_%s',
|
||||
'releases_debug': False,
|
||||
'master_doc': 'index',
|
||||
}
|
||||
# Allow tinkering with document filename
|
||||
if 'docname' in kwargs:
|
||||
app.env.temp_data['docname'] = kwargs.pop('docname')
|
||||
# Allow config overrides via kwargs
|
||||
for name in kwargs:
|
||||
config['releases_{}'.format(name)] = kwargs[name]
|
||||
# Stitch together as the sphinx app init() usually does w/ real conf files
|
||||
app.config._raw_config = config
|
||||
# init_values() requires a 'warn' runner on Sphinx 1.3-1.6, so if we seem
|
||||
# to be hitting arity errors, give it a dummy such callable. Hopefully
|
||||
# calling twice doesn't introduce any wacko state issues :(
|
||||
try:
|
||||
app.config.init_values()
|
||||
except TypeError: # boy I wish Python had an ArityError or w/e
|
||||
app.config.init_values(lambda x: x)
|
||||
return app
|
||||
|
||||
|
||||
def changelog2dict(changelog):
|
||||
"""
|
||||
Helper turning internal list-o-releases structure into a dict.
|
||||
|
||||
See `parse_changelog` docstring for return value details.
|
||||
"""
|
||||
return {r['obj'].number: r['entries'] for r in changelog}
|
10
setup.py
10
setup.py
|
@ -12,11 +12,15 @@ setup(
|
|||
name='releases',
|
||||
version=version,
|
||||
description='A Sphinx extension for changelog manipulation',
|
||||
long_description=open("README.rst").read(),
|
||||
author='Jeff Forcier',
|
||||
author_email='jeff@bitprophet.org',
|
||||
url='https://github.com/bitprophet/releases',
|
||||
packages=['releases'],
|
||||
install_requires=['semantic_version<3.0'],
|
||||
install_requires=[
|
||||
'semantic_version<3.0',
|
||||
'sphinx>=1.3,<1.7',
|
||||
],
|
||||
classifiers=[
|
||||
'Development Status :: 5 - Production/Stable',
|
||||
'Intended Audience :: Developers',
|
||||
|
@ -26,12 +30,10 @@ setup(
|
|||
'Operating System :: POSIX',
|
||||
'Operating System :: Microsoft :: Windows',
|
||||
'Programming Language :: Python',
|
||||
'Programming Language :: Python :: 2.6',
|
||||
'Programming Language :: Python :: 2.7',
|
||||
'Programming Language :: Python :: 3.2',
|
||||
'Programming Language :: Python :: 3.3',
|
||||
'Programming Language :: Python :: 3.4',
|
||||
'Programming Language :: Python :: 3.5',
|
||||
'Programming Language :: Python :: 3.6',
|
||||
'Topic :: Software Development',
|
||||
'Topic :: Software Development :: Documentation',
|
||||
'Topic :: Documentation',
|
||||
|
|
6
tasks.py
6
tasks.py
|
@ -1,3 +1,5 @@
|
|||
from os.path import join
|
||||
|
||||
from invocations import docs
|
||||
from invocations.testing import test, integration, watch_tests
|
||||
from invocations.packaging import release
|
||||
|
@ -13,5 +15,9 @@ ns.configure({
|
|||
'packaging': {
|
||||
'sign': True,
|
||||
'wheel': True,
|
||||
'changelog_file': join(
|
||||
docs.ns.configuration()['sphinx']['source'],
|
||||
'changelog.rst',
|
||||
),
|
||||
},
|
||||
})
|
||||
|
|
|
@ -1,14 +1,9 @@
|
|||
from tempfile import mkdtemp
|
||||
from shutil import rmtree
|
||||
|
||||
from docutils.nodes import (
|
||||
list_item, paragraph,
|
||||
)
|
||||
from mock import Mock
|
||||
from spec import eq_, ok_
|
||||
from sphinx.application import Sphinx
|
||||
import six
|
||||
import sphinx
|
||||
|
||||
from releases import (
|
||||
Issue,
|
||||
|
@ -17,57 +12,9 @@ from releases import (
|
|||
release_role,
|
||||
construct_releases,
|
||||
)
|
||||
from releases import setup as releases_setup # avoid unittest crap
|
||||
from releases.util import make_app, changelog2dict
|
||||
|
||||
|
||||
|
||||
def make_app(**kwargs):
|
||||
"""
|
||||
Create a real Sphinx app, with stupid temp dirs because it assumes.
|
||||
|
||||
Helps catch things like "testing a config option but forgot
|
||||
app.add_config_value()"
|
||||
|
||||
Kwargs (w/ exception of 'docname' which is used for document name if given)
|
||||
are turned into 'releases_xxx' config settings, so e.g.
|
||||
``make_app(foo='bar')`` is like setting ``releases_foo = 'bar'`` in
|
||||
``conf.py``.
|
||||
"""
|
||||
src, dst, doctree = mkdtemp(), mkdtemp(), mkdtemp()
|
||||
try:
|
||||
# STFU Sphinx :(
|
||||
Sphinx._log = lambda self, message, wfile, nonl=False: None
|
||||
app = Sphinx(
|
||||
srcdir=src,
|
||||
confdir=None,
|
||||
outdir=dst,
|
||||
doctreedir=doctree,
|
||||
buildername='html',
|
||||
)
|
||||
finally:
|
||||
[rmtree(x) for x in (src, doctree)]
|
||||
releases_setup(app)
|
||||
# Mock out the config within. More horrible assumptions by Sphinx :(
|
||||
config = {
|
||||
'releases_release_uri': 'foo_%s',
|
||||
'releases_issue_uri': 'bar_%s',
|
||||
'releases_debug': False,
|
||||
}
|
||||
# Allow tinkering with document filename
|
||||
if 'docname' in kwargs:
|
||||
app.env.temp_data['docname'] = kwargs.pop('docname')
|
||||
# Allow config overrides via kwargs
|
||||
for name in kwargs:
|
||||
config['releases_{0}'.format(name)] = kwargs[name]
|
||||
# Stitch together as the sphinx app init() usually does w/ real conf files
|
||||
app.config._raw_config = config
|
||||
# init_values() requires a 'warn' runner on Sphinx 1.3+, give it no-op.
|
||||
init_args = []
|
||||
if sphinx.version_info[:2] > (1, 2):
|
||||
init_args = [lambda x: x]
|
||||
app.config.init_values(*init_args)
|
||||
return app
|
||||
|
||||
def inliner(app=None):
|
||||
app = app or make_app()
|
||||
return Mock(document=Mock(settings=Mock(env=Mock(app=app))))
|
||||
|
@ -138,15 +85,9 @@ def release_list(*entries, **kwargs):
|
|||
entries.append(release('1.0.0'))
|
||||
return entries
|
||||
|
||||
def changelog2dict(changelog):
|
||||
d = {}
|
||||
for r in changelog:
|
||||
d[r['obj'].number] = r['entries']
|
||||
return d
|
||||
|
||||
def releases(*entries, **kwargs):
|
||||
app = kwargs.pop('app', None) or make_app()
|
||||
return construct_releases(release_list(*entries, **kwargs), app)
|
||||
return construct_releases(release_list(*entries, **kwargs), app)[0]
|
||||
|
||||
def setup_issues(self):
|
||||
self.f = f(12)
|
||||
|
@ -163,8 +104,8 @@ def expect_releases(entries, release_map, skip_initial=False, app=None):
|
|||
kwargs['app'] = app
|
||||
changelog = changelog2dict(releases(*entries, **kwargs))
|
||||
snapshot = dict(changelog)
|
||||
err = "Got unexpected contents for {0}: wanted {1}, got {2}"
|
||||
err += "\nFull changelog: {3!r}\n"
|
||||
err = "Got unexpected contents for {}: wanted {}, got {}"
|
||||
err += "\nFull changelog: {!r}\n"
|
||||
for rel, issues in six.iteritems(release_map):
|
||||
found = changelog.pop(rel)
|
||||
eq_(set(found), set(issues), err.format(rel, issues, found, snapshot))
|
||||
|
@ -172,4 +113,4 @@ def expect_releases(entries, release_map, skip_initial=False, app=None):
|
|||
for key in list(changelog.keys()):
|
||||
if not changelog[key]:
|
||||
del changelog[key]
|
||||
ok_(not changelog, "Found leftovers: {0}".format(changelog))
|
||||
ok_(not changelog, "Found leftovers: {}".format(changelog))
|
||||
|
|
|
@ -159,7 +159,7 @@ class organization(Spec):
|
|||
)
|
||||
# Modify 1.0.1 release to be speshul
|
||||
changelog[0][0].append(Text("2, 3"))
|
||||
rendered = construct_releases(changelog, make_app())
|
||||
rendered, _ = construct_releases(changelog, make_app())
|
||||
# 1.0.1 includes just 2 and 3, not bug 1
|
||||
one_0_1 = rendered[3]['entries']
|
||||
one_1_1 = rendered[2]['entries']
|
||||
|
@ -184,7 +184,7 @@ class organization(Spec):
|
|||
changelog = release_list('1.1.0', f1, f2)
|
||||
# Ensure that 1.1.0 specifies feature 2
|
||||
changelog[0][0].append(Text("2"))
|
||||
rendered = changelog2dict(construct_releases(changelog, make_app()))
|
||||
rendered = changelog2dict(construct_releases(changelog, make_app())[0])
|
||||
# 1.1.0 should have feature 2 only
|
||||
assert f2 in rendered['1.1.0']
|
||||
assert f1 not in rendered['1.1.0']
|
||||
|
@ -199,7 +199,7 @@ class organization(Spec):
|
|||
changelog = release_list('1.0.1', b1, b2)
|
||||
# Ensure that 1.0.1 specifies bug 2
|
||||
changelog[0][0].append(Text('2'))
|
||||
rendered = construct_releases(changelog, make_app())
|
||||
rendered, _ = construct_releases(changelog, make_app())
|
||||
# 1.0.1 should have bug 2 only
|
||||
assert b2 in rendered[1]['entries']
|
||||
assert b1 not in rendered[1]['entries']
|
||||
|
|
|
@ -49,7 +49,7 @@ class presentation(Spec):
|
|||
release('1.0.2', app=app),
|
||||
entry(b(15, app=app)),
|
||||
release('1.0.0'),
|
||||
], app=app))
|
||||
], app=app)[0])
|
||||
# Shorthand for "I'll do my own asserts"
|
||||
if expected is None:
|
||||
return nodes
|
||||
|
|
Loading…
Add table
Reference in a new issue