From b9f544a1e6c1fb0db38fefb1b31da425fd476c9f Mon Sep 17 00:00:00 2001 From: Greg Holmes Date: Fri, 21 Aug 2026 16:14:32 +0100 Subject: [PATCH 1/4] fix: preserve dict access on typed response models --- .fernignore | 9 +++ AGENTS.md | 2 + src/deepgram/core/unchecked_base_model.py | 8 +++ tests/custom/test_model_dict_compat.py | 73 +++++++++++++++++++++++ 4 files changed, 92 insertions(+) create mode 100644 tests/custom/test_model_dict_compat.py diff --git a/.fernignore b/.fernignore index 3c9cd00d..7d7d59be 100644 --- a/.fernignore +++ b/.fernignore @@ -146,6 +146,14 @@ src/deepgram/core/parse_error.py src/deepgram/core/query_encoder.py +# Read-side compatibility for the SDK 7.7 Listen V2 response retype. Responses +# were raw dicts through 7.6 because V2SocketClientResponse contained typing.Any; +# after the union was fixed, callers using the observed response["field"] API +# broke. UncheckedBaseModel carries a read-only __getitem__ shim so typed models +# support both attribute and wire-key subscript access. Remove in the next major. +# [temporarily frozen -- manual patch described above] +src/deepgram/core/unchecked_base_model.py + # Hand-written custom tests tests/custom/test_api_error_redaction.py tests/custom/test_agent_history.py @@ -164,6 +172,7 @@ tests/custom/test_latency_report_stt_compat.py tests/custom/test_listen_v2_connect_wire.py tests/custom/test_listen_v2_regen_constraints.py tests/custom/test_logging_and_retry_branches.py +tests/custom/test_model_dict_compat.py tests/custom/test_query_encoder.py tests/custom/test_secure_logging.py tests/custom/test_socket_client_shims.py diff --git a/AGENTS.md b/AGENTS.md index 807310be..95ed475b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,7 @@ Current permanently frozen files: - `src/deepgram/transport_interface.py`, `src/deepgram/transport.py`, `src/deepgram/transports/` — custom transport layer - `tests/custom/test_agent_history.py` — hand-written regression test for Agent History websocket payload parsing - `tests/custom/test_compat_aliases.py` — hand-written regression test for backward-compatible alias imports after regen renames +- `tests/custom/test_model_dict_compat.py` — hand-written regression coverage for the read-only dictionary-access compatibility shim on typed response models, including sync/async Listen V2 responses, nested words, wire aliases, unknown fields, and omitted-key behavior - `tests/custom/test_query_encoder.py` — hand-written regression test that `core/query_encoder.py` coerces Python bools to lowercase `"true"`/`"false"` before `urlencode` so websocket query strings stay wire-correct - `tests/custom/test_secure_logging.py` — hand-written regression test that the `websockets` Authorization-header DEBUG logs are redacted (API key never logged in clear text) - `tests/custom/test_speak_v2_interrupt_configure.py` — hand-written coverage for the Speak V2 barge-in / mid-stream reconfigure surface (`send_interrupt`, `send_configure`, `SpeechInterrupted`, `ConfigureSuccess`/`ConfigureFailure`, and the `speed`/`expressivity` connect params) @@ -63,6 +64,7 @@ Current temporarily frozen files: - `src/deepgram/agent/v1/types/agent_v1settings_agent_context.py`, `src/deepgram/agent/v1/types/agent_v1settings_agent.py`, `src/deepgram/agent/v1/types/agent_v1settings.py`, `src/deepgram/agent/v1/requests/agent_v1settings_agent_context.py`, `src/deepgram/agent/v1/requests/agent_v1settings_agent.py`, `src/deepgram/agent/v1/requests/agent_v1settings.py` — backward-compat patches for the 2026-05-05 Agent Settings schema restructure. These preserve callable `AgentV1SettingsAgent(...)`, keep `AgentV1Settings.agent` accepting both that wrapper and `agent_id` strings, restore the legacy request TypedDict shapes, remap legacy `messages=[...]` / nested `context=AgentV1SettingsAgentContext(messages=[...])` usage into the new `context={"messages": [...]}` wire shape, and keep read-side `obj.messages` access working. - `src/deepgram/core/api_error.py`, `src/deepgram/core/parse_error.py` — credential redaction. Every websocket `connect()` path raises `ApiError(headers=dict(headers), ...)` with the full request headers, and both error types stringify that dict, so an unredacted `Authorization` reached `str(e)`, tracebacks, log aggregators and error trackers (which serialise attributes as well as the message). Both now mask credential values at construction via `_secure_logging.redact_sensitive_headers`, preserving non-sensitive headers (`dg-request-id`) for debugging. This is the same threat `_secure_logging.py` covers for the `websockets` DEBUG handshake logs, via the other path to it. Regression coverage in `tests/custom/test_api_error_redaction.py`. Unfreeze if the generator starts redacting credentials itself. - `src/deepgram/core/query_encoder.py` — coerces Python bools to lowercase `"true"`/`"false"` before they reach `urllib.parse.urlencode` (which would otherwise produce `"True"`/`"False"` via `str()` and break websocket query strings). Only the four `*/connect()` paths call `urlencode`; HTTP raw clients hand params to httpx, which lowercases bools itself, so the patch is a no-op for the HTTP path. Once Fern's websocket codegen normalizes bools (or the spec types these as `boolean` end-to-end), this can be unfrozen. +- `src/deepgram/core/unchecked_base_model.py` — read-side compatibility shim for the SDK 7.7 Listen V2 response retype. Through 7.6, `V2SocketClientResponse` contained `typing.Any`, so every response was returned as a raw dict; fixing the union made responses typed models and broke callers using `response["field"]`. A hand-added read-only `__getitem__` preserves wire-key subscript access alongside canonical attribute access, including nested models and unknown fields, while omitted keys retain dict-style `KeyError` behavior. Remove and unfreeze in the next major release. - `src/deepgram/types/deepgram_listen_provider_v2.py`, `src/deepgram/agent/v1/types/agent_v1settings_agent_listen_provider.py`, `src/deepgram/agent/v1/types/agent_v1settings_agent_context_listen_provider.py` — behavioural back-compat shim for the `language_hint` -> `language_hints` rename (2026-06-15 regen). The public field was historically (incorrectly) singular and accepted a str or a list; the API field is `language_hints` (a list, and the server uses `deny_unknown_fields` so the singular key is rejected on the wire). Each carries a hand-added `model_validator(mode='before')` / `root_validator(pre=True)` that remaps a legacy `language_hint=` kwarg and drops the dead singular key. Remove and unfreeze when the singular alias is retired in a future major. - `src/deepgram/agent/v1/types/agent_v1update_listen_listen.py` — backward-compat patch for the 2026-07-31 `AgentV1UpdateListen` provider retype. The `provider` field changed from a bare `DeepgramListenProviderV2` to the required discriminated union `AgentV1UpdateListenListenProvider` (`_V1`/`_V2`, discriminant `version`). Carries a hand-added `model_validator(mode='before')` / `root_validator(pre=True)` that coerces a legacy `DeepgramListenProviderV1`/`V2` (or a dict lacking the `version` discriminant) into the new shape so existing callers keep working. Remove and unfreeze when the old provider payloads are retired in a future major. NOTE: this patch was silently lost once (it was absent from `.fernignore`, so a regen overwrote it) — keep it frozen. - `tests/wire/test_manage_v1_projects_keys.py` — restored wire coverage for the legacy `CreateKeyV1RequestOneParams` request alias so future regens do not silently drop that compatibility check diff --git a/src/deepgram/core/unchecked_base_model.py b/src/deepgram/core/unchecked_base_model.py index c5deec1f..13bf7980 100644 --- a/src/deepgram/core/unchecked_base_model.py +++ b/src/deepgram/core/unchecked_base_model.py @@ -65,6 +65,14 @@ class UncheckedBaseModel(UniversalBaseModel): class Config: extra = pydantic.Extra.allow + def __getitem__(self, key: str) -> typing.Any: + """Provide read-only dict-style access using the original wire keys.""" + if IS_PYDANTIC_V2: + values = self.model_dump(by_alias=True, exclude_unset=True) + else: + values = self.dict(by_alias=True, exclude_unset=True) + return values[key] + @classmethod def model_construct( cls: typing.Type["Model"], diff --git a/tests/custom/test_model_dict_compat.py b/tests/custom/test_model_dict_compat.py new file mode 100644 index 00000000..22d544c1 --- /dev/null +++ b/tests/custom/test_model_dict_compat.py @@ -0,0 +1,73 @@ +"""Regression coverage for read-only dictionary access on typed responses. + +Listen V2 responses were raw dictionaries through SDK 7.6 because the response +union contained ``typing.Any``. SDK 7.7 fixed deserialization to return typed +models, which broke callers using the observed dictionary interface. Typed +models now support both attribute and subscript access during that transition. +""" + +import json + +import pytest + +from deepgram.listen.v2.socket_client import AsyncV2SocketClient, V2SocketClient +from deepgram.listen.v2.types.listen_v2turn_info import ListenV2TurnInfo +from deepgram.types.get_model_v1response_metadata import GetModelV1ResponseMetadata + +TURN_INFO = { + "type": "TurnInfo", + "request_id": "request-id", + "sequence_id": 1, + "event": "EndOfTurn", + "turn_index": 0, + "audio_window_start": 0.0, + "audio_window_end": 1.0, + "transcript": "hello", + "words": [{"word": "hello", "confidence": 0.96}], + "end_of_turn_confidence": 0.9, + "future_field": "preserved", +} + + +class _FakeWebSocket: + def recv(self) -> str: + return json.dumps(TURN_INFO) + + +class _FakeAsyncWebSocket: + async def recv(self) -> str: + return json.dumps(TURN_INFO) + + +def _assert_attribute_and_subscript_access(message: object) -> None: + assert isinstance(message, ListenV2TurnInfo) + + assert message.transcript == "hello" + assert message["transcript"] == "hello" + + assert message.words[0].confidence == 0.96 + assert message.words[0]["confidence"] == 0.96 + assert message["words"][0]["confidence"] == 0.96 + + assert message["future_field"] == "preserved" + with pytest.raises(KeyError): + message["trigger"] + + +def test_sync_listen_v2_response_supports_both_access_styles() -> None: + message = V2SocketClient(websocket=_FakeWebSocket()).recv() + _assert_attribute_and_subscript_access(message) + + +async def test_async_listen_v2_response_supports_both_access_styles() -> None: + message = await AsyncV2SocketClient(websocket=_FakeAsyncWebSocket()).recv() + _assert_attribute_and_subscript_access(message) + + +def test_subscript_access_uses_wire_aliases() -> None: + metadata = GetModelV1ResponseMetadata(uuid="model-id") + + assert metadata.uuid_ == "model-id" + assert metadata["uuid"] == "model-id" + with pytest.raises(KeyError): + metadata["uuid_"] From 0c67f0038ea7de4829212afa2e550d64eb9847ec Mon Sep 17 00:00:00 2001 From: Greg Holmes Date: Fri, 21 Aug 2026 16:56:54 +0100 Subject: [PATCH 2/4] fix: scope dict compatibility to listen v2 responses --- .fernignore | 22 ++++-- AGENTS.md | 5 +- src/deepgram/core/unchecked_base_model.py | 8 -- src/deepgram/listen/v2/types/_dict_compat.py | 20 +++++ .../v2/types/listen_v2configure_failure.py | 3 +- .../v2/types/listen_v2configure_success.py | 3 +- .../listen_v2configure_success_thresholds.py | 3 +- .../listen/v2/types/listen_v2connected.py | 3 +- .../listen/v2/types/listen_v2fatal_error.py | 3 +- .../listen/v2/types/listen_v2turn_info.py | 3 +- .../v2/types/listen_v2turn_info_words_item.py | 3 +- tests/custom/test_model_dict_compat.py | 73 +++++++++++++++---- 12 files changed, 111 insertions(+), 38 deletions(-) create mode 100644 src/deepgram/listen/v2/types/_dict_compat.py diff --git a/.fernignore b/.fernignore index 7d7d59be..cfc5270b 100644 --- a/.fernignore +++ b/.fernignore @@ -14,6 +14,11 @@ src/deepgram/client.py # key / access token) in DEBUG handshake logs. No Fern-generated counterpart. src/deepgram/_secure_logging.py +# Hand-written read-side compatibility mixin for Listen V2 response models. +# Provides deprecated, read-only wire-key subscript access during the SDK 7.x +# transition from raw dict responses to typed models. No Fern counterpart. +src/deepgram/listen/v2/types/_dict_compat.py + # WebSocket socket clients: # - except Exception broad catch (supports custom transports, generator narrows to WebSocketException) # - _sanitize_numeric_types in agent socket client (float→int for API) @@ -146,13 +151,18 @@ src/deepgram/core/parse_error.py src/deepgram/core/query_encoder.py -# Read-side compatibility for the SDK 7.7 Listen V2 response retype. Responses -# were raw dicts through 7.6 because V2SocketClientResponse contained typing.Any; -# after the union was fixed, callers using the observed response["field"] API -# broke. UncheckedBaseModel carries a read-only __getitem__ shim so typed models -# support both attribute and wire-key subscript access. Remove in the next major. +# Read-side compatibility for the SDK 7.7 Listen V2 response retype. These +# generated response models inherit the hand-written mixin above so callers can +# temporarily use both attribute and wire-key subscript access. Remove the mixin +# inheritance and unfreeze these files in the next major release. # [temporarily frozen -- manual patch described above] -src/deepgram/core/unchecked_base_model.py +src/deepgram/listen/v2/types/listen_v2connected.py +src/deepgram/listen/v2/types/listen_v2turn_info.py +src/deepgram/listen/v2/types/listen_v2turn_info_words_item.py +src/deepgram/listen/v2/types/listen_v2configure_success.py +src/deepgram/listen/v2/types/listen_v2configure_success_thresholds.py +src/deepgram/listen/v2/types/listen_v2configure_failure.py +src/deepgram/listen/v2/types/listen_v2fatal_error.py # Hand-written custom tests tests/custom/test_api_error_redaction.py diff --git a/AGENTS.md b/AGENTS.md index 95ed475b..cc8b2082 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,7 @@ How to identify: Current permanently frozen files: - `src/deepgram/client.py` — entirely custom (Bearer auth, session ID, `transport_factory`, `reconnect` parity flag); no Fern equivalent - `src/deepgram/_secure_logging.py` — hand-written security utility that installs a `logging.Filter` on the `websockets` client/server loggers to mask the `Authorization` header in DEBUG handshake logs; called from `client.py`; no Fern equivalent +- `src/deepgram/listen/v2/types/_dict_compat.py` — hand-written read-side compatibility mixin for Listen V2 response models. It provides deprecated, read-only wire-key subscript access during the SDK 7.x transition from raw dict responses to typed models; no Fern counterpart. Remove in the next major release after removing its inheritance from the temporarily frozen response models below. - `src/deepgram/helpers/` — hand-written TextBuilder helpers - `src/deepgram/agent/v1/types/agent_v1history_content.py`, `src/deepgram/agent/v1/types/agent_v1history_function_calls.py`, `src/deepgram/agent/v1/types/agent_v1settings_agent_context_messages_item.py`, `src/deepgram/agent/v1/types/agent_v1settings_agent_context_messages_item_content.py`, `src/deepgram/agent/v1/types/agent_v1settings_agent_context_messages_item_content_role.py`, `src/deepgram/agent/v1/types/agent_v1settings_agent_context_messages_item_function_calls.py`, `src/deepgram/agent/v1/types/agent_v1settings_agent_context_messages_item_function_calls_function_calls_item.py` — hand-written compatibility aliases preserving old public Agent History type imports after regen renames - `src/deepgram/agent/v1/requests/agent_v1history_content.py`, `src/deepgram/agent/v1/requests/agent_v1history_function_calls.py`, `src/deepgram/agent/v1/requests/agent_v1settings_agent_context_messages_item.py`, `src/deepgram/agent/v1/requests/agent_v1settings_agent_context_messages_item_content.py`, `src/deepgram/agent/v1/requests/agent_v1settings_agent_context_messages_item_function_calls.py`, `src/deepgram/agent/v1/requests/agent_v1settings_agent_context_messages_item_function_calls_function_calls_item.py` — hand-written compatibility aliases preserving old public Agent History request-param imports after regen renames @@ -34,7 +35,7 @@ Current permanently frozen files: - `src/deepgram/transport_interface.py`, `src/deepgram/transport.py`, `src/deepgram/transports/` — custom transport layer - `tests/custom/test_agent_history.py` — hand-written regression test for Agent History websocket payload parsing - `tests/custom/test_compat_aliases.py` — hand-written regression test for backward-compatible alias imports after regen renames -- `tests/custom/test_model_dict_compat.py` — hand-written regression coverage for the read-only dictionary-access compatibility shim on typed response models, including sync/async Listen V2 responses, nested words, wire aliases, unknown fields, and omitted-key behavior +- `tests/custom/test_model_dict_compat.py` — hand-written regression coverage for the deprecated read-only dictionary-access compatibility shim on typed Listen V2 response models, including sync/async parsing, every response variant, nested objects, unknown fields, omitted-key behavior, warning frequency, and scope isolation from unrelated generated models - `tests/custom/test_query_encoder.py` — hand-written regression test that `core/query_encoder.py` coerces Python bools to lowercase `"true"`/`"false"` before `urlencode` so websocket query strings stay wire-correct - `tests/custom/test_secure_logging.py` — hand-written regression test that the `websockets` Authorization-header DEBUG logs are redacted (API key never logged in clear text) - `tests/custom/test_speak_v2_interrupt_configure.py` — hand-written coverage for the Speak V2 barge-in / mid-stream reconfigure surface (`send_interrupt`, `send_configure`, `SpeechInterrupted`, `ConfigureSuccess`/`ConfigureFailure`, and the `speed`/`expressivity` connect params) @@ -64,7 +65,7 @@ Current temporarily frozen files: - `src/deepgram/agent/v1/types/agent_v1settings_agent_context.py`, `src/deepgram/agent/v1/types/agent_v1settings_agent.py`, `src/deepgram/agent/v1/types/agent_v1settings.py`, `src/deepgram/agent/v1/requests/agent_v1settings_agent_context.py`, `src/deepgram/agent/v1/requests/agent_v1settings_agent.py`, `src/deepgram/agent/v1/requests/agent_v1settings.py` — backward-compat patches for the 2026-05-05 Agent Settings schema restructure. These preserve callable `AgentV1SettingsAgent(...)`, keep `AgentV1Settings.agent` accepting both that wrapper and `agent_id` strings, restore the legacy request TypedDict shapes, remap legacy `messages=[...]` / nested `context=AgentV1SettingsAgentContext(messages=[...])` usage into the new `context={"messages": [...]}` wire shape, and keep read-side `obj.messages` access working. - `src/deepgram/core/api_error.py`, `src/deepgram/core/parse_error.py` — credential redaction. Every websocket `connect()` path raises `ApiError(headers=dict(headers), ...)` with the full request headers, and both error types stringify that dict, so an unredacted `Authorization` reached `str(e)`, tracebacks, log aggregators and error trackers (which serialise attributes as well as the message). Both now mask credential values at construction via `_secure_logging.redact_sensitive_headers`, preserving non-sensitive headers (`dg-request-id`) for debugging. This is the same threat `_secure_logging.py` covers for the `websockets` DEBUG handshake logs, via the other path to it. Regression coverage in `tests/custom/test_api_error_redaction.py`. Unfreeze if the generator starts redacting credentials itself. - `src/deepgram/core/query_encoder.py` — coerces Python bools to lowercase `"true"`/`"false"` before they reach `urllib.parse.urlencode` (which would otherwise produce `"True"`/`"False"` via `str()` and break websocket query strings). Only the four `*/connect()` paths call `urlencode`; HTTP raw clients hand params to httpx, which lowercases bools itself, so the patch is a no-op for the HTTP path. Once Fern's websocket codegen normalizes bools (or the spec types these as `boolean` end-to-end), this can be unfrozen. -- `src/deepgram/core/unchecked_base_model.py` — read-side compatibility shim for the SDK 7.7 Listen V2 response retype. Through 7.6, `V2SocketClientResponse` contained `typing.Any`, so every response was returned as a raw dict; fixing the union made responses typed models and broke callers using `response["field"]`. A hand-added read-only `__getitem__` preserves wire-key subscript access alongside canonical attribute access, including nested models and unknown fields, while omitted keys retain dict-style `KeyError` behavior. Remove and unfreeze in the next major release. +- `src/deepgram/listen/v2/types/listen_v2connected.py`, `src/deepgram/listen/v2/types/listen_v2turn_info.py`, `src/deepgram/listen/v2/types/listen_v2turn_info_words_item.py`, `src/deepgram/listen/v2/types/listen_v2configure_success.py`, `src/deepgram/listen/v2/types/listen_v2configure_success_thresholds.py`, `src/deepgram/listen/v2/types/listen_v2configure_failure.py`, `src/deepgram/listen/v2/types/listen_v2fatal_error.py` — read-side compatibility for the SDK 7.7 Listen V2 response retype. Through 7.6, `V2SocketClientResponse` contained `typing.Any`, so every response was returned as a raw dict; fixing the union made responses typed models and broke callers using `response["field"]`. These generated response classes inherit the hand-written mixin above, preserving wire-key subscript access alongside canonical attribute access while emitting a `DeprecationWarning` once per callsite under Python's normal warning filter. Remove the inheritance and unfreeze these files in the next major release. - `src/deepgram/types/deepgram_listen_provider_v2.py`, `src/deepgram/agent/v1/types/agent_v1settings_agent_listen_provider.py`, `src/deepgram/agent/v1/types/agent_v1settings_agent_context_listen_provider.py` — behavioural back-compat shim for the `language_hint` -> `language_hints` rename (2026-06-15 regen). The public field was historically (incorrectly) singular and accepted a str or a list; the API field is `language_hints` (a list, and the server uses `deny_unknown_fields` so the singular key is rejected on the wire). Each carries a hand-added `model_validator(mode='before')` / `root_validator(pre=True)` that remaps a legacy `language_hint=` kwarg and drops the dead singular key. Remove and unfreeze when the singular alias is retired in a future major. - `src/deepgram/agent/v1/types/agent_v1update_listen_listen.py` — backward-compat patch for the 2026-07-31 `AgentV1UpdateListen` provider retype. The `provider` field changed from a bare `DeepgramListenProviderV2` to the required discriminated union `AgentV1UpdateListenListenProvider` (`_V1`/`_V2`, discriminant `version`). Carries a hand-added `model_validator(mode='before')` / `root_validator(pre=True)` that coerces a legacy `DeepgramListenProviderV1`/`V2` (or a dict lacking the `version` discriminant) into the new shape so existing callers keep working. Remove and unfreeze when the old provider payloads are retired in a future major. NOTE: this patch was silently lost once (it was absent from `.fernignore`, so a regen overwrote it) — keep it frozen. - `tests/wire/test_manage_v1_projects_keys.py` — restored wire coverage for the legacy `CreateKeyV1RequestOneParams` request alias so future regens do not silently drop that compatibility check diff --git a/src/deepgram/core/unchecked_base_model.py b/src/deepgram/core/unchecked_base_model.py index 13bf7980..c5deec1f 100644 --- a/src/deepgram/core/unchecked_base_model.py +++ b/src/deepgram/core/unchecked_base_model.py @@ -65,14 +65,6 @@ class UncheckedBaseModel(UniversalBaseModel): class Config: extra = pydantic.Extra.allow - def __getitem__(self, key: str) -> typing.Any: - """Provide read-only dict-style access using the original wire keys.""" - if IS_PYDANTIC_V2: - values = self.model_dump(by_alias=True, exclude_unset=True) - else: - values = self.dict(by_alias=True, exclude_unset=True) - return values[key] - @classmethod def model_construct( cls: typing.Type["Model"], diff --git a/src/deepgram/listen/v2/types/_dict_compat.py b/src/deepgram/listen/v2/types/_dict_compat.py new file mode 100644 index 00000000..f4b16ab8 --- /dev/null +++ b/src/deepgram/listen/v2/types/_dict_compat.py @@ -0,0 +1,20 @@ +import typing +import warnings + +from ....core.pydantic_utilities import IS_PYDANTIC_V2 + + +class ListenV2ResponseDictCompatMixin: + def __getitem__(self, key: str) -> typing.Any: + warnings.warn( + "Dictionary-style access to Listen V2 responses is deprecated; " + "use attribute access instead. Dictionary-style access will be removed in SDK 8.", + DeprecationWarning, + stacklevel=2, + ) + model = typing.cast(typing.Any, self) + if IS_PYDANTIC_V2: + values = model.model_dump(by_alias=True, exclude_unset=True) + else: + values = model.dict(by_alias=True, exclude_unset=True) + return values[key] diff --git a/src/deepgram/listen/v2/types/listen_v2configure_failure.py b/src/deepgram/listen/v2/types/listen_v2configure_failure.py index 2d0cb34f..4cb3405d 100644 --- a/src/deepgram/listen/v2/types/listen_v2configure_failure.py +++ b/src/deepgram/listen/v2/types/listen_v2configure_failure.py @@ -5,9 +5,10 @@ import pydantic from ....core.pydantic_utilities import IS_PYDANTIC_V2 from ....core.unchecked_base_model import UncheckedBaseModel +from ._dict_compat import ListenV2ResponseDictCompatMixin -class ListenV2ConfigureFailure(UncheckedBaseModel): +class ListenV2ConfigureFailure(ListenV2ResponseDictCompatMixin, UncheckedBaseModel): type: typing.Literal["ConfigureFailure"] = pydantic.Field(default="ConfigureFailure") """ Message type identifier diff --git a/src/deepgram/listen/v2/types/listen_v2configure_success.py b/src/deepgram/listen/v2/types/listen_v2configure_success.py index ce20724a..ba4c84ea 100644 --- a/src/deepgram/listen/v2/types/listen_v2configure_success.py +++ b/src/deepgram/listen/v2/types/listen_v2configure_success.py @@ -6,10 +6,11 @@ from ....core.pydantic_utilities import IS_PYDANTIC_V2 from ....core.unchecked_base_model import UncheckedBaseModel from ....types.listen_v2keyterm import ListenV2Keyterm +from ._dict_compat import ListenV2ResponseDictCompatMixin from .listen_v2configure_success_thresholds import ListenV2ConfigureSuccessThresholds -class ListenV2ConfigureSuccess(UncheckedBaseModel): +class ListenV2ConfigureSuccess(ListenV2ResponseDictCompatMixin, UncheckedBaseModel): type: typing.Literal["ConfigureSuccess"] = pydantic.Field(default="ConfigureSuccess") """ Message type identifier diff --git a/src/deepgram/listen/v2/types/listen_v2configure_success_thresholds.py b/src/deepgram/listen/v2/types/listen_v2configure_success_thresholds.py index 6c719233..1e499b6c 100644 --- a/src/deepgram/listen/v2/types/listen_v2configure_success_thresholds.py +++ b/src/deepgram/listen/v2/types/listen_v2configure_success_thresholds.py @@ -8,9 +8,10 @@ from ....types.listen_v2eager_eot_threshold import ListenV2EagerEotThreshold from ....types.listen_v2eot_threshold import ListenV2EotThreshold from ....types.listen_v2eot_timeout_ms import ListenV2EotTimeoutMs +from ._dict_compat import ListenV2ResponseDictCompatMixin -class ListenV2ConfigureSuccessThresholds(UncheckedBaseModel): +class ListenV2ConfigureSuccessThresholds(ListenV2ResponseDictCompatMixin, UncheckedBaseModel): """ Updates each parameter, if it is supplied. If a particular threshold parameter is not supplied, the configuration continues using the currently configured value. diff --git a/src/deepgram/listen/v2/types/listen_v2connected.py b/src/deepgram/listen/v2/types/listen_v2connected.py index cefc0236..c2773af0 100644 --- a/src/deepgram/listen/v2/types/listen_v2connected.py +++ b/src/deepgram/listen/v2/types/listen_v2connected.py @@ -5,9 +5,10 @@ import pydantic from ....core.pydantic_utilities import IS_PYDANTIC_V2 from ....core.unchecked_base_model import UncheckedBaseModel +from ._dict_compat import ListenV2ResponseDictCompatMixin -class ListenV2Connected(UncheckedBaseModel): +class ListenV2Connected(ListenV2ResponseDictCompatMixin, UncheckedBaseModel): type: typing.Literal["Connected"] = pydantic.Field(default="Connected") """ Message type identifier diff --git a/src/deepgram/listen/v2/types/listen_v2fatal_error.py b/src/deepgram/listen/v2/types/listen_v2fatal_error.py index c5106f07..600c2e0e 100644 --- a/src/deepgram/listen/v2/types/listen_v2fatal_error.py +++ b/src/deepgram/listen/v2/types/listen_v2fatal_error.py @@ -5,9 +5,10 @@ import pydantic from ....core.pydantic_utilities import IS_PYDANTIC_V2 from ....core.unchecked_base_model import UncheckedBaseModel +from ._dict_compat import ListenV2ResponseDictCompatMixin -class ListenV2FatalError(UncheckedBaseModel): +class ListenV2FatalError(ListenV2ResponseDictCompatMixin, UncheckedBaseModel): type: typing.Literal["Error"] = pydantic.Field(default="Error") """ Message type identifier diff --git a/src/deepgram/listen/v2/types/listen_v2turn_info.py b/src/deepgram/listen/v2/types/listen_v2turn_info.py index dd60abf5..a6745cad 100644 --- a/src/deepgram/listen/v2/types/listen_v2turn_info.py +++ b/src/deepgram/listen/v2/types/listen_v2turn_info.py @@ -5,11 +5,12 @@ import pydantic from ....core.pydantic_utilities import IS_PYDANTIC_V2 from ....core.unchecked_base_model import UncheckedBaseModel +from ._dict_compat import ListenV2ResponseDictCompatMixin from .listen_v2turn_info_event import ListenV2TurnInfoEvent from .listen_v2turn_info_words_item import ListenV2TurnInfoWordsItem -class ListenV2TurnInfo(UncheckedBaseModel): +class ListenV2TurnInfo(ListenV2ResponseDictCompatMixin, UncheckedBaseModel): """ Describes the current turn and latest state of the turn """ diff --git a/src/deepgram/listen/v2/types/listen_v2turn_info_words_item.py b/src/deepgram/listen/v2/types/listen_v2turn_info_words_item.py index 07ada673..40f9563c 100644 --- a/src/deepgram/listen/v2/types/listen_v2turn_info_words_item.py +++ b/src/deepgram/listen/v2/types/listen_v2turn_info_words_item.py @@ -5,9 +5,10 @@ import pydantic from ....core.pydantic_utilities import IS_PYDANTIC_V2 from ....core.unchecked_base_model import UncheckedBaseModel +from ._dict_compat import ListenV2ResponseDictCompatMixin -class ListenV2TurnInfoWordsItem(UncheckedBaseModel): +class ListenV2TurnInfoWordsItem(ListenV2ResponseDictCompatMixin, UncheckedBaseModel): word: str = pydantic.Field() """ The individual punctuated, properly-cased word from the transcript diff --git a/tests/custom/test_model_dict_compat.py b/tests/custom/test_model_dict_compat.py index 22d544c1..c0bc5f75 100644 --- a/tests/custom/test_model_dict_compat.py +++ b/tests/custom/test_model_dict_compat.py @@ -2,15 +2,23 @@ Listen V2 responses were raw dictionaries through SDK 7.6 because the response union contained ``typing.Any``. SDK 7.7 fixed deserialization to return typed -models, which broke callers using the observed dictionary interface. Typed -models now support both attribute and subscript access during that transition. +models, which broke callers using the observed dictionary interface. Listen V2 +response models now support both attribute and deprecated subscript access +during that transition. """ import json +import typing +import warnings import pytest from deepgram.listen.v2.socket_client import AsyncV2SocketClient, V2SocketClient +from deepgram.listen.v2.types.listen_v2configure_failure import ListenV2ConfigureFailure +from deepgram.listen.v2.types.listen_v2configure_success import ListenV2ConfigureSuccess +from deepgram.listen.v2.types.listen_v2configure_success_thresholds import ListenV2ConfigureSuccessThresholds +from deepgram.listen.v2.types.listen_v2connected import ListenV2Connected +from deepgram.listen.v2.types.listen_v2fatal_error import ListenV2FatalError from deepgram.listen.v2.types.listen_v2turn_info import ListenV2TurnInfo from deepgram.types.get_model_v1response_metadata import GetModelV1ResponseMetadata @@ -43,31 +51,66 @@ def _assert_attribute_and_subscript_access(message: object) -> None: assert isinstance(message, ListenV2TurnInfo) assert message.transcript == "hello" - assert message["transcript"] == "hello" - assert message.words[0].confidence == 0.96 - assert message.words[0]["confidence"] == 0.96 - assert message["words"][0]["confidence"] == 0.96 - assert message["future_field"] == "preserved" - with pytest.raises(KeyError): - message["trigger"] + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + assert message["transcript"] == "hello" + assert message.words[0]["confidence"] == 0.96 + assert message["words"][0]["confidence"] == 0.96 + assert message["future_field"] == "preserved" + with pytest.raises(KeyError): + message["trigger"] def test_sync_listen_v2_response_supports_both_access_styles() -> None: - message = V2SocketClient(websocket=_FakeWebSocket()).recv() + message = V2SocketClient(websocket=typing.cast(typing.Any, _FakeWebSocket())).recv() _assert_attribute_and_subscript_access(message) async def test_async_listen_v2_response_supports_both_access_styles() -> None: - message = await AsyncV2SocketClient(websocket=_FakeAsyncWebSocket()).recv() + message = await AsyncV2SocketClient(websocket=typing.cast(typing.Any, _FakeAsyncWebSocket())).recv() _assert_attribute_and_subscript_access(message) -def test_subscript_access_uses_wire_aliases() -> None: +def test_all_listen_v2_response_models_support_subscript_access() -> None: + configure_success = ListenV2ConfigureSuccess( + type="ConfigureSuccess", + request_id="request-id", + thresholds=ListenV2ConfigureSuccessThresholds(eot_threshold=0.7), + keyterms=[], + sequence_id=2, + ) + responses: typing.List[typing.Any] = [ + ListenV2Connected(type="Connected", request_id="request-id", sequence_id=0), + ListenV2ConfigureFailure(type="ConfigureFailure", request_id="request-id", sequence_id=1), + configure_success, + ListenV2FatalError(type="Error", sequence_id=3, code="ERROR", description="failure"), + ] + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + for response in responses: + assert response["type"] == response.type + assert configure_success.thresholds["eot_threshold"] == 0.7 + + +def test_subscript_access_warns_once_per_callsite() -> None: + message = V2SocketClient(websocket=typing.cast(typing.Any, _FakeWebSocket())).recv() + assert isinstance(message, ListenV2TurnInfo) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("default", DeprecationWarning) + for _ in range(3): + assert message["transcript"] == "hello" + + assert len(caught) == 1 + assert "will be removed in SDK 8" in str(caught[0].message) + + +def test_unrelated_models_do_not_gain_subscript_access() -> None: metadata = GetModelV1ResponseMetadata(uuid="model-id") assert metadata.uuid_ == "model-id" - assert metadata["uuid"] == "model-id" - with pytest.raises(KeyError): - metadata["uuid_"] + with pytest.raises(TypeError, match="not subscriptable"): + metadata["uuid"] # type: ignore[index] From 8a18e3bbea68f649772f2bf3b6aa993e2c596661 Mon Sep 17 00:00:00 2001 From: Greg Holmes Date: Fri, 21 Aug 2026 17:15:07 +0100 Subject: [PATCH 3/4] fix: restore listen v2 mapping behavior --- .fernignore | 8 +- AGENTS.md | 4 +- src/deepgram/listen/v2/types/_dict_compat.py | 83 +++++++++++++++++-- .../v2/types/listen_v2configure_failure.py | 5 +- .../v2/types/listen_v2configure_success.py | 5 +- .../listen_v2configure_success_thresholds.py | 5 +- .../listen/v2/types/listen_v2connected.py | 5 +- .../listen/v2/types/listen_v2fatal_error.py | 5 +- .../listen/v2/types/listen_v2turn_info.py | 5 +- .../v2/types/listen_v2turn_info_words_item.py | 5 +- tests/custom/test_model_dict_compat.py | 30 +++++++ 11 files changed, 127 insertions(+), 33 deletions(-) diff --git a/.fernignore b/.fernignore index cfc5270b..a5cb785e 100644 --- a/.fernignore +++ b/.fernignore @@ -14,8 +14,8 @@ src/deepgram/client.py # key / access token) in DEBUG handshake logs. No Fern-generated counterpart. src/deepgram/_secure_logging.py -# Hand-written read-side compatibility mixin for Listen V2 response models. -# Provides deprecated, read-only wire-key subscript access during the SDK 7.x +# Hand-written read-side compatibility base for Listen V2 response models. +# Provides deprecated, read-only Mapping access during the SDK 7.x # transition from raw dict responses to typed models. No Fern counterpart. src/deepgram/listen/v2/types/_dict_compat.py @@ -152,8 +152,8 @@ src/deepgram/core/parse_error.py src/deepgram/core/query_encoder.py # Read-side compatibility for the SDK 7.7 Listen V2 response retype. These -# generated response models inherit the hand-written mixin above so callers can -# temporarily use both attribute and wire-key subscript access. Remove the mixin +# generated response models inherit the hand-written base above so callers can +# temporarily use both attribute and read-only mapping access. Remove the custom # inheritance and unfreeze these files in the next major release. # [temporarily frozen -- manual patch described above] src/deepgram/listen/v2/types/listen_v2connected.py diff --git a/AGENTS.md b/AGENTS.md index cc8b2082..6733b3a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ How to identify: Current permanently frozen files: - `src/deepgram/client.py` — entirely custom (Bearer auth, session ID, `transport_factory`, `reconnect` parity flag); no Fern equivalent - `src/deepgram/_secure_logging.py` — hand-written security utility that installs a `logging.Filter` on the `websockets` client/server loggers to mask the `Authorization` header in DEBUG handshake logs; called from `client.py`; no Fern equivalent -- `src/deepgram/listen/v2/types/_dict_compat.py` — hand-written read-side compatibility mixin for Listen V2 response models. It provides deprecated, read-only wire-key subscript access during the SDK 7.x transition from raw dict responses to typed models; no Fern counterpart. Remove in the next major release after removing its inheritance from the temporarily frozen response models below. +- `src/deepgram/listen/v2/types/_dict_compat.py` — hand-written read-side compatibility base for Listen V2 response models. It provides deprecated, read-only `Mapping` access during the SDK 7.x transition from raw dict responses to typed models; no Fern counterpart. Remove in the next major release after restoring the temporarily frozen response models below to inherit `UncheckedBaseModel` directly. - `src/deepgram/helpers/` — hand-written TextBuilder helpers - `src/deepgram/agent/v1/types/agent_v1history_content.py`, `src/deepgram/agent/v1/types/agent_v1history_function_calls.py`, `src/deepgram/agent/v1/types/agent_v1settings_agent_context_messages_item.py`, `src/deepgram/agent/v1/types/agent_v1settings_agent_context_messages_item_content.py`, `src/deepgram/agent/v1/types/agent_v1settings_agent_context_messages_item_content_role.py`, `src/deepgram/agent/v1/types/agent_v1settings_agent_context_messages_item_function_calls.py`, `src/deepgram/agent/v1/types/agent_v1settings_agent_context_messages_item_function_calls_function_calls_item.py` — hand-written compatibility aliases preserving old public Agent History type imports after regen renames - `src/deepgram/agent/v1/requests/agent_v1history_content.py`, `src/deepgram/agent/v1/requests/agent_v1history_function_calls.py`, `src/deepgram/agent/v1/requests/agent_v1settings_agent_context_messages_item.py`, `src/deepgram/agent/v1/requests/agent_v1settings_agent_context_messages_item_content.py`, `src/deepgram/agent/v1/requests/agent_v1settings_agent_context_messages_item_function_calls.py`, `src/deepgram/agent/v1/requests/agent_v1settings_agent_context_messages_item_function_calls_function_calls_item.py` — hand-written compatibility aliases preserving old public Agent History request-param imports after regen renames @@ -65,7 +65,7 @@ Current temporarily frozen files: - `src/deepgram/agent/v1/types/agent_v1settings_agent_context.py`, `src/deepgram/agent/v1/types/agent_v1settings_agent.py`, `src/deepgram/agent/v1/types/agent_v1settings.py`, `src/deepgram/agent/v1/requests/agent_v1settings_agent_context.py`, `src/deepgram/agent/v1/requests/agent_v1settings_agent.py`, `src/deepgram/agent/v1/requests/agent_v1settings.py` — backward-compat patches for the 2026-05-05 Agent Settings schema restructure. These preserve callable `AgentV1SettingsAgent(...)`, keep `AgentV1Settings.agent` accepting both that wrapper and `agent_id` strings, restore the legacy request TypedDict shapes, remap legacy `messages=[...]` / nested `context=AgentV1SettingsAgentContext(messages=[...])` usage into the new `context={"messages": [...]}` wire shape, and keep read-side `obj.messages` access working. - `src/deepgram/core/api_error.py`, `src/deepgram/core/parse_error.py` — credential redaction. Every websocket `connect()` path raises `ApiError(headers=dict(headers), ...)` with the full request headers, and both error types stringify that dict, so an unredacted `Authorization` reached `str(e)`, tracebacks, log aggregators and error trackers (which serialise attributes as well as the message). Both now mask credential values at construction via `_secure_logging.redact_sensitive_headers`, preserving non-sensitive headers (`dg-request-id`) for debugging. This is the same threat `_secure_logging.py` covers for the `websockets` DEBUG handshake logs, via the other path to it. Regression coverage in `tests/custom/test_api_error_redaction.py`. Unfreeze if the generator starts redacting credentials itself. - `src/deepgram/core/query_encoder.py` — coerces Python bools to lowercase `"true"`/`"false"` before they reach `urllib.parse.urlencode` (which would otherwise produce `"True"`/`"False"` via `str()` and break websocket query strings). Only the four `*/connect()` paths call `urlencode`; HTTP raw clients hand params to httpx, which lowercases bools itself, so the patch is a no-op for the HTTP path. Once Fern's websocket codegen normalizes bools (or the spec types these as `boolean` end-to-end), this can be unfrozen. -- `src/deepgram/listen/v2/types/listen_v2connected.py`, `src/deepgram/listen/v2/types/listen_v2turn_info.py`, `src/deepgram/listen/v2/types/listen_v2turn_info_words_item.py`, `src/deepgram/listen/v2/types/listen_v2configure_success.py`, `src/deepgram/listen/v2/types/listen_v2configure_success_thresholds.py`, `src/deepgram/listen/v2/types/listen_v2configure_failure.py`, `src/deepgram/listen/v2/types/listen_v2fatal_error.py` — read-side compatibility for the SDK 7.7 Listen V2 response retype. Through 7.6, `V2SocketClientResponse` contained `typing.Any`, so every response was returned as a raw dict; fixing the union made responses typed models and broke callers using `response["field"]`. These generated response classes inherit the hand-written mixin above, preserving wire-key subscript access alongside canonical attribute access while emitting a `DeprecationWarning` once per callsite under Python's normal warning filter. Remove the inheritance and unfreeze these files in the next major release. +- `src/deepgram/listen/v2/types/listen_v2connected.py`, `src/deepgram/listen/v2/types/listen_v2turn_info.py`, `src/deepgram/listen/v2/types/listen_v2turn_info_words_item.py`, `src/deepgram/listen/v2/types/listen_v2configure_success.py`, `src/deepgram/listen/v2/types/listen_v2configure_success_thresholds.py`, `src/deepgram/listen/v2/types/listen_v2configure_failure.py`, `src/deepgram/listen/v2/types/listen_v2fatal_error.py` — read-side compatibility for the SDK 7.7 Listen V2 response retype. Through 7.6, `V2SocketClientResponse` contained `typing.Any`, so every response was returned as a raw dict; fixing the union made responses typed models and broke callers using dictionary operations. These generated response classes inherit the hand-written base above, preserving read-only `Mapping` behavior alongside canonical attribute access while emitting a `DeprecationWarning` once per callsite under Python's normal warning filter. Restore direct `UncheckedBaseModel` inheritance and unfreeze these files in the next major release. - `src/deepgram/types/deepgram_listen_provider_v2.py`, `src/deepgram/agent/v1/types/agent_v1settings_agent_listen_provider.py`, `src/deepgram/agent/v1/types/agent_v1settings_agent_context_listen_provider.py` — behavioural back-compat shim for the `language_hint` -> `language_hints` rename (2026-06-15 regen). The public field was historically (incorrectly) singular and accepted a str or a list; the API field is `language_hints` (a list, and the server uses `deny_unknown_fields` so the singular key is rejected on the wire). Each carries a hand-added `model_validator(mode='before')` / `root_validator(pre=True)` that remaps a legacy `language_hint=` kwarg and drops the dead singular key. Remove and unfreeze when the singular alias is retired in a future major. - `src/deepgram/agent/v1/types/agent_v1update_listen_listen.py` — backward-compat patch for the 2026-07-31 `AgentV1UpdateListen` provider retype. The `provider` field changed from a bare `DeepgramListenProviderV2` to the required discriminated union `AgentV1UpdateListenListenProvider` (`_V1`/`_V2`, discriminant `version`). Carries a hand-added `model_validator(mode='before')` / `root_validator(pre=True)` that coerces a legacy `DeepgramListenProviderV1`/`V2` (or a dict lacking the `version` discriminant) into the new shape so existing callers keep working. Remove and unfreeze when the old provider payloads are retired in a future major. NOTE: this patch was silently lost once (it was absent from `.fernignore`, so a regen overwrote it) — keep it frozen. - `tests/wire/test_manage_v1_projects_keys.py` — restored wire coverage for the legacy `CreateKeyV1RequestOneParams` request alias so future regens do not silently drop that compatibility check diff --git a/src/deepgram/listen/v2/types/_dict_compat.py b/src/deepgram/listen/v2/types/_dict_compat.py index f4b16ab8..db59f3b9 100644 --- a/src/deepgram/listen/v2/types/_dict_compat.py +++ b/src/deepgram/listen/v2/types/_dict_compat.py @@ -1,20 +1,91 @@ +import collections.abc +import functools import typing import warnings from ....core.pydantic_utilities import IS_PYDANTIC_V2 +from ....core.unchecked_base_model import UncheckedBaseModel -class ListenV2ResponseDictCompatMixin: - def __getitem__(self, key: str) -> typing.Any: +@functools.lru_cache(maxsize=None) +def _wire_key_to_field_name(model_type: typing.Type[typing.Any]) -> typing.Dict[str, str]: + fields = model_type.model_fields if IS_PYDANTIC_V2 else model_type.__fields__ + return {typing.cast(str, field.alias or name): name for name, field in fields.items()} + + +class ListenV2ResponseDictCompatModel( # type: ignore[misc] + UncheckedBaseModel, collections.abc.Mapping[str, typing.Any] +): + @staticmethod + def _warn_deprecated() -> None: warnings.warn( "Dictionary-style access to Listen V2 responses is deprecated; " "use attribute access instead. Dictionary-style access will be removed in SDK 8.", DeprecationWarning, - stacklevel=2, + stacklevel=3, ) + + def _iter_wire_keys(self) -> typing.Iterator[str]: model = typing.cast(typing.Any, self) + field_names = _wire_key_to_field_name(type(self)) + fields_set = model.model_fields_set if IS_PYDANTIC_V2 else model.__fields_set__ + + for wire_key, field_name in field_names.items(): + if field_name in fields_set: + yield wire_key + if IS_PYDANTIC_V2: - values = model.model_dump(by_alias=True, exclude_unset=True) + yield from (model.__pydantic_extra__ or {}).keys() else: - values = model.dict(by_alias=True, exclude_unset=True) - return values[key] + known_field_names = set(field_names.values()) + yield from ( + key for key in model.__dict__ if key in fields_set and key not in known_field_names + ) + + def __getitem__(self, key: str) -> typing.Any: + self._warn_deprecated() + return self._dict_compat_get_value(key) + + def _dict_compat_get_value(self, key: str) -> typing.Any: + model = typing.cast(typing.Any, self) + field_name = _wire_key_to_field_name(type(self)).get(key) + fields_set = model.model_fields_set if IS_PYDANTIC_V2 else model.__fields_set__ + + if field_name is not None: + if field_name not in fields_set: + raise KeyError(key) + return getattr(model, field_name) + + if IS_PYDANTIC_V2: + extras = model.__pydantic_extra__ or {} + if key in extras: + return extras[key] + elif key in fields_set and key in model.__dict__: + return model.__dict__[key] + + raise KeyError(key) + + def __contains__(self, key: object) -> bool: + self._warn_deprecated() + if not isinstance(key, str): + return False + try: + self._dict_compat_get_value(key) + except KeyError: + return False + return True + + def get(self, key: str, default: typing.Any = None) -> typing.Any: + self._warn_deprecated() + try: + return self._dict_compat_get_value(key) + except KeyError: + return default + + def __iter__(self) -> typing.Iterator[str]: # type: ignore[override] + self._warn_deprecated() + return self._iter_wire_keys() + + def __len__(self) -> int: + self._warn_deprecated() + return sum(1 for _ in self._iter_wire_keys()) diff --git a/src/deepgram/listen/v2/types/listen_v2configure_failure.py b/src/deepgram/listen/v2/types/listen_v2configure_failure.py index 4cb3405d..bf30e353 100644 --- a/src/deepgram/listen/v2/types/listen_v2configure_failure.py +++ b/src/deepgram/listen/v2/types/listen_v2configure_failure.py @@ -4,11 +4,10 @@ import pydantic from ....core.pydantic_utilities import IS_PYDANTIC_V2 -from ....core.unchecked_base_model import UncheckedBaseModel -from ._dict_compat import ListenV2ResponseDictCompatMixin +from ._dict_compat import ListenV2ResponseDictCompatModel -class ListenV2ConfigureFailure(ListenV2ResponseDictCompatMixin, UncheckedBaseModel): +class ListenV2ConfigureFailure(ListenV2ResponseDictCompatModel): type: typing.Literal["ConfigureFailure"] = pydantic.Field(default="ConfigureFailure") """ Message type identifier diff --git a/src/deepgram/listen/v2/types/listen_v2configure_success.py b/src/deepgram/listen/v2/types/listen_v2configure_success.py index ba4c84ea..bde84d6f 100644 --- a/src/deepgram/listen/v2/types/listen_v2configure_success.py +++ b/src/deepgram/listen/v2/types/listen_v2configure_success.py @@ -4,13 +4,12 @@ import pydantic from ....core.pydantic_utilities import IS_PYDANTIC_V2 -from ....core.unchecked_base_model import UncheckedBaseModel from ....types.listen_v2keyterm import ListenV2Keyterm -from ._dict_compat import ListenV2ResponseDictCompatMixin +from ._dict_compat import ListenV2ResponseDictCompatModel from .listen_v2configure_success_thresholds import ListenV2ConfigureSuccessThresholds -class ListenV2ConfigureSuccess(ListenV2ResponseDictCompatMixin, UncheckedBaseModel): +class ListenV2ConfigureSuccess(ListenV2ResponseDictCompatModel): type: typing.Literal["ConfigureSuccess"] = pydantic.Field(default="ConfigureSuccess") """ Message type identifier diff --git a/src/deepgram/listen/v2/types/listen_v2configure_success_thresholds.py b/src/deepgram/listen/v2/types/listen_v2configure_success_thresholds.py index 1e499b6c..e0353ec9 100644 --- a/src/deepgram/listen/v2/types/listen_v2configure_success_thresholds.py +++ b/src/deepgram/listen/v2/types/listen_v2configure_success_thresholds.py @@ -4,14 +4,13 @@ import pydantic from ....core.pydantic_utilities import IS_PYDANTIC_V2 -from ....core.unchecked_base_model import UncheckedBaseModel from ....types.listen_v2eager_eot_threshold import ListenV2EagerEotThreshold from ....types.listen_v2eot_threshold import ListenV2EotThreshold from ....types.listen_v2eot_timeout_ms import ListenV2EotTimeoutMs -from ._dict_compat import ListenV2ResponseDictCompatMixin +from ._dict_compat import ListenV2ResponseDictCompatModel -class ListenV2ConfigureSuccessThresholds(ListenV2ResponseDictCompatMixin, UncheckedBaseModel): +class ListenV2ConfigureSuccessThresholds(ListenV2ResponseDictCompatModel): """ Updates each parameter, if it is supplied. If a particular threshold parameter is not supplied, the configuration continues using the currently configured value. diff --git a/src/deepgram/listen/v2/types/listen_v2connected.py b/src/deepgram/listen/v2/types/listen_v2connected.py index c2773af0..b253d86c 100644 --- a/src/deepgram/listen/v2/types/listen_v2connected.py +++ b/src/deepgram/listen/v2/types/listen_v2connected.py @@ -4,11 +4,10 @@ import pydantic from ....core.pydantic_utilities import IS_PYDANTIC_V2 -from ....core.unchecked_base_model import UncheckedBaseModel -from ._dict_compat import ListenV2ResponseDictCompatMixin +from ._dict_compat import ListenV2ResponseDictCompatModel -class ListenV2Connected(ListenV2ResponseDictCompatMixin, UncheckedBaseModel): +class ListenV2Connected(ListenV2ResponseDictCompatModel): type: typing.Literal["Connected"] = pydantic.Field(default="Connected") """ Message type identifier diff --git a/src/deepgram/listen/v2/types/listen_v2fatal_error.py b/src/deepgram/listen/v2/types/listen_v2fatal_error.py index 600c2e0e..2202c82e 100644 --- a/src/deepgram/listen/v2/types/listen_v2fatal_error.py +++ b/src/deepgram/listen/v2/types/listen_v2fatal_error.py @@ -4,11 +4,10 @@ import pydantic from ....core.pydantic_utilities import IS_PYDANTIC_V2 -from ....core.unchecked_base_model import UncheckedBaseModel -from ._dict_compat import ListenV2ResponseDictCompatMixin +from ._dict_compat import ListenV2ResponseDictCompatModel -class ListenV2FatalError(ListenV2ResponseDictCompatMixin, UncheckedBaseModel): +class ListenV2FatalError(ListenV2ResponseDictCompatModel): type: typing.Literal["Error"] = pydantic.Field(default="Error") """ Message type identifier diff --git a/src/deepgram/listen/v2/types/listen_v2turn_info.py b/src/deepgram/listen/v2/types/listen_v2turn_info.py index a6745cad..d8fd72b9 100644 --- a/src/deepgram/listen/v2/types/listen_v2turn_info.py +++ b/src/deepgram/listen/v2/types/listen_v2turn_info.py @@ -4,13 +4,12 @@ import pydantic from ....core.pydantic_utilities import IS_PYDANTIC_V2 -from ....core.unchecked_base_model import UncheckedBaseModel -from ._dict_compat import ListenV2ResponseDictCompatMixin +from ._dict_compat import ListenV2ResponseDictCompatModel from .listen_v2turn_info_event import ListenV2TurnInfoEvent from .listen_v2turn_info_words_item import ListenV2TurnInfoWordsItem -class ListenV2TurnInfo(ListenV2ResponseDictCompatMixin, UncheckedBaseModel): +class ListenV2TurnInfo(ListenV2ResponseDictCompatModel): """ Describes the current turn and latest state of the turn """ diff --git a/src/deepgram/listen/v2/types/listen_v2turn_info_words_item.py b/src/deepgram/listen/v2/types/listen_v2turn_info_words_item.py index 40f9563c..88240cd9 100644 --- a/src/deepgram/listen/v2/types/listen_v2turn_info_words_item.py +++ b/src/deepgram/listen/v2/types/listen_v2turn_info_words_item.py @@ -4,11 +4,10 @@ import pydantic from ....core.pydantic_utilities import IS_PYDANTIC_V2 -from ....core.unchecked_base_model import UncheckedBaseModel -from ._dict_compat import ListenV2ResponseDictCompatMixin +from ._dict_compat import ListenV2ResponseDictCompatModel -class ListenV2TurnInfoWordsItem(ListenV2ResponseDictCompatMixin, UncheckedBaseModel): +class ListenV2TurnInfoWordsItem(ListenV2ResponseDictCompatModel): word: str = pydantic.Field() """ The individual punctuated, properly-cased word from the transcript diff --git a/tests/custom/test_model_dict_compat.py b/tests/custom/test_model_dict_compat.py index c0bc5f75..71883d4c 100644 --- a/tests/custom/test_model_dict_compat.py +++ b/tests/custom/test_model_dict_compat.py @@ -10,6 +10,7 @@ import json import typing import warnings +from collections.abc import Mapping import pytest @@ -108,6 +109,35 @@ def test_subscript_access_warns_once_per_callsite() -> None: assert "will be removed in SDK 8" in str(caught[0].message) +def test_read_only_mapping_helpers_match_dict_behavior() -> None: + message = V2SocketClient(websocket=typing.cast(typing.Any, _FakeWebSocket())).recv() + assert isinstance(message, ListenV2TurnInfo) + turn_info = typing.cast(ListenV2TurnInfo, message) + assert isinstance(turn_info, Mapping) + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + assert "transcript" in turn_info + assert "future_field" in turn_info + assert "trigger" not in turn_info + assert turn_info.get("transcript") == "hello" + assert turn_info.get("trigger", "missing") == "missing" + assert "transcript" in turn_info.keys() + assert dict(turn_info)["transcript"] == "hello" + assert isinstance(turn_info.words[0], Mapping) + + +def test_subscript_access_emits_no_unrelated_warnings() -> None: + message = V2SocketClient(websocket=typing.cast(typing.Any, _FakeWebSocket())).recv() + assert isinstance(message, ListenV2TurnInfo) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + assert message["transcript"] == "hello" + + assert [warning.category for warning in caught] == [DeprecationWarning] + + def test_unrelated_models_do_not_gain_subscript_access() -> None: metadata = GetModelV1ResponseMetadata(uuid="model-id") From aac5e0a5ad84665bc8c46e1ee829320c52553801 Mon Sep 17 00:00:00 2001 From: Greg Holmes Date: Fri, 21 Aug 2026 17:34:55 +0100 Subject: [PATCH 4/4] fix: narrow listen v2 compatibility to subscript access --- .fernignore | 8 +- AGENTS.md | 6 +- src/deepgram/listen/v2/types/_dict_compat.py | 79 ++------------------ tests/custom/test_model_dict_compat.py | 70 +++-------------- 4 files changed, 25 insertions(+), 138 deletions(-) diff --git a/.fernignore b/.fernignore index a5cb785e..6fda5042 100644 --- a/.fernignore +++ b/.fernignore @@ -15,7 +15,7 @@ src/deepgram/client.py src/deepgram/_secure_logging.py # Hand-written read-side compatibility base for Listen V2 response models. -# Provides deprecated, read-only Mapping access during the SDK 7.x +# Provides read-only wire-key subscript access during the SDK 7.x # transition from raw dict responses to typed models. No Fern counterpart. src/deepgram/listen/v2/types/_dict_compat.py @@ -24,8 +24,8 @@ src/deepgram/listen/v2/types/_dict_compat.py # - _sanitize_numeric_types in agent socket client (float→int for API) # - optional message param on control send_ methods (send_keep_alive, send_close_stream, etc.) # so users don't need to instantiate the type themselves for no-payload control messages -# - listen/v2 send_configure: typing.Any / raw _send shim (generator's ListenV2Configure model -# and ListenV2ConfigureSuccess not used) +# - listen/v2 send_configure: runtime tolerance for a raw dict alongside the +# generated ListenV2Configure model # [temporarily frozen — manual patches listed above] src/deepgram/agent/v1/socket_client.py src/deepgram/listen/v1/socket_client.py @@ -153,7 +153,7 @@ src/deepgram/core/query_encoder.py # Read-side compatibility for the SDK 7.7 Listen V2 response retype. These # generated response models inherit the hand-written base above so callers can -# temporarily use both attribute and read-only mapping access. Remove the custom +# temporarily use both attribute and read-only subscript access. Remove the custom # inheritance and unfreeze these files in the next major release. # [temporarily frozen -- manual patch described above] src/deepgram/listen/v2/types/listen_v2connected.py diff --git a/AGENTS.md b/AGENTS.md index 6733b3a7..e274f8dc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ How to identify: Current permanently frozen files: - `src/deepgram/client.py` — entirely custom (Bearer auth, session ID, `transport_factory`, `reconnect` parity flag); no Fern equivalent - `src/deepgram/_secure_logging.py` — hand-written security utility that installs a `logging.Filter` on the `websockets` client/server loggers to mask the `Authorization` header in DEBUG handshake logs; called from `client.py`; no Fern equivalent -- `src/deepgram/listen/v2/types/_dict_compat.py` — hand-written read-side compatibility base for Listen V2 response models. It provides deprecated, read-only `Mapping` access during the SDK 7.x transition from raw dict responses to typed models; no Fern counterpart. Remove in the next major release after restoring the temporarily frozen response models below to inherit `UncheckedBaseModel` directly. +- `src/deepgram/listen/v2/types/_dict_compat.py` — hand-written read-side compatibility base for Listen V2 response models. It provides read-only wire-key subscript access during the SDK 7.x transition from raw dict responses to typed models; no Fern counterpart. Remove in the next major release after restoring the temporarily frozen response models below to inherit `UncheckedBaseModel` directly. - `src/deepgram/helpers/` — hand-written TextBuilder helpers - `src/deepgram/agent/v1/types/agent_v1history_content.py`, `src/deepgram/agent/v1/types/agent_v1history_function_calls.py`, `src/deepgram/agent/v1/types/agent_v1settings_agent_context_messages_item.py`, `src/deepgram/agent/v1/types/agent_v1settings_agent_context_messages_item_content.py`, `src/deepgram/agent/v1/types/agent_v1settings_agent_context_messages_item_content_role.py`, `src/deepgram/agent/v1/types/agent_v1settings_agent_context_messages_item_function_calls.py`, `src/deepgram/agent/v1/types/agent_v1settings_agent_context_messages_item_function_calls_function_calls_item.py` — hand-written compatibility aliases preserving old public Agent History type imports after regen renames - `src/deepgram/agent/v1/requests/agent_v1history_content.py`, `src/deepgram/agent/v1/requests/agent_v1history_function_calls.py`, `src/deepgram/agent/v1/requests/agent_v1settings_agent_context_messages_item.py`, `src/deepgram/agent/v1/requests/agent_v1settings_agent_context_messages_item_content.py`, `src/deepgram/agent/v1/requests/agent_v1settings_agent_context_messages_item_function_calls.py`, `src/deepgram/agent/v1/requests/agent_v1settings_agent_context_messages_item_function_calls_function_calls_item.py` — hand-written compatibility aliases preserving old public Agent History request-param imports after regen renames @@ -35,7 +35,7 @@ Current permanently frozen files: - `src/deepgram/transport_interface.py`, `src/deepgram/transport.py`, `src/deepgram/transports/` — custom transport layer - `tests/custom/test_agent_history.py` — hand-written regression test for Agent History websocket payload parsing - `tests/custom/test_compat_aliases.py` — hand-written regression test for backward-compatible alias imports after regen renames -- `tests/custom/test_model_dict_compat.py` — hand-written regression coverage for the deprecated read-only dictionary-access compatibility shim on typed Listen V2 response models, including sync/async parsing, every response variant, nested objects, unknown fields, omitted-key behavior, warning frequency, and scope isolation from unrelated generated models +- `tests/custom/test_model_dict_compat.py` — hand-written regression coverage for the read-only subscript compatibility shim on typed Listen V2 response models, including sync/async parsing, every response variant, nested objects, unknown fields, omitted-key behavior, and scope isolation from unrelated generated models - `tests/custom/test_query_encoder.py` — hand-written regression test that `core/query_encoder.py` coerces Python bools to lowercase `"true"`/`"false"` before `urlencode` so websocket query strings stay wire-correct - `tests/custom/test_secure_logging.py` — hand-written regression test that the `websockets` Authorization-header DEBUG logs are redacted (API key never logged in clear text) - `tests/custom/test_speak_v2_interrupt_configure.py` — hand-written coverage for the Speak V2 barge-in / mid-stream reconfigure surface (`send_interrupt`, `send_configure`, `SpeechInterrupted`, `ConfigureSuccess`/`ConfigureFailure`, and the `speed`/`expressivity` connect params) @@ -65,7 +65,7 @@ Current temporarily frozen files: - `src/deepgram/agent/v1/types/agent_v1settings_agent_context.py`, `src/deepgram/agent/v1/types/agent_v1settings_agent.py`, `src/deepgram/agent/v1/types/agent_v1settings.py`, `src/deepgram/agent/v1/requests/agent_v1settings_agent_context.py`, `src/deepgram/agent/v1/requests/agent_v1settings_agent.py`, `src/deepgram/agent/v1/requests/agent_v1settings.py` — backward-compat patches for the 2026-05-05 Agent Settings schema restructure. These preserve callable `AgentV1SettingsAgent(...)`, keep `AgentV1Settings.agent` accepting both that wrapper and `agent_id` strings, restore the legacy request TypedDict shapes, remap legacy `messages=[...]` / nested `context=AgentV1SettingsAgentContext(messages=[...])` usage into the new `context={"messages": [...]}` wire shape, and keep read-side `obj.messages` access working. - `src/deepgram/core/api_error.py`, `src/deepgram/core/parse_error.py` — credential redaction. Every websocket `connect()` path raises `ApiError(headers=dict(headers), ...)` with the full request headers, and both error types stringify that dict, so an unredacted `Authorization` reached `str(e)`, tracebacks, log aggregators and error trackers (which serialise attributes as well as the message). Both now mask credential values at construction via `_secure_logging.redact_sensitive_headers`, preserving non-sensitive headers (`dg-request-id`) for debugging. This is the same threat `_secure_logging.py` covers for the `websockets` DEBUG handshake logs, via the other path to it. Regression coverage in `tests/custom/test_api_error_redaction.py`. Unfreeze if the generator starts redacting credentials itself. - `src/deepgram/core/query_encoder.py` — coerces Python bools to lowercase `"true"`/`"false"` before they reach `urllib.parse.urlencode` (which would otherwise produce `"True"`/`"False"` via `str()` and break websocket query strings). Only the four `*/connect()` paths call `urlencode`; HTTP raw clients hand params to httpx, which lowercases bools itself, so the patch is a no-op for the HTTP path. Once Fern's websocket codegen normalizes bools (or the spec types these as `boolean` end-to-end), this can be unfrozen. -- `src/deepgram/listen/v2/types/listen_v2connected.py`, `src/deepgram/listen/v2/types/listen_v2turn_info.py`, `src/deepgram/listen/v2/types/listen_v2turn_info_words_item.py`, `src/deepgram/listen/v2/types/listen_v2configure_success.py`, `src/deepgram/listen/v2/types/listen_v2configure_success_thresholds.py`, `src/deepgram/listen/v2/types/listen_v2configure_failure.py`, `src/deepgram/listen/v2/types/listen_v2fatal_error.py` — read-side compatibility for the SDK 7.7 Listen V2 response retype. Through 7.6, `V2SocketClientResponse` contained `typing.Any`, so every response was returned as a raw dict; fixing the union made responses typed models and broke callers using dictionary operations. These generated response classes inherit the hand-written base above, preserving read-only `Mapping` behavior alongside canonical attribute access while emitting a `DeprecationWarning` once per callsite under Python's normal warning filter. Restore direct `UncheckedBaseModel` inheritance and unfreeze these files in the next major release. +- `src/deepgram/listen/v2/types/listen_v2connected.py`, `src/deepgram/listen/v2/types/listen_v2turn_info.py`, `src/deepgram/listen/v2/types/listen_v2turn_info_words_item.py`, `src/deepgram/listen/v2/types/listen_v2configure_success.py`, `src/deepgram/listen/v2/types/listen_v2configure_success_thresholds.py`, `src/deepgram/listen/v2/types/listen_v2configure_failure.py`, `src/deepgram/listen/v2/types/listen_v2fatal_error.py` — read-side compatibility for the SDK 7.7 Listen V2 response retype. Through 7.6, `V2SocketClientResponse` contained `typing.Any`, so every response was returned as a raw dict; fixing the union made responses typed models and broke callers using `response["field"]`. These generated response classes inherit the hand-written base above, preserving read-only wire-key subscript access alongside canonical attribute access. Restore direct `UncheckedBaseModel` inheritance and unfreeze these files in the next major release. - `src/deepgram/types/deepgram_listen_provider_v2.py`, `src/deepgram/agent/v1/types/agent_v1settings_agent_listen_provider.py`, `src/deepgram/agent/v1/types/agent_v1settings_agent_context_listen_provider.py` — behavioural back-compat shim for the `language_hint` -> `language_hints` rename (2026-06-15 regen). The public field was historically (incorrectly) singular and accepted a str or a list; the API field is `language_hints` (a list, and the server uses `deny_unknown_fields` so the singular key is rejected on the wire). Each carries a hand-added `model_validator(mode='before')` / `root_validator(pre=True)` that remaps a legacy `language_hint=` kwarg and drops the dead singular key. Remove and unfreeze when the singular alias is retired in a future major. - `src/deepgram/agent/v1/types/agent_v1update_listen_listen.py` — backward-compat patch for the 2026-07-31 `AgentV1UpdateListen` provider retype. The `provider` field changed from a bare `DeepgramListenProviderV2` to the required discriminated union `AgentV1UpdateListenListenProvider` (`_V1`/`_V2`, discriminant `version`). Carries a hand-added `model_validator(mode='before')` / `root_validator(pre=True)` that coerces a legacy `DeepgramListenProviderV1`/`V2` (or a dict lacking the `version` discriminant) into the new shape so existing callers keep working. Remove and unfreeze when the old provider payloads are retired in a future major. NOTE: this patch was silently lost once (it was absent from `.fernignore`, so a regen overwrote it) — keep it frozen. - `tests/wire/test_manage_v1_projects_keys.py` — restored wire coverage for the legacy `CreateKeyV1RequestOneParams` request alias so future regens do not silently drop that compatibility check diff --git a/src/deepgram/listen/v2/types/_dict_compat.py b/src/deepgram/listen/v2/types/_dict_compat.py index db59f3b9..1f793d49 100644 --- a/src/deepgram/listen/v2/types/_dict_compat.py +++ b/src/deepgram/listen/v2/types/_dict_compat.py @@ -1,60 +1,20 @@ -import collections.abc -import functools import typing -import warnings from ....core.pydantic_utilities import IS_PYDANTIC_V2 from ....core.unchecked_base_model import UncheckedBaseModel -@functools.lru_cache(maxsize=None) -def _wire_key_to_field_name(model_type: typing.Type[typing.Any]) -> typing.Dict[str, str]: - fields = model_type.model_fields if IS_PYDANTIC_V2 else model_type.__fields__ - return {typing.cast(str, field.alias or name): name for name, field in fields.items()} - - -class ListenV2ResponseDictCompatModel( # type: ignore[misc] - UncheckedBaseModel, collections.abc.Mapping[str, typing.Any] -): - @staticmethod - def _warn_deprecated() -> None: - warnings.warn( - "Dictionary-style access to Listen V2 responses is deprecated; " - "use attribute access instead. Dictionary-style access will be removed in SDK 8.", - DeprecationWarning, - stacklevel=3, - ) - - def _iter_wire_keys(self) -> typing.Iterator[str]: - model = typing.cast(typing.Any, self) - field_names = _wire_key_to_field_name(type(self)) - fields_set = model.model_fields_set if IS_PYDANTIC_V2 else model.__fields_set__ - - for wire_key, field_name in field_names.items(): - if field_name in fields_set: - yield wire_key - - if IS_PYDANTIC_V2: - yield from (model.__pydantic_extra__ or {}).keys() - else: - known_field_names = set(field_names.values()) - yield from ( - key for key in model.__dict__ if key in fields_set and key not in known_field_names - ) - +class ListenV2ResponseDictCompatModel(UncheckedBaseModel): def __getitem__(self, key: str) -> typing.Any: - self._warn_deprecated() - return self._dict_compat_get_value(key) - - def _dict_compat_get_value(self, key: str) -> typing.Any: model = typing.cast(typing.Any, self) - field_name = _wire_key_to_field_name(type(self)).get(key) + fields = type(model).model_fields if IS_PYDANTIC_V2 else type(model).__fields__ fields_set = model.model_fields_set if IS_PYDANTIC_V2 else model.__fields_set__ - if field_name is not None: - if field_name not in fields_set: - raise KeyError(key) - return getattr(model, field_name) + for field_name, field in fields.items(): + if (field.alias or field_name) == key: + if field_name not in fields_set: + raise KeyError(key) + return getattr(model, field_name) if IS_PYDANTIC_V2: extras = model.__pydantic_extra__ or {} @@ -64,28 +24,3 @@ def _dict_compat_get_value(self, key: str) -> typing.Any: return model.__dict__[key] raise KeyError(key) - - def __contains__(self, key: object) -> bool: - self._warn_deprecated() - if not isinstance(key, str): - return False - try: - self._dict_compat_get_value(key) - except KeyError: - return False - return True - - def get(self, key: str, default: typing.Any = None) -> typing.Any: - self._warn_deprecated() - try: - return self._dict_compat_get_value(key) - except KeyError: - return default - - def __iter__(self) -> typing.Iterator[str]: # type: ignore[override] - self._warn_deprecated() - return self._iter_wire_keys() - - def __len__(self) -> int: - self._warn_deprecated() - return sum(1 for _ in self._iter_wire_keys()) diff --git a/tests/custom/test_model_dict_compat.py b/tests/custom/test_model_dict_compat.py index 71883d4c..93b215bf 100644 --- a/tests/custom/test_model_dict_compat.py +++ b/tests/custom/test_model_dict_compat.py @@ -3,14 +3,12 @@ Listen V2 responses were raw dictionaries through SDK 7.6 because the response union contained ``typing.Any``. SDK 7.7 fixed deserialization to return typed models, which broke callers using the observed dictionary interface. Listen V2 -response models now support both attribute and deprecated subscript access -during that transition. +response models now support both attribute and subscript access during that +transition. """ import json import typing -import warnings -from collections.abc import Mapping import pytest @@ -54,14 +52,12 @@ def _assert_attribute_and_subscript_access(message: object) -> None: assert message.transcript == "hello" assert message.words[0].confidence == 0.96 - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) - assert message["transcript"] == "hello" - assert message.words[0]["confidence"] == 0.96 - assert message["words"][0]["confidence"] == 0.96 - assert message["future_field"] == "preserved" - with pytest.raises(KeyError): - message["trigger"] + assert message["transcript"] == "hello" + assert message.words[0]["confidence"] == 0.96 + assert message["words"][0]["confidence"] == 0.96 + assert message["future_field"] == "preserved" + with pytest.raises(KeyError): + message["languages"] def test_sync_listen_v2_response_supports_both_access_styles() -> None: @@ -89,53 +85,9 @@ def test_all_listen_v2_response_models_support_subscript_access() -> None: ListenV2FatalError(type="Error", sequence_id=3, code="ERROR", description="failure"), ] - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) - for response in responses: - assert response["type"] == response.type - assert configure_success.thresholds["eot_threshold"] == 0.7 - - -def test_subscript_access_warns_once_per_callsite() -> None: - message = V2SocketClient(websocket=typing.cast(typing.Any, _FakeWebSocket())).recv() - assert isinstance(message, ListenV2TurnInfo) - - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("default", DeprecationWarning) - for _ in range(3): - assert message["transcript"] == "hello" - - assert len(caught) == 1 - assert "will be removed in SDK 8" in str(caught[0].message) - - -def test_read_only_mapping_helpers_match_dict_behavior() -> None: - message = V2SocketClient(websocket=typing.cast(typing.Any, _FakeWebSocket())).recv() - assert isinstance(message, ListenV2TurnInfo) - turn_info = typing.cast(ListenV2TurnInfo, message) - assert isinstance(turn_info, Mapping) - - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) - assert "transcript" in turn_info - assert "future_field" in turn_info - assert "trigger" not in turn_info - assert turn_info.get("transcript") == "hello" - assert turn_info.get("trigger", "missing") == "missing" - assert "transcript" in turn_info.keys() - assert dict(turn_info)["transcript"] == "hello" - assert isinstance(turn_info.words[0], Mapping) - - -def test_subscript_access_emits_no_unrelated_warnings() -> None: - message = V2SocketClient(websocket=typing.cast(typing.Any, _FakeWebSocket())).recv() - assert isinstance(message, ListenV2TurnInfo) - - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always") - assert message["transcript"] == "hello" - - assert [warning.category for warning in caught] == [DeprecationWarning] + for response in responses: + assert response["type"] == response.type + assert configure_success.thresholds["eot_threshold"] == 0.7 def test_unrelated_models_do_not_gain_subscript_access() -> None: