Source code for sphinx_highlights

#!/usr/bin/env python3
#
#  __init__.py
"""
Sphinx extension to display a selection of highlights from a Python library.
"""
#
#  Copyright © 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.
#
#  format_parameter based on CPython.
#  Licensed under the Python Software Foundation License Version 2.
#  Copyright © 2001-2020 Python Software Foundation. All rights reserved.
#  Copyright © 2000 BeOpen.com. All rights reserved.
#  Copyright © 1995-2000 Corporation for National Research Initiatives. All rights reserved.
#  Copyright © 1991-1995 Stichting Mathematisch Centrum. All rights reserved.
#

# stdlib
import inspect
import itertools
import random
import re
import sys
from importlib import import_module
from types import FunctionType
from typing import Iterable, Iterator, List, Optional, TypeVar, Union, get_type_hints

# 3rd party
import dict2css
from docutils import nodes
from docutils.parsers.rst.directives import unchanged_required
from docutils.statemachine import ViewList
from domdf_python_tools.paths import PathPlus
from domdf_python_tools.stringlist import DelimitedList, StringList

# This all has to be up here so it's triggered before Sphinx is imported.
if sys.version_info >= (3, 10):
	# stdlib
	import types
	types.Union = types.UnionType

# 3rd party
from sphinx.application import Sphinx
from sphinx.util.docutils import SphinxDirective
from sphinx_toolbox.more_autodoc.typehints import format_annotation
from sphinx_toolbox.utils import Purger, SphinxExtMetadata
from sphinxcontrib.default_values import format_default_value

# this package
from sphinx_highlights._eval_type import monkeypatcher

__author__: str = "Dominic Davis-Foster"
__copyright__: str = "2021 Dominic Davis-Foster"
__license__: str = "MIT License"
__version__: str = "0.6.0"
__email__: str = "dominic@davis-foster.co.uk"

__all__ = [
		"SphinxHighlightsDirective",
		"copy_assets",
		"format_parameter",
		"format_signature",
		"setup",
		"get_random_sample",
		]

_T = TypeVar("_T")

sphinx_highlights_purger = Purger("all_sphinx_highlights")


