Skip to content

Commit a5402a2

Browse files
l0lawrenceCopilot
andcommitted
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
1 parent 88a5b58 commit a5402a2

10 files changed

Lines changed: 106 additions & 14 deletions

File tree

.chronus/changes/python-disable-typeddict-generation-2026-7-24-12-59-0.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@ packages:
44
- "@typespec/http-client-python"
55
---
66

7-
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.
7+
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.

packages/http-client-python/.github/skills/generate-from-typespec/SKILL.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ The caller must provide:
2828
If not provided, ask the user.
2929
4. **Additional options** (optional) — any extra `key=value` emitter options the
3030
user wants applied on top of the tspconfig options. These override tspconfig
31-
values if there's a conflict (e.g., `models-mode=typeddict`).
31+
values if there's a conflict (e.g., `generate-typeddict=false`).
3232

3333
## Workflow
3434

@@ -191,7 +191,7 @@ After successful compilation:
191191
find < output-dir > -type d | sort
192192
```
193193

194-
2. Verify the output matches expectations (e.g., TypedDict if `models-mode=typeddict`).
194+
2. Verify the output matches expectations (e.g., TypedDict-only if `models-mode=none`).
195195

196196
3. If the generation overwrote files in an existing package, warn the user and
197197
offer to revert non-generated files:
@@ -222,6 +222,7 @@ and `--option` flags. The `flavor` option controls branded behavior:
222222

223223
| User request | Option to add |
224224
| -------------- | --------------------------------------------------------------- |
225-
| TypedDict only | `--option "@typespec/http-client-python.models-mode=typeddict"` |
225+
| TypedDict only | `--option "@typespec/http-client-python.models-mode=none"` |
226+
| No TypedDicts | `--option "@typespec/http-client-python.generate-typeddict=false"` |
226227
| No tests | `--option "@typespec/http-client-python.generate-test=false"` |
227228
| No samples | `--option "@typespec/http-client-python.generate-sample=false"` |

packages/http-client-python/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ Whether to keep the existing `setup.py` when `generate-packaging-files` is `true
134134

135135
**Type:** `boolean`
136136

137-
Whether to generate TypedDict request-body overloads in `models-mode: dpg`. Defaults to `true`.
137+
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.
138138

139139
### `keep-pyproject-fields`
140140

