Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

autodoc-documented type aliases can't be referenced from type annotations #10785

Open
godlygeek opened this issue Aug 23, 2022 · 4 comments
Open

Comments

@godlygeek
Copy link
Contributor

godlygeek commented Aug 23, 2022

Describe the bug

When using autodoc to document type aliases in a module, references to those aliases in the signature of a function cannot be resolved, even when using from __future__ import annotations and autodoc_type_aliases.

How to Reproduce

Create module.py with these contents:

from __future__ import annotations
import pathlib

#: Any type of path
pathlike = str | pathlib.Path


def read_file(path: pathlike) -> bytes:
    """Read a file and return its contents."""
    with open(path, "rb") as f:
        return f.read()

and index.rst with these contents:

.. automodule:: module
   :members:
   :member-order: bysource

and then run Sphinx, enabling autodoc and using autodoc_type_aliases:

$ python -m sphinx -aE -C -D 'extensions=sphinx.ext.autodoc' -D 'autodoc_type_aliases.pathlike=pathlike' . output

Expected behavior

On the module.read_file(path: pathlike) → bytes line, pathlike should be a link to the module.pathlike type alias, but it is not a link at all.

Running with nitpicky mode shows:

module.py:docstring of module.read_file:1: WARNING: py:class reference target not found: pathlike

This is because autodoc is generating a py:attr entry for pathlike, and Sphinx is trying to resolve a py:class entry instead.

Your project

See "how to reproduce"

Screenshots

No response

OS

Linux

Python version

3.10.6

Sphinx version

5.1.1

Sphinx extensions

sphinx.ext.autodoc

Extra tools

No response

Additional context

I'm working around this with a hack in my docs/conf.py:

TYPE_ALIASES = ["pathlike", "filelike"]

def resolve_type_aliases(app, env, node, contnode):
    """Resolve :class: references to our type aliases as :attr: instead."""
    if (
        node["refdomain"] == "py"
        and node["reftype"] == "class"
        and node["reftarget"] in TYPE_ALIASES
    ):
        return app.env.get_domain("py").resolve_xref(
            env, node["refdoc"], app.builder, "attr", node["reftarget"], node, contnode
        )


def setup(app):
    app.connect("missing-reference", resolve_type_aliases)
@electric-coder
Copy link

electric-coder commented Nov 20, 2022

Running with nitpicky mode shows:

module.py:docstring of module.read_file:1: WARNING: py:class reference target not found: pathlike

I was looking for a Python 3.9 workaround (not using TypeVar) if a solution couldn't be found, and this comment allowed me to find a way to get the hyperlink to the type alias working in the signature.

This is because autodoc is generating a py:attr entry for pathlike, and Sphinx is trying to resolve a py:class entry instead.

autodoc is wrong here to begin with, a module level type alias (at least up until Python 3.9 without TypeVar) is a py:data role. It makes sense that Sphinx itself is trying to resolve the role as py:class because types outside of the standard library used in signatures would always be classes.

A type alias (not using TypeVar) would be in reST:

.. data:: pathlike 
    :type: str | pathlib.Path

So to get the hyperlink to resolve I changed the declaration to:

.. class:: pathlike 

The important part is giving Sphinx the .. py:class directive it expects. Together with setting autodoc_type_aliases in conf.py the hyperlink then works.

autodoc_type_aliases = {
    'pathlike ': 'module.pathlike ',  # works
}

However, the workaround leaves you with a problem: in the documentation the type alias now appears as a class. So to have it display with the proper type (as a stop gap measure) you would have to:

  1. redeclare pathlike as .. data:: pathlike using reST
  2. add the :noindex: option to the .. data:: pathlike declaration
  3. Finally, use CSS to make the .. class:: pathlike declaration invisible:
.. class:: pathlike 

.. data:: pathlike 
    :type: str | pathlib.Path
    :noindex:

This does get the documentation with hyperlinks and cross-references working as intended. But it requires manually writing reST for each type alias declaration. From the end user's perspective the API shows correctly (the only telltale sign would the type in the index, but that could also be manually overridden.)

Julian added a commit to DanielNoord/jsonschema that referenced this issue Nov 30, 2022
@cjw296
Copy link

cjw296 commented Dec 2, 2022

In my case, I have a type annotation to an alias called Evaluator used as follows:

class Region:
    """
    Parsers should yield instances of this class for each example they
    discover in a documentation source file.
    
    :param start: 
        The character position at which the example starts in the
        :class:`~sybil.document.Document`.
    
    :param end: 
        The character position at which the example ends in the
        :class:`~sybil.document.Document`.
    
    :param parsed: 
        The parsed version of the example.
    
    :param evaluator: 
        The callable to use to evaluate this example and check if it is
        as it should be.
    """

    def __init__(self, start: int, end: int, parsed: Any, evaluator: Evaluator):
        #: The start of this region within the document's :attr:`~sybil.Document.text`.
        self.start: int = start
        #: The end of this region within the document's :attr:`~sybil.Document.text`.
        self.end: int = end
        #: The parsed version of this region. This only needs to have meaning to
        #: the :attr:`evaluator`.
        self.parsed: Any = parsed
        #: The :any:`Evaluator` for this region.
        self.evaluator: Evaluator = evaluator

This is defined in sybil/typing.py as:

