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 language: python
python: python:
- "2.6"
- "2.7" - "2.7"
- "3.3"
- "3.4" - "3.4"
- "3.5" - "3.5"
- "3.6"
- "pypy" - "pypy"
#- "pypy3" # Looks like Sphinx (as of 1.4.1) is not pypy3 compat #- "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: install:
- pip install -r dev-requirements.txt - pip install -r dev-requirements.txt
- pip install "sphinx$SPHINX"
script: script:
# Primary test suite # Primary test suite
- inv test - inv test

View file

@ -1,4 +1,4 @@
Copyright (c) 2016, Jeff Forcier Copyright (c) 2018, Jeff Forcier
All rights reserved. All rights reserved.
Redistribution and use in source and binary forms, with or without Redistribution and use in source and binary forms, with or without

View file

@ -4,9 +4,9 @@
What is Releases? What is Releases?
================= =================
Releases is a Python 2+3 compatible `Sphinx <http://sphinx-doc.org>`_ extension Releases is a Python (2.7, 3.4+) compatible `Sphinx <http://sphinx-doc.org>`_
designed to help you keep a source control friendly, merge friendly changelog (1.3+) extension designed to help you keep a source control friendly, merge
file & turn it into useful, human readable HTML output. friendly changelog file & turn it into useful, human readable HTML output.
Specifically: 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. For more documentation, please see http://releases.readthedocs.io.
.. note:: .. note::
You can install the `development version You can install the development version via ``pip install -e
<https://github.com/bitprophet/releases/tarball/master#egg=releases-dev>`_ git+https://github.com/bitprophet/releases/#egg=releases``.
via ``pip install releases==dev``.

View file

@ -1,6 +1,6 @@
# Task runner # Task runner
invoke>=0.6.0,<2.0 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) # Tests (N.B. integration suite also uses Invoke as above)
spec>=0.11.3,<2.0 spec>=0.11.3,<2.0
mock==1.0.1 mock==1.0.1
@ -8,8 +8,7 @@ mock==1.0.1
six>=1.4.1,<2.0 six>=1.4.1,<2.0
# Docs # Docs
-e . -e .
sphinx>=1.1,<2.0
sphinx_rtd_theme>=0.1.5,<2.0 sphinx_rtd_theme>=0.1.5,<2.0
# Builds # Builds
wheel==0.24 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 * :feature:`59` Allow multiple changelog files. Thanks to William Minchin for
the patch. 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>` * :release:`1.2.1 <2016-07-25>`
* :support:`51 backported` Modernize release management so PyPI trove * :support:`51 backported` Modernize release management so PyPI trove
classifiers are more accurate, wheel archives are universal instead of Python 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 # Dynamic sphinx opt overrides
if opts: if opts:
pairs = map(lambda x: '='.join(x), (opts or {}).items()) 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) flagstr = ' '.join(flags)
else: else:
flagstr = '' flagstr = ''
@ -25,8 +25,8 @@ class integration(Spec):
build = os.path.join(folder, '_build') build = os.path.join(folder, '_build')
try: try:
# Build # Build
cmd = 'sphinx-build {2} -c {3} -W {0} {1}'.format( cmd = 'sphinx-build {} -c {} -W {} {}'.format(
folder, build, flagstr, conf) flagstr, conf, folder, build)
result = run(cmd, warn=True, hide=True) result = run(cmd, warn=True, hide=True)
if callable(asserts): if callable(asserts):
asserts(result, build, target) asserts(result, build, target)
@ -40,10 +40,10 @@ class integration(Spec):
def _basic_asserts(self, result, build, target): def _basic_asserts(self, result, build, target):
# Check for errors # Check for errors
msg = "Build failed w/ stderr: {0}" msg = "Build failed w/ stderr: {}"
assert result.ok, msg.format(result.stderr) assert result.ok, msg.format(result.stderr)
# Check for vaguely correct output # 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: with open(changelog) as fd:
text = fd.read() text = fd.read()
assert "1.0.1" in text 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 functools import partial
from docutils import nodes, utils from docutils import nodes, utils
from docutils.parsers.rst import roles
import six import six
from .models import Issue, ISSUE_TYPES, Release, Version, Spec from .models import Issue, ISSUE_TYPES, Release, Version, Spec
from .line_manager import LineManager from .line_manager import LineManager
from ._version import __version__
def _log(txt, config): def _log(txt, config):
@ -44,7 +46,7 @@ def scan_for_spec(keyword):
# First, test for intermediate '1.2+' style # First, test for intermediate '1.2+' style
matches = release_line_re.findall(keyword) matches = release_line_re.findall(keyword)
if matches: if matches:
return Spec(">={0}".format(matches[0])) return Spec(">={}".format(matches[0]))
# Failing that, see if Spec can make sense of it # Failing that, see if Spec can make sense of it
try: try:
return Spec(keyword) return Spec(keyword)
@ -78,7 +80,7 @@ def issues_role(name, rawtext, text, lineno, inliner, options={}, content=[]):
# TODO: deal with % vs .format() # TODO: deal with % vs .format()
ref = config.releases_issue_uri % issue_no ref = config.releases_issue_uri % issue_no
elif config.releases_github_path: 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) config.releases_github_path, issue_no)
# Only generate a reference/link if we were able to make a URI # Only generate a reference/link if we were able to make a URI
if ref: if ref:
@ -109,7 +111,7 @@ def issues_role(name, rawtext, text, lineno, inliner, options={}, content=[]):
if part in ('backported', 'major'): if part in ('backported', 'major'):
keyword = part keyword = part
else: 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)) raise ValueError(err.format(keyword, issue_no))
# Create temporary node w/ data & final nodes to publish # Create temporary node w/ data & final nodes to publish
node = Issue( node = Issue(
@ -136,17 +138,17 @@ def release_nodes(text, slug, date, config):
# TODO: % vs .format() # TODO: % vs .format()
uri = config.releases_release_uri % slug uri = config.releases_release_uri % slug
elif config.releases_github_path: elif config.releases_github_path:
uri = "https://github.com/{0}/tree/{1}".format( uri = "https://github.com/{}/tree/{}".format(
config.releases_github_path, slug) config.releases_github_path, slug)
# Only construct link tag if user actually configured release URIs somehow # Only construct link tag if user actually configured release URIs somehow
if uri: 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: else:
link = text link = text
datespan = '' datespan = ''
if date: if date:
datespan = ' <span style="font-size: 75%;">{0}</span>'.format(date) datespan = ' <span style="font-size: 75%;">{}</span>'.format(date)
header = '<h2 style="margin-bottom: 0.3em;">{0}{1}</h2>'.format( header = '<h2 style="margin-bottom: 0.3em;">{}{}</h2>'.format(
link, datespan) link, datespan)
return nodes.section('', return nodes.section('',
nodes.raw(rawtext='', text=header, format='html'), nodes.raw(rawtext='', text=header, format='html'),
@ -188,7 +190,7 @@ def generate_unreleased_entry(header, line, issues, manager, app):
None, None,
app.config 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 { return {
'obj': Release(number=line, date=None, nodelist=nodelist), 'obj': Release(number=line, date=None, nodelist=nodelist),
'entries': issues, 'entries': issues,
@ -205,13 +207,13 @@ def append_unreleased_entries(app, manager, releases):
""" """
for family, lines in six.iteritems(manager): for family, lines in six.iteritems(manager):
for type_ in ('bugfix', 'feature'): 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 if bucket not in lines: # Implies unstable prehistory + 0.x fam
continue continue
issues = lines[bucket] issues = lines[bucket]
fam_prefix = "{0}.x ".format(family) if len(manager) > 1 else "" fam_prefix = "{}.x ".format(family) if len(manager) > 1 else ""
header = "Next {0}{1} release".format(fam_prefix, type_) header = "Next {}{} release".format(fam_prefix, type_)
line = "unreleased_{0}.x_{1}".format(family, type_) line = "unreleased_{}.x_{}".format(family, type_)
releases.append( releases.append(
generate_unreleased_entry(header, line, issues, manager, app) 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] missing = [i for i in explicit if i not in issues]
if missing: if missing:
raise ValueError( raise ValueError(
"Couldn't find issue(s) #{0} in the changelog!".format( "Couldn't find issue(s) #{} in the changelog!".format(
', '.join(missing))) ', '.join(missing)))
# Obtain the explicitly named issues from global list # Obtain the explicitly named issues from global list
entries = [] entries = []
@ -357,9 +359,9 @@ def construct_entry_without_release(focus, issues, manager, log, rest):
buried = focus.traverse(Issue) buried = focus.traverse(Issue)
if buried: if buried:
msg = """ 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 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. 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) reorder_release_entries(releases)
return releases return releases, manager
def construct_nodes(releases): def construct_nodes(releases):
@ -588,7 +590,7 @@ class BulletListVisitor(nodes.NodeVisitor):
if not self.found_changelog: if not self.found_changelog:
self.found_changelog = True self.found_changelog = True
# Walk + parse into release mapping # 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 # Construct new set of nodes to replace the old, and we're done
node.replace_self(construct_nodes(releases)) node.replace_self(construct_nodes(releases))
@ -627,7 +629,7 @@ def setup(app):
('unstable_prehistory', False), ('unstable_prehistory', False),
): ):
app.add_config_value( 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 # if a string is given for `document_name`, convert it to a list
# done to maintain backwards compatibility # done to maintain backwards compatibility
@ -643,7 +645,19 @@ def setup(app):
# Register intermediate roles # Register intermediate roles
for x in list(ISSUE_TYPES) + ['issue']: for x in list(ISSUE_TYPES) + ['issue']:
app.add_role(x, issues_role) add_role(app, x, issues_role)
app.add_role('release', release_role) add_role(app, 'release', release_role)
# Hook in our changelog transmutation at appropriate step # Hook in our changelog transmutation at appropriate step
app.connect('doctree-read', generate_changelog) 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__)) __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: if major_number == 0 and self.config.releases_unstable_prehistory:
keys = ['unreleased'] keys = ['unreleased']
# Either way, the buckets default to an empty list # Either way, the buckets default to an empty list
empty = {} self[major_number] = {key: [] for key in keys}
for key in keys:
empty[key] = []
self[major_number] = empty
@property @property
def unstable_prehistory(self): def unstable_prehistory(self):

View file

@ -59,7 +59,7 @@ class Issue(nodes.Element):
def __eq__(self, other): def __eq__(self, other):
for attr in self._cmp_keys: 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 False
return True return True
@ -110,18 +110,18 @@ class Issue(nodes.Element):
# Make sure truly-default spec skips 0.x if prehistory was unstable. # Make sure truly-default spec skips 0.x if prehistory was unstable.
stable_families = manager.stable_families stable_families = manager.stable_families
if manager.config.releases_unstable_prehistory and 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: if self.is_featurelike:
# TODO: if app->config-><releases_always_forwardport_features or # TODO: if app->config-><releases_always_forwardport_features or
# w/e # w/e
if True: if True:
specstr = ">={0}".format(max(manager.keys())) specstr = ">={}".format(max(manager.keys()))
else: else:
# Can only meaningfully limit to minor release buckets if they # Can only meaningfully limit to minor release buckets if they
# actually exist yet. # actually exist yet.
buckets = self.minor_releases(manager) buckets = self.minor_releases(manager)
if buckets: if buckets:
specstr = ">={0}".format(max(buckets)) specstr = ">={}".format(max(buckets))
return Spec(specstr) if specstr else Spec() return Spec(specstr) if specstr else Spec()
def add_to_manager(self, manager): def add_to_manager(self, manager):
@ -178,7 +178,7 @@ class Issue(nodes.Element):
elif self.spec: elif self.spec:
flag = self.spec flag = self.spec
if flag: if flag:
flag = ' ({0})'.format(flag) flag = ' ({})'.format(flag)
return '<{issue.type} #{issue.number}{flag}>'.format(issue=self, return '<{issue.type} #{issue.number}{flag}>'.format(issue=self,
flag=flag) flag=flag)
@ -200,4 +200,4 @@ class Release(nodes.Element):
return int(self.number.split('.')[0]) return int(self.number.split('.')[0])
def __repr__(self): 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', name='releases',
version=version, version=version,
description='A Sphinx extension for changelog manipulation', description='A Sphinx extension for changelog manipulation',
long_description=open("README.rst").read(),
author='Jeff Forcier', author='Jeff Forcier',
author_email='jeff@bitprophet.org', author_email='jeff@bitprophet.org',
url='https://github.com/bitprophet/releases', url='https://github.com/bitprophet/releases',
packages=['releases'], packages=['releases'],
install_requires=['semantic_version<3.0'], install_requires=[
'semantic_version<3.0',
'sphinx>=1.3,<1.7',
],
classifiers=[ classifiers=[
'Development Status :: 5 - Production/Stable', 'Development Status :: 5 - Production/Stable',
'Intended Audience :: Developers', 'Intended Audience :: Developers',
@ -26,12 +30,10 @@ setup(
'Operating System :: POSIX', 'Operating System :: POSIX',
'Operating System :: Microsoft :: Windows', 'Operating System :: Microsoft :: Windows',
'Programming Language :: Python', 'Programming Language :: Python',
'Programming Language :: Python :: 2.6',
'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3.2',
'Programming Language :: Python :: 3.3',
'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',
'Topic :: Software Development', 'Topic :: Software Development',
'Topic :: Software Development :: Documentation', 'Topic :: Software Development :: Documentation',
'Topic :: Documentation', 'Topic :: Documentation',

View file

@ -1,3 +1,5 @@
from os.path import join
from invocations import docs from invocations import docs
from invocations.testing import test, integration, watch_tests from invocations.testing import test, integration, watch_tests
from invocations.packaging import release from invocations.packaging import release
@ -13,5 +15,9 @@ ns.configure({
'packaging': { 'packaging': {
'sign': True, 'sign': True,
'wheel': 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 ( from docutils.nodes import (
list_item, paragraph, list_item, paragraph,
) )
from mock import Mock from mock import Mock
from spec import eq_, ok_ from spec import eq_, ok_
from sphinx.application import Sphinx
import six import six
import sphinx
from releases import ( from releases import (
Issue, Issue,
@ -17,57 +12,9 @@ from releases import (
release_role, release_role,
construct_releases, 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): def inliner(app=None):
app = app or make_app() app = app or make_app()
return Mock(document=Mock(settings=Mock(env=Mock(app=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')) entries.append(release('1.0.0'))
return entries return entries
def changelog2dict(changelog):
d = {}
for r in changelog:
d[r['obj'].number] = r['entries']
return d
def releases(*entries, **kwargs): def releases(*entries, **kwargs):
app = kwargs.pop('app', None) or make_app() 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): def setup_issues(self):
self.f = f(12) self.f = f(12)
@ -163,8 +104,8 @@ def expect_releases(entries, release_map, skip_initial=False, app=None):
kwargs['app'] = app kwargs['app'] = app
changelog = changelog2dict(releases(*entries, **kwargs)) changelog = changelog2dict(releases(*entries, **kwargs))
snapshot = dict(changelog) snapshot = dict(changelog)
err = "Got unexpected contents for {0}: wanted {1}, got {2}" err = "Got unexpected contents for {}: wanted {}, got {}"
err += "\nFull changelog: {3!r}\n" err += "\nFull changelog: {!r}\n"
for rel, issues in six.iteritems(release_map): for rel, issues in six.iteritems(release_map):
found = changelog.pop(rel) found = changelog.pop(rel)
eq_(set(found), set(issues), err.format(rel, issues, found, snapshot)) 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()): for key in list(changelog.keys()):
if not changelog[key]: if not changelog[key]:
del 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 # Modify 1.0.1 release to be speshul
changelog[0][0].append(Text("2, 3")) 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 # 1.0.1 includes just 2 and 3, not bug 1
one_0_1 = rendered[3]['entries'] one_0_1 = rendered[3]['entries']
one_1_1 = rendered[2]['entries'] one_1_1 = rendered[2]['entries']
@ -184,7 +184,7 @@ class organization(Spec):
changelog = release_list('1.1.0', f1, f2) changelog = release_list('1.1.0', f1, f2)
# Ensure that 1.1.0 specifies feature 2 # Ensure that 1.1.0 specifies feature 2
changelog[0][0].append(Text("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 # 1.1.0 should have feature 2 only
assert f2 in rendered['1.1.0'] assert f2 in rendered['1.1.0']
assert f1 not 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) changelog = release_list('1.0.1', b1, b2)
# Ensure that 1.0.1 specifies bug 2 # Ensure that 1.0.1 specifies bug 2
changelog[0][0].append(Text('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 # 1.0.1 should have bug 2 only
assert b2 in rendered[1]['entries'] assert b2 in rendered[1]['entries']
assert b1 not 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), release('1.0.2', app=app),
entry(b(15, app=app)), entry(b(15, app=app)),
release('1.0.0'), release('1.0.0'),
], app=app)) ], app=app)[0])
# Shorthand for "I'll do my own asserts" # Shorthand for "I'll do my own asserts"
if expected is None: if expected is None:
return nodes return nodes