Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
b9e1f80
Initial plan
Copilot Jul 23, 2026
fb6b9cb
feat(http-client-python): add dpg typeddict opt-out
Copilot Jul 23, 2026
3f8f519
test(http-client-python): cover json overload when typeddict is disabled
Copilot Jul 23, 2026
5014b3e
Merge branch 'main' into copilot/python-disable-typeddict-generation
l0lawrence Jul 23, 2026
c7a11a1
Merge branch 'main' into copilot/python-disable-typeddict-generation
l0lawrence Jul 24, 2026
be122e6
Add changeset for http-client-python TypedDict opt-out
l0lawrence Jul 24, 2026
025eceb
Restore pre-TypedDict JSON dict overload when generate-typeddict is f…
l0lawrence Jul 27, 2026
e957fc5
Fix single-body JSON overload skip for models-mode: typeddict spread …
l0lawrence Jul 27, 2026
210c4d2
Replace jsonOverloadReplacedByTypeddict flag with explicit skip_singl…
l0lawrence Jul 27, 2026
588c7f9
Merge branch 'main' into copilot/python-disable-typeddict-generation
l0lawrence Jul 27, 2026
a979f7c
Merge branch 'main' into copilot/python-disable-typeddict-generation
l0lawrence Jul 27, 2026
88a5b58
Apply remaining changes
Copilot Jul 28, 2026
a5402a2
feat(http-client-python): deprecate models-mode typeddict in favor of…
l0lawrence Jul 28, 2026
6eca06e
style(http-client-python): fix SKILL.md markdown table alignment
l0lawrence Jul 28, 2026
b6bb261
Merge branch 'main' into copilot/python-disable-typeddict-generation
l0lawrence Jul 29, 2026
5b3caec
fix(http-client-python): address review feedback on TypedDict opt-out
l0lawrence Aug 3, 2026
2a74ade
docs(http-client-python): clarify generate_typeddict_only is not depr…
l0lawrence Aug 3, 2026
5837b4f
docs(http-client-python): drop msrest mention from models-mode normal…
l0lawrence Aug 3, 2026
24ac210
refactor(http-client-python): drop dead 'typeddict' models-mode check…
l0lawrence Aug 3, 2026
9ba3373
refactor(http-client-python): simplify is_typeddict_only to single mo…
l0lawrence Aug 3, 2026
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 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`
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": {
Comment thread
l0lawrence marked this conversation as resolved.
type: "boolean",
nullable: true,
description:
"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",
nullable: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,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
29 changes: 24 additions & 5 deletions packages/http-client-python/generator/pygen/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 @@ -45,10 +46,20 @@ 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 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):
self._data[key] = self._validate_and_transform(key, self._data[key])
self._validate_combinations()

@property
def generate_typeddict_only(self) -> bool:
"""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
if key == "head-as-boolean" and self.get("azure-arm"):
# override to always true if azure-arm is set
Expand Down Expand Up @@ -167,14 +178,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', '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 'dpg' or 'none'. "
"Pass in 'dpg' for DPG models, or 'none' if you don't want any."
)
if key == "package-mode":
if (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)}]"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -176,15 +176,15 @@ 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

: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")
Expand Down Expand Up @@ -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}"
Expand Down Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading