releases-1.4.0/0000755000076500000240000000000013172412372014247 5ustar jforcierstaff00000000000000releases-1.4.0/dev-requirements.txt0000644000076500000240000000040113172165633020307 0ustar jforcierstaff00000000000000# Task runner invoke>=0.6.0,<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 # Just for tests...heh six>=1.4.1,<2.0 # Docs -e . sphinx_rtd_theme>=0.1.5,<2.0 # Builds wheel==0.24 twine==1.5 releases-1.4.0/docs/0000755000076500000240000000000013172412372015177 5ustar jforcierstaff00000000000000releases-1.4.0/docs/changelog.rst0000644000076500000240000001764613172412361017674 0ustar jforcierstaff00000000000000========= Changelog ========= * :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 2 only, and release artifacts are GPG signed. * :bug:`56` Fix exceptions that occurred when no release/issue link options were configured. Now those options are truly optional: release version and issue number text will simply display normally instead of as hyperlinks. Thanks to André Caron for the report. * :bug:`36` Changelogs with no releases whatsoever should still be viable instead of raising exceptions. This is now happily the case. All items in such changelogs will end up in a single "unreleased features" list, just as with regular prehistory entries. Thanks to Steve Ivy for initial report and André Caron for additional feedback. * :release:`1.2.0 <2016-05-20>` * :bug:`- major` Fix formatting of release header dates; a "75% text size" style rule has had an uncaught typo for some time. * :bug:`55 major` Non-annotated changelog line items (which implicitly become bugs) were incorrectly truncating their contents in some situations (basically, any time they included non-regular-text elements like monospace, bold etc). This has been fixed. * :feature:`19` Add ``unstable_prehistory`` option/mode for changelogs whose 0.x release cycle is "rapid" or "unstable" and doesn't closely follow normal semantic version-driven organization. See :ref:`unstable-prehistory`. * :bug:`53 major` Tweak newly-updated models so bugfix items prior to an initial release are considered 'major bugs' so they get rolled into that initial release (instead of causing a ``ValueError``). * :release:`1.1.0 <2016-04-28>` * :feature:`45` Add support for major version transitions (e.g. 1.0 to 2.0). .. note:: This adds a new install-time dependency: the `semantic_version library `_. It's pure Python, so installation should be trivial. * :bug:`44 major` Update one of our internal docutils-related classes for compatibility with Sphinx 1.4.x. Thanks to Gabi Davar for catch & patch. * :release:`1.0.0 <2015-11-05>` * :feature:`42` For readability, issues within each release so they are displayed in feature->bug->support order. * :feature:`41` Clean up changelog discovery so one can have comments, paragraphs or other non-bullet-list elements above or below the changelog. Thanks to Rodrigue Cloutier for the original request/patch. * :release:`0.7.0 <2014-09-04>` * :bug:`30 major` Add LICENSE (plus a handful of other administrative files) to a ``MANIFEST.in`` so sdists pick it up. Thanks to Zygmunt Krynicki for catch & original patch (:issue:`33`). * :feature:`21` Allow duplicate issue numbers; not allowing them was technically an implementation detail. Thanks to Dorian Puła for the patch. * :release:`0.6.1 <2014-04-06>` * :bug:`-` Fix a silly issue with the new feature from :issue:`22` where it accidentally referred to the Sphinx document *title* instead of the document *filename*. * :release:`0.6.0 <2014-04-03>` * :feature:`22` Make the document name used as the changelog - previously hardcoded as ``changelog`` (``.rst``) - configurable. Thanks to James Mills for the feature request. * :feature:`26` Allow specifying Github path shorthand config option instead of explicit release/issue URL strings. * :release:`0.5.3 <2014-03-15>` * :bug:`25` Empty/no-issue line items broke at some point; fixed. * :bug:`24` Broke inline issue parsing; fixed now. * :release:`0.5.2 <2014-03-13>` * :bug:`23` Rework implementation to deal with issue descriptions that span more than one paragraph - subsequent paragraphs/blocks were not being displayed prior. * :release:`0.5.1 <2014-02-11>` * :bug:`-` Fix silly bug in :issue:`20` that cropped up on Python 3.x. * :release:`0.5.0 <2014-02-11>` * :feature:`20` Allow specifying minimum release line in bugfixes that don't apply to all active lines (e.g. because they pertain to a recently added feature.) * :release:`0.4.0 <2013-12-24>` * :feature:`17` Allow releases to explicitly define which issues they include. Useful for overriding default assumptions (e.g. a special bugfix release from an otherwise dormant line.) * :release:`0.3.1 <2013-12-18>` * :bug:`16` Fix some edge cases regarding release ordering & unreleased issue display. Includes splitting unreleased display info into two 'Next release' pseudo-release entries. * :support:`15` Add :doc:`/concepts` to flesh out some assumptions not adequately explained in :doc:`/usage`. * :release:`0.3.0 <2013-11-21>` * :feature:`11` Fix up styling so changelogs don't look suboptimal under `the new Read The Docs theme `_. Still looks OK under their old theme too! * :support:`0` Move to actual Sphinx docs so we can use ourselves. * :support:`0` Created a basic test suite to protect against regressions. * :bug:`9 major` Clean up additional 'unreleased' display/organization behavior, including making sure ALL unreleased issues show up as 'unreleased'. Thanks to Donald Stufft for the report. * :feature:`1` (also :issue:`3`, :issue:`10`) Allow using ``-`` or ``0`` as a dummy issue 'number', which will result in no issue number/link being displayed. Thanks to Markus Zapke-Gründemann and Hynek Schlawack for patches & discussion. * This feature lets you categorize changes that aren't directly related to issues in your tracker. It's an improvement over, and replacement for, the previous "vanilla bullet list items are treated as bugs" behavior. * Said behavior (non-role-prefixed bullet list items turning into regular bugs) is being retained as there's not a lot to gain from deactivating it. * :release:`0.2.4 <2013.10.04>` * :support:`0 backported` Handful of typos, doc tweaks & addition of a .gitignore file. Thanks to Markus Zapke-Gründemann. * :bug:`0` Fix duplicate display of "bare" (not prefixed with an issue role) changelog entries. Thanks again to Markus. * :support:`0 backported` Edited the README/docs to be clearer about how Releases works/operates. * :support:`0 backported` Explicitly documented how non-role-prefixed line items are preserved. * :bug:`0` Updated non-role-prefixed line items so they get prefixed with a '[Bug]' signifier (since they are otherwise treated as bugfix items.) * :release:`0.2.3 <2013.09.16>` * :bug:`0` Fix a handful of bugs in release assignment logic. * :release:`0.2.2 <2013.09.15>` * :bug:`0` Ensured Python 3 compatibility. * :release:`0.2.1 <2013.09.15>` * :bug:`0` Fixed a stupid bug causing invalid issue hyperlinks. * :release:`0.2.0 <2013.09.15>` * :feature:`0` Basic functionality. releases-1.4.0/docs/concepts.rst0000644000076500000240000004522513106375063017561 0ustar jforcierstaff00000000000000======== Concepts ======== This page contains conceptual info about how Releases organizes and thinks about issues and releases. For details on formatting/config options/etc (e.g. so you can interpret the examples below), see :doc:`/usage`. Issue and release types ======================= * Issues are always one of three types: **features**, **bug fixes** or **support items**. * **Features** are (typically larger) changes adding new behavior. * **Bug fixes** are (typically minor) changes addressing incorrect behavior, crashes, etc. * **Support items** vary in size but are usually non-code-related changes, such as documentation or packaging updates. * Releases also happen to come in three flavors: * **Major releases** are backwards incompatible releases, often with large/sweeping changes to a codebase. * They increment the first version number only, e.g. ``1.0.0``. * **Feature releases** (sometimes called **minor** or **secondary**) are backwards compatible with the previous major release, and focus on adding new functionality (code, or support, or both.) They sometimes include major/complex bug fixes which are too risky to include in a bugfix release. * The second version number is incremented for these, e.g. ``1.1.0``. * **Bugfix releases** (sometimes called **tertiary**) focus on fixing incorrect behavior while minimizing the risk of creating more bugs. Rarely, they will include small new features deemed important enough to backport from their 'native' feature release. * These releases increment the third/final version number, e.g. ``1.1.1``. Release organization ==================== We parse changelog timelines so the resulting per-release issue lists honor the above descriptions. Here are the core rules, with examples. See :doc:`/usage` for details on formatting/etc. * **By default, bugfixes go into bugfix releases, features and support items go into feature releases.** * Input:: * :release:`1.1.0 ` * :release:`1.0.1 ` * :support:`4` Updated our test runner * :bug:`3` Another bugfix * :feature:`2` Implemented new feature * :bug:`1` Fixed a bug * :release:`1.0.0 ` * Result: * ``1.1.0``: feature #2, support #4 * ``1.0.1``: bug #1, bug #3 * **Bugfixes are assumed to backport to all stable release lines by default, and are displayed as such.** However, this can be overridden on a per-release and/or per-bug basis - see later bullet points. * Input:: * :release:`1.1.1 ` * :release:`1.0.2 ` * :bug:`3` Fixed another bug, onoes * :release:`1.1.0 ` * :release:`1.0.1 ` * :feature:`2` Implemented new feature * :bug:`1` Fixed a bug * :release:`1.0.0 ` * Result: * ``1.1.1``: bug #3 * ``1.0.2``: bug #3 * ``1.1.0``: feature #2 * ``1.0.1``: bug #1 * **Bugfixes marked 'major' go into feature releases instead.** In other words, they're displayed as bugs, but organized as features. * Input:: * :release:`1.1.0 ` * :release:`1.0.1 ` * :bug:`3 major` Big bugfix with lots of changes * :feature:`2` Implemented new feature * :bug:`1` Fixed a bug * :release:`1.0.0 ` * Result: * ``1.1.0``: feature #2, bug #3 * ``1.0.1``: bug #1 * **Features or support items marked 'backported' appear in both bugfix and feature releases.** In other words, they're displayed as feature/support items, but organized as a combination feature/support *and* bug item. * Input:: * :release:`1.1.0 ` * :release:`1.0.1 ` * :bug:`4` Fixed another bug * :feature:`3` Regular feature * :feature:`2 backported` Small new feature worth backporting * :bug:`1` Fixed a bug * :release:`1.0.0 ` * Result: * ``1.1.0``: feature #2, feature #3 * ``1.0.1``: bug #1, feature #2, bug #4 * **Releases implicitly include all issues from their own, and prior, release lines.** (Again, unless the release explicitly states otherwise - see below.) * For example, in the below changelog (remembering that changelogs are written in descending order from newest to oldest entry) the code released as ``1.1.0`` includes the changes from bugs #1 and #3, in addition to its explicitly stated contents of feature #2:: * :release:`1.1.0 ` * :release:`1.0.1 ` * :bug:`3` Another bugfix * :feature:`2` Implemented new feature * :bug:`1` Fixed a bug * :release:`1.0.0 ` * Again, to be explicit, the rendered changelog displays this breakdown: * ``1.1.0``: feature #2 * ``1.0.1``: bug #1, bug #3 But it's *implied* that ``1.1.0`` includes the contents of ``1.0.1`` because it released afterwards/simultaneously and is a higher release line. * **Releases may be told explicitly which issues to include** (using a comma-separated list.) This is useful for the rare bugfix that gets backported beyond the actively supported release lines. For example, below shows a project whose lifecycle is "release 1.0; release 1.1 and drop active support for 1.0; put out a special 1.0.x release." Without the explicit issue list for 1.0.1, Releases would roll up all bugfixes, including the two that didn't actually apply to the 1.0 line. * Input:: * :release:`1.0.1 ` 1, 5 * :release:`1.1.1 ` * :bug:`5` Bugfix that applied back to 1.0. * :bug:`4` Bugfix that didn't apply to 1.0, only 1.1 * :bug:`3` Bugfix that didn't apply to 1.0, only 1.1 * :release:`1.1.0 ` * :feature:`2` Implemented new feature * :bug:`1` Fixed a 1.0.0 bug * :release:`1.0.0 ` * Result: * ``1.1.0``: feature #2 * ``1.1.1``: bugs #3, #4 and #5 * ``1.0.1``: bugs #1 and #5 only * **Bugfix issues may be told explicitly which release line they 'start' in.** This is useful for bugs that don't go back all the way to the oldest actively supported line - it keeps them from showing up in "too-old" releases. The below example includes a project actively supporting 1.5, 1.6 and 1.7 release lines, with a couple of bugfixes that only applied to 1.6+. * Input:: * :release:`1.7.1 ` * :release:`1.6.2 ` * :release:`1.5.3 ` * :bug:`50` Bug applying to all lines * :bug:`42 (1.6+)` A bug only applying to the new feature in 1.6 * :release:`1.7.0 ` * :release:`1.6.1 ` * :release:`1.5.2 ` * :feature:`25` Another new feature * :bug:`35` Bug that applies to all lines * :bug:`34` Bug that applies to all lines * :release:`1.6.0 ` * :release:`1.5.1 ` * :feature:`22` Some new feature * :bug:`20` Bugfix * :release:`1.5.0 ` * Result: * ``1.7.1``: bugs #50 and #42 * ``1.6.2``: bugs #50 and #42 * ``1.5.3``: bug #50 only * ``1.7.0``: feature #25 * ``1.6.1``: bugs #34, #35 * ``1.5.2``: bugs #34, #35 * ``1.6.0``: feature #22 * ``1.5.1``: bug #20 * **Bugs listed before the first release are treated as though they have the 'major' keyword.** This is chiefly because it makes no sense to have a "bugfix release" as one's first-ever release - you can't fix something that's not public! Then once the changelog parser passes that initial release, normal rules start to apply again. * Input:: * :release:`0.1.1` * :bug:`3` The feature had bugs :( * :release:`0.1.0 ` * :feature:`2` Our first ever feature * :bug:`1` Explicitly marked bug, even though that is silly * Implicit issue/entry here (becomes a bug by default) * Result: * ``0.1.1``: bug #3 only, since it's the only bug after the first release. * ``0.1.0``: everything else - the implicit bug, the explicit bug #1, and the feature #2. Major releases ============== Major releases introduce additional concerns to changelog organization on top of those above. Users whose software tends to just "roll forwards" without keeping older stable branches alive for bugfix releases, will likely not need to do much. However, when your support window stretches across major version boundaries, telling Releases which issues belong to which major version (or versions plural) becomes a bit more work. There are two main rules to keep in mind when dealing with "mixed" major versions: * **All issues encountered after a major release** are considered associated with that major release line **by default**. * **All feature-like items (features, support, major bugs) encountered just prior to a major release** are considered part of the major release itself. * To force association with a **different major release** (or set of major releases), issues may **specify a 'version spec'** annotation. Here's some examples to clarify. "Rolling" releases ------------------ This example has no mixing of release lines, just moving from 1.x to 2.x. 1.x is effectively abandoned. (Hope 2.x is an easy upgrade...) Note how features 4 and 5, because they are encountered prior to 2.0.0, are attached to it automatically. Input:: * :release:`2.1.0 ` * :release:`2.0.1 ` * :feature:`7` Yet another new feature * :bug:`6` A bug :( * :release:`2.0.0 ` * :feature:`5` Another (backwards incompatible) feature! * :feature:`4` A (backwards incompatible) feature! * :release:`1.1.0 ` * :release:`1.0.1 ` * :feature:`3` New feature * :bug:`2` Another bug * :bug:`1` An bug * :release:`1.0.0 ` Result: * ``2.1.0``: feature #7 * ``2.0.1``: bug #6 * ``2.0.0``: feature #4, feature #5 * ``1.1.0``: feature #3 * ``1.0.1``: bug #1, bug #2 Pretty simple, nothing actually new here. Mostly-compatible 2.0 with continued maint for 1.x -------------------------------------------------- This maintainer is a bit more conscientious/masochistic and wants to keep users of 1.x happy for a while after 2.0 launches. The timeline is very similar to the previous example, but in this scenario, all issues developed on the 1.x branch are forward-ported to 2.x, because 2.x wasn't a huge departure from 1.x. To signify this, post-2.0 issues that were developed initially for 1.x, are annotated with ``(1.0+)``, telling Releases to add them to all releases above 1.0, instead of just the most recent major release (2.0):: * :release:`2.1.0 ` * :release:`2.0.1 ` * :release:`1.2.0 ` * :release:`1.1.1 ` * :release:`1.0.2 ` * :bug:`9` A 2.0-only bugfix. * :feature:`8` A 2.0-only feature. * :feature:`7 (1.0+)` Yet another new feature * :bug:`6 (1.0+)` A bug :( * :release:`2.0.0 ` * :feature:`5` Another (backwards incompatible) feature! * :feature:`4` A (backwards incompatible) feature! * :release:`1.1.0 ` * :release:`1.0.1 ` * :feature:`3` New feature * :bug:`2` Another bug * :bug:`1` An bug * :release:`1.0.0 ` Result: * ``2.1.0``: feature #7, feature #8 * ``2.0.1``: bug #6, bug #9 * ``1.2.0``: feature #7, but not feature #8 * ``1.1.1``: bug #6, but not bug #9 * ``1.0.2``: bug #6, but not bug #9 * ``2.0.0``: feature #4, feature #5 * ``1.1.0``: feature #3 * ``1.0.1``: bug #1, bug #2 Some issues forward-ported, others not -------------------------------------- This time, some issues remain 1.x-specific as they don't apply to 2.x for whatever reason. The simple "X.Y+" format doesn't let us declare this, so we use one you're familiar with from packaging systems like ``setuptools``/``pip``: * ``(<2.0)`` signifies "only included in releases lower than 2.0" * ``(>=2.0)`` says "only include in release lines 2.0 and higher" (thus applying to 2.1, 2.2, 3.0, 4.0 etc). * This is identical to saying ``(2.0+)``; the ``+`` version is just a convenient / backwards compatible shorthand. * ``(>=2.0,<3.0)`` limits an issue to *just* the 2.x line, preventing its inclusion in 1.x, 3.x or anything else. * And so on; see the documentation for the ``Spec`` class at https://python-semanticversion.readthedocs.io for details. * To be clear, **you may put any combination of major+minor version number in these annotations**, just as with the simpler ``(1.5+)`` style format. * This is mostly applicable to bugs or backported issues. Features, support items and major bugs only need to inform Releases about major release lines. Armed with this more powerful syntax, we can limit some issues just to the 1.x line:: * :release:`2.1.0 ` * :release:`2.0.1 ` * :release:`1.2.0 ` * :release:`1.1.1 ` * :release:`1.0.2 ` * :feature:`9 (>=1.0)` A new feature that works with both versions (using the more explicit version of "1.0+") * :feature:`8` A new feature that only works on 2.x (no annotation needed) * :bug:`7 (<2.0)` A bug only affecting 1.x * :bug:`6 (1.0+)` A bug affecting all versions * :release:`2.0.0 ` * :feature:`5` Another (backwards incompatible) feature! * :feature:`4` A (backwards incompatible) feature! * :release:`1.1.0 ` * :release:`1.0.1 ` * :feature:`3` New feature * :bug:`2` Another bug * :bug:`1` An bug * :release:`1.0.0 ` Result: * ``2.1.0``: feature #8, feature #9 * ``2.0.1``: bug #6 (but not #7) * ``1.2.0``: feature #9 (but not #8) * ``1.1.1``: bug #6, bug #7 * ``1.0.2``: bug #6, bug #7 * ``2.0.0``: feature #4, feature #5 * ``1.1.0``: feature #3 * ``1.0.1``: bug #1, bug #2 Mixed-but-exclusive features prior to a new major release --------------------------------------------------------- This example illustrates a corner case where one is actively maintaining a "current" 1.x line at the same time as releasing the new 2.x line. Unlike the earlier examples, this one has both "2.0-only" *and* "1.0-only" features in the run-up to 2.0.0 (plus bugs). In this scenario, the non-annotated features are automatically assigned to the 2.0 major version, even though the 1.2.0 release technically came out "before" 2.0.0. As long as no non-release line items appear between 1.2.0 and 2.0.0, the system will behave as if 2.0.0 was the "primary" next release, with 1.2.0 only capturing features explicitly annotated as being "<2.0" (or similar). .. note:: This behavior holds true even if the adjacent release line-items have different dates; the heuristic is solely about their placement in the changelog list. Note also how bugs found in this window just prior to 2.0.0, remain associated with the 1.x line that they are fixing; it wouldn't make sense to publish a bugfix for unreleased functionality. Changelog:: * :release:`2.0.0 ` * :release:`1.2.0 ` * :release:`1.1.1 ` * :bug:`6` A bug found after 1.1.0 came out * :feature:`5 (<2.0)` A 1.0-only feature! * :feature:`4` A (backwards incompatible) feature! * :release:`1.1.0 ` * :release:`1.0.1 ` * :feature:`3` New feature * :bug:`2` Another bug * :bug:`1` An bug * :release:`1.0.0 ` Result: * ``2.0.0``: feature #4 (but not feature #5) * ``1.2.0``: feature #5 (but not feature #4) * ``1.1.1``: bug 6 * ``1.1.0``: feature #3 * ``1.0.1``: bug #1, bug #2 .. _unstable-prehistory: "Unstable prehistory" mode ========================== All of the above assumes a mature, semantic-versioning-enabled project, where you have stable release lines as well as a feature development 'trunk' branch. This doesn't always describe young projects, however - before one's 1.0.0, semantic versioning may not apply strongly or at all. When the ``releases_unstable_prehistory`` option is enabled (it's off by default for backwards compatibility reasons), changelog parsing/organizing behaves differently, until releases other than ``0.x.x`` are encountered: * All issues, regardless of type, are assigned to the very next release; there's no organizing along minor release lines, no 'major' bugs are necessary, nor are 'backported' features. * Unmarked line-items - which are normally considered to be bugs - are displayed without any classification (i.e. they don't get a 'Bug' prefix). * This is mostly to enable the types of "pre-Releases" changelogs wherein *all* line items lack issue-type role prefixes. * If your changelog *does* include explicit role prefixes (``:bug:``, ``:feature:`` etc) they are left untouched & will still visually appear as the indicated type. Example ------- Here's an example of what this option means. Take the following changelog:: * :release:`0.2.1 ` * Bugfix #7 * Feature #6, but meh, we arbitrarily are gonna call the next release a tertiary one anyways * Bugfix #5 * :release:`0.2.0 ` * Medium bugfix #4 * Tiny bugfix #3 * Feature #2 * :release:`0.1.0 ` * It works! First public release. Under normal Releases behavior this wouldn't match what the author clearly intends - all of these line items lack roles, so they'd all be "bugs", and then none of them would get inserted into 0.1.0 or 0.2.0 which are feature releases. With ``releases_unstable_prehistory`` enabled, we instead get: * ``0.2.1``: bugfix 5, feature 6, bugfix 7 * ``0.2.0``: feature 2, bugfix 3, bugfix 4 * ``0.1.0``: the beginning-of-time "it works!" note Crossing the 1.0 boundary ------------------------- As mentioned, even when this option is enabled, the 1.0.0 release (or whichever release is the first not beginning with ``0.``) implicitly deactivates this behavior. All subsequent issues then follow the behavior outlined in the rest of the document: bugfixes only go in tertiary releases, features only go in minor releases, etc. Another explicit example - this changelog (which is even more arbitrary with its versioning prior to 1.0):: * :release:`1.1.0 ` * :release:`1.0.1 ` * :feature:`8` A new, backwards compatible feature, hooray * :bug:`7` First post-1.0 bugfix! * :release:`1.0.0 ` * Bug #6 * Feature #5 * `0.5.0` * Feature #4 * Bug #3 * Bug #2 * `0.1.0` * Feature #1 The resulting changelog is organized like so: * ``1.1.0``: Feature #8 * ``1.0.1``: Bug #7 - no features, this is the first "real" bugfix release * ``1.0.0``: Bug #6, feature #5 - this is the last "unstable" release rolling up all prior issues. * ``0.5.0``: Bug #2, bug #3, feature #4 * ``0.1.0``: Feature #1 releases-1.4.0/docs/conf.py0000644000076500000240000000115513106375063016502 0ustar jforcierstaff00000000000000from datetime import datetime import os import sys import sphinx_rtd_theme extensions = [] templates_path = ['_templates'] source_suffix = '.rst' master_doc = 'index' project = u'Releases' year = datetime.now().year copyright = u'%d Jeff Forcier' % year # Ensure project directory is on PYTHONPATH for version, autodoc access sys.path.insert(0, os.path.abspath(os.path.join(os.getcwd(), '..'))) exclude_patterns = ['_build'] # RTD theme html_theme = "sphinx_rtd_theme" html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] # Dogfood extensions.append('releases') releases_github_path = 'bitprophet/releases' releases-1.4.0/docs/index.rst0000644000076500000240000000024213106375063017040 0ustar jforcierstaff00000000000000======== Releases ======== .. include:: ../README.rst Table of Contents ================= .. toctree:: :maxdepth: 2 concepts usage changelog releases-1.4.0/docs/usage.rst0000644000076500000240000001602513106375063017043 0ustar jforcierstaff00000000000000===== Usage ===== To use Releases, mimic the format seen in `its own changelog `_ or in `Fabric's changelog `_. Specifically: * Install ``releases`` and update your Sphinx ``conf.py`` to include it in the ``extensions`` list setting: ``extensions = ['releases']``. * Also set the ``releases_release_uri`` and ``releases_issue_uri`` top level options - they determine the targets of the issue & release links in the HTML output. Both should have an unevaluated ``%s`` where the release/issue number would go. * Alternately, if your project is hosted on Github, set the ``releases_github_path`` setting instead, to e.g. ``account/project``. Releases will then use an appropriate Github URL for both releases and issues. * If ``releases_release_uri`` or ``releases_issue_uri`` are *also* configured, they will be preferred over ``releases_github_path``. (If only one is configured, the other link type will continue using ``releases_github_path``.) * See `Fabric's docs/conf.py `_ for an example. * You may optionally set ``releases_debug = True`` to see debug output while building your docs. * If your changelog includes "simple" pre-1.0 releases derived from a single branch (i.e. without stable release lines & semantic versioning) you may want to set ``releases_unstable_prehistory = True``. * This is also useful if you've just imported a non-Releases changelog, where your issues are all basic list-items and you don't want to go through and add bug/feature/support/etc roles. * See :ref:`the appropriate conceptual docs ` for details on this behavior. * Create a Sphinx document named ``changelog.rst`` containing a bulleted list somewhere at its topmost level. * If you wish to use a different document name, use another config option (as per previous bullet point), ``releases_document_name``. E.g. ``releases_document_name = "CHANGES"`` would cause Releases to mutate a file called ``CHANGES.rst`` instead of ``changelog.rst``. * Elements before or after this bulleted list will be untouched by Releases, allowing you to place e.g. paragraphs, comments etc at the top (or bottom) of the document. * List items are to be ordered chronologically with the newest ones on top. * As you fix issues, put them on the top of the list. * As you cut releases, put those on the top of the list and they will include the issues below them. * Issues with no releases above them will end up in a specially marked "Unreleased" section of the rendered changelog. * Bullet list items should use the ``support``, ``feature`` or ``bug`` roles to mark issues, or ``release`` to mark a release. These special roles must be the first element in each list item. * Line-items that do not start with any issue role will be considered bugs (both in terms of inclusion in releases, and formatting) and, naturally, will not be given a hyperlink. * Issue roles are of the form ``:type:`number[ keyword]```. Specifically: * ``number`` is used to generate the link to the actual issue in your issue tracker (going by the ``releases_issue_uri`` option). It's used for both the link target & (part of) the link text. * If ``number`` is given as ``-`` or ``0`` (as opposed to a "real" issue number), no issue link will be generated. You can use this for items without a related issue. * Keywords are optional and may be one of: * ``backported``: Given on *support* or *feature* issues to denote backporting to bugfix releases; such issues will show up in both release types. E.g. placing ``:support:`123 backported``` in your changelog below releases '1.1.1' and '1.2.0' will cause it to appear in both of those releases' lists. * ``major``: Given on *bug* issues to denote inclusion in feature, instead of bugfix, releases. E.g. placing ``:bug:`22 major``` below releases '1.1.1' and '1.2.0' will cause it to appear in '1.2.0' **only**. * ``(N.N+)`` where ``N.N`` is a valid release line, e.g. ``1.1`` or ``2.10``: Given on issues (usually *bugs*) to denote minimum release line. E.g. when actively backporting most bugs to release lines 1.2, 1.3 and 1.4, you might specify ``:bug:`55 (1.3+)``` to note that bug 55 only applies to releases in 1.3 and above - not 1.2. * A `semantic version range spec covering minor+major version numbers `_ such as ``(<2.0)`` or ``(>=1.0,<3.1)``. A more powerful version of ``(N.N+)`` allowing annotation of issues belonging to specific major versions. .. note:: It is possible to give *both* a regular keyword (``backported``/``major``) *and* a spec (``(N.N+)``/``(>=1.0)``) in the same issue. However, giving two keywords or two specs at the same time makes no sense & is not allowed. * Regular Sphinx content may be given after issue roles and will be preserved as-is when rendering. For example, in ``:bug:`123` Fixed a bug, thanks `@somebody`!``, the rendered changelog will preserve/render "Fixed a bug, thanks ``@somebody``!" after the issue link. * Release roles are of the form ``:release:`number ```. * You may place a comma-separated (whitespace optional) list of issue numbers after the release role, and this will limit the issues included in that release to that explicit list. * Otherwise, releases include all relevant issues as outlined above and in :doc:`/concepts`. Then build your docs; in the rendered output, ``changelog.html`` should show issues grouped by release, as per the above rules. Examples: `Releases' own rendered changelog `_, `Fabric's rendered changelog `_. Optional styling additions ========================== If you have any nontrivial changelog entries (e.g. whose description spans multiple paragraphs or includes their own bulleted lists, etc) you may run into `docutils' rather enthusiastic bulleted list massaging `_ which can then make your releases look different from one another. To help combat this, it may be useful to add the following rule to the Sphinx theme you're using:: div#changelog > div.section > ul > li > p:only-child { margin-bottom: 0; } .. note:: Some themes, like `Alabaster `_, may already include this style rule. releases-1.4.0/LICENSE0000644000076500000240000000244213106375063015260 0ustar jforcierstaff00000000000000Copyright (c) 2017, Jeff Forcier All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. releases-1.4.0/MANIFEST.in0000644000076500000240000000026513106375063016012 0ustar jforcierstaff00000000000000include LICENSE include dev-requirements.txt include tasks.py recursive-include docs * recursive-exclude docs/_build * recursive-include tests * recursive-exclude tests *.pyc *.pyo releases-1.4.0/PKG-INFO0000644000076500000240000000203613172412372015345 0ustar jforcierstaff00000000000000Metadata-Version: 1.1 Name: releases Version: 1.4.0 Summary: A Sphinx extension for changelog manipulation Home-page: https://github.com/bitprophet/releases Author: Jeff Forcier Author-email: jeff@bitprophet.org License: UNKNOWN Description-Content-Type: UNKNOWN Description: UNKNOWN Platform: UNKNOWN Classifier: Development Status :: 5 - Production/Stable Classifier: Intended Audience :: Developers Classifier: License :: OSI Approved :: BSD License Classifier: Operating System :: MacOS :: MacOS X Classifier: Operating System :: Unix Classifier: Operating System :: POSIX Classifier: Operating System :: Microsoft :: Windows Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 2.7 Classifier: Programming Language :: Python :: 3.4 Classifier: Programming Language :: Python :: 3.5 Classifier: Programming Language :: Python :: 3.6 Classifier: Topic :: Software Development Classifier: Topic :: Software Development :: Documentation Classifier: Topic :: Documentation Classifier: Topic :: Documentation :: Sphinx releases-1.4.0/README.rst0000644000076500000240000000260313172210715015734 0ustar jforcierstaff00000000000000.. image:: https://secure.travis-ci.org/bitprophet/releases.png?branch=master :target: https://travis-ci.org/bitprophet/releases What is Releases? ================= Releases is a Python (2.7, 3.4+) compatible `Sphinx `_ (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: * The source format (kept in your Sphinx tree as ``changelog.rst``) is a stream-like timeline that plays well with source control & only requires one entry per change (even for changes that exist in multiple release lines). * The output (when you have the extension installed and run your Sphinx build command) is a traditional looking changelog page with a section for every release; multi-release issues are copied automatically into each release. * By default, feature and support issues are only displayed under feature releases, and bugs are only displayed under bugfix releases. This can be overridden on a per-issue basis. 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 via ``pip install -e git+https://github.com/bitprophet/releases/#egg=releases``. releases-1.4.0/releases/0000755000076500000240000000000013172412372016052 5ustar jforcierstaff00000000000000releases-1.4.0/releases/__init__.py0000644000076500000240000006446713172207456020211 0ustar jforcierstaff00000000000000import itertools import re 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): """ Log debug output if debug setting is on. Intended to be partial'd w/ config at top of functions. Meh. """ if config.releases_debug: sys.stderr.write(str(txt) + "\n") sys.stderr.flush() def issue_nodelist(name, identifier=None): which = '[%s]' % ( ISSUE_TYPES[name], name.capitalize() ) signifier = [nodes.raw(text=which, format='html')] id_nodelist = [nodes.inline(text=" "), identifier] if identifier else [] trail = [] if identifier else [nodes.inline(text=" ")] return signifier + id_nodelist + [nodes.inline(text=":")] + trail release_line_re = re.compile(r'^(\d+\.\d+)\+$') # e.g. '1.2+' def scan_for_spec(keyword): """ Attempt to return some sort of Spec from given keyword value. Returns None if one could not be derived. """ # Both 'spec' formats are wrapped in parens, discard keyword = keyword.lstrip('(').rstrip(')') # First, test for intermediate '1.2+' style matches = release_line_re.findall(keyword) if matches: return Spec(">={}".format(matches[0])) # Failing that, see if Spec can make sense of it try: return Spec(keyword) # I've only ever seen Spec fail with ValueError. except ValueError: return None def issues_role(name, rawtext, text, lineno, inliner, options={}, content=[]): """ Use: :issue|bug|feature|support:`ticket_number` When invoked as :issue:, turns into just a "#NN" hyperlink to `releases_issue_uri`. When invoked otherwise, turns into "[Type] <#NN hyperlink>: ". Spaces present in the "ticket number" are used as fields for keywords (major, backported) and/or specs (e.g. '>=1.0'). This data is removed & used when constructing the object. May give a 'ticket number' of ``-`` or ``0`` to generate no hyperlink. """ parts = utils.unescape(text).split() issue_no = parts.pop(0) # Lol @ access back to Sphinx config = inliner.document.settings.env.app.config if issue_no not in ('-', '0'): ref = None if config.releases_issue_uri: # TODO: deal with % vs .format() ref = config.releases_issue_uri % issue_no elif config.releases_github_path: 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: identifier = nodes.reference( rawtext, '#' + issue_no, refuri=ref, **options ) # Otherwise, just make it regular text else: identifier = nodes.raw( rawtext=rawtext, text='#' + issue_no, format='html', **options ) else: identifier = None issue_no = None # So it doesn't gum up dupe detection later # Additional 'new-style changelog' stuff if name in ISSUE_TYPES: nodelist = issue_nodelist(name, identifier) spec = None keyword = None # TODO: sanity checks re: e.g. >2 parts, >1 instance of keywords, >1 # instance of specs, etc. for part in parts: maybe_spec = scan_for_spec(part) if maybe_spec: spec = maybe_spec else: if part in ('backported', 'major'): keyword = part else: 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( number=issue_no, type_=name, nodelist=nodelist, backported=(keyword == 'backported'), major=(keyword == 'major'), spec=spec, ) return [node], [] # Return old style info for 'issue' for older changelog entries else: return [identifier], [] def release_nodes(text, slug, date, config): # Doesn't seem possible to do this "cleanly" (i.e. just say "make me a # title and give it these HTML attributes during render time) so...fuckit. # We were already doing fully raw elements elsewhere anyway. And who cares # about a PDF of a changelog? :x uri = None if config.releases_release_uri: # TODO: % vs .format() uri = config.releases_release_uri % slug elif config.releases_github_path: 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 = '{}'.format(uri, text) else: link = text datespan = '' if date: datespan = ' {}'.format(date) header = '

