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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"` |
6 changes: 6 additions & 0 deletions packages/http-client-python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
7 changes: 7 additions & 0 deletions packages/http-client-python/emitter/src/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -111,6 +112,12 @@ export const PythonEmitterOptionsSchema: JSONSchemaType<PythonEmitterOptions> =
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": {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

won't this emitter option get confusing with the existing models-mode: typeddict? is there a way to combine the two?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I looked it doesnt looke like models-mode is checked in anywhere to azure-rest-api-specs main, but what is the flow here for causing a big behavioral change like this?

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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,7 @@ export const EMITTER_OPTIONS: Record<string, Record<string, string> | Record<str
{
"package-name": "typetest-model-usage-typeddictonly",
namespace: "typetest.model.usage.typeddictonly",
"models-mode": "typeddict",
"models-mode": "none",
},
],
"type/model/visibility": [
Expand Down
41 changes: 38 additions & 3 deletions packages/http-client-python/generator/pygen/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -47,6 +48,7 @@ def __init__(self, options: Optional[dict[str, Any]] = None) -> 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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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"] = {
Expand All @@ -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.
Expand All @@ -456,28 +506,33 @@ 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
# binary and JSON overloads are omitted). Collapse the combined
# 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:
Expand Down Expand Up @@ -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", []):
Expand Down
4 changes: 2 additions & 2 deletions packages/http-client-python/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

41 changes: 41 additions & 0 deletions packages/http-client-python/tests/unit/test_options_dict.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"})
Expand Down
Loading
Loading