Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .chronus/changes/python-addTypedDict-2026-3-21-17-47-3.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
80 changes: 45 additions & 35 deletions packages/http-client-python/generator/pygen/preprocess/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
18 changes: 16 additions & 2 deletions packages/http-client-python/tests/unit/test_typeddict_overloads.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,15 +88,29 @@ 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"]

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"])
Loading