{}{}

'.format( link, datespan) return nodes.section('', nodes.raw(rawtext='', text=header, format='html'), ids=[text] ) year_arg_re = re.compile(r'^(.+?)\s*(?$', re.DOTALL) def release_role(name, rawtext, text, lineno, inliner, options={}, content=[]): """ Invoked as :release:`N.N.N `. Turns into useful release header + link to GH tree for the tag. """ # Make sure year has been specified match = year_arg_re.match(text) if not match: msg = inliner.reporter.error("Must specify release date!") return [inliner.problematic(rawtext, rawtext, msg)], [msg] number, date = match.group(1), match.group(2) # Lol @ access back to Sphinx config = inliner.document.settings.env.app.config nodelist = [release_nodes(number, number, date, config)] # Return intermediate node node = Release(number=number, date=date, nodelist=nodelist) return [node], [] def generate_unreleased_entry(header, line, issues, manager, app): log = partial(_log, config=app.config) nodelist = [release_nodes( header, # TODO: should link to master for newest family and...what # exactly, for the others? Expectation isn't necessarily to # have a branch per family? Or is there? Maybe there must be.. 'master', None, app.config )] log("Creating {!r} faux-release with {!r}".format(line, issues)) return { 'obj': Release(number=line, date=None, nodelist=nodelist), 'entries': issues, } def append_unreleased_entries(app, manager, releases): """ Generate new abstract 'releases' for unreleased issues. There's one for each combination of bug-vs-feature & major release line. When only one major release line exists, that dimension is ignored. """ for family, lines in six.iteritems(manager): for type_ in ('bugfix', 'feature'): bucket = 'unreleased_{}'.format(type_) if bucket not in lines: # Implies unstable prehistory + 0.x fam continue issues = lines[bucket] 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) ) def reorder_release_entries(releases): """ Mutate ``releases`` so the entrylist in each is ordered by feature/bug/etc. """ order = {'feature': 0, 'bug': 1, 'support': 2} for release in releases: entries = release['entries'][:] release['entries'] = sorted(entries, key=lambda x: order[x.type]) def construct_entry_with_release(focus, issues, manager, log, releases, rest): """ Releases 'eat' the entries in their line's list and get added to the final data structure. They also inform new release-line 'buffers'. Release lines, once the release obj is removed, should be empty or a comma-separated list of issue numbers. """ log("release for line %r" % focus.minor) # Check for explicitly listed issues first explicit = None if rest[0].children: explicit = [x.strip() for x in rest[0][0].split(',')] # Do those by themselves since they override all other logic if explicit: log("Explicit issues requested: %r" % (explicit,)) # First scan global issue dict, dying if not found missing = [i for i in explicit if i not in issues] if missing: raise ValueError( "Couldn't find issue(s) #{} in the changelog!".format( ', '.join(missing))) # Obtain the explicitly named issues from global list entries = [] for i in explicit: for flattened_issue_item in itertools.chain(issues[i]): entries.append(flattened_issue_item) # Create release log("entries in this release: %r" % (entries,)) releases.append({ 'obj': focus, 'entries': entries, }) # Introspect these entries to determine which buckets they should get # removed from (it's not "all of them"!) for obj in entries: if obj.type == 'bug': # Major bugfix: remove from unreleased_feature if obj.major: log("Removing #%s from unreleased" % obj.number) # TODO: consider making a LineManager method somehow manager[focus.family]['unreleased_feature'].remove(obj) # Regular bugfix: remove from bucket for this release's # line + unreleased_bugfix else: if obj in manager[focus.family]['unreleased_bugfix']: log("Removing #%s from unreleased" % obj.number) manager[focus.family]['unreleased_bugfix'].remove(obj) if obj in manager[focus.family][focus.minor]: log("Removing #%s from %s" % (obj.number, focus.minor)) manager[focus.family][focus.minor].remove(obj) # Regular feature/support: remove from unreleased_feature # Backported feature/support: remove from bucket for this # release's line (if applicable) + unreleased_feature else: log("Removing #%s from unreleased" % obj.number) manager[focus.family]['unreleased_feature'].remove(obj) if obj in manager[focus.family].get(focus.minor, []): manager[focus.family][focus.minor].remove(obj) # Implicit behavior otherwise else: # Unstable prehistory -> just dump 'unreleased' and continue if manager.unstable_prehistory: # TODO: need to continue making LineManager actually OO, i.e. do # away with the subdicts + keys, move to sub-objects with methods # answering questions like "what should I give you for a release" # or whatever log("in unstable prehistory, dumping 'unreleased'") releases.append({ 'obj': focus, # NOTE: explicitly dumping 0, not focus.family, since this # might be the last pre-historical release and thus not 0.x 'entries': manager[0]['unreleased'][:], }) manager[0]['unreleased'] = [] # If this isn't a 0.x release, it signals end of prehistory, make a # new release bucket (as is also done below in regular behavior). # Also acts like a sentinel that prehistory is over. if focus.family != 0: manager[focus.family][focus.minor] = [] # Regular behavior from here else: # New release line/branch detected. Create it & dump unreleased # features. if focus.minor not in manager[focus.family]: log("not seen prior, making feature release & bugfix bucket") manager[focus.family][focus.minor] = [] # TODO: this used to explicitly say "go over everything in # unreleased_feature and dump if it's feature, support or major # bug". But what the hell else would BE in unreleased_feature? # Why not just dump the whole thing?? # # Dump only the items in the bucket whose family this release # object belongs to, i.e. 1.5.0 should only nab the 1.0 # family's unreleased feature items. releases.append({ 'obj': focus, 'entries': manager[focus.family]['unreleased_feature'][:], }) manager[focus.family]['unreleased_feature'] = [] # Existing line -> empty out its bucket into new release. # Skip 'major' bugs as those "belong" to the next release (and will # also be in 'unreleased_feature' - so safe to nuke the entire # line) else: log("pre-existing, making bugfix release") # TODO: as in other branch, I don't get why this wasn't just # dumping the whole thing - why would major bugs be in the # regular bugfix buckets? entries = manager[focus.family][focus.minor][:] releases.append({'obj': focus, 'entries': entries}) manager[focus.family][focus.minor] = [] # Clean out the items we just released from # 'unreleased_bugfix'. (Can't nuke it because there might # be some unreleased bugs for other release lines.) for x in entries: if x in manager[focus.family]['unreleased_bugfix']: manager[focus.family]['unreleased_bugfix'].remove(x) def construct_entry_without_release(focus, issues, manager, log, rest): # Handle rare-but-valid non-issue-attached line items, which are # always bugs. (They are their own description.) if not isinstance(focus, Issue): # First, sanity check for potential mistakes resulting in an issue node # being buried within something else. buried = focus.traverse(Issue) if buried: msg = """ Found issue node ({!r}) buried inside another node: {} 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. For example, indentation problems can accidentally generate nested definition lists. """ raise ValueError(msg.format(buried[0], str(buried[0].parent))) # OK, it looks legit - make it a bug. log("Found line item w/ no real issue object, creating bug") nodelist = issue_nodelist('bug') # Skip nodelist entirely if we're in unstable prehistory - # classification doesn't matter there. if manager.unstable_prehistory: nodelist = [] # Undo the 'pop' from outer scope. TODO: rework things so we don't have # to do this dumb shit uggggh rest[0].insert(0, focus) focus = Issue( type_='bug', nodelist=nodelist, description=rest, ) else: focus.attributes['description'] = rest # Add to global list (for use by explicit releases) or die trying issues[focus.number] = issues.get(focus.number, []) + [focus] # Add to per-release bugfix lines and/or unreleased bug/feature buckets, as # necessary. # TODO: suspect all of add_to_manager can now live in the manager; most of # Release's methods should probably go that way if manager.unstable_prehistory: log("Unstable prehistory -> adding to 0.x unreleased bucket") manager[0]['unreleased'].append(focus) else: log("Adding to release line manager") focus.add_to_manager(manager) def handle_upcoming_major_release(entries, manager): # Short-circuit if the future holds nothing for us if not entries: return # Short-circuit if we're in the middle of a block of releases, only the # last release before a bunch of issues, should be taking any action. if isinstance(entries[0], Release): return # Iterate through entries til we find the next Release or set of Releases next_releases = [] for index, obj in enumerate(entries): if isinstance(obj, Release): next_releases.append(obj) # Non-empty next_releases + encountered a non-release = done w/ release # block. elif next_releases: break # Examine result: is a major release present? If so, add its major number # to the line manager! for obj in next_releases: # TODO: update when Release gets tied closer w/ Version version = Version(obj.number) if version.minor == 0 and version.patch == 0: manager.add_family(obj.family) def handle_first_release_line(entries, manager): """ Set up initial line-manager entry for first encountered release line. To be called at start of overall process; afterwards, subsequent major lines are generated by `handle_upcoming_major_release`. """ # It's remotely possible the changelog is totally empty... if not entries: return # Obtain (short-circuiting) first Release obj. first_release = None for obj in entries: if isinstance(obj, Release): first_release = obj break # It's also possible it's non-empty but has no releases yet. if first_release: manager.add_family(obj.family) # If God did not exist, man would be forced to invent him. else: manager.add_family(0) def construct_releases(entries, app): log = partial(_log, config=app.config) # Walk from back to front, consuming entries & copying them into # per-release buckets as releases are encountered. Store releases in order. releases = [] # Release lines, to be organized by major releases, then by major+minor, # alongside per-major-release 'unreleased' bugfix/feature buckets. # NOTE: With exception of unstable_prehistory=True, which triggers use of a # separate, undifferentiated 'unreleased' bucket (albeit still within the # '0' major line family). manager = LineManager(app) # Also keep a master hash of issues by number to detect duplicates & assist # in explicitly defined release lists. issues = {} reversed_entries = list(reversed(entries)) # For the lookahead, so we're not doing this stripping O(n) times. # TODO: probs just merge the two into e.g. a list of 2-tuples of "actual # entry obj + rest"? stripped_entries = [x[0][0] for x in reversed_entries] # Perform an initial lookahead to prime manager with the 1st major release handle_first_release_line(stripped_entries, manager) # Start crawling... for index, obj in enumerate(reversed_entries): # Issue object is always found in obj (LI) index 0 (first, often only # P) and is the 1st item within that (index 0 again). # Preserve all other contents of 'obj'. focus = obj[0].pop(0) rest = obj log(repr(focus)) # Releases 'eat' the entries in their line's list and get added to the # final data structure. They also inform new release-line 'buffers'. # Release lines, once the release obj is removed, should be empty or a # comma-separated list of issue numbers. if isinstance(focus, Release): construct_entry_with_release( focus, issues, manager, log, releases, rest ) # After each release is handled, look ahead to see if we're # entering "last stretch before a major release". If so, # pre-emptively update the line-manager so upcoming features are # correctly sorted into that major release by default (re: logic in # Release.add_to_manager) handle_upcoming_major_release( stripped_entries[index + 1:], manager ) # Entries get copied into release line buckets as follows: # * Features and support go into 'unreleased_feature' for use in new # feature releases. # * Bugfixes go into all release lines (so they can be printed in >1 # bugfix release as appropriate) as well as 'unreleased_bugfix' (so # they can be displayed prior to release'). Caveats include bugs marked # 'major' (they go into unreleased_feature instead) or with 'N.N+' # (meaning they only go into release line buckets for that release and # up.) # * Support/feature entries marked as 'backported' go into all # release lines as well, on the assumption that they were released to # all active branches. # * The 'rest' variable (which here is the bug description, vitally # important!) is preserved by stuffing it into the focus (issue) # object - it will get unpacked by construct_nodes() later. else: construct_entry_without_release(focus, issues, manager, log, rest) if manager.unstable_prehistory: releases.append(generate_unreleased_entry( header="Next release", line="unreleased", issues=manager[0]['unreleased'], manager=manager, app=app, )) else: append_unreleased_entries(app, manager, releases) reorder_release_entries(releases) return releases, manager def construct_nodes(releases): result = [] # Reverse the list again so the final display is newest on top for d in reversed(releases): if not d['entries']: continue obj = d['obj'] entries = [] for entry in d['entries']: # Use nodes.Node.deepcopy to deepcopy the description # node. If this is not done, multiple references to the same # object (e.g. a reference object in the description of #649, which # is then copied into 2 different release lists) will end up in the # doctree, which makes subsequent parse steps very angry (index() # errors). desc = entry['description'].deepcopy() # Additionally, expand any other issue roles found in the # description - sometimes we refer to related issues inline. (They # can't be left as issue() objects at render time since that's # undefined.) # Use [:] slicing to avoid mutation during the loops. for index, node in enumerate(desc[:]): for subindex, subnode in enumerate(node[:]): if isinstance(subnode, Issue): lst = subnode['nodelist'] desc[index][subindex:subindex + 1] = lst # Rework this entry to insert the now-rendered issue nodes in front # of the 1st paragraph of the 'description' nodes (which should be # the preserved LI + nested paragraph-or-more from original # markup.) # FIXME: why is there no "prepend a list" method? for node in reversed(entry['nodelist']): desc[0].insert(0, node) entries.append(desc) # Entry list list_ = nodes.bullet_list('', *entries) # Insert list into release nodelist (as it's a section) obj['nodelist'][0].append(list_) # Release header header = nodes.paragraph('', '', *obj['nodelist']) result.extend(header) return result class BulletListVisitor(nodes.NodeVisitor): def __init__(self, document, app): nodes.NodeVisitor.__init__(self, document) self.found_changelog = False self.app = app def visit_bullet_list(self, node): # The first found bullet list (which should be the first one at the top # level of the document) is the changelog. if not self.found_changelog: self.found_changelog = True # Walk + parse into release mapping 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)) def unknown_visit(self, node): pass def generate_changelog(app, doctree): # Don't scan/mutate documents that don't match the configured document name # (which by default is changelog.rst). if app.env.docname != app.config.releases_document_name: return # Find the first bullet-list node & replace it with our organized/parsed # elements. changelog_visitor = BulletListVisitor(doctree, app) doctree.walk(changelog_visitor) def setup(app): for key, default in ( # Issue base URI setting: releases_issue_uri # E.g. 'https://github.com/fabric/fabric/issues/' ('issue_uri', None), # Release-tag base URI setting: releases_release_uri # E.g. 'https://github.com/fabric/fabric/tree/' ('release_uri', None), # Convenience Github version of above ('github_path', None), # Which document to use as the changelog ('document_name', 'changelog'), # Debug output ('debug', False), # Whether to enable linear history during 0.x release timeline # TODO: flip this to True by default in our 2.0 release ('unstable_prehistory', False), ): app.add_config_value( name='releases_{}'.format(key), default=default, rebuild='html' ) # Register intermediate roles for x in list(ISSUE_TYPES) + ['issue']: 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) releases-1.4.0/releases/_version.py0000644000076500000240000000012013172412372020241 0ustar jforcierstaff00000000000000__version_info__ = (1, 4, 0) __version__ = '.'.join(map(str, __version_info__)) releases-1.4.0/releases/line_manager.py0000644000076500000240000000501413172207554021051 0ustar jforcierstaff00000000000000# TODO: un-subclass dict in favor of something more explicit, once all regular # dict-like access has been factored out into methods class LineManager(dict): """ Manages multiple release lines/families as well as related config state. """ def __init__(self, app): """ Initialize new line manager dict. :param app: The core Sphinx app object. Mostly used for config. """ super(LineManager, self).__init__() self.app = app @property def config(self): """ Return Sphinx config object. """ return self.app.config def add_family(self, major_number): """ Expand to a new release line with given ``major_number``. This will flesh out mandatory buckets like ``unreleased_bugfix`` and do other necessary bookkeeping. """ # Normally, we have separate buckets for bugfixes vs features keys = ['unreleased_bugfix', 'unreleased_feature'] # But unstable prehistorical releases roll all up into just # 'unreleased' if major_number == 0 and self.config.releases_unstable_prehistory: keys = ['unreleased'] # Either way, the buckets default to an empty list self[major_number] = {key: [] for key in keys} @property def unstable_prehistory(self): """ Returns True if 'unstable prehistory' behavior should be applied. Specifically, checks config & whether any non-0.x releases exist. """ return ( self.config.releases_unstable_prehistory and not self.has_stable_releases ) @property def stable_families(self): """ Returns release family numbers which aren't 0 (i.e. prehistory). """ return [x for x in self if x != 0] @property def has_stable_releases(self): """ Returns whether stable (post-0.x) releases seem to exist. """ nonzeroes = self.stable_families # Nothing but 0.x releases -> yup we're prehistory if not nonzeroes: return False # Presumably, if there's >1 major family besides 0.x, we're at least # one release into the 1.0 (or w/e) line. if len(nonzeroes) > 1: return True # If there's only one, we may still be in the space before its N.0.0 as # well; we can check by testing for existence of bugfix buckets return any( x for x in self[nonzeroes[0]] if not x.startswith('unreleased') ) releases-1.4.0/releases/models.py0000644000076500000240000001644413172207370017720 0ustar jforcierstaff00000000000000from functools import reduce from operator import xor from docutils import nodes from semantic_version import Version as StrictVersion, Spec import six class Version(StrictVersion): """ Version subclass toggling ``partial=True`` by default. """ def __init__(self, version_string, partial=True): super(Version, self).__init__(version_string, partial) # Issue type list (keys) + color values ISSUE_TYPES = { 'bug': 'A04040', 'feature': '40A056', 'support': '4070A0', } class Issue(nodes.Element): # Technically, we just need number, but heck, you never know... _cmp_keys = ('type', 'number', 'backported', 'major') @property def type(self): return self['type_'] @property def is_featurelike(self): if self.type == 'bug': return self.major else: return not self.backported @property def is_buglike(self): return not self.is_featurelike @property def backported(self): return self.get('backported', False) @property def major(self): return self.get('major', False) @property def number(self): return self.get('number', None) @property def spec(self): return self.get('spec', None) def __eq__(self, other): for attr in self._cmp_keys: if getattr(self, attr, None) != getattr(other, attr, None): return False return True def __hash__(self): return reduce(xor, [hash(getattr(self, x)) for x in self._cmp_keys]) def minor_releases(self, manager): """ Return all minor release line labels found in ``manager``. """ # TODO: yea deffo need a real object for 'manager', heh. E.g. we do a # very similar test for "do you have any actual releases yet?" # elsewhere. (This may be fodder for changing how we roll up # pre-major-release features though...?) return [ key for key, value in six.iteritems(manager) if any(x for x in value if not x.startswith('unreleased')) ] def default_spec(self, manager): """ Given the current release-lines structure, return a default Spec. Specifics: * For feature-like issues, only the highest major release is used, so given a ``manager`` with top level keys of ``[1, 2]``, this would return ``Spec(">=2")``. * When ``releases_always_forwardport_features`` is ``True``, that behavior is nullified, and this function always returns the empty ``Spec`` (which matches any and all versions/lines). * For bugfix-like issues, we only consider major release families which have actual releases already. * Thus the core difference here is that features are 'consumed' by upcoming major releases, and bugfixes are not. * When the ``unstable_prehistory`` setting is ``True``, the default spec starts at the oldest non-zero release line. (Otherwise, issues posted after prehistory ends would try being added to the 0.x part of the tree, which makes no sense in unstable-prehistory mode.) """ # TODO: I feel like this + the surrounding bits in add_to_manager() # could be consolidated & simplified... specstr = "" # 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 = ">={}".format(min(stable_families)) if self.is_featurelike: # TODO: if app->config->=2, this means we don't even bother # looking in the 1.x family. families = [Version(str(x)) for x in manager] versions = list(spec.filter(families)) for version in versions: family = version.major # Within each family, we further limit which bugfix lines match up # to what self cares about (ignoring 'unreleased' until later) candidates = [ Version(x) for x in manager[family] if not x.startswith('unreleased') ] # Select matching release lines (& stringify) buckets = [] bugfix_buckets = [str(x) for x in spec.filter(candidates)] # Add back in unreleased_* as appropriate # TODO: probably leverage Issue subclasses for this eventually? if self.is_buglike: buckets.extend(bugfix_buckets) # Don't put into JUST unreleased_bugfix; it implies that this # major release/family hasn't actually seen any releases yet # and only exists for features to go into. if bugfix_buckets: buckets.append('unreleased_bugfix') # Obtain list of minor releases to check for "haven't had ANY # releases yet" corner case, in which case ALL issues get thrown in # unreleased_feature for the first release to consume. # NOTE: assumes first release is a minor or major one, # but...really? why would your first release be a bugfix one?? no_releases = not self.minor_releases(manager) if self.is_featurelike or self.backported or no_releases: buckets.append('unreleased_feature') # Now that we know which buckets are appropriate, add ourself to # all of them. TODO: or just...do it above...instead... for bucket in buckets: manager[family][bucket].append(self) def __repr__(self): flag = '' if self.backported: flag = 'backported' elif self.major: flag = 'major' elif self.spec: flag = self.spec if flag: flag = ' ({})'.format(flag) return '<{issue.type} #{issue.number}{flag}>'.format(issue=self, flag=flag) class Release(nodes.Element): @property def number(self): return self['number'] @property def minor(self): # TODO: use Version return '.'.join(self.number.split('.')[:-1]) @property def family(self): # TODO: use Version.major # TODO: and probs just rename to .major, 'family' is dumb tbh return int(self.number.split('.')[0]) def __repr__(self): return ''.format(self.number) releases-1.4.0/releases/util.py0000644000076500000240000002360113172237724017411 0ustar jforcierstaff00000000000000""" 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} releases-1.4.0/releases.egg-info/0000755000076500000240000000000013172412372017544 5ustar jforcierstaff00000000000000releases-1.4.0/releases.egg-info/dependency_links.txt0000644000076500000240000000000113172412372023612 0ustar jforcierstaff00000000000000 releases-1.4.0/releases.egg-info/PKG-INFO0000644000076500000240000000203613172412372020642 0ustar jforcierstaff00000000000000Metadata-Version: 1.1 Name: releases Version: 1.4.0 Summary: A Sphinx extension for changelog manipulation Home-page: https://github.com/bitprophet/releases Author: Jeff Forcier Author-email: jeff@bitprophet.org License: UNKNOWN Description-Content-Type: UNKNOWN Description: UNKNOWN Platform: UNKNOWN Classifier: Development Status :: 5 - Production/Stable Classifier: Intended Audience :: Developers Classifier: License :: OSI Approved :: BSD License Classifier: Operating System :: MacOS :: MacOS X Classifier: Operating System :: Unix Classifier: Operating System :: POSIX Classifier: Operating System :: Microsoft :: Windows Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 2.7 Classifier: Programming Language :: Python :: 3.4 Classifier: Programming Language :: Python :: 3.5 Classifier: Programming Language :: Python :: 3.6 Classifier: Topic :: Software Development Classifier: Topic :: Software Development :: Documentation Classifier: Topic :: Documentation Classifier: Topic :: Documentation :: Sphinx releases-1.4.0/releases.egg-info/requires.txt0000644000076500000240000000004413172412372022142 0ustar jforcierstaff00000000000000semantic_version<3.0 sphinx<2,>=1.3 releases-1.4.0/releases.egg-info/SOURCES.txt0000644000076500000240000000074013172412372021431 0ustar jforcierstaff00000000000000LICENSE MANIFEST.in README.rst dev-requirements.txt setup.cfg setup.py tasks.py docs/changelog.rst docs/concepts.rst docs/conf.py docs/index.rst docs/usage.rst releases/__init__.py releases/_version.py releases/line_manager.py releases/models.py releases/util.py releases.egg-info/PKG-INFO releases.egg-info/SOURCES.txt releases.egg-info/dependency_links.txt releases.egg-info/requires.txt releases.egg-info/top_level.txt tests/_util.py tests/organization.py tests/presentation.pyreleases-1.4.0/releases.egg-info/top_level.txt0000644000076500000240000000001113172412372022266 0ustar jforcierstaff00000000000000releases releases-1.4.0/setup.cfg0000644000076500000240000000024713172412372016073 0ustar jforcierstaff00000000000000[flake8] exclude = docs,.git,build,dist ignore = E124,E125,E128,E261,E301,E302,E303 max-line-length = 79 [wheel] universal = 1 [egg_info] tag_build = tag_date = 0 releases-1.4.0/setup.py0000644000076500000240000000245613172206625015772 0ustar jforcierstaff00000000000000#!/usr/bin/env python from setuptools import setup # Version info -- read without importing _locals = {} with open('releases/_version.py') as fp: exec(fp.read(), None, _locals) version = _locals['__version__'] setup( name='releases', version=version, description='A Sphinx extension for changelog manipulation', author='Jeff Forcier', author_email='jeff@bitprophet.org', url='https://github.com/bitprophet/releases', packages=['releases'], install_requires=[ 'semantic_version<3.0', 'sphinx>=1.3,<2', ], classifiers=[ 'Development Status :: 5 - Production/Stable', 'Intended Audience :: Developers', 'License :: OSI Approved :: BSD License', 'Operating System :: MacOS :: MacOS X', 'Operating System :: Unix', 'Operating System :: POSIX', 'Operating System :: Microsoft :: Windows', 'Programming Language :: Python', 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', 'Topic :: Software Development', 'Topic :: Software Development :: Documentation', 'Topic :: Documentation', 'Topic :: Documentation :: Sphinx', ], ) releases-1.4.0/tasks.py0000644000076500000240000000101713106375063015747 0ustar jforcierstaff00000000000000from os.path import join from invocations import docs from invocations.testing import test, integration, watch_tests from invocations.packaging import release from invoke import Collection ns = Collection(test, integration, watch_tests, release, docs) ns.configure({ 'tests': { 'package': 'releases', }, 'packaging': { 'sign': True, 'wheel': True, 'changelog_file': join( docs.ns.configuration()['sphinx']['source'], 'changelog.rst', ), }, }) releases-1.4.0/tests/0000755000076500000240000000000013172412372015411 5ustar jforcierstaff00000000000000releases-1.4.0/tests/_util.py0000644000076500000240000000670613172224427017112 0ustar jforcierstaff00000000000000from docutils.nodes import ( list_item, paragraph, ) from mock import Mock from spec import eq_, ok_ import six from releases import ( Issue, issues_role, Release, release_role, construct_releases, ) from releases.util import make_app, changelog2dict def inliner(app=None): app = app or make_app() return Mock(document=Mock(settings=Mock(env=Mock(app=app)))) # Obtain issue() object w/o wrapping all parse steps def issue(type_, number, **kwargs): text = str(number) if kwargs.get('backported', False): text += " backported" if kwargs.get('major', False): text += " major" if kwargs.get('spec', None): text += " (%s)" % kwargs['spec'] app = kwargs.get('app', None) return issues_role( name=type_, rawtext='', text=text, lineno=None, inliner=inliner(app=app), )[0][0] # Even shorter shorthand! def b(number, **kwargs): return issue('bug', str(number), **kwargs) def f(number, **kwargs): return issue('feature', str(number), **kwargs) def s(number, **kwargs): return issue('support', str(number), **kwargs) def entry(i): """ Easy wrapper for issue/release objects. Default is to give eg an issue/release object that gets wrapped in a LI->P. May give your own (non-issue/release) object to skip auto wrapping. (Useful since entry() is often called a few levels deep.) """ if not isinstance(i, (Issue, Release)): return i return list_item('', paragraph('', '', i)) def release(number, **kwargs): app = kwargs.get('app', None) nodes = release_role( name=None, rawtext='', text='%s <2013-11-20>' % number, lineno=None, inliner=inliner(app=app), )[0] return list_item('', paragraph('', '', *nodes)) def release_list(*entries, **kwargs): skip_initial = kwargs.pop('skip_initial', False) entries = list(entries) # lol tuples # Translate simple objs into changelog-friendly ones for index, item in enumerate(entries): if isinstance(item, six.string_types): entries[index] = release(item) else: entries[index] = entry(item) # Insert initial/empty 1st release to start timeline if not skip_initial: entries.append(release('1.0.0')) return entries def releases(*entries, **kwargs): app = kwargs.pop('app', None) or make_app() return construct_releases(release_list(*entries, **kwargs), app)[0] def setup_issues(self): self.f = f(12) self.s = s(5) self.b = b(15) self.mb = b(200, major=True) self.bf = f(27, backported=True) self.bs = s(29, backported=True) def expect_releases(entries, release_map, skip_initial=False, app=None): kwargs = {'skip_initial': skip_initial} # Let high level tests tickle config settings via make_app() if app is not None: kwargs['app'] = app changelog = changelog2dict(releases(*entries, **kwargs)) snapshot = dict(changelog) 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)) # Sanity: ensure no leftover issue lists exist (empty ones are OK) for key in list(changelog.keys()): if not changelog[key]: del changelog[key] ok_(not changelog, "Found leftovers: {}".format(changelog)) releases-1.4.0/tests/organization.py0000644000076500000240000003743313106375063020503 0ustar jforcierstaff00000000000000import six from spec import Spec, eq_, raises, skip from docutils.nodes import ( list_item, raw, paragraph, Text, ) from releases import ( Issue, construct_releases, ) from _util import ( b, f, s, changelog2dict, expect_releases, make_app, release_list, releases, setup_issues, ) class organization(Spec): """ Organization of issues into releases (parsing) """ def setup(self): setup_issues(self) def _expect_entries(self, all_entries, in_, not_in): # Grab 2nd release as 1st is the empty 'beginning of time' one entries = releases(*all_entries)[1]['entries'] eq_(len(entries), len(in_)) for x in in_: assert x in entries for x in not_in: assert x not in entries def feature_releases_include_features_and_support_not_bugs(self): self._expect_entries( ['1.1.0', self.f, self.b, self.s], [self.f, self.s], [self.b] ) def feature_releases_include_major_bugs(self): self._expect_entries( ['1.1.0', self.f, self.b, self.mb], [self.f, self.mb], [self.b] ) def bugfix_releases_include_bugs(self): self._expect_entries( ['1.0.2', self.f, self.b, self.mb], [self.b], [self.mb, self.f], ) def bugfix_releases_include_backported_features(self): self._expect_entries( ['1.0.2', self.bf, self.b, self.s], [self.b, self.bf], [self.s] ) def bugfix_releases_include_backported_support(self): self._expect_entries( ['1.0.2', self.f, self.b, self.s, self.bs], [self.b, self.bs], [self.s, self.f] ) def backported_features_also_appear_in_feature_releases(self): entries = ( '1.1.0', '1.0.2', self.bf, self.b, self.s, ) # Ensure bf (backported feature) is in BOTH 1.0.2 AND 1.1.0 expected = { '1.0.2': [self.bf, self.b], '1.1.0': [self.bf, self.s], } expect_releases(entries, expected) def unmarked_bullet_list_items_treated_as_bugs(self): fake = list_item('', paragraph('', '', raw('', 'whatever'))) changelog = releases('1.0.2', self.f, fake) entries = changelog[1]['entries'] eq_(len(entries), 1) assert self.f not in entries assert isinstance(entries[0], Issue) eq_(entries[0].number, None) def unreleased_items_go_in_unreleased_releases(self): changelog = releases(self.f, self.b) # Should have two unreleased lists, one feature w/ feature, one bugfix # w/ bugfix. bugfix, feature = changelog[1:] eq_(len(feature['entries']), 1) eq_(len(bugfix['entries']), 1) assert self.f in feature['entries'] assert self.b in bugfix['entries'] eq_(feature['obj'].number, 'unreleased_1.x_feature') eq_(bugfix['obj'].number, 'unreleased_1.x_bugfix') def issues_consumed_by_releases_are_not_in_unreleased(self): changelog = releases('1.0.2', self.f, self.b, self.s, self.bs) release = changelog[1]['entries'] unreleased = changelog[-1]['entries'] assert self.b in release assert self.b not in unreleased def oddly_ordered_bugfix_releases_and_unreleased_list(self): # Release set up w/ non-contiguous feature+bugfix releases; catches # funky problems with 'unreleased' buckets b2 = b(2) f3 = f(3) changelog = releases( '1.1.1', '1.0.2', self.f, b2, '1.1.0', f3, self.b ) assert f3 in changelog[1]['entries'] assert b2 in changelog[2]['entries'] assert b2 in changelog[3]['entries'] def release_line_bugfix_specifier(self): b50 = b(50) b42 = b(42, spec='1.1+') f25 = f(25) b35 = b(35) b34 = b(34) f22 = f(22) b20 = b(20) c = changelog2dict(releases( '1.2.1', '1.1.2', '1.0.3', b50, b42, '1.2.0', '1.1.1', '1.0.2', f25, b35, b34, '1.1.0', '1.0.1', f22, b20 )) for rel, issues in ( ('1.0.1', [b20]), ('1.1.0', [f22]), ('1.0.2', [b34, b35]), ('1.1.1', [b34, b35]), ('1.2.0', [f25]), ('1.0.3', [b50]), # the crux - is not b50 + b42 ('1.1.2', [b50, b42]), ('1.2.1', [b50, b42]), ): eq_(set(c[rel]), set(issues)) def releases_can_specify_issues_explicitly(self): # Build regular list-o-entries b2 = b(2) b3 = b(3) changelog = release_list( '1.0.1', '1.1.1', b3, b2, self.b, '1.1.0', self.f ) # Modify 1.0.1 release to be speshul changelog[0][0].append(Text("2, 3")) 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'] assert self.b not in one_0_1 assert b2 in one_0_1 assert b3 in one_0_1 # 1.1.1 includes all 3 (i.e. the explicitness of 1.0.1 didn't affect # the 1.1 line bucket.) assert self.b in one_1_1 assert b2 in one_1_1 assert b3 in one_1_1 def explicit_release_list_split_works_with_unicode(self): changelog = release_list('1.0.1', b(17)) changelog[0][0].append(Text(six.text_type('17'))) # When using naive method calls, this explodes construct_releases(changelog, make_app()) def explicit_feature_release_features_are_removed_from_unreleased(self): f1 = f(1) f2 = f(2) 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())[0]) # 1.1.0 should have feature 2 only assert f2 in rendered['1.1.0'] assert f1 not in rendered['1.1.0'] # unreleased feature list should still get/see feature 1 assert f1 in rendered['unreleased_1.x_feature'] # now-released feature 2 should not be in unreleased_feature assert f2 not in rendered['unreleased_1.x_feature'] def explicit_bugfix_releases_get_removed_from_unreleased(self): b1 = b(1) b2 = b(2) 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()) # 1.0.1 should have bug 2 only assert b2 in rendered[1]['entries'] assert b1 not in rendered[1]['entries'] # unreleased bug list should still get/see bug 1 assert b1 in rendered[2]['entries'] @raises(ValueError) def explicit_releases_error_on_unfound_issues(self): # Just a release - result will have 1.0.0, 1.0.1, and unreleased changelog = release_list('1.0.1') # No issues listed -> this clearly doesn't exist in any buckets changelog[1][0].append(Text("25")) # This should asplode construct_releases(changelog, make_app()) def duplicate_issue_numbers_adds_two_issue_items(self): test_changelog = releases('1.0.1', self.b, self.b) test_changelog = changelog2dict(test_changelog) eq_(len(test_changelog['1.0.1']), 2) def duplicate_zeroes_dont_error(self): cl = releases('1.0.1', b(0), b(0)) cl = changelog2dict(cl) assert len(cl['1.0.1']) == 2 def issues_are_sorted_by_type_within_releases(self): b1 = b(123, major=True) b2 = b(124, major=True) s1 = s(25) s2 = s(26) f1 = f(3455) f2 = f(3456) # Semi random definitely-not-in-desired-order order changelog = changelog2dict(releases('1.1', b1, s1, s2, f1, b2, f2)) # Order should be feature, bug, support. While it doesn't REALLY # matter, assert that within each category the order matches the old # 'reverse chronological' order. eq_(changelog['1.1'], [f2, f1, b2, b1, s2, s1]) def rolling_release_works_without_annotation(self): b1 = b(1) b2 = b(2) f3 = f(3) f4 = f(4) f5 = f(5) b6 = b(6) f7 = f(7) entries = ( '2.1.0', '2.0.1', f7, b6, '2.0.0', f5, f4, '1.1.0', '1.0.1', f3, b2, b1 ) expected = { '1.0.1': [b1, b2], '1.1.0': [f3], '2.0.0': [f4, f5], '2.0.1': [b6], '2.1.0': [f7], } expect_releases(entries, expected) def plus_annotations_let_old_lines_continue_getting_released(self): b9 = b(9) f8 = f(8) f7 = f(7, spec="1.0+") b6 = b(6, spec="1.0+") f5 = f(5) f4 = f(4) f3 = f(3) b2 = b(2) b1 = b(1) entries = ( '2.1.0', '2.0.1', '1.2.0', '1.1.1', '1.0.2', b9, f8, f7, b6, '2.0.0', f5, f4, '1.1.0', '1.0.1', f3, b2, b1, ) expected = { '2.1.0': [f7, f8], '2.0.1': [b6, b9], '1.2.0': [f7], # but not f8 '1.1.1': [b6], # but not b9 '1.0.2': [b6], # but not b9 '2.0.0': [f4, f5], '1.1.0': [f3], '1.0.1': [b1, b2], } expect_releases(entries, expected) def semver_spec_annotations_allow_preventing_forward_porting(self): f9 = f(9, spec=">=1.0") f8 = f(8) b7 = b(7, spec="<2.0") b6 = b(6, spec="1.0+") f5 = f(5) f4 = f(4) f3 = f(3) b2 = b(2) b1 = b(1) entries = ( '2.1.0', '2.0.1', '1.2.0', '1.1.1', '1.0.2', f9, f8, b7, b6, '2.0.0', f5, f4, '1.1.0', '1.0.1', f3, b2, b1, ) expected = { '2.1.0': [f8, f9], '2.0.1': [b6], # (but not #7) '1.2.0': [f9], # (but not #8) '1.1.1': [b6, b7], '1.0.2': [b6, b7], '2.0.0': [f4, f5], '1.1.0': [f3], '1.0.1': [b1, b2], } expect_releases(entries, expected) def bugs_before_major_releases_associate_with_previous_release_only(self): b1 = b(1) b2 = b(2) f3 = f(3) f4 = f(4) f5 = f(5, spec="<2.0") b6 = b(6) entries = ( '2.0.0', '1.2.0', '1.1.1', b6, f5, f4, '1.1.0', '1.0.1', f3, b2, b1, ) expected = { '2.0.0': [f4], # but not f5 '1.2.0': [f5], # but not f4 '1.1.1': [b6], '1.1.0': [f3], '1.0.1': [b1, b2] } expect_releases(entries, expected) def semver_double_ended_specs_work_when_more_than_two_major_versions(self): skip() def can_disable_default_pin_to_latest_major_version(self): skip() def features_before_first_release_function_correctly(self): f0 = f(0) b1 = b(1) f2 = f(2) entries = ( '0.2.0', f2, '0.1.1', b1, '0.1.0', f0 ) expected = { '0.1.0': [f0], '0.1.1': [b1], '0.2.0': [f2], } # Make sure to skip typically-implicit 1.0.0 release. # TODO: consider removing that entirely; arguably needing it is a bug? expect_releases(entries, expected, skip_initial=True) def all_bugs_before_first_release_act_featurelike(self): b1 = b(1) f2 = f(2) b3 = b(3) implicit = list_item('', paragraph('', '', raw('', 'whatever'))) changelog = changelog2dict(releases( '0.1.1', b3, '0.1.0', f2, b1, implicit, skip_initial=True )) first = changelog['0.1.0'] second = changelog['0.1.1'] assert b1 in first assert f2 in first eq_(len(first), 3) # Meh, hard to assert about the implicit one eq_(second, [b3]) def specs_and_keywords_play_together_nicely(self): b1 = b(1) b2 = b(2, major=True, spec='1.0+') f3 = f(3) # Feature copied to both 1.x and 2.x branches f4 = f(4, spec='1.0+') # Support item backported to bugfix line + 1.17 + 2.0.0 s5 = s(5, spec='1.0+', backported=True) entries = ( '2.0.0', '1.17.0', '1.16.1', s5, f4, f3, b2, b1, '1.16.0', ) expected = { '1.16.1': [b1, s5], # s5 backported ok '1.17.0': [b2, f4, s5], # s5 here too, plus major bug b2 '2.0.0': [b2, f3, f4, s5], # all featurelike items here } expect_releases(entries, expected) def changelogs_without_any_releases_display_unreleased_normally(self): changelog = releases(self.f, self.b, skip_initial=True) # Ensure only the two unreleased 'releases' showed up eq_(len(changelog), 2) # And assert that both items appeared in one of them (since there's no # real releases at all, the bugfixes are treated as 'major' bugs, as # per concepts doc.) bugfix, feature = changelog eq_(len(feature['entries']), 2) eq_(len(bugfix['entries']), 0) class unstable_prehistory: def _expect_releases(self, *args, **kwargs): """ expect_releases() wrapper setting unstable_prehistory by default """ kwargs['app'] = make_app(unstable_prehistory=True) return expect_releases(*args, **kwargs) def all_issue_types_rolled_up_together(self): # Pre-1.0-only base case entries = ( '0.1.1', f(4), b(3), '0.1.0', f(2), b(1), ) expected = { '0.1.1': [b(3), f(4)], '0.1.0': [b(1), f(2)], } self._expect_releases(entries, expected, skip_initial=True) def does_not_affect_releases_after_1_0(self): # Mixed changelog crossing 1.0 boundary entries = ( '1.1.0', '1.0.1', f(6), b(5), '1.0.0', f(4), b(3), '0.1.0', f(2), b(1), ) expected = { '1.1.0': [f(6)], '1.0.1': [b(5)], '1.0.0': [b(3), f(4)], '0.1.0': [b(1), f(2)], } self._expect_releases(entries, expected, skip_initial=True) def doesnt_care_if_you_skipped_1_0_entirely(self): # Mixed changelog where 1.0 is totally skipped and one goes to 2.0 entries = ( '2.1.0', '2.0.1', f(6), b(5), '2.0.0', f(4), b(3), '0.1.0', f(2), b(1), ) expected = { '2.1.0': [f(6)], '2.0.1': [b(5)], '2.0.0': [b(3), f(4)], '0.1.0': [b(1), f(2)], } self._expect_releases(entries, expected, skip_initial=True) def explicit_unstable_releases_still_eat_their_issues(self): # I.e. an 0.x.y releases using explicit issue listings, works # correctly - the explicitly listed issues don't appear in nearby # implicit releases. skip() releases-1.4.0/tests/presentation.py0000644000076500000240000002222613106375063020504 0ustar jforcierstaff00000000000000from spec import Spec, eq_ from docutils.nodes import ( reference, bullet_list, list_item, literal, raw, paragraph, Text ) from releases import ( Issue, construct_releases, construct_nodes, ) from _util import ( b, f, s, entry, make_app, release, releases, setup_issues, ) def _obj2name(obj): cls = obj if isinstance(obj, type) else obj.__class__ return cls.__name__.split('.')[-1] def _expect_type(node, cls): type_ = _obj2name(node) name = _obj2name(cls) msg = "Expected %r to be a %s, but it's a %s" % (node, name, type_) assert isinstance(node, cls), msg class presentation(Spec): """ Expansion/extension of docutils nodes (rendering) """ def setup(self): setup_issues(self) def _generate(self, *entries, **kwargs): raw = kwargs.pop('raw', False) nodes = construct_nodes(releases(*entries, **kwargs)) # By default, yield the contents of the bullet list. return nodes if raw else nodes[0][1][0] def _test_link(self, kwargs, type_, expected): app = make_app(**kwargs) nodes = construct_nodes(construct_releases([ release('1.0.2', app=app), entry(b(15, app=app)), release('1.0.0'), ], app=app)[0]) # Shorthand for "I'll do my own asserts" if expected is None: return nodes if type_ == 'release': header = nodes[0][0][0].astext() assert expected in header elif type_ == 'issue': link = nodes[0][1][0][0][2] eq_(link['refuri'], expected) else: raise Exception("Gave unknown type_ kwarg to _test_link()!") def issues_with_numbers_appear_as_number_links(self): self._test_link({}, 'issue', 'bar_15') def releases_appear_as_header_links(self): self._test_link({}, 'release', 'foo_1.0.2') def links_will_use_github_option_if_defined(self): kwargs = { 'release_uri': None, 'issue_uri': None, 'github_path': 'foo/bar', } for type_, expected in ( ('issue', 'https://github.com/foo/bar/issues/15'), ('release', 'https://github.com/foo/bar/tree/1.0.2'), ): self._test_link(kwargs, type_, expected) def issue_links_prefer_explicit_setting_over_github_setting(self): kwargs = { 'release_uri': None, 'issue_uri': 'explicit_issue_%s', 'github_path': 'foo/bar', } self._test_link(kwargs, 'issue', 'explicit_issue_15') def release_links_prefer_explicit_setting_over_github_setting(self): kwargs = { 'release_uri': 'explicit_release_%s', 'issue_uri': None, 'github_path': 'foo/bar', } self._test_link(kwargs, 'release', 'explicit_release_1.0.2') def completely_blank_uri_settings_does_not_asplode(self): kwargs = { 'release_uri': None, 'issue_uri': None, 'github_path': None, } # Get nodes for direct inspection nodes = self._test_link(kwargs, 'release', None) # Ensure release entry still displays release version. # (These are curently constructed as raw text nodes so no other great # way to test this. Meh.) text = nodes[0][0][0].astext() assert '>1.0.2 Bug] #15:' in text def _assert_prefix(self, entries, expectation): assert expectation in self._generate(*entries)[0][0][0] def bugs_marked_as_bugs(self): self._assert_prefix(['1.0.2', self.b], 'Bug') def features_marked_as_features(self): self._assert_prefix(['1.1.0', self.f], 'Feature') def support_marked_as_support(self): self._assert_prefix(['1.1.0', self.s], 'Support') def dashed_issues_appear_as_unlinked_issues(self): node = self._generate('1.0.2', b('-')) assert not isinstance(node[0][2], reference) def zeroed_issues_appear_as_unlinked_issues(self): node = self._generate('1.0.2', b(0)) assert not isinstance(node[0][2], reference) def un_prefixed_list_items_appear_as_unlinked_bugs(self): fake = list_item('', paragraph('', '', Text("fixes an issue in "), literal('', 'methodname'))) node = self._generate('1.0.2', fake) # [, , , , ] eq_(len(node[0]), 5) assert 'Bug' in str(node[0][0]) assert 'fixes an issue' in str(node[0][3]) assert 'methodname' in str(node[0][4]) def un_prefixed_list_items_get_no_prefix_under_unstable_prehistory(self): app = make_app(unstable_prehistory=True) fake = list_item('', paragraph('', '', raw('', 'whatever'))) node = self._generate('0.1.0', fake, app=app, skip_initial=True) # [] eq_(len(node[0]), 1) assert 'Bug' not in str(node[0][0]) assert 'whatever' in str(node[0][0]) def issues_remain_wrapped_in_unordered_list_nodes(self): node = self._generate('1.0.2', self.b, raw=True)[0][1] _expect_type(node, bullet_list) _expect_type(node[0], list_item) def release_headers_have_local_style_tweaks(self): node = self._generate('1.0.2', self.b, raw=True)[0][0] _expect_type(node, raw) # Header w/ bottom margin assert '