Merge branch 'master' into 61-int

This commit is contained in:
Jeff Forcier 2018-05-01 13:31:48 -07:00
commit 3e9710e587
19 changed files with 470 additions and 120 deletions

View file

@ -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

View file

@ -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

View file

@ -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``.

View file

@ -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

View file

@ -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

View 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>`

View file

@ -0,0 +1,6 @@
====
Test
====
.. toctree::
changelog

View file

@ -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
View 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])

View file

@ -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)

View file

@ -1,2 +1,2 @@
__version_info__ = (1, 2, 1)
__version_info__ = (1, 4, 2)
__version__ = '.'.join(map(str, __version_info__))

View file

@ -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):

View file

@ -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
View 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}

View file

@ -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',

View file

@ -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',
),
},
})

View file

@ -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))

View file

@ -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']

View file

@ -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