Source code for sphinx_toolbox.installation

#!/usr/bin/env python3
#
#  installation.py
r"""
.. extensions:: sphinx_toolbox.installation


Configuration
--------------

.. confval:: conda_channels
	:type: :class:`~typing.List`\[:class:`str`\]
	:required: False
	:default: ``[]``

	The conda channels required to install the library from Anaconda.

	An alternative to setting it within the :rst:dir:`installation` directive.


Usage
-------

.. rst:directive:: .. installation:: name

	Adds a series of tabs providing installation instructions for the project from a number of sources.

	The directive takes a single required argument -- the name of the project.
	If the project uses a different name on PyPI and/or Anaconda,
	the ``:pypi-name:`` and ``:conda-name:`` options can be used to set the name
	for those repositories.

	.. rst:directive:option:: pypi
		:type: flag

		Flag to indicate the project can be installed from PyPI.

	.. rst:directive:option:: pypi-name: name
		:type: string

		The name of the project on PyPI.

	.. rst:directive:option:: conda
		:type: flag

		Flag to indicate the project can be installed with Conda.

	.. rst:directive:option:: conda-name: name
		:type: string

		The name of the project on Conda.

	.. rst:directive:option:: conda-channels: channels
		:type: comma separated strings

		Comma-separated list of required Conda channels.

		This can also be set via the :confval:`conda_channels` option.

	.. rst:directive:option:: github
		:type: flag

		Flag to indicate the project can be installed from GitHub.

		To use this option add the following to your ``conf.py``:

		.. code-block:: python

			extensions = [
					...
					'sphinx_toolbox.github',
					]

			github_username = '<your username>'
			github_repository = '<your repository>'


		See :mod:`sphinx_toolbox.github` for more information.

	.. latex:vspace:: 5px

	**Example**

	.. rest-example::

		.. installation:: sphinx-toolbox
			:pypi:
			:anaconda:
			:conda-channels: domdfcoding,conda-forge
			:github:

.. latex:clearpage::


.. rst:directive:: extensions

	Shows instructions on how to enable a Sphinx extension.

	The directive takes a single argument -- the name of the extension.

	.. rst:directive:option:: import-name
		:type: string

		The name used to import the extension, if different from the name of the extension.

	.. rst:directive:option:: no-preamble
		:type: flag

		Disables the preamble text.

	.. rst:directive:option:: no-postamble
		:type: flag

		Disables the postamble text.

	.. rst:directive:option:: first
		:type: flag

		Puts the entry for extension before its dependencies.
		By default is is placed at the end.

		.. versionadded:: 0.4.0


	.. latex:vspace:: 10px

	**Example**

	.. rest-example::

		.. extensions:: sphinx-toolbox
			:import-name: sphinx_toolbox

			sphinx.ext.viewcode
			sphinx_tabs.tabs
			sphinx-prompt


.. latex:clearpage::

API Reference
--------------
"""  # noqa: D400
#
#  Copyright © 2020-2021 Dominic Davis-Foster <dominic@davis-foster.co.uk>
#
#  Permission is hereby granted, free of charge, to any person obtaining a copy
#  of this software and associated documentation files (the "Software"), to deal
#  in the Software without restriction, including without limitation the rights
#  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
#  copies of the Software, and to permit persons to whom the Software is
#  furnished to do so, subject to the following conditions:
#
#  The above copyright notice and this permission notice shall be included in all
#  copies or substantial portions of the Software.
#
#  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
#  EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
#  MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
#  IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
#  DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
#  OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
#  OR OTHER DEALINGS IN THE SOFTWARE.
#

# stdlib
import inspect
import re
import warnings
from typing import Any, Callable, Dict, List, Optional, Tuple

