Skip to content

Commit c7c4d9a

Browse files
committed
wip
1 parent 61d65c4 commit c7c4d9a

2 files changed

Lines changed: 213 additions & 123 deletions

File tree

Lines changed: 116 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
1-
# TODO: Support `typing.ReadOnly`.
21
# TODO: Support `extra_items=type`.
32
# TODO: Support `closed=True/False`.
43

54
from __future__ import annotations
65

76
import ast
7+
from itertools import chain
88
from typing import TYPE_CHECKING, Any, TypedDict
99

1010
from griffe._internal.docstrings.models import (
1111
DocstringParameter,
12-
DocstringSectionOtherParameters,
1312
DocstringSectionParameters,
1413
)
1514
from griffe._internal.enumerations import DocstringSectionKind, ParameterKind
@@ -18,21 +17,56 @@
1817
from griffe._internal.models import Class, Docstring, Function, Parameter, Parameters
1918

2019
if TYPE_CHECKING:
21-
from collections.abc import Iterable
20+
from collections.abc import Iterable, Iterator
2221

2322

2423
class _TypedDictAttr(TypedDict):
2524
name: str
2625
annotation: str | Expr | None
2726
docstring: Docstring | None
28-
required: bool
2927

3028

31-
def _get_or_set_attrs(cls: Class) -> list[_TypedDictAttr]:
29+
def _unwrap_annotation(annotation: str | Expr | None, *, default_required: bool) -> tuple[str | Expr | None, bool]:
30+
required = default_required
31+
32+
# Annotations can be written ReadOnly[Required[T]] or Required[ReadOnly[T]],
33+
# so we unwrap a first time here and a second time at the end.
34+
if isinstance(annotation, ExprSubscript) and annotation.canonical_path in {
35+
"typing.ReadOnly",
36+
"typing_extensions.ReadOnly",
37+
}:
38+
annotation = annotation.slice # type: ignore[union-attr]
39+
40+
# Unwrap `Required` and `NotRequired`, set `required` accordingly.
41+
if isinstance(annotation, ExprSubscript):
42+
if annotation.canonical_path in {
43+
"typing.Required",
44+
"typing_extensions.Required",
45+
}:
46+
annotation = annotation.slice # type: ignore[union-attr]
47+
required = True
48+
elif annotation.canonical_path in {
49+
"typing.NotRequired",
50+
"typing_extensions.NotRequired",
51+
}:
52+
annotation = annotation.slice # type: ignore[union-attr]
53+
required = False
54+
55+
# Unwrap `ReadOnly` a second time here.
56+
if isinstance(annotation, ExprSubscript) and annotation.canonical_path in {
57+
"typing.ReadOnly",
58+
"typing_extensions.ReadOnly",
59+
}:
60+
annotation = annotation.slice # type: ignore[union-attr]
61+
62+
return annotation, required
63+
64+
65+
def _get_or_set_attrs(cls: Class) -> tuple[list[_TypedDictAttr], list[_TypedDictAttr]]:
3266
if (attrs := cls.extra.get("unpack_typeddict", {}).get("_attributes")) is not None:
3367
return attrs
3468

35-
attrs = []
69+
# Inspect `total` keyword argument to determine default requiredness.
3670
default_required = True
3771
for arg, value in cls.keywords.items():
3872
if arg == "total":
@@ -45,61 +79,54 @@ def _get_or_set_attrs(cls: Class) -> list[_TypedDictAttr]:
4579
elif total is False:
4680
default_required = False
4781
break
82+
83+
# Extract attributes.
84+
required_attrs = []
85+
optional_attrs = []
4886
for attr in cls.attributes.values():
49-
annotation = attr.annotation
50-
required = default_required
51-
if isinstance(annotation, ExprSubscript):
52-
if annotation.canonical_path in {
53-
"typing.Required",
54-
"typing_extensions.Required",
55-
}:
56-
annotation = annotation.slice.elements[0] # type: ignore[union-attr]
57-
required = True
58-
elif annotation.canonical_path in {
59-
"typing.NotRequired",
60-
"typing_extensions.NotRequired",
61-
}:
62-
annotation = annotation.slice.elements[0] # type: ignore[union-attr]
63-
required = False
64-
attrs.append(
65-
_TypedDictAttr(
66-
name=attr.name,
67-
annotation=annotation,
68-
docstring=attr.docstring,
69-
required=required,
70-
),
71-
)
87+
annotation, required = _unwrap_annotation(attr.annotation, default_required=default_required)
88+
if required:
89+
required_attrs.append(
90+
_TypedDictAttr(
91+
name=attr.name,
92+
annotation=annotation,
93+
docstring=attr.docstring,
94+
),
95+
)
96+
else:
97+
optional_attrs.append(
98+
_TypedDictAttr(
99+
name=attr.name,
100+
annotation=annotation,
101+
docstring=attr.docstring,
102+
),
103+
)
72104

