sphinx
sphinx copied to clipboard
`autodoc`-documented type aliases can't be referenced from type annotations
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)
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:attrentry for pathlike, and Sphinx is trying to resolve apy:classentry 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:
- redeclare
pathlikeas.. data:: pathlikeusing reST - add the
:noindex:option to the.. data:: pathlikedeclaration - Finally, use CSS to make the
.. class:: pathlikedeclaration 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.)
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.Evaluatorinstead of.. autoclass:: sybil.typing.Evaluator- Both
autoclassandautodatabut withautodoc_type_aliases = {'Evaluator': 'sybil.typing.Evaluator'}in myconf.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:
- 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?)
- Where Evaluator is used in a method parameter type annotation, it is replaced by the type annotation rather than just the text
Evaluatorlinked to its autoclass definition. Is anyone aware of a github issue already open for this? - I've noticed that
Anyisn'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!
To add on top of https://github.com/sphinx-doc/sphinx/issues/10785#issuecomment-1321100925, here's how to properly document and hide the dummy class reference using Python 3.12:
- Declare your aliases in some file, e.g., in
my_package/utils.py, using new type statement (see PEP 695) - 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 ...
- In
conf.pyadd an alias dictionaryTYPE_ALIASmapping from type names to their module paths - Modify HTML to delete
classdirectives but copy their properties (including ID) to correspondingdatadirectives. 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)
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.