diff --git a/assemblyai/__version__.py b/assemblyai/__version__.py index 70c5d8e..fa72dac 100644 --- a/assemblyai/__version__.py +++ b/assemblyai/__version__.py @@ -1 +1 @@ -__version__ = "0.67.00" +__version__ = "0.68.00" diff --git a/assemblyai/async_client.py b/assemblyai/async_client.py index 76382d4..a0acf4e 100644 --- a/assemblyai/async_client.py +++ b/assemblyai/async_client.py @@ -5,7 +5,7 @@ from typing_extensions import Self from . import types -from .client import _build_headers, _build_limits +from .client import _MISSING_API_KEY_ERROR, _build_headers, _build_limits class AsyncClient: @@ -32,24 +32,30 @@ class AsyncClient: def __init__( self, *, - settings: types.Settings, + settings: Optional[types.Settings] = None, + api_key: Optional[str] = None, api_key_required: bool = True, ) -> None: """ Creates the asyncio AssemblyAI client. Args: - settings: The settings to use for the client. + settings: The settings to use for the client. If `None` is given, the global + settings are used. The client holds a copy, so the given settings object + is never modified. + api_key: The API key to authenticate with. Overrides the key on `settings`. api_key_required: If an API key is required (either as environment variable or the global settings). Can be set to `False` if a different authentication method is used, e.g., a temporary token. """ + from . import settings as default_settings - self._settings = settings.copy() + self._settings = (settings if settings is not None else default_settings).copy() + + if api_key is not None: + self._settings.api_key = api_key if api_key_required and not self._settings.api_key: - raise ValueError( - "Please provide an API key via the ASSEMBLYAI_API_KEY environment variable or the global settings." - ) + raise ValueError(_MISSING_API_KEY_ERROR) self._last_response: Optional[httpx.Response] = None @@ -111,3 +117,35 @@ async def __aexit__( traceback: Optional[TracebackType], ) -> None: await self.aclose() + + +def _resolve_client( + client: Optional[AsyncClient], + api_key: Optional[str], +) -> AsyncClient: + """ + Returns the client a transcriber sends its requests with. + + `api_key` takes precedence. A client's credentials are baked into its + connection pool when it is built, so a key given alongside a `client` + derives a new client from a copy of that client's settings rather than + changing anything on it. The given client is left untouched and unused. + + A client built here is owned by the transcriber, which closes it. Only the + caller's `client`, passed without an `api_key`, stays the caller's to close. + + Args: + client: An explicit `AsyncClient`, or `None`. + api_key: An API key, or `None`. + """ + + if client is not None: + if api_key is not None: + # `AsyncClient.__init__` copies the settings it is given. + return AsyncClient(settings=client.settings, api_key=api_key) + + return client + + from . import settings as default_settings + + return AsyncClient(settings=default_settings, api_key=api_key) diff --git a/assemblyai/client.py b/assemblyai/client.py index 62f570f..2d180d8 100644 --- a/assemblyai/client.py +++ b/assemblyai/client.py @@ -36,6 +36,12 @@ def _build_limits(settings: types.Settings) -> httpx.Limits: ) +_MISSING_API_KEY_ERROR = ( + "Please provide an API key: set the ASSEMBLYAI_API_KEY environment variable, " + "set aai.settings.api_key, or pass api_key= to the transcriber or client." +) + + class Client: _default: ClassVar[Optional["Client"]] = None _lock: ClassVar[threading.Lock] = threading.Lock() @@ -43,24 +49,30 @@ class Client: def __init__( self, *, - settings: types.Settings, + settings: Optional[types.Settings] = None, + api_key: Optional[str] = None, api_key_required: bool = True, ) -> None: """ Creates the AssemblyAI client. Args: - settings: The settings to use for the client. + settings: The settings to use for the client. If `None` is given, the global + settings are used. The client holds a copy, so the given settings object + is never modified. + api_key: The API key to authenticate with. Overrides the key on `settings`. api_key_required: If an API key is required (either as environment variable or the global settings). Can be set to `False` if a different authentication method is used, e.g., a temporary token. """ + from . import settings as default_settings + + self._settings = (settings if settings is not None else default_settings).copy() - self._settings = settings.copy() + if api_key is not None: + self._settings.api_key = api_key if api_key_required and not self._settings.api_key: - raise ValueError( - "Please provide an API key via the ASSEMBLYAI_API_KEY environment variable or the global settings." - ) + raise ValueError(_MISSING_API_KEY_ERROR) self._last_response: Optional[httpx.Response] = None @@ -128,3 +140,36 @@ def get_default(cls, api_key_required: bool = True): ) return cls._default + + +def _resolve_client( + client: Optional[Client], + api_key: Optional[str], +) -> Client: + """ + Returns the client a transcriber sends its requests with. + + `api_key` takes precedence. A client's credentials are baked into its + connection pool when it is built, so a key given alongside a `client` + derives a new client from a copy of that client's settings rather than + changing anything on it. The given client is left untouched and unused. + + A client returned here is the transcriber's own except when it is the + caller's `client` passed without an `api_key`. + + Args: + client: An explicit `Client`, or `None`. + api_key: An API key, or `None`. + """ + + if client is not None: + if api_key is not None: + # `Client.__init__` copies the settings it is given. + return Client(settings=client.settings, api_key=api_key) + + return client + + if api_key is not None: + return Client(api_key=api_key) + + return Client.get_default() diff --git a/assemblyai/prerecorded/v2/async_client.py b/assemblyai/prerecorded/v2/async_client.py index d12021d..6f34f6a 100644 --- a/assemblyai/prerecorded/v2/async_client.py +++ b/assemblyai/prerecorded/v2/async_client.py @@ -170,6 +170,7 @@ def __init__( *, client: Optional[_async_client.AsyncClient] = None, config: Optional[types.TranscriptionConfig] = None, + api_key: Optional[str] = None, ) -> None: """ Initializes the `AsyncTranscriber` with the given parameters. @@ -181,11 +182,15 @@ def __init__( transcribers. config: The default configuration for the `AsyncTranscriber`. If `None`, a default `TranscriptionConfig` is used. + api_key: The API key to authenticate with. The transcriber builds + its own `AsyncClient` from it and closes that client on + `aclose()`. Given alongside `client`, it takes precedence: the + transcriber builds and owns a client made from a copy of that + client's settings with the key replaced, and the given client is + left untouched and stays the caller's to close. """ - from ... import settings as default_settings - - self._owns_client = client is None - self._client = client or _async_client.AsyncClient(settings=default_settings) + self._owns_client = client is None or api_key is not None + self._client = _async_client._resolve_client(client, api_key) self.config = config or types.TranscriptionConfig() @property diff --git a/assemblyai/prerecorded/v2/client.py b/assemblyai/prerecorded/v2/client.py index 81d2279..071d88f 100644 --- a/assemblyai/prerecorded/v2/client.py +++ b/assemblyai/prerecorded/v2/client.py @@ -179,6 +179,7 @@ def __init__( client: Optional[_client.Client] = None, config: Optional[types.TranscriptionConfig] = None, max_workers: Optional[int] = None, + api_key: Optional[str] = None, ) -> None: """ Initializes the `Transcriber` with the given parameters. @@ -190,6 +191,11 @@ def __init__( the default configuration of a `TranscriptionConfig` will be used. `max_workers`: The maximum number of parallel jobs when using the `_async` methods on the `Transcriber`. By default it uses `os.cpu_count() - 1` + `api_key`: The API key to authenticate with. Builds a `Client` for this + `Transcriber`. Given alongside `client`, it takes precedence: the + `Transcriber` builds its own client from a copy of that client's + settings with the key replaced, and the given client is left + untouched. Example: To use the `Transcriber` with the default settings, you can simply do: @@ -204,7 +210,7 @@ def __init__( transcriber = aai.Transcriber(config=config) ``` """ - self._client = client or _client.Client.get_default() + self._client = _client._resolve_client(client, api_key) self._impl = _TranscriberImpl( client=self._client, diff --git a/assemblyai/streaming/v3/_base.py b/assemblyai/streaming/v3/_base.py index a76b244..7fd155d 100644 --- a/assemblyai/streaming/v3/_base.py +++ b/assemblyai/streaming/v3/_base.py @@ -173,6 +173,46 @@ def _build_headers(options: StreamingClientOptions) -> Dict[str, Optional[str]]: } +def _resolve_options( + options: Optional[StreamingClientOptions], + api_key: Optional[str], +) -> StreamingClientOptions: + """Returns the options a streaming client is configured with. + + ``api_key`` takes precedence: given alongside ``options``, it replaces + the key on a copy of ``options`` and every other field is carried over + as the caller set it. The caller's ``options`` object is never mutated. + + Args: + ``options``: an explicit ``StreamingClientOptions``, or ``None``. + Returned as-is when no ``api_key`` accompanies it. + ``api_key``: an API key, or ``None``. On its own it builds options + with every other field left at its default. + + Raises: + ValueError: if neither is given. + """ + + if options is not None: + if api_key is not None: + # pydantic v2 renamed `copy(update=...)` to `model_copy(update=...)`. + if hasattr(options, "model_copy"): + return options.model_copy(update={"api_key": api_key}) + + return options.copy(update={"api_key": api_key}) + + return options + + if api_key is not None: + return StreamingClientOptions(api_key=api_key) + + raise ValueError( + "Please provide credentials: pass api_key= to the client, or pass " + "options=StreamingClientOptions(api_key=...) — or " + "options=StreamingClientOptions(token=...) for a temporary token." + ) + + class _BaseStreamingClient: """Sync/async-agnostic core for streaming clients. diff --git a/assemblyai/streaming/v3/async_client.py b/assemblyai/streaming/v3/async_client.py index 03f3ef0..e959cb8 100644 --- a/assemblyai/streaming/v3/async_client.py +++ b/assemblyai/streaming/v3/async_client.py @@ -31,6 +31,7 @@ _dump_model_json, _emit_param_warnings, _normalize_min_turn_silence, + _resolve_options, _user_agent, ) from .models import ( @@ -94,7 +95,28 @@ class AsyncStreamingClient(_BaseStreamingClient): raises. """ - def __init__(self, options: StreamingClientOptions): + def __init__( + self, + options: Optional[StreamingClientOptions] = None, + *, + api_key: Optional[str] = None, + ): + """Create an asyncio streaming client. + + Args: + ``options``: the full client configuration — credentials, host, + timeouts, retries. + ``api_key``: the API key to authenticate with. On its own it + builds ``StreamingClientOptions`` with every other option left + at its default. Passed alongside ``options`` it takes + precedence, replacing the key while every other field is + carried over; the ``options`` object itself is left untouched. + + Raises: + ValueError: if neither ``options`` nor ``api_key`` is given. + """ + options = _resolve_options(options, api_key) + super().__init__(options) self._client = _AsyncHTTPClient( diff --git a/assemblyai/streaming/v3/client.py b/assemblyai/streaming/v3/client.py index 64740c3..147c58f 100644 --- a/assemblyai/streaming/v3/client.py +++ b/assemblyai/streaming/v3/client.py @@ -18,6 +18,7 @@ _dump_model_json, _emit_param_warnings, _normalize_min_turn_silence, + _resolve_options, _user_agent, ) from .models import ( @@ -41,7 +42,28 @@ class StreamingClient(_BaseStreamingClient): - def __init__(self, options: StreamingClientOptions): + def __init__( + self, + options: Optional[StreamingClientOptions] = None, + *, + api_key: Optional[str] = None, + ): + """Create a streaming client. + + Args: + ``options``: the full client configuration — credentials, host, + timeouts, retries. + ``api_key``: the API key to authenticate with. On its own it + builds ``StreamingClientOptions`` with every other option left + at its default. Passed alongside ``options`` it takes + precedence, replacing the key while every other field is + carried over; the ``options`` object itself is left untouched. + + Raises: + ValueError: if neither ``options`` nor ``api_key`` is given. + """ + options = _resolve_options(options, api_key) + super().__init__(options) self._client = _HTTPClient(api_host=options.api_host, api_key=options.api_key) diff --git a/assemblyai/sync/v1/async_client.py b/assemblyai/sync/v1/async_client.py index 6925065..36b8cc0 100644 --- a/assemblyai/sync/v1/async_client.py +++ b/assemblyai/sync/v1/async_client.py @@ -70,6 +70,7 @@ def __init__( *, client: Optional[_async_client.AsyncClient] = None, config: Optional[types.SyncTranscriptionConfig] = None, + api_key: Optional[str] = None, ) -> None: """ Creates an `AsyncSyncTranscriber`. @@ -81,11 +82,15 @@ def __init__( transcribers. config: Default transcription options. Per-call `config` overrides it. + api_key: The API key to authenticate with. The transcriber builds + its own `AsyncClient` from it and closes that client on + `aclose()`. Given alongside `client`, it takes precedence: the + transcriber builds and owns a client made from a copy of that + client's settings with the key replaced, and the given client is + left untouched and stays the caller's to close. """ - from ... import settings as default_settings - - self._owns_client = client is None - self._client = client or _async_client.AsyncClient(settings=default_settings) + self._owns_client = client is None or api_key is not None + self._client = _async_client._resolve_client(client, api_key) self.config = config or types.SyncTranscriptionConfig() @property diff --git a/assemblyai/sync/v1/client.py b/assemblyai/sync/v1/client.py index a855651..2d7ca92 100644 --- a/assemblyai/sync/v1/client.py +++ b/assemblyai/sync/v1/client.py @@ -39,6 +39,7 @@ def __init__( client: Optional[_client.Client] = None, config: Optional[types.SyncTranscriptionConfig] = None, max_workers: Optional[int] = None, + api_key: Optional[str] = None, ) -> None: """ Creates a `SyncTranscriber`. @@ -48,8 +49,13 @@ def __init__( config: Default transcription options. Per-call `config` overrides it. max_workers: Thread pool size for `transcribe_async`. Defaults to the CPU count minus one. + api_key: The API key to authenticate with. Builds a `Client` for this + transcriber. Given alongside `client`, it takes precedence: the + transcriber builds its own client from a copy of that client's + settings with the key replaced, and the given client is left + untouched. """ - self._client = client or _client.Client.get_default() + self._client = _client._resolve_client(client, api_key) self._impl = _SyncTranscriberImpl( client=self._client, config=config or types.SyncTranscriptionConfig(), diff --git a/tests/unit/test_dx.py b/tests/unit/test_dx.py new file mode 100644 index 0000000..708f766 --- /dev/null +++ b/tests/unit/test_dx.py @@ -0,0 +1,276 @@ +"""Tests for `api_key=` construction across the client surface. + +Covers what the four transcribers, the two HTTP clients, and the two streaming +clients share: building one from an explicit key, how that interacts with an +explicit `client=`/`options=`, and the guarantee that neither the global +settings nor a caller's own options object is mutated along the way. +""" + +import pytest + +import assemblyai as aai +from assemblyai.streaming.v3 import ( + AsyncStreamingClient, + StreamingClient, + StreamingClientOptions, +) + +aai.settings.api_key = "test" + + +@pytest.fixture +def no_global_api_key(): + """Clears the global API key, so only an explicit `api_key=` can authenticate.""" + + original = aai.settings.api_key + aai.settings.api_key = None + yield + aai.settings.api_key = original + + +# == transcribers and clients: api_key= == + + +def test_transcriber_accepts_api_key(no_global_api_key): + # When constructing a Transcriber with only an explicit key + transcriber = aai.Transcriber(api_key="explicit-key") + + # Then the client authenticates with it and the global settings are untouched + assert transcriber._client.settings.api_key == "explicit-key" + assert aai.settings.api_key is None + + +def test_sync_transcriber_accepts_api_key(no_global_api_key): + transcriber = aai.SyncTranscriber(api_key="explicit-key") + + assert transcriber._client.settings.api_key == "explicit-key" + assert aai.settings.api_key is None + + +@pytest.mark.asyncio +async def test_async_transcriber_accepts_api_key(no_global_api_key): + transcriber = aai.AsyncTranscriber(api_key="explicit-key") + try: + # Then the client authenticates with it and is owned by the transcriber + assert transcriber.client.settings.api_key == "explicit-key" + assert transcriber._owns_client is True + assert aai.settings.api_key is None + finally: + await transcriber.aclose() + + assert transcriber.client.http_client.is_closed + + +@pytest.mark.asyncio +async def test_async_sync_transcriber_accepts_api_key(no_global_api_key): + transcriber = aai.AsyncSyncTranscriber(api_key="explicit-key") + try: + assert transcriber.client.settings.api_key == "explicit-key" + assert transcriber._owns_client is True + assert aai.settings.api_key is None + finally: + await transcriber.aclose() + + assert transcriber.client.http_client.is_closed + + +def _caller_settings() -> aai.Settings: + """Settings with a distinguishable field, to prove they survive a derive.""" + + return aai.Settings(api_key="from-client", http_timeout=42.5) + + +@pytest.mark.parametrize("transcriber_class", [aai.Transcriber, aai.SyncTranscriber]) +def test_api_key_takes_precedence_over_a_given_client(transcriber_class): + caller_client = aai.Client(settings=_caller_settings()) + + transcriber = transcriber_class(client=caller_client, api_key="explicit-key") + + # Then a derived client is used, not the caller's + assert transcriber._client is not caller_client + assert transcriber._client.settings.api_key == "explicit-key" + # And the caller's client is untouched, with its other settings carried over + assert caller_client.settings.api_key == "from-client" + assert transcriber._client.settings.http_timeout == 42.5 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "transcriber_class", [aai.AsyncTranscriber, aai.AsyncSyncTranscriber] +) +async def test_async_api_key_takes_precedence_over_a_given_client(transcriber_class): + caller_client = aai.AsyncClient(settings=_caller_settings()) + + transcriber = transcriber_class(client=caller_client, api_key="explicit-key") + try: + assert transcriber.client is not caller_client + assert transcriber.client.settings.api_key == "explicit-key" + assert caller_client.settings.api_key == "from-client" + assert transcriber.client.settings.http_timeout == 42.5 + # And the derived client is the transcriber's to close + assert transcriber._owns_client is True + finally: + await transcriber.aclose() + + assert transcriber.client.http_client.is_closed + # The caller's client is left open for the caller to close + assert not caller_client.http_client.is_closed + + await caller_client.aclose() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "transcriber_class", [aai.AsyncTranscriber, aai.AsyncSyncTranscriber] +) +async def test_async_client_passed_alone_stays_the_callers(transcriber_class): + async with aai.AsyncClient(settings=aai.settings) as caller_client: + transcriber = transcriber_class(client=caller_client) + + assert transcriber.client is caller_client + assert transcriber._owns_client is False + + await transcriber.aclose() + + # aclose() leaves a client it does not own alone + assert not caller_client.http_client.is_closed + + +def test_client_without_arguments_uses_the_global_settings(): + # When constructing a client with no arguments + client = aai.Client() + + # Then it copies the global settings rather than sharing them + assert client.settings.api_key == aai.settings.api_key + assert client.settings is not aai.settings + + +def test_client_accepts_api_key(no_global_api_key): + # When constructing a client with only an explicit key + client = aai.Client(api_key="explicit-key") + + # Then it authenticates with it and the global settings are untouched + assert client.settings.api_key == "explicit-key" + assert aai.settings.api_key is None + + +def test_async_client_accepts_api_key(no_global_api_key): + client = aai.AsyncClient(api_key="explicit-key") + + assert client.settings.api_key == "explicit-key" + assert aai.settings.api_key is None + + +def test_api_key_overrides_the_key_on_given_settings(): + # Given a settings object of the caller's own + settings = aai.Settings(api_key="from-settings") + + # When a client is built from it with an explicit key + client = aai.Client(settings=settings, api_key="explicit-key") + + # Then the explicit key wins and the caller's settings are unchanged + assert client.settings.api_key == "explicit-key" + assert settings.api_key == "from-settings" + + +def test_missing_api_key_names_every_way_to_provide_one(no_global_api_key): + with pytest.raises(ValueError) as exc_info: + aai.Client() + + message = str(exc_info.value) + assert "ASSEMBLYAI_API_KEY" in message + assert "aai.settings.api_key" in message + assert "api_key=" in message + + +# == streaming clients: api_key= == + + +@pytest.mark.parametrize("client_class", [StreamingClient, AsyncStreamingClient]) +def test_streaming_client_accepts_api_key(client_class): + # When constructing with only an explicit key + client = client_class(api_key="explicit-key") + + # Then the options carry it and every other option keeps its default + defaults = StreamingClientOptions(api_key="explicit-key") + assert client._options.api_key == "explicit-key" + assert client._options.token is None + assert client._options == defaults + + +@pytest.mark.parametrize("client_class", [StreamingClient, AsyncStreamingClient]) +def test_api_key_overrides_the_key_on_options(client_class): + options = StreamingClientOptions(api_key="from-options") + + client = client_class(options, api_key="explicit-key") + + # Then the constructor key wins + assert client._options.api_key == "explicit-key" + + +@pytest.mark.parametrize("client_class", [StreamingClient, AsyncStreamingClient]) +def test_api_key_override_carries_every_other_option(client_class): + options = StreamingClientOptions( + api_key="from-options", + token="temporary-token", + api_host="streaming.example.org", + connect_timeout=2.5, + max_connection_retries=7, + connection_retry_delay=1.25, + terminate_timeout=9.5, + ) + + client = client_class(options, api_key="explicit-key") + + # Then only api_key differs from what the caller set + resolved = client._options + assert resolved.api_key == "explicit-key" + assert resolved.token == "temporary-token" + assert resolved.api_host == "streaming.example.org" + assert resolved.connect_timeout == 2.5 + assert resolved.max_connection_retries == 7 + assert resolved.connection_retry_delay == 1.25 + assert resolved.terminate_timeout == 9.5 + + +@pytest.mark.parametrize("client_class", [StreamingClient, AsyncStreamingClient]) +def test_api_key_override_does_not_mutate_the_callers_options(client_class): + options = StreamingClientOptions(api_key="from-options", connect_timeout=2.5) + + client = client_class(options, api_key="explicit-key") + + # Then the caller's own object is untouched, and a copy was used + assert options.api_key == "from-options" + assert client._options is not options + assert client._options.connect_timeout == 2.5 + + +@pytest.mark.parametrize("client_class", [StreamingClient, AsyncStreamingClient]) +def test_streaming_client_still_accepts_options_only(client_class): + # Given the options a caller builds today, positionally + options = StreamingClientOptions(api_key="from-options", connect_timeout=2.5) + + client = client_class(options) + + # Then the very object passed in is what the client uses + assert client._options is options + assert client._options.connect_timeout == 2.5 + + +@pytest.mark.parametrize("client_class", [StreamingClient, AsyncStreamingClient]) +def test_streaming_client_still_accepts_a_token_in_options(client_class): + client = client_class(StreamingClientOptions(token="temporary-token")) + + assert client._options.token == "temporary-token" + assert client._options.api_key is None + + +@pytest.mark.parametrize("client_class", [StreamingClient, AsyncStreamingClient]) +def test_streaming_client_without_credentials_names_both_fixes(client_class): + with pytest.raises(ValueError) as exc_info: + client_class() + + message = str(exc_info.value) + assert "api_key=" in message + assert "StreamingClientOptions" in message + assert "token=" in message