diff --git a/.chronus/changes/dpg-export-typeddicts-in-init-2026-7-7-12-40-0.md b/.chronus/changes/dpg-export-typeddicts-in-init-2026-7-7-12-40-0.md new file mode 100644 index 00000000000..80a71336dfb --- /dev/null +++ b/.chronus/changes/dpg-export-typeddicts-in-init-2026-7-7-12-40-0.md @@ -0,0 +1,11 @@ +--- +changeKind: feature +packages: + - "@typespec/http-client-python" +--- + +Export generated `TypedDict` types (and their `Union`/`Literal` aliases from `types.py`) from the namespace-root `__init__.py` in `dpg` and `typeddict` models modes, so they can be imported directly from the package: + +```python +from my_package import MyModel # previously only reachable via `from my_package import types` +``` diff --git a/packages/http-client-python/generator/pygen/codegen/serializers/general_serializer.py b/packages/http-client-python/generator/pygen/codegen/serializers/general_serializer.py index c55dc359c65..d68b4ab6ff1 100644 --- a/packages/http-client-python/generator/pygen/codegen/serializers/general_serializer.py +++ b/packages/http-client-python/generator/pygen/codegen/serializers/general_serializer.py @@ -198,6 +198,40 @@ def serialize_pkgutil_init_file(self) -> str: template = self.env.get_template("pkgutil_init.py.jinja2") return template.render() + def typeddict_names_for_init(self) -> list[str]: + """Names defined in this namespace's ``types.py`` that should be re-exported from ``__init__.py``. + + Mirrors the gate and name selection used to generate ``types.py`` (see + ``CodeModelSerializer`` and ``TypesSerializer``) so that only names that actually exist in + ``types.py`` are imported. ``types.py`` lives at the namespace root next to the sync + ``__init__.py``, so nothing is re-exported from the ``aio`` init. + """ + if self.async_mode: + return [] + namespace_type = self.code_model.client_namespace_types.get(self.client_namespace) + if namespace_type is None: + return [] + models = namespace_type.models + has_types_models = any(m.is_used_in_operations_via_types for m in models if m.base != "json") + has_types_enums = any(e.is_typeddict_mode for e in namespace_type.enums) + if not (has_types_models or has_types_enums): + return [] + + from .types_serializer import TypesSerializer + + is_typeddict_mode = self.code_model.options["models-mode"] == "typeddict" + serializer = TypesSerializer( + code_model=self.code_model, + env=self.env, + client_namespace=self.client_namespace, + models=models, + enums=namespace_type.enums if is_typeddict_mode else None, + ) + names = [e.name for e in serializer.literal_enums] + names += [m.name for m in serializer.discriminated_base_models] + names += [m.name for m in serializer.typeddict_models] + return sorted(set(names)) + def serialize_init_file(self, clients: list[Client]) -> str: template = self.env.get_template("init.py.jinja2") return template.render( @@ -205,6 +239,7 @@ def serialize_init_file(self, clients: list[Client]) -> str: clients=clients, async_mode=self.async_mode, serialize_namespace=self.serialize_namespace, + typeddict_names=self.typeddict_names_for_init(), ) def serialize_service_client_file(self, clients: list[Client]) -> str: diff --git a/packages/http-client-python/generator/pygen/codegen/templates/init.py.jinja2 b/packages/http-client-python/generator/pygen/codegen/templates/init.py.jinja2 index 007009e4bc9..4864a84218a 100644 --- a/packages/http-client-python/generator/pygen/codegen/templates/init.py.jinja2 +++ b/packages/http-client-python/generator/pygen/codegen/templates/init.py.jinja2 @@ -9,6 +9,13 @@ from .{{ client.filename }} import {{ client.name }} # type: ignore {% endfor %} {% endif %} +{% if typeddict_names %} +from .types import ( # type: ignore + {% for name in typeddict_names %} + {{ name }}, + {% endfor %} +) +{% endif %} {% if not async_mode and code_model.options.get("package-version") %} from {{ code_model.get_relative_import_path(serialize_namespace, module_name="_version") }} import VERSION @@ -20,6 +27,9 @@ __all__ = [ {% for client in clients %} {{ keywords.escape_str(client.name) }}, {% endfor %} + {% for name in typeddict_names %} + {{ keywords.escape_str(name) }}, + {% endfor %} ] {{ keywords.extend_all }} diff --git a/packages/http-client-python/tests/unit/test_typeddict.py b/packages/http-client-python/tests/unit/test_typeddict.py index 3fb20d7cc96..69838040ed6 100644 --- a/packages/http-client-python/tests/unit/test_typeddict.py +++ b/packages/http-client-python/tests/unit/test_typeddict.py @@ -252,3 +252,75 @@ def test_typed_dict_only_docstring_type(): docstring = model.docstring_type() assert "types.Foo" in docstring assert "models.Foo" not in docstring + + +# ---------- __init__.py re-exports types.py names ---------- + + +def _make_typeddict_named_model(code_model, name): + return TypedDictModelType( + yaml_data={ + "name": name, + "type": "model", + "snakeCaseName": name.lower(), + "usage": 2, + "clientNamespace": "blah", + }, + code_model=code_model, + properties=[], + ) + + +def _serialize_root_init(code_model, async_mode=False): + from pygen.codegen.serializers.general_serializer import GeneralSerializer + + env = _make_env() + serializer = GeneralSerializer(code_model=code_model, env=env, async_mode=async_mode, client_namespace="blah") + return serializer.serialize_init_file(code_model.get_clients("blah")) + + +def test_typeddict_mode_init_reexports_typeddicts(): + """In typeddict mode, the namespace-root __init__.py should re-export the TypedDicts from types.py.""" + code_model = _make_code_model(models_mode="typeddict") + foo = _make_typeddict_named_model(code_model, "Foo") + bar = _make_typeddict_named_model(code_model, "Bar") + code_model.model_types = [foo, bar] + + output = _serialize_root_init(code_model) + assert "from .types import (" in output + assert "Foo," in output + assert "Bar," in output + assert "'Foo'" in output + assert "'Bar'" in output + + +def test_dpg_mode_init_reexports_typeddict_copies(): + """In dpg mode, TypedDict copies (base='typeddict') in types.py should be re-exported from __init__.py.""" + code_model = _make_code_model(models_mode="dpg") + # A base='typeddict' copy lands in types.py and is used in operations via types. + copy = _make_typeddict_named_model(code_model, "Widget") + code_model.model_types = [copy] + + output = _serialize_root_init(code_model) + assert "from .types import (" in output + assert "Widget," in output + assert "'Widget'" in output + + +def test_async_init_does_not_reexport_typeddicts(): + """The aio/__init__.py should not import from .types (types.py lives at the namespace root).""" + code_model = _make_code_model(models_mode="typeddict") + foo = _make_typeddict_named_model(code_model, "Foo") + code_model.model_types = [foo] + + output = _serialize_root_init(code_model, async_mode=True) + assert "from .types import" not in output + + +def test_init_without_types_has_no_types_import(): + """When there are no TypedDicts, __init__.py should not import from .types.""" + code_model = _make_code_model(models_mode="dpg") + code_model.model_types = [] + + output = _serialize_root_init(code_model) + assert "from .types import" not in output