1616
1717
1818def partial_model (model : type [BaseModel ]) -> type [BaseModel ]:
19+ """Class decorator that makes all fields optional with a default of None.
20+
21+ Used on update schemas so PATCH endpoints accept partial payloads — only the
22+ fields present in the request body are updated, others remain unchanged.
23+
24+ Args:
25+ model: A Pydantic BaseModel class whose fields should all become optional.
26+
27+ Returns:
28+ A new model class named ``Partial<OriginalName>`` where every field is
29+ ``Optional[original_type]`` with ``default=None``.
30+
31+ Example::
32+
33+ @partial_model
34+ class ItemUpdate(ItemBase):
35+ pass
36+ # ItemUpdate(name="new name") # only name; description is None
37+ """
38+
1939 def make_field_optional (
2040 field : FieldInfo , default : Any = None
2141 ) -> tuple [Any , FieldInfo ]:
@@ -161,10 +181,34 @@ def _extract_nested_basemodels(annotation: Any) -> list[type[BaseModel]]:
161181 return basemodel_types
162182
163183
184+ def _unwrap_if_optional (annotation : Any ) -> Any :
185+ """Return the inner type if annotation is Optional[T], otherwise return annotation unchanged."""
186+ if get_origin (annotation ) is Union and type (None ) in get_args (annotation ):
187+ non_none = [a for a in get_args (annotation ) if a is not type (None )]
188+ if len (non_none ) == 1 :
189+ return non_none [0 ]
190+ return annotation
191+
192+
164193def _transform_annotation_to_partial (
165194 annotation : Any , cache : dict [type [BaseModel ], type [BaseModel ]]
166195) -> Any :
167- """Transform type annotation to use partial BaseModel versions."""
196+ """Recursively rewrite a type annotation so embedded BaseModels become their partial versions.
197+
198+ Strategy:
199+ - **Direct BaseModel**: replace with ``Optional[cache[model]]`` if the model has been
200+ processed (is in cache). The ``Optional`` wrapper ensures the field can be omitted.
201+ - **Union[T, None] (Optional[T])**: process the inner type; re-wrap the result as Union
202+ without double-nesting ``None``.
203+ - **List[T]**: transform the element type; return ``Optional[list[T']]``.
204+ - **Other generic types** (e.g. ``Dict[K, V]``): transform each argument and attempt to
205+ reconstruct the generic; fall back to ``Optional[annotation]`` on failure.
206+ - **Scalar types / forward references**: return ``Optional[annotation]`` unchanged.
207+
208+ The caller (``recursive_partial_model``) always wraps the final annotation in
209+ ``Optional``; this function handles intermediate nesting so Union/list containers
210+ do not end up with double ``None`` entries.
211+ """
168212 # Handle string annotations (forward references)
169213 if isinstance (annotation , str ):
170214 return Optional [annotation ]
@@ -196,19 +240,7 @@ def _transform_annotation_to_partial(
196240 new_args .append (cache [arg ])
197241 else :
198242 transformed = _transform_annotation_to_partial (arg , cache )
199- # Extract from Optional if we wrapped it
200- if get_origin (transformed ) is Union and type (None ) in get_args (
201- transformed
202- ):
203- non_none_args = [
204- a for a in get_args (transformed ) if a is not type (None )
205- ]
206- if len (non_none_args ) == 1 :
207- new_args .append (non_none_args [0 ])
208- else :
209- new_args .append (transformed )
210- else :
211- new_args .append (transformed )
243+ new_args .append (_unwrap_if_optional (transformed ))
212244 return (
213245 Optional [tuple (new_args )]
214246 if len (new_args ) > 1
@@ -219,36 +251,15 @@ def _transform_annotation_to_partial(
219251 elif origin is list :
220252 # Transform BaseModels in List types
221253 if args :
222- transformed_arg = _transform_annotation_to_partial (args [0 ], cache )
223- # Extract from Optional if we wrapped it
224- if get_origin (transformed_arg ) is Union and type (None ) in get_args (
225- transformed_arg
226- ):
227- non_none_args = [
228- a for a in get_args (transformed_arg ) if a is not type (None )
229- ]
230- if len (non_none_args ) == 1 :
231- return Optional [list [non_none_args [0 ]]] # type: ignore[valid-type]
232- return Optional [list [transformed_arg ]]
254+ transformed_arg = _unwrap_if_optional (_transform_annotation_to_partial (args [0 ], cache ))
255+ return Optional [list [transformed_arg ]] # type: ignore[valid-type]
233256 return Optional [annotation ]
234257 elif hasattr (annotation , "__origin__" ) and args :
235258 # Handle other generic types
236- new_args = []
237- for arg in args :
238- transformed_arg = _transform_annotation_to_partial (arg , cache )
239- # Extract from Optional if we wrapped it
240- if get_origin (transformed_arg ) is Union and type (None ) in get_args (
241- transformed_arg
242- ):
243- non_none_args = [
244- a for a in get_args (transformed_arg ) if a is not type (None )
245- ]
246- if len (non_none_args ) == 1 :
247- new_args .append (non_none_args [0 ])
248- else :
249- new_args .append (transformed_arg )
250- else :
251- new_args .append (transformed_arg )
259+ new_args = [
260+ _unwrap_if_optional (_transform_annotation_to_partial (arg , cache ))
261+ for arg in args
262+ ]
252263 # Reconstruct the generic type
253264 try :
254265 return Optional [origin [tuple (new_args )]]
0 commit comments