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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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"` | From 5b3caec42992c8f09f5f5e4aeaf8076701818ba8 Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Mon, 3 Aug 2026 12:22:14 -0700 Subject: [PATCH 11/15] fix(http-client-python): address review feedback on TypedDict opt-out - Remove synthetic internal `models-mode: typeddict`; represent the deprecated value as `models-mode: none` + `generate-typeddict: true`, and drive TypedDict-only behavior via a `generate_typeddict_only` helper instead of a fake models-mode. - Replace generic `**kwargs` in the `_plugin` test helper with an explicit `generate_typeddict: bool` parameter. - Drop msrest from the models-mode ValueError help text (back-compat only). - Reword the `generate-typeddict` description: it adds TypedDict typing for JSON dict input, not an extra request-body overload. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c8b26a94-682b-4350-a424-97093fd1c183 --- packages/http-client-python/README.md | 2 +- .../http-client-python/emitter/src/lib.ts | 2 +- .../generator/pygen/__init__.py | 61 +++++++------------ .../pygen/codegen/models/__init__.py | 4 +- .../pygen/codegen/models/code_model.py | 8 ++- .../pygen/codegen/models/enum_type.py | 14 ++--- .../pygen/codegen/models/list_type.py | 1 + .../pygen/codegen/models/model_type.py | 4 +- .../pygen/codegen/models/operation.py | 9 ++- .../pygen/codegen/models/parameter.py | 4 +- .../pygen/codegen/serializers/__init__.py | 12 ++-- .../codegen/serializers/builder_serializer.py | 30 ++++++--- .../generator/pygen/preprocess/__init__.py | 16 ++--- .../generator/pygen/utils.py | 16 +++++ .../tests/unit/test_options_dict.py | 13 +++- .../tests/unit/test_typeddict.py | 25 +++++--- .../tests/unit/test_typeddict_overloads.py | 21 ++++--- .../http-client-python/reference/emitter.md | 2 +- 18 files changed, 140 insertions(+), 104 deletions(-) diff --git a/packages/http-client-python/README.md b/packages/http-client-python/README.md index 8427f585478..0c3335df5fa 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` 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. +Whether to add TypedDict typing for JSON dictionary input in `models-mode: dpg`, instead of accepting only generic JSON. This enriches the typing on the existing overloads rather than adding another request-body overload. Defaults to `true`. ### `keep-pyproject-fields` diff --git a/packages/http-client-python/emitter/src/lib.ts b/packages/http-client-python/emitter/src/lib.ts index eb1fedfd071..31f66b117fb 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` 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.", + "Whether to add TypedDict typing for JSON dictionary input in `models-mode: dpg`, instead of accepting only generic JSON. This enriches the typing on the existing overloads rather than adding another request-body overload. Defaults to `true`.", }, "keep-pyproject-fields": { type: "object", diff --git a/packages/http-client-python/generator/pygen/__init__.py b/packages/http-client-python/generator/pygen/__init__.py index 4d6a0530b2b..dd61f02e214 100644 --- a/packages/http-client-python/generator/pygen/__init__.py +++ b/packages/http-client-python/generator/pygen/__init__.py @@ -12,7 +12,7 @@ from typing import Any, Iterator, Optional, Union import yaml -from .utils import TYPESPEC_PACKAGE_MODE, VALID_PACKAGE_MODE +from .utils import TYPESPEC_PACKAGE_MODE, VALID_PACKAGE_MODE, is_typeddict_only from ._version import VERSION @@ -46,11 +46,19 @@ class OptionsDict(MutableMapping): def __init__(self, options: Optional[dict[str, Any]] = None) -> None: self._data = options.copy() if options else {} + # 'models-mode: typeddict' is deprecated. Represent it internally as 'none' models-mode with + # TypedDict generation enabled, so the rest of the codebase only reasons about dpg/msrest/none. + if self._data.get("models-mode") == "typeddict": + self._data["generate-typeddict"] = True for key in list(self._data): self._data[key] = self._validate_and_transform(key, self._data[key]) - self._normalize_models_mode() self._validate_combinations() + @property + def generate_typeddict_only(self) -> bool: + """Whether this is the deprecated TypedDict-only generation ('models-mode: none' + TypedDicts).""" + return is_typeddict_only(self) + def __getitem__(self, key: str) -> Any: # pylint: disable=too-many-return-statements if key == "head-as-boolean" and self.get("azure-arm"): # override to always true if azure-arm is set @@ -133,38 +141,6 @@ 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( @@ -201,15 +177,22 @@ def _validate_and_transform(self, key: str, value: Any) -> Any: if key == "builders-visibility" and value not in ["public", "hidden", "embedded"]: raise ValueError("The value of --builders-visibility must be either 'public', 'hidden', or 'embedded'") + if key == "models-mode" and value == "typeddict": + # Deprecated: keep accepting it for back-compat but store it as 'none' (falsy) with + # TypedDict generation enabled (see OptionsDict.__init__ and generate_typeddict_only). + _LOGGER.warning( + "'models-mode: typeddict' is deprecated. Use 'models-mode: none' with " + "'generate-typeddict: true' (the default) instead." + ) + value = False + if key == "models-mode" and value == "none": value = False # switch to falsy value for easier code writing - if key == "models-mode" and value not in ["msrest", "dpg", "typeddict", False]: + if key == "models-mode" and value not in ["msrest", "dpg", False]: raise ValueError( - "--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)." + "--models-mode can only be 'dpg' or 'none'. " + "Pass in 'dpg' for DPG models, or 'none' if you don't want any." ) if key == "package-mode": if ( diff --git a/packages/http-client-python/generator/pygen/codegen/models/__init__.py b/packages/http-client-python/generator/pygen/codegen/models/__init__.py index 1706576fb9d..b971054b32f 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/__init__.py +++ b/packages/http-client-python/generator/pygen/codegen/models/__init__.py @@ -169,7 +169,9 @@ def build_type(yaml_data: dict[str, Any], code_model: CodeModel) -> BaseType: # need to special case model to avoid recursion if yaml_data["base"] == "typeddict": model_type = TypedDictModelType # type: ignore - elif yaml_data["base"] == "json" or not code_model.options["models-mode"]: + elif yaml_data["base"] == "json" or ( + not code_model.options["models-mode"] and not code_model.generate_typeddict_only + ): model_type = JSONModelType elif yaml_data["base"] == "dpg": model_type = DPGModelType # type: ignore diff --git a/packages/http-client-python/generator/pygen/codegen/models/code_model.py b/packages/http-client-python/generator/pygen/codegen/models/code_model.py index 112485a3f86..7b629086a11 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/code_model.py +++ b/packages/http-client-python/generator/pygen/codegen/models/code_model.py @@ -17,6 +17,7 @@ from .utils import NamespaceType from .._utils import DEFAULT_HEADER_TEXT, DEFAULT_LICENSE_DESCRIPTION from ... import OptionsDict +from ...utils import is_typeddict_only def _is_legacy(options) -> bool: @@ -89,7 +90,7 @@ def __init__( self.clients: list[Client] = [ Client.from_yaml(client_yaml_data, self) for client_yaml_data in yaml_data["clients"] ] - if self.options["models-mode"] and self.model_types: + if (self.options["models-mode"] or self.generate_typeddict_only) and self.model_types: self.sort_model_types() self.named_unions: list[CombinedType] = [ t for t in self.types_map.values() if isinstance(t, CombinedType) and t.name @@ -174,6 +175,11 @@ def get_unique_models_alias(self, serialize_namespace: str, imported_namespace: def get_unique_types_alias(self, serialize_namespace: str, imported_namespace: str) -> str: return self._get_unique_import_alias(serialize_namespace, imported_namespace, "types") + @property + def generate_typeddict_only(self) -> bool: + """Whether this is the deprecated TypedDict-only generation ('models-mode: none' + TypedDicts).""" + return is_typeddict_only(self.options) + @property def client_namespace_types(self) -> dict[str, ClientNamespaceType]: if not self._client_namespace_types: diff --git a/packages/http-client-python/generator/pygen/codegen/models/enum_type.py b/packages/http-client-python/generator/pygen/codegen/models/enum_type.py index 278c0b7e4a4..3bf89d395b8 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/enum_type.py +++ b/packages/http-client-python/generator/pygen/codegen/models/enum_type.py @@ -40,7 +40,7 @@ def description(self, *, is_operation_file: bool) -> str: def type_annotation(self, **kwargs: Any) -> str: """The python type used for type annotation""" - if self.code_model.options["models-mode"] == "typeddict": + if self.code_model.generate_typeddict_only: # A single constant enum value must be # annotated with its literal value directly (e.g. ``Literal["red"]``). return f"Literal[{self.value_type.get_declaration(self.value)}]" @@ -83,7 +83,7 @@ def imports(self, **kwargs: Any) -> FileImport: file_import = FileImport(self.code_model) file_import.merge(self.value_type.imports(**kwargs)) file_import.add_submodule_import("typing", "Literal", ImportType.STDLIB, TypingSection.REGULAR) - if self.code_model.options["models-mode"] == "typeddict": + if self.code_model.generate_typeddict_only: # In typeddict mode the enums module (``_enums.py``) is never generated return file_import serialize_namespace = kwargs.get("serialize_namespace", self.code_model.namespace) @@ -176,7 +176,7 @@ def description(self, *, is_operation_file: bool) -> str: @property def is_typeddict_mode(self) -> bool: - return self.code_model.options["models-mode"] == "typeddict" + return self.code_model.generate_typeddict_only def type_annotation(self, **kwargs: Any) -> str: """The python type used for type annotation @@ -184,7 +184,7 @@ def type_annotation(self, **kwargs: Any) -> str: :return: The type annotation for this schema :rtype: str """ - if self.code_model.options["models-mode"]: + if self.code_model.options["models-mode"] or self.code_model.generate_typeddict_only: if self.is_typeddict_mode: # In typeddict mode, enums are Literal aliases defined in types.py serialize_namespace_type = kwargs.get("serialize_namespace_type") @@ -221,13 +221,13 @@ def get_declaration(self, value: Any) -> str: return self.value_type.get_declaration(value) def docstring_text(self, **kwargs: Any) -> str: - if self.code_model.options["models-mode"]: + if self.code_model.options["models-mode"] or self.code_model.generate_typeddict_only: return self.name return self.value_type.type_annotation(**kwargs) def docstring_type(self, **kwargs: Any) -> str: """The python type used for RST syntax input and type annotation.""" - if self.code_model.options["models-mode"]: + if self.code_model.options["models-mode"] or self.code_model.generate_typeddict_only: type_annotation = self.value_type.type_annotation(**kwargs) enum_type_annotation = f"{self.client_namespace}.models.{self.name}" return f"{type_annotation} or ~{enum_type_annotation}" @@ -261,7 +261,7 @@ def from_yaml(cls, yaml_data: dict[str, Any], code_model: "CodeModel") -> "EnumT def imports(self, **kwargs: Any) -> FileImport: file_import = FileImport(self.code_model) file_import.merge(self.value_type.imports(**kwargs)) - if self.code_model.options["models-mode"]: + if self.code_model.options["models-mode"] or self.code_model.generate_typeddict_only: if self.is_typeddict_mode: # In typeddict mode, enums are Literal aliases in types.py — no Union needed serialize_namespace_type = kwargs.get("serialize_namespace_type") diff --git a/packages/http-client-python/generator/pygen/codegen/models/list_type.py b/packages/http-client-python/generator/pygen/codegen/models/list_type.py index 275432b4179..368fb39816d 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/list_type.py +++ b/packages/http-client-python/generator/pygen/codegen/models/list_type.py @@ -38,6 +38,7 @@ def type_annotation(self, **kwargs: Any) -> str: self.code_model.options["version-tolerant"] and self.element_type.is_xml and not self.code_model.options["models-mode"] + and not self.code_model.generate_typeddict_only ): # this means we're version tolerant XML, we just return the XML element return self.element_type.type_annotation(**kwargs) diff --git a/packages/http-client-python/generator/pygen/codegen/models/model_type.py b/packages/http-client-python/generator/pygen/codegen/models/model_type.py index 49fb22a547b..f7d4a9c6e7a 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/model_type.py +++ b/packages/http-client-python/generator/pygen/codegen/models/model_type.py @@ -77,9 +77,7 @@ def __init__( self.cross_language_definition_id: Optional[str] = self.yaml_data.get("crossLanguageDefinitionId") self.usage: int = self.yaml_data.get("usage", UsageFlags.Input.value | UsageFlags.Output.value) self.client_namespace: str = self.yaml_data.get("clientNamespace", code_model.namespace) - self.is_typed_dict_only: bool = ( - self.yaml_data.get("typedDictOnly", False) or code_model.options["models-mode"] == "typeddict" - ) + self.is_typed_dict_only: bool = self.yaml_data.get("typedDictOnly", False) or code_model.generate_typeddict_only @property def is_usage_output(self) -> bool: diff --git a/packages/http-client-python/generator/pygen/codegen/models/operation.py b/packages/http-client-python/generator/pygen/codegen/models/operation.py index 7ba964132d0..3c6d525f122 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/operation.py +++ b/packages/http-client-python/generator/pygen/codegen/models/operation.py @@ -334,7 +334,7 @@ def imports( # pylint: disable=too-many-branches, disable=too-many-statements file_import.merge( response.imports(async_mode=async_mode, need_import_iobase=self.need_import_iobase, **kwargs) ) - if self.code_model.options["models-mode"]: + if self.code_model.options["models-mode"] or self.code_model.generate_typeddict_only: for exception in self.exceptions: file_import.merge(exception.imports(async_mode=async_mode, **kwargs)) @@ -552,7 +552,12 @@ def imports(self, async_mode: bool, **kwargs: Any) -> FileImport: "distributed_trace_async", ImportType.SDKCORE, ) - if self.has_response_body and not self.has_optional_return_type and not self.code_model.options["models-mode"]: + if ( + self.has_response_body + and not self.has_optional_return_type + and not self.code_model.options["models-mode"] + and not self.code_model.generate_typeddict_only + ): file_import.add_submodule_import("typing", "cast", ImportType.STDLIB) return file_import diff --git a/packages/http-client-python/generator/pygen/codegen/models/parameter.py b/packages/http-client-python/generator/pygen/codegen/models/parameter.py index 775a7db00f0..0a7aeb95c64 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/parameter.py +++ b/packages/http-client-python/generator/pygen/codegen/models/parameter.py @@ -338,7 +338,9 @@ def method_location( # pylint: disable=too-many-return-statements ) -> ParameterMethodLocation: if not self.in_method_signature: raise ValueError(f"Parameter '{self.client_name}' is not in the method.") - if self.code_model.options["models-mode"] in ("dpg", "typeddict") and self.in_flattened_body: + if ( + self.code_model.options["models-mode"] == "dpg" or self.code_model.generate_typeddict_only + ) and self.in_flattened_body: return ParameterMethodLocation.KEYWORD_ONLY if self.grouper: return ParameterMethodLocation.POSITIONAL diff --git a/packages/http-client-python/generator/pygen/codegen/serializers/__init__.py b/packages/http-client-python/generator/pygen/codegen/serializers/__init__.py index e2acbcf10b9..c5d786a4a6e 100644 --- a/packages/http-client-python/generator/pygen/codegen/serializers/__init__.py +++ b/packages/http-client-python/generator/pygen/codegen/serializers/__init__.py @@ -207,10 +207,10 @@ def serialize(self) -> None: self._serialize_and_write_top_level_folder(env=env, namespace=client_namespace) # add models folder if there are models in this namespace - is_typeddict_mode = self.code_model.options["models-mode"] == "typeddict" - if ( - self.code_model.has_non_json_models(client_namespace_type.models) or client_namespace_type.enums - ) and self.code_model.options["models-mode"]: + is_typeddict_mode = self.code_model.generate_typeddict_only + if (self.code_model.has_non_json_models(client_namespace_type.models) or client_namespace_type.enums) and ( + self.code_model.options["models-mode"] or self.code_model.generate_typeddict_only + ): self._serialize_and_write_models_folder( env=env, namespace=client_namespace, @@ -238,7 +238,7 @@ def serialize(self) -> None: ).serialize(), ) - if not self.code_model.options["models-mode"]: + if not self.code_model.options["models-mode"] and not self.code_model.generate_typeddict_only: # keep models file if users ended up just writing a models file model_path = generation_path / Path("models.py") if self.read_file(model_path): @@ -321,7 +321,7 @@ def _serialize_and_write_models_folder( # Write the models folder models_path = self.code_model.get_generation_dir(namespace) / "models" models_mode = self.code_model.options["models-mode"] - if models_mode in ("dpg", "typeddict"): + if models_mode == "dpg" or self.code_model.generate_typeddict_only: serializer = DpgModelSerializer else: serializer = MsrestModelSerializer diff --git a/packages/http-client-python/generator/pygen/codegen/serializers/builder_serializer.py b/packages/http-client-python/generator/pygen/codegen/serializers/builder_serializer.py index 791709407b6..152b840d260 100644 --- a/packages/http-client-python/generator/pygen/codegen/serializers/builder_serializer.py +++ b/packages/http-client-python/generator/pygen/codegen/serializers/builder_serializer.py @@ -715,7 +715,9 @@ def _serialize_body_parameter(self, builder: OperationType) -> list[str]: send_xml = builder.parameters.body_parameter.type.is_xml xml_serialization_ctxt = body_param.type.xml_serialization_ctxt if send_xml else None ser_ctxt_name = "serialization_ctxt" - if xml_serialization_ctxt and self.code_model.options["models-mode"]: + if xml_serialization_ctxt and ( + self.code_model.options["models-mode"] or self.code_model.generate_typeddict_only + ): retval.append(f'{ser_ctxt_name} = {{"xml": {{{xml_serialization_ctxt}}}}}') if self.code_model.options["models-mode"] == "msrest": is_xml_cmd = _xml_config(send_xml, builder.parameters.body_parameter.content_types) @@ -725,7 +727,7 @@ def _serialize_body_parameter(self, builder: OperationType) -> list[str]: f"_{body_kwarg_name} = self._serialize.body({body_param.client_name}, " f"'{serialization_type}'{is_xml_cmd}{serialization_ctxt_cmd})" ) - elif self.code_model.options["models-mode"] == "typeddict": + elif self.code_model.generate_typeddict_only: # TypedDict-only models are plain dicts — no serialization needed create_body_call = f"_{body_kwarg_name} = {body_param.client_name}" elif self.code_model.options["models-mode"] == "dpg": @@ -844,7 +846,9 @@ def _initialize_overloads(self, builder: OperationType, is_paging: bool = False) overload.request_builder.parameters.body_parameter.client_name for overload in builder.overloads ] all_dpg_model_overloads = False - if self.code_model.options["models-mode"] in ("dpg", "typeddict") and builder.overloads: + if ( + self.code_model.options["models-mode"] == "dpg" or self.code_model.generate_typeddict_only + ) and builder.overloads: all_dpg_model_overloads = all( _is_dpg_or_typeddict_body(o.parameters.body_parameter) for o in builder.overloads ) @@ -1131,8 +1135,8 @@ def handle_error_response( # pylint: disable=too-many-statements, too-many-bran retval.extend([f" {l}" for l in response_read]) retval.append(" map_error(status_code=response.status_code, response=response, error_map=error_map)") error_model = "" - if ( # pylint: disable=too-many-nested-blocks - builder.non_default_errors and self.code_model.options["models-mode"] + if builder.non_default_errors and ( # pylint: disable=too-many-nested-blocks + self.code_model.options["models-mode"] or self.code_model.generate_typeddict_only ): error_model = ", model=error" condition = "if" @@ -1214,7 +1218,9 @@ def handle_error_response( # pylint: disable=too-many-statements, too-many-bran condition = "elif" # default error handling default_error_deserialization = builder.default_error_deserialization(self.serialize_namespace) - if default_error_deserialization and self.code_model.options["models-mode"]: + if default_error_deserialization and ( + self.code_model.options["models-mode"] or self.code_model.generate_typeddict_only + ): error_model = ", model=error" indent = " " if builder.non_default_errors else " " if builder.non_default_errors: @@ -1293,7 +1299,11 @@ def handle_response(self, builder: OperationType) -> list[str]: else: retval.extend(self.response_headers_and_deserialization(builder, builder.responses[0])) retval.append("") - if builder.has_optional_return_type or self.code_model.options["models-mode"]: + if ( + builder.has_optional_return_type + or self.code_model.options["models-mode"] + or self.code_model.generate_typeddict_only + ): deserialized = "deserialized" else: deserialized = f"cast({builder.response_type_annotation(async_mode=self.async_mode)}, deserialized)" @@ -1329,7 +1339,9 @@ def _need_specific_error_map(self, code: int, builder: OperationType) -> bool: def error_map(self, builder: OperationType) -> list[str]: retval = ["error_map: MutableMapping = {"] - if builder.non_default_errors and self.code_model.options["models-mode"]: + if builder.non_default_errors and ( + self.code_model.options["models-mode"] or self.code_model.generate_typeddict_only + ): # TODO: we should decide whether to add the build-in error map when there is a customized default error type if self._need_specific_error_map(401, builder): retval.append(" 401: ClientAuthenticationError,") @@ -1693,7 +1705,7 @@ def get_long_running_output(self, builder: LROOperationType) -> list[str]: if builder.lro_response.headers: retval.append(" response_headers = {}") if ( - not self.code_model.options["models-mode"] + (not self.code_model.options["models-mode"] and not self.code_model.generate_typeddict_only) or self.code_model.options["models-mode"] == "dpg" or builder.lro_response.headers ): diff --git a/packages/http-client-python/generator/pygen/preprocess/__init__.py b/packages/http-client-python/generator/pygen/preprocess/__init__.py index 4682932be5b..ff3d6f094e3 100644 --- a/packages/http-client-python/generator/pygen/preprocess/__init__.py +++ b/packages/http-client-python/generator/pygen/preprocess/__init__.py @@ -87,12 +87,10 @@ 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 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. + path. It is True for both models-mode: dpg (generate-typeddict on) and + the TypedDict-only mode (models-mode: none with generate-typeddict on), 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 ( @@ -451,7 +449,7 @@ def add_body_param_type( body_parameter and body_parameter["type"]["type"] == "bytes" and is_binary_body - and self.options["models-mode"] != "typeddict" + and not self.options.generate_typeddict_only and not any(t for t in ["flattened", "groupedBy"] if body_parameter.get(t)) ): body_parameter["type"] = { @@ -475,9 +473,7 @@ 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" + is_typeddict_only = self.options.generate_typeddict_only body_parameter["type"] = { "type": "combined", diff --git a/packages/http-client-python/generator/pygen/utils.py b/packages/http-client-python/generator/pygen/utils.py index c3f27e78dad..878c1dd0766 100644 --- a/packages/http-client-python/generator/pygen/utils.py +++ b/packages/http-client-python/generator/pygen/utils.py @@ -28,6 +28,22 @@ def description_ends_with_code_block(description: str) -> bool: return all(not line.strip() or line.startswith((" ", "\t")) for line in lines[directives[-1] + 1 :]) +def is_typeddict_only(options: Any) -> bool: + """Whether generation is TypedDict-only. + + The deprecated ``models-mode: typeddict`` is represented internally as ``models-mode: none`` + together with ``generate-typeddict`` enabled. It only applies to TypeSpec + input; swagger ``models-mode: none`` is left as a plain no-models mode. + + ``options`` may be an :class:`OptionsDict` (where ``models-mode`` is already normalized to a + falsy value) or a plain mapping (where it may still be the string ``"none"``/``"typeddict"``). + Both the normalized and un-normalized forms are treated as "no concrete models". + """ + models_mode = options.get("models-mode") + no_models = not models_mode or models_mode in ("none", "typeddict") + return bool(options.get("tsp_file")) and no_models and bool(options.get("generate-typeddict", True)) + + def update_enum_value(name: str, value: Any, description: str, enum_type: dict[str, Any]) -> dict[str, Any]: return { "name": name, 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 a8be3646e01..cd7a2451dbb 100644 --- a/packages/http-client-python/tests/unit/test_options_dict.py +++ b/packages/http-client-python/tests/unit/test_options_dict.py @@ -39,15 +39,18 @@ def test_generate_typeddict_can_be_disabled(): 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. + # default. Internally models-mode stays 'none' (falsy); TypedDict-only + # generation is expressed via generate_typeddict_only. options = OptionsDict({"models-mode": "none", "tsp_file": "main.tsp"}) - assert options["models-mode"] == "typeddict" + assert options["models-mode"] is False + assert options.generate_typeddict_only is True 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 + assert options.generate_typeddict_only is False def test_models_mode_none_without_tsp_stays_false(): @@ -66,7 +69,11 @@ def test_models_mode_typeddict_is_deprecated_but_accepted(caplog): with caplog.at_level(logging.WARNING): options = OptionsDict({"models-mode": "typeddict", "tsp_file": "main.tsp"}) - assert options["models-mode"] == "typeddict" + # Deprecated 'typeddict' is normalized to 'none' (falsy) with TypedDict + # generation on, i.e. represented via generate_typeddict_only. + assert options["models-mode"] is False + assert options["generate-typeddict"] is True + assert options.generate_typeddict_only is True assert any("deprecated" in record.getMessage() for record in caplog.records) diff --git a/packages/http-client-python/tests/unit/test_typeddict.py b/packages/http-client-python/tests/unit/test_typeddict.py index 781554ebd68..69a2e4b745a 100644 --- a/packages/http-client-python/tests/unit/test_typeddict.py +++ b/packages/http-client-python/tests/unit/test_typeddict.py @@ -18,6 +18,20 @@ def _make_code_model(models_mode="dpg"): + options = { + "show-send-request": True, + "builders-visibility": "public", + "show-operations": True, + "models-mode": models_mode, + "flavor": "unbranded", + "client-side-validation": False, + } + # The deprecated 'typeddict' models-mode is represented internally as models-mode + # none (falsy) + generate-typeddict enabled on a TypeSpec input. + if models_mode == "typeddict": + options["models-mode"] = None + options["generate-typeddict"] = True + options["tsp_file"] = True return CodeModel( { "clients": [ @@ -32,21 +46,14 @@ def _make_code_model(models_mode="dpg"): ], "namespace": "namespace", }, - options={ - "show-send-request": True, - "builders-visibility": "public", - "show-operations": True, - "models-mode": models_mode, - "flavor": "unbranded", - "client-side-validation": False, - }, + options=options, ) def _make_model(code_model, name, model_cls=None, properties=None): """Create a model of the given class attached to code_model.""" if model_cls is None: - if code_model.options["models-mode"] == "typeddict": + if code_model.generate_typeddict_only: model_cls = TypedDictModelType elif code_model.options["models-mode"] == "dpg": model_cls = DPGModelType 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 491dd690b6a..0ce1ae24ede 100644 --- a/packages/http-client-python/tests/unit/test_typeddict_overloads.py +++ b/packages/http-client-python/tests/unit/test_typeddict_overloads.py @@ -14,18 +14,17 @@ from pygen.preprocess import PreProcessPlugin, add_overloads_for_body_param -def _plugin(models_mode: str, **kwargs) -> PreProcessPlugin: +def _plugin(models_mode: str, generate_typeddict: bool = True) -> PreProcessPlugin: return PreProcessPlugin( output_folder="", **{ "version-tolerant": True, "models-mode": models_mode, - "generate-typeddict": True, + "generate-typeddict": generate_typeddict, "tsp_file": True, "show-operations": True, "show-send-request": True, "builders-visibility": "public", - **kwargs, }, ) @@ -95,13 +94,15 @@ def test_typeddict_only_single_body_emits_no_overload(): 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``. + ``generate-typeddict`` defaults to ``True``, so ``none`` generates TypedDicts. + Internally ``models-mode`` stays ``none`` (falsy) and typeddict-only generation + is expressed via ``generate_typeddict_only``. 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" + # models-mode stays 'none' (falsy); TypedDict-only is expressed via generate_typeddict_only. + assert plugin.options["models-mode"] is False + assert plugin.options.generate_typeddict_only is True code_model, yaml_data, model_type = _json_model_operation() body_parameter = yaml_data["bodyParameter"] @@ -129,7 +130,7 @@ def test_dpg_mode_still_emits_multiple_overloads(): def test_dpg_mode_can_disable_typeddict_autogeneration(): """Opting out reverts to the pre-TypedDict overloads: model + raw-JSON + binary.""" - plugin = _plugin("dpg", **{"generate-typeddict": False}) + plugin = _plugin("dpg", generate_typeddict=False) code_model, yaml_data, model_type = _json_model_operation() body_parameter = yaml_data["bodyParameter"] @@ -149,7 +150,7 @@ def test_dpg_mode_can_disable_typeddict_autogeneration(): def test_spread_body_opt_out_keeps_json_overload(): """Spread bodies revert to pre-TypedDict: flattened + single-body JSON + binary.""" - plugin = _plugin("dpg", **{"generate-typeddict": False}) + plugin = _plugin("dpg", generate_typeddict=False) spread_body = _json_spread_body_parameter("CreateRequest", "Contoso.Widget") yaml_data = { "name": "create", 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 e499e8eefc6..c2a86182af5 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` 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. +Whether to add TypedDict typing for JSON dictionary input in `models-mode: dpg`, instead of accepting only generic JSON. This enriches the typing on the existing overloads rather than adding another request-body overload. Defaults to `true`. ### `keep-pyproject-fields` From 2a74ade1f60769794862ee3d02dc45cfcbb95fbf Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Mon, 3 Aug 2026 12:35:15 -0700 Subject: [PATCH 12/15] docs(http-client-python): clarify generate_typeddict_only is not deprecated Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c8b26a94-682b-4350-a424-97093fd1c183 --- packages/http-client-python/generator/pygen/__init__.py | 2 +- .../generator/pygen/codegen/models/code_model.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/http-client-python/generator/pygen/__init__.py b/packages/http-client-python/generator/pygen/__init__.py index dd61f02e214..10aac6be454 100644 --- a/packages/http-client-python/generator/pygen/__init__.py +++ b/packages/http-client-python/generator/pygen/__init__.py @@ -56,7 +56,7 @@ def __init__(self, options: Optional[dict[str, Any]] = None) -> None: @property def generate_typeddict_only(self) -> bool: - """Whether this is the deprecated TypedDict-only generation ('models-mode: none' + TypedDicts).""" + """Whether this is TypedDict-only generation ('models-mode: none' + TypedDicts).""" return is_typeddict_only(self) def __getitem__(self, key: str) -> Any: # pylint: disable=too-many-return-statements diff --git a/packages/http-client-python/generator/pygen/codegen/models/code_model.py b/packages/http-client-python/generator/pygen/codegen/models/code_model.py index 7b629086a11..73cd410eb84 100644 --- a/packages/http-client-python/generator/pygen/codegen/models/code_model.py +++ b/packages/http-client-python/generator/pygen/codegen/models/code_model.py @@ -177,7 +177,7 @@ def get_unique_types_alias(self, serialize_namespace: str, imported_namespace: s @property def generate_typeddict_only(self) -> bool: - """Whether this is the deprecated TypedDict-only generation ('models-mode: none' + TypedDicts).""" + """Whether this is TypedDict-only generation ('models-mode: none' + TypedDicts).""" return is_typeddict_only(self.options) @property From 5837b4f0c216dc743467806d362ac9378fd0eea3 Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Mon, 3 Aug 2026 14:29:14 -0700 Subject: [PATCH 13/15] docs(http-client-python): drop msrest mention from models-mode normalization comment Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c8b26a94-682b-4350-a424-97093fd1c183 --- packages/http-client-python/generator/pygen/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/http-client-python/generator/pygen/__init__.py b/packages/http-client-python/generator/pygen/__init__.py index 10aac6be454..5b77d715d8d 100644 --- a/packages/http-client-python/generator/pygen/__init__.py +++ b/packages/http-client-python/generator/pygen/__init__.py @@ -47,7 +47,8 @@ class OptionsDict(MutableMapping): def __init__(self, options: Optional[dict[str, Any]] = None) -> None: self._data = options.copy() if options else {} # 'models-mode: typeddict' is deprecated. Represent it internally as 'none' models-mode with - # TypedDict generation enabled, so the rest of the codebase only reasons about dpg/msrest/none. + # TypedDict generation enabled, so the rest of the codebase reasons in terms of models-mode + # plus the generate-typeddict flag. if self._data.get("models-mode") == "typeddict": self._data["generate-typeddict"] = True for key in list(self._data): From 24ac2105e58574f9413be153bef62311dfbb71d1 Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Mon, 3 Aug 2026 14:36:49 -0700 Subject: [PATCH 14/15] refactor(http-client-python): drop dead 'typeddict' models-mode check in is_typeddict_only Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c8b26a94-682b-4350-a424-97093fd1c183 --- packages/http-client-python/generator/pygen/utils.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/http-client-python/generator/pygen/utils.py b/packages/http-client-python/generator/pygen/utils.py index 878c1dd0766..fe8b1a3a1e3 100644 --- a/packages/http-client-python/generator/pygen/utils.py +++ b/packages/http-client-python/generator/pygen/utils.py @@ -31,16 +31,16 @@ def description_ends_with_code_block(description: str) -> bool: def is_typeddict_only(options: Any) -> bool: """Whether generation is TypedDict-only. - The deprecated ``models-mode: typeddict`` is represented internally as ``models-mode: none`` - together with ``generate-typeddict`` enabled. It only applies to TypeSpec - input; swagger ``models-mode: none`` is left as a plain no-models mode. + True for TypeSpec input where no concrete models mode is selected (``models-mode: none``, + normalized to a falsy value) and ``generate-typeddict`` is enabled. Swagger ``models-mode: + none`` is left as a plain no-models mode because it never sets ``tsp_file``. ``options`` may be an :class:`OptionsDict` (where ``models-mode`` is already normalized to a - falsy value) or a plain mapping (where it may still be the string ``"none"``/``"typeddict"``). - Both the normalized and un-normalized forms are treated as "no concrete models". + falsy value) or a plain mapping (where it may still be the string ``"none"``). Both forms are + treated as "no concrete models". """ models_mode = options.get("models-mode") - no_models = not models_mode or models_mode in ("none", "typeddict") + no_models = not models_mode or models_mode == "none" return bool(options.get("tsp_file")) and no_models and bool(options.get("generate-typeddict", True)) From 9ba33738892f28ef8d97090ab9cb767365bbd79b Mon Sep 17 00:00:00 2001 From: Libba Lawrence Date: Mon, 3 Aug 2026 14:40:02 -0700 Subject: [PATCH 15/15] refactor(http-client-python): simplify is_typeddict_only to single models-mode check Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c8b26a94-682b-4350-a424-97093fd1c183 --- .../http-client-python/generator/pygen/utils.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/packages/http-client-python/generator/pygen/utils.py b/packages/http-client-python/generator/pygen/utils.py index fe8b1a3a1e3..dc5a796c7ce 100644 --- a/packages/http-client-python/generator/pygen/utils.py +++ b/packages/http-client-python/generator/pygen/utils.py @@ -32,16 +32,15 @@ def is_typeddict_only(options: Any) -> bool: """Whether generation is TypedDict-only. True for TypeSpec input where no concrete models mode is selected (``models-mode: none``, - normalized to a falsy value) and ``generate-typeddict`` is enabled. Swagger ``models-mode: - none`` is left as a plain no-models mode because it never sets ``tsp_file``. - - ``options`` may be an :class:`OptionsDict` (where ``models-mode`` is already normalized to a - falsy value) or a plain mapping (where it may still be the string ``"none"``). Both forms are - treated as "no concrete models". + normalized to a falsy value by :class:`OptionsDict`) and ``generate-typeddict`` is enabled. + Swagger ``models-mode: none`` is left as a plain no-models mode because it never sets + ``tsp_file``. """ - models_mode = options.get("models-mode") - no_models = not models_mode or models_mode == "none" - return bool(options.get("tsp_file")) and no_models and bool(options.get("generate-typeddict", True)) + return ( + bool(options.get("tsp_file")) + and not options.get("models-mode") + and bool(options.get("generate-typeddict", True)) + ) def update_enum_value(name: str, value: Any, description: str, enum_type: dict[str, Any]) -> dict[str, Any]: