From 3f079e203d9eac5416e189bdac5d7b1b23f31998 Mon Sep 17 00:00:00 2001 From: su-fang Date: Mon, 10 Oct 2022 17:08:27 +0800 Subject: [PATCH] Import Upstream version 0.5.4 --- .gitignore | 10 + .pre-commit-config.yaml | 28 ++ LICENSE | 19 + MANIFEST.in | 7 + README.rst | 141 ++++++++ pyproject.toml | 2 + setup.cfg | 15 + setup.py | 45 +++ sphinx_paramlinks/__init__.py | 3 + sphinx_paramlinks/sphinx_paramlinks.css | 13 + sphinx_paramlinks/sphinx_paramlinks.py | 439 ++++++++++++++++++++++++ 11 files changed, 722 insertions(+) create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 LICENSE create mode 100644 MANIFEST.in create mode 100644 README.rst create mode 100644 pyproject.toml create mode 100644 setup.cfg create mode 100644 setup.py create mode 100644 sphinx_paramlinks/__init__.py create mode 100644 sphinx_paramlinks/sphinx_paramlinks.css create mode 100644 sphinx_paramlinks/sphinx_paramlinks.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f9e5e52 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +*.pyc +build/ +dist/ +docs/build/output/ +*.orig +alembic.ini +tox.ini +.venv +.egg-info +.coverage diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..a7920ac --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,28 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +repos: +- repo: https://github.com/python/black + rev: 20.8b1 + hooks: + - id: black + +- repo: https://github.com/sqlalchemyorg/zimports + rev: master + hooks: + - id: zimports + +- repo: https://github.com/pycqa/flake8 + rev: master + hooks: + - id: flake8 + additional_dependencies: + - flake8-import-order + - flake8-builtins + - flake8-docstrings + - flake8-rst-docstrings + - pydocstyle + - pygments + + + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fc45c5b --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +This is the MIT license: http://www.opensource.org/licenses/mit-license.php + +Copyright (C) by Michael Bayer. + +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. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..4102785 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,7 @@ +recursive-include sphinx_paramlinks *.py *.css + +include README* LICENSE + +exclude pyproject.toml + + diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..e395c82 --- /dev/null +++ b/README.rst @@ -0,0 +1,141 @@ +================== +Sphinx Paramlinks +================== + +A `Sphinx `_ extension which allows ``:param:`` +directives within Python documentation to be linkable. + +This is an experimental extension that's used by the +`SQLAlchemy `_ project and related projects. + +Configuration +============= + +Just turn it on in ``conf.py``:: + + extensions = [ + 'sphinx_paramlinks', + + # your other sphinx extensions + # ... + ] + +Since version 0.5.3, you can modify how clickable hyperlinks are placed around the names of +the parameter using the ``paramlinks_hyperlink_param`` setting in ``conf.py``:: + + paramlinks_hyperlink_param='name' + +This parameter accepts the following values: + +* ``'none'``: No link will be be inserted. The parameter still has a target + attached to it so that you can e.g. jump to it from the search. + +* ``'name'``: The parameter name is a clickable hyperlink. + +* ``'link_symbol'``: A clickable link symbol is inserted after the parameter + name (but before an eventual type specification). By default, this symbol + only shows when hovering the parameter description (see below) + +* ``'name_and_symbol'``: link both the name and also generate a link symbol. + +The default is ``paramlinks_hyperlink_param = 'link_symbol'``. + +Features +======== + +* ``:param:`` directives within Sphinx function/method descriptions + will be given a paragraph link so that they can be linked + to externally. + +* A new text role ``:paramref:`` is added, which works like ``:meth:``, + ``:func:``, etc. Just append the parameter name as an additional token:: + + :paramref:`.EnvironmentContext.configure.transactional_ddl` + + The directive makes use of the existing Python role to do the method/function + lookup, searching first the ``:meth:``, then the ``:class:``, and then the + ``:func:`` role; then the parameter name is applied separately to produce the + final reference link. (new in 0.3.4, search for ``:meth:`` / ``:func:`` / + ``:class:`` individually rather than using ``:obj:`` which catches lots of + things that don't have parameters) + +* The paramlinks are also added to the master index as well as the list + of domain objects, which allows them to be searchable through the + searchindex.js system. (new in 0.3.0) + +Stylesheet +========== + +The paragraph link involves a short stylesheet, to allow the links to +be visible when hovered. This sheet is called +``sphinx_paramlinks.css`` and the plugin will copy it to the ``_static`` +directory of the output automatically. The stylesheet is added to the +``css_files`` list present in the template namespace for Sphinx via the +``Sphinx.add_stylesheet()`` hook. + +Customization +------------- + +To customize the link styling, you can override the configuration of +``sphinx_paramlinks.css`` by adding a custom style sheet via:: + + app.add_css_file("path/to/custom.css") + +If the parameter name is a hyperlink, the HTML code will look something like +this:: + + + parameter_name + + +The class ``paramname`` is defined by ``sphinx-paramlinks`` and can be used to +customize the styling. + +If a link symbol is inserted after the hyperlink, the HTML code will look +something like this:: + + + +The class ``paramlink`` is defined by ``sphinx-paramlinks`` and can be used to +customize the styling. + + +Compatibility +============= + +Python Compatibility +-------------------- + +sphinx-paramlinks is fully Python 3 compatible. + +Sphinx Compatibility +-------------------- + +I've tried *very* hard to make as few assumptions as possible about Sphinx +and to use only very simple public APIs, so that architectural changes in future +Sphinx versions won't break this plugin. To come up with this plugin I +spent many hours with Sphinx source and tried many different approaches to +various elements of functionality; hopefully what's here is as simple and +stable as possible based on the current extension capabilities of Sphinx. + +One element that involves using a bit of internals is the usage of the +``sphinx.domains.python.PyXRefRole`` class, which is currently the +Sphinx class that defines roles for things like ``:meth:``, +``:func:``, etc. The object is used as-is in order to define the +``:paramref:`` role; the product of this role is later transformed +using standard hooks. + +Another assumption is that in order to locate the RST nodes Sphinx +creates for the ``:param:`` tags, we look at ``nodes.strong``, +assuming that this is the type of node currently used to render +``:param:`` within RST. If this changes, or needs to be expanded to +support other domains, this traversal can be opened up as needed. +This part was difficult as Sphinx really doesn't provide any hooks +into how the "Info Field List" aspect of domains is handled. + +Overall, the approach here is to apply extra information to constructs +going into the Sphinx system, then do some transformations as the data +comes back out. This relies on as little of how Sphinx does its +thing as possible, rather than going with custom domains and heavy use +of injected APIs which may change in future releases. + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a8f43fe --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,2 @@ +[tool.black] +line-length = 79 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..ead512a --- /dev/null +++ b/setup.cfg @@ -0,0 +1,15 @@ +[flake8] +show-source = false +enable-extensions = G + +# E203 is due to https://github.com/PyCQA/pycodestyle/issues/373 +ignore = + A003, + D, + E203,E305,E711,E712,E721,E722,E741, + N801,N802,N806, + RST304,RST303,RST299,RST399, + W503,W504 +exclude = .venv,.git,.tox,dist,doc,*egg,build +import-order-style = google +application-import-names = sqlalchemy,test diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..a2f1d0f --- /dev/null +++ b/setup.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python +import os +import re + +from setuptools import setup + + +v = open( + os.path.join(os.path.dirname(__file__), "sphinx_paramlinks/__init__.py") +) +VERSION = ( + re.compile(r".*__version__ = [\"'](.*?)[\"']", re.S) + .match(v.read()) + .group(1) +) +v.close() + +readme = os.path.join(os.path.dirname(__file__), "README.rst") + + +setup( + name="sphinx-paramlinks", + version=VERSION, + description="Allows param links in Sphinx function/method " + "descriptions to be linkable", + long_description=open(readme).read(), + classifiers=[ + "Development Status :: 3 - Alpha", + "Environment :: Console", + "Intended Audience :: Developers", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", + "Topic :: Documentation", + ], + keywords="sphinx", + author="Mike Bayer", + author_email="mike@zzzcomputing.com", + url="http://github.com/sqlalchemyorg/sphinx-paramlinks", + license="MIT", + packages=["sphinx_paramlinks"], + include_package_data=True, + zip_safe=False, +) diff --git a/sphinx_paramlinks/__init__.py b/sphinx_paramlinks/__init__.py new file mode 100644 index 0000000..33dcef1 --- /dev/null +++ b/sphinx_paramlinks/__init__.py @@ -0,0 +1,3 @@ +__version__ = "0.5.3" + +from .sphinx_paramlinks import setup # noqa diff --git a/sphinx_paramlinks/sphinx_paramlinks.css b/sphinx_paramlinks/sphinx_paramlinks.css new file mode 100644 index 0000000..1d320a0 --- /dev/null +++ b/sphinx_paramlinks/sphinx_paramlinks.css @@ -0,0 +1,13 @@ +li > a.paramlink { + visibility: hidden; +} + + +p:hover > a.headerlink, p:hover > a.paramlink, li:hover > a.paramlink, li:hover > a.headerlink { + visibility: visible; +} + +a.paramname { + color:inherit; +} + diff --git a/sphinx_paramlinks/sphinx_paramlinks.py b/sphinx_paramlinks/sphinx_paramlinks.py new file mode 100644 index 0000000..cc134be --- /dev/null +++ b/sphinx_paramlinks/sphinx_paramlinks.py @@ -0,0 +1,439 @@ +#!coding: utf-8 +from distutils.version import LooseVersion +from enum import Enum +import os +import re + +from docutils import nodes +from docutils.transforms import Transform +from sphinx import __version__ +from sphinx import addnodes +from sphinx.domains import ObjType +from sphinx.domains.python import ObjectEntry +from sphinx.domains.python import PythonDomain +from sphinx.domains.python import PyXRefRole +from sphinx.util import logging +from sphinx.util.console import bold +from sphinx.util.osutil import copyfile + + +# the searchindex.js system relies upon the object types +# in the PythonDomain to create search entries + +PythonDomain.object_types["parameter"] = ObjType("parameter", "param") + +LOG = logging.getLogger(__name__) + + +def _is_html(app): + return app.builder.name in ("html", "readthedocs") + + +# https://www.sphinx-doc.org/en/master/extdev/deprecated.html + +# Constants for link styles +class HyperlinkStyle(Enum): + NONE = "none" + NAME = "name" + LINK_SYMBOL = "link_symbol" + NAME_AND_SYMBOL = "name_and_symbol" + + +def _indexentries(env): + return env.domains["index"].entries + + +def _tempdata(app): + if "_sphinx_paramlinks_index" in _indexentries(app.env): + idx = _indexentries(app.env)["_sphinx_paramlinks_index"] + else: + _indexentries(app.env)["_sphinx_paramlinks_index"] = idx = {} + return idx + + +def autodoc_process_docstring(app, what, name, obj, options, lines): + # locate :param: lines within docstrings. augment the parameter + # name with that of the parent object name plus a token we can + # spot later. Also put an index entry in a temporary collection. + + idx = _tempdata(app) + + docname = app.env.temp_data.get("docname") + if not docname: + return + if docname in idx: + doc_idx = idx[docname] + else: + idx[docname] = doc_idx = [] + + def _cvt_param(name, line): + if name.endswith(".__init__"): + # kill off __init__ if present, the links are always + # off the class + name = name[0:-9] + + def cvt(m): + role, modifier, objname, paramname = m.group(1), m.group(2) or "", name, m.group(3) + refname = _refname_from_paramname(paramname, strip_markup=True) + item = ( + "single", + "%s (%s parameter)" % (refname, objname), + "%s.params.%s" % (objname, refname), + "", + ) + if LooseVersion(__version__) >= LooseVersion("1.4.0"): + item += (None,) + + doc_idx.append(item) + return ":%s %s_sphinx_paramlinks_%s.%s:" % ( + role, + modifier, + objname, + paramname, + ) + + def secondary_cvt(m): + role, modifier, objname, paramname = m.group(1), m.group(2) or "", name, m.group(3) + return ":%s %s_sphinx_paramlinks_%s.%s:" % ( + role, + modifier, + objname, + paramname, + ) + + line = re.sub(r"^:(keyword|param) ([^:]+? )?([^:]+?):", cvt, line) + line = re.sub(r"^:(kwtype|type) ([^:]+? )?([^:]+?):", secondary_cvt, line) + return line + + if what in ("function", "method", "class"): + lines[:] = [_cvt_param(name, line) for line in lines] + + +def _refname_from_paramname(paramname, strip_markup=False): + literal_match = re.match(r"^``(.+?)``$", paramname) + if literal_match: + paramname = literal_match.group(1) + refname = paramname + eq_match = re.match(r"(.+?)=.+$", refname) + if eq_match: + refname = eq_match.group(1) + if strip_markup: + refname = re.sub(r"\\", "", refname) + return refname + + +class ApplyParamPrefix(Transform): + """Obfuscate the token name inside of a ":paramref:" reference + to prevent Sphinx from resolving the node + and generating a reference node that doesn't look the way we want it to. + Ensure that our own missing-reference resolver is used. + + """ + + default_priority = 210 + + def apply(self): + for ref in self.document.traverse(addnodes.pending_xref): + # look only at paramref + if ref["reftype"] != "paramref": + continue + + # for params that explicitly have ".params." in the reference + # source, let those just resolve normally. this is not + # really expected but it seems to work already. + if "params." in ref["reftarget"]: + continue + + target_tokens = ref["reftarget"].split(".") + + # apply a token to the link that will completely prevent + # Sphinx from ever resolving this node, because WE want to + # resolve and render the reference node, ALWAYS, THANK YOU! + target_tokens[-1] = "_sphinx_paramlinks_" + target_tokens[-1] + ref["reftarget"] = ".".join(target_tokens) + + +class LinkParams(Transform): + # apply references targets and optional references + # to nodes that contain our target text. + default_priority = 210 + + def apply(self): + config_value = ( + self.document.settings.env.app.config.paramlinks_hyperlink_param + ) + try: + link_style = HyperlinkStyle[config_value.upper()] + except KeyError as exc: + raise ValueError( + f"Unknown value {repr(config_value)} for " + f"'paramlinks_hyperlink_param'. " + f"Must be one of " + f"""{ + ', '.join(repr(member.value) for member in HyperlinkStyle) + }.""" + ) from exc + + if link_style is HyperlinkStyle.NONE: + return + + is_html = _is_html(self.document.settings.env.app) + + # search nodes, which will include the titles for + # those :param: directives, looking for our special token. + # then fix up the text within the node. + for ref in self.document.traverse(nodes.strong): + text = ref.astext() + if text.startswith("_sphinx_paramlinks_"): + components = re.match(r"_sphinx_paramlinks_(.+)\.(.+)$", text) + location, paramname = components.group(1, 2) + refname = _refname_from_paramname(paramname) + + refid = "%s.params.%s" % (location, refname) + ref.parent.insert(0, nodes.target("", "", ids=[refid])) + del ref[0] + + ref.insert(0, nodes.Text(paramname, paramname)) + + if is_html: + # add the "p" thing only if we're the HTML builder. + + # using a real ¶, surprising, right? + # http://docutils.sourceforge.net/FAQ.html + # #how-can-i-represent-esoteric-characters- + # e-g-character-entities-in-a-document + + # "For example, say you want an em-dash (XML + # character entity —, Unicode character + # U+2014) in your document: use a real em-dash. + # Insert concrete characters (e.g. type a real em- + # dash) into your input file, using whatever + # encoding suits your application, and tell + # Docutils the input encoding. Docutils uses + # Unicode internally, so the em-dash character is + # a real em-dash internally." OK ! + + for pos, node in enumerate(ref.parent.children): + # try to figure out where the node with the + # paramname is. thought this was simple, but + # readthedocs proving..it's not. + # TODO: need to take into account a type name + # with the parens. + if ( + isinstance(node, nodes.TextElement) + and node.astext() == paramname + ): + break + else: + return + + refparent = ref.parent + + if link_style in ( + HyperlinkStyle.NAME, + HyperlinkStyle.NAME_AND_SYMBOL, + ): + # If the parameter name should be a href, we wrap it + # into an tag + element = refparent.pop(pos) + + # note this is expected to be the same.... + # assert element is ref + + newnode = nodes.reference( + "", + "", + # needed to avoid recursion overflow + element.deepcopy(), + refid=refid, + classes=["paramname"], + ) + refparent.insert(pos, newnode) + + if link_style in ( + HyperlinkStyle.LINK_SYMBOL, + HyperlinkStyle.NAME_AND_SYMBOL, + ): + # If there should be a link symbol after the parameter + # name, insert it here + refparent.insert( + pos + 1, + nodes.reference( + "", + "", + nodes.Text("¶", "¶"), + refid=refid, + # paramlink is our own CSS class, headerlink + # is theirs. Trying to get everything we can + # for existing symbols... + classes=["paramlink", "headerlink"], + ), + ) + + +def lookup_params(app, env, node, contnode): + # here, we catch the "pending xref" nodes that we created with + # the "paramref" role we added. The resolve_xref() routine + # knows nothing about this node type so it never finds anything; + # the Sphinx BuildEnvironment then gives us one more chance to do a lookup + # here. + + if node["reftype"] != "paramref": + return None + + target = node["reftarget"] + + tokens = target.split(".") + + # if we just have :paramref:`arg` and not :paramref:`namespace.arg`, + # we must assume that the current namespace is meant. + if tokens == [target]: + # + # node.source is expected to look like: + # /path/to/file.py:docstring of module.clsname.methname + # + docstring_match = re.match(r".*?:docstring of (.*)", node.source) + if docstring_match: + full_attr_path = docstring_match.group(1) + fn_name = full_attr_path.split(".")[-1] + tokens.insert(0, fn_name) + + resolve_target = ".".join(tokens[0:-1]) + + # we are now cleared of Sphinx's resolver. + # remove _sphinx_paramlinks_ token from refid so we can search + # for the node normally. + paramname = tokens[-1].replace("_sphinx_paramlinks_", "") + + # Remove _sphinx_paramlinks_ obfuscation string from all text nodes + # for rendering. + for replnode in (node, contnode): + for text_node in replnode.traverse(nodes.Text): + text_node.parent.replace( + text_node, + nodes.Text(text_node.replace("_sphinx_paramlinks_", "")), + ) + + # emulate the approach within + # sphinx.environment.BuildEnvironment.resolve_references + try: + domain = env.domains[node["refdomain"]] # hint: this will be 'py' + except KeyError: + return None + + # BuildEnvironment doesn't pass us "fromdocname" here as the + # fallback, oh well + refdoc = node.get("refdoc", None) + + # we call the same "resolve_xref" that BuildEnvironment just tried + # to call for us, but we load the call with information we know + # it can find, e.g. the "object" role (or we could use :meth:/:func:) + # along with the classname/methodname/funcname minus the parameter + # part. + + for search in ["meth", "class", "func"]: + newnode = domain.resolve_xref( + env, refdoc, app.builder, search, resolve_target, node, contnode + ) + if newnode is not None: + break + + if newnode is not None: + # assuming we found it, tack the paramname back onto to the final + # URI + if "refuri" in newnode: + newnode["refuri"] += ".params." + paramname + elif "refid" in newnode: + newnode["refid"] += ".params." + paramname + + return newnode + + +def add_stylesheet(app): + # changed in 1.8 from add_stylesheet() + # https://www.sphinx-doc.org/en/master/extdev/appapi.html + # #sphinx.application.Sphinx.add_css_file + app.add_css_file("sphinx_paramlinks.css") + + +def copy_stylesheet(app, exception): + LOG.info( + bold("The name of the builder is: %s" % app.builder.name), nonl=True + ) + + if not _is_html(app) or exception: + return + LOG.info(bold("Copying sphinx_paramlinks stylesheet... "), nonl=True) + + source = os.path.abspath(os.path.dirname(__file__)) + + # the '_static' directory name is hardcoded in + # sphinx.builders.html.StandaloneHTMLBuilder.copy_static_files. + # would be nice if Sphinx could improve the API here so that we just + # give it the path to a .css file and it does the right thing. + dest = os.path.join(app.builder.outdir, "_static", "sphinx_paramlinks.css") + copyfile(os.path.join(source, "sphinx_paramlinks.css"), dest) + LOG.info("done") + + +def build_index(app, doctree): + entries = _tempdata(app) + + for docname in entries: + doc_entries = entries[docname] + _indexentries(app.env)[docname].extend(doc_entries) + + if LooseVersion(__version__) >= LooseVersion("4.0.0"): + for entry in doc_entries: + sing, desc, ref, extra = entry[:4] + app.env.domains["py"].data["objects"][ref] = ObjectEntry( + docname, ref, "parameter", False + ) + elif LooseVersion(__version__) >= LooseVersion("3.0.0"): + for entry in doc_entries: + sing, desc, ref, extra = entry[:4] + app.env.domains["py"].data["objects"][ref] = ObjectEntry( + docname, + ref, + "parameter", + ) + else: + for entry in doc_entries: + sing, desc, ref, extra = entry[:4] + app.env.domains["py"].data["objects"][ref] = ( + docname, + "parameter", + ) + + _indexentries(app.env).pop("_sphinx_paramlinks_index") + + +def setup(app): + app.add_transform(LinkParams) + app.add_transform(ApplyParamPrefix) + + # Make sure that default is are the same as in LinkParams + # When config changes, the whole env needs to be rebuild since + # LinkParams is applied while building the doctrees + app.add_config_value( + "paramlinks_hyperlink_param", + HyperlinkStyle.LINK_SYMBOL.name, + "env", + [str], + ) + + # PyXRefRole is what the sphinx Python domain uses to set up + # role nodes like "meth", "func", etc. It produces a "pending xref" + # sphinx node along with contextual information. + app.add_role_to_domain("py", "paramref", PyXRefRole()) + + app.connect("autodoc-process-docstring", autodoc_process_docstring) + app.connect("builder-inited", add_stylesheet) + app.connect("build-finished", copy_stylesheet) + app.connect("doctree-read", build_index) + app.connect("missing-reference", lookup_params) + + return { + "parallel_read_safe": True, + "parallel_write_safe": True, + }