From 4179366ed0944da72e351994465f06bff4f73055 Mon Sep 17 00:00:00 2001 From: Kayvan Zahiri Date: Mon, 17 Aug 2026 16:40:19 -0700 Subject: [PATCH] fix: resolve DEEPGRAM_API_KEY when the client is constructed The generated base client takes os.getenv("DEEPGRAM_API_KEY") as a default argument, so Python evaluates it once at import. The idiomatic layout of imports at the top and load_dotenv() below them therefore fails: the default is captured as None before the key exists, and DeepgramClient() raises even though the variable is set by the time it is called. client.py is permanently frozen in .fernignore, so the re-read goes there rather than in the generated file. Closes #734 --- .fernignore | 1 + AGENTS.md | 1 + src/deepgram/client.py | 11 +++++ tests/custom/test_api_key_env_resolution.py | 54 +++++++++++++++++++++ 4 files changed, 67 insertions(+) create mode 100644 tests/custom/test_api_key_env_resolution.py diff --git a/.fernignore b/.fernignore index 3c9cd00d..df2ee03b 100644 --- a/.fernignore +++ b/.fernignore @@ -150,6 +150,7 @@ src/deepgram/core/query_encoder.py tests/custom/test_api_error_redaction.py tests/custom/test_agent_history.py tests/custom/test_agent_update_listen.py +tests/custom/test_api_key_env_resolution.py tests/custom/test_client.py tests/custom/test_client_construction_and_helpers.py tests/custom/test_compat_aliases.py diff --git a/AGENTS.md b/AGENTS.md index 807310be..d8ccb18a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,6 +33,7 @@ Current permanently frozen files: - `src/deepgram/listen/v2/types/listen_v2close_stream_type.py` — hand-written shim recreating `ListenV2CloseStreamType`, which Fern removed in the 2026-06-15 regen (docs #946). The original generated type wrongly allowed `Union[Literal["Finalize","CloseStream","KeepAlive"], Any]` (v2 copied v1's control-message enum); a CloseStream message's `type` can only ever be `"CloseStream"`. Recreated as the corrected `Literal["CloseStream"]` to preserve the public import path without resurrecting the invalid values. Re-exported from the three `listen` `__init__.py` files (temporarily frozen, below). - `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_api_key_env_resolution.py` — hand-written regression test that `DEEPGRAM_API_KEY` is resolved when the client is constructed rather than when the module is imported. The generated base client takes `os.getenv(...)` as a default argument, which Python evaluates once at import, so `load_dotenv()` placed below the imports left the default captured as `None` (issue #734). `client.py` re-reads the variable; this pins that, plus explicit-key and `access_token` precedence and the no-key-anywhere error - `tests/custom/test_compat_aliases.py` — hand-written regression test for backward-compatible alias imports after regen renames - `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) diff --git a/src/deepgram/client.py b/src/deepgram/client.py index 5d0383f1..014fc2f2 100644 --- a/src/deepgram/client.py +++ b/src/deepgram/client.py @@ -22,6 +22,7 @@ reconnect logic. """ +import os import types import uuid from typing import Any, Callable, Dict, Optional @@ -114,6 +115,11 @@ def __init__(self, *args, **kwargs) -> None: # Set a placeholder api_key if none provided (base client requires it) if kwargs.get("api_key") is None: kwargs["api_key"] = "token" + elif kwargs.get("api_key") is None: + # The generated base client takes os.getenv("DEEPGRAM_API_KEY") as a default + # argument, so it is read once at import. Re-read it here so a key set after + # import (load_dotenv below the imports) is still picked up. + kwargs["api_key"] = os.getenv("DEEPGRAM_API_KEY") super().__init__(*args, **kwargs) self.session_id = final_session_id @@ -193,6 +199,11 @@ def __init__(self, *args, **kwargs) -> None: # Set a placeholder api_key if none provided (base client requires it) if kwargs.get("api_key") is None: kwargs["api_key"] = "token" + elif kwargs.get("api_key") is None: + # The generated base client takes os.getenv("DEEPGRAM_API_KEY") as a default + # argument, so it is read once at import. Re-read it here so a key set after + # import (load_dotenv below the imports) is still picked up. + kwargs["api_key"] = os.getenv("DEEPGRAM_API_KEY") super().__init__(*args, **kwargs) self.session_id = final_session_id diff --git a/tests/custom/test_api_key_env_resolution.py b/tests/custom/test_api_key_env_resolution.py new file mode 100644 index 00000000..abe61e7e --- /dev/null +++ b/tests/custom/test_api_key_env_resolution.py @@ -0,0 +1,54 @@ +""" +``DEEPGRAM_API_KEY`` is read when the client is constructed, not when the module +is imported. + +The generated base client takes ``os.getenv("DEEPGRAM_API_KEY")`` as a default +argument, which Python evaluates once at import. These tests pin the custom +client's re-read so the common ``load_dotenv()``-below-the-imports layout keeps +working (issue #734). +""" + +import typing + +import pytest + +from deepgram import AsyncDeepgramClient, DeepgramClient +from deepgram.core.api_error import ApiError + + +@pytest.fixture +def no_env_key(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("DEEPGRAM_API_KEY", raising=False) + + +def _auth(client: typing.Any) -> typing.Optional[str]: + return client._client_wrapper.api_key + + +def test_env_key_set_after_import_is_used( + no_env_key: None, monkeypatch: pytest.MonkeyPatch +) -> None: + """The import already happened above with no key set; this is the load_dotenv case.""" + monkeypatch.setenv("DEEPGRAM_API_KEY", "set-after-import") + assert _auth(DeepgramClient()) == "set-after-import" + assert _auth(AsyncDeepgramClient()) == "set-after-import" + + +def test_explicit_api_key_beats_the_environment(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("DEEPGRAM_API_KEY", "from-env") + assert _auth(DeepgramClient(api_key="explicit")) == "explicit" + assert _auth(AsyncDeepgramClient(api_key="explicit")) == "explicit" + + +def test_access_token_still_takes_precedence(monkeypatch: pytest.MonkeyPatch) -> None: + """access_token callers get the placeholder, not the environment key.""" + monkeypatch.setenv("DEEPGRAM_API_KEY", "from-env") + assert _auth(DeepgramClient(access_token="tok")) == "token" + assert _auth(AsyncDeepgramClient(access_token="tok")) == "token" + + +def test_no_key_anywhere_still_raises(no_env_key: None) -> None: + with pytest.raises(ApiError): + DeepgramClient() + with pytest.raises(ApiError): + AsyncDeepgramClient()