Skip to content
Closed
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
11 changes: 11 additions & 0 deletions .chronus/changes/dpg-export-typeddicts-in-init-2026-7-7-12-40-0.md
Original file line number Diff line number Diff line change
@@ -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`
```
Original file line number Diff line number Diff line change
Expand Up @@ -198,13 +198,48 @@ 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(
code_model=self.code_model,
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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 }}

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