[docs]def format_parameter(param: inspect.Parameter) -> str: """ Format an :class:`inspect.Parameter`, for insertion into the highlight panel. :param param: :return: The reStructuredText string. """ formatted = param.name # Add annotation and default value if param.annotation is not inspect.Parameter.empty: formatted = f"{formatted}: {format_annotation(param.annotation)}" if param.default is not inspect.Parameter.empty: formatted = f"{formatted} = {format_default_value(param.default)}" if param.kind == inspect.Parameter.VAR_POSITIONAL: formatted = rf'\*{formatted}' elif param.kind == inspect.Parameter.VAR_KEYWORD: formatted = rf"\*\*{formatted}" return formatted
[docs]def format_signature(obj: Union[type, FunctionType]) -> StringList: """ Format the signature of the given object, for insertion into the highlight panel. :param obj: :return: A list of reStructuredText lines. """ with monkeypatcher(): obj.__annotations__ = get_type_hints(obj) signature: inspect.Signature = inspect.signature(obj) buf = StringList(".. parsed-literal::") buf.blankline() buf.indent_type = " " buf.indent_size = 1 if signature.return_annotation is not inspect.Signature.empty and not isinstance(obj, type): return_annotation = f") -> {format_annotation(signature.return_annotation)}" else: return_annotation = f")" total_length = len(obj.__name__) + len(return_annotation) arguments_buf: DelimitedList[str] = DelimitedList() param: inspect.Parameter for param in signature.parameters.values(): arguments_buf.append(f"{format_parameter(param)}") total_length += len(arguments_buf[-1]) if total_length <= 60: signature_buf = StringList(''.join([f"{obj.__name__}(", f"{arguments_buf:, }", return_annotation])) else: signature_buf = StringList([f"{obj.__name__}("]) signature_buf.indent_type = " " with signature_buf.with_indent_size(1): signature_buf.extend([f"{arguments_buf:,\n}" + ',', return_annotation]) buf.extend(signature_buf) return buf
[docs]def get_random_sample(items: Iterable[_T]) -> List[_T]: """ Returns four random elements from ``items``. :param items: """ return random.sample(extend(items, 4), 4)
def extend(sequence: Iterable[_T], minsize: int) -> List[_T]: """ Extend ``sequence`` by repetition until it is at least as long as ``minsize``. :param sequence: :param minsize: """ output = list(sequence) cycle = itertools.cycle(output) while len(output) < minsize: output.append(next(cycle)) return output
[docs]class SphinxHighlightsDirective(SphinxDirective): """ Provides the :rst:dir:`api-highlights` directive. """ has_content = True option_spec = { "colours": unchanged_required, "module": unchanged_required, "class": unchanged_required, }
[docs] def delimited_get(self, option: str, default: str) -> Iterator[str]: """ Returns the value of the option with the given name, splitting the input at commas, semicolons and spaces. :param option: The option name. :param default: The default value, as a string separated by commas, spaces or semicolons. """ # noqa: D400 return filter(bool, re.split("[,; ]", self.options.get(option, default)))
[docs] def run_html(self) -> List[nodes.Node]: """ Generate output for ``HTML`` builders. """ # colours = itertools.cycle(self.delimited_get("colours", "#6ab0de")) colours = itertools.cycle(get_random_sample(self.delimited_get("colours", "blue"))) classes = list(self.delimited_get("class", "col-xl-6 col-lg-6 col-md-12 col-sm-12 col-xs-12 p-2")) content = StringList() content.append(".. panels::") content.indent_type = " " content.indent_size = 1 content.append(":container: container-xl pb-4 sphinx-highlights") content.blankline() for obj_name in get_random_sample(sorted(set(self.content))): if self.options.get("module", '') and obj_name.startswith('.'): obj_name = obj_name.replace('.', f"{self.options['module']}.", 1) name_parts = obj_name.split('.') module = import_module('.'.join(name_parts[:-1])) obj = getattr(module, name_parts[-1]) colour_class = f"highlight-{next(colours)}" content.append(f":column: {DelimitedList((*classes, colour_class)): }") if isinstance(obj, FunctionType): content.append(f":func:`{'.'.join(name_parts[1:])}() <.{obj_name}>`") elif isinstance(obj, type): content.append(f":class:`{'.'.join(name_parts[1:])} <.{obj_name}>`") else: content.append(f":py:obj:`{'.'.join(name_parts[1:])} <.{obj_name}>`") content.append('^' * len(content[-1])) content.blankline() # content.append(f".. function:: {name_parts[-1]} {stringify_signature(inspect.signature(obj))}") content.append(format_signature(obj)) content.blankline() content.append(inspect.cleandoc(obj.__doc__ or '').split("\n\n")[0]) content.blankline() content.append(f"See more in :mod:`{module.__name__}`.") content.append("---") content.pop(-1) targetid = f'sphinx-highlights-{self.env.new_serialno("sphinx-highlights"):d}' targetnode = nodes.target('', '', ids=[targetid]) view = ViewList(content) body_node = nodes.paragraph(rawsource=str(content)) self.state.nested_parse(view, self.content_offset, body_node) # type: ignore sphinx_highlights_purger.add_node(self.env, body_node, targetnode, self.lineno) return [targetnode, body_node]
[docs] def run_generic(self) -> List[nodes.Node]: """ Generate generic reStructuredText output. """ content = StringList() content.indent_type = ' ' for obj_name in get_random_sample(sorted(set(self.content))): if self.options.get("module", '') and obj_name.startswith('.'): obj_name = obj_name.replace('.', f"{self.options['module']}.", 1) name_parts = obj_name.split('.') module = import_module('.'.join(name_parts[:-1])) obj = getattr(module, name_parts[-1]) if isinstance(obj, FunctionType): content.append(f"* :func:`{'.'.join(name_parts[1:])}() <.{obj_name}>`") elif isinstance(obj, type): content.append(f"* :class:`{'.'.join(name_parts[1:])} <.{obj_name}>`") else: content.append(f"* :py:obj:`{'.'.join(name_parts[1:])} <.{obj_name}>`") with content.with_indent_size(2): content.blankline() content.append(format_signature(obj)) content.blankline() content.append(inspect.cleandoc(obj.__doc__ or '').split("\n\n")[0]) content.blankline() targetid = f'sphinx-highlights-{self.env.new_serialno("sphinx-highlights"):d}' targetnode = nodes.target('', '', ids=[targetid]) view = ViewList(content) body_node = nodes.container(rawsource=str(content)) self.state.nested_parse(view, self.content_offset, body_node) # type: ignore sphinx_highlights_purger.add_node(self.env, body_node, targetnode, self.lineno) return [targetnode, body_node]
[docs] def run(self) -> List[nodes.Node]: """ Create the highlights node. """ assert self.env.app.builder is not None if self.env.app.builder.format.lower() == "html": return self.run_html() else: return self.run_generic()
_colour_map = { "blue": "#6ab0de", "orange": "#f0b37e", "green": "#1abc9c", "red": "#f29f97", }
[docs]def copy_assets(app: Sphinx, exception: Optional[Exception] = None) -> None: """ Copy asset files to the output. :param app: The Sphinx application. :param exception: Any exception which occurred and caused Sphinx to abort. """ if exception: # pragma: no cover return style = {} for colour, hex_ in _colour_map.items(): style[f"div.sphinx-highlights div.highlight-{colour} div.card-header"] = {"background-color": hex_} # if app.config.html_theme in {"domdf_sphinx_theme", "sphinx_rtd_theme"}: # header_colour = app.config.html_theme_options.get("style_nav_header_background", "#2980B9") # # style.blankline() # style.extend([ # "div.sphinx-highlights div.card-header {", # f" background-color: {header_colour}", # '}', # ]) assert app.builder is not None css_dir = PathPlus(app.builder.outdir) / "_static" / "css" css_dir.maybe_make(parents=True) dict2css.dump(style, css_dir / "sphinx_highlights.css")
def env_get_outdated(app, env, added, changed, removed): return [node["docname"] for node in getattr(env, sphinx_highlights_purger.attr_name, ())]
[docs]def setup(app: Sphinx) -> SphinxExtMetadata: """ Setup :mod:`sphinx_highlights`. :param app: The Sphinx application. """ app.setup_extension("sphinx_panels") app.setup_extension("sphinx_toolbox.tweaks.sphinx_panels_tabs") app.add_directive("api-highlights", SphinxHighlightsDirective) app.add_css_file("css/sphinx_highlights.css") app.connect("build-finished", copy_assets) app.connect("env-get-outdated", env_get_outdated) app.connect("env-purge-doc", sphinx_highlights_purger.purge_nodes) return { "version": __version__, "parallel_read_safe": True, }