diff --git a/PKG-INFO b/PKG-INFO index ace45ca..dfb90b7 100644 --- a/PKG-INFO +++ b/PKG-INFO @@ -1,20 +1,42 @@ -Metadata-Version: 1.1 +Metadata-Version: 1.2 Name: releases -Version: 1.6.3 +Version: 2.1.1 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: .. image:: https://secure.travis-ci.org/bitprophet/releases.png?branch=master - :target: https://travis-ci.org/bitprophet/releases +Project-URL: Docs, https://releases.readthedocs.io +Project-URL: Source, https://github.com/bitprophet/releases +Project-URL: Changelog, https://releases.readthedocs.io/en/latest/changelog.html +Project-URL: CI, https://app.circleci.com/pipelines/github/bitprophet/releases +Description: |version| |python| |license| |ci| |coverage| + + .. |version| image:: https://img.shields.io/pypi/v/releases + :target: https://pypi.org/project/releases/ + :alt: PyPI - Package Version + .. |python| image:: https://img.shields.io/pypi/pyversions/releases + :target: https://pypi.org/project/releases/ + :alt: PyPI - Python Version + .. |license| image:: https://img.shields.io/pypi/l/releases + :target: https://github.com/bitprophet/releases/blob/main/LICENSE + :alt: PyPI - License + .. |ci| image:: https://img.shields.io/circleci/build/github/bitprophet/releases/main + :target: https://app.circleci.com/pipelines/github/bitprophet/releases + :alt: CircleCI + .. |coverage| image:: https://img.shields.io/codecov/c/gh/bitprophet/releases + :target: https://app.codecov.io/gh/bitprophet/releases + :alt: Codecov + 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. + Releases is a `Sphinx `_ extension designed to help you + keep a source control friendly, merge friendly changelog file & turn it into + useful, human readable HTML output. It's compatible with Python 3.6+, and may + work on Sphinx versions as far back as 1.8.x, though 4.x and up are + recommended and generally all we will support. Specifically: @@ -31,7 +53,9 @@ Description: .. image:: https://secure.travis-ci.org/bitprophet/releases.png?bra Some background on why this tool was created can be found in `this blog post `_. - For more documentation, please see http://releases.readthedocs.io. + For more documentation, please see http://releases.readthedocs.io. For a + roadmap, see the maintainer's `roadmap page + `_. .. note:: You can install the development version via ``pip install -e @@ -46,11 +70,16 @@ 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 +Classifier: Programming Language :: Python :: 3 :: Only Classifier: Programming Language :: Python :: 3.6 +Classifier: Programming Language :: Python :: 3.7 +Classifier: Programming Language :: Python :: 3.8 +Classifier: Programming Language :: Python :: 3.9 +Classifier: Programming Language :: Python :: 3.10 +Classifier: Programming Language :: Python :: 3.11 Classifier: Topic :: Software Development Classifier: Topic :: Software Development :: Documentation Classifier: Topic :: Documentation Classifier: Topic :: Documentation :: Sphinx +Requires-Python: >=3.6 diff --git a/README.rst b/README.rst index bdaac6b..6a7b187 100644 --- a/README.rst +++ b/README.rst @@ -1,12 +1,30 @@ -.. image:: https://secure.travis-ci.org/bitprophet/releases.png?branch=master - :target: https://travis-ci.org/bitprophet/releases +|version| |python| |license| |ci| |coverage| + +.. |version| image:: https://img.shields.io/pypi/v/releases + :target: https://pypi.org/project/releases/ + :alt: PyPI - Package Version +.. |python| image:: https://img.shields.io/pypi/pyversions/releases + :target: https://pypi.org/project/releases/ + :alt: PyPI - Python Version +.. |license| image:: https://img.shields.io/pypi/l/releases + :target: https://github.com/bitprophet/releases/blob/main/LICENSE + :alt: PyPI - License +.. |ci| image:: https://img.shields.io/circleci/build/github/bitprophet/releases/main + :target: https://app.circleci.com/pipelines/github/bitprophet/releases + :alt: CircleCI +.. |coverage| image:: https://img.shields.io/codecov/c/gh/bitprophet/releases + :target: https://app.codecov.io/gh/bitprophet/releases + :alt: Codecov + 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. +Releases is a `Sphinx `_ extension designed to help you +keep a source control friendly, merge friendly changelog file & turn it into +useful, human readable HTML output. It's compatible with Python 3.6+, and may +work on Sphinx versions as far back as 1.8.x, though 4.x and up are +recommended and generally all we will support. Specifically: @@ -23,7 +41,9 @@ Specifically: Some background on why this tool was created can be found in `this blog post `_. -For more documentation, please see http://releases.readthedocs.io. +For more documentation, please see http://releases.readthedocs.io. For a +roadmap, see the maintainer's `roadmap page +`_. .. note:: You can install the development version via ``pip install -e diff --git a/debian/changelog b/debian/changelog index 29305ea..c5647eb 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,52 @@ +python-releases (2.1.1-2) unstable; urgency=medium + + * Team upload. + * Set DPT as Maintainer per new Team Policy + * Use new dh-sequence-python3 + + -- Alexandre Detiste Wed, 28 Aug 2024 17:51:42 +0200 + +python-releases (2.1.1-1) unstable; urgency=medium + + * Team upload. + + [ Debian Janitor ] + * Remove constraints unnecessary since buster (oldstable): + + Build-Depends: Drop versioned constraint on python3-sphinx and + python3-sphinx-rtd-theme. + * Bump debhelper from old 12 to 13. + * Re-export upstream signing key without extra signatures. + * Update standards version to 4.6.1, no changes needed. + + [ Emmanuel Arias ] + * d/watch: Remove option pgpsigurlmangle. + - d/u/signing-key.asc: Remove file it is not in pypi.d.n repository. + * New upstream version (Closes: #1067826, #1073488). + * d/patches: Update + 0001-Remove-travis-ci-image-and-link-to-avoid-privacy-bre.patch, + 0002-Remove-calculated-date-from-docs-conf.py-to-support-.patch and + 0003-Fix-usage-of-semanticversion-to-intended-API.patch patches. + * d/watch: Bump watch version to 4. + + -- Emmanuel Arias Sun, 16 Jun 2024 11:52:48 -0300 + +python-releases (1.6.3-2) unstable; urgency=low + + [ Debian Janitor ] + * Set upstream metadata fields: Bug-Database, Bug-Submit, Repository, + Repository-Browse. + * Update standards version to 4.5.0, no changes needed. + + [ Ondřej Nový ] + * d/control: Update Vcs-* fields with new Debian Python Team Salsa + layout. + + [ Stefano Rivera ] + * d/control: Update Uploaders field with new Debian Python Team + contact address. + + -- Sandro Tosi Fri, 03 Jun 2022 23:33:05 -0400 + python-releases (1.6.3-1) unstable; urgency=medium * Team upload. diff --git a/debian/control b/debian/control index 3e23072..8b82290 100644 --- a/debian/control +++ b/debian/control @@ -1,18 +1,18 @@ Source: python-releases Section: python Priority: optional -Maintainer: Zygmunt Krynicki -Uploaders: Debian Python Modules Team -Build-Depends: debhelper-compat (= 12), - dh-python, +Maintainer: Debian Python Team +Uploaders: Zygmunt Krynicki , +Build-Depends: debhelper-compat (= 13), + dh-sequence-python3, python3-all, python3-semantic-version, python3-setuptools, - python3-sphinx (>= 1.3.1-3~), - python3-sphinx-rtd-theme (>= 0.1.8-2~) -Standards-Version: 4.4.1 -Vcs-Git: https://salsa.debian.org/python-team/modules/python-releases.git -Vcs-Browser: https://salsa.debian.org/python-team/modules/python-releases + python3-sphinx, + python3-sphinx-rtd-theme +Standards-Version: 4.6.1 +Vcs-Git: https://salsa.debian.org/python-team/packages/python-releases.git +Vcs-Browser: https://salsa.debian.org/python-team/packages/python-releases Homepage: https://github.com/bitprophet/releases Rules-Requires-Root: no Testsuite: autopkgtest-pkg-python @@ -47,6 +47,7 @@ Package: python-releases-doc Architecture: all Section: doc Depends: ${misc:Depends}, ${sphinxdoc:Depends} +Multi-Arch: foreign Description: Sphinx extension for changelog manipulation documentation Releases is a Sphinx extension designed to help you keep a source control friendly, merge friendly changelog file & turn it into useful, human readable diff --git a/debian/patches/0001-Remove-travis-ci-image-and-link-to-avoid-privacy-bre.patch b/debian/patches/0001-Remove-travis-ci-image-and-link-to-avoid-privacy-bre.patch index 999154a..2843a53 100644 --- a/debian/patches/0001-Remove-travis-ci-image-and-link-to-avoid-privacy-bre.patch +++ b/debian/patches/0001-Remove-travis-ci-image-and-link-to-avoid-privacy-bre.patch @@ -6,13 +6,27 @@ Subject: Remove travis-ci image and link to avoid privacy breach README.rst | 3 --- 1 file changed, 3 deletions(-) -diff --git a/README.rst b/README.rst -index bdaac6b..cab592a 100644 --- a/README.rst +++ b/README.rst -@@ -1,6 +1,3 @@ --.. image:: https://secure.travis-ci.org/bitprophet/releases.png?branch=master -- :target: https://travis-ci.org/bitprophet/releases +@@ -1,22 +1,5 @@ + |version| |python| |license| |ci| |coverage| + +-.. |version| image:: https://img.shields.io/pypi/v/releases +- :target: https://pypi.org/project/releases/ +- :alt: PyPI - Package Version +-.. |python| image:: https://img.shields.io/pypi/pyversions/releases +- :target: https://pypi.org/project/releases/ +- :alt: PyPI - Python Version +-.. |license| image:: https://img.shields.io/pypi/l/releases +- :target: https://github.com/bitprophet/releases/blob/main/LICENSE +- :alt: PyPI - License +-.. |ci| image:: https://img.shields.io/circleci/build/github/bitprophet/releases/main +- :target: https://app.circleci.com/pipelines/github/bitprophet/releases +- :alt: CircleCI +-.. |coverage| image:: https://img.shields.io/codecov/c/gh/bitprophet/releases +- :target: https://app.codecov.io/gh/bitprophet/releases +- :alt: Codecov +- - What is Releases? ================= diff --git a/debian/patches/0002-Remove-calculated-date-from-docs-conf.py-to-support-.patch b/debian/patches/0002-Remove-calculated-date-from-docs-conf.py-to-support-.patch index efd9d8f..a23e301 100644 --- a/debian/patches/0002-Remove-calculated-date-from-docs-conf.py-to-support-.patch +++ b/debian/patches/0002-Remove-calculated-date-from-docs-conf.py-to-support-.patch @@ -7,8 +7,6 @@ Subject: Remove calculated date from docs/conf.py to support reproducible docs/conf.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) -diff --git a/docs/conf.py b/docs/conf.py -index 6b6ff31..6964763 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,4 +1,3 @@ @@ -16,12 +14,12 @@ index 6b6ff31..6964763 100644 import os import sys -@@ -11,7 +10,7 @@ source_suffix = '.rst' - master_doc = 'index' +@@ -11,7 +10,7 @@ + master_doc = "index" - project = u'Releases' --year = datetime.now().year -+year = 2015 - copyright = u'%d Jeff Forcier' % year + project = "Releases" +-copyright = f"{datetime.now().year} Jeff Forcier" ++copyright = "2015 Jeff Forcier" # Ensure project directory is on PYTHONPATH for version, autodoc access + sys.path.insert(0, os.path.abspath(os.path.join(os.getcwd(), ".."))) diff --git a/debian/patches/0003-Fix-usage-of-semanticversion-to-intended-API.patch b/debian/patches/0003-Fix-usage-of-semanticversion-to-intended-API.patch index 4057cb5..b63ee40 100644 --- a/debian/patches/0003-Fix-usage-of-semanticversion-to-intended-API.patch +++ b/debian/patches/0003-Fix-usage-of-semanticversion-to-intended-API.patch @@ -35,11 +35,9 @@ Bug-Debian: https://bugs.debian.org/944263 releases/models.py | 54 ++++++++++++++++++++++++++++------------------------ 2 files changed, 30 insertions(+), 26 deletions(-) -diff --git a/releases/__init__.py b/releases/__init__.py -index fc8763c..3e1289d 100644 --- a/releases/__init__.py +++ b/releases/__init__.py -@@ -424,7 +424,7 @@ def handle_upcoming_major_release(entries, manager): +@@ -425,7 +425,7 @@ # to the line manager! for obj in next_releases: # TODO: update when Release gets tied closer w/ Version @@ -48,40 +46,36 @@ index fc8763c..3e1289d 100644 if version.minor == 0 and version.patch == 0: manager.add_family(obj.family) -diff --git a/releases/models.py b/releases/models.py -index d980e9c..0517174 100644 --- a/releases/models.py +++ b/releases/models.py -@@ -2,18 +2,10 @@ from functools import reduce +@@ -2,16 +2,7 @@ from operator import xor from docutils import nodes -from semantic_version import Version as StrictVersion, Spec -+from semantic_version import Version, 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) -- - +- def __init__(self, version_string, partial=True): +- super().__init__(version_string, partial) ++from semantic_version import Version, Spec + + # Issue type list (keys) + color values - ISSUE_TYPES = { - 'bug': 'A04040', -@@ -122,7 +114,7 @@ class Issue(nodes.Element): +@@ -119,7 +110,7 @@ buckets = self.minor_releases(manager) if buckets: specstr = ">={}".format(max(buckets)) - return Spec(specstr) if specstr else Spec() -+ return Spec(specstr) if specstr else Spec('*') ++ return Spec(specstr) if specstr else Spec("*") def add_to_manager(self, manager): """ -@@ -130,32 +122,43 @@ class Issue(nodes.Element): +@@ -127,32 +118,39 @@ """ # Derive version spec allowing us to filter against major/minor buckets spec = self.spec or self.default_spec(manager) @@ -96,7 +90,6 @@ index d980e9c..0517174 100644 - # to what self cares about (ignoring 'unreleased' until later) - candidates = [ - Version(x) -+ + # Browse through families, adding us to every line we match. + for family in manager: + # Map changelog keys to Version objects, keeping a link @@ -104,11 +97,10 @@ index d980e9c..0517174 100644 + versions = { + Version.coerce(x): x for x in manager[family] - if not x.startswith('unreleased') + if not x.startswith("unreleased") - ] - # Select matching release lines (& stringify) + } -+ + # Bail out if no listed version (included pending feature/bugfix) + # match self.spec: if self is an issue for >=2, don't look + # at the 1.x family. If self is an issue for >=1.0, include it @@ -128,23 +120,32 @@ index d980e9c..0517174 100644 if self.is_buglike: - buckets.extend(bugfix_buckets) + # Convert back Version() to line -+ buckets.extend([ -+ versions[bucket] for bucket in bugfix_buckets -+ ]) ++ buckets.extend([versions[bucket] for bucket in 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') + 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. -@@ -164,6 +167,7 @@ class Issue(nodes.Element): +@@ -161,6 +159,7 @@ no_releases = not self.minor_releases(manager) if self.is_featurelike or self.backported or no_releases: - buckets.append('unreleased_feature') + 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: +@@ -176,9 +175,7 @@ + flag = self.spec + if flag: + flag = " ({})".format(flag) +- return "<{issue.type} #{issue.number}{flag}>".format( +- issue=self, flag=flag +- ) ++ return "<{issue.type} #{issue.number}{flag}>".format(issue=self, flag=flag) + + + class Release(nodes.Element): diff --git a/debian/rules b/debian/rules index 56306f3..cba5a0f 100755 --- a/debian/rules +++ b/debian/rules @@ -9,7 +9,7 @@ export PYBUILD_NAME=releases export PYBUILD_DISABLE=test %: - dh $@ --with=python3,sphinxdoc --buildsystem=pybuild + dh $@ --with=sphinxdoc --buildsystem=pybuild override_dh_auto_build: PYTHONPATH=. python3 -m sphinx -b html -d docs/_build/.doctrees -N docs docs/_build/html diff --git a/debian/upstream/metadata b/debian/upstream/metadata new file mode 100644 index 0000000..3e57999 --- /dev/null +++ b/debian/upstream/metadata @@ -0,0 +1,5 @@ +--- +Bug-Database: https://github.com/bitprophet/releases/issues +Bug-Submit: https://github.com/bitprophet/releases/issues/new +Repository: https://github.com/bitprophet/releases.git +Repository-Browse: https://github.com/bitprophet/releases diff --git a/debian/upstream/signing-key.asc b/debian/upstream/signing-key.asc deleted file mode 100644 index 1bd4db3..0000000 --- a/debian/upstream/signing-key.asc +++ /dev/null @@ -1,41 +0,0 @@ ------BEGIN PGP PUBLIC KEY BLOCK----- - -mQENBE+0NSEBCADO8i5BPVFFxA4KKe85NB7t79u7w5wkHST4y1sc0wEnk3kVXpCp -O7Fy+fbRzM4wy5ezC8zH+4m/wPUMmnFTaxIR/iz8lGASptcgJXKfbBA0+t/NvFdO -9kAVlKx5AqnfGFmXaaDxj/I86vYVQK1iiSh9bOCfPlDhYc8ntITGbOqUan7rI8P2 -WhxMhcCnRJv7cZxq7k/amNwt5zCTriyfhQHBm//ZNSVh1Fc9KWr8Kn47RzIDwodv -643Wb+l+4v+JoEsT7UCMGOaSJbxX3xB3dm+2u/ZHUxR3j8JNkRF5wIbORvwMDyQf -r23LFn0A7ygOWOkMXcM7SaiJGJ/0OXdgxRPzABEBAAG0IkplZmYgRm9yY2llciA8 -amVmZkBiaXRwcm9waGV0Lm9yZz6JATgEEwECACIFAk+0NSECGwMGCwkIBwMCBhUI -AgkKCwQWAgMBAh4BAheAAAoJEJwpvFYAQekwvU8IALwTpGULawPWLCJlgbw5IEiV -h/yyagnh3bnwcHkz/gfZgzh02mwljjaKXydPhWpB+9ILpGPd9wucHAwbQ40aGDd5 -XJ65jyxjhrLc1R2ZYhMonsgyT7CFGdQSCAyZduKg/1terx20wv+EnGpQ++B9X1fr -+8O/7Kj3B8qWL3QPx0rJPi9UZq4lbklHbUo7bYGL3YLdCnEpjR467cSys33CEe72 -DLOcOvoMWT0ymFcc4oeAqOwhc4kJTrr1YUbRHREr9N1yaCo2A7v3hfEBAYAtPSLV -20RZwWQrqPHUc1EVGUi5lQrxD3pBy93JDYy3H/BS7MN5OFTyZobix0GA+ZqloBCJ -AiAEEAECAAoFAlFH8HgDBQF4AAoJEBJfXGff6UCETCQP/iO6+jYdHx5BwCdZ08Ma -qt1L+4DR9Bhdgtijl8XIVt2FwhALS3PNy6iiCOXeKPDzvNjL38PPdqYN7nnAMQ6A -8oD2CFIxu7FoXfjjpC1fuXNx05sd3WdgFX2SIzg7VM0YCEIAb0dn8hFXSPEAvPZX -eTxu2vwH7QJKKRK5Kr0WtODxOHL8vQEoWj84ETjUvBMZjWcr8/E5DKtY0eEgX3CF -OdOBFqEfKPgGT/1Zw6oQSgMZVkwOe/UpyvACDFTEEADVAQQpxD9h0zHVzmZd0v2k -crqMyUF1eyGZ7XATGXfTIjDBTsseBZEojwukL+qIckIOMDYYAdnXnYA3K3bTH2Mk -qpwFZdlLTqo+U56nf7rdDA4AHlEJax8ZqNnUpm8RXvq1PIubRSvmRY/9AHeEs8oj -q1lo0rF4SsFEKXyklbAMnLEXgnvwAeh6QjFb3cjICfKRbOiWe+9MJQ4vR1VzTEkj -WzN1JDyU44Jo/wvyC0rn4Z0ME+fJftRsY6d0ZO9XNqxP30aQ82ePviPjzHgCKQMv -/feTEZpldPEZUuj0alvkbfjVBmno+wOPDjtJm3EQbl+SvFADxDbhbGP1+jQhLgAN -0KPOGiO0TZGCgakpgrFhh/4BFLxdgy3jjXZBk5lSGRWygTYuxMzzONuVqmZdOXRE -/hHZOZQzC8qXKB0OAd0TXI8vuQENBE+0NSEBCACpsVdkrVtiE2Uso5vTXKPCIXQ4 -D4c/Wi1/i+aXjPrB0+Q7hq/bim7RdmeJGhllEa8/cQ7Sd0ToJ0/LMyBN3+B+xnDA -FurRK+fGTYKQ+93GFzvh1NZCZZ18poFDyHLF/knGE6gFFsuwA17kRAio3DG8pz7P -YQtdeB96pDSQKsQoohYuoW+G8SHX++KPuDO4ulJzYQqgntvTgQWBaTQMj7LVqQdu -5VpGNrETuOOyTGKYFsx+J7jYYut6e9rUxjsV8hFYDOFRpzzQ119TGX7xSGYVI5bx -3yUg4MvneSUXLl53hbXi5kX8XclPwlZ333Axl7VFCKW6Ao4tflIfQHtgASlTABEB -AAGJAR8EGAECAAkFAk+0NSECGwwACgkQnCm8VgBB6TARcAf/Q+70a7EMpZN4+jaC -Hf4yg/B/3oPO5rfI0839UaWts9IRIdP9Bp/NyjUlmmguczbVqBR/AJ1TOeNVPP65 -4ZyxsWhDbsWl7PO97pdiYa4OavdoSfSf1sR1mWyQa2D4tNbdE6ms0ifdn6OnX8N0 -oxtGuXE73MKwGOTf/invUR1SsdVlH3NPwlQz1nJMrxRT6vDyDYyaAA9obsMS04hP -CKRlDMCY0AWK2litpFvRv6kSSCItKw9VCQIm9kNjp3nBpZ86eTvKeXc+K8llUQQm -K3t7PDua8luFPTze6ttZP48W8A1kcITrSSJWn7TCLTH3XZotPydHUgJUyUF/P2nP -u6pINQ== -=7x3O ------END PGP PUBLIC KEY BLOCK----- diff --git a/debian/watch b/debian/watch index 987e436..a28699d 100644 --- a/debian/watch +++ b/debian/watch @@ -1,3 +1,3 @@ -version=3 -opts=uversionmangle=s/(rc|a|b|c)/~$1/,pgpsigurlmangle=s/$/.asc/ \ +version=4 +opts=uversionmangle=s/(rc|a|b|c)/~$1/ \ https://pypi.debian.net/releases/releases-(.+)\.(?:zip|tgz|tbz|txz|(?:tar\.(?:gz|bz2|xz))) diff --git a/dev-requirements.txt b/dev-requirements.txt index 241a6b7..8d3b2cc 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,14 +1,16 @@ # Task runner -invoke>=0.6.0,<2.0 -invocations>=0.14,<2.0 +invoke>=1.7.3 +invocations>=3 # 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 +pytest-relaxed>=2 +pytest-cov>=2.4 +pytest>=4.6.9 +coverage==4.4.2 +# Docs (including self, because dogfood) -e . sphinx_rtd_theme>=0.1.5,<2.0 -# Builds -wheel==0.24 -twine==1.11.0 +# Formatting +flake8==5.0.4 +black==22.8.0 +# Miscellany +icecream==2.1.3 diff --git a/docs/changelog.rst b/docs/changelog.rst index c330e79..304b656 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -2,6 +2,43 @@ Changelog ========= +- :release:`2.1.1 <2023-04-28>` +- :release:`2.0.1 <2023-04-28>` +- :bug:`-` Fix up an internal utility which monkeypatches a Sphinx/docutils + internal, so that it accepts arbitrary args/kwargs instead of exploding on + newer Sphinxes. +- :release:`2.1.0 <2023-02-24>` +- :feature:`-` Add a new configuration setting, + ``releases_supported_versions``, allowing you to limit how many "Next 1.x + feature release" (or bugfix, etc) sections appear at the top of your + changelog. +- :feature:`-` Allow controlling the name of your development branch for source + code links (eg "Next 1.x feature release" section headers) via the new + ``releases_development_branch`` config option. +- :release:`2.0.0 <2022-12-31>` +- :bug:`- major` Changelog transformation sometimes failed to occur when + running under a 'single HTML file' Sphinx builder (eg ``singlehtml``), which + resulted in 'unknown node' errors. This has been fixed. +- :support:`-` Administrivia overhaul: enhanced README, packaging metadata + cleaned up/expanded, CI moved to Circle-CI, renamed dev branch to ``main``, + and more besides. +- :support:`-` The ``releases_release_uri``/``releases_issue_uri`` settings now + allow modern (``.format``/f-strings) string formatting, in addition to the + old ``%s``-based interpolation. +- :support:`-` Dropped support for Python 2.7, Python 3.4, and Python 3.5, to + align slightly better with upstream (and ecosystem) EOLs. +- :bug:`- major` Don't make tmpdirs in ``releases.util.make_app`` when being + given explicit directory args. +- :support:`-` Dropped support for Sphinx <4. We tried to support 1.8+, but too + many transitive dependencies have clearly "moved on" and cause various cells + in the test matrix to fail hard. + + .. warning:: + This change is backwards incompatible if your Sphinx docs don't already + work on recent Sphinx versions. + +- :support:`-` Migrated the test suite to use ``pytest-relaxed`` (and thus + pytest) instead of ``spec``. - :release:`1.6.3 <2020-01-10>` - :support:`87 backported` (via :issue:`88`) Our upper Sphinx version limit was mostly defensive and at this point is just too old to even build on diff --git a/docs/conf.py b/docs/conf.py index 6b6ff31..2c57a81 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -6,23 +6,23 @@ extensions = [] -templates_path = ['_templates'] -source_suffix = '.rst' -master_doc = 'index' +templates_path = ["_templates"] +source_suffix = ".rst" +master_doc = "index" -project = u'Releases' -year = datetime.now().year -copyright = u'%d Jeff Forcier' % year +project = "Releases" +copyright = f"{datetime.now().year} Jeff Forcier" # Ensure project directory is on PYTHONPATH for version, autodoc access -sys.path.insert(0, os.path.abspath(os.path.join(os.getcwd(), '..'))) +sys.path.insert(0, os.path.abspath(os.path.join(os.getcwd(), ".."))) -exclude_patterns = ['_build'] +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' +extensions.append("releases") +releases_github_path = "bitprophet/releases" +releases_supported_versions = [2] diff --git a/docs/usage.rst b/docs/usage.rst index 4629b72..e81e170 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -3,9 +3,9 @@ Usage ===== To use Releases, mimic the format seen in `its own changelog -`_ or in +`_ or in `Fabric's changelog -`_. +`_. Specifically: * Install ``releases`` and update your Sphinx ``conf.py`` to include it in the @@ -13,8 +13,9 @@ Specifically: * 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. + in the HTML output. Both must include a ``{number}`` slug (for use + with `str.format`) where the release/issue number should go; the older + ``%s`` style is also acceptable. * Alternately, if your project is hosted on Github, set the ``releases_github_path`` setting instead, to e.g. @@ -40,6 +41,15 @@ Specifically: through and add bug/feature/support/etc roles. * See :ref:`the appropriate conceptual docs ` for details on this behavior. + * If your development branch which is linked to for unreleased changelog + items, does not match the current default (``master`` as of version 2.0, + to be changed to ``main`` in 3.0), you may override it via + ``releases_development_branch``. + * Projects with a long history of major release versions may want to + specify which of them get 'unreleased' entries at the top of the + changelog; set ``releases_supported_versions`` to a list of major version + numbers, eg ``releases_supported_versions = [2, 3]`` to drop any "Next + 1.x (feature|bugfix)" buckets. * Create a Sphinx document named ``changelog.rst`` containing a bulleted list somewhere at its topmost level. diff --git a/releases.egg-info/PKG-INFO b/releases.egg-info/PKG-INFO index ace45ca..dfb90b7 100644 --- a/releases.egg-info/PKG-INFO +++ b/releases.egg-info/PKG-INFO @@ -1,20 +1,42 @@ -Metadata-Version: 1.1 +Metadata-Version: 1.2 Name: releases -Version: 1.6.3 +Version: 2.1.1 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: .. image:: https://secure.travis-ci.org/bitprophet/releases.png?branch=master - :target: https://travis-ci.org/bitprophet/releases +Project-URL: Docs, https://releases.readthedocs.io +Project-URL: Source, https://github.com/bitprophet/releases +Project-URL: Changelog, https://releases.readthedocs.io/en/latest/changelog.html +Project-URL: CI, https://app.circleci.com/pipelines/github/bitprophet/releases +Description: |version| |python| |license| |ci| |coverage| + + .. |version| image:: https://img.shields.io/pypi/v/releases + :target: https://pypi.org/project/releases/ + :alt: PyPI - Package Version + .. |python| image:: https://img.shields.io/pypi/pyversions/releases + :target: https://pypi.org/project/releases/ + :alt: PyPI - Python Version + .. |license| image:: https://img.shields.io/pypi/l/releases + :target: https://github.com/bitprophet/releases/blob/main/LICENSE + :alt: PyPI - License + .. |ci| image:: https://img.shields.io/circleci/build/github/bitprophet/releases/main + :target: https://app.circleci.com/pipelines/github/bitprophet/releases + :alt: CircleCI + .. |coverage| image:: https://img.shields.io/codecov/c/gh/bitprophet/releases + :target: https://app.codecov.io/gh/bitprophet/releases + :alt: Codecov + 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. + Releases is a `Sphinx `_ extension designed to help you + keep a source control friendly, merge friendly changelog file & turn it into + useful, human readable HTML output. It's compatible with Python 3.6+, and may + work on Sphinx versions as far back as 1.8.x, though 4.x and up are + recommended and generally all we will support. Specifically: @@ -31,7 +53,9 @@ Description: .. image:: https://secure.travis-ci.org/bitprophet/releases.png?bra Some background on why this tool was created can be found in `this blog post `_. - For more documentation, please see http://releases.readthedocs.io. + For more documentation, please see http://releases.readthedocs.io. For a + roadmap, see the maintainer's `roadmap page + `_. .. note:: You can install the development version via ``pip install -e @@ -46,11 +70,16 @@ 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 +Classifier: Programming Language :: Python :: 3 :: Only Classifier: Programming Language :: Python :: 3.6 +Classifier: Programming Language :: Python :: 3.7 +Classifier: Programming Language :: Python :: 3.8 +Classifier: Programming Language :: Python :: 3.9 +Classifier: Programming Language :: Python :: 3.10 +Classifier: Programming Language :: Python :: 3.11 Classifier: Topic :: Software Development Classifier: Topic :: Software Development :: Documentation Classifier: Topic :: Documentation Classifier: Topic :: Documentation :: Sphinx +Requires-Python: >=3.6 diff --git a/releases.egg-info/SOURCES.txt b/releases.egg-info/SOURCES.txt index 2d9a302..83fdf66 100644 --- a/releases.egg-info/SOURCES.txt +++ b/releases.egg-info/SOURCES.txt @@ -2,7 +2,6 @@ LICENSE MANIFEST.in README.rst dev-requirements.txt -setup.cfg setup.py tasks.py docs/changelog.rst @@ -21,5 +20,7 @@ releases.egg-info/dependency_links.txt releases.egg-info/requires.txt releases.egg-info/top_level.txt tests/_util.py +tests/conftest.py tests/organization.py -tests/presentation.py \ No newline at end of file +tests/presentation.py +tests/util.py \ No newline at end of file diff --git a/releases.egg-info/requires.txt b/releases.egg-info/requires.txt index 6c4ed05..cb75f33 100644 --- a/releases.egg-info/requires.txt +++ b/releases.egg-info/requires.txt @@ -1,2 +1,2 @@ +sphinx>=4 semantic_version<2.7 -sphinx>=1.3 diff --git a/releases/__init__.py b/releases/__init__.py index 3c73f0b..7bf9876 100644 --- a/releases/__init__.py +++ b/releases/__init__.py @@ -5,7 +5,6 @@ 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 @@ -19,21 +18,19 @@ def _log(txt, config): 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() + print(txt, file=sys.stderr, flush=True) def issue_nodelist(name, identifier=None): - which = '[%s]' % ( - ISSUE_TYPES[name], name.capitalize() - ) - signifier = [nodes.raw(text=which, format='html')] + which = f'[{name.capitalize()}]' # noqa + 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+' +release_line_re = re.compile(r"^(\d+\.\d+)\+$") # e.g. '1.2+' + def scan_for_spec(keyword): """ @@ -42,11 +39,11 @@ def scan_for_spec(keyword): Returns None if one could not be derived. """ # Both 'spec' formats are wrapped in parens, discard - keyword = keyword.lstrip('(').rstrip(')') + keyword = keyword.lstrip("(").rstrip(")") # First, test for intermediate '1.2+' style matches = release_line_re.findall(keyword) if matches: - return Spec(">={}".format(matches[0])) + return Spec(f">={matches[0]}") # Failing that, see if Spec can make sense of it try: return Spec(keyword) @@ -55,6 +52,14 @@ def scan_for_spec(keyword): return None +# TODO 3.0: make this less dumb, do away with %s, just have callers do +# f-strings or un-opinionated .format +def interpolate(text, number): + if "%s" in text: + return text % number + return text.format(number=number) + + def issues_role(name, rawtext, text, lineno, inliner, options={}, content=[]): """ Use: :issue|bug|feature|support:`ticket_number` @@ -74,28 +79,25 @@ def issues_role(name, rawtext, text, lineno, inliner, options={}, content=[]): issue_no = parts.pop(0) # Lol @ access back to Sphinx config = inliner.document.settings.env.app.config - if issue_no not in ('-', '0'): + 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 + ref = interpolate(text=config.releases_issue_uri, number=issue_no) elif config.releases_github_path: - ref = "https://github.com/{}/issues/{}".format( - config.releases_github_path, issue_no) + ref = f"https://github.com/{config.releases_github_path}/issues/{issue_no}" # noqa # Only generate a reference/link if we were able to make a URI if ref: identifier = nodes.reference( - rawtext, '#' + issue_no, refuri=ref, **options + rawtext, "#" + issue_no, refuri=ref, **options ) # Otherwise, just make it regular text else: identifier = nodes.raw( - rawtext=rawtext, text='#' + issue_no, format='html', - **options + rawtext=rawtext, text="#" + issue_no, format="html", **options ) else: identifier = None - issue_no = None # So it doesn't gum up dupe detection later + 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) @@ -108,18 +110,19 @@ def issues_role(name, rawtext, text, lineno, inliner, options={}, content=[]): if maybe_spec: spec = maybe_spec else: - if part in ('backported', 'major'): + if part in ("backported", "major"): keyword = part else: - err = "Gave unknown keyword {!r} for issue no. {}" - raise ValueError(err.format(keyword, issue_no)) + raise ValueError( + f"Gave unknown keyword {keyword!r} for issue no. {issue_no}" # noqa + ) # Create temporary node w/ data & final nodes to publish node = Issue( number=issue_no, type_=name, nodelist=nodelist, - backported=(keyword == 'backported'), - major=(keyword == 'major'), + backported=(keyword == "backported"), + major=(keyword == "major"), spec=spec, ) return [node], [] @@ -135,30 +138,24 @@ def release_nodes(text, slug, date, config): # about a PDF of a changelog? :x uri = None if config.releases_release_uri: - # TODO: % vs .format() - uri = config.releases_release_uri % slug + uri = interpolate(text=config.releases_release_uri, number=slug) elif config.releases_github_path: - uri = "https://github.com/{}/tree/{}".format( - config.releases_github_path, slug) + uri = f"https://github.com/{config.releases_github_path}/tree/{slug}" # Only construct link tag if user actually configured release URIs somehow if uri: - link = '{}'.format( - uri, text, - ) + link = f'{text}' else: link = text - datespan = '' + datespan = "" if date: - datespan = ' {}'.format(date) - header = '