73-
cls.extra["unpack_typeddict"]["_attributes"] = attrs
74-
return attrs
105+
cls.extra["unpack_typeddict"]["_attributes"] = (required_attrs, optional_attrs)
106+
return (required_attrs, optional_attrs)
75107

76108

77-
def _update_docstring(func: Function, attributes: Iterable[_TypedDictAttr], kwparam: Parameter | None = None) -> None:
109+
def _update_docstring(
110+
func: Function,
111+
required: Iterable[_TypedDictAttr],
112+
optional: Iterable[_TypedDictAttr],
113+
kwparam: Parameter | None = None,
114+
) -> None:
78115
if not func.docstring:
79116
func.docstring = Docstring("", parent=func)
80117

81-
required = []
82-
optional = []
83-
for attribute in attributes:
84-
if attribute["required"]:
85-
required.append(attribute)
86-
else:
87-
optional.append(attribute)
88-
89118
params_section = None
90-
other_params_section = None
91119
sections = func.docstring.parsed
92120

93121
# Find existing "Parameters" section.
94122
section_gen = (section for section in sections if section.kind is DocstringSectionKind.parameters)
95123
params_section = next(section_gen, None)
96124

97125
# Pop original variadic keyword parameter from section.
98-
varkw_param = None
99126
if kwparam and params_section is not None:
100127
param_gen = (i for i, arg in enumerate(params_section.value) if arg.name.lstrip("*") == kwparam.name)
101128
if (kwarg_pos := next(param_gen, None)) is not None:
102-
varkw_param = params_section.value.pop(kwarg_pos)
129+
params_section.value.pop(kwarg_pos)
103130

104131
# If we have required parameters, add them to the "Parameters" section.
105132
if required:
@@ -118,55 +145,44 @@ def _update_docstring(func: Function, attributes: Iterable[_TypedDictAttr], kwpa
118145
),
119146
)
120147

121-
# Add back the original variadic keyword parameter if it was present,
122-
# and if some parameters are optional.
123-
if optional and varkw_param is not None:
124-
params_section.value.append(
125-
DocstringParameter(
126-
name=varkw_param.name,
127-
description=varkw_param.description,
128-
annotation=varkw_param.annotation,
129-
),
130-
)
131-
132-
# If we have optional parameters, add them to the "Other parameters" section.
148+
# If we have optional parameters, add them to the "Parameters" section too,
149+
# with a default value of `...`.
133150
if optional:
134-
# Create an "Other parameters" section if none exists.
135-
section_gen = (section for section in sections if section.kind is DocstringSectionKind.other_parameters)
136-
if (other_params_section := next(section_gen, None)) is None:
137-
other_params_section = DocstringSectionOtherParameters([])
138-
func.docstring.parsed.append(other_params_section)
151+
# Create a "Parameters" section if none exists.
152+
if params_section is None:
153+
params_section = DocstringSectionParameters([])
154+
func.docstring.parsed.append(params_section)
139155

