Skip to content

Commit

Permalink
Support Sphinx 8.2.0 - drop 3.10 support because Sphinx does (#525)
Browse files Browse the repository at this point in the history
* chore(deps): update dependency sphinx to v8.2.0

* refactor: patch stringify_annotation with arbitrary *args and **kwargs

* fix: derive annotation for TypeAliasForwardRef from its name attribute

TypeAliasForwardRef.__repr__ was changed in sphinx v8.2.0 to not only yield the name, but also the class name.

* test: update warning message

"unpickable" was renamed to "unpickleable" in sphinx v8.2.0

* feat: respect configuration option python_display_short_literal_types

https://www.sphinx-doc.org/en/master/usage/configuration.html#confval-python_display_short_literal_types

* Sphinx 8.2 drops 3.10 support

Signed-off-by: Bernát Gábor <[email protected]>

---------

Signed-off-by: Bernát Gábor <[email protected]>
Co-authored-by: Bernát Gábor <[email protected]>
  • Loading branch information
b-kamphorst and gaborbernat authored Feb 19, 2025
1 parent f09eb89 commit ca5fcc0
Show file tree
Hide file tree
Showing 9 changed files with 91 additions and 43 deletions.
1 change: 0 additions & 1 deletion .github/workflows/check.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ jobs:
- "3.13"
- "3.12"
- "3.11"
- "3.10"
- type
- dev
- pkg_meta
Expand Down
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ repos:
hooks:
- id: prettier
additional_dependencies:
- prettier@3.4.2
- prettier@3.5.1
- "@prettier/[email protected]"
- repo: meta
hooks:
Expand Down
11 changes: 5 additions & 6 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,14 @@ maintainers = [
authors = [
{ name = "Bernát Gábor", email = "[email protected]" },
]
requires-python = ">=3.10"
requires-python = ">=3.11"
classifiers = [
"Development Status :: 5 - Production/Stable",
"Framework :: Sphinx :: Extension",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
Expand All @@ -41,16 +40,16 @@ dynamic = [
"version",
]
dependencies = [
"sphinx>=8.1.3",
"sphinx>=8.2",
]
optional-dependencies.docs = [
"furo>=2024.8.6",
]
optional-dependencies.testing = [
"covdefaults>=2.3",
"coverage>=7.6.10",
"coverage>=7.6.12",
"defusedxml>=0.7.1", # required by sphinx.testing
"diff-cover>=9.2.1",
"diff-cover>=9.2.3",
"pytest>=8.3.4",
"pytest-cov>=6",
"sphobjinv>=2.3.1.2",
Expand Down Expand Up @@ -142,7 +141,7 @@ run.plugins = [
]

[tool.mypy]
python_version = "3.10"
python_version = "3.11"
strict = true
exclude = "^(.*/roots/.*)|(tests/test_integration.*.py)$"
overrides = [
Expand Down
31 changes: 20 additions & 11 deletions src/sphinx_autodoc_typehints/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,11 +172,11 @@ def get_annotation_args(annotation: Any, module: str, class_name: str) -> tuple[
return () if len(result) == 1 and result[0] == () else result # type: ignore[misc]


def format_internal_tuple(t: tuple[Any, ...], config: Config) -> str:
def format_internal_tuple(t: tuple[Any, ...], config: Config, *, short_literals: bool = False) -> str:
# An annotation can be a tuple, e.g., for numpy.typing:
# In this case, format_annotation receives:
# This solution should hopefully be general for *any* type that allows tuples in annotations
fmt = [format_annotation(a, config) for a in t]
fmt = [format_annotation(a, config, short_literals=short_literals) for a in t]
if len(fmt) == 0:
return "()"
if len(fmt) == 1:
Expand All @@ -196,12 +196,13 @@ def fixup_module_name(config: Config, module: str) -> str:
return module


def format_annotation(annotation: Any, config: Config) -> str: # noqa: C901, PLR0911, PLR0912, PLR0915, PLR0914
def format_annotation(annotation: Any, config: Config, *, short_literals: bool = False) -> str: # noqa: C901, PLR0911, PLR0912, PLR0915, PLR0914
"""
Format the annotation.
:param annotation:
:param config:
:param short_literals: Render :py:class:`Literals` in PEP 604 style (``|``).
:return:
"""
typehints_formatter: Callable[..., str] | None = getattr(config, "typehints_formatter", None)
Expand All @@ -222,7 +223,7 @@ def format_annotation(annotation: Any, config: Config) -> str: # noqa: C901, PL
return format_internal_tuple(annotation, config)

if isinstance(annotation, TypeAliasForwardRef):
return str(annotation)
return annotation.name

try:
module = get_annotation_module(annotation)
Expand Down Expand Up @@ -254,7 +255,7 @@ def format_annotation(annotation: Any, config: Config) -> str: # noqa: C901, PL
params = {k: getattr(annotation, f"__{k}__") for k in ("bound", "covariant", "contravariant")}
params = {k: v for k, v in params.items() if v}
if "bound" in params:
params["bound"] = f" {format_annotation(params['bound'], config)}"
params["bound"] = f" {format_annotation(params['bound'], config, short_literals=short_literals)}"
args_format = f"\\(``{annotation.__name__}``{', {}' if args else ''}"
if params:
args_format += "".join(f", {k}={v}" for k, v in params.items())
Expand All @@ -275,20 +276,22 @@ def format_annotation(annotation: Any, config: Config) -> str: # noqa: C901, PL
args_format = f"\\[:py:data:`{prefix}typing.Union`\\[{{}}]]"
args = tuple(x for x in args if x is not type(None))
elif full_name in {"typing.Callable", "collections.abc.Callable"} and args and args[0] is not ...:
fmt = [format_annotation(arg, config) for arg in args]
fmt = [format_annotation(arg, config, short_literals=short_literals) for arg in args]
formatted_args = f"\\[\\[{', '.join(fmt[:-1])}], {fmt[-1]}]"
elif full_name == "typing.Literal":
if short_literals:
return f"\\{' | '.join(f'``{arg!r}``' for arg in args)}"
formatted_args = f"\\[{', '.join(f'``{arg!r}``' for arg in args)}]"
elif is_bars_union:
return " | ".join([format_annotation(arg, config) for arg in args])
return " | ".join([format_annotation(arg, config, short_literals=short_literals) for arg in args])

if args and not formatted_args:
try:
iter(args)
except TypeError:
fmt = [format_annotation(args, config)]
fmt = [format_annotation(args, config, short_literals=short_literals)]
else:
fmt = [format_annotation(arg, config) for arg in args]
fmt = [format_annotation(arg, config, short_literals=short_literals) for arg in args]
formatted_args = args_format.format(", ".join(fmt))

escape = "\\ " if formatted_args else ""
Expand Down Expand Up @@ -783,7 +786,10 @@ def _inject_signature(
if annotation is None:
type_annotation = f":type {arg_name}: "
else:
formatted_annotation = add_type_css_class(format_annotation(annotation, app.config))
short_literals = app.config.python_display_short_literal_types
formatted_annotation = add_type_css_class(
format_annotation(annotation, app.config, short_literals=short_literals)
)
type_annotation = f":type {arg_name}: {formatted_annotation}"

if app.config.typehints_defaults:
Expand Down Expand Up @@ -923,7 +929,10 @@ def _inject_rtype( # noqa: PLR0913, PLR0917
if not app.config.typehints_use_rtype and r.found_return and " -- " in lines[insert_index]:
return

formatted_annotation = add_type_css_class(format_annotation(type_hints["return"], app.config))
short_literals = app.config.python_display_short_literal_types
formatted_annotation = add_type_css_class(
format_annotation(type_hints["return"], app.config, short_literals=short_literals)
)

if r.found_param and insert_index < len(lines) and lines[insert_index].strip():
insert_index -= 1
Expand Down
4 changes: 2 additions & 2 deletions src/sphinx_autodoc_typehints/attributes_patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,12 @@
orig_handle_signature = PyAttribute.handle_signature


def _stringify_annotation(app: Sphinx, annotation: Any, mode: str = "") -> str: # noqa: ARG001
def _stringify_annotation(app: Sphinx, annotation: Any, *args: Any, short_literals: bool = False, **kwargs: Any) -> str: # noqa: ARG001
# Format the annotation with sphinx-autodoc-typehints and inject our magic prefix to tell our patched
# PyAttribute.handle_signature to treat it as rst.
from . import format_annotation # noqa: PLC0415

return TYPE_IS_RST_LABEL + format_annotation(annotation, app.config)
return TYPE_IS_RST_LABEL + format_annotation(annotation, app.config, short_literals=short_literals)


def patch_attribute_documenter(app: Sphinx) -> None:
Expand Down
5 changes: 2 additions & 3 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import re
import shutil
import sys
from contextlib import suppress
from pathlib import Path
from typing import TYPE_CHECKING

Expand Down Expand Up @@ -36,11 +37,9 @@ def _remove_sphinx_projects(sphinx_test_tempdir: Path) -> None:
# the temporary directory area.
# See https://github.com/sphinx-doc/sphinx/issues/4040
for entry in sphinx_test_tempdir.iterdir():
try:
with suppress(PermissionError):
if entry.is_dir() and Path(entry, "_build").exists():
shutil.rmtree(str(entry))
except PermissionError: # noqa: PERF203
pass


@pytest.fixture
Expand Down
56 changes: 55 additions & 1 deletion tests/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from typing import ( # no type comments
TYPE_CHECKING,
Any,
Literal,
NewType,
Optional,
TypeVar,
Expand Down Expand Up @@ -661,6 +662,59 @@ def func_with_overload(a: Union[int, str], b: Union[int, str]) -> None:
"""


@expected(
"""\
mod.func_literals_long_format(a, b)
A docstring.
Parameters:
* **a** ("Literal"["'arg1'", "'arg2'"]) -- Argument that can
take either of two literal values.
* **b** ("Literal"["'arg1'", "'arg2'"]) -- Argument that can
take either of two literal values.
Return type:
"None"
""",
)
def func_literals_long_format(a: Literal["arg1", "arg2"], b: Literal["arg1", "arg2"]) -> None:
"""
A docstring.
:param a: Argument that can take either of two literal values.
:param b: Argument that can take either of two literal values.
"""


@expected(
"""\
mod.func_literals_short_format(a, b)
A docstring.
Parameters:
* **a** ("'arg1'" | "'arg2'") -- Argument that can take either
of two literal values.
* **b** ("'arg1'" | "'arg2'") -- Argument that can take either
of two literal values.
Return type:
"None"
""",
python_display_short_literal_types=True,
)
def func_literals_short_format(a: Literal["arg1", "arg2"], b: Literal["arg1", "arg2"]) -> None:
"""
A docstring.
:param a: Argument that can take either of two literal values.
:param b: Argument that can take either of two literal values.
"""


@expected(
"""\
class mod.TestClassAttributeDocs
Expand Down Expand Up @@ -1386,7 +1440,7 @@ def has_doctest1() -> None:
Unformatted = TypeVar("Unformatted")


@warns("cannot cache unpickable configuration value: 'typehints_formatter'")
@warns("cannot cache unpickleable configuration value: 'typehints_formatter'")
@expected(
"""
mod.typehints_formatter_applied_to_signature(param: Formatted) -> Formatted
Expand Down
13 changes: 1 addition & 12 deletions tests/test_sphinx_autodoc_typehints.py
Original file line number Diff line number Diff line change
Expand Up @@ -554,17 +554,6 @@ class dummy_module.DataClass(x)
assert contents == expected_contents


def maybe_fix_py310(expected_contents: str) -> str:
if sys.version_info >= (3, 11):
return expected_contents

for old, new in [
('"str" | "None"', '"Optional"["str"]'),
]:
expected_contents = expected_contents.replace(old, new)
return expected_contents


@pytest.mark.sphinx("text", testroot="dummy")
@patch("sphinx.writers.text.MAXWIDTH", 2000)
def test_sphinx_output_future_annotations(app: SphinxTestApp, status: StringIO) -> None:
Expand Down Expand Up @@ -595,7 +584,7 @@ def test_sphinx_output_future_annotations(app: SphinxTestApp, status: StringIO)
"str"
"""
expected_contents = dedent(expected_contents)
expected_contents = maybe_fix_py310(dedent(expected_contents))
expected_contents = dedent(expected_contents)
assert contents == expected_contents


Expand Down
11 changes: 5 additions & 6 deletions tox.ini
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
[tox]
requires =
tox>=4.23.2
tox-uv>=1.16.2
tox>=4.24.1
tox-uv>=1.24
env_list =
fix
3.13
3.12
3.11
3.10
type
pkg_meta
skip_missing_interpreters = true
Expand Down Expand Up @@ -45,7 +44,7 @@ commands =
[testenv:type]
description = run type check on code base
deps =
mypy==1.14
mypy==1.15
types-docutils>=0.21.0.20241128
commands =
mypy src
Expand All @@ -56,8 +55,8 @@ description = check that the long description is valid
skip_install = true
deps =
check-wheel-contents>=0.6.1
twine>=6.0.1
uv>=0.5.11
twine>=6.1
uv>=0.6.1
commands =
uv build --sdist --wheel --out-dir {env_tmp_dir} .
twine check {env_tmp_dir}{/}*
Expand Down

0 comments on commit ca5fcc0

Please sign in to comment.