{}{}

'.format( - link, datespan) - return nodes.section('', - nodes.raw(rawtext='', text=header, format='html'), - ids=[text] + datespan = f' {date}' + header = f'

{link}{datespan}

' + return nodes.section( + "", nodes.raw(rawtext="", text=header, format="html"), ids=[text] ) -year_arg_re = re.compile(r'^(.+?)\s*(?$', re.DOTALL) +year_arg_re = re.compile(r"^(.+?)\s*(?$", re.DOTALL) def release_role(name, rawtext, text, lineno, inliner, options={}, content=[]): @@ -183,19 +180,18 @@ def release_role(name, rawtext, text, lineno, inliner, options={}, content=[]): 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)) + nodelist = [ + release_nodes( + header, + app.config.releases_development_branch, + None, + app.config, + ) + ] + log(f"Creating {line!r} faux-release with {issues!r}") return { - 'obj': Release(number=line, date=None, nodelist=nodelist), - 'entries': issues, + "obj": Release(number=line, date=None, nodelist=nodelist), + "entries": issues, } @@ -207,15 +203,19 @@ def append_unreleased_entries(app, manager, releases): 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 + for family, lines in manager.items(): + # Skip over any unsupported lines + supported = app.config.releases_supported_versions + if supported is not None and family not in supported: + continue + for type_ in ("bugfix", "feature"): + bucket = f"unreleased_{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_) + fam_prefix = f"{family}.x " if len(manager) > 1 else "" + header = f"Next {fam_prefix}{type_} release" + line = f"unreleased_{family}.x_{type_}" releases.append( generate_unreleased_entry(header, line, issues, manager, app) ) @@ -225,10 +225,10 @@ 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} + order = {"feature": 0, "bug": 1, "support": 2} for release in releases: - entries = release['entries'][:] - release['entries'] = sorted(entries, key=lambda x: order[x.type]) + entries = release["entries"].copy() + release["entries"] = sorted(entries, key=lambda x: order[x.type]) def construct_entry_with_release(focus, issues, manager, log, releases, rest): @@ -238,55 +238,52 @@ def construct_entry_with_release(focus, issues, manager, log, releases, rest): 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) + log(f"release for line {focus.minor!r}") # Check for explicitly listed issues first explicit = None if rest[0].children: - explicit = [x.strip() for x in rest[0][0].split(',')] + 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,)) + log(f"Explicit issues requested: {explicit!r}") # 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))) + f"Couldn't find issue(s) #{', '.join(missing)} in the changelog!" # noqa + ) # 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, - }) + log(f"entries in this release: {entries!r}") + 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': + if obj.type == "bug": # Major bugfix: remove from unreleased_feature if obj.major: - log("Removing #%s from unreleased" % obj.number) + log(f"Removing #{obj.number} from unreleased") # TODO: consider making a LineManager method somehow - manager[focus.family]['unreleased_feature'].remove(obj) + 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]["unreleased_bugfix"]: + log(f"Removing #{obj.number} from unreleased") + manager[focus.family]["unreleased_bugfix"].remove(obj) if obj in manager[focus.family][focus.minor]: - log("Removing #%s from %s" % (obj.number, focus.minor)) + log(f"Removing #{obj.number} from {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) + log(f"Removing #{obj.number} from unreleased") + manager[focus.family]["unreleased_feature"].remove(obj) if obj in manager[focus.family].get(focus.minor, []): manager[focus.family][focus.minor].remove(obj) @@ -299,13 +296,15 @@ def construct_entry_with_release(focus, issues, manager, log, releases, rest): # 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'] = [] + 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"].copy(), + } + ) + 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. @@ -326,11 +325,15 @@ def construct_entry_with_release(focus, issues, manager, log, releases, rest): # 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'] = [] + 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 @@ -341,15 +344,15 @@ def construct_entry_with_release(focus, issues, manager, log, releases, rest): # 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}) + entries = manager[focus.family][focus.minor].copy() + 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) + 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): @@ -360,10 +363,10 @@ def construct_entry_without_release(focus, issues, manager, log, rest): # being buried within something else. buried = focus.traverse(Issue) if buried: - msg = """ -Found issue node ({!r}) buried inside another node: + msg = f""" +Found issue node ({buried[0]!r}) buried inside another node: -{} +{buried[0].parent} 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. @@ -371,10 +374,10 @@ def construct_entry_without_release(focus, issues, manager, log, rest): For example, indentation problems can accidentally generate nested definition lists. """ - raise ValueError(msg.format(buried[0], str(buried[0].parent))) + raise ValueError(msg) # OK, it looks legit - make it a bug. log("Found line item w/ no real issue object, creating bug") - nodelist = issue_nodelist('bug') + nodelist = issue_nodelist("bug") # Skip nodelist entirely if we're in unstable prehistory - # classification doesn't matter there. if manager.unstable_prehistory: @@ -382,13 +385,9 @@ def construct_entry_without_release(focus, issues, manager, log, rest): # 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, - ) + focus = Issue(type_="bug", nodelist=nodelist, description=rest) else: - focus.attributes['description'] = rest + focus.attributes["description"] = rest # Add to global list (for use by explicit releases) or die trying issues[focus.number] = issues.get(focus.number, []) + [focus] @@ -399,7 +398,7 @@ def construct_entry_without_release(focus, issues, manager, log, rest): # 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) + manager[0]["unreleased"].append(focus) else: log("Adding to release line manager") focus.add_to_manager(manager) @@ -499,7 +498,7 @@ def construct_releases(entries, app): # correctly sorted into that major release by default (re: logic in # Release.add_to_manager) handle_upcoming_major_release( - stripped_entries[index + 1:], manager + stripped_entries[index + 1 :], manager ) # Entries get copied into release line buckets as follows: @@ -521,13 +520,15 @@ def construct_releases(entries, app): 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, - )) + 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) @@ -540,75 +541,99 @@ 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']: + if not d["entries"]: continue - obj = d['obj'] + obj = d["obj"] entries = [] - for entry in d['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() + 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. + # Use [:] slicing (even under modern Python; the objects here are + # docutils Nodes whose .copy() is weird) 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 + 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']): + for node in reversed(entry["nodelist"]): desc[0].insert(0, node) entries.append(desc) # Entry list - list_ = nodes.bullet_list('', *entries) + list_ = nodes.bullet_list("", *entries) # Insert list into release nodelist (as it's a section) - obj['nodelist'][0].append(list_) + obj["nodelist"][0].append(list_) # Release header - header = nodes.paragraph('', '', *obj['nodelist']) + header = nodes.paragraph("", "", *obj["nodelist"]) result.extend(header) return result class BulletListVisitor(nodes.NodeVisitor): - def __init__(self, document, app): + def __init__(self, document, app, docnames, is_singlepage): nodes.NodeVisitor.__init__(self, document) self.found_changelog = False self.app = app + # document names to seek out (eg "changelog") + self.docnames = docnames + self.is_singlepage = is_singlepage 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)) + # Short circuit if already mutated a changelog bullet list or if the + # one being visited doesn't appear to apply. + if self.found_changelog: + return + # Also short circuit if we're in singlepage mode and the node's parent + # doesn't seem to be named after an expected changelog docname. In this + # mode, this is the earliest we can actually tell whether a given + # bullet list is or is not "the changelog". + if ( + self.is_singlepage + and node.parent.attributes.get("docname", None) + not in self.docnames + ): + return + # At this point, we can safely assume the node we're visiting is the + # right one to mutate. + 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 not in app.config.releases_document_name: +def generate_changelog(app, doctree, docname): + desired_docnames = app.config.releases_document_name + # Ensure we still work mostly-correctly in singlehtml builder situations + # (must use name substring test as RTD's singlehtml builder doesn't + # actually inherit from Sphinx's own!) + is_singlepage = "singlehtml" in app.builder.name + changelog_names = ["index"] if is_singlepage else desired_docnames + if docname not in changelog_names: return - # Find the first bullet-list node & replace it with our organized/parsed - # elements. - changelog_visitor = BulletListVisitor(doctree, app) + # Find an appropriate bullet-list node & replace it with our + # organized/parsed elements. + changelog_visitor = BulletListVisitor( + doctree, app, desired_docnames, is_singlepage + ) doctree.walk(changelog_visitor) @@ -616,44 +641,40 @@ 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), + ("issue_uri", None), # Release-tag base URI setting: releases_release_uri # E.g. 'https://github.com/fabric/fabric/tree/' - ('release_uri', None), + ("release_uri", None), # Convenience Github version of above - ('github_path', None), + ("github_path", None), + # Which branch to use for unreleased feature items + # TODO 3.0: s/master/main/ + ("development_branch", "master"), + # Which versions to show unreleased buckets for + ("supported_versions", None), # Which document to use as the changelog - ('document_name', ['changelog']), + ("document_name", ["changelog"]), # Debug output - ('debug', False), + ("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), + # TODO 3.0: flip this to True by default? + ("unstable_prehistory", False), ): app.add_config_value( - name='releases_{}'.format(key), default=default, rebuild='html' + name=f"releases_{key}", default=default, rebuild="html" ) - # if a string is given for `document_name`, convert it to a list - # done to maintain backwards compatibility - # https://stackoverflow.com/questions/1303243/how-to-find-out-if-a-python-object-is-a-string - PY2 = sys.version_info[0] == 2 - if PY2: - string_types = (basestring,) - else: - string_types = (str,) - - if isinstance(app.config.releases_document_name, string_types): + if isinstance(app.config.releases_document_name, str): app.config.releases_document_name = [app.config.releases_document_name] # Register intermediate roles - for x in list(ISSUE_TYPES) + ['issue']: + for x in list(ISSUE_TYPES) + ["issue"]: add_role(app, x, issues_role) - add_role(app, 'release', release_role) + add_role(app, "release", release_role) # Hook in our changelog transmutation at appropriate step - app.connect('doctree-read', generate_changelog) + app.connect("doctree-resolved", generate_changelog) # identifies the version of our extension - return {'version': __version__} + return {"version": __version__} def add_role(app, name, role_obj): diff --git a/releases/_version.py b/releases/_version.py index 0caaf8f..572fcf1 100644 --- a/releases/_version.py +++ b/releases/_version.py @@ -1,2 +1,2 @@ -__version_info__ = (1, 6, 3) -__version__ = '.'.join(map(str, __version_info__)) +__version_info__ = (2, 1, 1) +__version__ = ".".join(map(str, __version_info__)) diff --git a/releases/line_manager.py b/releases/line_manager.py index 2e3268a..e378dc5 100644 --- a/releases/line_manager.py +++ b/releases/line_manager.py @@ -4,13 +4,14 @@ 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__() + super().__init__() self.app = app @property @@ -28,11 +29,11 @@ def add_family(self, major_number): other necessary bookkeeping. """ # Normally, we have separate buckets for bugfixes vs features - keys = ['unreleased_bugfix', 'unreleased_feature'] + 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'] + keys = ["unreleased"] # Either way, the buckets default to an empty list self[major_number] = {key: [] for key in keys} @@ -44,8 +45,8 @@ def unstable_prehistory(self): Specifically, checks config & whether any non-0.x releases exist. """ return ( - self.config.releases_unstable_prehistory and - not self.has_stable_releases + self.config.releases_unstable_prehistory + and not self.has_stable_releases ) @property @@ -71,5 +72,5 @@ def has_stable_releases(self): # 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') + x for x in self[nonzeroes[0]] if not x.startswith("unreleased") ) diff --git a/releases/models.py b/releases/models.py index d980e9c..3c10b85 100644 --- a/releases/models.py +++ b/releases/models.py @@ -3,36 +3,32 @@ 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) + super().__init__(version_string, partial) # Issue type list (keys) + color values -ISSUE_TYPES = { - 'bug': 'A04040', - 'feature': '40A056', - 'support': '4070A0', -} +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') + _cmp_keys = ("type", "number", "backported", "major") @property def type(self): - return self['type_'] + return self["type_"] @property def is_featurelike(self): - if self.type == 'bug': + if self.type == "bug": return self.major else: return not self.backported @@ -43,19 +39,19 @@ def is_buglike(self): @property def backported(self): - return self.get('backported', False) + return self.get("backported", False) @property def major(self): - return self.get('major', False) + return self.get("major", False) @property def number(self): - return self.get('number', None) + return self.get("number", None) @property def spec(self): - return self.get('spec', None) + return self.get("spec", None) def __eq__(self, other): for attr in self._cmp_keys: @@ -75,8 +71,9 @@ def minor_releases(self, manager): # 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')) + key + for key, value in manager.items() + if any(x for x in value if not x.startswith("unreleased")) ] def default_spec(self, manager): @@ -142,7 +139,7 @@ def add_to_manager(self, manager): candidates = [ Version(x) for x in manager[family] - if not x.startswith('unreleased') + if not x.startswith("unreleased") ] # Select matching release lines (& stringify) buckets = [] @@ -155,7 +152,7 @@ def add_to_manager(self, manager): # 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') + 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. @@ -163,41 +160,42 @@ def add_to_manager(self, manager): # 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') + 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 = '' + flag = "" if self.backported: - flag = 'backported' + flag = "backported" elif self.major: - flag = '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) + 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'] + return self["number"] @property def minor(self): # TODO: use Version - return '.'.join(self.number.split('.')[:-1]) + 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]) + return int(self.number.split(".")[0]) def __repr__(self): - return ''.format(self.number) + return "".format(self.number) diff --git a/releases/util.py b/releases/util.py index 12e3365..65b662a 100644 --- a/releases/util.py +++ b/releases/util.py @@ -4,32 +4,11 @@ import logging import os +from pathlib import Path 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 -try: - from sphinx.io import ( - SphinxStandaloneReader, SphinxFileInput, SphinxDummyWriter, - ) -except ImportError: - # NOTE: 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 sphinx.application import Sphinx # not exposed at top level from . import construct_releases, setup @@ -88,17 +67,17 @@ def parse_changelog(path, **kwargs): # - 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'): + 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) + 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) + unreleased = manager[family].pop("unreleased_feature", None) if unreleased is not None: - ret['unreleased_{}_feature'.format(family)] = unreleased + 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_* @@ -106,18 +85,27 @@ def parse_changelog(path, **kwargs): return ret +def _faux_write_doctree(self, docname, doctree, *args, **kwargs): + self._read_doctree = doctree + + def get_doctree(path, **kwargs): """ - Obtain a Sphinx doctree from the RST file at ``path``. + Obtain a mostly-rendered 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. + The returned doctree is parsed to the point where Releases' own objects + (such as Release and Issue nodes) have been injected, but not yet turned + into their final representation (such as HTML tags). + + .. note:: + This is primarily useful for the use case of `parse_changelog` in this + module and is not intended as a generic-use in-memory Sphinx build + function! Any additional kwargs are passed unmodified into an internal `make_app` call. - :param str path: A relative or absolute file path string. + :param str path: A relative or absolute Sphinx sourcedir path. :returns: A two-tuple of the generated ``sphinx.application.Sphinx`` app and the @@ -126,72 +114,18 @@ def get_doctree(path, **kwargs): .. versionchanged:: 1.6 Added support for passing kwargs to `make_app`. """ - root, filename = os.path.split(path) - docname, _ = os.path.splitext(filename) + path = Path(path) # 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, **kwargs) - # 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, - } - 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): - try: - reader = SphinxStandaloneReader(**reader_kwargs) - except TypeError: - # If we import from io, this happens automagically, not in API - del reader_kwargs['parsers'] - 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 + # NOTE: using absolute to avoid docutils bugs + app = make_app(srcdir=path.parent.absolute(), **kwargs) + app.env.temp_data["docname"] = path.stem + # NOTE: prior to v7, sphinx.io.read_doc was used and just returned the + # generated document. its alternative tries literally writing to disk, so + # we neuter that part via a nasty monkeypatch in order to obtain the value + app.builder.__class__.write_doctree = _faux_write_doctree + app.builder.read_doc(str(path.absolute().with_suffix(""))) + return app, app.builder._read_doctree def load_conf(srcdir): @@ -200,8 +134,8 @@ def load_conf(srcdir): :returns: Dictionary derived from the conf module. """ - path = os.path.join(srcdir, 'conf.py') - mylocals = {'__file__': path} + path = os.path.join(srcdir, "conf.py") + mylocals = {"__file__": path} with open(path) as fd: exec(fd.read(), mylocals) return mylocals @@ -248,19 +182,23 @@ def make_app(**kwargs): .. versionchanged:: 1.6 Added the ``load_extensions`` kwarg. """ - srcdir = kwargs.pop('srcdir', mkdtemp()) - dstdir = kwargs.pop('dstdir', mkdtemp()) - doctreedir = kwargs.pop('doctreedir', mkdtemp()) - load_extensions = kwargs.pop('load_extensions', False) + srcdir = kwargs.pop("srcdir", None) + if srcdir is None: + srcdir = mkdtemp() + dstdir = kwargs.pop("dstdir", None) + if dstdir is None: + dstdir = mkdtemp() + doctreedir = kwargs.pop("doctreedir", None) + if doctreedir is None: + doctreedir = mkdtemp() + load_extensions = kwargs.pop("load_extensions", False) real_conf = None 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. - # NOTE: used to just do 'sphinx' but that stopped working, even on - # sphinx 1.6.x. Weird. Unsure why hierarchy not functioning. - for name in ('sphinx', 'sphinx.sphinx.application'): + # Turn off most logging, which is rarely useful and usually just gums + # up the output of whatever tool is calling us. + # NOTE: used to just do 'sphinx' but that stopped working. Unsure why + # hierarchy not functioning. + for name in ("sphinx", "sphinx.sphinx.application"): logging.getLogger(name).setLevel(logging.ERROR) # App API seems to work on all versions so far. app = Sphinx( @@ -268,7 +206,7 @@ def make_app(**kwargs): confdir=None, outdir=dstdir, doctreedir=doctreedir, - buildername='html', + buildername="html", ) # Might as well load the conf file here too. if load_extensions: @@ -287,32 +225,26 @@ def make_app(**kwargs): # feasible given the rest of the weird ordering we have to do? If it is, # maybe just literally slap this over the return value of load_conf()... config = { - 'releases_release_uri': 'foo_%s', - 'releases_issue_uri': 'bar_%s', - 'releases_debug': False, - 'master_doc': 'index', + "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') + 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] + 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) + app.config.init_values() # Initialize extensions (the internal call to this happens at init time, # which of course had no valid config yet here...) if load_extensions: - for extension in real_conf.get('extensions', []): + for extension in real_conf.get("extensions", []): # But don't set up ourselves again, that causes errors - if extension == 'releases': + if extension == "releases": continue app.setup_extension(extension) return app @@ -324,4 +256,4 @@ def changelog2dict(changelog): See `parse_changelog` docstring for return value details. """ - return {r['obj'].number: r['entries'] for r in changelog} + return {r["obj"].number: r["entries"] for r in changelog} diff --git a/setup.cfg b/setup.cfg index 52be4e1..8bfd5a1 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,11 +1,3 @@ -[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 diff --git a/setup.py b/setup.py index 10b17eb..83ef0c1 100644 --- a/setup.py +++ b/setup.py @@ -4,39 +4,57 @@ # Version info -- read without importing _locals = {} -with open('releases/_version.py') as fp: +with open("releases/_version.py") as fp: exec(fp.read(), None, _locals) -version = _locals['__version__'] +version = _locals["__version__"] setup( - name='releases', + name="releases", version=version, - description='A Sphinx extension for changelog manipulation', + description="A Sphinx extension for changelog manipulation", long_description=open("README.rst").read(), - author='Jeff Forcier', - author_email='jeff@bitprophet.org', - url='https://github.com/bitprophet/releases', - packages=['releases'], + author="Jeff Forcier", + author_email="jeff@bitprophet.org", + url="https://github.com/bitprophet/releases", + project_urls={ + "Docs": "https://releases.readthedocs.io", + "Source": "https://github.com/bitprophet/releases", + "Changelog": "https://releases.readthedocs.io/en/latest/changelog.html", # noqa + "CI": "https://app.circleci.com/pipelines/github/bitprophet/releases", + }, + packages=["releases"], install_requires=[ - 'semantic_version<2.7', - 'sphinx>=1.3', + # We mostly still work on Sphinx>=1.8, but a number of transitive + # dependencies do not, and trying to square that circle is definitely + # not worth the effort at this time. PRs that can pass the entire test + # matrix are welcome, if you disagree! + "sphinx>=4", + # Continuing to pin an old semantic_version until I have time to update + # and finish the branch I made for + # https://github.com/bitprophet/releases/pull/86#issuecomment-580037996 + "semantic_version<2.7", ], + python_requires=">=3.6", 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', + "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 :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Topic :: Software Development", + "Topic :: Software Development :: Documentation", + "Topic :: Documentation", + "Topic :: Documentation :: Sphinx", ], ) diff --git a/tasks.py b/tasks.py index 3a2fc06..189337b 100644 --- a/tasks.py +++ b/tasks.py @@ -1,23 +1,21 @@ from os.path import join -from invocations import docs -from invocations.testing import test, integration, watch_tests +from invocations import docs, checks, ci +from invocations.pytest import test, integration, coverage 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', - ), - }, -}) +ns = Collection(test, integration, coverage, release, docs, ci, checks.blacken) +ns.configure( + { + "packaging": { + "sign": True, + "wheel": True, + "changelog_file": join( + docs.ns.configuration()["sphinx"]["source"], "changelog.rst" + ), + }, + } +) diff --git a/tests/_util.py b/tests/_util.py index d39a6bd..54b81ec 100644 --- a/tests/_util.py +++ b/tests/_util.py @@ -1,9 +1,5 @@ -from docutils.nodes import ( - list_item, paragraph, -) -from mock import Mock -from spec import eq_, ok_ -import six +from docutils.nodes import list_item, paragraph +from unittest.mock import Mock from releases import ( Issue, @@ -19,33 +15,38 @@ 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): + if kwargs.get("backported", False): text += " backported" - if kwargs.get('major', False): + if kwargs.get("major", False): text += " major" - if kwargs.get('spec', None): - text += " (%s)" % kwargs['spec'] - app = kwargs.get('app', None) + if kwargs.get("spec", None): + text += f" ({kwargs['spec']})" + app = kwargs.get("app", None) return issues_role( name=type_, - rawtext='', + rawtext="", text=text, lineno=None, inliner=inliner(app=app), )[0][0] + # Even shorter shorthand! def b(number, **kwargs): - return issue('bug', str(number), **kwargs) + return issue("bug", str(number), **kwargs) + def f(number, **kwargs): - return issue('feature', str(number), **kwargs) + return issue("feature", str(number), **kwargs) + def s(number, **kwargs): - return issue('support', str(number), **kwargs) + return issue("support", str(number), **kwargs) + def entry(i): """ @@ -58,37 +59,41 @@ def entry(i): """ if not isinstance(i, (Issue, Release)): return i - return list_item('', paragraph('', '', i)) + return list_item("", paragraph("", "", i)) + def release(number, **kwargs): - app = kwargs.get('app', None) + app = kwargs.get("app", None) nodes = release_role( name=None, - rawtext='', - text='%s <2013-11-20>' % number, + rawtext="", + text=f"{number} <2013-11-20>", lineno=None, inliner=inliner(app=app), )[0] - return list_item('', paragraph('', '', *nodes)) + return list_item("", paragraph("", "", *nodes)) + def release_list(*entries, **kwargs): - skip_initial = kwargs.pop('skip_initial', False) - entries = list(entries) # lol tuples + 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): + if isinstance(item, str): 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')) + entries.append(release("1.0.0")) return entries + def releases(*entries, **kwargs): - app = kwargs.pop('app', None) or make_app() + app = kwargs.pop("app", None) or make_app() return construct_releases(release_list(*entries, **kwargs), app)[0] + def setup_issues(self): self.f = f(12) self.s = s(5) @@ -97,20 +102,22 @@ def setup_issues(self): 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} + kwargs = {"skip_initial": skip_initial} # Let high level tests tickle config settings via make_app() if app is not None: - kwargs['app'] = app + 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): + for rel, issues in release_map.items(): found = changelog.pop(rel) - eq_(set(found), set(issues), err.format(rel, issues, found, snapshot)) + msg = err.format(rel, issues, found, snapshot) + assert set(found) == set(issues), msg # 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)) + assert not changelog, "Found leftovers: {}".format(changelog) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..533288f --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,5 @@ +from icecream import ic, install as install_ic + + +install_ic() +ic.configureOutput(includeContext=True) diff --git a/tests/organization.py b/tests/organization.py index 8d0e985..cbda9d2 100644 --- a/tests/organization.py +++ b/tests/organization.py @@ -1,16 +1,13 @@ -import six -from spec import Spec, eq_, raises, skip -from docutils.nodes import ( - list_item, raw, paragraph, Text, -) +from pytest import skip +from pytest_relaxed import raises +from docutils.nodes import list_item, raw, paragraph, Text -from releases import ( - Issue, - construct_releases, -) +from releases import Issue, construct_releases from _util import ( - b, f, s, + b, + f, + s, changelog2dict, expect_releases, make_app, @@ -20,17 +17,18 @@ ) -class organization(Spec): +class organization: """ Organization of issues into releases (parsing) """ - def setup(self): + + def setup_method(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_)) + entries = releases(*all_entries)[1]["entries"] + assert len(entries) == len(in_) for x in in_: assert x in entries for x in not_in: @@ -38,75 +36,62 @@ def _expect_entries(self, all_entries, in_, not_in): 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] + ["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] + ["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], + ["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] + ["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], + ["1.0.2", self.f, self.b, self.s, self.bs], [self.b, self.bs], - [self.s, self.f] + [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, - ) + 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], - } + 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) + fake = list_item("", paragraph("", "", raw("", "whatever"))) + changelog = releases("1.0.2", self.f, fake) + entries = changelog[1]["entries"] + assert len(entries) == 1 assert self.f not in entries assert isinstance(entries[0], Issue) - eq_(entries[0].number, None) + assert entries[0].number is 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') + assert len(feature["entries"]) == 1 + assert len(bugfix["entries"]) == 1 + assert self.f in feature["entries"] + assert self.b in bugfix["entries"] + assert feature["obj"].number == "unreleased_1.x_feature" + assert 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'] + 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 @@ -115,54 +100,65 @@ def oddly_ordered_bugfix_releases_and_unreleased_list(self): # 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'] + 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+') + 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 - )) + 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]), + ("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)) + err = "Expected {} to contain {!r}, but it contained {!r}" + got, expected = set(c[rel]), set(issues) + assert got == expected, err.format(rel, expected, got) 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 + "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'] + 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 @@ -173,57 +169,121 @@ def releases_can_specify_issues_explicitly(self): 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'))) + changelog = release_list("1.0.1", b(17)) + changelog[0][0].append(Text(str("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) + 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'] + 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'] + 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'] + assert f2 not in rendered["unreleased_1.x_feature"] + + class unsupported_families_not_included_in_unreleased: + _entries = ( + f(7), # should appear in unreleased features for 3.x + b(6), # should appear in unreleased bugs for 3.x + "3.0.0", + f(5), # should appear in unreleased features for 2.x + b(4), # should appear in unreleased bugs for 2.x + "2.0.0", + f(3), # should appear in unreleased features for 1.x + b(2), # should appear in unreleased bugs for 1.x + "1.0.1", + b(1), # prehistory + ) + + def no_actual_hiding_when_given_but_contains_all_families(self): + # Expectation: everything + families = [1, 2, 3] + for option in (None, families): # Also test default None + changelog = release_list(*self._entries) + releases = changelog2dict( + construct_releases( + changelog, make_app(supported_versions=option) + )[0] + ) + for major in families: + for type_ in ("bugfix", "feature"): + assert f"unreleased_{major}.x_{type_}" in releases + + def one_old_family_hidden(self): + changelog = release_list(*self._entries) + releases = changelog2dict( + construct_releases( + changelog, make_app(supported_versions=[2, 3]) + )[0] + ) + for type_ in ("bugfix", "feature"): + assert f"unreleased_1.x_{type_}" not in releases + assert f"unreleased_2.x_{type_}" in releases + assert f"unreleased_3.x_{type_}" in releases + + def multiple_old_families_hidden(self): + changelog = release_list(*self._entries) + releases = changelog2dict( + construct_releases( + changelog, make_app(supported_versions=[3]) + )[0] + ) + for type_ in ("bugfix", "feature"): + assert f"unreleased_1.x_{type_}" not in releases + assert f"unreleased_2.x_{type_}" not in releases + assert f"unreleased_3.x_{type_}" in releases + + def in_between_family_hidden_for_mysterious_reasons(self): + changelog = release_list(*self._entries) + releases = changelog2dict( + construct_releases( + changelog, make_app(supported_versions=[1, 3]) + )[0] + ) + for type_ in ("bugfix", "feature"): + assert f"unreleased_1.x_{type_}" in releases + assert f"unreleased_2.x_{type_}" not in releases + assert f"unreleased_3.x_{type_}" in releases def explicit_bugfix_releases_get_removed_from_unreleased(self): b1 = b(1) b2 = b(2) - changelog = release_list('1.0.1', b1, b2) + changelog = release_list("1.0.1", b1, b2) # Ensure that 1.0.1 specifies bug 2 - changelog[0][0].append(Text('2')) + changelog[0][0].append(Text("2")) rendered, _ = construct_releases(changelog, make_app()) # 1.0.1 should have bug 2 only - assert b2 in rendered[1]['entries'] - assert b1 not in rendered[1]['entries'] + 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'] + 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') + 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 = releases("1.0.1", self.b, self.b) test_changelog = changelog2dict(test_changelog) - eq_(len(test_changelog['1.0.1']), 2) + assert len(test_changelog["1.0.1"]) == 2 def duplicate_zeroes_dont_error(self): - cl = releases('1.0.1', b(0), b(0)) + cl = releases("1.0.1", b(0), b(0)) cl = changelog2dict(cl) - assert len(cl['1.0.1']) == 2 + assert len(cl["1.0.1"]) == 2 def issues_are_sorted_by_type_within_releases(self): b1 = b(123, major=True) @@ -234,12 +294,12 @@ def issues_are_sorted_by_type_within_releases(self): f2 = f(3456) # Semi random definitely-not-in-desired-order order - changelog = changelog2dict(releases('1.1', b1, s1, s2, f1, b2, f2)) + 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]) + assert changelog["1.1"], [f2, f1, b2, b1, s2 == s1] def rolling_release_works_without_annotation(self): b1 = b(1) @@ -250,15 +310,25 @@ def rolling_release_works_without_annotation(self): 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 + "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], + "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) @@ -273,18 +343,33 @@ def plus_annotations_let_old_lines_continue_getting_released(self): 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, + "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], + "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) @@ -300,34 +385,34 @@ def semver_spec_annotations_allow_preventing_forward_porting(self): b1 = b(1) entries = ( - '2.1.0', - '2.0.1', - '1.2.0', - '1.1.1', - '1.0.2', + "2.1.0", + "2.0.1", + "1.2.0", + "1.1.1", + "1.0.2", f9, f8, b7, b6, - '2.0.0', + "2.0.0", f5, f4, - '1.1.0', - '1.0.1', + "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], + "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) @@ -340,25 +425,25 @@ def bugs_before_major_releases_associate_with_previous_release_only(self): b6 = b(6) entries = ( - '2.0.0', - '1.2.0', - '1.1.1', + "2.0.0", + "1.2.0", + "1.1.1", b6, f5, f4, - '1.1.0', - '1.0.1', + "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] + "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) @@ -372,14 +457,8 @@ 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], - } + 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) @@ -388,120 +467,99 @@ 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'] + 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]) + assert len(first) == 3 # Meh, hard to assert about the implicit one + assert second == [b3] def specs_and_keywords_play_together_nicely(self): b1 = b(1) - b2 = b(2, major=True, spec='1.0+') + 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+') + 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', - ) + 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 + "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) + assert 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) + assert len(feature["entries"]) == 2 + assert 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) + 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)], - } + 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', + "1.1.0", + "1.0.1", f(6), b(5), - '1.0.0', + "1.0.0", f(4), b(3), - '0.1.0', + "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)], + "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', + "2.1.0", + "2.0.1", f(6), b(5), - '2.0.0', + "2.0.0", f(4), b(3), - '0.1.0', + "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)], + "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) diff --git a/tests/presentation.py b/tests/presentation.py index 29b1f6e..4aca395 100644 --- a/tests/presentation.py +++ b/tests/presentation.py @@ -1,165 +1,176 @@ -from spec import Spec, eq_ from docutils.nodes import ( - reference, bullet_list, list_item, literal, raw, paragraph, Text + reference, + bullet_list, + list_item, + literal, + raw, + paragraph, + Text, ) -from releases import ( - Issue, - construct_releases, - construct_nodes, -) +from releases import Issue, construct_releases, construct_nodes -from _util import ( - b, f, s, - entry, - make_app, - release, - releases, - setup_issues, -) +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] + 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_) + msg = f"Expected {node!r} to be a {name}, but it's a {type_}" assert isinstance(node, cls), msg -class presentation(Spec): +class presentation: """ Expansion/extension of docutils nodes (rendering) """ - def setup(self): + + def setup_method(self): setup_issues(self) def _generate(self, *entries, **kwargs): - raw = kwargs.pop('raw', False) + 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): + def _test_link(self, kwargs, type_, expected, entries=None): 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]) + # Lazy-evaluated entries: (callable, *args) + entries = entries or [ + (release, "1.0.2"), + (b, 15), + (release, "1.0.0"), + ] + nodes = construct_nodes( + construct_releases( + [entry(x[0](*x[1:], app=app)) for x in entries], + app=app, + )[0] + ) # Shorthand for "I'll do my own asserts" if expected is None: return nodes - if type_ == 'release': + if type_ == "release": header = nodes[0][0][0].astext() assert expected in header - elif type_ == 'issue': + elif type_ == "issue": link = nodes[0][1][0][0][2] - eq_(link['refuri'], expected) + assert 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') + self._test_link({}, "issue", "bar_15") def releases_appear_as_header_links(self): - self._test_link({}, 'release', 'foo_1.0.2') + 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', + "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'), + ("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', + "release_uri": None, + "issue_uri": "explicit_issue_%s", + "github_path": "foo/bar", } - self._test_link(kwargs, 'issue', 'explicit_issue_15') + 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', + "release_uri": "explicit_release_%s", + "issue_uri": None, + "github_path": "foo/bar", } - self._test_link(kwargs, 'release', 'explicit_release_1.0.2') + self._test_link(kwargs, "release", "explicit_release_1.0.2") - def completely_blank_uri_settings_does_not_asplode(self): + def links_allow_curlybrace_number_formatting_too(self): kwargs = { - 'release_uri': None, - 'issue_uri': None, - 'github_path': None, + "release_uri": "release_{number}", + "issue_uri": "issue_{number}", + "github_path": None, } + self._test_link(kwargs, "release", "release_1.0.2") + self._test_link(kwargs, "issue", "issue_15") + + 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) + 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 1.0.2 Bug] #15:' in text - + assert ">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') + self._assert_prefix(["1.0.2", self.b], "Bug") def features_marked_as_features(self): - self._assert_prefix(['1.1.0', self.f], 'Feature') + self._assert_prefix(["1.1.0", self.f], "Feature") def support_marked_as_support(self): - self._assert_prefix(['1.1.0', self.s], 'Support') + self._assert_prefix(["1.1.0", self.s], "Support") def dashed_issues_appear_as_unlinked_issues(self): - node = self._generate('1.0.2', b('-')) + 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)) + 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) + 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]) + assert 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) + 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]) + assert 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] + 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] + node = self._generate("1.0.2", self.b, raw=True)[0][0] _expect_type(node, raw) # Header w/ bottom margin assert '