1- # TODO: Support `typing.ReadOnly`.
21# TODO: Support `extra_items=type`.
32# TODO: Support `closed=True/False`.
43
54from __future__ import annotations
65
76import ast
7+ from itertools import chain
88from typing import TYPE_CHECKING , Any , TypedDict
99
1010from griffe ._internal .docstrings .models import (
1111 DocstringParameter ,
12- DocstringSectionOtherParameters ,
1312 DocstringSectionParameters ,
1413)
1514from griffe ._internal .enumerations import DocstringSectionKind , ParameterKind
1817from griffe ._internal .models import Class , Docstring , Function , Parameter , Parameters
1918
2019if TYPE_CHECKING :
21- from collections .abc import Iterable
20+ from collections .abc import Iterable , Iterator
2221
2322
2423class _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
172188class 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