packages/http-client-python/emitter/src/lib.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ export const PythonEmitterOptionsSchema: JSONSchemaType<PythonEmitterOptions> =
116116
type: "boolean",
117117
nullable: true,
118118
description:
119-
"Whether to generate TypedDict request-body overloads in `models-mode: dpg`. Defaults to `true`.",
119+
"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.",
120120
},
121121
"keep-pyproject-fields": {
122122
type: "object",

packages/http-client-python/eng/scripts/ci/regenerate-common.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,7 @@ export const EMITTER_OPTIONS: Record<string, Record<string, string> | Record<str
321321
{
322322
"package-name": "typetest-model-usage-typeddictonly",
323323
namespace: "typetest.model.usage.typeddictonly",
324-
"models-mode": "typeddict",
324+
"models-mode": "none",
325325
},
326326
],
327327
"type/model/visibility": [

packages/http-client-python/generator/pygen/__init__.py

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ def __init__(self, options: Optional[dict[str, Any]] = None) -> None:
4848
self._data = options.copy() if options else {}
4949
for key in list(self._data):
5050
self._data[key] = self._validate_and_transform(key, self._data[key])
51+
self._normalize_models_mode()
5152
self._validate_combinations()
5253

5354
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
132133
return self.get("flavor") == "azure"
133134
return self.DEFAULTS[key]
134135

136+
def _normalize_models_mode(self) -> None:
137+
"""Reconcile ``models-mode`` with ``generate-typeddict`` for TypeSpec generation.
138+
139+
The user-facing ``models-mode`` values are ``dpg`` and ``none`` (``msrest``
140+
is kept for back-compat). TypedDict output is controlled independently by
141+
``generate-typeddict`` (default ``True``):
142+
143+
* ``dpg`` + ``generate-typeddict`` -> DPG models and TypedDict overloads
144+
* ``dpg`` + no ``generate-typeddict`` -> DPG models only
145+
* ``none`` + ``generate-typeddict`` -> TypedDicts only
146+
* ``none`` + no ``generate-typeddict`` -> nothing
147+
148+
``models-mode: typeddict`` is deprecated; it is still accepted (with a
149+
warning) and behaves as TypedDict-only. Internally, TypedDict-only
150+
generation is represented by ``models-mode == "typeddict"``, so the
151+
``none`` + TypedDicts case is remapped to it here. This only applies to
152+
TypeSpec input; swagger ``models-mode: none`` is left untouched.
153+
"""
154+
if "models-mode" not in self._data:
155+
return
156+
models_mode = self._data["models-mode"]
157+
if models_mode == "typeddict":
158+
_LOGGER.warning(
159+
"'models-mode: typeddict' is deprecated. Use 'models-mode: none' instead "
160+
"(TypedDicts are generated by default; set 'generate-typeddict: false' to opt out)."
161+
)
162+
return
163+
# 'none' is stored as falsy False. For TypeSpec, keep generating TypedDicts
164+
# by default by remapping to the internal typeddict-only mode.
165+
if bool(self._data.get("tsp_file")) and models_mode is False and self.get("generate-typeddict"):
166+
self._data["models-mode"] = "typeddict"
167+
135168
def _validate_combinations(self) -> None:
136169
if not self.get("show-operations") and self.get("builders-visibility") == "embedded":
137170
raise ValueError(
@@ -173,9 +206,10 @@ def _validate_and_transform(self, key: str, value: Any) -> Any:
173206

174207
if key == "models-mode" and value not in ["msrest", "dpg", "typeddict", False]:
175208
raise ValueError(
176-
"--models-mode can only be 'msrest', 'dpg', 'typeddict', or 'none'. "
177-
"Pass in 'msrest' if you want msrest models, 'typeddict' for TypedDict models, or "
178-
"'none' if you don't want any."
209+
"--models-mode can only be 'msrest', 'dpg', or 'none'. "
210+
"Pass in 'msrest' if you want msrest models, 'dpg' for DPG models, or "
211+
"'none' if you don't want any. TypedDicts are controlled by --generate-typeddict "
212+
"(the deprecated 'typeddict' value is still accepted for back-compat)."
179213
)
180214
if key == "package-mode":
181215
if (

packages/http-client-python/generator/pygen/preprocess/__init__.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,10 +87,12 @@ def add_overloads_for_body_param(yaml_data: dict[str, Any], skip_single_body_jso
8787
``skip_single_body_json`` is the authoritative signal, computed by
8888
``add_body_param_type``, for whether a TypedDict-style overload was inserted
8989
to replace the single-body raw-JSON overload on the spread (``base: json``)
90-
path. It is True for both models-mode: dpg (generate-typeddict on) and
91-
models-mode: typeddict, and False when TypedDict generation is disabled (in
90+
path. It is True whenever TypedDicts are generated -- i.e. for the internal
91+
``dpg`` mode (with ``generate-typeddict`` on) and the internal ``typeddict``
92+
mode (which the user-facing ``models-mode: none`` remaps to). It is False
93+
when TypedDict generation is disabled (``generate-typeddict: false``), in
9294
which case the single-body raw-JSON overload is kept, matching pre-TypedDict
93-
behavior).
95+
behavior.
9496
"""
9597
body_parameter = yaml_data["bodyParameter"]
9698
if not (
@@ -473,6 +475,8 @@ def add_body_param_type(
473475
)
474476
is_dpg_model = model_type.get("base") == "dpg"
475477
is_json_model = model_type.get("base") == "json"
478+
# ``typeddict`` is now an internal-only models-mode: the user-facing
479+
# ``models-mode: none`` (with generate-typeddict on) remaps to it.
476480
is_typeddict_only = self.options["models-mode"] == "typeddict"
477481

478482
body_parameter["type"] = {

packages/http-client-python/tests/unit/test_options_dict.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,39 @@ def test_generate_typeddict_can_be_disabled():
3737
assert OptionsDict({"generate-typeddict": False})["generate-typeddict"] is False
3838

3939

40+
def test_models_mode_none_with_tsp_generates_typeddict_by_default():
41+
# For TypeSpec input, models-mode=none keeps TypedDict generation on by
42+
# default, represented internally as the typeddict-only mode.
43+
options = OptionsDict({"models-mode": "none", "tsp_file": "main.tsp"})
44+
assert options["models-mode"] == "typeddict"
45+
46+
47+
def test_models_mode_none_with_tsp_and_generate_typeddict_false_is_nothing():
48+
# Opting out of TypedDicts on top of models-mode=none produces no models.
49+
options = OptionsDict({"models-mode": "none", "tsp_file": "main.tsp", "generate-typeddict": False})
50+
assert options["models-mode"] is False
51+
52+
53+
def test_models_mode_none_without_tsp_stays_false():
54+
# Swagger input: models-mode=none must remain "no models", untouched by the
55+
# generate-typeddict default.
56+
assert OptionsDict({"models-mode": "none"})["models-mode"] is False
57+
58+
59+
def test_models_mode_dpg_with_tsp_is_unchanged():
60+
options = OptionsDict({"models-mode": "dpg", "tsp_file": "main.tsp"})
61+
assert options["models-mode"] == "dpg"
62+
63+
64+
def test_models_mode_typeddict_is_deprecated_but_accepted(caplog):
65+
import logging
66+
67+
with caplog.at_level(logging.WARNING):
68+
options = OptionsDict({"models-mode": "typeddict", "tsp_file": "main.tsp"})
69+
assert options["models-mode"] == "typeddict"
70+
assert any("deprecated" in record.getMessage() for record in caplog.records)
71+
72+
4073
def test_package_mode_validation_uses_from_typespec_from_constructor_any_order():
4174
with pytest.raises(ValueError):
4275
OptionsDict({"from-typespec": True, "package-mode": "dataplane", "package-version": "1.0.0"})

packages/http-client-python/tests/unit/test_typeddict_overloads.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,26 @@ def test_typeddict_only_single_body_emits_no_overload():
9292
assert body_parameter["type"]["type"] == "model"
9393

9494

95+
def test_models_mode_none_maps_to_typeddict_only():
96+
"""User-facing ``models-mode: none`` (TypeSpec) behaves as typeddict-only.
97+
98+
``generate-typeddict`` defaults to ``True``, so ``none`` is remapped to the
99+
internal typeddict-only mode by ``OptionsDict``. A lone TypedDict body variant
100+
must still NOT emit a single ``@overload``.
101+
"""
102+
plugin = _plugin("none")
103+
# OptionsDict normalizes none + generate-typeddict -> internal typeddict mode.
104+
assert plugin.options["models-mode"] == "typeddict"
105+
code_model, yaml_data, model_type = _json_model_operation()
106+
body_parameter = yaml_data["bodyParameter"]
107+
108+
skip_single_body_json = plugin.add_body_param_type(code_model, body_parameter)
109+
add_overloads_for_body_param(yaml_data, skip_single_body_json=skip_single_body_json)
110+
111+
assert len(yaml_data["overloads"]) == 0
112+
assert body_parameter["type"] is model_type
113+
114+
95115
def test_dpg_mode_still_emits_multiple_overloads():
96116
"""Regression guard: dpg mode keeps its binary + typeddict overloads."""
97117
plugin = _plugin("dpg")

website/src/content/docs/docs/emitters/clients/http-client-python/reference/emitter.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ Whether to keep the existing `setup.py` when `generate-packaging-files` is `true
128128

129129
**Type:** `boolean`
130130

131-
Whether to generate TypedDict request-body overloads in `models-mode: dpg`. Defaults to `true`.
131+
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.
132132

133133
### `keep-pyproject-fields`
134134

0 commit comments

Comments
 (0)