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..9f4e654ad60 --- /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 (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..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 @@ -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: @@ -220,8 +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=typeddict"` | -| No tests | `--option "@typespec/http-client-python.generate-test=false"` | -| No samples | `--option "@typespec/http-client-python.generate-sample=false"` | +| 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"` | diff --git a/packages/http-client-python/README.md b/packages/http-client-python/README.md index 980cbb66703..8427f585478 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` 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/emitter/src/lib.ts b/packages/http-client-python/emitter/src/lib.ts index 70d5d0d081f..eb1fedfd071 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` 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", nullable: true, 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 @@ -131,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( @@ -172,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 b5149be1b47..4682932be5b 100644 --- a/packages/http-client-python/generator/pygen/preprocess/__init__.py +++ b/packages/http-client-python/generator/pygen/preprocess/__init__.py @@ -81,8 +81,19 @@ 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 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. + """ body_parameter = yaml_data["bodyParameter"] if not ( body_parameter["type"]["type"] == "combined" @@ -94,8 +105,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 - continue + 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") content_type_param["inOverload"] = False @@ -306,6 +317,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], @@ -394,11 +409,39 @@ 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], 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. @@ -416,7 +459,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 @@ -432,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"] = { @@ -442,11 +487,16 @@ 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 + # 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: - 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) + 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) + skip_single_body_json = True + 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. @@ -456,17 +506,20 @@ 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. + skip_single_body_json = True if origin_type == "model": body_parameter["type"]["types"].insert(1, original) else: td_list_or_dict = copy.deepcopy(body_parameter["type"]["types"][0]) td_list_or_dict["elementType"] = original body_parameter["type"]["types"].insert(1, td_list_or_dict) - else: + 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) + skip_single_body_json = True if len(body_parameter["type"]["types"]) == 1: # Only one body variant remains (e.g. typeddict-only mode where the @@ -474,10 +527,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: @@ -649,8 +704,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/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": { 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..a8be3646e01 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,47 @@ 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_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 0789877a7dc..491dd690b6a 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, }, ) @@ -77,24 +79,47 @@ 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" +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") 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. @@ -102,6 +127,121 @@ def test_dpg_mode_still_emits_multiple_overloads(): assert len(yaml_data["overloads"]) >= 2 +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}) + 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 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 + 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 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 = { + "name": "create", + "bodyParameter": spread_body, + "parameters": [_content_type_param()], + "overloads": [], + "responses": [], + "exceptions": [], + } + code_model = {"types": [spread_body["type"]]} + + 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 + 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") + + +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"]]} + + 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 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) + + +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 + ``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" + 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"]]} + + 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 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" + 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 = { 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..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 @@ -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` 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`