Skip to content

[Bug]: typespec-python: models-mode=none crashes - "MsrestModelSerializer object has no attribute get_properties_to_declare" #11046

Description

@cheatsheet1999

Describe the bug

Summary

When the Python emitter (@azure-tools/typespec-python, backed by @typespec/http-client-python) is run with --option "@azure-tools/typespec-python.models-mode=none", code generation crashes instead of producing a modelless client:

jinja2.exceptions.UndefinedError: 'pygen.codegen.serializers.model_serializer.MsrestModelSerializer object' has no attribute 'get_properties_to_declare

The root cause is in pygen’s OptionsDict.__init__: it stores the constructor-supplied options directly (self._data = options.copy()), bypassing the _validate_and_transform step that every other write path (__setitem__) goes through. That transform is what converts the string "none" into the falsy False. Because it’s skipped, models-mode stays the truthy string "none", the serializer wrongly enters the “write models folder” path, and it does so with MsrestModelSerializer (which has no get_properties_to_declare), crashing the render.

Environment

Component Version
@typespec/compiler 1.12.0
@azure-tools/typespec-python (emitter) 0.62.1
@typespec/http-client-python 0.29.2
Python (emitter venv) 3.12.3
Node.js 22.23.0
OS Linux (Ubuntu / WSL2)

Actual behavior

Generation aborts with the following traceback (paths abbreviated):

Traceback (most recent call last):
  File ".../pygen/codegen/__init__.py", line 83, in process
  File ".../pygen/codegen/serializers/__init__.py", line 212, in serialize
  File ".../pygen/codegen/serializers/__init__.py", line 305, in _serialize_and_write_models_folder
  File ".../pygen/codegen/serializers/model_serializer.py", line 98, in serialize
  File ".../jinja2/environment.py", line 1295, in render
  File ".../pygen/codegen/templates/model_container.py.jinja2", line 13, in top-level template code
  File ".../pygen/codegen/templates/model_dpg.py.jinja2", line 24, in top-level template code
    {% for p in serializer.get_properties_to_declare(model) %}
jinja2.exceptions.UndefinedError:
  'pygen.codegen.serializers.model_serializer.MsrestModelSerializer object' has no attribute 'get_properties_to_declare'

error @typespec/http-client-python/unknown-error: Can't generate Python client code from this TypeSpec.
Please open an issue on https://github.com/microsoft/typespec.

Note the emitter still enters _serialize_and_write_models_folder even though --models-mode=none was passed.

Expected behavior

models-mode=none should generate a modelless client (no models/ folder; operations take/return plain JSON = MutableMapping[str, Any]), exactly as the __setitem__ normalization already implies - not crash.

Root cause

In pygen/__init__.py, class OptionsDict(MutableMapping):

# __init__  — stores options RAW, skipping the transform:
def __init__(self, options: Optional[dict[str, Any]] = None) -> None:
    self._data = options.copy() if options else {}      # <-- bypasses _validate_and_transform
    self._validate_combinations()

# __setitem__ — the normal write path, which DOES transform:
def __setitem__(self, key: str, value: Any) -> None:
    validated_value = self._validate_and_transform(key, value)
    self._data[key] = validated_value

# _validate_and_transform — where "none" becomes falsy False:
def _validate_and_transform(self, key: str, value: Any) -> Any:
    ...
    if key == "models-mode" and value == "none":
        value = False  # switch to falsy value for easier code writing
    ...
    return value

Because __init__ assigns self._data directly, options passed to the constructor never run through _validate_and_transform. So models-mode="none" is kept as the truthy string "none" instead of False. Downstream serialization treats that as “emit models”, picks MsrestModelSerializer, and renders model_dpg.py.jinja2, which calls get_properties_to_declare - a method that doesn’t exist on MsrestModelSerializer - producing the crash.

Proposed fix

Route constructor-supplied options through the same _validate_and_transform used by __setitem__:

     def __init__(self, options: Optional[dict[str, Any]] = None) -> None:
-        self._data = options.copy() if options else {}
+        self._data = {}
+        if options:
+            for key, value in options.items():
+                self._data[key] = self._validate_and_transform(key, value)
         self._validate_combinations()

With this change, OptionsDict({"models-mode": "none"})["models-mode"] returns False, generation honors models-mode=none, and a modelless client is produced successfully.

Reproduction

Minimal reproduction (isolates the bug, no spec needed)

from pygen import OptionsDict

# Same option, two write paths:
via_ctor = OptionsDict({"models-mode": "none"})["models-mode"]
o = OptionsDict(); o["models-mode"] = "none"
via_setitem = o["models-mode"]

print("via constructor :", repr(via_ctor))     # 'none'   <-- BUG: stays a truthy string
print("via __setitem__ :", repr(via_setitem))  # False    <-- correct (normalized)

Output:

via constructor : 'none'
via __setitem__ : False

The two paths disagree. The emitter constructs OptionsDict(options) with the CLI options (including models-mode=none), so it always takes the buggy constructor path.

Full reproduction (end-to-end)

Compile any ARM management-plane TypeSpec spec with models-mode=none:

tsp compile <spec>/client.tsp \
  --emit @azure-tools/typespec-python \
  --option "@azure-tools/typespec-python.models-mode=none" \
  --option "@azure-tools/typespec-python.namespace=my.pkg.mgmt" \
  --option "@azure-tools/typespec-python.emitter-output-dir=$PWD/out"

Checklist

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingemitter:client:pythonIssue for the Python client emitter: @typespec/http-client-python

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions