Skip to content

Commit 95eea48

Browse files
committed
Vendor towncrier_draft_ext Sphinx extension
This change implements injecting the Towncrier draft change notes RST output into the Sphinx-managed docs site. It is heavily inspired by the work of @gaborbernat in tox and reinvented as a native Sphinx extension. Refs: * tox-dev#859 * ansible/pylibssh@f5f9ef1 In internally generates an unreleased changelog for the next unpublished project version (by calling `towncrier` in a subprocess) and provides it for injection as an RST directive called "towncrier-draft-entries". To start using it, first, add it to extensions in `conf.py`: extensions.append('towncrier_draft_ext') Then, optionally, set the global extensions options: towncrier_draft_autoversion_mode = 'scm-draft' # or: 'scm', 'draft', 'sphinx-version', 'sphinx-release' towncrier_draft_include_empty = True towncrier_draft_working_directory = PROJECT_ROOT_DIR towncrier_draft_config_path = 'pyproject.toml' # relative to cwd Their meaning is as follows: * towncrier_draft_autoversion_mode -- mechanism for the fallback version detection. It kicks in if, when using the directive, you don't specify the version argument and then will be passed to the `towncrier` invocation. Possible values are: * 'scm-draft' -- default, use setuptools-scm followed by a string "[UNRELEASED DRAFT]" * 'scm' -- use setuptools-scm * 'draft' -- use just a string "[UNRELEASED DRAFT]" * 'sphinx-version' -- use value of the "version" var in as set in `conf.py` * 'sphinx-release' -- use value of the "release" var in as set in `conf.py` * towncrier_draft_include_empty -- control whether the directive injects anything if there's no fragments in the repo. Boolean, defaults to `True`. * towncrier_draft_working_directory -- if set, this will be the current working directory of the `towncrier` invocation. * towncrier_draft_config_path -- path of the custom config to use for the invocation. Should be relative to the working directory. Not yet supported: the corresponding Towncrier CLI option is in their master but is not yet released. Don't use it unless you install a bleading-edge towncrier copy (or they make a release). Finally, use the directive in your RST source as follows: .. towncrier-draft-entries:: |release| [UNRELEASED DRAFT] The inline argument of the directive is what is passed to towncrier as a target version. It is optional and if not set, the fallback from the global config will be used. Pro tip: you can use RST substitutions like `|release|` or `|version|` in order to match the version with what's set in Sphinx and other release-related configs. src: https://github.com/ansible/pylibssh/blob/8b21ad7/docs/_ext/towncrier_draft_ext.py Resolves tox-dev#1639
1 parent 9d01182 commit 95eea48

File tree

6 files changed

+198
-36
lines changed

6 files changed

+198
-36
lines changed

.gitignore

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,6 @@ __pycache__
2121
# tools
2222
/.*_cache
2323

24-
# documentation
25-
/docs/_draft.rst
26-
2724
# release
2825
credentials.json
2926