#: The signature for an evaluator. See :ref:`developing-parsers`.
Evaluator = Callable[['sybil.Example'], Optional[str]]

This is documented in a .rst file as follows:

.. autoclass:: sybil.typing.Evaluator

Now, for the __init__ parameter usage, I get this rendering:

class sybil.Region(start: int, end: int, parsed: Any, evaluator: Callable[[sybil.Example], Optional[str]])

However, for the attribute, I get:

evaluator: Evaluator
The Evaluator for this region.

Note the lack of linking in Evaluator. I get the following warning:

sybil/region.py:docstring of sybil.Region.evaluator:1: WARNING: py:class reference target not found: Evaluator

I can find no way to fix this, I tried:

  • .. autodata:: sybil.typing.Evaluator instead of .. autoclass:: sybil.typing.Evaluator
  • Both autoclass and autodata but with autodoc_type_aliases = {'Evaluator': 'sybil.typing.Evaluator'} in my conf.py

Since I have nitpicky on and treating warnings as thing to cause a doc build to fail, the only workaround I can find is to put this in my conf.py:

nitpick_ignore = [('py:class', 'Evaluator')]

So, three problems:

  1. Evaluator isn't linked and I get a "py:class reference target not found: Evaluator" warning (which I believe is the crux of this github issue?)
  2. Where Evaluator is used in a method parameter type annotation, it is replaced by the type annotation rather than just the text Evaluator linked to its autoclass definition. Is anyone aware of a github issue already open for this?
  3. I've noticed that Any isn't link to anywhere in the Python docs from my Sphinx sounds, again, is there an issue already open for this?

If I've missed any information that would be useful in making progress on this, please let me know!

osandov added a commit to osandov/drgn that referenced this issue Nov 1, 2023
Sphinx normally makes type names in annotations links to the
documentation for that type, but this doesn't work for type aliases
(like drgn.Path). See sphinx-doc/sphinx#10785. Add a workaround inspired
by adafruit/circuitpython#8236.

Signed-off-by: Omar Sandoval <[email protected]>
@mantasu
Copy link

mantasu commented Jan 18, 2024

To add on top of #10785 (comment), here's how to properly document and hide the dummy class reference using Python 3.12:

  1. Declare your aliases in some file, e.g., in my_package/utils.py, using new type statement (see PEP 695)
  2. At the top of the file add a docstring describing your aliases (use :data: directive with :noindex:). For each alias add a dummy :class: directive with the same name (as mentioned above)

An example python file may look like this:

"""
.. class:: BytesOrStr
 
.. data:: BytesOrStr
    :noindex:
    :type: typing.TypeAliasType
    :value: str | bytes
    
    Type alias for bytes or string.

    Bound:
        :class:`str` | :class:`bytes`

.. class:: FilePath
    
.. data:: FilePath
    :noindex:
    :type: typing.TypeAliasType
    :value: BytesOrStr | os.PathLike

    Type alias for a file path.

    Bound:
        :class:`str` | :class:`bytes` | :class:`os.PathLike`
"""
import os

type BytesOrStr = str | bytes
type FilePath = BytesOrStr | os.PathLike
# More type aliases ...
  1. In conf.py add an alias dictionary TYPE_ALIAS mapping from type names to their module paths
  2. Modify HTML to delete class directives but copy their properties (including ID) to corresponding data directives. This can be done using build-finished event.

Here is an example of what could be appended to conf.py (I'm using sphinx 7.2.6 and pydata-sphinx-theme 0.15.1, other versions may have different HTML layouts):

from pathlib import Path
from bs4 import BeautifulSoup, Tag

# Define alias paths
TYPE_ALIASES = {
    "BytesOrStr": "my_package.utils.",
    "FilePath": "my_package.utils.",
}

def keep_only_data(soup: BeautifulSoup):
    def has_children(tag: Tag, txt1: str, txt2: str):
        if tag.name != "dt":
            return False

        # Get the prename and name elements of the signature
        ch1 = tag.select_one("span.sig-prename.descclassname span.pre")
        ch2 = tag.select_one("span.sig-name.descname span.pre")

        return ch1 and ch2 and ch1.string == txt1 and ch2.string == txt2

    for alias, module in TYPE_ALIASES.items():
        if dt := soup.find("dt", id=f"{module}{alias}"):
            # Copy class directive's a
            a = dt.find("a").__copy__()
            dt.parent.decompose()
        else:
            continue

        if dt := soup.find(lambda tag: has_children(tag, module, alias)):
            # ID and a for data directive
            dt["id"] = f"{module}{alias}"
            dt.append(a)

def edit_html(app, exception):
    if app.builder.format != "html":
        return

    for pagename in app.env.found_docs:
        if not isinstance(pagename, str):
            continue

        with (Path(app.outdir) / f"{pagename}.html").open("r") as f:
            # Parse HTML using BeautifulSoup html parser
            soup = BeautifulSoup(f.read(), "html.parser")
            keep_only_data(soup)

        with (Path(app.outdir) / f"{pagename}.html").open("w") as f:
            # Write back HTML
            f.write(str(soup))

def setup(app):
    app.connect("build-finished", edit_html)

@Shoeboxam
Copy link

In my case, I only encounter this issue if I have from __future__ import annotations in the file. I avoided the issue by just using Union[A, B] instead of the future import and A | B.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

6 participants