"""Sphinx extension for injecting an unreleased changelog into docs."""importsubprocess# noqa: S404importsysfromcontextlibimportsuppressassuppress_exceptionsfromfunctoolsimportlru_cachefrompathlibimportPathfromtypingimportDict,List,Optional,Set,Tuple,Unionfromsphinx.applicationimportSphinxfromsphinx.configimportConfigasSphinxConfigfromsphinx.environmentimportBuildEnvironmentfromsphinx.environment.collectorsimportEnvironmentCollectorfromsphinx.utilimportloggingfromsphinx.util.docutilsimportSphinxDirectivefromsphinx.util.nodesimportnested_parse_with_titles,nodes# isort: splittry:# pylint: disable=no-name-in-modulefromtowncrier.buildimportfind_fragments# noqa: WPS433exceptImportError:# pylint: disable=import-selffromtowncrierimportfind_fragments# noqa: WPS433, WPS440# Ref: https://github.com/PyCQA/pylint/issues/3817fromdocutilsimportstatemachine# pylint: disable=wrong-import-orderfrom._towncrierimportget_towncrier_config# noqa: WPS436from._versionimport__version__# noqa: WPS436PROJECT_ROOT_DIR=Path(__file__).parents[3].resolve()TOWNCRIER_DRAFT_CMD=(sys.executable,'-m',# invoke via runpy under the same interpreter'towncrier','build','--draft',# write to stdout, don't change anything on disk)logger=logging.getLogger(__name__)
[docs]@lru_cache(typed=True)def_get_changelog_draft_entries(target_version:str,allow_empty:bool=False,working_dir:str=None,config_path:str=None,)->str:"""Retrieve the unreleased changelog entries from Towncrier."""extra_cli_args:Tuple[str,...]=('--version',rf'\ {target_version}',# version value to be used in the RST title# NOTE: The escaped space sequence (`\ `) is necessary to address# NOTE: a corner case when the towncrier config has something like# NOTE: `v{version}` in the title format **and** the directive target# NOTE: argument starts with a substitution like `|release|`. And so# NOTE: when combined, they'd produce `v|release|` causing RST to not# NOTE: substitute the `|release|` part. But adding an escaped space# NOTE: solves this: that escaped space renders as an empty string and# NOTE: the substitution gets processed properly so the result would# NOTE: be something like `v1.0` as expected.)ifconfig_pathisnotNone:# This isn't actually supported by a released version of Towncrier yet:# https://github.com/twisted/towncrier/pull/157#issuecomment-666549246# https://github.com/twisted/towncrier/issues/269extra_cli_args+='--config',str(config_path)towncrier_output=subprocess.check_output(# noqa: S603TOWNCRIER_DRAFT_CMD+extra_cli_args,cwd=str(working_dir)ifworking_direlseNone,universal_newlines=True,# this arg has "text" alias since Python 3.7).strip()ifnotallow_emptyand'No significant changes'intowncrier_output:raiseLookupError('There are no unreleased changelog entries so far')returntowncrier_output
# pylint: disable=fixme# FIXME: refactor `_lookup_towncrier_fragments` to drop noqas
[docs]@lru_cache(maxsize=1,typed=True)# noqa: WPS210def_lookup_towncrier_fragments(# noqa: WPS210working_dir:str=None,config_path:str=None,)->Set[Path]:"""Emit RST-formatted Towncrier changelog fragment paths."""project_path=Path.cwd()ifworking_dirisNoneelsePath(working_dir)final_config_path=project_path/'pyproject.toml'ifconfig_pathisnotNone:final_config_path=project_path/config_pathelif(# noqa: WPS337notfinal_config_path.is_file()and(project_path/'towncrier.toml').is_file()):final_config_path=project_path/'towncrier.toml'try:towncrier_config=get_towncrier_config(project_path,final_config_path,)exceptKeyErroraskey_err:# NOTE: The error is missing key 'towncrier' or similarlogger.warning(f'Missing key {key_err!s} in file {final_config_path!s}',)returnset()fragment_directory:Optional[str]='newsfragments'try:fragment_base_directory=project_path/towncrier_config['directory']exceptKeyError:fragment_base_directory=project_path/towncrier_config['directory']else:fragment_directory=None_fragments,fragment_filenames=find_fragments(base_directory=str(fragment_base_directory),fragment_directory=fragment_directory,definitions=towncrier_config['types'],sections=towncrier_config['sections'],)returnset(fragment_filenames)
[docs]@lru_cache(maxsize=1,typed=True)def_get_draft_version_fallback(strategy:str,sphinx_config:SphinxConfig,)->str:"""Generate a fallback version string for towncrier draft."""known_strategies={'draft','sphinx-version','sphinx-release'}ifstrategynotinknown_strategies:raiseValueError('Expected "strategy" to be 'f'one of {known_strategies!r} but got {strategy!r}',)if'sphinx'instrategy:return(sphinx_config.releaseif'release'instrategyelsesphinx_config.version)return'[UNRELEASED DRAFT]'
[docs]def_nodes_from_rst(state:statemachine.State,rst_source:str,)->List[nodes.Node]:"""Turn an RST string into a list of nodes. These nodes can be used in the document. """node=nodes.Element()node.document=state.documentnested_parse_with_titles(state=state,content=statemachine.ViewList(statemachine.string2lines(rst_source),source='[towncrier-fragments]',),node=node,)returnnode.children
[docs]classTowncrierDraftEntriesDirective(SphinxDirective):"""Definition of the ``towncrier-draft-entries`` directive."""has_content=True# default: False
[docs]defrun(self)->List[nodes.Node]:# noqa: WPS210"""Generate a node tree in place of the directive."""target_version=(self.content[:1][0]ifself.content[:1]elseNone)ifself.content[1:]:# inner content presentraiseself.error(f'Error in "{self.name!s}" directive: ''only one argument permitted.',)config=self.env.configautoversion_mode=config.towncrier_draft_autoversion_modeinclude_empty=config.towncrier_draft_include_emptytowncrier_fragment_paths=_lookup_towncrier_fragments(working_dir=config.towncrier_draft_working_directory,config_path=config.towncrier_draft_config_path,)forpathintowncrier_fragment_paths:# make sphinx discard doctree cache on file changesself.env.note_dependency(str(path))try:self.env.towncrier_fragment_paths|=(# type: ignoretowncrier_fragment_paths)exceptAttributeError:# If the attribute hasn't existed, initialize it instead of# updatingself.env.towncrier_fragment_paths=(# type: ignoretowncrier_fragment_paths)try:self.env.towncrier_fragment_docs|={# type: ignoreself.env.docname,}exceptAttributeError:# If the attribute hasn't existed, initialize it instead of# updatingself.env.towncrier_fragment_docs={# type: ignoreself.env.docname,}try:draft_changes=_get_changelog_draft_entries(target_versionor_get_draft_version_fallback(autoversion_mode,config),allow_empty=include_empty,working_dir=config.towncrier_draft_working_directory,config_path=config.towncrier_draft_config_path,)exceptsubprocess.CalledProcessErrorasproc_exc:raiseself.error(proc_exc)exceptLookupError:return[]return_nodes_from_rst(state=self.state,rst_source=draft_changes)
[docs]classTowncrierDraftEntriesEnvironmentCollector(EnvironmentCollector):r"""Environment collector for ``TowncrierDraftEntriesDirective``. When :py:class:`~TowncrierDraftEntriesDirective` is used in a document, it depends on some dynamically generated change fragments. After the first render, the doctree nodes are put in cache and are reused from there. There's a way to make Sphinx aware of the directive dependencies by calling :py:meth:`BuildEnvironment.\ note_dependency <sphinx.environment.BuildEnvironment.\ note_dependency>` but this will only work for fragments that have existed at the time of that first directive invocation. In order to track newly appearing change fragment dependencies, we need to do so at the time of Sphinx identifying what documents require rebuilding. There's :event:`env-get-outdated` that allows to extend this list of planned rebuilds and we could use it by assigning a document-to-fragments map from within the directive and reading it in the event handler later (since env contents are preserved in cache). But this approach does not take into account cleanups and parallel runs of Sphinx. In order to make it truly parallelism-compatible, we need to define how to merge our custom cache attribute collected within multiple Sphinx subprocesses into one object and that's where :py:class:`~sphinx.environment.\ collectors.EnvironmentCollector` comes into play. Refs: * https://github.com/sphinx-doc/sphinx/issues/8040#issuecomment-671587308 * https://github.com/sphinx-contrib/sphinxcontrib-towncrier/issues/1 """
[docs]defclear_doc(self,app:Sphinx,env:BuildEnvironment,docname:str,)->None:"""Clean up env metadata related to the removed document. This is a handler for :event:`env-purge-doc`. """withsuppress_exceptions(AttributeError,KeyError):env.towncrier_fragment_docs.remove(docname)# type: ignore
[docs]defmerge_other(self,app:Sphinx,env:BuildEnvironment,docnames:Set[str],other:BuildEnvironment,)->None:"""Merge doc-to-fragments from another proc into this env. This is a handler for :event:`env-merge-info`. """try:other_fragment_docs:Set[str]=(other.towncrier_fragment_docs# type: ignore)exceptAttributeError:# If the other process env doesn't have documents using# `TowncrierDraftEntriesDirective`, there's nothing to mergereturnifnothasattr(env,'towncrier_fragment_docs'):# noqa: WPS421# If the other process env doesn't have documents using# `TowncrierDraftEntriesDirective`, initialize the structure# at leastenv.towncrier_fragment_docs=set()# type: ignoreifnothasattr(env,'towncrier_fragment_paths'):# noqa: WPS421env.towncrier_fragment_paths=set()# type: ignore# Since Sphinx does not pull the same document into multiple# processes, we don't care about the same dict key appearing# in different envs with different sets of the depsenv.towncrier_fragment_docs.update(# type: ignoreother_fragment_docs,)env.towncrier_fragment_paths.update(# type: ignoreother.towncrier_fragment_paths,# type: ignore)
[docs]defprocess_doc(self,app:Sphinx,doctree:nodes.document)->None:"""React to :event:`doctree-read` with no-op."""
# pylint: disable=too-many-arguments
[docs]defget_outdated_docs(# noqa: WPS211self,app:Sphinx,env:BuildEnvironment,added:Set[str],changed:Set[str],removed:Set[str],)->List[str]:"""Mark docs with changed fragment deps for rebuild. This is a handler for :event:`env-get-outdated`. """towncrier_fragment_paths=_lookup_towncrier_fragments(working_dir=env.config.towncrier_draft_working_directory,config_path=env.config.towncrier_draft_config_path,)fragments_changed=Falsewithsuppress_exceptions(AttributeError):fragments_changed=bool(towncrier_fragment_paths^env.towncrier_fragment_paths,# type: ignore)return(list(env.towncrier_fragment_docs-changed)# type: ignoreiffragments_changedelse[])
[docs]defsetup(app:Sphinx)->Dict[str,Union[bool,str]]:"""Initialize the extension."""rebuild_trigger='html'# rebuild full html on settings changeapp.add_config_value('towncrier_draft_config_path',default=None,rebuild=rebuild_trigger,)app.add_config_value('towncrier_draft_autoversion_mode',default='scm-draft',rebuild=rebuild_trigger,)app.add_config_value('towncrier_draft_include_empty',default=True,rebuild=rebuild_trigger,)app.add_config_value('towncrier_draft_working_directory',default=None,rebuild=rebuild_trigger,)app.add_directive('towncrier-draft-entries',TowncrierDraftEntriesDirective,)# Register an environment collector to merge data gathered by the# directive in parallel buildsapp.add_env_collector(TowncrierDraftEntriesEnvironmentCollector)return{'parallel_read_safe':True,'parallel_write_safe':True,'version':__version__,}