diff --git a/.chronus/changes/python-addTypedDict-2026-3-21-17-47-3.md b/.chronus/changes/python-addTypedDict-2026-3-21-17-47-3.md index fae8e302e0b..0c93612b39d 100644 --- a/.chronus/changes/python-addTypedDict-2026-3-21-17-47-3.md +++ b/.chronus/changes/python-addTypedDict-2026-3-21-17-47-3.md @@ -5,4 +5,4 @@ packages: - "@typespec/http-client-python" --- -[python] Always generate `TypedDict` typing hints for input models in the `types.py` file, and named union aliases in the `_unions.py` file +[python] Add a `models-mode: typeddict` option that generates `TypedDict` typing hints for input models in the `types.py` file. Also generate named union aliases in the `_unions.py` file (renamed from `_types.py`). `TypedDict` generation is scoped to `models-mode: typeddict`; the default `dpg` mode output is unchanged. diff --git a/packages/http-client-python/generator/pygen/preprocess/__init__.py b/packages/http-client-python/generator/pygen/preprocess/__init__.py index 6961d890a2d..45e03b19466 100644 --- a/packages/http-client-python/generator/pygen/preprocess/__init__.py +++ b/packages/http-client-python/generator/pygen/preprocess/__init__.py @@ -373,42 +373,53 @@ def add_body_param_type( if not (self.is_tsp and has_multi_part_content_type(body_parameter)) and not is_typeddict_only: body_parameter["type"]["types"].append(KNOWN_TYPES["binary"]) - # Add typeddict overload for non-spread dpg models - if self.options["models-mode"] == "dpg" and is_dpg_model: - cross_lang_id = model_type.get("crossLanguageDefinitionId") - existing_td = self._find_existing_typeddict(code_model, cross_lang_id) - self._insert_typeddict_overload(code_model, body_parameter, model_type, origin_type, existing_td) - - # For spread bodies (json base), add a typeddict overload that references - # the original model. This replaces the JSON single-body overload. - if is_json_model: - cross_lang_id = model_type.get("crossLanguageDefinitionId") - original = None - if cross_lang_id: - original = next( - ( - t - for t in code_model["types"] - if t.get("type") == "model" - and t.get("crossLanguageDefinitionId") == cross_lang_id - and t is not model_type - ), - None, - ) - - if is_typeddict_only and original: - # In typeddict-only mode, the original dpg model already renders - # as a TypedDict — reference it directly, no copy needed. - if origin_type == "model": - body_parameter["type"]["types"].insert(1, original) + # TypedDict overloads are only generated in ``models-mode: typeddict``. + # In ``dpg`` mode we keep the historical behavior of accepting either the + # generated model or a raw JSON object, so we never emit a ``_types.X`` + # reference that has no matching definition in ``types.py``. + if is_typeddict_only: + # A plain model body needs no extra overload: the dpg model already + # renders as a TypedDict, so the single body type collapses to it. + # For spread bodies (json base), add a typeddict overload that + # references the original model, replacing the JSON single-body overload. + if is_json_model: + cross_lang_id = model_type.get("crossLanguageDefinitionId") + original = None + if cross_lang_id: + original = next( + ( + t + for t in code_model["types"] + if t.get("type") == "model" + and t.get("crossLanguageDefinitionId") == cross_lang_id + and t is not model_type + ), + None, + ) + + if original: + # The original dpg model already renders as a TypedDict — + # reference it directly, no copy needed. + if origin_type == "model": + body_parameter["type"]["types"].insert(1, original) + else: + td_list_or_dict = copy.deepcopy(body_parameter["type"]["types"][0]) + td_list_or_dict["elementType"] = original + body_parameter["type"]["types"].insert(1, td_list_or_dict) else: - td_list_or_dict = copy.deepcopy(body_parameter["type"]["types"][0]) - td_list_or_dict["elementType"] = original - body_parameter["type"]["types"].insert(1, td_list_or_dict) + existing_td = self._find_existing_typeddict(code_model, cross_lang_id) + self._insert_typeddict_overload( + code_model, body_parameter, model_type, origin_type, existing_td + ) + elif self.options["models-mode"] == "dpg" and is_dpg_model: + if origin_type == "model": + body_parameter["type"]["types"].insert(1, KNOWN_TYPES["any-object"]) else: - source = original or model_type - existing_td = self._find_existing_typeddict(code_model, cross_lang_id) - self._insert_typeddict_overload(code_model, body_parameter, source, origin_type, existing_td) + # dict or list + # copy the original dict / list type + any_obj_list_or_dict = copy.deepcopy(body_parameter["type"]["types"][0]) + any_obj_list_or_dict["elementType"] = KNOWN_TYPES["any-object"] + body_parameter["type"]["types"].insert(1, any_obj_list_or_dict) if len(body_parameter["type"]["types"]) == 1: # Only one body variant remains (e.g. typeddict-only mode where the @@ -420,7 +431,6 @@ def add_body_param_type( code_model["types"].append(body_parameter["type"]) - def pad_reserved_words(self, name: str, pad_type: PadType, yaml_type: dict[str, Any]) -> str: # we want to pad hidden variables as well if not name: diff --git a/packages/http-client-python/tests/unit/test_typeddict_overloads.py b/packages/http-client-python/tests/unit/test_typeddict_overloads.py index e98a0af65ec..6bce000957e 100644 --- a/packages/http-client-python/tests/unit/test_typeddict_overloads.py +++ b/packages/http-client-python/tests/unit/test_typeddict_overloads.py @@ -88,7 +88,7 @@ def test_typeddict_only_single_body_emits_no_overload(): def test_dpg_mode_still_emits_multiple_overloads(): - """Regression guard: dpg mode keeps its binary + typeddict overloads.""" + """Regression guard: dpg mode keeps its binary + JSON overloads.""" plugin = _plugin("dpg") code_model, yaml_data, _ = _json_model_operation() body_parameter = yaml_data["bodyParameter"] @@ -96,7 +96,21 @@ def test_dpg_mode_still_emits_multiple_overloads(): plugin.add_body_param_type(code_model, body_parameter) add_overloads_for_body_param(yaml_data) - # dpg mode adds at least the binary overload alongside the model, so the + # dpg mode adds the binary + raw JSON overloads alongside the model, so the # combined type has multiple members and overloads are generated. assert body_parameter["type"]["type"] == "combined" assert len(yaml_data["overloads"]) >= 2 + + +def test_dpg_mode_emits_no_typeddict_reference(): + """dpg mode must not insert a ``typeddict`` body type (would dangle in types.py).""" + plugin = _plugin("dpg") + code_model, yaml_data, _ = _json_model_operation() + body_parameter = yaml_data["bodyParameter"] + + plugin.add_body_param_type(code_model, body_parameter) + + member_bases = [t.get("base") for t in body_parameter["type"]["types"]] + assert "typeddict" not in member_bases + # no stray typeddict copy leaked into the shared type list either + assert all(t.get("base") != "typeddict" for t in code_model["types"])