Skip to content

Commit ddac8c8

Browse files
authored
Python 3.14 support (#569)
1 parent 239cc7b commit ddac8c8

File tree

10 files changed

+99
-63
lines changed

10 files changed

+99
-63
lines changed

.github/workflows/check.yaml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ jobs:
1919
fail-fast: false
2020
matrix:
2121
env:
22+
- "3.14t"
23+
- "3.14"
2224
- "3.13"
2325
- "3.12"
2426
- "3.11"
@@ -38,9 +40,9 @@ jobs:
3840
cache-dependency-glob: "pyproject.toml"
3941
github-token: ${{ secrets.GITHUB_TOKEN }}
4042
- name: Install tox
41-
run: uv tool install --python-preference only-managed --python 3.13 tox --with tox-uv
43+
run: uv tool install --python-preference only-managed --python 3.14 tox --with tox-uv
4244
- name: Install Python
43-
if: startsWith(matrix.env, '3.') && matrix.env != '3.13'
45+
if: startsWith(matrix.env, '3.') && matrix.env != '3.14'
4446
run: uv python install --python-preference only-managed ${{ matrix.env }}
4547
- name: Setup test suite
4648
run: tox run -vv --notest --skip-missing-interpreters false -e ${{ matrix.env }}

.github/workflows/release.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ jobs:
2020
cache-dependency-glob: "pyproject.toml"
2121
github-token: ${{ secrets.GITHUB_TOKEN }}
2222
- name: Build package
23-
run: uv build --python 3.13 --python-preference only-managed --sdist --wheel . --out-dir dist
23+
run: uv build --python 3.14 --python-preference only-managed --sdist --wheel . --out-dir dist
2424
- name: Store the distribution packages
2525
uses: actions/upload-artifact@v4
2626
with:

.pre-commit-config.yaml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,11 @@ repos:
2020
- id: tox-ini-fmt
2121
args: ["-p", "fix"]
2222
- repo: https://github.com/tox-dev/pyproject-fmt
23-
rev: "v2.7.0"
23+
rev: "v2.8.0"
2424
hooks:
2525
- id: pyproject-fmt
2626
- repo: https://github.com/astral-sh/ruff-pre-commit
27-
rev: "v0.13.3"
27+
rev: "v0.14.0"
2828
hooks:
2929
- id: ruff-format
3030
- id: ruff
@@ -34,8 +34,8 @@ repos:
3434
hooks:
3535
- id: prettier
3636
additional_dependencies:
37-
- prettier@3.5.1
38-
- "@prettier/[email protected].1"
37+
- prettier@3.6.2
38+
- "@prettier/[email protected].2"
3939
- repo: meta
4040
hooks:
4141
- id: check-hooks-apply

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,8 @@ The following configuration options are accepted:
6464
`True`, add stub documentation for undocumented parameters to be able to add type info.
6565
- `always_use_bars_union ` (default: `False`): If `True`, display Union's using the | operator described in PEP 604.
6666
(e.g `X` | `Y` or `int` | `None`). If `False`, Unions will display with the typing in brackets. (e.g. `Union[X, Y]`
67-
or `Optional[int]`)
67+
or `Optional[int]`). Note that on 3.14 and later this will always be `True` and not configurable due the interpreter
68+
no longer differentiating between the two types, and we have no way to determine what the user used.
6869
- `typehints_document_rtype` (default: `True`): If `False`, never add an `:rtype:` directive. If `True`, add the
6970
`:rtype:` directive if no existing `:rtype:` is found.
7071
- `typehints_document_rtype_none` (default: `True`): If `False`, never add an `:rtype: None` directive. If `True`, add the `:rtype: None`.
@@ -74,7 +75,6 @@ The following configuration options are accepted:
7475
[napoleon_use_rtype](https://www.sphinx-doc.org/en/master/usage/extensions/napoleon.html#confval-napoleon_use_rtype)
7576
to avoid generation of duplicate or redundant return type information.
7677
- `typehints_defaults` (default: `None`): If `None`, defaults are not added. Otherwise, adds a default annotation:
77-
7878
- `'comma'` adds it after the type, changing Sphinx’ default look to “**param** (_int_, default: `1`) -- text”.
7979
- `'braces'` adds `(default: ...)` after the type (useful for numpydoc like styles).
8080
- `'braces-after'` adds `(default: ...)` at the end of the parameter documentation text instead.

pyproject.toml

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[build-system]
22
build-backend = "hatchling.build"
33
requires = [
4-
"hatch-vcs>=0.4",
4+
"hatch-vcs>=0.5",
55
"hatchling>=1.27",
66
]
77

@@ -40,20 +40,20 @@ dynamic = [
4040
"version",
4141
]
4242
dependencies = [
43-
"sphinx>=8.2",
43+
"sphinx>=8.2.3",
4444
]
4545
optional-dependencies.docs = [
46-
"furo>=2024.8.6",
46+
"furo>=2025.9.25",
4747
]
4848
optional-dependencies.testing = [
4949
"covdefaults>=2.3",
50-
"coverage>=7.6.12",
51-
"defusedxml>=0.7.1", # required by sphinx.testing
52-
"diff-cover>=9.2.3",
53-
"pytest>=8.3.4",
54-
"pytest-cov>=6",
55-
"sphobjinv>=2.3.1.2",
56-
"typing-extensions>=4.12.2",
50+
"coverage>=7.10.7",
51+
"defusedxml>=0.7.1", # required by sphinx.testing
52+
"diff-cover>=9.7.1",
53+
"pytest>=8.4.2",
54+
"pytest-cov>=7",
55+
"sphobjinv>=2.3.1.3",
56+
"typing-extensions>=4.15",
5757
]
5858
urls.Changelog = "https://github.com/tox-dev/sphinx-autodoc-typehints/releases"
5959
urls.Homepage = "https://github.com/tox-dev/sphinx-autodoc-typehints"

src/sphinx_autodoc_typehints/__init__.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,17 @@
3636
from sphinx.ext.autodoc import Options
3737

3838
_LOGGER = logging.getLogger(__name__)
39-
_PYDATA_ANNOTS_TYPING = {"Any", "AnyStr", "Callable", "ClassVar", "Literal", "NoReturn", "Optional", "Tuple", "Union"}
39+
_PYDATA_ANNOTS_TYPING = {
40+
"Any",
41+
"AnyStr",
42+
"Callable",
43+
"ClassVar",
44+
"Literal",
45+
"NoReturn",
46+
"Optional",
47+
"Tuple",
48+
*({"Union"} if sys.version_info < (3, 14) else set()),
49+
}
4050
_PYDATA_ANNOTS_TYPES = {
4151
*("AsyncGeneratorType", "BuiltinFunctionType", "BuiltinMethodType"),
4252
*("CellType", "ClassMethodDescriptorType", "CoroutineType"),
@@ -246,8 +256,10 @@ def format_annotation(annotation: Any, config: Config, *, short_literals: bool =
246256
formatted_args: str | None = ""
247257

248258
always_use_bars_union: bool = getattr(config, "always_use_bars_union", True)
249-
is_bars_union = full_name == "types.UnionType" or (
250-
always_use_bars_union and type(annotation).__qualname__ == "_UnionGenericAlias"
259+
is_bars_union = (
260+
(sys.version_info >= (3, 14) and full_name == "typing.Union")
261+
or full_name == "types.UnionType"
262+
or (always_use_bars_union and type(annotation).__qualname__ == "_UnionGenericAlias")
251263
)
252264
if is_bars_union:
253265
full_name = ""
@@ -588,7 +600,7 @@ def _one_child(module: Module) -> stmt | None:
588600
return {}
589601

590602
try:
591-
type_comment = obj_ast.type_comment
603+
type_comment = obj_ast.type_comment # type: ignore[attr-defined]
592604
except AttributeError:
593605
return {}
594606

@@ -610,7 +622,7 @@ def _one_child(module: Module) -> stmt | None:
610622
if comment_returns:
611623
rv["return"] = comment_returns
612624

613-
args = load_args(obj_ast)
625+
args = load_args(obj_ast) # type: ignore[arg-type]
614626
comment_args = split_type_comment_args(comment_args_str)
615627
is_inline = len(comment_args) == 1 and comment_args[0] == "..."
616628
if not is_inline:

tests/test_integration.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ class mod.Class(x, y, z=None)
9292
9393
* **y** ("int") -- bar
9494
95-
* **z** ("Optional"["str"]) -- baz
95+
* **z** ("str" | "None") -- baz
9696
9797
class InnerClass
9898
@@ -117,7 +117,7 @@ class InnerClass
117117
118118
* **y** ("int") -- bar
119119
120-
* **z** ("Optional"["str"]) -- baz
120+
* **z** ("str" | "None") -- baz
121121
122122
Return type:
123123
"str"
@@ -131,7 +131,7 @@ class InnerClass
131131
132132
* **y** ("int") -- bar
133133
134-
* **z** ("Optional"["str"]) -- baz
134+
* **z** ("str" | "None") -- baz
135135
136136
Return type:
137137
"str"
@@ -149,7 +149,7 @@ class InnerClass
149149
150150
* **y** ("int") -- bar
151151
152-
* **z** ("Optional"["str"]) -- baz
152+
* **z** ("str" | "None") -- baz
153153
154154
Return type:
155155
"str"
@@ -284,7 +284,7 @@ def __init__(self, message: str) -> None:
284284
285285
* **y** ("int") -- bar
286286
287-
* **z_** ("Optional"["str"]) -- baz
287+
* **z_** ("str" | "None") -- baz
288288
289289
Returns:
290290
something
@@ -471,7 +471,7 @@ def method_without_typehint(self, x): # noqa: ANN001, ANN201, ARG002, PLR6301
471471
Function docstring.
472472
473473
Parameters:
474-
* **x** ("Union"["str", "bytes", "None"]) -- foo
474+
* **x** ("str" | "bytes" | "None") -- foo
475475
476476
* **y** ("str") -- bar
477477
@@ -502,7 +502,7 @@ class mod.ClassWithTypehintsNotInline(x=None)
502502
Class docstring.
503503
504504
Parameters:
505-
**x** ("Optional"["Callable"[["int", "bytes"], "int"]]) -- foo
505+
**x** ("Callable"[["int", "bytes"], "int"] | "None") -- foo
506506
507507
foo(x=1)
508508
@@ -519,8 +519,7 @@ class mod.ClassWithTypehintsNotInline(x=None)
519519
Method docstring.
520520
521521
Parameters:
522-
**x** ("Optional"["Callable"[["int", "bytes"], "int"]]) --
523-
foo
522+
**x** ("Callable"[["int", "bytes"], "int"] | "None") -- foo
524523
525524
Return type:
526525
"ClassWithTypehintsNotInline"
@@ -666,9 +665,9 @@ def func_with_overload(a: str, b: str) -> None: ...
666665
they must both have the same type.
667666
668667
Parameters:
669-
* **a** ("Union"["int", "str"]) -- The first thing
668+
* **a** ("int" | "str") -- The first thing
670669
671-
* **b** ("Union"["int", "str"]) -- The second thing
670+
* **b** ("int" | "str") -- The second thing
672671
673672
Return type:
674673
"None"
@@ -747,7 +746,7 @@ class mod.TestClassAttributeDocs
747746
748747
A class
749748
750-
code: "Optional"["CodeType"]
749+
code: "CodeType" | "None"
751750
752751
An attribute
753752
""",
@@ -1547,6 +1546,7 @@ def test_integration(
15471546
(Path(app.srcdir) / "index.rst").write_text(template.format(val.__name__))
15481547
app.config.__dict__.update(configs[conf_run])
15491548
app.config.__dict__.update(val.OPTIONS)
1549+
app.config.always_use_bars_union = True
15501550
monkeypatch.setitem(sys.modules, "mod", sys.modules[__name__])
15511551
app.build()
15521552
assert "build succeeded" in status.getvalue() # Build succeeded

tests/test_integration_autodoc_type_aliases.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,13 +97,13 @@ def g(s: AliasedClass) -> AliasedClass:
9797

9898

9999
@expected(
100-
"""\
100+
f"""\
101101
mod.function(x, y)
102102
103103
Function docstring.
104104
105105
Parameters:
106-
* **x** ("Optional"[Array]) -- foo
106+
* **x** ({'Array | "None"' if sys.version_info >= (3, 14) else '"Optional"[Array]'}) -- foo
107107
108108
* **y** ("Schema") -- boo
109109

tests/test_sphinx_autodoc_typehints.py

Lines changed: 37 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ def test_parse_annotation(annotation: Any, module: str, class_name: str, args: t
181181
pytest.param(Type[A], rf":py:class:`~typing.Type`\ \[:py:class:`~{__name__}.A`]", id="typing-A"),
182182
pytest.param(Any, ":py:data:`~typing.Any`", id="Any"),
183183
pytest.param(AnyStr, ":py:data:`~typing.AnyStr`", id="AnyStr"),
184-
pytest.param(Generic[T], r":py:class:`~typing.Generic`\ \[:py:class:`~typing.TypeVar`\ \(``T``)]", id="Generic"),
184+
pytest.param(Generic[T], r":py:class:`~typing.Generic`\ \[:py:class:`~typing.TypeVar`\ \(``T``)]", id="Generic"), # type: ignore[index]
185185
pytest.param(Mapping, ":py:class:`~collections.abc.Mapping`", id="Mapping"),
186186
pytest.param(
187187
Mapping[T, int], # type: ignore[valid-type]
@@ -244,37 +244,63 @@ def test_parse_annotation(annotation: Any, module: str, class_name: str, args: t
244244
r":py:data:`~typing.Tuple`\ \[:py:class:`str`, :py:data:`...<Ellipsis>`]",
245245
id="Tuple-str-Ellipsis",
246246
),
247-
pytest.param(Union, ":py:data:`~typing.Union`", id="Union"),
247+
pytest.param(Union, "" if sys.version_info >= (3, 14) else ":py:data:`~typing.Union`", id="Union"),
248248
pytest.param(
249249
Union[str, bool],
250-
r":py:data:`~typing.Union`\ \[:py:class:`str`, :py:class:`bool`]",
250+
":py:class:`str` | :py:class:`bool`"
251+
if sys.version_info >= (3, 14)
252+
else r":py:data:`~typing.Union`\ \[:py:class:`str`, :py:class:`bool`]",
251253
id="Union-str-bool",
252254
),
253255
pytest.param(
254256
Union[str, bool, None],
255-
r":py:data:`~typing.Union`\ \[:py:class:`str`, :py:class:`bool`, :py:obj:`None`]",
257+
":py:class:`str` | :py:class:`bool` | :py:obj:`None`"
258+
if sys.version_info >= (3, 14)
259+
else r":py:data:`~typing.Union`\ \[:py:class:`str`, :py:class:`bool`, :py:obj:`None`]",
256260
id="Union-str-bool-None",
257261
),
258262
pytest.param(
259263
Union[str, Any],
260-
r":py:data:`~typing.Union`\ \[:py:class:`str`, :py:data:`~typing.Any`]",
264+
":py:class:`str` | :py:data:`~typing.Any`"
265+
if sys.version_info >= (3, 14)
266+
else r":py:data:`~typing.Union`\ \[:py:class:`str`, :py:data:`~typing.Any`]",
261267
id="Union-str-Any",
262268
),
263269
pytest.param(
264270
Optional[str],
265-
r":py:data:`~typing.Optional`\ \[:py:class:`str`]",
271+
":py:class:`str` | :py:obj:`None`"
272+
if sys.version_info >= (3, 14)
273+
else r":py:data:`~typing.Optional`\ \[:py:class:`str`]",
266274
id="Optional-str",
267275
),
268276
pytest.param(
269277
Union[str, None],
270-
r":py:data:`~typing.Optional`\ \[:py:class:`str`]",
278+
":py:class:`str` | :py:obj:`None`"
279+
if sys.version_info >= (3, 14)
280+
else r":py:data:`~typing.Optional`\ \[:py:class:`str`]",
271281
id="Optional-str-None",
272282
),
273283
pytest.param(
274284
Optional[str | bool],
275-
r":py:data:`~typing.Union`\ \[:py:class:`str`, :py:class:`bool`, :py:obj:`None`]",
285+
":py:class:`str` | :py:class:`bool` | :py:obj:`None`"
286+
if sys.version_info >= (3, 14)
287+
else r":py:data:`~typing.Union`\ \[:py:class:`str`, :py:class:`bool`, :py:obj:`None`]",
276288
id="Optional-Union-str-bool",
277289
),
290+
pytest.param(
291+
RecList,
292+
":py:class:`int` | :py:class:`~typing.List`\\ \\[RecList]"
293+
if sys.version_info >= (3, 14)
294+
else r":py:data:`~typing.Union`\ \[:py:class:`int`, :py:class:`~typing.List`\ \[RecList]]",
295+
id="RecList",
296+
),
297+
pytest.param(
298+
MutualRecA,
299+
":py:class:`bool` | :py:class:`~typing.List`\\ \\[MutualRecB]"
300+
if sys.version_info >= (3, 14)
301+
else r":py:data:`~typing.Union`\ \[:py:class:`bool`, :py:class:`~typing.List`\ \[MutualRecB]]",
302+
id="MutualRecA",
303+
),
278304
pytest.param(Callable, ":py:class:`~collections.abc.Callable`", id="Callable"),
279305
pytest.param(
280306
Callable[..., int],
@@ -359,14 +385,6 @@ def test_parse_annotation(annotation: Any, module: str, class_name: str, args: t
359385
r":py:data:`~typing.Tuple`\ \[:py:class:`int`, :py:data:`...<Ellipsis>`]",
360386
id="Tuple-p-Ellipsis",
361387
),
362-
pytest.param(
363-
RecList, r":py:data:`~typing.Union`\ \[:py:class:`int`, :py:class:`~typing.List`\ \[RecList]]", id="RecList"
364-
),
365-
pytest.param(
366-
MutualRecA,
367-
r":py:data:`~typing.Union`\ \[:py:class:`bool`, :py:class:`~typing.List`\ \[MutualRecB]]",
368-
id="MutualRecA",
369-
),
370388
]
371389

372390

@@ -418,7 +436,9 @@ def test_format_annotation(inv: Inventory, annotation: Any, expected_result: str
418436
assert format_annotation(annotation, conf) == expected_result
419437

420438
# Test for the correct role (class vs data) using the official Sphinx inventory
421-
if any(modname in expected_result for modname in ("typing", "types")):
439+
if any(modname in expected_result for modname in ("typing", "types")) and not (
440+
sys.version_info >= (3, 14) and isinstance(annotation, Union)
441+
):
422442
m = re.match(r"^:py:(?P<role>class|data|func):`~(?P<name>[^`]+)`", result)
423443
assert m, "No match"
424444
name = m.group("name")

0 commit comments

Comments
 (0)