Skip to content

Commit 60cbb5f

Browse files
committed
feat: Support unpacking typed dicts in signatures and docstrings
Issue-mkdocstrings-python-207: mkdocstrings/python#207 Issue-284: #284
1 parent 824abb8 commit 60cbb5f

File tree

13 files changed

+333
-17
lines changed

13 files changed

+333
-17
lines changed

docs/extensions/built-in.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ Built-in extensions are maintained in Griffe's code base. They generally bring s
55
Extension | Description
66
--------- | -----------
77
[`dataclasses`](built-in/dataclasses.md) | Support for [`dataclasses`][].
8+
[`unpack_typeddict`](built-in/unpack-typeddict.md) | Support for [`typing.Unpack`][] and [`typing.TypedDict`][].
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# `unpack`
2+
3+
The `unpack_typeddict` extension adds support for [`Unpack`][typing.Unpack] and [`TypedDict`][typing.TypedDict] from the standard library. When enabled, it will add an `__init__` method to typed dictionaries, and expand `**kwargs: Unpack[...]` (the ellipsis being a [typed dict][typing.TypedDict] class) in function signatures to the relevant parameters, using the typed dict attributes. The extension will also update any Parameters section in the function docstring, to reflect the signature update.
4+
5+
Example:
6+
7+
```python
8+
from typing import TypedDict, Unpack
9+
10+
11+
class GreetKwargs(TypedDict):
12+
name: str
13+
"""The name of a person to greet."""
14+
shout: bool
15+
"""Whether to shout."""
16+
17+
18+
def greet(**kwargs: Unpack[GreetKwargs]) -> str:
19+
"""Greet someone.
20+
21+
Parameters:
22+
**kwargs: Greet parameters.
23+
24+
Returns:
25+
A message.
26+
"""
27+
message = f"Hello {kwargs['name']}!"
28+
if kwargs["shout"]:
29+
return message.upper() + "!!"
30+
return message
31+
```
32+
33+
With the `unpack_typeddict` extension enabled, the data loaded by Griffe will be updated as follows:
34+
35+
```python
36+
class GreetKwargs(TypedDict):
37+
# Attributes removed from Griffe data (not from runtime class).
38+
39+
# Added by the `typeddict` extension to Griffe data (not to the runtime class):
40+
def __init__(self, name: str, shout: bool) -> None:
41+
"""
42+
Parameters:
43+
name: The name of a person to greet.
44+
shout: Whether to shout.
45+
"""
46+
47+
48+
def greet(*, name: str, shout: bool) -> str:
49+
"""Greet someone.
50+
51+
Parameters:
52+
name: The name of a person to greet.
53+
shout: Whether to shout.
54+
55+
Returns:
56+
A message.
57+
"""
58+
```
59+
60+
Thanks to this `__init__` method now appearing in the typed dictionary, tools like mkdocstrings can now render a proper signature for `GreetKwargs`.
61+
62+
NOTE: Our example shows a Google-style docstring, but we actually insert a structured docstring section into the parsed data, which is style-agnostic, so it works with any docstring style.
63+
64+
To enable the extension:
65+
66+
=== "CLI"
67+
```console
68+
$ griffe dump -e unpack_typeddict my_package
69+
```
70+
71+
=== "Python"
72+
```python
73+
import griffe
74+
75+
my_package = griffe.load("my_package", extensions=griffe.load_extensions("unpack_typeddict"))
76+
```
77+
78+
=== "mkdocstrings"
79+
```yaml title="mkdocs.yml"
80+
plugins:
81+
- mkdocstrings:
82+
handlers:
83+
python:
84+
options:
85+
extensions:
86+
- unpack_typeddict
87+
```

docs/reference/api/extensions.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,7 @@
2121
::: griffe.DataclassesExtension
2222
options:
2323
inherited_members: false
24+
25+
::: griffe.UnpackTypedDictExtension
26+
options:
27+
inherited_members: false

duties.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -289,8 +289,12 @@ def check_api(ctx: Context, *cli_args: str) -> None:
289289
"griffe",
290290
search=["src"],
291291
color=True,
292-
# YORE: Bump 2: Remove line.
293-
extensions=["scripts/griffe_exts.py"],
292+
extensions=[
293+
"griffe_inherited_docstrings",
294+
# YORE: Bump 2: Remove line.
295+
"scripts/griffe_exts.py",
296+
"unpack_typeddict",
297+
],
294298
).add_args(*cli_args),
295299
title="Checking for API breaking changes",
296300
nofail=True,

mkdocs.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ nav:
6363
- Built-in:
6464
- extensions/built-in.md
6565
- dataclasses: extensions/built-in/dataclasses.md
66+
- unpack-typeddict: extensions/built-in/unpack-typeddict.md
6667
- Official:
6768
- extensions/official.md
6869
- autodocstringstyle: extensions/official/autodocstringstyle.md
@@ -232,6 +233,7 @@ plugins:
232233
- griffe_inherited_docstrings
233234
# YORE: Bump 2: Remove line.
234235
- scripts/griffe_exts.py
236+
- unpack_typeddict
235237
heading_level: 2
236238
inherited_members: true
237239
merge_init_into_class: true

src/griffe/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,7 @@
336336
load_extensions,
337337
)
338338
from griffe._internal.extensions.dataclasses import DataclassesExtension
339+
from griffe._internal.extensions.unpack_typeddict import UnpackTypedDictExtension
339340
from griffe._internal.finder import ModuleFinder, NamePartsAndPathType, NamePartsType, NamespacePackage, Package
340341
from griffe._internal.git import GitInfo, KnownGitService
341342
from griffe._internal.importer import dynamic_import, sys_path
@@ -558,6 +559,7 @@ def __getattr__(name: str) -> Any:
558559
"TypeParameters",
559560
"UnhandledEditableModuleError",
560561
"UnimportableModuleError",
562+
"UnpackTypedDictExtension",
561563
"Visitor",
562564
"assert_git_repo",
563565
"ast_children",

src/griffe/_internal/extensions/base.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,7 @@ def call(self, event: str, **kwargs: Any) -> None:
520520

521521
builtin_extensions: set[str] = {
522522
"dataclasses",
523+
"unpack_typeddict",
523524
}
524525
"""The names of built-in Griffe extensions."""
525526

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING, Any
4+
5+
from griffe._internal.docstrings.models import DocstringParameter, DocstringSectionParameters
6+
from griffe._internal.enumerations import DocstringSectionKind, ParameterKind
7+
from griffe._internal.expressions import Expr, ExprSubscript
8+
from griffe._internal.extensions.base import Extension
9+
from griffe._internal.models import Class, Docstring, Function, Parameter, Parameters
10+
11+
if TYPE_CHECKING:
12+
from collections.abc import Iterable
13+
14+
15+
def _update_docstring(func: Function, parameters: Iterable[Parameter], kwparam: Parameter | None = None) -> None:
16+
if not func.docstring:
17+
func.docstring = Docstring("", parent=func)
18+
sections = func.docstring.parsed
19+
section_gen = (section for section in sections if section.kind is DocstringSectionKind.parameters)
20+
if kwparam and (params_section := next(section_gen, None)):
21+
# Remove the `**kwargs` entry.
22+
param_gen = (i for i, arg in enumerate(params_section.value) if arg.name.lstrip("*") == kwparam.name)
23+
if (kwarg_pos := next(param_gen, None)) is not None:
24+
params_section.value.pop(kwarg_pos)
25+
else:
26+
# Create a parameters section if none exists.
27+
params_section = DocstringSectionParameters([])
28+
func.docstring.parsed.append(params_section)
29+
# Add entries for all TypedDict attributes.
30+
for param in parameters:
31+
if param.name != "self":
32+
params_section.value.append(
33+
DocstringParameter(
34+
name=param.name,
35+
description=param.docstring.value if param.docstring else "",
36+
annotation=param.annotation,
37+
value=param.default,
38+
),
39+
)
40+
41+
42+
def _params_from_attrs(attrs: Iterable[Any]) -> Parameters:
43+
return Parameters(
44+
Parameter(name="self", kind=ParameterKind.positional_or_keyword),
45+
*(
46+
Parameter(
47+
name=attr.name,
48+
annotation=attr.annotation,
49+
kind=ParameterKind.keyword_only,
50+
default=attr.value,
51+
docstring=attr.docstring,
52+
)
53+
for attr in attrs
54+
),
55+
)
56+
57+
58+
class UnpackTypedDictExtension(Extension):
59+
"""An extension to handle `Unpack[TypeDict]`."""
60+
61+
def on_class(self, *, cls: Class, **kwargs: Any) -> None: # noqa: ARG002
62+
"""Add an `__init__` method to `TypedDict` classes if missing."""
63+
for base in cls.bases:
64+
if isinstance(base, Expr) and base.canonical_path in {"typing.TypedDict", "typing_extensions.TypedDict"}:
65+
cls.labels.add("typed-dict")
66+
break
67+
else:
68+
return
69+
70+
attributes = cls.attributes.values()
71+
72+
if "__init__" not in cls.members:
73+
# Build the `__init__` method and add it to the class.
74+
parameters = _params_from_attrs(attributes)
75+
init = Function(name="__init__", parameters=parameters, returns="None")
76+
cls.set_member("__init__", init)
77+
# Update the `__init__` docstring.
78+
_update_docstring(init, parameters)
79+
80+
# Remove attributes from the class, as they are now in the `__init__` method.
81+
for attr in attributes:
82+
cls.del_member(attr.name)
83+
84+
def on_function(self, *, func: Function, **kwargs: Any) -> None: # noqa: ARG002
85+
"""Replace `**kwargs: Unpack[TypedDict]` parameters with the actual TypedDict attributes."""
86+
# Find any `**kwargs: Unpack[TypedDict]` parameter.
87+
for parameter in func.parameters:
88+
if parameter.kind is ParameterKind.var_keyword:
89+
annotation = parameter.annotation
90+
if isinstance(annotation, ExprSubscript) and annotation.canonical_path in {
91+
"typing.Annotated",
92+
"typing_extensions.Annotated",
93+
}:
94+
annotation = annotation.slice.elements[0] # type: ignore[union-attr]
95+
if isinstance(annotation, ExprSubscript) and annotation.canonical_path in {
96+
"typing.Unpack",
97+
"typing_extensions.Unpack",
98+
}:
99+
slice_path = annotation.slice.canonical_path # type: ignore[union-attr]
100+
typed_dict = func.modules_collection[slice_path]
101+
break
102+
else:
103+
return
104+
105+
if "__init__" in typed_dict.members:
106+
# The `__init__` was already generated: use its parameters.
107+
parameters = typed_dict["__init__"].parameters
108+
else:
109+
# Fallback to building parameters from attributes.
110+
parameters = _params_from_attrs(typed_dict.attributes.values())
111+
112+
# Update any parameter section in the docstring.
113+
# We do this before updating the signature so that
114+
# parsing the docstring doesn't emit warnings.
115+
_update_docstring(func, parameters, parameter)
116+
117+
# Update the function parameters.
118+
del func.parameters[parameter.name]
119+
for param in parameters:
120+
if param.name != "self":
121+
func.parameters[param.name] = Parameter(
122+
name=param.name,
123+
annotation=param.annotation,
124+
kind=ParameterKind.keyword_only,
125+
default=param.default,
126+
docstring=param.docstring,
127+
)

src/griffe/_internal/loader.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,8 @@ def load(
190190
return self._post_load(top_module, obj_path)
191191

192192
def _fire_load_events(self, obj: Object) -> None:
193-
for member in obj.members.values():
193+
# Wrapping in tuple() to avoid "dictionary changed size during iteration" errors.
194+
for member in tuple(obj.members.values()):
194195
if member.is_alias:
195196
self.extensions.call("on_alias", alias=member, loader=self)
196197
continue

tests/test_api.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,14 @@
1818

1919
@pytest.fixture(name="loader", scope="module")
2020
def _fixture_loader() -> griffe.GriffeLoader:
21-
# YORE: Bump 2: Regex-replace `extensions=.*` with `)` within line.
22-
loader = griffe.GriffeLoader(extensions=griffe.load_extensions("scripts/griffe_exts.py"))
21+
loader = griffe.GriffeLoader(
22+
extensions=griffe.load_extensions(
23+
"griffe_inherited_docstrings",
24+
# YORE: Bump 2: Remove line.
25+
"scripts/griffe_exts.py",
26+
"unpack_typeddict",
27+
),
28+
)
2329
loader.load("griffe")
2430
loader.resolve_aliases()
2531
return loader
@@ -150,7 +156,7 @@ def _public_path(obj: griffe.Object | griffe.Alias) -> bool:
150156
def test_api_matches_inventory(inventory: Inventory, public_objects: list[griffe.Object | griffe.Alias]) -> None:
151157
"""All public objects are added to the inventory."""
152158
ignore_names = {"__getattr__", "__init__", "__repr__", "__str__", "__post_init__"}
153-
ignore_paths = {"griffe.DataclassesExtension.*"}
159+
ignore_paths = {"griffe.DataclassesExtension.*", "griffe.UnpackTypedDictExtension.*"}
154160
not_in_inventory = [
155161
f"{obj.relative_filepath}:{obj.lineno}: {obj.path}"
156162
for obj in public_objects

0 commit comments

Comments
 (0)