From b9e1f8017146244026b5c30eaaeeb81fe11279fc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:48:49 +0000 Subject: [PATCH 01/10] Initial plan From fb6b9cb8ecbf155d0acf0a5ae1ea37ff6cc3e9b8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:55:26 +0000 Subject: [PATCH 02/10] feat(http-client-python): add dpg typeddict opt-out Co-authored-by: l0lawrence <100643745+l0lawrence@users.noreply.github.com> --- packages/http-client-python/README.md | 6 ++++++ .../http-client-python/emitter/src/lib.ts | 7 +++++++ .../generator/pygen/__init__.py | 1 + .../generator/pygen/preprocess/__init__.py | 8 ++++++-- .../tests/unit/test_options_dict.py | 8 ++++++++ .../tests/unit/test_typeddict_overloads.py | 19 ++++++++++++++++++- .../http-client-python/reference/emitter.md | 6 ++++++ 7 files changed, 52 insertions(+), 3 deletions(-) diff --git a/packages/http-client-python/README.md b/packages/http-client-python/README.md index 980cbb66703..11bd39d5547 100644 --- a/packages/http-client-python/README.md +++ b/packages/http-client-python/README.md @@ -130,6 +130,12 @@ options: Whether to keep the existing `setup.py` when `generate-packaging-files` is `true`. If set to `false` and by default, `pyproject.toml` will be generated instead. To generate `setup.py`, use `basic-setup-py`. +### `generate-typeddict` + +**Type:** `boolean` + +Whether to generate TypedDict request-body overloads in `models-mode: dpg`. Defaults to `true`. + ### `keep-pyproject-fields` **Type:** `object` diff --git a/packages/http-client-python/emitter/src/lib.ts b/packages/http-client-python/emitter/src/lib.ts index 70d5d0d081f..55f66b5f9bc 100644 --- a/packages/http-client-python/emitter/src/lib.ts +++ b/packages/http-client-python/emitter/src/lib.ts @@ -24,6 +24,7 @@ export interface PythonEmitterOptions { "head-as-boolean"?: boolean; "use-pyodide"?: boolean; "keep-setup-py"?: boolean; + "generate-typeddict"?: boolean; "keep-pyproject-fields"?: { authors?: boolean; description?: boolean; @@ -111,6 +112,12 @@ export const PythonEmitterOptionsSchema: JSONSchemaType = description: "Whether to keep the existing `setup.py` when `generate-packaging-files` is `true`. If set to `false` and by default, `pyproject.toml` will be generated instead. To generate `setup.py`, use `basic-setup-py`.", }, + "generate-typeddict": { + type: "boolean", + nullable: true, + description: + "Whether to generate TypedDict request-body overloads in `models-mode: dpg`. Defaults to `true`.", + }, "keep-pyproject-fields": { type: "object", nullable: true, diff --git a/packages/http-client-python/generator/pygen/__init__.py b/packages/http-client-python/generator/pygen/__init__.py index ab54592e083..1871f62f4f0 100644 --- a/packages/http-client-python/generator/pygen/__init__.py +++ b/packages/http-client-python/generator/pygen/__init__.py @@ -36,6 +36,7 @@ class OptionsDict(MutableMapping): "low-level-client": False, "no-async": False, "no-namespace-folders": False, + "generate-typeddict": True, "polymorphic-examples": 5, "validate-versioning": True, "version-tolerant": True, diff --git a/packages/http-client-python/generator/pygen/preprocess/__init__.py b/packages/http-client-python/generator/pygen/preprocess/__init__.py index b5149be1b47..d839f7e8404 100644 --- a/packages/http-client-python/generator/pygen/preprocess/__init__.py +++ b/packages/http-client-python/generator/pygen/preprocess/__init__.py @@ -306,6 +306,10 @@ def models_mode(self) -> Optional[str]: def is_tsp(self) -> bool: return self.options.get("tsp_file", False) + @property + def generate_typeddict(self) -> bool: + return self.options.get("generate-typeddict", True) + @staticmethod def _find_existing_typeddict( code_model: dict[str, Any], @@ -443,7 +447,7 @@ def add_body_param_type( 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: + if self.options["models-mode"] == "dpg" and self.generate_typeddict and is_dpg_model: cross_lang_id = model_type.get("crossLanguageDefinitionId") existing_td = self._find_existing_typeddict(code_model, cross_lang_id, model_type.get("name")) self._insert_typeddict_overload(code_model, body_parameter, model_type, origin_type, existing_td) @@ -463,7 +467,7 @@ def add_body_param_type( 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: + elif self.generate_typeddict: source = original or model_type existing_td = self._find_existing_typeddict(code_model, cross_lang_id, source.get("name")) self._insert_typeddict_overload(code_model, body_parameter, source, origin_type, existing_td) diff --git a/packages/http-client-python/tests/unit/test_options_dict.py b/packages/http-client-python/tests/unit/test_options_dict.py index 5fb78421f1b..5bdff6b25a3 100644 --- a/packages/http-client-python/tests/unit/test_options_dict.py +++ b/packages/http-client-python/tests/unit/test_options_dict.py @@ -29,6 +29,14 @@ def test_constructor_and_setitem_agree(): assert via_ctor == via_setitem +def test_generate_typeddict_defaults_to_true(): + assert OptionsDict()["generate-typeddict"] is True + + +def test_generate_typeddict_can_be_disabled(): + assert OptionsDict({"generate-typeddict": False})["generate-typeddict"] is False + + def test_package_mode_validation_uses_from_typespec_from_constructor_any_order(): with pytest.raises(ValueError): OptionsDict({"from-typespec": True, "package-mode": "dataplane", "package-version": "1.0.0"}) 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 0789877a7dc..16dc30ea9e1 100644 --- a/packages/http-client-python/tests/unit/test_typeddict_overloads.py +++ b/packages/http-client-python/tests/unit/test_typeddict_overloads.py @@ -14,16 +14,18 @@ from pygen.preprocess import PreProcessPlugin, add_overloads_for_body_param -def _plugin(models_mode: str) -> PreProcessPlugin: +def _plugin(models_mode: str, **kwargs) -> PreProcessPlugin: return PreProcessPlugin( output_folder="", **{ "version-tolerant": True, "models-mode": models_mode, + "generate-typeddict": True, "tsp_file": True, "show-operations": True, "show-send-request": True, "builders-visibility": "public", + **kwargs, }, ) @@ -102,6 +104,21 @@ def test_dpg_mode_still_emits_multiple_overloads(): assert len(yaml_data["overloads"]) >= 2 +def test_dpg_mode_can_disable_typeddict_autogeneration(): + """Opting out keeps the dpg model + binary overloads, but skips typeddict generation.""" + plugin = _plugin("dpg", **{"generate-typeddict": False}) + code_model, yaml_data, model_type = _json_model_operation() + body_parameter = yaml_data["bodyParameter"] + + plugin.add_body_param_type(code_model, body_parameter) + add_overloads_for_body_param(yaml_data) + + assert body_parameter["type"]["type"] == "combined" + assert body_parameter["type"]["types"] == [model_type, {"type": "binary"}] + assert len(yaml_data["overloads"]) == 2 + assert not any(t for t in code_model["types"] if t.get("base") == "typeddict") + + def _dpg_body_parameter(name: str, cross_lang_id: str) -> dict: """A JSON dpg-model body parameter for the given model name and cross-language id.""" model_type = { diff --git a/website/src/content/docs/docs/emitters/clients/http-client-python/reference/emitter.md b/website/src/content/docs/docs/emitters/clients/http-client-python/reference/emitter.md index d39beaf8112..14b714ced4c 100644 --- a/website/src/content/docs/docs/emitters/clients/http-client-python/reference/emitter.md +++ b/website/src/content/docs/docs/emitters/clients/http-client-python/reference/emitter.md @@ -124,6 +124,12 @@ options: Whether to keep the existing `setup.py` when `generate-packaging-files` is `true`. If set to `false` and by default, `pyproject.toml` will be generated instead. To generate `setup.py`, use `basic-setup-py`. +### `generate-typeddict` + +**Type:** `boolean` + +Whether to generate TypedDict request-body overloads in `models-mode: dpg`. Defaults to `true`. + ### `keep-pyproject-fields` **Type:** `object` From 3f8f51938eb63f5235eb52be8b94066a3b9b0b08 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Jul 2026 19:26:48 +0000 Subject: [PATCH 03/10] test(http-client-python): cover json overload when typeddict is disabled Co-authored-by: l0lawrence <100643745+l0lawrence@users.noreply.github.com> --- .../generator/pygen/preprocess/__init__.py | 3 ++- .../tests/unit/test_typeddict_overloads.py | 26 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/http-client-python/generator/pygen/preprocess/__init__.py b/packages/http-client-python/generator/pygen/preprocess/__init__.py index d839f7e8404..975f7a04232 100644 --- a/packages/http-client-python/generator/pygen/preprocess/__init__.py +++ b/packages/http-client-python/generator/pygen/preprocess/__init__.py @@ -94,7 +94,8 @@ def add_overloads_for_body_param(yaml_data: dict[str, Any]) -> None: continue if body_type.get("type") == "model" and body_type.get("base") == "json": yaml_data["overloads"].append(add_overload(yaml_data, body_type, for_flatten_params=True)) - # Skip single-body JSON overload; the TypedDict overload replaces it + # Use the flattened JSON overload and skip the single-body JSON overload. + # When TypedDict generation is disabled, this JSON overload remains. continue yaml_data["overloads"].append(add_overload(yaml_data, body_type)) content_type_param = next(p for p in yaml_data["parameters"] if p["wireName"].lower() == "content-type") 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 16dc30ea9e1..30c24ba6a3a 100644 --- a/packages/http-client-python/tests/unit/test_typeddict_overloads.py +++ b/packages/http-client-python/tests/unit/test_typeddict_overloads.py @@ -119,6 +119,32 @@ def test_dpg_mode_can_disable_typeddict_autogeneration(): assert not any(t for t in code_model["types"] if t.get("base") == "typeddict") +def test_spread_body_opt_out_keeps_json_overload(): + """Spread bodies keep the flattened JSON overload when TypedDict generation is disabled.""" + plugin = _plugin("dpg", **{"generate-typeddict": False}) + spread_body = _json_spread_body_parameter("CreateRequest", "Contoso.Widget") + yaml_data = { + "name": "create", + "bodyParameter": spread_body, + "parameters": [_content_type_param()], + "overloads": [], + "responses": [], + "exceptions": [], + } + code_model = {"types": [spread_body["type"]]} + + plugin.add_body_param_type(code_model, spread_body) + add_overloads_for_body_param(yaml_data) + + assert spread_body["type"]["type"] == "combined" + assert spread_body["type"]["types"][0]["base"] == "json" + assert spread_body["type"]["types"][1] == {"type": "binary"} + assert len(yaml_data["overloads"]) == 2 + json_overload = next(o for o in yaml_data["overloads"] if o["bodyParameter"]["type"].get("base") == "json") + assert json_overload["bodyParameter"]["flattened"] is True + assert not any(t for t in code_model["types"] if t.get("base") == "typeddict") + + def _dpg_body_parameter(name: str, cross_lang_id: str) -> dict: """A JSON dpg-model body parameter for the given model name and cross-language id.""" model_type = { From be122e6c173858f9dc78f2fb1426e4727d07dbf1 Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Fri, 24 Jul 2026 13:30:03 -0700 Subject: [PATCH 04/10] Add changeset for http-client-python TypedDict opt-out Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 753f3788-98a9-4e39-8655-9c6a3ce90536 --- ...ython-disable-typeddict-generation-2026-7-24-12-59-0.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .chronus/changes/python-disable-typeddict-generation-2026-7-24-12-59-0.md diff --git a/.chronus/changes/python-disable-typeddict-generation-2026-7-24-12-59-0.md b/.chronus/changes/python-disable-typeddict-generation-2026-7-24-12-59-0.md new file mode 100644 index 00000000000..cf69e8816da --- /dev/null +++ b/.chronus/changes/python-disable-typeddict-generation-2026-7-24-12-59-0.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/http-client-python" +--- + +Add a `generate-typeddict` emitter option to opt out of generating `TypedDict` request-body overloads in `models-mode: dpg`. Defaults to `true`; set it to `false` to disable the DPG `TypedDict` overloads. From 025eceb9120c67b1297e515f6b300feebd117253 Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Mon, 27 Jul 2026 10:53:11 -0700 Subject: [PATCH 05/10] Restore pre-TypedDict JSON dict overload when generate-typeddict is false When generate-typeddict: false, the opt-out now performs a true revert to pre-TypedDict behavior by restoring the standalone raw-JSON dict @overload (and JSON in the impl Union) for both the explicit/model body path and the spread path, instead of only dropping the TypedDict overload. - add_overloads_for_body_param: only skip the single-body JSON overload when a TypedDict overload was actually inserted; otherwise keep it. - add_body_param_type: insert the raw-JSON (any-object) overload via new _insert_json_overload helper when TypedDict generation is disabled. - Update test_typeddict_overloads.py opt-out tests to assert the restored [model, JSON, binary] / flattened+JSON+binary overload sets. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 753f3788-98a9-4e39-8655-9c6a3ce90536 --- .../generator/pygen/preprocess/__init__.py | 42 +++++++++++++++---- .../tests/unit/test_typeddict_overloads.py | 19 +++++---- 2 files changed, 46 insertions(+), 15 deletions(-) diff --git a/packages/http-client-python/generator/pygen/preprocess/__init__.py b/packages/http-client-python/generator/pygen/preprocess/__init__.py index 975f7a04232..3d799a0858f 100644 --- a/packages/http-client-python/generator/pygen/preprocess/__init__.py +++ b/packages/http-client-python/generator/pygen/preprocess/__init__.py @@ -94,9 +94,12 @@ def add_overloads_for_body_param(yaml_data: dict[str, Any]) -> None: continue if body_type.get("type") == "model" and body_type.get("base") == "json": yaml_data["overloads"].append(add_overload(yaml_data, body_type, for_flatten_params=True)) - # Use the flattened JSON overload and skip the single-body JSON overload. - # When TypedDict generation is disabled, this JSON overload remains. - continue + # When a TypedDict overload was inserted, it replaces the single-body JSON + # overload, so skip it. When TypedDict generation is disabled, no TypedDict + # overload exists and we keep the single-body raw-JSON overload (pre-TypedDict + # behavior) by falling through. + if any(t.get("base") == "typeddict" for t in body_parameter["type"]["types"]): + continue yaml_data["overloads"].append(add_overload(yaml_data, body_type)) content_type_param = next(p for p in yaml_data["parameters"] if p["wireName"].lower() == "content-type") content_type_param["inOverload"] = False @@ -399,6 +402,25 @@ def _insert_typeddict_overload( if not existing_td: code_model["types"].append(td_elem) + @staticmethod + def _insert_json_overload( + body_parameter: dict[str, Any], + origin_type: str, + ) -> None: + """Insert a raw-JSON (any-object) type into the body parameter's combined types. + + This restores the pre-TypedDict dict-body overload used when TypedDict + generation is disabled. + """ + if origin_type == "model": + body_parameter["type"]["types"].insert(1, KNOWN_TYPES["any-object"]) + else: + # dict or list: copy the original container type and swap its element + # type for the raw-JSON any-object. + 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) + def add_body_param_type( self, code_model: dict[str, Any], @@ -447,11 +469,15 @@ 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 self.generate_typeddict and is_dpg_model: - cross_lang_id = model_type.get("crossLanguageDefinitionId") - existing_td = self._find_existing_typeddict(code_model, cross_lang_id, model_type.get("name")) - self._insert_typeddict_overload(code_model, body_parameter, model_type, origin_type, existing_td) + # Add the dict-body overload for non-spread dpg models: a TypedDict when + # enabled, otherwise the raw-JSON overload (pre-TypedDict behavior). + if self.options["models-mode"] == "dpg" and is_dpg_model: + if self.generate_typeddict: + cross_lang_id = model_type.get("crossLanguageDefinitionId") + existing_td = self._find_existing_typeddict(code_model, cross_lang_id, model_type.get("name")) + self._insert_typeddict_overload(code_model, body_parameter, model_type, origin_type, existing_td) + else: + self._insert_json_overload(body_parameter, origin_type) # For spread bodies (json base), add a typeddict overload that references # the original model. This replaces the JSON single-body overload. 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 30c24ba6a3a..e1fe01f1895 100644 --- a/packages/http-client-python/tests/unit/test_typeddict_overloads.py +++ b/packages/http-client-python/tests/unit/test_typeddict_overloads.py @@ -105,7 +105,7 @@ def test_dpg_mode_still_emits_multiple_overloads(): def test_dpg_mode_can_disable_typeddict_autogeneration(): - """Opting out keeps the dpg model + binary overloads, but skips typeddict generation.""" + """Opting out reverts to the pre-TypedDict overloads: model + raw-JSON + binary.""" plugin = _plugin("dpg", **{"generate-typeddict": False}) code_model, yaml_data, model_type = _json_model_operation() body_parameter = yaml_data["bodyParameter"] @@ -114,13 +114,16 @@ def test_dpg_mode_can_disable_typeddict_autogeneration(): add_overloads_for_body_param(yaml_data) assert body_parameter["type"]["type"] == "combined" - assert body_parameter["type"]["types"] == [model_type, {"type": "binary"}] - assert len(yaml_data["overloads"]) == 2 + # The dict-body overload is the raw-JSON ``any-object`` (not a TypedDict). + assert body_parameter["type"]["types"] == [model_type, {"type": "any-object"}, {"type": "binary"}] + assert len(yaml_data["overloads"]) == 3 + overload_types = [o["bodyParameter"]["type"]["type"] for o in yaml_data["overloads"]] + assert overload_types == ["model", "any-object", "binary"] assert not any(t for t in code_model["types"] if t.get("base") == "typeddict") def test_spread_body_opt_out_keeps_json_overload(): - """Spread bodies keep the flattened JSON overload when TypedDict generation is disabled.""" + """Spread bodies revert to pre-TypedDict: flattened + single-body JSON + binary.""" plugin = _plugin("dpg", **{"generate-typeddict": False}) spread_body = _json_spread_body_parameter("CreateRequest", "Contoso.Widget") yaml_data = { @@ -139,9 +142,11 @@ def test_spread_body_opt_out_keeps_json_overload(): assert spread_body["type"]["type"] == "combined" assert spread_body["type"]["types"][0]["base"] == "json" assert spread_body["type"]["types"][1] == {"type": "binary"} - assert len(yaml_data["overloads"]) == 2 - json_overload = next(o for o in yaml_data["overloads"] if o["bodyParameter"]["type"].get("base") == "json") - assert json_overload["bodyParameter"]["flattened"] is True + assert len(yaml_data["overloads"]) == 3 + json_overloads = [o for o in yaml_data["overloads"] if o["bodyParameter"]["type"].get("base") == "json"] + # Both a flattened (keyword params) overload AND a single-body raw-JSON overload. + assert any(o["bodyParameter"].get("flattened") for o in json_overloads) + assert any(not o["bodyParameter"].get("flattened") for o in json_overloads) assert not any(t for t in code_model["types"] if t.get("base") == "typeddict") From e957fc57c0b2f08a6bd7b4857b2ab2aaafcc134b Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Mon, 27 Jul 2026 12:07:27 -0700 Subject: [PATCH 06/10] Fix single-body JSON overload skip for models-mode: typeddict spread bodies The skip condition sniffed for a combined-type member with base == typeddict, but in models-mode: typeddict a spread body inserts the original base: dpg model as its overload (it renders as a TypedDict via models-mode). That made the sniff false, so the single-body JSON overload was wrongly kept, regressing the prior behavior where the TypedDict overload replaced it. Track this explicitly with a jsonOverloadReplacedByTypeddict flag set by add_body_param_type wherever a TypedDict-style overload is inserted (both the generate-typeddict dpg path via _insert_typeddict_overload and the typeddict-only spread branch). add_overloads_for_body_param now checks the flag instead of sniffing base == typeddict, so: - models-mode: dpg (generate-typeddict on) and models-mode: typeddict both skip the single-body JSON overload, and - only the generate-typeddict: false opt-out keeps it (pre-TypedDict behavior). Addresses the PR reviewer comment. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 753f3788-98a9-4e39-8655-9c6a3ce90536 --- .../generator/pygen/preprocess/__init__.py | 18 ++++-- .../tests/unit/test_typeddict_overloads.py | 64 +++++++++++++++++++ 2 files changed, 76 insertions(+), 6 deletions(-) diff --git a/packages/http-client-python/generator/pygen/preprocess/__init__.py b/packages/http-client-python/generator/pygen/preprocess/__init__.py index 3d799a0858f..3ac95c45ed0 100644 --- a/packages/http-client-python/generator/pygen/preprocess/__init__.py +++ b/packages/http-client-python/generator/pygen/preprocess/__init__.py @@ -94,11 +94,12 @@ def add_overloads_for_body_param(yaml_data: dict[str, Any]) -> None: continue if body_type.get("type") == "model" and body_type.get("base") == "json": yaml_data["overloads"].append(add_overload(yaml_data, body_type, for_flatten_params=True)) - # When a TypedDict overload was inserted, it replaces the single-body JSON - # overload, so skip it. When TypedDict generation is disabled, no TypedDict - # overload exists and we keep the single-body raw-JSON overload (pre-TypedDict - # behavior) by falling through. - if any(t.get("base") == "typeddict" for t in body_parameter["type"]["types"]): + # When a TypedDict-style overload was inserted, it replaces the single-body + # JSON overload, so skip it. add_body_param_type sets this flag for both + # models-mode: dpg (generate-typeddict on) and models-mode: typeddict. When + # TypedDict generation is disabled the flag is absent and we keep the + # single-body raw-JSON overload (pre-TypedDict behavior) by falling through. + if body_parameter["type"].get("jsonOverloadReplacedByTypeddict"): continue yaml_data["overloads"].append(add_overload(yaml_data, body_type)) content_type_param = next(p for p in yaml_data["parameters"] if p["wireName"].lower() == "content-type") @@ -389,6 +390,9 @@ def _insert_typeddict_overload( existing_td: Optional[dict[str, Any]], ) -> None: """Insert a typeddict type into the body parameter's combined types.""" + # Mark that a TypedDict-style overload now stands in for the single-body JSON + # overload, so add_overloads_for_body_param knows to skip re-adding it. + body_parameter["type"]["jsonOverloadReplacedByTypeddict"] = True if origin_type == "model": td_type = existing_td or {**source, "base": "typeddict"} body_parameter["type"]["types"].insert(1, td_type) @@ -487,7 +491,9 @@ def add_body_param_type( 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. + # as a TypedDict — reference it directly, no copy needed. It also + # replaces the single-body JSON overload. + body_parameter["type"]["jsonOverloadReplacedByTypeddict"] = True if origin_type == "model": body_parameter["type"]["types"].insert(1, original) else: 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 e1fe01f1895..adfeb7a0a5c 100644 --- a/packages/http-client-python/tests/unit/test_typeddict_overloads.py +++ b/packages/http-client-python/tests/unit/test_typeddict_overloads.py @@ -150,6 +150,70 @@ def test_spread_body_opt_out_keeps_json_overload(): assert not any(t for t in code_model["types"] if t.get("base") == "typeddict") +def test_spread_body_dpg_typeddict_on_skips_single_json_overload(): + """dpg + generate-typeddict on: the TypedDict overload replaces the single-body JSON one.""" + plugin = _plugin("dpg") + clid = "Contoso.Widget" + original = _dpg_body_parameter("Widget", clid)["type"] + spread_body = _json_spread_body_parameter("CreateRequest", clid) + yaml_data = { + "name": "create", + "bodyParameter": spread_body, + "parameters": [_content_type_param()], + "overloads": [], + "responses": [], + "exceptions": [], + } + code_model = {"types": [original, spread_body["type"]]} + + plugin.add_body_param_type(code_model, spread_body) + add_overloads_for_body_param(yaml_data) + + # Combined types: [json_model, typeddict, binary]; the single-body JSON overload is + # replaced by the TypedDict, so only the flattened JSON overload remains. + assert spread_body["type"].get("jsonOverloadReplacedByTypeddict") is True + json_overloads = [o for o in yaml_data["overloads"] if o["bodyParameter"]["type"].get("base") == "json"] + assert all(o["bodyParameter"].get("flattened") for o in json_overloads) + assert not any(not o["bodyParameter"].get("flattened") for o in json_overloads) + + +def test_spread_body_typeddict_mode_skips_single_json_overload(): + """models-mode: typeddict spread bodies must NOT keep the single-body JSON overload. + + The inserted overload is the original ``base: dpg`` model (it renders as a TypedDict + via models-mode), so a ``base == "typeddict"`` sniff would miss it. The flag set by + ``add_body_param_type`` ensures the single-body JSON overload is still skipped. + """ + plugin = _plugin("typeddict") + clid = "Contoso.Widget" + original = _dpg_body_parameter("Widget", clid)["type"] + spread_body = _json_spread_body_parameter("CreateRequest", clid) + yaml_data = { + "name": "create", + "bodyParameter": spread_body, + "parameters": [_content_type_param()], + "overloads": [], + "responses": [], + "exceptions": [], + } + code_model = {"types": [original, spread_body["type"]]} + + plugin.add_body_param_type(code_model, spread_body) + add_overloads_for_body_param(yaml_data) + + assert spread_body["type"]["type"] == "combined" + assert spread_body["type"].get("jsonOverloadReplacedByTypeddict") is True + # typeddict-only mode omits the binary overload; the inserted overload is the + # original dpg model referenced directly. + assert spread_body["type"]["types"][0]["base"] == "json" + assert spread_body["type"]["types"][1] is original + # Exactly the flattened JSON overload plus the original-model overload; the + # single-body JSON overload must be absent. + json_overloads = [o for o in yaml_data["overloads"] if o["bodyParameter"]["type"].get("base") == "json"] + assert len(json_overloads) == 1 + assert json_overloads[0]["bodyParameter"].get("flattened") is True + + def _dpg_body_parameter(name: str, cross_lang_id: str) -> dict: """A JSON dpg-model body parameter for the given model name and cross-language id.""" model_type = { From 210c4d25a10520830258204f1c8d7a262993786c Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Mon, 27 Jul 2026 14:24:46 -0700 Subject: [PATCH 07/10] Replace jsonOverloadReplacedByTypeddict flag with explicit skip_single_body_json param Refactor the single-body JSON overload skip decision from a persisted `jsonOverloadReplacedByTypeddict` YAML side-channel flag to an explicit function parameter. `add_body_param_type` now returns whether a TypedDict-style overload was inserted in place of the single-body raw-JSON overload, and the single caller passes that to `add_overloads_for_body_param` as `skip_single_body_json`. This removes the implicit cross-function coupling and gives one source-of-truth decision point. Pure refactor: generated output is byte-identical across all configs (dpg+generate-typeddict on/off, models-mode: typeddict) - verified by regenerating parameters/body-optionality both ways and diffing all files. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 753f3788-98a9-4e39-8655-9c6a3ce90536 --- .../generator/pygen/preprocess/__init__.py | 48 ++++++++++++------- .../tests/unit/test_typeddict_overloads.py | 40 +++++++++------- 2 files changed, 55 insertions(+), 33 deletions(-) diff --git a/packages/http-client-python/generator/pygen/preprocess/__init__.py b/packages/http-client-python/generator/pygen/preprocess/__init__.py index 3ac95c45ed0..56ff1f72d21 100644 --- a/packages/http-client-python/generator/pygen/preprocess/__init__.py +++ b/packages/http-client-python/generator/pygen/preprocess/__init__.py @@ -81,8 +81,17 @@ def add_overload(yaml_data: dict[str, Any], body_type: dict[str, Any], for_flatt return overload -def add_overloads_for_body_param(yaml_data: dict[str, Any]) -> None: - """If we added a body parameter type, add overloads for that type""" +def add_overloads_for_body_param(yaml_data: dict[str, Any], skip_single_body_json: bool = False) -> None: + """If we added a body parameter type, add overloads for that type. + + ``skip_single_body_json`` is the authoritative signal, computed by + ``add_body_param_type``, for whether a TypedDict-style overload was inserted + to replace the single-body raw-JSON overload on the spread (``base: json``) + path. It is True for both models-mode: dpg (generate-typeddict on) and + models-mode: typeddict, and False when TypedDict generation is disabled (in + which case the single-body raw-JSON overload is kept, matching pre-TypedDict + behavior). + """ body_parameter = yaml_data["bodyParameter"] if not ( body_parameter["type"]["type"] == "combined" @@ -94,12 +103,7 @@ def add_overloads_for_body_param(yaml_data: dict[str, Any]) -> None: continue if body_type.get("type") == "model" and body_type.get("base") == "json": yaml_data["overloads"].append(add_overload(yaml_data, body_type, for_flatten_params=True)) - # When a TypedDict-style overload was inserted, it replaces the single-body - # JSON overload, so skip it. add_body_param_type sets this flag for both - # models-mode: dpg (generate-typeddict on) and models-mode: typeddict. When - # TypedDict generation is disabled the flag is absent and we keep the - # single-body raw-JSON overload (pre-TypedDict behavior) by falling through. - if body_parameter["type"].get("jsonOverloadReplacedByTypeddict"): + if skip_single_body_json: continue yaml_data["overloads"].append(add_overload(yaml_data, body_type)) content_type_param = next(p for p in yaml_data["parameters"] if p["wireName"].lower() == "content-type") @@ -390,9 +394,6 @@ def _insert_typeddict_overload( existing_td: Optional[dict[str, Any]], ) -> None: """Insert a typeddict type into the body parameter's combined types.""" - # Mark that a TypedDict-style overload now stands in for the single-body JSON - # overload, so add_overloads_for_body_param knows to skip re-adding it. - body_parameter["type"]["jsonOverloadReplacedByTypeddict"] = True if origin_type == "model": td_type = existing_td or {**source, "base": "typeddict"} body_parameter["type"]["types"].insert(1, td_type) @@ -429,7 +430,16 @@ def add_body_param_type( self, code_model: dict[str, Any], body_parameter: dict[str, Any], - ): + ) -> bool: + """Build the combined body-parameter type and its overload variants. + + Returns whether a TypedDict-style overload was inserted in place of the + single-body raw-JSON overload on the spread (``base: json``) path. The + caller passes this to ``add_overloads_for_body_param`` as + ``skip_single_body_json``. It is False when TypedDict generation is + disabled, so the pre-TypedDict single-body raw-JSON overload is kept. + """ + skip_single_body_json = False # For a binary `bytes` body (e.g. content type application/octet-stream or a custom # binary media type), add an IO overload alongside the `bytes` one. This keeps backward # compatibility for services migrating from swagger, whose binary bodies were typed as IO. @@ -447,7 +457,7 @@ def add_body_param_type( "types": [body_parameter["type"], KNOWN_TYPES["binary"]], } code_model["types"].append(body_parameter["type"]) - return + return skip_single_body_json # only add overload for special content type if ( # pylint: disable=too-many-boolean-expressions @@ -480,6 +490,7 @@ def add_body_param_type( cross_lang_id = model_type.get("crossLanguageDefinitionId") existing_td = self._find_existing_typeddict(code_model, cross_lang_id, model_type.get("name")) self._insert_typeddict_overload(code_model, body_parameter, model_type, origin_type, existing_td) + skip_single_body_json = True else: self._insert_json_overload(body_parameter, origin_type) @@ -493,7 +504,7 @@ def add_body_param_type( # In typeddict-only mode, the original dpg model already renders # as a TypedDict — reference it directly, no copy needed. It also # replaces the single-body JSON overload. - body_parameter["type"]["jsonOverloadReplacedByTypeddict"] = True + skip_single_body_json = True if origin_type == "model": body_parameter["type"]["types"].insert(1, original) else: @@ -504,6 +515,7 @@ def add_body_param_type( source = original or model_type existing_td = self._find_existing_typeddict(code_model, cross_lang_id, source.get("name")) self._insert_typeddict_overload(code_model, body_parameter, source, origin_type, existing_td) + skip_single_body_json = True if len(body_parameter["type"]["types"]) == 1: # Only one body variant remains (e.g. typeddict-only mode where the @@ -511,10 +523,12 @@ def add_body_param_type( # wrapper back to the single type so we don't emit a lone # ``@overload`` (mypy rejects a single overload definition). body_parameter["type"] = body_parameter["type"]["types"][0] - return + return skip_single_body_json code_model["types"].append(body_parameter["type"]) + return skip_single_body_json + 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: @@ -686,8 +700,8 @@ def update_operation( response["discriminator"] = "operation" if body_parameter and not is_overload: # if we have a JSON body, we add a binary overload - self.add_body_param_type(code_model, body_parameter) - add_overloads_for_body_param(yaml_data) + skip_single_body_json = self.add_body_param_type(code_model, body_parameter) + add_overloads_for_body_param(yaml_data, skip_single_body_json=skip_single_body_json) def _update_lro_operation_helper(self, yaml_data: dict[str, Any]) -> None: for response in yaml_data.get("responses", []): 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 adfeb7a0a5c..510fe3deaaa 100644 --- a/packages/http-client-python/tests/unit/test_typeddict_overloads.py +++ b/packages/http-client-python/tests/unit/test_typeddict_overloads.py @@ -79,11 +79,14 @@ def test_typeddict_only_single_body_emits_no_overload(): code_model, yaml_data, model_type = _json_model_operation() body_parameter = yaml_data["bodyParameter"] - plugin.add_body_param_type(code_model, body_parameter) - add_overloads_for_body_param(yaml_data) + skip_single_body_json = plugin.add_body_param_type(code_model, body_parameter) + add_overloads_for_body_param(yaml_data, skip_single_body_json=skip_single_body_json) # A single overload is illegal for mypy; we expect none at all. assert len(yaml_data["overloads"]) == 0 + # No TypedDict replacement is inserted when the body collapses to a single type. + assert skip_single_body_json is False + assert len(yaml_data["overloads"]) == 0 # The body stays a plain single type rather than a one-member combined type. assert body_parameter["type"] is model_type assert body_parameter["type"]["type"] == "model" @@ -95,8 +98,8 @@ def test_dpg_mode_still_emits_multiple_overloads(): 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) + skip_single_body_json = plugin.add_body_param_type(code_model, body_parameter) + add_overloads_for_body_param(yaml_data, skip_single_body_json=skip_single_body_json) # dpg mode adds at least the binary overload alongside the model, so the # combined type has multiple members and overloads are generated. @@ -110,10 +113,12 @@ def test_dpg_mode_can_disable_typeddict_autogeneration(): code_model, yaml_data, model_type = _json_model_operation() body_parameter = yaml_data["bodyParameter"] - plugin.add_body_param_type(code_model, body_parameter) - add_overloads_for_body_param(yaml_data) + skip_single_body_json = plugin.add_body_param_type(code_model, body_parameter) + add_overloads_for_body_param(yaml_data, skip_single_body_json=skip_single_body_json) assert body_parameter["type"]["type"] == "combined" + # Opting out inserts no TypedDict replacement, so the single-body JSON overload is kept. + assert skip_single_body_json is False # The dict-body overload is the raw-JSON ``any-object`` (not a TypedDict). assert body_parameter["type"]["types"] == [model_type, {"type": "any-object"}, {"type": "binary"}] assert len(yaml_data["overloads"]) == 3 @@ -136,10 +141,12 @@ def test_spread_body_opt_out_keeps_json_overload(): } code_model = {"types": [spread_body["type"]]} - plugin.add_body_param_type(code_model, spread_body) - add_overloads_for_body_param(yaml_data) + skip_single_body_json = plugin.add_body_param_type(code_model, spread_body) + add_overloads_for_body_param(yaml_data, skip_single_body_json=skip_single_body_json) assert spread_body["type"]["type"] == "combined" + # Opting out inserts no TypedDict replacement, so the single-body JSON overload is kept. + assert skip_single_body_json is False assert spread_body["type"]["types"][0]["base"] == "json" assert spread_body["type"]["types"][1] == {"type": "binary"} assert len(yaml_data["overloads"]) == 3 @@ -166,12 +173,12 @@ def test_spread_body_dpg_typeddict_on_skips_single_json_overload(): } code_model = {"types": [original, spread_body["type"]]} - plugin.add_body_param_type(code_model, spread_body) - add_overloads_for_body_param(yaml_data) + skip_single_body_json = plugin.add_body_param_type(code_model, spread_body) + add_overloads_for_body_param(yaml_data, skip_single_body_json=skip_single_body_json) # Combined types: [json_model, typeddict, binary]; the single-body JSON overload is # replaced by the TypedDict, so only the flattened JSON overload remains. - assert spread_body["type"].get("jsonOverloadReplacedByTypeddict") is True + assert skip_single_body_json is True json_overloads = [o for o in yaml_data["overloads"] if o["bodyParameter"]["type"].get("base") == "json"] assert all(o["bodyParameter"].get("flattened") for o in json_overloads) assert not any(not o["bodyParameter"].get("flattened") for o in json_overloads) @@ -181,8 +188,9 @@ def test_spread_body_typeddict_mode_skips_single_json_overload(): """models-mode: typeddict spread bodies must NOT keep the single-body JSON overload. The inserted overload is the original ``base: dpg`` model (it renders as a TypedDict - via models-mode), so a ``base == "typeddict"`` sniff would miss it. The flag set by - ``add_body_param_type`` ensures the single-body JSON overload is still skipped. + via models-mode), so a ``base == "typeddict"`` sniff would miss it. The + ``skip_single_body_json`` signal returned by ``add_body_param_type`` ensures the + single-body JSON overload is still skipped. """ plugin = _plugin("typeddict") clid = "Contoso.Widget" @@ -198,11 +206,11 @@ def test_spread_body_typeddict_mode_skips_single_json_overload(): } code_model = {"types": [original, spread_body["type"]]} - plugin.add_body_param_type(code_model, spread_body) - add_overloads_for_body_param(yaml_data) + skip_single_body_json = plugin.add_body_param_type(code_model, spread_body) + add_overloads_for_body_param(yaml_data, skip_single_body_json=skip_single_body_json) assert spread_body["type"]["type"] == "combined" - assert spread_body["type"].get("jsonOverloadReplacedByTypeddict") is True + assert skip_single_body_json is True # typeddict-only mode omits the binary overload; the inserted overload is the # original dpg model referenced directly. assert spread_body["type"]["types"][0]["base"] == "json" From 88a5b58a5f1bc9953f21b2816419108e32f89aee Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:03:30 +0000 Subject: [PATCH 08/10] Apply remaining changes Co-authored-by: l0lawrence <100643745+l0lawrence@users.noreply.github.com> --- packages/http-client-python/package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/http-client-python/package-lock.json b/packages/http-client-python/package-lock.json index 24fce49d063..4d314b3e2b3 100644 --- a/packages/http-client-python/package-lock.json +++ b/packages/http-client-python/package-lock.json @@ -1,12 +1,12 @@ { "name": "@typespec/http-client-python", - "version": "0.34.2", + "version": "0.35.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@typespec/http-client-python", - "version": "0.34.2", + "version": "0.35.0", "hasInstallScript": true, "license": "MIT", "dependencies": { From a5402a281e962c072d77b693cd14c6a7530be49e Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Tue, 28 Jul 2026 13:47:00 -0700 Subject: [PATCH 09/10] feat(http-client-python): deprecate models-mode typeddict in favor of models-mode none Rework the DPG TypedDict opt-out so models-mode only toggles dpg/none (msrest kept for back-compat) and TypedDict output is controlled by the generate-typeddict option. models-mode: none now remaps internally to the typeddict-only path, reusing all existing machinery; models-mode: typeddict is deprecated (still accepted with a warning). Update option docs, changelog, SKILL, and regen-common dogfooding, and clarify internal preprocess comments. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 511e3e1d-5321-4281-8d91-d5be7a28d81d --- ...-typeddict-generation-2026-7-24-12-59-0.md | 2 +- .../skills/generate-from-typespec/SKILL.md | 7 ++-- packages/http-client-python/README.md | 2 +- .../http-client-python/emitter/src/lib.ts | 2 +- .../eng/scripts/ci/regenerate-common.ts | 2 +- .../generator/pygen/__init__.py | 40 +++++++++++++++++-- .../generator/pygen/preprocess/__init__.py | 10 +++-- .../tests/unit/test_options_dict.py | 33 +++++++++++++++ .../tests/unit/test_typeddict_overloads.py | 20 ++++++++++ .../http-client-python/reference/emitter.md | 2 +- 10 files changed, 106 insertions(+), 14 deletions(-) diff --git a/.chronus/changes/python-disable-typeddict-generation-2026-7-24-12-59-0.md b/.chronus/changes/python-disable-typeddict-generation-2026-7-24-12-59-0.md index cf69e8816da..9f4e654ad60 100644 --- a/.chronus/changes/python-disable-typeddict-generation-2026-7-24-12-59-0.md +++ b/.chronus/changes/python-disable-typeddict-generation-2026-7-24-12-59-0.md @@ -4,4 +4,4 @@ packages: - "@typespec/http-client-python" --- -Add a `generate-typeddict` emitter option to opt out of generating `TypedDict` request-body overloads in `models-mode: dpg`. Defaults to `true`; set it to `false` to disable the DPG `TypedDict` overloads. +Add a `generate-typeddict` emitter option (default `true`) that controls `TypedDict` generation independently of `models-mode`. `models-mode` now toggles just `dpg` and `none`; the `typeddict` value is deprecated. diff --git a/packages/http-client-python/.github/skills/generate-from-typespec/SKILL.md b/packages/http-client-python/.github/skills/generate-from-typespec/SKILL.md index 30d783e0ef5..d4f802d17da 100644 --- a/packages/http-client-python/.github/skills/generate-from-typespec/SKILL.md +++ b/packages/http-client-python/.github/skills/generate-from-typespec/SKILL.md @@ -28,7 +28,7 @@ The caller must provide: If not provided, ask the user. 4. **Additional options** (optional) — any extra `key=value` emitter options the user wants applied on top of the tspconfig options. These override tspconfig - values if there's a conflict (e.g., `models-mode=typeddict`). + values if there's a conflict (e.g., `generate-typeddict=false`). ## Workflow @@ -191,7 +191,7 @@ After successful compilation: find < output-dir > -type d | sort ``` -2. Verify the output matches expectations (e.g., TypedDict if `models-mode=typeddict`). +2. Verify the output matches expectations (e.g., TypedDict-only if `models-mode=none`). 3. If the generation overwrote files in an existing package, warn the user and offer to revert non-generated files: @@ -222,6 +222,7 @@ and `--option` flags. The `flavor` option controls branded behavior: | User request | Option to add | | -------------- | --------------------------------------------------------------- | -| TypedDict only | `--option "@typespec/http-client-python.models-mode=typeddict"` | +| TypedDict only | `--option "@typespec/http-client-python.models-mode=none"` | +| No TypedDicts | `--option "@typespec/http-client-python.generate-typeddict=false"` | | No tests | `--option "@typespec/http-client-python.generate-test=false"` | | No samples | `--option "@typespec/http-client-python.generate-sample=false"` | diff --git a/packages/http-client-python/README.md b/packages/http-client-python/README.md index 11bd39d5547..8427f585478 100644 --- a/packages/http-client-python/README.md +++ b/packages/http-client-python/README.md @@ -134,7 +134,7 @@ Whether to keep the existing `setup.py` when `generate-packaging-files` is `true **Type:** `boolean` -Whether to generate TypedDict request-body overloads in `models-mode: dpg`. Defaults to `true`. +Whether to generate `TypedDict` types for request bodies. Defaults to `true`. With `models-mode: dpg` this adds `TypedDict` request-body overloads alongside the model classes; with `models-mode: none` it generates `TypedDict`-only types. Set to `false` to opt out of `TypedDict` generation. ### `keep-pyproject-fields` diff --git a/packages/http-client-python/emitter/src/lib.ts b/packages/http-client-python/emitter/src/lib.ts index 55f66b5f9bc..eb1fedfd071 100644 --- a/packages/http-client-python/emitter/src/lib.ts +++ b/packages/http-client-python/emitter/src/lib.ts @@ -116,7 +116,7 @@ export const PythonEmitterOptionsSchema: JSONSchemaType = type: "boolean", nullable: true, description: - "Whether to generate TypedDict request-body overloads in `models-mode: dpg`. Defaults to `true`.", + "Whether to generate `TypedDict` types for request bodies. Defaults to `true`. With `models-mode: dpg` this adds `TypedDict` request-body overloads alongside the model classes; with `models-mode: none` it generates `TypedDict`-only types. Set to `false` to opt out of `TypedDict` generation.", }, "keep-pyproject-fields": { type: "object", diff --git a/packages/http-client-python/eng/scripts/ci/regenerate-common.ts b/packages/http-client-python/eng/scripts/ci/regenerate-common.ts index 630bee17c7c..bd7c6574752 100644 --- a/packages/http-client-python/eng/scripts/ci/regenerate-common.ts +++ b/packages/http-client-python/eng/scripts/ci/regenerate-common.ts @@ -321,7 +321,7 @@ export const EMITTER_OPTIONS: Record | Record None: self._data = options.copy() if options else {} for key in list(self._data): self._data[key] = self._validate_and_transform(key, self._data[key]) + self._normalize_models_mode() self._validate_combinations() def __getitem__(self, key: str) -> Any: # pylint: disable=too-many-return-statements @@ -132,6 +133,38 @@ def _get_default(self, key: str) -> Any: # pylint: disable=too-many-return-stat return self.get("flavor") == "azure" return self.DEFAULTS[key] + def _normalize_models_mode(self) -> None: + """Reconcile ``models-mode`` with ``generate-typeddict`` for TypeSpec generation. + + The user-facing ``models-mode`` values are ``dpg`` and ``none`` (``msrest`` + is kept for back-compat). TypedDict output is controlled independently by + ``generate-typeddict`` (default ``True``): + + * ``dpg`` + ``generate-typeddict`` -> DPG models and TypedDict overloads + * ``dpg`` + no ``generate-typeddict`` -> DPG models only + * ``none`` + ``generate-typeddict`` -> TypedDicts only + * ``none`` + no ``generate-typeddict`` -> nothing + + ``models-mode: typeddict`` is deprecated; it is still accepted (with a + warning) and behaves as TypedDict-only. Internally, TypedDict-only + generation is represented by ``models-mode == "typeddict"``, so the + ``none`` + TypedDicts case is remapped to it here. This only applies to + TypeSpec input; swagger ``models-mode: none`` is left untouched. + """ + if "models-mode" not in self._data: + return + models_mode = self._data["models-mode"] + if models_mode == "typeddict": + _LOGGER.warning( + "'models-mode: typeddict' is deprecated. Use 'models-mode: none' instead " + "(TypedDicts are generated by default; set 'generate-typeddict: false' to opt out)." + ) + return + # 'none' is stored as falsy False. For TypeSpec, keep generating TypedDicts + # by default by remapping to the internal typeddict-only mode. + if bool(self._data.get("tsp_file")) and models_mode is False and self.get("generate-typeddict"): + self._data["models-mode"] = "typeddict" + def _validate_combinations(self) -> None: if not self.get("show-operations") and self.get("builders-visibility") == "embedded": raise ValueError( @@ -173,9 +206,10 @@ def _validate_and_transform(self, key: str, value: Any) -> Any: if key == "models-mode" and value not in ["msrest", "dpg", "typeddict", False]: raise ValueError( - "--models-mode can only be 'msrest', 'dpg', 'typeddict', or 'none'. " - "Pass in 'msrest' if you want msrest models, 'typeddict' for TypedDict models, or " - "'none' if you don't want any." + "--models-mode can only be 'msrest', 'dpg', or 'none'. " + "Pass in 'msrest' if you want msrest models, 'dpg' for DPG models, or " + "'none' if you don't want any. TypedDicts are controlled by --generate-typeddict " + "(the deprecated 'typeddict' value is still accepted for back-compat)." ) if key == "package-mode": if ( diff --git a/packages/http-client-python/generator/pygen/preprocess/__init__.py b/packages/http-client-python/generator/pygen/preprocess/__init__.py index 56ff1f72d21..4682932be5b 100644 --- a/packages/http-client-python/generator/pygen/preprocess/__init__.py +++ b/packages/http-client-python/generator/pygen/preprocess/__init__.py @@ -87,10 +87,12 @@ def add_overloads_for_body_param(yaml_data: dict[str, Any], skip_single_body_jso ``skip_single_body_json`` is the authoritative signal, computed by ``add_body_param_type``, for whether a TypedDict-style overload was inserted to replace the single-body raw-JSON overload on the spread (``base: json``) - path. It is True for both models-mode: dpg (generate-typeddict on) and - models-mode: typeddict, and False when TypedDict generation is disabled (in + path. It is True whenever TypedDicts are generated -- i.e. for the internal + ``dpg`` mode (with ``generate-typeddict`` on) and the internal ``typeddict`` + mode (which the user-facing ``models-mode: none`` remaps to). It is False + when TypedDict generation is disabled (``generate-typeddict: false``), in which case the single-body raw-JSON overload is kept, matching pre-TypedDict - behavior). + behavior. """ body_parameter = yaml_data["bodyParameter"] if not ( @@ -473,6 +475,8 @@ def add_body_param_type( ) is_dpg_model = model_type.get("base") == "dpg" is_json_model = model_type.get("base") == "json" + # ``typeddict`` is now an internal-only models-mode: the user-facing + # ``models-mode: none`` (with generate-typeddict on) remaps to it. is_typeddict_only = self.options["models-mode"] == "typeddict" body_parameter["type"] = { diff --git a/packages/http-client-python/tests/unit/test_options_dict.py b/packages/http-client-python/tests/unit/test_options_dict.py index 5bdff6b25a3..a8be3646e01 100644 --- a/packages/http-client-python/tests/unit/test_options_dict.py +++ b/packages/http-client-python/tests/unit/test_options_dict.py @@ -37,6 +37,39 @@ def test_generate_typeddict_can_be_disabled(): assert OptionsDict({"generate-typeddict": False})["generate-typeddict"] is False +def test_models_mode_none_with_tsp_generates_typeddict_by_default(): + # For TypeSpec input, models-mode=none keeps TypedDict generation on by + # default, represented internally as the typeddict-only mode. + options = OptionsDict({"models-mode": "none", "tsp_file": "main.tsp"}) + assert options["models-mode"] == "typeddict" + + +def test_models_mode_none_with_tsp_and_generate_typeddict_false_is_nothing(): + # Opting out of TypedDicts on top of models-mode=none produces no models. + options = OptionsDict({"models-mode": "none", "tsp_file": "main.tsp", "generate-typeddict": False}) + assert options["models-mode"] is False + + +def test_models_mode_none_without_tsp_stays_false(): + # Swagger input: models-mode=none must remain "no models", untouched by the + # generate-typeddict default. + assert OptionsDict({"models-mode": "none"})["models-mode"] is False + + +def test_models_mode_dpg_with_tsp_is_unchanged(): + options = OptionsDict({"models-mode": "dpg", "tsp_file": "main.tsp"}) + assert options["models-mode"] == "dpg" + + +def test_models_mode_typeddict_is_deprecated_but_accepted(caplog): + import logging + + with caplog.at_level(logging.WARNING): + options = OptionsDict({"models-mode": "typeddict", "tsp_file": "main.tsp"}) + assert options["models-mode"] == "typeddict" + assert any("deprecated" in record.getMessage() for record in caplog.records) + + def test_package_mode_validation_uses_from_typespec_from_constructor_any_order(): with pytest.raises(ValueError): OptionsDict({"from-typespec": True, "package-mode": "dataplane", "package-version": "1.0.0"}) 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 510fe3deaaa..491dd690b6a 100644 --- a/packages/http-client-python/tests/unit/test_typeddict_overloads.py +++ b/packages/http-client-python/tests/unit/test_typeddict_overloads.py @@ -92,6 +92,26 @@ def test_typeddict_only_single_body_emits_no_overload(): assert body_parameter["type"]["type"] == "model" +def test_models_mode_none_maps_to_typeddict_only(): + """User-facing ``models-mode: none`` (TypeSpec) behaves as typeddict-only. + + ``generate-typeddict`` defaults to ``True``, so ``none`` is remapped to the + internal typeddict-only mode by ``OptionsDict``. A lone TypedDict body variant + must still NOT emit a single ``@overload``. + """ + plugin = _plugin("none") + # OptionsDict normalizes none + generate-typeddict -> internal typeddict mode. + assert plugin.options["models-mode"] == "typeddict" + code_model, yaml_data, model_type = _json_model_operation() + body_parameter = yaml_data["bodyParameter"] + + skip_single_body_json = plugin.add_body_param_type(code_model, body_parameter) + add_overloads_for_body_param(yaml_data, skip_single_body_json=skip_single_body_json) + + assert len(yaml_data["overloads"]) == 0 + assert body_parameter["type"] is model_type + + def test_dpg_mode_still_emits_multiple_overloads(): """Regression guard: dpg mode keeps its binary + typeddict overloads.""" plugin = _plugin("dpg") diff --git a/website/src/content/docs/docs/emitters/clients/http-client-python/reference/emitter.md b/website/src/content/docs/docs/emitters/clients/http-client-python/reference/emitter.md index 14b714ced4c..e499e8eefc6 100644 --- a/website/src/content/docs/docs/emitters/clients/http-client-python/reference/emitter.md +++ b/website/src/content/docs/docs/emitters/clients/http-client-python/reference/emitter.md @@ -128,7 +128,7 @@ Whether to keep the existing `setup.py` when `generate-packaging-files` is `true **Type:** `boolean` -Whether to generate TypedDict request-body overloads in `models-mode: dpg`. Defaults to `true`. +Whether to generate `TypedDict` types for request bodies. Defaults to `true`. With `models-mode: dpg` this adds `TypedDict` request-body overloads alongside the model classes; with `models-mode: none` it generates `TypedDict`-only types. Set to `false` to opt out of `TypedDict` generation. ### `keep-pyproject-fields` From 6eca06e02dc927ac5049a1acddeed13c7b514695 Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Tue, 28 Jul 2026 13:52:54 -0700 Subject: [PATCH 10/10] style(http-client-python): fix SKILL.md markdown table alignment Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 511e3e1d-5321-4281-8d91-d5be7a28d81d --- .../.github/skills/generate-from-typespec/SKILL.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/http-client-python/.github/skills/generate-from-typespec/SKILL.md b/packages/http-client-python/.github/skills/generate-from-typespec/SKILL.md index d4f802d17da..9a998cf6f8f 100644 --- a/packages/http-client-python/.github/skills/generate-from-typespec/SKILL.md +++ b/packages/http-client-python/.github/skills/generate-from-typespec/SKILL.md @@ -220,9 +220,9 @@ and `--option` flags. The `flavor` option controls branded behavior: ### Common additional options the user may request -| User request | Option to add | -| -------------- | --------------------------------------------------------------- | -| TypedDict only | `--option "@typespec/http-client-python.models-mode=none"` | +| User request | Option to add | +| -------------- | ------------------------------------------------------------------ | +| TypedDict only | `--option "@typespec/http-client-python.models-mode=none"` | | No TypedDicts | `--option "@typespec/http-client-python.generate-typeddict=false"` | -| No tests | `--option "@typespec/http-client-python.generate-test=false"` | -| No samples | `--option "@typespec/http-client-python.generate-sample=false"` | +| No tests | `--option "@typespec/http-client-python.generate-test=false"` | +| No samples | `--option "@typespec/http-client-python.generate-sample=false"` |