140156
# Add optional parameters to the section.
141157
for attr in optional:
142-
other_params_section.value.append(
158+
params_section.value.append(
143159
DocstringParameter(
144160
name=attr["name"],
145161
description=attr["docstring"].value if attr["docstring"] else "",
146162
annotation=attr["annotation"],
163+
value="...",
147164
),
148165
)
149166

167+
# TODO: Add `**kwargs` parameter if extra items are allowed.
150168

151-
def _params_from_attrs(attrs: Iterable[_TypedDictAttr]) -> Parameters:
152-
parameters = Parameters(Parameter(name="self", kind=ParameterKind.positional_or_keyword))
153-
found_optional = False
154-
for attr in attrs:
155-
if attr["required"]:
156-
parameters.add(
157-
Parameter(
158-
name=attr["name"],
159-
annotation=attr["annotation"],
160-
kind=ParameterKind.keyword_only,
161-
docstring=attr["docstring"],
162-
),
163-
)
164-
else:
165-
found_optional = True
166-
if found_optional:
167-
name = "_kwargs" if "kwargs" in parameters else "kwargs"
168-
parameters.add(Parameter(name=name, kind=ParameterKind.var_keyword))
169-
return parameters
169+
170+
def _params_from_attrs(required: Iterable[_TypedDictAttr], optional: Iterable[_TypedDictAttr]) -> Iterator[Parameter]:
171+
for attr in required:
172+
yield Parameter(
173+
name=attr["name"],
174+
annotation=attr["annotation"],
175+
kind=ParameterKind.keyword_only,
176+
docstring=attr["docstring"],
177+
)
178+
for attr in optional:
179+
yield Parameter(
180+
name=attr["name"],
181+
annotation=attr["annotation"],
182+
kind=ParameterKind.keyword_only,
183+
default="...",
184+
docstring=attr["docstring"],
185+
)
170186

171187

172188
class UnpackTypedDictExtension(Extension):
@@ -181,18 +197,22 @@ def on_class(self, *, cls: Class, **kwargs: Any) -> None: # noqa: ARG002
181197
else:
182198
return
183199

184-
attributes = _get_or_set_attrs(cls)
200+
required, optional = _get_or_set_attrs(cls)
185201

186202
if "__init__" not in cls.members:
187203
# Build the `__init__` method and add it to the class.
188-
parameters = _params_from_attrs(attributes)
204+
parameters = Parameters(
205+
Parameter(name="self", kind=ParameterKind.positional_or_keyword),
206+
*_params_from_attrs(required, optional),
207+
)
208+
# TODO: Add `**kwargs` parameter if extra items are allowed.
189209
init = Function(name="__init__", parameters=parameters, returns="None")
190210
cls.set_member("__init__", init)
191211
# Update the `__init__` docstring.
192-
_update_docstring(init, attributes)
212+
_update_docstring(init, required, optional)
193213

194214
# Remove attributes from the class, as they are now in the `__init__` method.
195-
for attr in attributes:
215+
for attr in chain(required, optional):
196216
cls.del_member(attr["name"])
197217

198218
def on_function(self, *, func: Function, **kwargs: Any) -> None: # noqa: ARG002
@@ -216,28 +236,22 @@ def on_function(self, *, func: Function, **kwargs: Any) -> None: # noqa: ARG002
216236
else:
217237
return
218238

219-
attributes = _get_or_set_attrs(typed_dict)
220-
221-
if "__init__" in typed_dict.members:
222-
# The `__init__` was already generated: use its parameters.
223-
parameters = typed_dict["__init__"].parameters
224-
else:
225-
# Fallback to building parameters from attributes.
226-
parameters = _params_from_attrs(attributes)
239+
required, optional = _get_or_set_attrs(typed_dict)
227240

228241
# Update any parameter section in the docstring.
229242
# We do this before updating the signature so that
230243
# parsing the docstring doesn't emit warnings.
231-
_update_docstring(func, attributes, parameter)
244+
_update_docstring(func, required, optional, parameter)
232245

233246
# Update the function parameters.
234247
del func.parameters[parameter.name]
235-
for param in parameters:
236-
if param.name != "self":
237-
func.parameters[param.name] = Parameter(
238-
name=param.name,
239-
annotation=param.annotation,
240-
kind=ParameterKind.keyword_only,
241-
default=param.default,
242-
docstring=param.docstring,
243-
)
248+
for param in _params_from_attrs(required, optional):
249+
func.parameters[param.name] = Parameter(
250+
name=param.name,
251+
annotation=param.annotation,
252+
kind=ParameterKind.keyword_only,
253+
default=param.default,
254+
docstring=param.docstring,
255+
)
256+
257+
# TODO: Add `**kwargs` parameter if extra items are allowed.

0 commit comments

Comments
 (0)