# 3rd party
import dict2css
import sphinx.environment
from docutils import nodes
from docutils.parsers.rst import directives
from docutils.statemachine import ViewList
from domdf_python_tools.paths import PathPlus
from domdf_python_tools.stringlist import StringList
from domdf_python_tools.words import word_join
from sphinx.application import Sphinx
from sphinx.config import Config
from sphinx.environment import BuildEnvironment
from sphinx.util.docutils import SphinxDirective

# this package
from sphinx_toolbox import _css
from sphinx_toolbox.utils import OptionSpec, Purger, SphinxExtMetadata, flag, metadata_add_version

__all__ = [
		"InstallationDirective",
		"ExtensionsDirective",
		"make_installation_instructions",
		"Sources",
		"sources",
		"pypi_installation",
		"conda_installation",
		"github_installation",
		"installation_node_purger",
		"extensions_node_purger",
		"copy_asset_files",
		"setup",
		]


class _Purger(Purger):

	def purge_nodes(self, app: Sphinx, env: BuildEnvironment, docname: str) -> None:  # pragma: no cover
		"""
		Remove all redundant nodes.

		:param app: The Sphinx application.
		:param env: The Sphinx build environment.
		:param docname: The name of the document to remove nodes for.
		"""

		if not hasattr(env, self.attr_name):
			return

		setattr(env, self.attr_name, [])


installation_node_purger = _Purger("all_installation_node_nodes")
extensions_node_purger = Purger("all_extensions_node_nodes")


[docs]class Sources(List[Tuple[str, str, Callable, Callable, Optional[Dict[str, Callable]]]]): """ Class to store functions that provide installation instructions for different sources. The syntax of each entry is: .. code-block:: python (option_name, source_name, getter_function, validator_function, extra_options) * ``option_name`` -- a string to use in the directive to specify the source to use, * ``source_name`` -- a string to use in the tabs to indicate the installation source, * ``getter_function`` -- the function that returns the installation instructions, * ``validator_function`` -- a function to validate the option value provided by the user, * ``extra_options`` -- a mapping of additional options for the directive that are used by the getter_function. """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) _args = ["options", "env"] _directive_name = "installation"
[docs] def register( self, option_name: str, source_name: str, validator: Callable = directives.unchanged, extra_options: Optional[Dict[str, Callable]] = None, ) -> Callable: """ Decorator to register a function. The function must have the following signature: .. code-block:: python def function( options: Dict[str, Any], # Mapping of option names to values. env: sphinx.environment.BuildEnvironment, # The Sphinx build environment. ) -> List[str]: ... :param option_name: A string to use in the directive to specify the source to use. :param source_name: A string to use in tabbed installation instructions to represent this source. :param validator: A function to validate the option value provided by the user. :default validator: :func:`docutils.parsers.rst.directives.unchanged` :param extra_options: An optional mapping of extra option names to validator functions. :default extra_options: ``{}`` :return: The registered function. :raises: :exc:`SyntaxError` if the decorated function does not take the correct arguments. """ def _decorator(function: Callable) -> Callable: signature = inspect.signature(function) if list(signature.parameters.keys()) != self._args: raise SyntaxError( # pragma: no cover "The decorated function must take only the following arguments: " f"{word_join(self._args, use_repr=True, oxford=True)}" ) self.append((option_name, source_name, function, validator, extra_options or {})) setattr(function, f"_{self._directive_name}_registered", True) return function return _decorator
#: Instance of :class:`~.Sources`. sources: Sources = Sources() # pypi_name: The name of the project on PyPI.
[docs]@sources.register("pypi", "PyPI", flag, {"pypi-name": directives.unchanged}) def pypi_installation( options: Dict[str, Any], env: sphinx.environment.BuildEnvironment, ) -> List[str]: """ Source to provide instructions for installing from PyPI. :param options: Mapping of option names to values. :param env: The Sphinx build environment. """ if "pypi-name" in options: pypi_name = options["pypi-name"] elif "project_name" in options: pypi_name = options["project_name"] else: raise ValueError("No PyPI project name supplied for the PyPI installation instructions.") return [".. prompt:: bash", '', f" python3 -m pip install {pypi_name} --user"]
# conda_name: The name of the project on PyPI.
[docs]@sources.register( "anaconda", "Anaconda", flag, {"conda-name": directives.unchanged, "conda-channels": directives.unchanged}, ) def conda_installation( options: Dict[str, Any], env: sphinx.environment.BuildEnvironment, ) -> List[str]: """ Source to provide instructions for installing from Anaconda. :param options: Mapping of option names to values. :param env: The Sphinx build environment. """ if "conda-name" in options: conda_name = options["conda-name"] elif "pypi-name" in options: conda_name = options["pypi-name"] elif "project_name" in options: conda_name = options["project_name"] else: raise ValueError("No username supplied for the Anaconda installation instructions.") lines: StringList = StringList() lines.indent_type = " " if "conda-channels" in options: channels = str(options["conda-channels"]).split(',') else: channels = env.config.conda_channels if channels: lines.append("First add the required channels\n\n.. prompt:: bash\n") with lines.with_indent_size(lines.indent_size + 1): for channel in channels: lines.append(f"conda config --add channels https://conda.anaconda.org/{channel.strip()}") lines.append("\nThen install") if lines: lines.blankline(ensure_single=True) lines.append(f".. prompt:: bash") lines.blankline(ensure_single=True) with lines.with_indent_size(lines.indent_size + 1): lines.append(f"conda install {conda_name}") lines.blankline(ensure_single=True) return list(lines)
[docs]@sources.register("github", "GitHub", flag) def github_installation( options: Dict[str, Any], env: sphinx.environment.BuildEnvironment, ) -> List[str]: """ Source to provide instructions for installing from GitHub. :param options: Mapping of option names to values. :param env: The Sphinx build environment. """ if "sphinx_toolbox.github" not in env.app.extensions: raise ValueError( "The 'sphinx_toolbox.github' extension is required for the " ":github: option but it is not enabled!" ) username = getattr(env.config, "github_username", None) if username is None: raise ValueError("'github_username' has not been set in 'conf.py'!") repository = getattr(env.config, "github_repository", None) if repository is None: raise ValueError("'github_repository' has not been set in 'conf.py'!") return [ ".. prompt:: bash", '', f" python3 -m pip install git+https://github.com/{username}/{repository}@master --user" ]
[docs]class InstallationDirective(SphinxDirective): """ Directive to show installation instructions. """ has_content: bool = True optional_arguments: int = 1 # The name of the project; can be overridden for each source # Registered sources option_spec: OptionSpec = { # type: ignore source[0].lower(): source[3] for source in sources # pylint: disable=not-an-iterable } # Extra options for registered sources for source in sources: # pylint: disable=not-an-iterable if source[4] is not None: option_spec.update(source[4]) # type: ignore options: Dict[str, Any] """ Mapping of option names to values. The options are as follows: * **pypi**: Flag to indicate the project can be installed from PyPI. * **pypi-name**: The name of the project on PyPI. * **conda**: Flag to indicate the project can be installed with Conda. * **conda-name**: The name of the project on Conda. * **conda-channels**: Comma-separated list of required Conda channels. * **github**: Flag to indicate the project can be installed from GitHub. The GitHub username and repository are configured in ``conf.py`` and are available in ``env.config``. """
[docs] def run_html(self) -> List[nodes.Node]: """ Generate output for ``HTML`` builders. """ targetid = f'installation-{self.env.new_serialno("sphinx-toolbox installation"):d}' targetnode = nodes.target('', '', ids=[targetid]) content = make_installation_instructions(self.options, self.env) view = ViewList(content) installation_node = nodes.paragraph(rawsource=content) # type: ignore self.state.nested_parse(view, self.content_offset, installation_node) # type: ignore installation_node_purger.add_node(self.env, installation_node, targetnode, self.lineno) return [targetnode, installation_node]
[docs] def run_generic(self) -> List[nodes.Node]: """ Generate generic reStructuredText output. """ targetid = f'installation-{self.env.new_serialno("sphinx-toolbox installation"):d}' targetnode = nodes.target('', '', ids=[targetid]) tabs: Dict[str, List[str]] = _get_installation_instructions(self.options, self.env) if not tabs: warnings.warn("No installation source specified. No installation instructions will be shown.") return [] nodes_to_return: List[nodes.Node] = [targetnode] for tab_name, tab_content in tabs.items(): section_id = re.sub(r'\W+', '_', tab_name) section = nodes.section(ids=[f"{targetid}-{section_id}"]) section += nodes.title(tab_name, tab_name) nodes_to_return.append(section) installation_node_purger.add_node(self.env, section, targetnode, self.lineno) view = ViewList(tab_content) paragraph_node = nodes.paragraph(rawsource=tab_content) # type: ignore self.state.nested_parse(view, self.content_offset, paragraph_node) # type: ignore nodes_to_return.append(paragraph_node) installation_node_purger.add_node(self.env, paragraph_node, targetnode, self.lineno) return nodes_to_return
[docs] def run(self) -> List[nodes.Node]: """ Create the installation node. """ assert self.env.app.builder is not None if self.arguments: self.options["project_name"] = self.arguments[0] if self.env.app.builder.format.lower() == "html": return self.run_html() else: return self.run_generic()
[docs]def make_installation_instructions(options: Dict[str, Any], env: BuildEnvironment) -> List[str]: """ Make the content of an installation node. :param options: :param env: The Sphinx build environment. """ tabs: Dict[str, List[str]] = _get_installation_instructions(options, env) if not tabs: warnings.warn("No installation source specified. No installation instructions will be shown.") return [] content = StringList([".. tabs::", '']) content.set_indent_type(" ") for tab_name, tab_content in tabs.items(): with content.with_indent_size(1): content.append(f".. tab:: {tab_name}") content.blankline(ensure_single=True) with content.with_indent_size(2): content.extend([f"{line}" if line else '' for line in tab_content]) return list(content)
def _get_installation_instructions(options: Dict[str, Any], env: BuildEnvironment) -> Dict[str, List[str]]: """ Returns a mapping of tab/section names to their content. :param options: :param env: The Sphinx build environment. """ tabs: Dict[str, List[str]] = {} for option_name, source_name, getter_function, validator_function, extra_options in sources: if option_name in options: tabs[f"from {source_name}"] = getter_function(options, env) return tabs
[docs]class ExtensionsDirective(SphinxDirective): """ Directive to show instructions for enabling the extension. """ has_content: bool = True # Other required extensions, one per line optional_arguments: int = 1 # The name of the project option_spec: OptionSpec = { # type: ignore "import-name": directives.unchanged_required, # If different to project name "no-preamble": flag, "no-postamble": flag, "first": flag, }
[docs] def run(self) -> List[nodes.Node]: """ Create the extensions node. """ extensions = list(self.content) first = self.options.get("first", False) if "import-name" in self.options and first: extensions.insert(0, self.options["import-name"]) elif "import-name" in self.options: extensions.append(self.options["import-name"]) elif first: extensions.insert(0, self.arguments[0]) else: extensions.append(self.arguments[0]) targetid = f'extensions-{self.env.new_serialno("sphinx-toolbox extensions"):d}' targetnode = nodes.target('', '', ids=[targetid]) top_text = [ ".. latex:vspace:: 10px", ".. rst-class:: sphinx-toolbox-extensions", '', f" Enable ``{self.arguments[0]}`` by adding the following", f" to the ``extensions`` variable in your ``conf.py``:", ] bottom_text = ( "For more information see " "https://www.sphinx-doc.org/en/master/usage/extensions#third-party-extensions ." ) if "no-preamble" in self.options: content = [] else: content = [*top_text, ''] content.append(".. code-block:: python", ) if "no-postamble" not in self.options: content.append(" :class: sphinx-toolbox-extensions") content.extend([ '', " extensions = [", " ...", ]) for extension in extensions: content.append(f" {extension!r},") content.extend([" ]", '']) if "no-postamble" not in self.options: content.extend([bottom_text, '']) extensions_node = nodes.paragraph(rawsource=content) # type: ignore self.state.nested_parse(ViewList(content), self.content_offset, extensions_node) # type: ignore extensions_node_purger.add_node(self.env, extensions_node, targetnode, self.lineno) return [targetnode, extensions_node]
[docs]def copy_asset_files(app: Sphinx, exception: Optional[Exception] = None): """ Copy additional stylesheets into the HTML build directory. .. versionadded:: 1.2.0 :param app: The Sphinx application. :param exception: Any exception which occurred and caused Sphinx to abort. """ if exception: # pragma: no cover return if app.builder is None or app.builder.format.lower() != "html": # pragma: no cover return static_dir = PathPlus(app.outdir) / "_static" static_dir.maybe_make(parents=True) dict2css.dump(_css.installation_styles, static_dir / "sphinx_toolbox_installation.css", minify=True) (static_dir / "sphinx_toolbox_installation.js").write_lines([ "// Based on https://github.com/executablebooks/sphinx-tabs/blob/master/sphinx_tabs/static/tabs.js", "// Copyright (c) 2017 djungelorm", "// MIT Licensed", '', "function deselectTabset(target) {", " const parent = target.parentNode;", " const grandparent = parent.parentNode;", '', ' if (parent.parentNode.parentNode.getAttribute("id").startsWith("installation")) {', '', " // Hide all tabs in current tablist, but not nested", " Array.from(parent.children).forEach(t => {", ' if (t.getAttribute("name") !== target.getAttribute("name")) {', ' t.setAttribute("aria-selected", "false");', " }", " });", '', " // Hide all associated panels", " Array.from(grandparent.children).slice(1).forEach(p => { // Skip tablist", ' if (p.getAttribute("name") !== target.getAttribute("name")) {', ' p.setAttribute("hidden", "false")', " }", " });", " }", '', " else {", " // Hide all tabs in current tablist, but not nested", " Array.from(parent.children).forEach(t => {", ' t.setAttribute("aria-selected", "false");', " });", '', " // Hide all associated panels", " Array.from(grandparent.children).slice(1).forEach(p => { // Skip tablist", ' p.setAttribute("hidden", "true")', " });", " }", '', '}', '', "// Compatibility with sphinx-tabs 2.1.0 and later", "function deselectTabList(tab) {deselectTabset(tab)}", '', ])
def _on_config_inited(app: Sphinx, config: Config): app.add_css_file("sphinx_toolbox_installation.css") app.add_js_file("sphinx_toolbox_installation.js")
[docs]@metadata_add_version def setup(app: Sphinx) -> SphinxExtMetadata: """ Setup :mod:`sphinx_toolbox.installation`. .. versionadded:: 0.7.0 :param app: The Sphinx application. """ if "sphinx_inline_tabs" not in getattr(app, "extensions", ()): app.setup_extension("sphinx_tabs.tabs") app.setup_extension("sphinx-prompt") app.setup_extension("sphinx_toolbox._css") app.setup_extension("sphinx_toolbox.latex") app.add_config_value("conda_channels", [], "env", types=[list]) # Instructions for installing a python package app.add_directive("installation", InstallationDirective) app.connect("env-get-outdated", installation_node_purger.get_outdated_docnames) # app.connect("env-purge-doc", installation_node_purger.purge_nodes) # Instructions for enabling a sphinx extension app.add_directive("extensions", ExtensionsDirective) app.connect("env-purge-doc", extensions_node_purger.purge_nodes) # Ensure this happens after tabs.js has been added app.connect("config-inited", _on_config_inited, priority=510) app.connect("build-finished", copy_asset_files) return {"parallel_read_safe": True}