Skip to content

Commit 803b447

Browse files
committed
1 parent 9d01182 commit 803b447

File tree

6 files changed

+200
-36
lines changed

6 files changed

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

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)