docs/_ext/towncrier_draft_ext.py

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
# fmt: off
2+
# Requires Python 3.6+
3+
"""Sphinx extension for making titles with dates from Git tags."""
4+
5+
6+
import subprocess # noqa: S404
7+
import sys
8+
from functools import lru_cache
9+
from pathlib import Path
10+
from typing import Any, Dict, List, Union
11+
12+
from sphinx.application import Sphinx
13+
from sphinx.util.docutils import SphinxDirective
14+
from sphinx.util.nodes import nodes
15+
16+
# isort: split
17+
18+
from docutils import statemachine
19+
from setuptools_scm import get_version
20+
21+
PROJECT_ROOT_DIR = Path(__file__).parents[2].resolve()
22+
TOWNCRIER_DRAFT_CMD = (
23+
sys.executable, '-m', # invoke via runpy under the same interpreter
24+
'towncrier',
25+
'--draft', # write to stdout, don't change anything on disk
26+
)
27+
28+
29+
@lru_cache(typed=True)
30+
def _get_changelog_draft_entries(
31+
target_version: str,
32+
allow_empty: bool = False,
33+
working_dir: str = None,
34+
config_path: str = None,
35+
) -> str:
36+
"""Retrieve the unreleased changelog entries from Towncrier."""
37+
extra_cli_args = (
38+
'--version',
39+
rf'\ {target_version}', # version value to be used in the RST title
40+
# NOTE: The escaped space sequence (`\ `) is necessary to address
41+
# NOTE: a corner case when the towncrier config has something like
42+
# NOTE: `v{version}` in the title format **and** the directive target
43+
# NOTE: argument starts with a substitution like `|release|`. And so
44+
# NOTE: when combined, they'd produce `v|release|` causing RST to not
45+
# NOTE: substitute the `|release|` part. But adding an escaped space
46+
# NOTE: solves this: that escaped space renders as an empty string and
47+
# NOTE: the substitution gets processed properly so the result would
48+
# NOTE: be something like `v1.0` as expected.
49+
)
50+
if config_path is not None:
51+
# This isn't actually supported by a released version of Towncrier yet:
52+
# https://github.com/twisted/towncrier/pull/157#issuecomment-666549246
53+
# https://github.com/twisted/towncrier/issues/269
54+
extra_cli_args += '--config', str(config_path)
55+
towncrier_output = subprocess.check_output( # noqa: S603
56+
TOWNCRIER_DRAFT_CMD + extra_cli_args,
57+
cwd=str(working_dir) if working_dir else None,
58+
universal_newlines=True,
59+
).strip()
60+
61+
if not allow_empty and 'No significant changes' in towncrier_output:
62+
raise LookupError('There are no unreleased changelog entries so far')
63+
64+
return towncrier_output
65+
66+
67+
@lru_cache(maxsize=1, typed=True)
68+
def _autodetect_scm_version():
69+
"""Retrieve an SCM-based project version."""
70+
for scm_checkout_path in Path(__file__).parents: # noqa: WPS500
71+
is_scm_checkout = (
72+
(scm_checkout_path / '.git').exists()
73+
or (scm_checkout_path / '.hg').exists()
74+
)
75+
if is_scm_checkout:
76+
return get_version(root=scm_checkout_path)
77+
else:
78+
raise LookupError("Failed to locate the project's SCM repo")
79+
80+
81+
@lru_cache(maxsize=1, typed=True)
82+
def _get_draft_version_fallback(strategy: str, sphinx_config: Dict[str, Any]):
83+
"""Generate a fallback version string for towncrier draft."""
84+
known_strategies = {'scm-draft', 'scm', 'draft', 'sphinx-version', 'sphinx-release'}
85+
if strategy not in known_strategies:
86+
raise ValueError(
87+
'Expected "stragegy" to be '
88+
f'one of {known_strategies!r} but got {strategy!r}',
89+
)
90+
91+
if 'sphinx' in strategy:
92+
return (
93+
sphinx_config.release
94+
if 'release' in strategy
95+
else sphinx_config.version
96+
)
97+
98+
draft_msg = '[UNRELEASED DRAFT]'
99+
msg_chunks = ()
100+
if 'scm' in strategy:
101+
msg_chunks += (_autodetect_scm_version(),)
102+
if 'draft' in strategy:
103+
msg_chunks += (draft_msg,)
104+
105+
return ' '.join(msg_chunks)
106+
107+
108+
class TowncrierDraftEntriesDirective(SphinxDirective):
109+
"""Definition of the ``towncrier-draft-entries`` directive."""
110+
111+
has_content = True # default: False
112+
113+
def run(self) -> List[nodes.Node]:
114+
"""Generate a node tree in place of the directive."""
115+
target_version = self.content[:1][0] if self.content[:1] else None
116+
if self.content[1:]: # inner content present
117+
raise self.error(
118+
f'Error in "{self.name!s}" directive: '
119+
'only one argument permitted.',
120+
)
121+
122+
config = self.state.document.settings.env.config # noqa: WPS219
123+
autoversion_mode = config.towncrier_draft_autoversion_mode
124+
include_empty = config.towncrier_draft_include_empty
125+
126+
try:
127+
draft_changes = _get_changelog_draft_entries(
128+
target_version
129+
or _get_draft_version_fallback(autoversion_mode, config),
130+
allow_empty=include_empty,
131+
working_dir=config.towncrier_draft_working_directory,
132+
config_path=config.towncrier_draft_config_path,
133+
)
134+
except subprocess.CalledProcessError as proc_exc:
135+
raise self.error(proc_exc)
136+
except LookupError:
137+
return []
138+
139+
self.state_machine.insert_input(
140+
statemachine.string2lines(draft_changes),
141+
'[towncrier draft]',
142+
)
143+
return []
144+
145+
146+
def setup(app: Sphinx) -> Dict[str, Union[bool, str]]:
147+
"""Initialize the extension."""
148+
rebuild_trigger = 'html' # rebuild full html on settings change
149+
app.add_config_value(
150+
'towncrier_draft_config_path',
151+
default=None,
152+
rebuild=rebuild_trigger,
153+
)
154+
app.add_config_value(
155+
'towncrier_draft_autoversion_mode',
156+
default='scm-draft',
157+
rebuild=rebuild_trigger,
158+
)
159+
app.add_config_value(
160+
'towncrier_draft_include_empty',
161+
default=True,
162+
rebuild=rebuild_trigger,
163+
)
164+
app.add_config_value(
165+
'towncrier_draft_working_directory',
166+
default=None,
167+
rebuild=rebuild_trigger,
168+
)
169+
app.add_directive(
170+
'towncrier-draft-entries',
171+
TowncrierDraftEntriesDirective,
172+
)
173+
174+
return {
175+
'parallel_read_safe': True,
176+
'parallel_write_safe': True,
177+
'version': get_version(root=PROJECT_ROOT_DIR),
178+
}

docs/changelog.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ Versions follow `Semantic Versioning <https://semver.org/>`_ (``<major>.<minor>.
77
Backward incompatible (breaking) changes will only be introduced in major versions
88
with advance notice in the **Deprecations** section of releases.
99

10-
.. include:: _draft.rst
10+
.. towncrier-draft-entries:: DRAFT
1111

1212
.. towncrier release notes start
1313

docs/conf.py

Lines changed: 17 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
import os
2-
import re
3-
import subprocess
42
import sys
53
from datetime import date
64
from pathlib import Path
@@ -16,36 +14,15 @@
1614
"sphinx.ext.intersphinx",
1715
"sphinx.ext.viewcode",
1816
"sphinxcontrib.autoprogram",
17+
"towncrier_draft_ext", # in-tree
1918
]
20-
ROOT_SRC_TREE_DIR = Path(__file__).parents[1]
21-
22-
23-
def generate_draft_news():
24-
home = "https://github.com"
25-
issue = "{}/issue".format(home)
26-
fragments_path = ROOT_SRC_TREE_DIR / "docs" / "changelog"
27-
for pattern, replacement in (
28-
(r"[^`]@([^,\s]+)", r"`@\1 <{}/\1>`_".format(home)),
29-
(r"[^`]#([\d]+)", r"`#pr\1 <{}/\1>`_".format(issue)),
30-
):
31-
for path in fragments_path.glob("*.rst"):
32-
path.write_text(re.sub(pattern, replacement, path.read_text()))
33-
env = os.environ.copy()
34-
env["PATH"] += os.pathsep.join(
35-
[os.path.dirname(sys.executable)] + env["PATH"].split(os.pathsep),
36-
)
37-
changelog = subprocess.check_output(
38-
["towncrier", "--draft", "--version", "DRAFT"], cwd=str(ROOT_SRC_TREE_DIR), env=env,
39-
).decode("utf-8")
40-
if "No significant changes" in changelog:
41-
content = ""
42-
else:
43-
note = "*Changes in master, but not released yet are under the draft section*."
44-
content = "{}\n\n{}".format(note, changelog)
45-
(ROOT_SRC_TREE_DIR / "docs" / "_draft.rst").write_text(content)
46-
47-
48-
generate_draft_news()
19+
ROOT_SRC_TREE_DIR = Path(__file__).parents[1].resolve()
20+
SPHINX_EXTENSIONS_DIR = (Path(__file__).parent / "_ext").resolve()
21+
# Make in-tree extension importable in non-tox setups/envs, like RTD.
22+
# Refs:
23+
# https://github.com/readthedocs/readthedocs.org/issues/6311
24+
# https://github.com/readthedocs/readthedocs.org/issues/7182
25+
sys.path.insert(0, str(SPHINX_EXTENSIONS_DIR))
4926

5027
project = u"tox"
5128
_full_version = tox.__version__
@@ -127,3 +104,12 @@ def parse_node(env, text, node):
127104
"pull": ("https://github.com/tox-dev/tox/pull/%s", "p"),
128105
"user": ("https://github.com/%s", "@"),
129106
}
107+
108+
# -- Options for towncrier_draft extension -----------------------------------
109+
110+
towncrier_draft_autoversion_mode = (
111+
"draft" # or: 'scm-draft' (default, 'scm', 'sphinx-version', 'sphinx-release'
112+
)
113+
towncrier_draft_include_empty = False
114+
towncrier_draft_working_directory = ROOT_SRC_TREE_DIR
115+
# Not yet supported: towncrier_draft_config_path = 'pyproject.toml' # relative to cwd

setup.cfg

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ console_scripts =
5555
[options.extras_require]
5656
docs =
5757
pygments-github-lexers>=0.0.5
58+
setuptools-scm
5859
sphinx>=2.0.0
5960
sphinxcontrib-autoprogram>=0.1.5
6061
towncrier>=18.5.0

tox.ini

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ include_trailing_comma = True
155155
force_grid_wrap = 0
156156
line_length = 99
157157
known_first_party = tox,tests
158-
known_third_party = apiclient,docutils,filelock,flaky,freezegun,git,httplib2,oauth2client,packaging,pathlib2,pluggy,py,pytest,setuptools,six,sphinx,toml
158+
known_third_party = apiclient,docutils,filelock,flaky,freezegun,git,httplib2,oauth2client,packaging,pathlib2,pluggy,py,pytest,setuptools,setuptools_scm,six,sphinx,toml
159159
160160
[testenv:release]
161161
description = do a release, required posarg of the version number

0 commit comments

Comments
 (0)