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()