diff --git a/examples/batch/transcribe_file_sync.py b/examples/batch/transcribe_file_sync.py new file mode 100644 index 00000000..36c9fb80 --- /dev/null +++ b/examples/batch/transcribe_file_sync.py @@ -0,0 +1,101 @@ +""" +Sync example showing batch transcription with the blocking Client. + +No event loop is involved. This also demonstrates synchronous transcription +(the ``wait`` parameter), where the server holds the request open until the +transcript is ready, so one request replaces submit + poll + fetch. +""" + +import os + +from speechmatics.batch import Client +from speechmatics.batch import FormatType +from speechmatics.batch import JobConfig +from speechmatics.batch import JobStatus +from speechmatics.batch import JobType +from speechmatics.batch import Transcript +from speechmatics.batch import TranscriptionConfig + +audio_file = os.getenv("AUDIO_FILE_PATH", os.path.join(os.path.dirname(__file__), "../example1.wav")) + +# Seconds to let the server wait for the job before falling back to polling. +wait_seconds = int(os.getenv("WAIT_SECONDS", "60")) + + +def job_config() -> JobConfig: + return JobConfig( + type=JobType.TRANSCRIPTION, + transcription_config=TranscriptionConfig(language="en", enable_entities=True), + ) + + +def transcribe_in_one_request(client: Client) -> None: + """Submit and get the transcript back in a single request.""" + print("\n1) Synchronous transcription (one request)") + print(f" Submitting {audio_file} with wait={wait_seconds}s") + + job = client.submit_job(audio_file, config=job_config(), wait=wait_seconds) + print(f" Job {job.id} came back with status: {job.status.value}") + + if job.status == JobStatus.DONE and job.transcript is not None: + print(" The transcript was embedded in the submit response, so no further calls were needed.") + print(f" Transcript: {job.transcript.transcript_text}") + elif job.status == JobStatus.DONE: + # Finished, but the response carried no transcript: one extra call. + print(" Job finished but no transcript was embedded; fetching it separately.") + print(f" Transcript: {client.get_transcript(job.id).transcript_text}") + else: + # The wait elapsed before the job finished, which is expected for longer audio. + # No timeout here: how long transcription takes depends on the audio's + # length, so the example doesn't guess at a deadline. + print(f" Still running after {wait_seconds}s, falling back to polling.") + result = client.wait_for_completion(job.id, polling_interval=2.0, timeout=None) + print(f" Transcript: {result.transcript_text}") + + +def transcribe_as_plain_text(client: Client) -> None: + """The same thing in one call, asking for plain text instead of JSON.""" + print("\n2) One call via transcribe(), plain text output") + + text = client.transcribe( + audio_file, + config=job_config(), + format_type=FormatType.TXT, + wait=wait_seconds, + timeout=None, + ) + print(f" Transcript: {text}") + + +def submit_and_poll(client: Client) -> None: + """The classic flow, for comparison: submit, then poll until done.""" + print("\n3) Classic flow: submit, then poll") + + job = client.submit_job(audio_file, config=job_config()) + print(f" Job submitted: {job.id}") + + result = client.wait_for_completion(job.id, polling_interval=2.0, timeout=None) + if isinstance(result, Transcript): + print(f" Transcript: {result.transcript_text}") + else: + print(f" Transcript: {result}") + + +def main() -> None: + """Run sync batch transcription examples.""" + with Client(api_key=os.getenv("SPEECHMATICS_API_KEY")) as client: + try: + transcribe_in_one_request(client) + transcribe_as_plain_text(client) + submit_and_poll(client) + + except FileNotFoundError: + print(f"Audio file not found: {audio_file}") + print("Set AUDIO_FILE_PATH environment variable to specify audio file") + + except Exception as e: + print(f"Transcription failed: {e}") + + +if __name__ == "__main__": + main() diff --git a/sdk/agent_stt/speechmatics/__init__.py b/sdk/agent_stt/speechmatics/__init__.py deleted file mode 100644 index 8db66d3d..00000000 --- a/sdk/agent_stt/speechmatics/__init__.py +++ /dev/null @@ -1 +0,0 @@ -__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/sdk/batch/MIGRATION.md b/sdk/batch/MIGRATION.md index abedf51f..14230015 100644 --- a/sdk/batch/MIGRATION.md +++ b/sdk/batch/MIGRATION.md @@ -1,37 +1,63 @@ # Batch SDK Migration Guide -This guide helps users migrate from the legacy Speechmatics Batch Client (`speechmatics-python`) to the new Speechmatics Batch SDK (`speechmatics-batch`). The new SDK provides a modern async API, improved error handling, and enhanced authentication options with minimal dependencies. +This guide helps you migrate from the legacy Speechmatics Batch Client +(`speechmatics-python`, class `BatchClient`) to the new Speechmatics Batch SDK +(`speechmatics-batch`). + +**Coming from blocking code?** Use `Client` — it is a drop-in-shaped +replacement for `BatchClient`: no `asyncio` required, same synchronous +mental model. `AsyncClient` exposes identical methods with `async`/`await`, +for code that already runs an event loop, but adopting it is optional. + +## Quick reference + +| Legacy (`BatchClient`) | New (`Client` / `AsyncClient`) | Notes | +| --- | --- | --- | +| `BatchClient(auth_token)` | `Client(api_key=...)` / `AsyncClient(api_key=...)` | Also reads `SPEECHMATICS_API_KEY` — see [Credentials](#credentials) | +| `client.connect()` / `with BatchClient(...)` | `Client(...)` used directly, or `with Client(...)` | `connect()` no longer exists or is needed | +| `client.submit_job(audio, transcription_config)` → `str` (job ID) | `client.submit_job(audio, config=...)` → `JobDetails` | Return type changed — use `job.id` | +| `client.submit_jobs(paths, config, concurrency=5)` | No direct equivalent | See [Bulk submission](#bulk-submission) | +| `client.check_job_status(job_id)` → raw `dict` | `client.get_job_info(job_id)` → `JobDetails` | | +| `client.get_job_result(job_id, transcription_format="json-v2")` | `client.get_transcript(job_id, format_type=FormatType.JSON)` → raises `TranscriptNotReadyError` if the job isn't done yet | Catch `TranscriptNotReadyError` to retry — see [Exceptions](#exceptions) | +| `client.wait_for_completion(job_id, transcription_format="txt")` | `client.wait_for_completion(job_id, format_type=FormatType.TXT)` | **Default format changed** — see [Breaking Changes](#breaking-changes) | +| `client.delete_job(job_id, force=False)` → `str` | `client.delete_job(job_id, force=False)` → `None`, raises on failure | | +| `client.list_jobs()` → `List[dict]` | `client.list_jobs()` → `List[JobDetails]` | | -## Significant Changes +## Installation -- **Fully async API**: All operations now use async/await pattern with `AsyncClient` -- **Enhanced authentication**: Support for both API key and JWT authentication methods -- **Better error handling**: More specific exceptions and clearer error messages -- **Lightweight package**: Minimal dependencies for faster installation and reduced conflicts -- **Improved job management**: Better job status tracking and result handling -- **Streamlined configuration**: Unified `JobConfig` for all job types -- **URL and API key configuration**: Allows loading URL and API key from environment variables +``` bash +pip install speechmatics-batch +``` -### Breaking Changes +## Credentials -- **Import paths**: `speechmatics.batch_client` → `speechmatics.batch` -- **Client class**: `BatchClient` → `AsyncClient` -- **All methods**: Synchronous → Asynchronous (requires `await`) -- **Configuration**: `BatchTranscriptionConfig` → `JobConfig` with `TranscriptionConfig` -- **Job submission**: Direct config parameter → Structured `JobConfig` object -- **Result format**: `transcription_format` parameter → `format_type` parameter -- **Authentication**: API key parameter naming changed to `api_key` -- **CLI not available**: CLI will be released as a separate package +Legacy `BatchClient` took an explicit `auth_token`, or fell back to +`~/.speechmatics/config` (a file written by the legacy CLI's +`speechmatics config set`). The new SDK does not read that file — pass +`api_key` explicitly, or set an environment variable. If you configured the +legacy CLI, your token is already on disk: -## Installation +```bash +cat ~/.speechmatics/config # find the auth_token you already have -``` bash -pip install speechmatics-batch +export SPEECHMATICS_API_KEY="" +``` + +```python +from speechmatics.batch import Client + +with Client() as client: # reads SPEECHMATICS_API_KEY + ... ``` +A custom `ConnectionSettings.url` is replaced by the `SPEECHMATICS_BATCH_URL` +environment variable, or `Client(url=...)` / `ConnectionConfig(url=...)`. + ## Usage -Before +### Submit and wait (blocking — the direct replacement) + +Before: ```python from speechmatics.models import ConnectionSettings, BatchTranscriptionConfig @@ -45,20 +71,174 @@ with BatchClient("API-KEY") as client: print(transcript) ``` -After +After: + +```python +from speechmatics.batch import Client, FormatType, JobConfig, JobType, TranscriptionConfig + +with Client(api_key="API-KEY") as client: + config = JobConfig( + type=JobType.TRANSCRIPTION, + transcription_config=TranscriptionConfig(language=LANGUAGE), + ) + + job = client.submit_job(PATH_TO_FILE, config=config) + + transcript = client.wait_for_completion(job.id, format_type=FormatType.TXT) + + print(transcript) +``` + +### Submit and wait (async, if you're adopting `asyncio`) ```python from speechmatics.batch import AsyncClient, FormatType, JobConfig, JobType, TranscriptionConfig -async with AsyncClient(os.environ.get("SPEECHMATICS_API_KEY")) as client: +async with AsyncClient(api_key="API-KEY") as client: config = JobConfig( type=JobType.TRANSCRIPTION, - transcription_config=TranscriptionConfig(language="en"), + transcription_config=TranscriptionConfig(language=LANGUAGE), ) - job = await client.submit_job("audio.wav", config=config) + job = await client.submit_job(PATH_TO_FILE, config=config) + + transcript = await client.wait_for_completion(job.id, format_type=FormatType.TXT) + + print(transcript) +``` + +### Bulk submission + +`submit_jobs()` has no direct equivalent. Reproduce a concurrency-limited +batch with `asyncio.gather` and a semaphore — see the +[README's Bulk and Concurrent Transcription section](README.md#bulk-and-concurrent-transcription) +for a full example (both async and blocking) that submits each file, waits +for it, and returns its transcript. It defaults to a concurrency of 5, +matching `submit_jobs()`'s old default. - result = await client.wait_for_completion(job.id, format_type=FormatType.TXT) +### Manual job management - print(f"Transcript: {result.transcript_text}") +Before: + +```python +status = client.check_job_status(job_id)["job"]["status"] +if status == "done": + result = client.get_job_result(job_id, transcription_format="json-v2") ``` + +After: + +```python +from speechmatics.batch import JobStatus + +job_info = client.get_job_info(job_id) +if job_info.status == JobStatus.DONE: + result = client.get_transcript(job_id) +``` + +## Breaking Changes + +- **Import paths**: `speechmatics.batch_client` → `speechmatics.batch` +- **Client class**: `BatchClient` → `AsyncClient`, or `Client` to stay synchronous +- **All methods async on `AsyncClient`** (requires `await`); `Client` keeps them blocking +- **Configuration**: `BatchTranscriptionConfig` → `JobConfig` with `TranscriptionConfig` +- **`submit_job()` return type changed**: it returned a job ID `str`; it now + returns a `JobDetails` object — use `job.id` instead of the return value + directly. +- **`wait_for_completion()` default format changed**: the legacy default was + `transcription_format="txt"`; the new default is `format_type=FormatType.JSON`. + **If your code relied on the default rather than passing the format + explicitly, you will silently start getting JSON instead of plain text.** +- **Result format**: `transcription_format` string parameter → `format_type`, + a `FormatType` enum +- **Authentication**: API key parameter renamed to `api_key`; credentials are + no longer read from `~/.speechmatics/config` — see [Credentials](#credentials) +- **`get_transcript()` raises `TranscriptNotReadyError` when a job isn't + finished yet** — catch this specifically to retry, rather than treating it + the same as a missing job (`JobError`). +- **`submit_jobs()` (bulk submission) removed**: no direct equivalent — see + [Bulk submission](#bulk-submission). +- **No CLI**: there is no command-line tool for `speechmatics-batch`, and + none is planned. Use the SDK directly from a script for one-off jobs. + +## Exceptions + +| Legacy exception (`speechmatics.exceptions`) | Raised by | New exception (`speechmatics.batch`) | +| --- | --- | --- | +| `JobNotFoundException` | `check_job_status()`, `delete_job()` (job not found) | `JobError` | +| `JobNotFoundException` | `get_job_result()` (job not ready, or not found) | `TranscriptNotReadyError` (from `get_transcript()`) | +| `TranscriptionError` (invalid format string) | `get_job_result()` | N/A — `format_type` takes a `FormatType` enum member, not a string | +| `TranscriptionError` (job in an unexpected state) | `wait_for_completion()` | `JobError` | +| `httpx.HTTPStatusError` (401/403) | any request | `AuthenticationError` | +| *(no equivalent — surfaces as a generic `httpx.HTTPStatusError`)* | job data expired (HTTP 410) | `JobExpiredError` | + +## Changes within `speechmatics-batch` + +- **Type hints now reach type checkers.** `speechmatics` is a PEP 420 namespace package, so the + `py.typed` marker is found where mypy and pyright look for it. Previously an installed + `speechmatics-batch` was reported as untyped (`missing library stubs or py.typed marker`) and + every call inferred as `Any`. Type checking your code against this SDK may now surface real + errors that were silently ignored before. +- **Invalid credentials now raise `AuthenticationError`**: HTTP 401/403 responses were + previously re-wrapped as `TransportError` before reaching callers, despite the documented + behaviour. Code that catches `TransportError` for authentication failures must catch + `AuthenticationError` instead. +- **Unavailable transcripts now raise `TranscriptNotReadyError`**: `get_transcript()` answers + an HTTP 404 with this exception, which subclasses `JobError`, so existing `except JobError` + handlers keep working. +- **Expired jobs now raise `JobExpiredError`**: Speechmatics retains job data for a limited + time; once it expires, both `get_job_info()` and `get_transcript()` answer with HTTP 410, + which now raises this exception (a subclass of `JobError`) instead of a generic `JobError` + with the raw response embedded in the message. +- **`JobStatus.EXPIRED` removed**: the API signals expiry via HTTP 410 on the endpoints above, + never as a `status` field value, so this member never matched anything a real API response + could produce and has been removed. +- **`AuthBase` methods are no longer abstract**: a custom auth class implements + `get_auth_headers` (async), `get_auth_headers_sync` (blocking), or both. Previously a + blocking-only implementation could not be instantiated because the async method was + abstract. Calling a method a subclass does not implement raises `NotImplementedError` + naming the missing one. +- **`delete_job()` accepts `force`**: the API refuses to delete a running job with HTTP 423, so + deleting one previously was not possible. Pass `force=True` to delete it anyway; without it, + the resulting `JobError` now says so explicitly. +- **New dependency: `httpx`**, alongside the existing `aiohttp`/`aiofiles`. It powers the new + blocking `Client`; `AsyncClient` does not use it. +- **Waiting is now bounded by default**: `wait_for_completion()` and `transcribe()` default to + `timeout=3600.0` instead of waiting forever, so a job that never reaches a terminal state can + no longer hang a thread or task indefinitely. Pass `timeout=None` for the old behaviour. +- **Status polling now backs off**: it starts at `min_polling_interval` (0.5s, a new argument) + and ramps towards `polling_interval`, which is now the ceiling rather than a fixed interval. + Its default is unchanged at 5 seconds, so long jobs poll as often as they did before while + short ones are picked up sooner. +- **Polling intervals must be greater than 0**: zero never backs off, so it polled in an + unthrottled loop. It now raises `ValueError`. +- **A failed status check no longer abandons a running job**: connection errors, request + timeouts and HTTP 408/429/5xx are retried, up to 5 consecutive failures. + +## Polling behaviour + +Legacy `wait_for_completion()` slept for 10% of the audio's duration before +polling at all, then polled at a fixed 15-second interval regardless of file +length, and gave up after an hour. + +The new SDK does not scale the wait to the audio's duration. It starts at +`min_polling_interval` (0.5 seconds) and backs off towards `polling_interval` +(5 seconds), with up to 20% jitter so concurrent clients don't synchronise, so +short jobs are picked up quickly while long jobs settle into infrequent checks. +Waiting is still bounded at an hour by default, matching the legacy cap; pass +`timeout=None` to wait indefinitely. + +Two things the legacy client did not do: + +- **Transient failures are retried.** A status check that fails with a + connection error, a request timeout or HTTP 408/429/5xx is retried, up to 5 + consecutive failures, rather than abandoning a job that is still running. + Failures that are an answer rather than a blip — bad credentials, an unknown + job, an expired job — are still raised immediately. +- **The API may hold each status request open briefly before answering it.** + The SDK does not depend on how long that is, so treat the intervals above as + what the client adds on top, not as the total time between checks. + +If you added your own delay logic on top of the legacy client to reduce latency +on short files, you no longer need it — tune `min_polling_interval` and +`polling_interval` on `wait_for_completion()`/`transcribe()` instead. diff --git a/sdk/batch/README.md b/sdk/batch/README.md index 4e21719a..33c51b11 100644 --- a/sdk/batch/README.md +++ b/sdk/batch/README.md @@ -3,11 +3,14 @@ [![PyPI](https://img.shields.io/pypi/v/speechmatics-batch)](https://pypi.org/project/speechmatics-batch/) ![PythonSupport](https://img.shields.io/badge/Python-3.9%2B-green) -Async Python client for Speechmatics Batch API. +Python client for Speechmatics Batch API, with both async and blocking interfaces. + +> **Migrating from `speechmatics-python` (the legacy `BatchClient`)?** See [MIGRATION.md](MIGRATION.md) for a full guide, including a method-by-method mapping table. ## Features -- Async API client with comprehensive error handling +- Async (`AsyncClient`) and blocking (`Client`) API clients with comprehensive error handling +- Synchronous transcription support: get a transcript in a single request, without polling - Type hints throughout for better IDE support - Environment variable support for credentials - Easy-to-use interface for submitting, monitoring, and retrieving transcription jobs @@ -25,20 +28,128 @@ pip install speechmatics-batch ### Quick Start +```python +from speechmatics.batch import Client + +# Create a client using environment variable SPEECHMATICS_API_KEY +with Client() as client: + # Simple transcription + result = client.transcribe("audio.wav") + print(result.transcript_text) +``` + +### Async + +`AsyncClient` is the async/await equivalent of `Client`, for code that +already runs an event loop. It exposes the same methods and returns the +same models: + ```python import asyncio from speechmatics.batch import AsyncClient async def main(): - # Create a client using environment variable SPEECHMATICS_API_KEY async with AsyncClient() as client: - # Simple transcription result = await client.transcribe("audio.wav") print(result.transcript_text) asyncio.run(main()) ``` +### Synchronous Transcription + +By default a transcription job is submitted, polled until it finishes, and then +its transcript is fetched. For short audio you can instead ask the server to +hold the request open until the transcript is ready, so one call replaces the +whole cycle. Pass `wait` (in seconds) to do this: + +```python +from speechmatics.batch import Client, FormatType + +with Client() as client: + # One request: submit, transcribe and return the transcript + text = client.transcribe("audio.wav", wait=60, format_type=FormatType.TXT) + print(text) +``` + +If the job is still running when the wait elapses, `transcribe()` falls back to +polling automatically, so longer audio keeps working unchanged. + +`wait` is also available on the individual operations, for full control: + +```python +from speechmatics.batch import Client, JobStatus, TranscriptNotReadyError + +with Client() as client: + job = client.submit_job("audio.wav", wait=60) + + if job.status == JobStatus.DONE: + print(job.transcript.transcript_text) # already available, no extra call + else: + # status is JobStatus.CREATED: the wait elapsed, the job is still running + print(client.wait_for_completion(job.id).transcript_text) +``` + +Requesting a transcript before it exists raises `TranscriptNotReadyError` (a +subclass of `JobError`), which is the signal to retry: + +```python +try: + transcript = client.get_transcript(job.id, wait=30) +except TranscriptNotReadyError: + transcript = client.wait_for_completion(job.id) +``` + +Notes: + +- Synchronous transcription is available on Speechmatics SaaS only. on-premises + deployments do not support it. +- The server caps how long it will wait, and intermediate proxies may close + long-held connections, so treat the fallback path as the normal case for + longer audio. +- The API applies a small default wait to the `GET` endpoints when `wait` is omitted. + Pass `wait=0` to return immediately. + +Everything above works identically on `AsyncClient` with `await`: + +```python +async with AsyncClient() as client: + text = await client.transcribe("audio.wav", wait=60, format_type=FormatType.TXT) +``` + +### Polling and Timeouts + +When a transcript isn't returned by `wait`, the client polls the job status +until it finishes. Polling starts at `min_polling_interval` and backs off +towards `polling_interval`, so short jobs are picked up quickly without long +jobs making hundreds of requests: + +```python +result = client.wait_for_completion( + job.id, + min_polling_interval=0.5, # first gap between status checks + polling_interval=5.0, # ceiling the backoff climbs to + timeout=3600.0, # give up after an hour +) +``` + +Both intervals must be greater than 0, and up to 20% jitter is applied to each +wait so that concurrent clients don't synchronise into bursts. + +Waiting is bounded by default: `timeout` is one hour unless you change it. Pass +`timeout=None` only if a job that never reaches a terminal state should block +indefinitely. + +A long wait makes many status requests, so a single failed one doesn't abandon +the job: connection errors, request timeouts and HTTP 408/429/5xx are retried, +up to 5 consecutive failures. Failures that are an answer rather than a blip — +bad credentials, an unknown job, an expired job — are raised straight away. + +Note that the API may also hold each status request open briefly before +answering it, and the SDK doesn't depend on how long that is. The intervals +above control what the client adds on top, so the time between checks can be +longer than the interval you set. + ## JWT Authentication For enhanced security, use temporary JWT tokens instead of static API keys. @@ -170,6 +281,53 @@ async def main(): asyncio.run(main()) ``` +### Bulk and Concurrent Transcription + +To transcribe many files with a concurrency cap, use `asyncio.gather` with a +semaphore (`AsyncClient`). `concurrency` limits how many jobs are in flight at +once — the example below submits 8 files with a cap of 5, so at most 5 run +concurrently and the rest queue behind the semaphore: + +```python +import asyncio +from speechmatics.batch import AsyncClient, JobConfig, JobType, TranscriptionConfig + +async def transcribe_all(paths, concurrency=5): + config = JobConfig(type=JobType.TRANSCRIPTION, transcription_config=TranscriptionConfig(language="en")) + semaphore = asyncio.Semaphore(concurrency) + + async with AsyncClient() as client: + async def run(path): + async with semaphore: + job = await client.submit_job(path, config=config) + return path, await client.wait_for_completion(job.id) + + return await asyncio.gather(*(run(path) for path in paths)) + +paths = [f"audio_{i}.wav" for i in range(8)] +results = asyncio.run(transcribe_all(paths)) +``` + +The equivalent with the blocking `Client` uses a thread pool, since each +`transcribe()` call blocks on network I/O. `max_workers` plays the same role +as `concurrency` above — it caps how many requests run at once, regardless of +how many paths are submitted: + +```python +from concurrent.futures import ThreadPoolExecutor +from speechmatics.batch import Client, JobConfig, JobType, TranscriptionConfig + +def transcribe_all(paths, concurrency=5): + config = JobConfig(type=JobType.TRANSCRIPTION, transcription_config=TranscriptionConfig(language="en")) + + with Client() as client, ThreadPoolExecutor(max_workers=concurrency) as pool: + futures = {pool.submit(client.transcribe, path, config=config): path for path in paths} + return [(futures[future], future.result()) for future in futures] + +paths = [f"audio_{i}.wav" for i in range(8)] +results = transcribe_all(paths) +``` + ### Different Output Formats ```python diff --git a/sdk/batch/pyproject.toml b/sdk/batch/pyproject.toml index 8bfdcd16..a579aebb 100644 --- a/sdk/batch/pyproject.toml +++ b/sdk/batch/pyproject.toml @@ -10,7 +10,7 @@ readme = "README.md" authors = [{ name = "Speechmatics", email = "support@speechmatics.com" }] license = "MIT" requires-python = ">=3.9" -dependencies = ["aiohttp", "aiofiles", "typing-extensions>=4.5.0"] +dependencies = ["aiohttp", "aiofiles", "httpx>=0.23", "typing-extensions>=4.5.0"] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", diff --git a/sdk/batch/speechmatics/__init__.py b/sdk/batch/speechmatics/__init__.py deleted file mode 100644 index 8db66d3d..00000000 --- a/sdk/batch/speechmatics/__init__.py +++ /dev/null @@ -1 +0,0 @@ -__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/sdk/batch/speechmatics/batch/__init__.py b/sdk/batch/speechmatics/batch/__init__.py index c889cbbd..5b87e22e 100644 --- a/sdk/batch/speechmatics/batch/__init__.py +++ b/sdk/batch/speechmatics/batch/__init__.py @@ -4,12 +4,15 @@ from ._auth import AuthBase from ._auth import JWTAuth from ._auth import StaticKeyAuth +from ._common import PROCESSING_DATA_HEADER from ._exceptions import AuthenticationError from ._exceptions import BatchError from ._exceptions import ConfigurationError from ._exceptions import ConnectionError from ._exceptions import JobError +from ._exceptions import JobExpiredError from ._exceptions import TimeoutError +from ._exceptions import TranscriptNotReadyError from ._exceptions import TransportError from ._models import AudioFilteringConfig from ._models import AutoChaptersConfig @@ -36,13 +39,14 @@ from ._models import TranscriptFilteringConfig from ._models import TranscriptionConfig from ._models import TranslationConfig -from ._transport import PROCESSING_DATA_HEADER +from ._sync_client import Client __all__ = [ "AudioFilteringConfig", "AsyncClient", "PROCESSING_DATA_HEADER", "AuthBase", + "Client", "AuthenticationError", "AutoChaptersConfig", "BatchError", @@ -55,6 +59,7 @@ "JobDetailError", "JobDetails", "JobError", + "JobExpiredError", "JobInfo", "JobStatus", "JobType", @@ -72,6 +77,7 @@ "TimeoutError", "TopicDetectionConfig", "Transcript", + "TranscriptNotReadyError", "TranscriptionConfig", "TranslationConfig", "TransportError", diff --git a/sdk/batch/speechmatics/batch/_async_client.py b/sdk/batch/speechmatics/batch/_async_client.py index 56612384..d47cc3a5 100644 --- a/sdk/batch/speechmatics/batch/_async_client.py +++ b/sdk/batch/speechmatics/batch/_async_client.py @@ -8,8 +8,8 @@ from __future__ import annotations import asyncio -import logging import os +import time import uuid from typing import Any from typing import BinaryIO @@ -18,10 +18,33 @@ from ._auth import AuthBase from ._auth import StaticKeyAuth +from ._common import DEFAULT_TIMEOUT +from ._common import MAX_TRANSIENT_POLL_FAILURES +from ._common import PollingInterval +from ._common import build_delete_job_params +from ._common import build_fetch_data_multipart +from ._common import build_file_multipart +from ._common import build_job_config +from ._common import build_list_jobs_params +from ._common import build_processing_data_header +from ._common import build_query_params +from ._common import build_submit_query_params +from ._common import clamp_wait +from ._common import is_job_active +from ._common import is_transient_poll_error +from ._common import job_details_from_submit_response +from ._common import raise_for_failed_status +from ._common import request_timeout_for_wait +from ._common import validate_audio_source +from ._common import validate_wait +from ._common import warn_deprecated_operating_point from ._exceptions import AuthenticationError from ._exceptions import BatchError from ._exceptions import JobError +from ._exceptions import JobExpiredError from ._exceptions import TimeoutError +from ._exceptions import TranscriptNotReadyError +from ._exceptions import TransportError from ._helpers import prepare_audio_file from ._logging import get_logger from ._models import ConnectionConfig @@ -29,10 +52,8 @@ from ._models import JobConfig from ._models import JobDetails from ._models import JobStatus -from ._models import JobType from ._models import Transcript from ._models import TranscriptionConfig -from ._transport import PROCESSING_DATA_HEADER from ._transport import Transport @@ -67,7 +88,7 @@ class AsyncClient: >>> async with AsyncClient(api_key="your-key") as client: ... job = await client.submit_job("audio.wav") ... result = await client.wait_for_completion(job.id) - ... print(result.transcript) + ... print(result.transcript_text) With JWT authentication: >>> from speechmatics.batch import JWTAuth @@ -143,6 +164,8 @@ async def submit_job( transcription_config: Optional[TranscriptionConfig] = None, parallel_engines: Optional[int] = None, user_id: Optional[str] = None, + wait: Optional[int] = None, + format_type: FormatType = FormatType.JSON, ) -> JobDetails: """ Submit a new transcription job. @@ -164,15 +187,31 @@ async def submit_job( user_id: Optional user identifier to associate with this job. Sent as ``{"user_id": "..."}`` in the ``X-SM-Processing-Data`` header. This only applies when using the container onPrem on http batch mode. + wait: Seconds to let the server hold the request open waiting for the + job to finish (synchronous transcription, SaaS only). When the job + finishes in time, the returned JobDetails has status DONE and + carries the transcript in ``transcript``. Otherwise the job is + still running and must be polled as usual. Note that the server + caps this value, and that long-held connections may be closed + early by intermediate proxies. + format_type: Format of the transcript embedded in the response when + ``wait`` is used. Ignored otherwise. Returns: - JobDetails object containing the job ID and initial status. + JobDetails object containing the job ID and status, plus the transcript + if the server returned one. Raises: BatchError: If job submission fails. AuthenticationError: If API key is invalid. ConfigurationError: If configuration is invalid. + Note: + Submission is not idempotent: if a submission fails after the server + accepted it (for example a network timeout while waiting), resubmitting + creates a second billable job. Prefer listing jobs to recover the + original job over blind retries. + Examples: Basic job submission: >>> job = await client.submit_job("audio.wav") @@ -186,44 +225,38 @@ async def submit_job( ... ) ... ) >>> job = await client.submit_job("audio.wav", config=config) + + Submit and get the transcript in one call: + >>> job = await client.submit_job("audio.wav", wait=60) + >>> if job.status == JobStatus.DONE: + ... print(job.transcript.transcript_text) """ - # Prepare job configuration - if config is None: - transcription_config = transcription_config or TranscriptionConfig() - config = JobConfig(type=JobType.TRANSCRIPTION, transcription_config=transcription_config) - - if config.transcription_config is not None and config.transcription_config.operating_point is not None: - logging.warning( - "TranscriptionConfig.operating_point is deprecated and will be removed in the future. Please use the model property instead." - ) + validate_wait(wait) + config = build_job_config(config, transcription_config) + warn_deprecated_operating_point(config) - # Check for fetch_data configuration config_dict = config.to_dict() - has_fetch_data = "fetch_data" in config_dict - - # Validate input combination - if audio_file is not None and has_fetch_data: - raise ValueError("Cannot specify both audio_file and fetch_data") - if audio_file is None and not has_fetch_data: - raise ValueError("Must provide either audio_file or fetch_data in config") + has_fetch_data = validate_audio_source(audio_file, config_dict) try: - # Prepare multipart data based on strategy if has_fetch_data: - multipart_data, filename = await self._prepare_fetch_data_submission(config_dict) - else: - assert audio_file is not None # for type checker; validated above - multipart_data, filename = await self._prepare_file_submission(audio_file, config_dict) - - return await self._submit_and_create_job_details( - multipart_data, filename, config, parallel_engines, user_id - ) + multipart_data, filename = build_fetch_data_multipart(config_dict) + return await self._submit( + multipart_data, filename, config, parallel_engines, user_id, wait, format_type + ) + + assert audio_file is not None # for type checker; validated above + async with prepare_audio_file(audio_file) as (filename, file_data): + multipart_data = build_file_multipart(config_dict, filename, file_data) + return await self._submit( + multipart_data, filename, config, parallel_engines, user_id, wait, format_type + ) except Exception as e: if isinstance(e, (AuthenticationError, BatchError)): raise raise BatchError(f"Job submission failed: {e}") from e - async def get_job_info(self, job_id: str) -> JobDetails: + async def get_job_info(self, job_id: str, *, wait: Optional[int] = None) -> JobDetails: """ Get information about a specific job. @@ -231,11 +264,16 @@ async def get_job_info(self, job_id: str) -> JobDetails: Args: job_id: The unique job identifier. + wait: Seconds to let the server hold the request open until the job + reaches a terminal state (synchronous transcription, SaaS only). + The API applies a small default wait when this is omitted. + Pass 0 to return immediately. Returns: JobDetails object with current job status and metadata. Raises: + JobExpiredError: If the job's data has expired and been deleted. JobError: If job is not found or cannot be retrieved. AuthenticationError: If API key is invalid. @@ -244,8 +282,12 @@ async def get_job_info(self, job_id: str) -> JobDetails: >>> print(f"Job status: {job_info.status}") """ try: - self._logger.debug("Retrieving job info for job_id=%s", job_id) - response = await self._transport.get(f"/jobs/{job_id}") + self._logger.debug("Retrieving job info for job_id=%s (wait=%s)", job_id, wait) + response = await self._transport.get( + f"/jobs/{job_id}", + params=build_query_params(wait=wait), + timeout=request_timeout_for_wait(wait, self._conn_config), + ) job = response.get("job") if job is None: raise JobError(f"No job information found for job ID: {job_id}") @@ -253,6 +295,8 @@ async def get_job_info(self, job_id: str) -> JobDetails: except Exception as e: if isinstance(e, AuthenticationError): raise + if isinstance(e, TransportError) and e.status_code == 410: + raise JobExpiredError(f"Job {job_id} has expired and is no longer available") from e raise JobError(f"Failed to get job info: {e}") from e async def list_jobs( @@ -282,17 +326,11 @@ async def list_jobs( >>> for job in jobs: ... print(f"Job {job.id}: {job.status}") """ - params = {} - if limit is not None: - params["limit"] = str(limit) - if created_before: - params["created_before"] = created_before - if created_after: - params["created_after"] = created_after + params = build_list_jobs_params(limit, created_before, created_after) try: self._logger.debug("Listing jobs (limit=%s)", limit) - response = await self._transport.get("/jobs", params=params or None) + response = await self._transport.get("/jobs", params=params) jobs_data = response.get("jobs", []) self._logger.debug("Jobs retrieved (%d jobs)", len(jobs_data)) return [JobDetails.from_dict(job) for job in jobs_data] @@ -301,7 +339,7 @@ async def list_jobs( raise raise BatchError(f"Failed to list jobs: {e}") from e - async def delete_job(self, job_id: str) -> None: + async def delete_job(self, job_id: str, *, force: bool = False) -> None: """ Delete a job and its results. @@ -310,6 +348,8 @@ async def delete_job(self, job_id: str) -> None: Args: job_id: The unique job identifier. + force: Delete the job even if it is still running. Without this the + API refuses to delete a running job. Raises: JobError: If job cannot be deleted. @@ -320,80 +360,132 @@ async def delete_job(self, job_id: str) -> None: """ try: self._logger.debug("Deleting job_id=%s", job_id) - await self._transport.delete(f"/jobs/{job_id}") + await self._transport.delete(f"/jobs/{job_id}", params=build_delete_job_params(force)) self._logger.debug("Job deleted successfully (job_id=%s)", job_id) except Exception as e: if isinstance(e, AuthenticationError): raise + if isinstance(e, TransportError) and e.status_code == 423: + raise JobError(f"Job {job_id} is still running; pass force=True to delete it") from e raise JobError(f"Failed to delete job: {e}") from e - async def get_transcript(self, job_id: str, *, format_type: FormatType = FormatType.JSON) -> Union[Transcript, str]: + async def get_transcript( + self, + job_id: str, + *, + format_type: FormatType = FormatType.JSON, + wait: Optional[int] = None, + ) -> Union[Transcript, str]: """ Get the transcript for a completed job. Args: job_id: The unique job identifier. format_type: Output format (FormatType.JSON, FormatType.TXT, FormatType.SRT). Defaults to FormatType.JSON. + wait: Seconds to let the server hold the request open until the + transcript is ready (synchronous transcription, SaaS only). + The API applies a small default wait when this is omitted. + Pass 0 to return immediately. Returns: Transcript object for JSON format, or string for text/SRT formats. Raises: + JobExpiredError: If the job's data has expired and been deleted. + TranscriptNotReadyError: If the transcript is not available yet (the + job may still be running, or the job ID may not exist). JobError: If transcript cannot be retrieved or job is not complete. AuthenticationError: If API key is invalid. Examples: >>> result = await client.get_transcript("12345") - >>> print(result.transcript) + >>> print(result.transcript_text) >>> # Get plain text transcript >>> text = await client.get_transcript("12345", format_type=FormatType.TXT) >>> print(text) """ - params = {"format": format_type.value} if format_type != FormatType.JSON else None - try: self._logger.debug("Retrieving transcript for job_id=%s (format=%s)", job_id, format_type.value) - response = await self._transport.get(f"/jobs/{job_id}/transcript", params=params) + response = await self._transport.get( + f"/jobs/{job_id}/transcript", + params=build_query_params(wait=wait, format_type=format_type), + timeout=request_timeout_for_wait(wait, self._conn_config), + ) if format_type == FormatType.JSON: return Transcript.from_dict(response) - else: - # Return plain text for other formats - return response.get("content", "") # type: ignore[no-any-return] + return str(response.get("content", "")) except Exception as e: if isinstance(e, AuthenticationError): raise + if isinstance(e, TransportError) and e.status_code == 410: + raise JobExpiredError(f"Transcript for job {job_id} has expired and is no longer available") from e + if isinstance(e, TransportError) and e.status_code == 404: + raise TranscriptNotReadyError( + f"Transcript for job {job_id} is not available yet; retry the request " + "(the job may still be running, or the job ID may not exist)" + ) from e raise JobError(f"Failed to get transcript: {e}") from e - async def _poll_job_status(self, job_id: str, polling_interval: float) -> None: + async def _poll_job_status(self, job_id: str, polling_interval: float, min_polling_interval: float) -> None: """Poll job status until completion or failure.""" - self._logger.debug("Starting job status polling for job_id=%s (interval=%.1fs)", job_id, polling_interval) + self._logger.debug( + "Starting job status polling for job_id=%s (min_interval=%.1fs, max_interval=%.1fs)", + job_id, + min_polling_interval, + polling_interval, + ) + started_at = time.monotonic() + interval = PollingInterval(min_polling_interval, polling_interval) poll_count = 0 last_log_time = 0.0 - import time + transient_failures = 0 while True: poll_count += 1 - job_info = await self.get_job_info(job_id) - - if job_info.status == JobStatus.DONE: - self._logger.info("Job completed (job_id=%s, polls=%d)", job_id, poll_count) - return - elif job_info.status == JobStatus.REJECTED: - self._logger.warning("Job was rejected (job_id=%s)", job_id) - raise JobError(f"Job {job_id} was rejected") - elif job_info.status == JobStatus.RUNNING: - # Log progress every 30 seconds - current_time: float = time.time() - if current_time - last_log_time >= 30.0: - self._logger.debug("Job still running (job_id=%s, polls=%d)", job_id, poll_count) - last_log_time = current_time - await asyncio.sleep(polling_interval) + try: + job_info = await self.get_job_info(job_id) + except Exception as e: + if not is_transient_poll_error(e): + raise + transient_failures += 1 + if transient_failures > MAX_TRANSIENT_POLL_FAILURES: + raise JobError( + f"Job {job_id} status could not be read after " + f"{transient_failures} consecutive failures: {e}" + ) from e + self._logger.warning( + "Job status poll failed, retrying (job_id=%s, failure=%d/%d): %s", + job_id, + transient_failures, + MAX_TRANSIENT_POLL_FAILURES, + e, + ) else: - self._logger.error("Job has unknown status (job_id=%s, status=%s)", job_id, job_info.status) - raise JobError(f"Job {job_id} has unknown status: {job_info.status}") + transient_failures = 0 + + if job_info.status == JobStatus.DONE: + self._logger.info("Job completed (job_id=%s, polls=%d)", job_id, poll_count) + self._logger.debug( + "Job turnaround time: %.2fs (job_id=%s, polls=%d)", + time.monotonic() - started_at, + job_id, + poll_count, + ) + return + elif is_job_active(job_info.status): + current_time: float = time.monotonic() + if current_time - last_log_time >= 30.0: + self._logger.debug("Job still running (job_id=%s, polls=%d)", job_id, poll_count) + last_log_time = current_time + else: + self._logger.warning("Job did not succeed (job_id=%s, status=%s)", job_id, job_info.status.value) + raise_for_failed_status(job_id, job_info.status) + raise JobError(f"Job {job_id} has unexpected status: {job_info.status.value}") + + await asyncio.sleep(interval.next()) async def wait_for_completion( self, @@ -401,7 +493,8 @@ async def wait_for_completion( *, format_type: FormatType = FormatType.JSON, polling_interval: float = 5.0, - timeout: Optional[float] = None, + min_polling_interval: float = 0.5, + timeout: Optional[float] = DEFAULT_TIMEOUT, ) -> Union[Transcript, str]: """ Wait for a job to complete and return the result. @@ -412,8 +505,16 @@ async def wait_for_completion( Args: job_id: The unique job identifier. format_type: Output format (FormatType.JSON, FormatType.TXT, FormatType.SRT). Defaults to FormatType.JSON. - polling_interval: Time in seconds between status checks. - timeout: Maximum time in seconds to wait for completion. + polling_interval: Ceiling in seconds for the time between status + checks. Polling starts at ``min_polling_interval`` and backs off + towards this value, so short jobs are caught quickly while + long-running jobs settle into this interval. Up to 20% jitter is + applied so that concurrent clients do not synchronise, so an + individual wait can exceed this by that much. Must be greater than 0. + min_polling_interval: Initial time in seconds between status checks. + Must be greater than 0. + timeout: Maximum time in seconds to wait for completion, one hour + by default. Pass ``None`` to wait indefinitely. Returns: Transcript object for JSON format, or string for text/SRT formats. @@ -426,7 +527,7 @@ async def wait_for_completion( Examples: >>> job = await client.submit_job("audio.wav") >>> result = await client.wait_for_completion(job.id) - >>> print(f"Transcript: {result.transcript}") + >>> print(f"Transcript: {result.transcript_text}") >>> # With custom timeout and format >>> result = await client.wait_for_completion( @@ -437,7 +538,9 @@ async def wait_for_completion( ... ) """ try: - await asyncio.wait_for(self._poll_job_status(job_id, polling_interval), timeout=timeout) + await asyncio.wait_for( + self._poll_job_status(job_id, polling_interval, min_polling_interval), timeout=timeout + ) return await self.get_transcript(job_id, format_type=format_type) @@ -450,10 +553,13 @@ async def transcribe( *, config: Optional[JobConfig] = None, transcription_config: Optional[TranscriptionConfig] = None, + format_type: FormatType = FormatType.JSON, polling_interval: float = 5.0, - timeout: Optional[float] = None, + min_polling_interval: float = 0.5, + timeout: Optional[float] = DEFAULT_TIMEOUT, parallel_engines: Optional[int] = None, user_id: Optional[str] = None, + wait: Optional[int] = None, ) -> Union[Transcript, str]: """ Complete transcription workflow: submit job and wait for completion. @@ -465,17 +571,30 @@ async def transcribe( audio_file: Path to audio file or file-like object. config: Complete job configuration. transcription_config: Transcription-specific configuration. - polling_interval: Time in seconds between status checks. - timeout: Maximum time in seconds to wait for completion. + format_type: Output format (FormatType.JSON, FormatType.TXT, FormatType.SRT). Defaults to FormatType.JSON. + polling_interval: Ceiling in seconds for the time between status + checks. Polling starts at ``min_polling_interval`` and backs off + towards this value, so short jobs are caught quickly while + long-running jobs settle into this interval. Up to 20% jitter is + applied so that concurrent clients do not synchronise, so an + individual wait can exceed this by that much. Must be greater than 0. + min_polling_interval: Initial time in seconds between status checks. + Must be greater than 0. + timeout: Maximum time in seconds to wait for completion, one hour + by default. Pass ``None`` to wait indefinitely. parallel_engines: Optional number of parallel engines to request for this job. Sent as ``{"parallel_engines": N}`` in the ``X-SM-Processing-Data`` header. This only applies when using the container onPrem on http batch mode. user_id: Optional user identifier to associate with this job. Sent as ``{"user_id": "..."}`` in the ``X-SM-Processing-Data`` header. This only applies when using the container onPrem on http batch mode. + wait: Seconds to let the server hold the submit request open waiting + for the transcript (synchronous transcription, SaaS only). Short + audio then needs only one round trip; anything still running when + the wait elapses falls back to polling. Returns: - Transcript object containing the transcript and metadata. + Transcript object for JSON format, or string for text/SRT formats. Raises: BatchError: If job submission fails. @@ -485,7 +604,7 @@ async def transcribe( Examples: >>> result = await client.transcribe("audio.wav") - >>> print(f"Transcript: {result.transcript}") + >>> print(f"Transcript: {result.transcript_text}") >>> # With custom configuration >>> config = TranscriptionConfig(language="es", enable_entities=True) @@ -494,7 +613,15 @@ async def transcribe( ... transcription_config=config, ... timeout=300.0 ... ) + + >>> # One round trip for short audio + >>> result = await client.transcribe("audio.wav", wait=60) """ + started_at = time.monotonic() + + # The server-side wait must not outlive the caller's own timeout. + wait = clamp_wait(wait, timeout) + # Submit the job job = await self.submit_job( audio_file, @@ -502,14 +629,26 @@ async def transcribe( transcription_config=transcription_config, parallel_engines=parallel_engines, user_id=user_id, + wait=wait, + format_type=format_type, ) + if job.status == JobStatus.DONE: + if job.transcript is not None: + self._logger.info("Transcription completed during submit (job_id=%s)", job.id) + return job.transcript + return await self.get_transcript(job.id, format_type=format_type) + + remaining = None if timeout is None else max(0.0, timeout - (time.monotonic() - started_at)) + # Wait for completion and return result self._logger.debug("Waiting for job completion (job_id=%s)", job.id) result = await self.wait_for_completion( job.id, + format_type=format_type, polling_interval=polling_interval, - timeout=timeout, + min_polling_interval=min_polling_interval, + timeout=remaining, ) self._logger.info("Transcription job completed successfully (job_id=%s)", job.id) return result @@ -536,50 +675,24 @@ async def close(self) -> None: except Exception: pass # Best effort cleanup - # ------------------------------------------------------------------ - # Internal helpers for job submission strategies - # ------------------------------------------------------------------ - async def _prepare_fetch_data_submission(self, config_dict: dict) -> tuple[dict, str]: - """Prepare multipart data for fetch_data submission.""" - filename = config_dict["fetch_data"]["url"] - multipart_data = {"config": config_dict} - return multipart_data, filename - - async def _prepare_file_submission(self, audio_file: Union[str, BinaryIO], config_dict: dict) -> tuple[dict, str]: - """Prepare multipart data for file upload submission.""" - async with prepare_audio_file(audio_file) as (filename, file_data): - multipart_data = { - "config": config_dict, - "data_file": (filename, file_data, "audio/wav"), - } - return multipart_data, filename - - async def _submit_and_create_job_details( + async def _submit( self, - multipart_data: dict, + multipart_data: dict[str, Any], filename: str, config: JobConfig, - parallel_engines: Optional[int] = None, - user_id: Optional[str] = None, + parallel_engines: Optional[int], + user_id: Optional[str], + wait: Optional[int], + format_type: FormatType, ) -> JobDetails: - """Submit job and create JobDetails response.""" - extra_headers: Optional[dict[str, Any]] = None - processing_data: dict[str, Any] = {} - if parallel_engines is not None: - processing_data["parallel_engines"] = parallel_engines - if user_id is not None: - processing_data["user_id"] = user_id - if processing_data: - extra_headers = {PROCESSING_DATA_HEADER: processing_data} - response = await self._transport.post("/jobs", multipart_data=multipart_data, extra_headers=extra_headers) - job_id = response.get("id") - if not job_id: - raise BatchError("No job ID returned from server") - self._logger.debug("Job submitted successfully (job_id=%s, filename=%s)", job_id, filename) - return JobDetails( - id=job_id, - status=JobStatus.RUNNING, - created_at=response.get("created_at", ""), - data_name=filename, - config=config, + """Submit the job and build its JobDetails.""" + response = await self._transport.post( + "/jobs", + multipart_data=multipart_data, + extra_headers=build_processing_data_header(parallel_engines, user_id), + params=build_submit_query_params(wait, format_type), + timeout=request_timeout_for_wait(wait, self._conn_config), ) + job = job_details_from_submit_response(response, config, filename, format_type) + self._logger.debug("Job submitted successfully (job_id=%s, filename=%s)", job.id, filename) + return job diff --git a/sdk/batch/speechmatics/batch/_auth.py b/sdk/batch/speechmatics/batch/_auth.py index e71730ab..9cf7f029 100644 --- a/sdk/batch/speechmatics/batch/_auth.py +++ b/sdk/batch/speechmatics/batch/_auth.py @@ -1,29 +1,58 @@ +from __future__ import annotations + import abc -import asyncio import os +import threading import time +from typing import TYPE_CHECKING +from typing import Any from typing import Literal from typing import Optional from ._exceptions import AuthenticationError +if TYPE_CHECKING: + import asyncio + -class AuthBase(abc.ABC): +# Kept as an ABC so the public class keeps its metaclass, even though neither +# authentication method is abstract any more. +class AuthBase(abc.ABC): # noqa: B024 """ - Abstract base class for authentication methods. + Base class for authentication methods. + + Subclasses implement whichever of the two methods they need: async-only + callers implement ``get_auth_headers``, blocking callers implement + ``get_auth_headers_sync``, and most implementations provide both. Neither is + abstract, so a subclass used with only one client does not have to write a + method it will never call. """ BASE_URL = "https://mp.speechmatics.com" - @abc.abstractmethod async def get_auth_headers(self) -> dict[str, str]: """ - Get authentication headers asynchronously. + Get authentication headers asynchronously, for use with ``AsyncClient``. Returns: A dictionary of authentication headers. """ - raise NotImplementedError + raise NotImplementedError( + f"{type(self).__name__} does not support asynchronous authentication. " + "Implement get_auth_headers() to use this auth method with AsyncClient." + ) + + def get_auth_headers_sync(self) -> dict[str, str]: + """ + Get authentication headers synchronously, for use with ``Client``. + + Returns: + A dictionary of authentication headers. + """ + raise NotImplementedError( + f"{type(self).__name__} does not support synchronous authentication. " + "Implement get_auth_headers_sync() to use this auth method with the sync Client." + ) class StaticKeyAuth(AuthBase): @@ -52,6 +81,9 @@ def __init__(self, api_key: Optional[str] = None): async def get_auth_headers(self) -> dict[str, str]: return {"Authorization": f"Bearer {self._api_key}"} + def get_auth_headers_sync(self) -> dict[str, str]: + return {"Authorization": f"Bearer {self._api_key}"} + class JWTAuth(AuthBase): """ @@ -105,29 +137,68 @@ def __init__( self._cached_token: Optional[str] = None self._token_expires_at: float = 0 - self._token_lock = asyncio.Lock() + # The cache is reachable from an event loop and from threads, so both + # paths guard it with _cache_lock and serialise minting separately. + self._cache_lock = threading.Lock() + self._sync_mint_lock = threading.Lock() + # Created on first async use: on Python 3.9 asyncio.Lock() binds to the + # current event loop, so building one outside a loop (as sync-only + # callers do) raises RuntimeError. + self._token_lock: Optional[asyncio.Lock] = None + + def _async_mint_lock(self) -> asyncio.Lock: + """The asyncio lock serialising async minting, created inside the loop.""" + import asyncio + + if self._token_lock is None: + self._token_lock = asyncio.Lock() + return self._token_lock + + def _cached_headers(self) -> Optional[dict[str, str]]: + """Return headers from the cached token, if it is still valid.""" + with self._cache_lock: + if self._cached_token and time.time() < self._token_expires_at - 10: + return {"Authorization": f"Bearer {self._cached_token}"} + return None + + def _store_token(self, token: str) -> dict[str, str]: + """Cache a freshly minted token and return its headers.""" + with self._cache_lock: + self._cached_token = token + self._token_expires_at = time.time() + self._ttl + return {"Authorization": f"Bearer {token}"} async def get_auth_headers(self) -> dict[str, str]: """Get JWT auth headers with caching.""" - async with self._token_lock: - current_time = time.time() - if current_time >= self._token_expires_at - 10: - self._cached_token = await self._generate_token() - self._token_expires_at = current_time + self._ttl - - return {"Authorization": f"Bearer {self._cached_token}"} - - async def _generate_token(self) -> str: - try: - import aiohttp - except ImportError: - raise ImportError( - "aiohttp is required for JWT authentication. Please install it with `pip install 'speechmatics-batch[jwt]'`" - ) - + headers = self._cached_headers() + if headers is not None: + return headers + + async with self._async_mint_lock(): + # Another coroutine may have minted a token while we waited. + headers = self._cached_headers() + if headers is not None: + return headers + return self._store_token(await self._generate_token()) + + def get_auth_headers_sync(self) -> dict[str, str]: + """Get JWT auth headers with caching, without an event loop.""" + headers = self._cached_headers() + if headers is not None: + return headers + + with self._sync_mint_lock: + # Another thread may have minted a token while we waited. + headers = self._cached_headers() + if headers is not None: + return headers + return self._store_token(self._generate_token_sync()) + + def _token_request(self) -> tuple[str, dict[str, str], dict[str, Any], dict[str, str]]: + """Build the endpoint, params, payload and headers for minting a token.""" endpoint = f"{self._mp_url}/v1/api_keys" params = {"type": "batch"} - payload = {"ttl": self._ttl, "region": str(self._region)} + payload: dict[str, Any] = {"ttl": self._ttl, "region": str(self._region)} if self._client_ref: payload["client_ref"] = self._client_ref @@ -140,6 +211,32 @@ async def _generate_token(self) -> str: if self._request_id: headers["X-Request-Id"] = self._request_id + return endpoint, params, payload, headers + + def _generate_token_sync(self) -> str: + import httpx + + endpoint, params, payload, headers = self._token_request() + + try: + response = httpx.post(endpoint, params=params, json=payload, headers=headers, timeout=10) + if response.status_code != 201: + raise AuthenticationError(f"Failed to generate JWT: HTTP {response.status_code}: {response.text}") + + return str(response.json()["key_value"]) + + except httpx.HTTPError as e: + raise AuthenticationError(f"Network error generating JWT: {e}") + except AuthenticationError: + raise + except Exception as e: + raise AuthenticationError(f"Unexpected error generating JWT: {e}") + + async def _generate_token(self) -> str: + import aiohttp + + endpoint, params, payload, headers = self._token_request() + try: async with aiohttp.ClientSession() as session: async with session.post( @@ -158,5 +255,7 @@ async def _generate_token(self) -> str: except aiohttp.ClientError as e: raise AuthenticationError(f"Network error generating JWT: {e}") + except AuthenticationError: + raise except Exception as e: raise AuthenticationError(f"Unexpected error generating JWT: {e}") diff --git a/sdk/batch/speechmatics/batch/_common.py b/sdk/batch/speechmatics/batch/_common.py new file mode 100644 index 00000000..d3c5369f --- /dev/null +++ b/sdk/batch/speechmatics/batch/_common.py @@ -0,0 +1,359 @@ +""" +Transport-agnostic logic shared by the async and sync Batch clients. + +Request construction and response parsing live here so that ``AsyncClient`` and +``Client`` cannot drift apart: the only thing the two client modules should +differ in is how they perform I/O. +""" + +from __future__ import annotations + +import logging +import os +import random +from typing import Any +from typing import BinaryIO +from typing import Optional +from typing import Union + +from ._exceptions import BatchError +from ._exceptions import ConnectionError +from ._exceptions import JobError +from ._exceptions import TransportError +from ._models import ConnectionConfig +from ._models import FormatType +from ._models import JobConfig +from ._models import JobDetails +from ._models import JobStatus +from ._models import JobType +from ._models import Transcript +from ._models import TranscriptionConfig + +PROCESSING_DATA_HEADER = "X-SM-Processing-Data" + +# Slack on top of a server-side ``wait``, so the request is not aborted +# client-side while the server is still inside its own wait window. +WAIT_TIMEOUT_BUFFER = 10.0 + +# Waiting for a job is bounded by default: an unbounded wait turns a job that +# never reaches a terminal state into a thread or task that hangs forever. +# Callers who genuinely want that can pass ``timeout=None``. +DEFAULT_TIMEOUT = 3600.0 + +# A long wait makes hundreds of status requests, so a single failed one must not +# abandon a job that is still running. ``GET /jobs/{id}`` is idempotent, so +# these are safe to repeat. +MAX_TRANSIENT_POLL_FAILURES = 5 + +_TRANSIENT_STATUS_CODES = frozenset({408, 429, 500, 502, 503, 504}) + +# The API spells the default format ``json-v2`` where the SDK spells it +# FormatType.JSON. This value is both the ``format`` query parameter and the +# key an embedded transcript arrives under. +_API_FORMAT_VALUES = { + FormatType.JSON: "json-v2", + FormatType.TXT: "txt", + FormatType.SRT: "srt", +} + +_ACTIVE_JOB_STATUSES = (JobStatus.CREATED, JobStatus.RUNNING) + +_FAILED_JOB_STATUSES = { + JobStatus.REJECTED: "was rejected", + JobStatus.DELETED: "was deleted before it finished", +} + + +def api_format_value(format_type: FormatType) -> str: + """Return the API's name for an output format (FormatType.JSON -> json-v2).""" + return _API_FORMAT_VALUES[format_type] + + +def is_job_active(status: JobStatus) -> bool: + """Return True while a job is still queued or running.""" + return status in _ACTIVE_JOB_STATUSES + + +def raise_for_failed_status(job_id: str, status: JobStatus) -> None: + """Raise JobError if a status is terminal and not successful.""" + reason = _FAILED_JOB_STATUSES.get(status) + if reason is not None: + raise JobError(f"Job {job_id} {reason}") + + +def build_job_config( + config: Optional[JobConfig], + transcription_config: Optional[TranscriptionConfig], +) -> JobConfig: + """Return the job config to submit, building a default one if needed.""" + if config is not None: + return config + return JobConfig( + type=JobType.TRANSCRIPTION, + transcription_config=transcription_config or TranscriptionConfig(), + ) + + +def validate_audio_source(audio_file: Union[str, BinaryIO, None], config_dict: dict[str, Any]) -> bool: + """ + Validate that exactly one audio source is configured. + + Returns: + True if the job fetches its own audio via ``fetch_data``. + """ + has_fetch_data = "fetch_data" in config_dict + if audio_file is not None and has_fetch_data: + raise ValueError("Cannot specify both audio_file and fetch_data") + if audio_file is None and not has_fetch_data: + raise ValueError("Must provide either audio_file or fetch_data in config") + return has_fetch_data + + +def validate_wait(wait: Optional[int]) -> None: + """ + Validate the ``wait`` argument. + + Raises: + ValueError: If wait is negative or not a whole number of seconds. + """ + if wait is None: + return + if wait < 0: + raise ValueError("wait must be a non-negative number of seconds") + if not float(wait).is_integer(): + raise ValueError("wait must be a whole number of seconds") + + +def warn_deprecated_operating_point(config: JobConfig) -> None: + """Emit the deprecation warning for ``operating_point`` if it is in use.""" + if config.transcription_config is not None and config.transcription_config.operating_point is not None: + logging.warning( + "TranscriptionConfig.operating_point is deprecated and will be removed in the future. Please use the model property instead." + ) + + +def resolve_stream_filename(audio_file: BinaryIO) -> str: + """Derive a filename for a file-like audio source.""" + filename = getattr(audio_file, "name", "audio.wav") + if hasattr(filename, "split"): + filename = os.path.basename(filename) + return str(filename) + + +def build_fetch_data_multipart(config_dict: dict[str, Any]) -> tuple[dict[str, Any], str]: + """Build multipart fields for a ``fetch_data`` submission.""" + return {"config": config_dict}, str(config_dict["fetch_data"]["url"]) + + +def build_file_multipart( + config_dict: dict[str, Any], + filename: str, + file_data: Union[BinaryIO, bytes], +) -> dict[str, Any]: + """Build multipart fields for a file upload submission.""" + return { + "config": config_dict, + "data_file": (filename, file_data, "audio/wav"), + } + + +def build_processing_data_header( + parallel_engines: Optional[int], + user_id: Optional[str], +) -> Optional[dict[str, Any]]: + """Build the ``X-SM-Processing-Data`` header value, if any is needed.""" + processing_data: dict[str, Any] = {} + if parallel_engines is not None: + processing_data["parallel_engines"] = parallel_engines + if user_id is not None: + processing_data["user_id"] = user_id + if not processing_data: + return None + return {PROCESSING_DATA_HEADER: processing_data} + + +def build_list_jobs_params( + limit: Optional[int], + created_before: Optional[str], + created_after: Optional[str], +) -> Optional[dict[str, str]]: + """Build query parameters for listing jobs.""" + params: dict[str, str] = {} + if limit is not None: + params["limit"] = str(limit) + if created_before: + params["created_before"] = created_before + if created_after: + params["created_after"] = created_after + return params or None + + +def build_delete_job_params(force: bool) -> Optional[dict[str, str]]: + """Query parameters for deleting a job; a running job needs force.""" + return {"force": "true"} if force else None + + +def build_query_params( + *, + wait: Optional[int] = None, + format_type: FormatType = FormatType.JSON, +) -> Optional[dict[str, str]]: + """ + Build query parameters for synchronous transcription. + + ``wait`` asks the server to hold the request open until the job reaches a + terminal state. ``format`` is omitted for FormatType.JSON because that is + the documented API default (``json-v2``). + """ + validate_wait(wait) + params: dict[str, str] = {} + if wait is not None: + params["wait"] = str(int(wait)) + if format_type != FormatType.JSON: + params["format"] = api_format_value(format_type) + return params or None + + +def build_submit_query_params( + wait: Optional[int], + format_type: FormatType, +) -> Optional[dict[str, str]]: + """ + Build query parameters for a job submission. + + ``format`` only describes the transcript the server embeds while waiting, so + it is pointless without ``wait``. + """ + if wait is None: + return None + return build_query_params(wait=wait, format_type=format_type) + + +def request_timeout_for_wait(wait: Optional[int], conn_config: ConnectionConfig) -> Optional[float]: + """ + Request timeout that covers both the upload and the server-side ``wait``. + + The wait window is added to the configured operation timeout rather than + replacing it, so that asking the server to wait can never give a request + less time than the same request without ``wait``. + """ + if wait is None: + return None + return conn_config.operation_timeout + wait + WAIT_TIMEOUT_BUFFER + + +def clamp_wait(wait: Optional[int], remaining_timeout: Optional[float]) -> Optional[int]: + """ + Reduce ``wait`` so it cannot outlive the caller's overall timeout. + + Without this, ``transcribe(timeout=5, wait=60)`` would block for the full + 60 seconds inside the submit call before the deadline was ever checked. + """ + if wait is None or remaining_timeout is None: + return wait + return max(0, min(wait, int(remaining_timeout))) + + +def extract_embedded_transcript( + response: dict[str, Any], + format_type: FormatType = FormatType.JSON, +) -> Optional[Union[Transcript, str]]: + """ + Pull the transcript out of a response that embedded one. + + When a ``wait`` request finishes in time, the transcript is embedded under a + key named after the requested format: a string for ``txt``/``srt``, and a + nested transcript object for ``json-v2``. + """ + value = response.get(api_format_value(format_type)) + + if format_type == FormatType.JSON: + return Transcript.from_dict(value) if isinstance(value, dict) else None + return value if isinstance(value, str) else None + + +def job_details_from_submit_response( + response: dict[str, Any], + config: JobConfig, + filename: str, + format_type: FormatType = FormatType.JSON, +) -> JobDetails: + """Build JobDetails from a ``POST /jobs`` response.""" + job_id = response.get("id") + if not job_id: + raise BatchError("No job ID returned from server") + + raw_status = response.get("status") + status = JobStatus(raw_status) if raw_status else JobStatus.RUNNING + + return JobDetails( + id=job_id, + status=status, + created_at=response.get("created_at", ""), + data_name=filename, + config=config, + transcript=extract_embedded_transcript(response, format_type), + ) + + +def jittered_interval(base_interval: float) -> float: + """ + Add +/-20% jitter to a polling interval. + + Many clients polling on a fixed interval synchronise into load spikes + against the API, so spread them out. + """ + return base_interval * random.uniform(0.8, 1.2) # noqa: S311 - jitter, not crypto + + +def is_transient_poll_error(error: Exception) -> bool: + """ + Whether a failed status poll is worth retrying. + + ``get_job_info()`` wraps transport failures in :class:`JobError`, so the + original failure is inspected through ``__cause__``. Anything that is not a + recognised transport failure (a bad job ID, an expired job, bad + credentials) is the caller's answer, not something to retry. + """ + cause = error.__cause__ if isinstance(error, JobError) else error + if isinstance(cause, ConnectionError): + return True + if isinstance(cause, TransportError): + # No status code means the request never produced an HTTP response + # (timeout, unreadable body), which is as retryable as a 503. + return cause.status_code is None or cause.status_code in _TRANSIENT_STATUS_CODES + return False + + +class PollingInterval: + """ + Exponential backoff for a polling interval. + + Starts at ``min_interval`` and multiplies by ``multiplier`` after every + call to :meth:`next`, capping at ``max_interval``. This keeps + turn-around time low for short jobs (which are caught by one of the + early, fast polls) while converging to the same steady-state interval + as a fixed polling interval for long-running jobs. Jitter is applied + via :func:`jittered_interval` so concurrent clients do not synchronise. + + Both intervals must be positive: zero would never grow (``0 * multiplier`` + is still zero), turning the caller's poll loop into an unthrottled stream + of requests. + """ + + def __init__(self, min_interval: float, max_interval: float, multiplier: float = 1.6) -> None: + if min_interval <= 0: + raise ValueError("min_polling_interval must be greater than 0") + if max_interval <= 0: + raise ValueError("polling_interval must be greater than 0") + if multiplier <= 1: + raise ValueError("multiplier must be greater than 1") + self._max_interval = max_interval + self._multiplier = multiplier + self._current = min(min_interval, max_interval) + + def next(self) -> float: + """Return the next (jittered) interval and advance the backoff.""" + interval = jittered_interval(self._current) + self._current = min(self._current * self._multiplier, self._max_interval) + return interval diff --git a/sdk/batch/speechmatics/batch/_exceptions.py b/sdk/batch/speechmatics/batch/_exceptions.py index 173c4002..8b72062b 100644 --- a/sdk/batch/speechmatics/batch/_exceptions.py +++ b/sdk/batch/speechmatics/batch/_exceptions.py @@ -1,3 +1,6 @@ +from typing import Optional + + class ConfigurationError(Exception): """Raised when there's an error in configuration.""" @@ -17,9 +20,16 @@ class ConnectionError(Exception): class TransportError(Exception): - """Raised when there's an error in the transport layer.""" + """ + Raised when there's an error in the transport layer. - pass + Attributes: + status_code: HTTP status code, when the error came from an HTTP response. + """ + + def __init__(self, message: str, *, status_code: Optional[int] = None) -> None: + super().__init__(message) + self.status_code = status_code class BatchError(Exception): @@ -34,6 +44,30 @@ class JobError(Exception): pass +class TranscriptNotReadyError(JobError): + """ + Raised when a transcript is requested before it is available. + + The API answers ``GET /jobs/{id}/transcript`` with HTTP 404 both while a job + is still running and for a job that does not exist, so retry the request + (or check the job status) rather than assuming the job is missing. + """ + + pass + + +class JobExpiredError(JobError): + """ + Raised when a job's data has expired and been deleted from storage. + + Speechmatics retains job data for a limited time. Once it expires, both + ``GET /jobs/{id}`` and ``GET /jobs/{id}/transcript`` answer with HTTP 410, + not a ``status`` field, so this is raised instead of a generic JobError. + """ + + pass + + class TimeoutError(Exception): """Raised when an operation times out.""" diff --git a/sdk/batch/speechmatics/batch/_helpers.py b/sdk/batch/speechmatics/batch/_helpers.py index 443e49a4..78496ef7 100644 --- a/sdk/batch/speechmatics/batch/_helpers.py +++ b/sdk/batch/speechmatics/batch/_helpers.py @@ -7,12 +7,16 @@ import importlib.metadata import os from collections.abc import AsyncGenerator +from collections.abc import Generator from contextlib import asynccontextmanager +from contextlib import contextmanager from typing import BinaryIO from typing import Union import aiofiles +from ._common import resolve_stream_filename + @asynccontextmanager async def prepare_audio_file( @@ -39,10 +43,35 @@ async def prepare_audio_file( yield filename, content else: # It's already a file-like object - filename = getattr(audio_file, "name", "audio.wav") - if hasattr(filename, "split"): - filename = os.path.basename(filename) - yield filename, audio_file + yield resolve_stream_filename(audio_file), audio_file + + +@contextmanager +def prepare_audio_file_sync( + audio_file: Union[str, BinaryIO], +) -> Generator[tuple[str, Union[BinaryIO, bytes]], None, None]: + """ + Context manager for file handling with proper resource management. + + The open file handle is yielded rather than its contents so that the upload + is streamed; a multi-hour audio file never has to fit in memory. + + Args: + audio_file: Path to audio file or file-like object containing audio data. + + Yields: + Tuple of (filename, file_data) + + Examples: + >>> with prepare_audio_file_sync("audio.wav") as (filename, file_data): + ... # Use file_data for upload + ... pass + """ + if isinstance(audio_file, str): + with open(audio_file, "rb") as f: + yield os.path.basename(audio_file), f + else: + yield resolve_stream_filename(audio_file), audio_file def get_version() -> str: diff --git a/sdk/batch/speechmatics/batch/_models.py b/sdk/batch/speechmatics/batch/_models.py index c30f8310..07b594c1 100644 --- a/sdk/batch/speechmatics/batch/_models.py +++ b/sdk/batch/speechmatics/batch/_models.py @@ -14,6 +14,7 @@ from enum import Enum from typing import Any from typing import Optional +from typing import Union from typing import cast from warnings import warn @@ -35,11 +36,11 @@ class JobStatus(str, Enum): during the batch transcription. """ + CREATED = "created" RUNNING = "running" DONE = "done" REJECTED = "rejected" DELETED = "deleted" - EXPIRED = "expired" @deprecated("Use Model instead") @@ -575,6 +576,9 @@ class JobDetails: duration: Duration of the audio file in seconds. config: Complete job configuration used. errors: List of errors encountered during job processing. + transcript: Transcript embedded by the server when a synchronous + (``wait``) request completed before the wait elapsed. None + otherwise, including for jobs that are still running. """ id: str @@ -584,6 +588,7 @@ class JobDetails: duration: Optional[float] = None config: Optional[JobConfig] = None errors: Optional[list[JobDetailError]] = None + transcript: Optional[Union[Transcript, str]] = None @classmethod def from_dict(cls, data: dict[str, Any]) -> JobDetails: @@ -596,11 +601,12 @@ def from_dict(cls, data: dict[str, Any]) -> JobDetails: if "errors" in data and data["errors"]: errors = [JobDetailError.from_dict(error) for error in data["errors"]] + # A job whose server-side wait elapsed returns only an id and a status. return cls( id=data["id"], - status=JobStatus(data["status"]), - created_at=data["created_at"], - data_name=data["data_name"], + status=JobStatus(data["status"]) if data.get("status") else JobStatus.RUNNING, + created_at=data.get("created_at", ""), + data_name=data.get("data_name", ""), duration=data.get("duration"), config=config, errors=errors, @@ -829,6 +835,8 @@ class Transcript: _LANG_PACK_WORD_DELIMITER_KEY = "word_delimiter" _LANG_PACK_PER_LANG_DELIMITERS_KEY = "per_language_word_delimiters" + _UNKNOWN_SPEAKER_LABEL = "UU" + format: str job: JobInfo metadata: RecognitionMetadata @@ -882,7 +890,9 @@ def transcript_text(self) -> str: alternative = result.alternatives[0] content = alternative.content - speaker = alternative.speaker + # The API returns "UU" when no speaker was identified, which must not + # be rendered as a speaker label. + speaker = None if alternative.speaker == self._UNKNOWN_SPEAKER_LABEL else alternative.speaker word_delimiter = default_word_delimiter if alternative.language and alternative.language in per_lang_word_delimiters: word_delimiter = per_lang_word_delimiters[alternative.language] diff --git a/sdk/batch/speechmatics/batch/_sync_client.py b/sdk/batch/speechmatics/batch/_sync_client.py new file mode 100644 index 00000000..f411f83b --- /dev/null +++ b/sdk/batch/speechmatics/batch/_sync_client.py @@ -0,0 +1,669 @@ +""" +Synchronous client for Speechmatics batch transcription. + +This module provides the blocking Client class for callers that do not run an +event loop, such as scripts, notebooks, serverless handlers and synchronous +web frameworks. +""" + +from __future__ import annotations + +import os +import time +import uuid +from typing import Any +from typing import BinaryIO +from typing import Optional +from typing import Union + +from ._auth import AuthBase +from ._auth import StaticKeyAuth +from ._common import DEFAULT_TIMEOUT +from ._common import MAX_TRANSIENT_POLL_FAILURES +from ._common import PollingInterval +from ._common import build_delete_job_params +from ._common import build_fetch_data_multipart +from ._common import build_file_multipart +from ._common import build_job_config +from ._common import build_list_jobs_params +from ._common import build_processing_data_header +from ._common import build_query_params +from ._common import build_submit_query_params +from ._common import clamp_wait +from ._common import is_job_active +from ._common import is_transient_poll_error +from ._common import job_details_from_submit_response +from ._common import raise_for_failed_status +from ._common import request_timeout_for_wait +from ._common import validate_audio_source +from ._common import validate_wait +from ._common import warn_deprecated_operating_point +from ._exceptions import AuthenticationError +from ._exceptions import BatchError +from ._exceptions import JobError +from ._exceptions import JobExpiredError +from ._exceptions import TimeoutError +from ._exceptions import TranscriptNotReadyError +from ._exceptions import TransportError +from ._helpers import prepare_audio_file_sync +from ._logging import get_logger +from ._models import ConnectionConfig +from ._models import FormatType +from ._models import JobConfig +from ._models import JobDetails +from ._models import JobStatus +from ._models import Transcript +from ._models import TranscriptionConfig +from ._sync_transport import SyncTransport + + +class Client: + """ + Synchronous client for Speechmatics batch speech transcription. + + This is the blocking counterpart of :class:`AsyncClient`, for code that is + not running an event loop. It exposes the same operations and returns the + same models. + + Args: + auth: Authentication instance. If not provided, uses StaticKeyAuth + with api_key parameter or SPEECHMATICS_API_KEY environment variable. + api_key: Speechmatics API key (used only if auth not provided). + url: REST API endpoint URL. If not provided, uses SPEECHMATICS_BATCH_URL + environment variable or defaults to production endpoint. + conn_config: Complete connection configuration object. If provided, overrides + other parameters. + + Raises: + ConfigurationError: If required configuration is missing or invalid. + + Examples: + Transcribe in a single call, letting the server hold the request open + until the transcript is ready: + >>> with Client(api_key="your-key") as client: + ... result = client.transcribe("audio.wav", wait=60) + ... print(result.transcript_text) + + Submit and poll explicitly: + >>> with Client(api_key="your-key") as client: + ... job = client.submit_job("audio.wav") + ... result = client.wait_for_completion(job.id) + """ + + def __init__( + self, + auth: Optional[AuthBase] = None, + *, + api_key: Optional[str] = None, + url: Optional[str] = None, + conn_config: Optional[ConnectionConfig] = None, + ) -> None: + """ + Initialize the Client. + + Args: + auth: Authentication method, it can be StaticKeyAuth or JWTAuth. + If None, creates StaticKeyAuth with the api_key. + api_key: Speechmatics API key. If None, uses SPEECHMATICS_API_KEY env var. + url: REST API endpoint URL. If None, uses SPEECHMATICS_BATCH_URL env var + or defaults to production endpoint. + conn_config: Complete connection configuration. + + Raises: + ConfigurationError: If auth is None and API key is not provided/found. + """ + self._auth = auth or StaticKeyAuth(api_key) + self._url = url or os.environ.get("SPEECHMATICS_BATCH_URL") or "https://asr.api.speechmatics.com/v2" + self._conn_config = conn_config or ConnectionConfig() + self._request_id = str(uuid.uuid4()) + self._transport = SyncTransport(self._url, self._conn_config, self._auth, self._request_id) + + self._logger = get_logger(__name__) + self._logger.debug("Client initialized (request_id=%s, url=%s)", self._request_id, self._url) + + def __enter__(self) -> Client: + """ + Context manager entry. + + Returns: + Self for use in with statements. + + Examples: + >>> with Client(api_key="key") as client: + ... job = client.submit_job("audio.wav") + """ + return self + + def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: + """ + Context manager exit with automatic cleanup. + + Args: + exc_type: Exception type if an exception occurred. + exc_val: Exception value if an exception occurred. + exc_tb: Exception traceback if an exception occurred. + """ + self.close() + + def submit_job( + self, + audio_file: Union[str, BinaryIO, None], + *, + config: Optional[JobConfig] = None, + transcription_config: Optional[TranscriptionConfig] = None, + parallel_engines: Optional[int] = None, + user_id: Optional[str] = None, + wait: Optional[int] = None, + format_type: FormatType = FormatType.JSON, + ) -> JobDetails: + """ + Submit a new transcription job. + + Args: + audio_file: Path to audio file or file-like object containing audio data, or None if using fetch_data. + NOTE: You must explicitly pass audio_file=None if providing a fetch_data in the config + config: Complete job configuration. If not provided, uses transcription_config + to build a basic job configuration. + transcription_config: Transcription-specific configuration. Used if config + is not provided. + parallel_engines: Optional number of parallel engines to request for this job. + Sent as ``{"parallel_engines": N}`` in the ``X-SM-Processing-Data`` header. + This only applies when using the container onPrem on http batch mode. + user_id: Optional user identifier to associate with this job. + Sent as ``{"user_id": "..."}`` in the ``X-SM-Processing-Data`` header. + This only applies when using the container onPrem on http batch mode. + wait: Seconds to let the server hold the request open waiting for the + job to finish (synchronous transcription, SaaS only). When the job + finishes in time, the returned JobDetails has status DONE and + carries the transcript in ``transcript``. Otherwise the job is + still running and must be polled as usual. Note that the server + caps this value, and that long-held connections may be closed + early by intermediate proxies. + format_type: Format of the transcript embedded in the response when + ``wait`` is used. Ignored otherwise. + + Returns: + JobDetails object containing the job ID and status, plus the transcript + if the server returned one. + + Raises: + BatchError: If job submission fails. + AuthenticationError: If API key is invalid. + ConfigurationError: If configuration is invalid. + + Note: + Submission is not idempotent: if a submission fails after the server + accepted it (for example a network timeout while waiting), resubmitting + creates a second billable job. Prefer listing jobs to recover the + original job over blind retries. + + Examples: + Basic job submission: + >>> job = client.submit_job("audio.wav") + >>> print(f"Job submitted: {job.id}") + + Submit and get the transcript in one call: + >>> job = client.submit_job("audio.wav", wait=60) + >>> if job.status == JobStatus.DONE: + ... print(job.transcript.transcript_text) + """ + validate_wait(wait) + config = build_job_config(config, transcription_config) + warn_deprecated_operating_point(config) + + config_dict = config.to_dict() + has_fetch_data = validate_audio_source(audio_file, config_dict) + + try: + if has_fetch_data: + multipart_data, filename = build_fetch_data_multipart(config_dict) + return self._submit(multipart_data, filename, config, parallel_engines, user_id, wait, format_type) + + assert audio_file is not None # for type checker; validated above + with prepare_audio_file_sync(audio_file) as (filename, file_data): + multipart_data = build_file_multipart(config_dict, filename, file_data) + return self._submit(multipart_data, filename, config, parallel_engines, user_id, wait, format_type) + except Exception as e: + if isinstance(e, (AuthenticationError, BatchError)): + raise + raise BatchError(f"Job submission failed: {e}") from e + + def get_job_info(self, job_id: str, *, wait: Optional[int] = None) -> JobDetails: + """ + Get information about a specific job. + + Args: + job_id: The unique job identifier. + wait: Seconds to let the server hold the request open until the job + reaches a terminal state (synchronous transcription, SaaS only). + The API applies a small default wait when this is omitted. + Pass 0 to return immediately. + + Returns: + JobDetails object with current job status and metadata. + + Raises: + JobExpiredError: If the job's data has expired and been deleted. + JobError: If job is not found or cannot be retrieved. + AuthenticationError: If API key is invalid. + + Examples: + >>> job_info = client.get_job_info("12345") + >>> print(f"Job status: {job_info.status}") + """ + return self._request_job_info(job_id, wait=wait, request_timeout=None) + + def _request_job_info(self, job_id: str, *, wait: Optional[int], request_timeout: Optional[float]) -> JobDetails: + """ + Fetch job info, optionally under a caller-imposed request timeout. + + ``request_timeout`` lets the polling loop keep a single hung request + from outliving the deadline the caller gave ``wait_for_completion()``. + """ + try: + self._logger.debug("Retrieving job info for job_id=%s (wait=%s)", job_id, wait) + response = self._transport.get( + f"/jobs/{job_id}", + params=build_query_params(wait=wait), + timeout=request_timeout or request_timeout_for_wait(wait, self._conn_config), + ) + job = response.get("job") + if job is None: + raise JobError(f"No job information found for job ID: {job_id}") + return JobDetails.from_dict(job) + except Exception as e: + if isinstance(e, AuthenticationError): + raise + if isinstance(e, TransportError) and e.status_code == 410: + raise JobExpiredError(f"Job {job_id} has expired and is no longer available") from e + raise JobError(f"Failed to get job info: {e}") from e + + def list_jobs( + self, + *, + limit: Optional[int] = None, + created_before: Optional[str] = None, + created_after: Optional[str] = None, + ) -> list[JobDetails]: + """ + List jobs with optional filtering. + + Args: + limit: Maximum number of jobs to return. + created_before: Only return jobs created before this timestamp. + created_after: Only return jobs created after this timestamp. + + Returns: + List of JobDetails objects. + + Raises: + BatchError: If listing jobs fails. + AuthenticationError: If API key is invalid. + + Examples: + >>> jobs = client.list_jobs(limit=10) + >>> for job in jobs: + ... print(f"Job {job.id}: {job.status}") + """ + params = build_list_jobs_params(limit, created_before, created_after) + + try: + self._logger.debug("Listing jobs (limit=%s)", limit) + response = self._transport.get("/jobs", params=params) + jobs_data = response.get("jobs", []) + self._logger.debug("Jobs retrieved (%d jobs)", len(jobs_data)) + return [JobDetails.from_dict(job) for job in jobs_data] + except Exception as e: + if isinstance(e, AuthenticationError): + raise + raise BatchError(f"Failed to list jobs: {e}") from e + + def delete_job(self, job_id: str, *, force: bool = False) -> None: + """ + Delete a job and its results. + + This method permanently deletes a job and all associated data. + Use with caution as this operation cannot be undone. + + Args: + job_id: The unique job identifier. + force: Delete the job even if it is still running. Without this the + API refuses to delete a running job. + + Raises: + JobError: If job cannot be deleted. + AuthenticationError: If API key is invalid. + + Examples: + >>> client.delete_job("12345") + """ + try: + self._logger.debug("Deleting job_id=%s", job_id) + self._transport.delete(f"/jobs/{job_id}", params=build_delete_job_params(force)) + self._logger.debug("Job deleted successfully (job_id=%s)", job_id) + except Exception as e: + if isinstance(e, AuthenticationError): + raise + if isinstance(e, TransportError) and e.status_code == 423: + raise JobError(f"Job {job_id} is still running; pass force=True to delete it") from e + raise JobError(f"Failed to delete job: {e}") from e + + def get_transcript( + self, + job_id: str, + *, + format_type: FormatType = FormatType.JSON, + wait: Optional[int] = None, + ) -> Union[Transcript, str]: + """ + Get the transcript for a completed job. + + Args: + job_id: The unique job identifier. + format_type: Output format (FormatType.JSON, FormatType.TXT, FormatType.SRT). Defaults to FormatType.JSON. + wait: Seconds to let the server hold the request open until the + transcript is ready (synchronous transcription, SaaS only). The + The API applies a small default wait when this is omitted. + Pass 0 to return immediately. + + Returns: + Transcript object for JSON format, or string for text/SRT formats. + + Raises: + JobExpiredError: If the job's data has expired and been deleted. + TranscriptNotReadyError: If the transcript is not available yet (the + job may still be running, or the job ID may not exist). + JobError: If transcript cannot be retrieved or job is not complete. + AuthenticationError: If API key is invalid. + + Examples: + >>> result = client.get_transcript("12345") + >>> print(result.transcript_text) + + >>> # Get plain text transcript + >>> text = client.get_transcript("12345", format_type=FormatType.TXT) + >>> print(text) + """ + try: + self._logger.debug("Retrieving transcript for job_id=%s (format=%s)", job_id, format_type.value) + response = self._transport.get( + f"/jobs/{job_id}/transcript", + params=build_query_params(wait=wait, format_type=format_type), + timeout=request_timeout_for_wait(wait, self._conn_config), + ) + + if format_type == FormatType.JSON: + return Transcript.from_dict(response) + return str(response.get("content", "")) + + except Exception as e: + if isinstance(e, AuthenticationError): + raise + if isinstance(e, TransportError) and e.status_code == 410: + raise JobExpiredError(f"Transcript for job {job_id} has expired and is no longer available") from e + if isinstance(e, TransportError) and e.status_code == 404: + raise TranscriptNotReadyError( + f"Transcript for job {job_id} is not available yet; retry the request " + "(the job may still be running, or the job ID may not exist)" + ) from e + raise JobError(f"Failed to get transcript: {e}") from e + + def wait_for_completion( + self, + job_id: str, + *, + format_type: FormatType = FormatType.JSON, + polling_interval: float = 5.0, + min_polling_interval: float = 0.5, + timeout: Optional[float] = DEFAULT_TIMEOUT, + ) -> Union[Transcript, str]: + """ + Wait for a job to complete and return the result. + + This method polls the job status until it completes (successfully or with error) + and then retrieves the transcript result. + + Args: + job_id: The unique job identifier. + format_type: Output format (FormatType.JSON, FormatType.TXT, FormatType.SRT). Defaults to FormatType.JSON. + polling_interval: Ceiling in seconds for the time between status + checks. Polling starts at ``min_polling_interval`` and backs off + towards this value, so short jobs are caught quickly while + long-running jobs settle into this interval. Up to 20% jitter is + applied so that concurrent clients do not synchronise, so an + individual wait can exceed this by that much. Must be greater than 0. + min_polling_interval: Initial time in seconds between status checks. + Must be greater than 0. + timeout: Maximum time in seconds to wait for completion, one hour + by default. Pass ``None`` to wait indefinitely. + + Returns: + Transcript object for JSON format, or string for text/SRT formats. + + Raises: + TimeoutError: If job doesn't complete within timeout. + JobError: If job fails or cannot be retrieved. + AuthenticationError: If API key is invalid. + + Examples: + >>> job = client.submit_job("audio.wav") + >>> result = client.wait_for_completion(job.id) + >>> print(f"Transcript: {result.transcript_text}") + """ + self._poll_job_status(job_id, polling_interval, min_polling_interval, timeout) + return self.get_transcript(job_id, format_type=format_type) + + def transcribe( + self, + audio_file: Union[str, BinaryIO], + *, + config: Optional[JobConfig] = None, + transcription_config: Optional[TranscriptionConfig] = None, + format_type: FormatType = FormatType.JSON, + polling_interval: float = 5.0, + min_polling_interval: float = 0.5, + timeout: Optional[float] = DEFAULT_TIMEOUT, + parallel_engines: Optional[int] = None, + user_id: Optional[str] = None, + wait: Optional[int] = None, + ) -> Union[Transcript, str]: + """ + Complete transcription workflow: submit job and return the transcript. + + Args: + audio_file: Path to audio file or file-like object. + config: Complete job configuration. + transcription_config: Transcription-specific configuration. + format_type: Output format (FormatType.JSON, FormatType.TXT, FormatType.SRT). Defaults to FormatType.JSON. + polling_interval: Ceiling in seconds for the time between status + checks. Polling starts at ``min_polling_interval`` and backs off + towards this value, so short jobs are caught quickly while + long-running jobs settle into this interval. Up to 20% jitter is + applied so that concurrent clients do not synchronise, so an + individual wait can exceed this by that much. Must be greater than 0. + min_polling_interval: Initial time in seconds between status checks. + Must be greater than 0. + timeout: Maximum time in seconds to wait for completion, one hour + by default. Pass ``None`` to wait indefinitely. + parallel_engines: Optional number of parallel engines to request for this job. + Sent as ``{"parallel_engines": N}`` in the ``X-SM-Processing-Data`` header. + This only applies when using the container onPrem on http batch mode. + user_id: Optional user identifier to associate with this job. + Sent as ``{"user_id": "..."}`` in the ``X-SM-Processing-Data`` header. + This only applies when using the container onPrem on http batch mode. + wait: Seconds to let the server hold the submit request open waiting + for the transcript (synchronous transcription, SaaS only). Short + audio then needs only one round trip; anything still running when + the wait elapses falls back to polling. + + Returns: + Transcript object for JSON format, or string for text/SRT formats. + + Raises: + BatchError: If job submission fails. + TimeoutError: If job doesn't complete within timeout. + JobError: If job fails. + AuthenticationError: If API key is invalid. + + Examples: + >>> result = client.transcribe("audio.wav") + >>> print(f"Transcript: {result.transcript_text}") + + >>> # One round trip for short audio + >>> result = client.transcribe("audio.wav", wait=60) + """ + started_at = time.monotonic() + + # The server-side wait must not outlive the caller's own timeout. + wait = clamp_wait(wait, timeout) + + job = self.submit_job( + audio_file, + config=config, + transcription_config=transcription_config, + parallel_engines=parallel_engines, + user_id=user_id, + wait=wait, + format_type=format_type, + ) + + if job.status == JobStatus.DONE: + if job.transcript is not None: + self._logger.info("Transcription completed during submit (job_id=%s)", job.id) + return job.transcript + return self.get_transcript(job.id, format_type=format_type) + + remaining = None if timeout is None else max(0.0, timeout - (time.monotonic() - started_at)) + + self._logger.debug("Waiting for job completion (job_id=%s)", job.id) + result = self.wait_for_completion( + job.id, + format_type=format_type, + polling_interval=polling_interval, + min_polling_interval=min_polling_interval, + timeout=remaining, + ) + self._logger.info("Transcription job completed successfully (job_id=%s)", job.id) + return result + + def close(self) -> None: + """ + Close the client and cleanup all resources. + + This method is safe to call multiple times and will handle cleanup + gracefully even if errors occur during the process. + + Examples: + >>> client = Client(api_key="key") + >>> try: + ... result = client.transcribe("audio.wav") + >>> finally: + ... client.close() + """ + try: + self._transport.close() + except Exception: + pass # Best effort cleanup + + def _poll_job_status( + self, job_id: str, polling_interval: float, min_polling_interval: float, timeout: Optional[float] + ) -> None: + """Poll job status until completion, failure or timeout.""" + self._logger.debug( + "Starting job status polling for job_id=%s (min_interval=%.1fs, max_interval=%.1fs)", + job_id, + min_polling_interval, + polling_interval, + ) + started_at = time.monotonic() + deadline = None if timeout is None else started_at + timeout + interval = PollingInterval(min_polling_interval, polling_interval) + poll_count = 0 + last_log_time = 0.0 + transient_failures = 0 + + while True: + poll_count += 1 + request_timeout = None + if deadline is not None: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError(f"Job {job_id} did not complete within {timeout} seconds") + # Without this a single hung request could outlive the caller's + # deadline by a whole operation timeout. + request_timeout = min(remaining, self._conn_config.operation_timeout) + + try: + job_info = self._request_job_info(job_id, wait=None, request_timeout=request_timeout) + except Exception as e: + if not is_transient_poll_error(e): + raise + if deadline is not None and time.monotonic() >= deadline: + # The request was cut short by the caller's own deadline + # (the server holds a status request for an unspecified + # time), so report the timeout rather than a transport fault. + raise TimeoutError(f"Job {job_id} did not complete within {timeout} seconds") from e + transient_failures += 1 + if transient_failures > MAX_TRANSIENT_POLL_FAILURES: + raise JobError( + f"Job {job_id} status could not be read after " + f"{transient_failures} consecutive failures: {e}" + ) from e + self._logger.warning( + "Job status poll failed, retrying (job_id=%s, failure=%d/%d): %s", + job_id, + transient_failures, + MAX_TRANSIENT_POLL_FAILURES, + e, + ) + else: + transient_failures = 0 + + if job_info.status == JobStatus.DONE: + self._logger.info("Job completed (job_id=%s, polls=%d)", job_id, poll_count) + self._logger.debug( + "Job turnaround time: %.2fs (job_id=%s, polls=%d)", + time.monotonic() - started_at, + job_id, + poll_count, + ) + return + elif is_job_active(job_info.status): + current_time = time.monotonic() + if current_time - last_log_time >= 30.0: + self._logger.debug("Job still running (job_id=%s, polls=%d)", job_id, poll_count) + last_log_time = current_time + else: + self._logger.warning("Job did not succeed (job_id=%s, status=%s)", job_id, job_info.status.value) + raise_for_failed_status(job_id, job_info.status) + raise JobError(f"Job {job_id} has unexpected status: {job_info.status.value}") + + sleep_for = interval.next() + if deadline is not None: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError(f"Job {job_id} did not complete within {timeout} seconds") + sleep_for = min(sleep_for, remaining) + time.sleep(sleep_for) + + def _submit( + self, + multipart_data: dict[str, Any], + filename: str, + config: JobConfig, + parallel_engines: Optional[int], + user_id: Optional[str], + wait: Optional[int], + format_type: FormatType, + ) -> JobDetails: + """Submit the job and build its JobDetails.""" + response = self._transport.post( + "/jobs", + multipart_data=multipart_data, + extra_headers=build_processing_data_header(parallel_engines, user_id), + params=build_submit_query_params(wait, format_type), + timeout=request_timeout_for_wait(wait, self._conn_config), + ) + job = job_details_from_submit_response(response, config, filename, format_type) + self._logger.debug("Job submitted successfully (job_id=%s, filename=%s)", job.id, filename) + return job diff --git a/sdk/batch/speechmatics/batch/_sync_transport.py b/sdk/batch/speechmatics/batch/_sync_transport.py new file mode 100644 index 00000000..7cf4ad5d --- /dev/null +++ b/sdk/batch/speechmatics/batch/_sync_transport.py @@ -0,0 +1,365 @@ +""" +Blocking transport layer for Speechmatics Batch HTTP communication. + +This module provides the SyncTransport class, the blocking counterpart of +``Transport``. It exposes the same request interface, so both clients can share +all request construction and response parsing logic. +""" + +from __future__ import annotations + +import json as _json +import sys +import uuid +from typing import Any +from typing import Optional + +import httpx + +from ._auth import AuthBase +from ._exceptions import AuthenticationError +from ._exceptions import ConnectionError +from ._exceptions import TransportError +from ._helpers import get_version +from ._logging import get_logger +from ._models import ConnectionConfig + +_JSON_CONTENT_TYPES = ("application/json", "application/vnd.speechmatics.v2+json") + + +class SyncTransport: + """ + Blocking HTTP transport layer for Speechmatics Batch API communication. + + This class handles all low-level HTTP communication with the Speechmatics + Batch API without requiring an event loop, including connection pooling, + request serialization, authentication, and response handling. + + Args: + url: Base URL for the Speechmatics Batch API. + conn_config: Connection configuration including timeouts. + auth: Authentication instance for handling credentials. + request_id: Optional unique identifier for request tracking. Generated + automatically if not provided. + + Examples: + Basic usage: + >>> from ._auth import StaticKeyAuth + >>> transport = SyncTransport( + ... "https://asr.api.speechmatics.com/v2", + ... ConnectionConfig(), + ... StaticKeyAuth("your-api-key"), + ... ) + >>> response = transport.get("/jobs") + >>> transport.close() + """ + + def __init__( + self, + url: str, + conn_config: ConnectionConfig, + auth: AuthBase, + request_id: Optional[str] = None, + ) -> None: + """ + Initialize the transport with connection configuration. + + Args: + url: Base URL for the Speechmatics Batch API. + conn_config: Connection configuration object containing connection parameters. + auth: Authentication instance for handling credentials. + request_id: Optional unique identifier for request tracking. + Generated automatically if not provided. + """ + self._url = url + self._conn_config = conn_config + self._auth = auth + self._request_id = request_id or str(uuid.uuid4()) + self._client: Optional[httpx.Client] = None + self._closed = False + self._logger = get_logger(__name__) + + self._logger.debug("SyncTransport initialized (request_id=%s, url=%s)", self._request_id, self._url) + + def __enter__(self) -> SyncTransport: + """Context manager entry.""" + self._ensure_client() + return self + + def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: + """Context manager exit with automatic cleanup.""" + self.close() + + def get( + self, path: str, params: Optional[dict[str, Any]] = None, timeout: Optional[float] = None + ) -> dict[str, Any]: + """ + Send GET request to the API. + + Args: + path: API endpoint path (e.g., "/jobs") + params: Optional query parameters + timeout: Optional request timeout + + Returns: + JSON response as dictionary + + Raises: + AuthenticationError: If authentication fails + TransportError: If request fails + """ + return self._request("GET", path, params=params, timeout=timeout) + + def post( + self, + path: str, + json_data: Optional[dict[str, Any]] = None, + multipart_data: Optional[dict[str, Any]] = None, + timeout: Optional[float] = None, + extra_headers: Optional[dict[str, Any]] = None, + params: Optional[dict[str, Any]] = None, + ) -> dict[str, Any]: + """ + Send POST request to the API. + + Args: + path: API endpoint path + json_data: Optional JSON data for request body + multipart_data: Optional multipart form data + timeout: Optional request timeout + extra_headers: Optional additional headers to include in the request + params: Optional query parameters + + Returns: + JSON response as dictionary + + Raises: + AuthenticationError: If authentication fails + TransportError: If request fails + """ + return self._request( + "POST", + path, + json_data=json_data, + multipart_data=multipart_data, + timeout=timeout, + extra_headers=extra_headers, + params=params, + ) + + def delete( + self, path: str, timeout: Optional[float] = None, params: Optional[dict[str, Any]] = None + ) -> dict[str, Any]: + """ + Send DELETE request to the API. + + Args: + path: API endpoint path + timeout: Optional request timeout + params: Optional query parameters + + Returns: + JSON response as dictionary + + Raises: + AuthenticationError: If authentication fails + TransportError: If request fails + """ + return self._request("DELETE", path, timeout=timeout, params=params) + + def close(self) -> None: + """ + Close the HTTP client and cleanup resources. + + This method gracefully closes the HTTP client and marks the + transport as closed. It's safe to call multiple times. + """ + if self._client: + try: + self._client.close() + except Exception: + pass # Best effort cleanup + finally: + self._client = None + self._closed = True + + @property + def is_connected(self) -> bool: + """ + Check if the transport has an active client. + + Returns: + True if the client is active, False otherwise + """ + return self._client is not None and not self._closed + + def _ensure_client(self) -> None: + """Ensure the HTTP client is created.""" + if self._client is None and not self._closed: + self._logger.debug( + "Creating HTTP client (connect_timeout=%.1fs, operation_timeout=%.1fs)", + self._conn_config.connect_timeout, + self._conn_config.operation_timeout, + ) + timeout = httpx.Timeout( + self._conn_config.operation_timeout, + connect=self._conn_config.connect_timeout, + ) + self._client = httpx.Client(timeout=timeout) + + def _request( + self, + method: str, + path: str, + params: Optional[dict[str, Any]] = None, + json_data: Optional[dict[str, Any]] = None, + multipart_data: Optional[dict[str, Any]] = None, + timeout: Optional[float] = None, + extra_headers: Optional[dict[str, Any]] = None, + ) -> dict[str, Any]: + """ + Send HTTP request to the API. + + Args: + method: HTTP method (GET, POST, DELETE) + path: API endpoint path + params: Optional query parameters + json_data: Optional JSON data for request body + multipart_data: Optional multipart form data + timeout: Optional request timeout + extra_headers: Optional additional headers to include in the request + + Returns: + JSON response as dictionary + + Raises: + AuthenticationError: If authentication fails + ConnectionError: If connection fails + TransportError: For other transport errors + """ + self._ensure_client() + + if self._client is None: + raise ConnectionError("Failed to create HTTP client") + + url = f"{self._url.rstrip('/')}{path}" + headers = self._prepare_headers() + if extra_headers: + for k, v in extra_headers.items(): + headers[k] = _json.dumps(v) if isinstance(v, dict) else v + + self._logger.debug( + "Sending HTTP request %s %s (json=%s, multipart=%s)", + method, + url, + json_data is not None, + multipart_data is not None, + ) + + kwargs: dict[str, Any] = { + "headers": headers, + "params": params, + } + + if timeout: + kwargs["timeout"] = httpx.Timeout(timeout) + + if json_data: + kwargs["json"] = json_data + elif multipart_data: + kwargs["files"] = self._encode_multipart(multipart_data) + + try: + response = self._client.request(method, url, **kwargs) + return self._handle_response(response) + + except httpx.TimeoutException: + self._logger.error( + "Request timeout %s %s (timeout=%.1fs)", + method, + path, + timeout or self._conn_config.operation_timeout, + ) + raise TransportError(f"Request timeout for {method} {path}") from None + except (AuthenticationError, TransportError): + raise + except httpx.HTTPError as e: + self._logger.error("Request failed %s %s: %s", method, path, e) + raise ConnectionError(f"Request failed: {e}") from e + except Exception as e: + self._logger.error("Unexpected error %s %s: %s", method, path, e) + raise TransportError(f"Unexpected error: {e}") from e + + @staticmethod + def _encode_multipart(multipart_data: dict[str, Any]) -> dict[str, Any]: + """ + Convert shared multipart fields into httpx's files mapping. + + Every field goes through ``files`` (non-file fields with a None + filename) because httpx would otherwise url-encode a body that has no + file in it, and the API requires multipart for fetch_data submissions. + """ + files: dict[str, Any] = {} + for key, value in multipart_data.items(): + if isinstance(value, tuple) and len(value) == 3: + files[key] = value + elif isinstance(value, dict): + files[key] = (None, _json.dumps(value), "application/json") + else: + files[key] = (None, value) + return files + + def _prepare_headers(self) -> dict[str, str]: + """ + Prepare HTTP headers for requests. + + Returns: + Headers dictionary with authentication and tracking info + """ + auth_headers = self._auth.get_auth_headers_sync() + auth_headers[ + "User-Agent" + ] = f"speechmatics-batch-v{get_version()} python/{sys.version_info.major}.{sys.version_info.minor}" + + if self._request_id: + auth_headers["X-Request-Id"] = self._request_id + + return auth_headers + + def _handle_response(self, response: httpx.Response) -> dict[str, Any]: + """ + Handle HTTP response and extract JSON data. + + Args: + response: HTTP response object + + Returns: + JSON response as dictionary + + Raises: + AuthenticationError: For 401/403 responses + TransportError: For other error responses + """ + if response.status_code == 401: + raise AuthenticationError("Invalid API key - authentication failed") + elif response.status_code == 403: + raise AuthenticationError("Access forbidden - check API key permissions") + elif response.status_code >= 400: + self._logger.error("HTTP error %d %s: %s", response.status_code, response.reason_phrase, response.text) + raise TransportError( + f"HTTP {response.status_code}: {response.reason_phrase} - {response.text}", + status_code=response.status_code, + ) + + content_type = response.headers.get("content-type", "").split(";")[0].strip() + + if content_type in _JSON_CONTENT_TYPES: + try: + return dict(response.json()) + except ValueError as e: + self._logger.error("Failed to parse JSON response: %s", e) + raise TransportError(f"Failed to parse response: {e}") from e + + # For non-JSON responses (like plain text transcripts) + self._logger.debug("Parsing text response (content_type=%s)", content_type) + return {"content": response.text, "content_type": content_type} diff --git a/sdk/batch/speechmatics/batch/_transport.py b/sdk/batch/speechmatics/batch/_transport.py index 4beb3d69..d5a24458 100644 --- a/sdk/batch/speechmatics/batch/_transport.py +++ b/sdk/batch/speechmatics/batch/_transport.py @@ -19,6 +19,7 @@ import aiohttp from ._auth import AuthBase +from ._common import PROCESSING_DATA_HEADER from ._exceptions import AuthenticationError from ._exceptions import ConnectionError from ._exceptions import TransportError @@ -26,7 +27,7 @@ from ._logging import get_logger from ._models import ConnectionConfig -PROCESSING_DATA_HEADER = "X-SM-Processing-Data" +__all__ = ["PROCESSING_DATA_HEADER", "Transport"] class Transport: @@ -120,6 +121,7 @@ async def post( multipart_data: Optional[dict[str, Any]] = None, timeout: Optional[float] = None, extra_headers: Optional[dict[str, Any]] = None, + params: Optional[dict[str, Any]] = None, ) -> dict[str, Any]: """ Send POST request to the API. @@ -130,6 +132,7 @@ async def post( multipart_data: Optional multipart form data timeout: Optional request timeout extra_headers: Optional additional headers to include in the request + params: Optional query parameters Returns: JSON response as dictionary @@ -145,15 +148,19 @@ async def post( multipart_data=multipart_data, timeout=timeout, extra_headers=extra_headers, + params=params, ) - async def delete(self, path: str, timeout: Optional[float] = None) -> dict[str, Any]: + async def delete( + self, path: str, timeout: Optional[float] = None, params: Optional[dict[str, Any]] = None + ) -> dict[str, Any]: """ Send DELETE request to the API. Args: path: API endpoint path timeout: Optional request timeout + params: Optional query parameters Returns: JSON response as dictionary @@ -162,7 +169,7 @@ async def delete(self, path: str, timeout: Optional[float] = None) -> dict[str, AuthenticationError: If authentication fails TransportError: If request fails """ - return await self._request("DELETE", path, timeout=timeout) + return await self._request("DELETE", path, timeout=timeout, params=params) async def close(self) -> None: """ @@ -298,6 +305,8 @@ async def _request( "Request timeout %s %s (timeout=%.1fs)", method, path, self._conn_config.operation_timeout ) raise TransportError(f"Request timeout for {method} {path}") from None + except (AuthenticationError, TransportError): + raise except aiohttp.ClientError as e: self._logger.error("Request failed %s %s: %s", method, path, e) raise ConnectionError(f"Request failed: {e}") from e @@ -313,9 +322,9 @@ async def _prepare_headers(self) -> dict[str, str]: Headers dictionary with authentication and tracking info """ auth_headers = await self._auth.get_auth_headers() - auth_headers["User-Agent"] = ( - f"speechmatics-batch-v{get_version()} python/{sys.version_info.major}.{sys.version_info.minor}" - ) + auth_headers[ + "User-Agent" + ] = f"speechmatics-batch-v{get_version()} python/{sys.version_info.major}.{sys.version_info.minor}" if self._request_id: auth_headers["X-Request-Id"] = self._request_id @@ -344,7 +353,10 @@ async def _handle_response(self, response: aiohttp.ClientResponse) -> dict[str, elif response.status >= 400: error_text = await response.text() self._logger.error("HTTP error %d %s: %s", response.status, response.reason, error_text) - raise TransportError(f"HTTP {response.status}: {response.reason} - {error_text}") + raise TransportError( + f"HTTP {response.status}: {response.reason} - {error_text}", + status_code=response.status, + ) # Try to parse JSON response if ( @@ -361,6 +373,8 @@ async def _handle_response(self, response: aiohttp.ClientResponse) -> dict[str, except aiohttp.ContentTypeError as e: self._logger.error("Failed to parse JSON response: %s", e) raise TransportError(f"Failed to parse response: {e}") from e + except (AuthenticationError, TransportError): + raise except Exception as e: self._logger.error("Error handling response: %s", e) raise TransportError(f"Error handling response: {e}") from e diff --git a/sdk/flow/speechmatics/__init__.py b/sdk/flow/speechmatics/__init__.py deleted file mode 100644 index 8db66d3d..00000000 --- a/sdk/flow/speechmatics/__init__.py +++ /dev/null @@ -1 +0,0 @@ -__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/sdk/rt/speechmatics/__init__.py b/sdk/rt/speechmatics/__init__.py deleted file mode 100644 index 8db66d3d..00000000 --- a/sdk/rt/speechmatics/__init__.py +++ /dev/null @@ -1 +0,0 @@ -__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/sdk/tts/speechmatics/__init__.py b/sdk/tts/speechmatics/__init__.py deleted file mode 100644 index 8db66d3d..00000000 --- a/sdk/tts/speechmatics/__init__.py +++ /dev/null @@ -1 +0,0 @@ -__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/sdk/voice/speechmatics/__init__.py b/sdk/voice/speechmatics/__init__.py deleted file mode 100644 index 8db66d3d..00000000 --- a/sdk/voice/speechmatics/__init__.py +++ /dev/null @@ -1 +0,0 @@ -__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/tests/batch/test_client_parity.py b/tests/batch/test_client_parity.py new file mode 100644 index 00000000..c0ac36a3 --- /dev/null +++ b/tests/batch/test_client_parity.py @@ -0,0 +1,433 @@ +""" +Behavioural parity between AsyncClient and Client. + +Every test here runs against both clients through a thin adapter, so a +behaviour that exists in one implementation and not the other fails the build. +This is the guardrail for the two clients being separate implementations. +""" + +import asyncio +import inspect +from io import BytesIO +from unittest.mock import AsyncMock +from unittest.mock import patch + +import pytest + +from speechmatics.batch import AsyncClient +from speechmatics.batch import AuthenticationError +from speechmatics.batch import BatchError +from speechmatics.batch import Client +from speechmatics.batch import ConnectionError as SMConnectionError +from speechmatics.batch import FetchData +from speechmatics.batch import FormatType +from speechmatics.batch import JobConfig +from speechmatics.batch import JobError +from speechmatics.batch import JobExpiredError +from speechmatics.batch import JobStatus +from speechmatics.batch import JobType +from speechmatics.batch import TimeoutError as SMTimeoutError +from speechmatics.batch import Transcript +from speechmatics.batch import TranscriptNotReadyError +from speechmatics.batch import TransportError + +# Polling intervals must be positive, so tests use the smallest interval that +# keeps them fast rather than disabling the wait entirely. +MIN_SLEEP = 0.001 + + +def _transcript_payload(text: str = "Hello") -> dict: + return { + "format": "2.9", + "job": {"id": "job-1", "created_at": "t", "data_name": "audio.wav"}, + "metadata": {"created_at": "t", "type": "transcription"}, + "results": [ + { + "type": "word", + "start_time": 0.0, + "end_time": 0.5, + "alternatives": [{"content": text, "confidence": 0.9, "language": "en"}], + } + ], + } + + +class ClientUnderTest: + """Drives either client with one blocking interface.""" + + def __init__(self, kind: str): + self.kind = kind + self.is_async = kind == "async" + self.client = AsyncClient(api_key="test-key") if self.is_async else Client(api_key="test-key") + # A private loop, so this fixture never mutates global event-loop state. + self._loop = asyncio.new_event_loop() if self.is_async else None + + def close(self) -> None: + if self._loop is not None: + self._loop.close() + + def call(self, method: str, *args, **kwargs): + result = getattr(self.client, method)(*args, **kwargs) + if inspect.isawaitable(result): + assert self._loop is not None + return self._loop.run_until_complete(result) + return result + + def patch_transport(self, method: str, **kwargs): + if self.is_async: + kwargs.setdefault("new_callable", AsyncMock) + return patch.object(self.client._transport, method, **kwargs) + + +@pytest.fixture(params=["sync", "async"]) +def client(request): + """Each test body runs twice: once per client implementation.""" + under_test = ClientUnderTest(request.param) + try: + yield under_test + finally: + under_test.close() + + +class TestSubmitParity: + def test_returns_job_details(self, client): + with client.patch_transport("post") as post: + post.return_value = {"id": "job-1", "created_at": "t"} + job = client.call("submit_job", BytesIO(b"audio")) + + assert (job.id, job.status) == ("job-1", JobStatus.RUNNING) + + def test_embedded_txt_transcript(self, client): + with client.patch_transport("post") as post: + post.return_value = {"id": "job-1", "status": "done", "txt": "Hi there"} + job = client.call("submit_job", BytesIO(b"audio"), wait=60, format_type=FormatType.TXT) + + assert job.status == JobStatus.DONE + assert job.transcript == "Hi there" + + def test_embedded_json_transcript_under_documented_key(self, client): + with client.patch_transport("post") as post: + post.return_value = {"id": "job-1", "status": "done", "json-v2": _transcript_payload()} + job = client.call("submit_job", BytesIO(b"audio"), wait=60) + + assert isinstance(job.transcript, Transcript) + assert job.transcript.transcript_text == "Hello" + + def test_wait_elapsed_leaves_job_created(self, client): + with client.patch_transport("post") as post: + post.return_value = {"id": "job-1", "status": "created"} + job = client.call("submit_job", BytesIO(b"audio"), wait=5) + + assert job.status == JobStatus.CREATED + assert job.transcript is None + + def test_missing_job_id_raises_batch_error(self, client): + with client.patch_transport("post") as post: + post.return_value = {"created_at": "t"} + with pytest.raises(BatchError, match="No job ID returned"): + client.call("submit_job", BytesIO(b"audio")) + + def test_negative_wait_raises_value_error(self, client): + """A programmer error stays a ValueError, not a BatchError.""" + with pytest.raises(ValueError, match="non-negative"): + client.call("submit_job", BytesIO(b"audio"), wait=-1) + + def test_fractional_wait_raises_value_error(self, client): + with pytest.raises(ValueError, match="whole number"): + client.call("submit_job", BytesIO(b"audio"), wait=2.5) + + def test_conflicting_audio_sources_raise_value_error(self, client): + config = JobConfig(type=JobType.TRANSCRIPTION, fetch_data=FetchData(url="https://e.com/a.wav")) + with pytest.raises(ValueError, match="Cannot specify both"): + client.call("submit_job", BytesIO(b"audio"), config=config) + + def test_timeout_budget_not_exceeded_by_wait(self, client): + """transcribe(timeout=5, wait=60) must not ask the server to wait 60s.""" + with client.patch_transport("post") as post: + post.return_value = {"id": "job-1", "status": "done", "txt": "quick"} + client.call("transcribe", BytesIO(b"audio"), wait=60, timeout=5, format_type=FormatType.TXT) + + assert post.call_args.kwargs["params"]["wait"] == "5" + + def test_request_timeout_covers_upload_and_wait(self, client): + with client.patch_transport("post") as post: + post.return_value = {"id": "job-1", "status": "created"} + client.call("submit_job", BytesIO(b"audio"), wait=60) + + # Must exceed the operation timeout, not replace it. + assert post.call_args.kwargs["timeout"] > client.client._conn_config.operation_timeout + + +class TestTranscriptParity: + def test_json_transcript(self, client): + with client.patch_transport("get") as get: + get.return_value = _transcript_payload("Parity") + result = client.call("get_transcript", "job-1") + + assert isinstance(result, Transcript) + assert result.transcript_text == "Parity" + + def test_text_transcript(self, client): + with client.patch_transport("get") as get: + get.return_value = {"content": "plain text"} + result = client.call("get_transcript", "job-1", format_type=FormatType.TXT) + + assert result == "plain text" + + def test_404_raises_transcript_not_ready(self, client): + with client.patch_transport("get", side_effect=TransportError("HTTP 404", status_code=404)): + with pytest.raises(TranscriptNotReadyError, match="not available yet"): + client.call("get_transcript", "job-1", wait=5) + + def test_410_raises_job_expired(self, client): + """The API returns HTTP 410, not a status field, for expired data.""" + with client.patch_transport("get", side_effect=TransportError("HTTP 410", status_code=410)): + with pytest.raises(JobExpiredError, match="has expired"): + client.call("get_transcript", "job-1") + + def test_job_expired_is_a_job_error(self, client): + """Existing `except JobError` handlers keep working.""" + with client.patch_transport("get", side_effect=TransportError("HTTP 410", status_code=410)): + with pytest.raises(JobError): + client.call("get_transcript", "job-1") + + def test_transcript_not_ready_is_a_job_error(self, client): + """Existing `except JobError` handlers keep working.""" + with client.patch_transport("get", side_effect=TransportError("HTTP 404", status_code=404)): + with pytest.raises(JobError): + client.call("get_transcript", "job-1") + + def test_other_http_errors_stay_job_errors(self, client): + with client.patch_transport("get", side_effect=TransportError("HTTP 500", status_code=500)): + with pytest.raises(JobError, match="Failed to get transcript"): + client.call("get_transcript", "job-1") + + def test_auth_errors_are_not_wrapped(self, client): + with client.patch_transport("get", side_effect=AuthenticationError("bad key")): + with pytest.raises(AuthenticationError): + client.call("get_transcript", "job-1") + + +class TestPollingParity: + def test_created_then_done(self, client): + responses = [ + {"job": {"id": "job-1", "status": "created"}}, + {"job": {"id": "job-1", "status": "done"}}, + _transcript_payload(), + ] + with client.patch_transport("get", side_effect=responses): + result = client.call("wait_for_completion", "job-1", polling_interval=MIN_SLEEP) + + assert isinstance(result, Transcript) + + def test_unrecognised_status_raises_job_error(self, client): + """A status this SDK version does not know about is a JobError, not a crash.""" + with client.patch_transport("get") as get: + get.return_value = {"job": {"id": "job-1", "status": "some-new-status"}} + with pytest.raises(JobError): + client.call("wait_for_completion", "job-1", polling_interval=MIN_SLEEP) + + @pytest.mark.parametrize( + ("status", "message"), + [ + ("rejected", "was rejected"), + ("deleted", "deleted before it finished"), + ], + ) + def test_terminal_failures_are_reported_accurately(self, client, status, message): + with client.patch_transport("get") as get: + get.return_value = {"job": {"id": "job-1", "status": status}} + with pytest.raises(JobError, match=message): + client.call("wait_for_completion", "job-1", polling_interval=MIN_SLEEP) + + def test_timeout_raises_timeout_error(self, client): + with client.patch_transport("get") as get: + get.return_value = {"job": {"id": "job-1", "status": "running"}} + with pytest.raises(SMTimeoutError): + client.call("wait_for_completion", "job-1", polling_interval=MIN_SLEEP, timeout=0.01) + + def test_waiting_is_bounded_by_default(self, client): + """An unbounded default would hang forever on a job that never finishes.""" + for method in ("wait_for_completion", "transcribe"): + default = inspect.signature(getattr(client.client, method)).parameters["timeout"].default + + assert default is not None, f"{method}() defaults to waiting forever" + assert default == 3600.0 + + +class TestPollingResilienceParity: + """A long wait makes hundreds of requests; one blip must not abandon the job.""" + + def test_transient_failure_is_retried(self, client): + responses = [ + TransportError("HTTP 502", status_code=502), + {"job": {"id": "job-1", "status": "running"}}, + SMConnectionError("Request failed: connection reset"), + {"job": {"id": "job-1", "status": "done"}}, + _transcript_payload("Survived"), + ] + with client.patch_transport("get", side_effect=responses) as get: + result = client.call("wait_for_completion", "job-1", polling_interval=MIN_SLEEP) + + assert result.transcript_text == "Survived" + assert get.call_count == 5 + + def test_gives_up_after_repeated_failures(self, client): + with client.patch_transport("get", side_effect=TransportError("HTTP 503", status_code=503)) as get: + with pytest.raises(JobError, match="consecutive failures"): + client.call("wait_for_completion", "job-1", polling_interval=MIN_SLEEP) + + # Six attempts: the first failure plus MAX_TRANSIENT_POLL_FAILURES retries. + assert get.call_count == 6 + + def test_failure_streak_resets_after_a_good_poll(self, client): + """Blips spread over a long wait must not accumulate into a give-up.""" + responses = [ + *[TransportError("HTTP 503", status_code=503)] * 5, + {"job": {"id": "job-1", "status": "running"}}, + *[TransportError("HTTP 503", status_code=503)] * 5, + {"job": {"id": "job-1", "status": "done"}}, + _transcript_payload("Recovered"), + ] + with client.patch_transport("get", side_effect=responses): + result = client.call("wait_for_completion", "job-1", polling_interval=MIN_SLEEP) + + assert result.transcript_text == "Recovered" + + def test_authentication_failure_is_not_retried(self, client): + with client.patch_transport("get", side_effect=AuthenticationError("bad key")) as get: + with pytest.raises(AuthenticationError): + client.call("wait_for_completion", "job-1", polling_interval=MIN_SLEEP) + + assert get.call_count == 1 + + def test_expired_job_is_not_retried(self, client): + with client.patch_transport("get", side_effect=TransportError("HTTP 410", status_code=410)) as get: + with pytest.raises(JobExpiredError): + client.call("wait_for_completion", "job-1", polling_interval=MIN_SLEEP) + + assert get.call_count == 1 + + def test_unknown_job_is_not_retried(self, client): + with client.patch_transport("get", side_effect=TransportError("HTTP 404", status_code=404)) as get: + with pytest.raises(JobError): + client.call("wait_for_completion", "job-1", polling_interval=MIN_SLEEP) + + assert get.call_count == 1 + + def test_retries_still_respect_the_timeout(self, client): + with client.patch_transport("get", side_effect=TransportError("HTTP 503", status_code=503)): + with pytest.raises(SMTimeoutError): + client.call("wait_for_completion", "job-1", polling_interval=100.0, timeout=0.01) + + @pytest.mark.parametrize("interval", [0, -1.0]) + def test_non_positive_polling_interval_is_rejected(self, client, interval): + """Zero never backs off, so it would poll in an unthrottled loop.""" + with client.patch_transport("get") as get: + get.return_value = {"job": {"id": "job-1", "status": "running"}} + with pytest.raises(ValueError, match="polling_interval"): + client.call("wait_for_completion", "job-1", polling_interval=interval) + + @pytest.mark.parametrize("interval", [0, -1.0]) + def test_non_positive_min_polling_interval_is_rejected(self, client, interval): + with client.patch_transport("get") as get: + get.return_value = {"job": {"id": "job-1", "status": "running"}} + with pytest.raises(ValueError, match="min_polling_interval"): + client.call("wait_for_completion", "job-1", min_polling_interval=interval) + + + +class TestTranscribeParity: + def test_single_round_trip_when_wait_succeeds(self, client): + with client.patch_transport("post") as post: + with client.patch_transport("get") as get: + post.return_value = {"id": "job-1", "status": "done", "txt": "One call"} + result = client.call("transcribe", BytesIO(b"audio"), wait=60, format_type=FormatType.TXT) + + assert result == "One call" + get.assert_not_called() + + def test_falls_back_to_polling(self, client): + responses = [ + {"job": {"id": "job-1", "status": "created"}}, + {"job": {"id": "job-1", "status": "done"}}, + _transcript_payload("Polled"), + ] + with client.patch_transport("post") as post: + with client.patch_transport("get", side_effect=responses) as get: + post.return_value = {"id": "job-1", "status": "created"} + result = client.call("transcribe", BytesIO(b"audio"), wait=5, polling_interval=MIN_SLEEP) + + assert result.transcript_text == "Polled" + # One poll to see it's still running, one to see it's done, one to fetch the transcript. + assert get.call_count == 3 + + def test_no_query_params_without_wait(self, client): + with client.patch_transport("post") as post: + post.return_value = {"id": "job-1", "created_at": "t"} + client.call("submit_job", BytesIO(b"audio"), format_type=FormatType.TXT) + + assert post.call_args.kwargs["params"] is None + assert post.call_args.kwargs["timeout"] is None + + +class TestListAndDeleteParity: + def test_list_jobs_params(self, client): + with client.patch_transport("get") as get: + get.return_value = {"jobs": []} + client.call("list_jobs", limit=5, created_before="2024-01-01", created_after="2023-01-01") + + assert get.call_args.kwargs["params"] == { + "limit": "5", + "created_before": "2024-01-01", + "created_after": "2023-01-01", + } + + def test_list_jobs_without_filters(self, client): + with client.patch_transport("get") as get: + get.return_value = {"jobs": []} + client.call("list_jobs") + + assert get.call_args.kwargs["params"] is None + + def test_delete_job(self, client): + with client.patch_transport("delete") as delete: + client.call("delete_job", "job-1") + + assert delete.call_args.args[0] == "/jobs/job-1" + + def test_delete_job_force(self, client): + with client.patch_transport("delete") as delete: + client.call("delete_job", "job-1", force=True) + + assert delete.call_args.kwargs["params"] == {"force": "true"} + + def test_delete_job_without_force_sends_no_params(self, client): + with client.patch_transport("delete") as delete: + client.call("delete_job", "job-1") + + assert delete.call_args.kwargs["params"] is None + + def test_locked_job_suggests_force(self, client): + """The API answers 423 for a running job; the error must say what to do.""" + locked = TransportError("HTTP 423: Locked", status_code=423) + with client.patch_transport("delete", side_effect=locked): + with pytest.raises(JobError, match="pass force=True"): + client.call("delete_job", "job-1") + + def test_other_delete_failures_keep_their_message(self, client): + with client.patch_transport("delete", side_effect=TransportError("HTTP 500", status_code=500)): + with pytest.raises(JobError, match="Failed to delete job"): + client.call("delete_job", "job-1") + + def test_missing_job_info_raises(self, client): + with client.patch_transport("get") as get: + get.return_value = {} + with pytest.raises(JobError, match="No job information found"): + client.call("get_job_info", "job-1") + + def test_get_job_info_410_raises_job_expired(self, client): + """The API returns HTTP 410, not a status field, for expired data.""" + with client.patch_transport("get", side_effect=TransportError("HTTP 410", status_code=410)): + with pytest.raises(JobExpiredError, match="has expired"): + client.call("get_job_info", "job-1") diff --git a/tests/batch/test_integration.py b/tests/batch/test_integration.py new file mode 100644 index 00000000..adbf3621 --- /dev/null +++ b/tests/batch/test_integration.py @@ -0,0 +1,153 @@ +""" +Integration tests against the real Speechmatics Batch API. + +These verify the things mocks cannot: the actual wire format of a synchronous +transcription response, the real status values, and the behaviour of a +long-held connection. Synchronous transcription is SaaS-only, so these are +skipped against on-premises endpoints. + +Run with: + + SPEECHMATICS_API_KEY=... pytest tests/batch -m integration + +They submit real, billable jobs, so they only run when an API key is set. +""" + +import os +from pathlib import Path + +import pytest + +from speechmatics.batch import AsyncClient +from speechmatics.batch import Client +from speechmatics.batch import FormatType +from speechmatics.batch import JobError +from speechmatics.batch import JobStatus +from speechmatics.batch import Transcript +from speechmatics.batch import TranscriptNotReadyError +from speechmatics.batch._common import api_format_value +from speechmatics.batch._common import is_job_active + +pytestmark = [ + pytest.mark.integration, + pytest.mark.skipif( + not os.environ.get("SPEECHMATICS_API_KEY"), + reason="SPEECHMATICS_API_KEY not set", + ), +] + +# Generous, because a real job is being transcribed. +WAIT_SECONDS = 60 + + +@pytest.fixture(scope="module") +def audio_file() -> str: + """ + Real speech, so the transcript is non-empty. + + Silence transcribes to no text at all, and the API omits an empty + transcript, which makes "was a transcript embedded?" unanswerable. + """ + path = Path(__file__).resolve().parents[2] / "examples" / "example1.wav" + if not path.exists(): + pytest.skip(f"sample audio not found at {path}") + return str(path) + + +@pytest.fixture +def client(): + with Client() as sync_client: + yield sync_client + + +class TestSynchronousTranscriptionWireFormat: + """Pin the response shape this SDK parses, against the live API.""" + + def test_embedded_json_transcript_key(self, client, audio_file): + """The transcript must arrive under the 'json-v2' key, not merged or elsewhere.""" + job = client.submit_job(audio_file, wait=WAIT_SECONDS) + + if job.status != JobStatus.DONE: + pytest.skip(f"Server did not finish within the wait window (status: {job.status.value})") + + assert isinstance( + job.transcript, Transcript + ), "Embedded json-v2 transcript was not parsed; the response shape may have changed" + + def test_embedded_txt_transcript_key(self, client, audio_file): + job = client.submit_job(audio_file, wait=WAIT_SECONDS, format_type=FormatType.TXT) + + if job.status != JobStatus.DONE: + pytest.skip(f"Server did not finish within the wait window (status: {job.status.value})") + assert isinstance( + job.transcript, str + ), f"expected a txt transcript under the {api_format_value(FormatType.TXT)!r} key, got None" + assert job.transcript.strip() + + def test_raw_response_shape_is_what_the_docs_describe(self, client, audio_file): + """Inspect the untouched response body, so a shape change is visible here first.""" + from speechmatics.batch._common import build_file_multipart + from speechmatics.batch._helpers import prepare_audio_file_sync + + config = {"type": "transcription", "transcription_config": {"language": "en"}} + with prepare_audio_file_sync(audio_file) as (filename, data): + response = client._transport.post( + "/jobs", + multipart_data=build_file_multipart(config, filename, data), + params={"wait": str(WAIT_SECONDS)}, + timeout=WAIT_SECONDS + 60, + ) + + assert "id" in response and "status" in response + if response["status"] == JobStatus.DONE.value: + key = api_format_value(FormatType.JSON) + assert key in response, f"expected transcript under {key!r}, got keys {sorted(response)}" + assert isinstance(response[key], dict) + + +class TestSynchronousTranscriptionFlow: + def test_transcribe_in_one_call(self, client, audio_file): + result = client.transcribe(audio_file, wait=WAIT_SECONDS, format_type=FormatType.TXT) + assert isinstance(result, str) + + def test_transcribe_without_wait_still_polls(self, client, audio_file): + result = client.transcribe(audio_file, polling_interval=2.0, timeout=300.0) + assert isinstance(result, Transcript) + + def test_transcript_before_ready_raises_not_ready(self, client, audio_file): + """wait=0 should not have a transcript yet, and must raise the typed error.""" + job = client.submit_job(audio_file, wait=0) + try: + with pytest.raises(TranscriptNotReadyError): + client.get_transcript(job.id, wait=0) + finally: + client.delete_job(job.id, force=True) + + def test_wait_zero_returns_immediately(self, client, audio_file): + job = client.submit_job(audio_file, wait=0) + try: + assert is_job_active(job.status) + assert job.transcript is None + finally: + client.delete_job(job.id, force=True) + + @pytest.mark.asyncio + async def test_async_client_matches_sync(self, audio_file): + async with AsyncClient() as async_client: + result = await async_client.transcribe(audio_file, wait=WAIT_SECONDS, format_type=FormatType.TXT) + assert isinstance(result, str) + + +class TestDeletingRunningJobs: + def test_running_job_needs_force(self, client, audio_file): + """The API answers HTTP 423 for a running job unless force is passed.""" + job = client.submit_job(audio_file, wait=0) + try: + with pytest.raises(JobError, match="pass force=True"): + client.delete_job(job.id) + finally: + client.delete_job(job.id, force=True) + + def test_force_deletes_a_running_job(self, client, audio_file): + job = client.submit_job(audio_file, wait=0) + client.delete_job(job.id, force=True) diff --git a/tests/batch/test_models.py b/tests/batch/test_models.py index f0130778..48855c04 100644 --- a/tests/batch/test_models.py +++ b/tests/batch/test_models.py @@ -1,8 +1,10 @@ -import json - import pytest -from speechmatics.batch._models import JobConfig, Model, OperatingPoint, TranscriptFilteringConfig, TranscriptionConfig +from speechmatics.batch._models import JobConfig +from speechmatics.batch._models import Model +from speechmatics.batch._models import OperatingPoint +from speechmatics.batch._models import TranscriptFilteringConfig +from speechmatics.batch._models import TranscriptionConfig class TestTranscriptFilteringConfigToDict: diff --git a/tests/batch/test_polling.py b/tests/batch/test_polling.py new file mode 100644 index 00000000..7399374f --- /dev/null +++ b/tests/batch/test_polling.py @@ -0,0 +1,124 @@ +""" +Unit tests for the status-polling primitives. + +These cover the backoff schedule and the transient-failure classification in +isolation; the behaviour they drive is exercised end to end against both +clients in test_client_parity.py. +""" + +import pytest + +from speechmatics.batch import AuthenticationError +from speechmatics.batch import ConnectionError as SMConnectionError +from speechmatics.batch import JobError +from speechmatics.batch import JobExpiredError +from speechmatics.batch import TransportError +from speechmatics.batch._common import PollingInterval +from speechmatics.batch._common import is_transient_poll_error +from speechmatics.batch._common import jittered_interval + + +def _reraise_as_job_error(cause: Exception) -> JobError: + """A failure shaped the way get_job_info() re-raises transport errors.""" + error = JobError(f"Failed to get job info: {cause}") + error.__cause__ = cause + return error + + +class TestPollingInterval: + def test_ramps_by_multiplier_up_to_the_cap(self): + interval = PollingInterval(1.0, 8.0, multiplier=2.0) + + # Jitter is +/-20%, so assert the band each step should land in. + for expected in (1.0, 2.0, 4.0, 8.0, 8.0, 8.0): + assert interval.next() == pytest.approx(expected, rel=0.2) + + def test_never_exceeds_the_cap_beyond_jitter(self): + interval = PollingInterval(0.5, 5.0) + + values = [interval.next() for _ in range(20)] + + assert max(values) <= 5.0 * 1.2 + # The tail has converged on the cap rather than still climbing. + assert values[-1] == pytest.approx(5.0, rel=0.2) + + def test_starts_at_min_interval(self): + assert PollingInterval(0.5, 5.0).next() == pytest.approx(0.5, rel=0.2) + + def test_min_above_max_is_clamped_to_max(self): + """A caller who inverts the two gets the cap, not a wait longer than it.""" + interval = PollingInterval(30.0, 5.0) + + assert interval.next() == pytest.approx(5.0, rel=0.2) + + @pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({"min_interval": -1.0, "max_interval": 5.0}, "min_polling_interval"), + ({"min_interval": 0.5, "max_interval": -5.0}, "polling_interval"), + ({"min_interval": 0.5, "max_interval": 5.0, "multiplier": 1.0}, "multiplier"), + ], + ) + def test_rejects_invalid_arguments(self, kwargs, message): + with pytest.raises(ValueError, match=message): + PollingInterval(**kwargs) + + @pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({"min_interval": 0, "max_interval": 5.0}, "min_polling_interval"), + ({"min_interval": 0.5, "max_interval": 0}, "polling_interval"), + ], + ) + def test_rejects_zero(self, kwargs, message): + """Zero never grows, so it would poll in an unthrottled loop.""" + with pytest.raises(ValueError, match=message): + PollingInterval(**kwargs) + + +class TestJitteredInterval: + def test_stays_within_twenty_percent(self): + values = [jittered_interval(10.0) for _ in range(100)] + + assert all(8.0 <= value <= 12.0 for value in values) + # Jitter actually varies, rather than returning the base every time. + assert len(set(values)) > 1 + + +class TestTransientPollErrors: + @pytest.mark.parametrize("status_code", [408, 429, 500, 502, 503, 504]) + def test_retryable_status_codes(self, status_code): + error = _reraise_as_job_error(TransportError(f"HTTP {status_code}", status_code=status_code)) + + assert is_transient_poll_error(error) is True + + def test_request_timeout_without_status_code_is_retryable(self): + error = _reraise_as_job_error(TransportError("Request timeout for GET /jobs/job-1")) + + assert is_transient_poll_error(error) is True + + def test_connection_failure_is_retryable(self): + error = _reraise_as_job_error(SMConnectionError("Request failed: connection reset")) + + assert is_transient_poll_error(error) is True + + @pytest.mark.parametrize("status_code", [400, 404, 410, 422]) + def test_client_errors_are_not_retryable(self, status_code): + error = _reraise_as_job_error(TransportError(f"HTTP {status_code}", status_code=status_code)) + + assert is_transient_poll_error(error) is False + + def test_authentication_errors_are_not_retryable(self): + assert is_transient_poll_error(AuthenticationError("bad key")) is False + + def test_expired_jobs_are_not_retryable(self): + error = JobExpiredError("Job job-1 has expired") + error.__cause__ = TransportError("HTTP 410", status_code=410) + + assert is_transient_poll_error(error) is False + + def test_job_error_without_a_transport_cause_is_not_retryable(self): + """A malformed response body is an answer, not a blip worth repeating.""" + error = _reraise_as_job_error(JobError("No job information found for job ID: job-1")) + + assert is_transient_poll_error(error) is False diff --git a/tests/batch/test_submit_job.py b/tests/batch/test_submit_job.py index bbbf364c..fa3605a0 100644 --- a/tests/batch/test_submit_job.py +++ b/tests/batch/test_submit_job.py @@ -1,21 +1,19 @@ """Unit tests for AsyncClient.submit_job, focusing on the parallel engines and user_id features.""" -import json from io import BytesIO +from typing import Optional from unittest.mock import AsyncMock from unittest.mock import MagicMock from unittest.mock import patch -from typing import Optional - import pytest +from speechmatics.batch import PROCESSING_DATA_HEADER from speechmatics.batch import AsyncClient from speechmatics.batch import JobConfig from speechmatics.batch import JobStatus from speechmatics.batch import JobType from speechmatics.batch import TranscriptionConfig -from speechmatics.batch import PROCESSING_DATA_HEADER def _make_client(api_key: str = "test-key") -> AsyncClient: diff --git a/tests/batch/test_sync_client.py b/tests/batch/test_sync_client.py new file mode 100644 index 00000000..6881192e --- /dev/null +++ b/tests/batch/test_sync_client.py @@ -0,0 +1,520 @@ +"""Unit tests for the synchronous Client, SyncTransport and sync authentication.""" + +import json +import logging +import time +from io import BytesIO +from unittest.mock import patch + +import httpx +import pytest + +from speechmatics.batch import PROCESSING_DATA_HEADER +from speechmatics.batch import AuthBase +from speechmatics.batch import AuthenticationError +from speechmatics.batch import Client +from speechmatics.batch import ConnectionConfig +from speechmatics.batch import FetchData +from speechmatics.batch import FormatType +from speechmatics.batch import JobConfig +from speechmatics.batch import JobStatus +from speechmatics.batch import JobType +from speechmatics.batch import JWTAuth +from speechmatics.batch import StaticKeyAuth +from speechmatics.batch import TimeoutError as SMTimeoutError +from speechmatics.batch import TranscriptionConfig +from speechmatics.batch import TransportError +from speechmatics.batch._sync_transport import SyncTransport + +# Polling intervals must be positive, so tests use the smallest interval that +# keeps them fast rather than disabling the wait entirely. +MIN_SLEEP = 0.001 + + +def _transport_with_handler(handler, **kwargs) -> SyncTransport: + """A SyncTransport whose HTTP calls are served by an in-process handler.""" + transport = SyncTransport( + "https://asr.api.speechmatics.com/v2", + ConnectionConfig(), + StaticKeyAuth("test-key"), + request_id="req-1", + **kwargs, + ) + transport._client = httpx.Client(transport=httpx.MockTransport(handler)) + return transport + + +def _json_response(payload: dict, status_code: int = 200) -> httpx.Response: + return httpx.Response(status_code, json=payload) + + +# --------------------------------------------------------------------------- +# Sync authentication +# --------------------------------------------------------------------------- + + +class TestSyncAuth: + def test_static_key_auth_headers(self): + assert StaticKeyAuth("abc").get_auth_headers_sync() == {"Authorization": "Bearer abc"} + + def test_custom_auth_without_sync_support_raises_clear_error(self): + class CustomAuth(AuthBase): + async def get_auth_headers(self): + return {"Authorization": "Bearer custom"} + + # Existing custom auth classes still instantiate; only sync use fails. + auth = CustomAuth() + with pytest.raises(NotImplementedError, match="does not support synchronous authentication"): + auth.get_auth_headers_sync() + + def test_sync_only_auth_can_be_implemented(self): + """A blocking caller must not have to write an async method.""" + + class SyncOnlyAuth(AuthBase): + def get_auth_headers_sync(self): + return {"Authorization": "Bearer sync-only"} + + client = Client(auth=SyncOnlyAuth()) + assert client._auth.get_auth_headers_sync() == {"Authorization": "Bearer sync-only"} + + @pytest.mark.asyncio + async def test_sync_only_auth_reports_clearly_when_used_async(self): + class SyncOnlyAuth(AuthBase): + def get_auth_headers_sync(self): + return {"Authorization": "Bearer sync-only"} + + with pytest.raises(NotImplementedError, match="does not support asynchronous authentication"): + await SyncOnlyAuth().get_auth_headers() + + def test_constructible_without_an_event_loop(self): + """ + Sync callers have no event loop. On Python 3.9 asyncio.Lock() binds to + the current loop when constructed, so building JWTAuth outside a loop + used to raise RuntimeError. + """ + import asyncio + + previous = None + try: + previous = asyncio.get_event_loop_policy().get_event_loop() + except RuntimeError: + pass + + asyncio.set_event_loop(None) + try: + auth = JWTAuth("api-key") + with patch("httpx.post", return_value=httpx.Response(201, json={"key_value": "t"})): + assert auth.get_auth_headers_sync() == {"Authorization": "Bearer t"} + finally: + asyncio.set_event_loop(previous) + + def test_jwt_auth_mints_token_synchronously(self): + auth = JWTAuth("api-key", ttl=300) + response = httpx.Response(201, json={"key_value": "jwt-token"}) + + with patch("httpx.post", return_value=response) as mock_post: + headers = auth.get_auth_headers_sync() + + assert headers == {"Authorization": "Bearer jwt-token"} + assert mock_post.call_args.kwargs["json"]["ttl"] == 300 + + def test_jwt_auth_caches_token(self): + auth = JWTAuth("api-key", ttl=300) + response = httpx.Response(201, json={"key_value": "jwt-token"}) + + with patch("httpx.post", return_value=response) as mock_post: + auth.get_auth_headers_sync() + auth.get_auth_headers_sync() + + assert mock_post.call_count == 1 + + def test_jwt_auth_error_is_not_double_wrapped(self): + auth = JWTAuth("api-key") + + with patch("httpx.post", return_value=httpx.Response(401, text="nope")): + with pytest.raises(AuthenticationError, match="Failed to generate JWT: HTTP 401"): + auth.get_auth_headers_sync() + + +# --------------------------------------------------------------------------- +# SyncTransport +# --------------------------------------------------------------------------- + + +class TestSyncTransportRequests: + def test_get_sends_auth_and_tracking_headers(self): + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["headers"] = request.headers + return _json_response({"jobs": []}) + + transport = _transport_with_handler(handler) + transport.get("/jobs") + + assert captured["headers"]["authorization"] == "Bearer test-key" + assert captured["headers"]["x-request-id"] == "req-1" + assert captured["headers"]["user-agent"].startswith("speechmatics-batch-v") + + def test_get_sends_query_params(self): + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["url"] = str(request.url) + return _json_response({"job": {"id": "j1", "status": "done"}}) + + transport = _transport_with_handler(handler) + transport.get("/jobs/j1", params={"wait": "60"}) + + assert captured["url"] == "https://asr.api.speechmatics.com/v2/jobs/j1?wait=60" + + def test_post_encodes_config_and_file_as_multipart(self): + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["content_type"] = request.headers["content-type"] + captured["body"] = request.content + return _json_response({"id": "j1"}, status_code=201) + + transport = _transport_with_handler(handler) + transport.post( + "/jobs", + multipart_data={ + "config": {"type": "transcription"}, + "data_file": ("audio.wav", b"RIFF", "audio/wav"), + }, + ) + + assert captured["content_type"].startswith("multipart/form-data") + assert b'name="config"' in captured["body"] + assert b'name="data_file"; filename="audio.wav"' in captured["body"] + assert b"RIFF" in captured["body"] + + def test_post_uses_multipart_even_without_a_file(self): + """fetch_data submissions have no file, but the API still requires multipart.""" + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["content_type"] = request.headers["content-type"] + captured["body"] = request.content + return _json_response({"id": "j1"}, status_code=201) + + transport = _transport_with_handler(handler) + transport.post("/jobs", multipart_data={"config": {"type": "transcription"}}) + + assert captured["content_type"].startswith("multipart/form-data") + assert b'name="config"' in captured["body"] + + def test_post_sends_extra_headers_as_json(self): + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["headers"] = request.headers + return _json_response({"id": "j1"}, status_code=201) + + transport = _transport_with_handler(handler) + transport.post( + "/jobs", + multipart_data={"config": {"type": "transcription"}}, + extra_headers={PROCESSING_DATA_HEADER: {"user_id": "u1"}}, + ) + + assert json.loads(captured["headers"][PROCESSING_DATA_HEADER]) == {"user_id": "u1"} + + def test_delete_request(self): + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["method"] = request.method + return _json_response({}) + + transport = _transport_with_handler(handler) + transport.delete("/jobs/j1") + + assert captured["method"] == "DELETE" + + +class TestSyncTransportResponses: + def test_401_raises_authentication_error(self): + transport = _transport_with_handler(lambda request: httpx.Response(401)) + with pytest.raises(AuthenticationError, match="Invalid API key"): + transport.get("/jobs") + + def test_403_raises_authentication_error(self): + transport = _transport_with_handler(lambda request: httpx.Response(403)) + with pytest.raises(AuthenticationError, match="Access forbidden"): + transport.get("/jobs") + + def test_server_error_raises_transport_error(self): + transport = _transport_with_handler(lambda request: httpx.Response(500, text="boom")) + with pytest.raises(TransportError, match="HTTP 500"): + transport.get("/jobs") + + def test_speechmatics_json_content_type_is_parsed(self): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + content=json.dumps({"id": "j1"}).encode(), + headers={"content-type": "application/vnd.speechmatics.v2+json"}, + ) + + transport = _transport_with_handler(handler) + assert transport.get("/jobs/j1") == {"id": "j1"} + + def test_json_content_type_with_charset_is_parsed(self): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + content=json.dumps({"id": "j1"}).encode(), + headers={"content-type": "application/json; charset=utf-8"}, + ) + + transport = _transport_with_handler(handler) + assert transport.get("/jobs/j1") == {"id": "j1"} + + def test_plain_text_response_is_wrapped(self): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, text="hello world", headers={"content-type": "text/plain"}) + + transport = _transport_with_handler(handler) + result = transport.get("/jobs/j1/transcript") + + assert result["content"] == "hello world" + assert result["content_type"] == "text/plain" + + def test_timeout_raises_transport_error(self): + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.ReadTimeout("too slow", request=request) + + transport = _transport_with_handler(handler) + with pytest.raises(TransportError, match="Request timeout"): + transport.get("/jobs") + + def test_network_error_raises_connection_error(self): + from speechmatics.batch import ConnectionError as SMConnectionError + + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("no route", request=request) + + transport = _transport_with_handler(handler) + with pytest.raises(SMConnectionError, match="Request failed"): + transport.get("/jobs") + + +class TestSyncTransportLifecycle: + def test_close_is_idempotent(self): + transport = _transport_with_handler(lambda request: _json_response({})) + transport.close() + transport.close() + assert not transport.is_connected + + def test_context_manager_closes_client(self): + with SyncTransport("https://asr.api.speechmatics.com/v2", ConnectionConfig(), StaticKeyAuth("k")) as transport: + assert transport.is_connected + assert not transport.is_connected + + +# --------------------------------------------------------------------------- +# Client +# --------------------------------------------------------------------------- + + +class TestSyncClientBasics: + def test_requires_api_key(self, monkeypatch): + monkeypatch.delenv("SPEECHMATICS_API_KEY", raising=False) + with pytest.raises(ValueError, match="API key required"): + Client() + + def test_uses_url_from_environment(self, monkeypatch): + monkeypatch.setenv("SPEECHMATICS_BATCH_URL", "https://example.com/v2") + client = Client(api_key="k") + assert client._url == "https://example.com/v2" + + def test_context_manager_closes_transport(self): + client = Client(api_key="k") + with patch.object(client._transport, "close") as mock_close: + with client: + pass + mock_close.assert_called_once() + + def test_submit_job_posts_to_jobs(self): + client = Client(api_key="k") + with patch.object(client._transport, "post") as mock_post: + mock_post.return_value = {"id": "job-1", "created_at": "2024-01-01T00:00:00Z"} + job = client.submit_job(BytesIO(b"audio")) + + assert mock_post.call_args.args[0] == "/jobs" + assert job.id == "job-1" + assert job.status == JobStatus.RUNNING + + def test_submit_job_sends_processing_data_header(self): + client = Client(api_key="k") + with patch.object(client._transport, "post") as mock_post: + mock_post.return_value = {"id": "job-1"} + client.submit_job(BytesIO(b"audio"), parallel_engines=4, user_id="u1") + + extra_headers = mock_post.call_args.kwargs["extra_headers"] + assert extra_headers[PROCESSING_DATA_HEADER] == {"parallel_engines": 4, "user_id": "u1"} + + def test_submit_job_reads_file_from_path(self, tmp_path): + audio_path = tmp_path / "sample.wav" + audio_path.write_bytes(b"RIFF-data") + + client = Client(api_key="k") + with patch.object(client._transport, "post") as mock_post: + mock_post.return_value = {"id": "job-1"} + job = client.submit_job(str(audio_path)) + + # The file handle is passed through, not its contents, so the upload streams. + filename, file_data, content_type = mock_post.call_args.kwargs["multipart_data"]["data_file"] + assert (filename, content_type) == ("sample.wav", "audio/wav") + assert hasattr(file_data, "read") + assert job.data_name == "sample.wav" + + def test_submit_job_with_fetch_data(self): + client = Client(api_key="k") + config = JobConfig( + type=JobType.TRANSCRIPTION, + fetch_data=FetchData(url="https://example.com/audio.wav"), + transcription_config=TranscriptionConfig(language="en"), + ) + with patch.object(client._transport, "post") as mock_post: + mock_post.return_value = {"id": "job-1"} + job = client.submit_job(None, config=config) + + multipart = mock_post.call_args.kwargs["multipart_data"] + assert "data_file" not in multipart + assert job.data_name == "https://example.com/audio.wav" + + def test_rejects_missing_audio_source(self): + client = Client(api_key="k") + with pytest.raises(ValueError, match="Must provide either"): + client.submit_job(None) + + def test_list_jobs(self): + client = Client(api_key="k") + with patch.object(client._transport, "get") as mock_get: + mock_get.return_value = { + "jobs": [ + {"id": "j1", "status": "done", "created_at": "t", "data_name": "a.wav"}, + {"id": "j2", "status": "running", "created_at": "t", "data_name": "b.wav"}, + ] + } + jobs = client.list_jobs(limit=2) + + assert [job.id for job in jobs] == ["j1", "j2"] + assert mock_get.call_args.kwargs["params"] == {"limit": "2"} + + def test_authentication_error_is_not_wrapped(self): + client = Client(api_key="k") + with patch.object(client._transport, "get", side_effect=AuthenticationError("bad key")): + with pytest.raises(AuthenticationError): + client.get_job_info("job-1") + + +class TestSyncClientWaitForCompletion: + + def test_sleep_is_bounded_by_remaining_timeout(self): + from speechmatics.batch import TimeoutError as SMTimeoutError + + client = Client(api_key="k") + with patch.object(client._transport, "get") as mock_get: + mock_get.return_value = {"job": {"id": "job-1", "status": "running"}} + with patch("speechmatics.batch._sync_client.time.sleep") as mock_sleep: + with pytest.raises(SMTimeoutError): + client.wait_for_completion("job-1", polling_interval=100.0, timeout=0.5) + + assert all(call.args[0] <= 0.5 for call in mock_sleep.call_args_list) + + def test_requests_are_bounded_by_remaining_timeout(self): + """ + Bounding only the sleeps lets one hung request overrun the caller's + timeout by a whole operation timeout, so the requests are bounded too. + """ + from speechmatics.batch import TimeoutError as SMTimeoutError + + client = Client(api_key="k") + client._conn_config.operation_timeout = 300.0 + with patch.object(client._transport, "get") as mock_get: + mock_get.return_value = {"job": {"id": "job-1", "status": "running"}} + with pytest.raises(SMTimeoutError): + client.wait_for_completion("job-1", polling_interval=MIN_SLEEP, timeout=0.5) + + timeouts = [call.kwargs["timeout"] for call in mock_get.call_args_list] + assert timeouts + assert all(timeout is not None and timeout <= 0.5 for timeout in timeouts) + + def test_deadline_is_not_reported_as_a_transport_failure(self, caplog): + """ + The server holds a status request for an unspecified time, so a request + cut short by the caller's deadline is the timeout arriving, not a blip. + """ + client = Client(api_key="k") + + def held_past_the_deadline(*args, **kwargs): + time.sleep(0.15) + raise TransportError("Request timeout for GET /jobs/job-1") + + with patch.object(client._transport, "get", side_effect=held_past_the_deadline): + with caplog.at_level(logging.WARNING, logger="speechmatics.batch"): + with pytest.raises(SMTimeoutError): + client.wait_for_completion("job-1", polling_interval=MIN_SLEEP, timeout=0.05) + + assert "retrying" not in caplog.text + + def test_get_job_info_keeps_the_default_request_timeout(self): + """Bounding polling requests must not change a plain get_job_info().""" + client = Client(api_key="k") + with patch.object(client._transport, "get") as mock_get: + mock_get.return_value = {"job": {"id": "job-1", "status": "running"}} + client.get_job_info("job-1") + + assert mock_get.call_args.kwargs["timeout"] is None + + +class TestSyncClientEndToEnd: + """The full submit-and-fetch flow over a mocked HTTP layer.""" + + def test_transcribe_with_wait(self): + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.params["wait"] == "60" + assert request.url.params["format"] == "txt" + return _json_response({"id": "job-1", "status": "done", "txt": "End to end"}, status_code=201) + + client = Client(api_key="k") + client._transport = _transport_with_handler(handler) + + result = client.transcribe(BytesIO(b"audio"), wait=60, format_type=FormatType.TXT) + + assert result == "End to end" + + def test_submit_then_poll_then_fetch(self): + calls = [] + + def handler(request: httpx.Request) -> httpx.Response: + calls.append((request.method, request.url.path)) + if request.method == "POST": + return _json_response({"id": "job-1", "status": "created"}, status_code=201) + if request.url.path.endswith("/transcript"): + return _json_response( + { + "format": "2.9", + "job": {"id": "job-1", "created_at": "t", "data_name": "audio.wav"}, + "metadata": {"created_at": "t", "type": "transcription"}, + "results": [], + } + ) + return _json_response({"job": {"id": "job-1", "status": "done"}}) + + client = Client(api_key="k") + client._transport = _transport_with_handler(handler) + + job = client.submit_job(BytesIO(b"audio")) + result = client.wait_for_completion(job.id, polling_interval=MIN_SLEEP) + + assert result.results == [] + assert calls == [ + ("POST", "/v2/jobs"), + ("GET", "/v2/jobs/job-1"), + ("GET", "/v2/jobs/job-1/transcript"), + ] diff --git a/tests/batch/test_sync_transcription.py b/tests/batch/test_sync_transcription.py new file mode 100644 index 00000000..390db1b2 --- /dev/null +++ b/tests/batch/test_sync_transcription.py @@ -0,0 +1,322 @@ +"""Unit tests for synchronous transcription (the server-side ``wait`` parameter).""" + +from io import BytesIO +from unittest.mock import AsyncMock +from unittest.mock import MagicMock +from unittest.mock import patch + +import pytest + +from speechmatics.batch import AsyncClient +from speechmatics.batch import Client +from speechmatics.batch import ConnectionConfig +from speechmatics.batch import FormatType +from speechmatics.batch import JobDetails +from speechmatics.batch import JobStatus +from speechmatics.batch import StaticKeyAuth +from speechmatics.batch import Transcript +from speechmatics.batch._common import build_query_params +from speechmatics.batch._common import extract_embedded_transcript +from speechmatics.batch._common import is_job_active +from speechmatics.batch._common import jittered_interval +from speechmatics.batch._common import request_timeout_for_wait + + +def _transcript_payload(text: str = "Hello") -> dict: + return { + "format": "2.9", + "job": {"id": "job-123", "created_at": "2024-01-01T00:00:00Z", "data_name": "audio.wav"}, + "metadata": {"created_at": "2024-01-01T00:00:00Z", "type": "transcription"}, + "results": [ + { + "type": "word", + "start_time": 0.0, + "end_time": 0.5, + "alternatives": [{"content": text, "confidence": 0.9, "language": "en"}], + } + ], + } + + +# --------------------------------------------------------------------------- +# Query parameter construction +# --------------------------------------------------------------------------- + + +class TestBuildQueryParams: + def test_no_params_when_nothing_requested(self): + assert build_query_params() is None + + def test_wait_serialized(self): + assert build_query_params(wait=60) == {"wait": "60"} + + def test_wait_zero_is_sent(self): + assert build_query_params(wait=0) == {"wait": "0"} + + def test_json_format_omitted_to_use_api_default(self): + assert build_query_params(format_type=FormatType.JSON) is None + + def test_non_default_format_sent(self): + assert build_query_params(format_type=FormatType.TXT) == {"format": "txt"} + + def test_wait_and_format_together(self): + assert build_query_params(wait=30, format_type=FormatType.SRT) == {"wait": "30", "format": "srt"} + + def test_negative_wait_rejected(self): + with pytest.raises(ValueError, match="non-negative"): + build_query_params(wait=-1) + + +class TestRequestTimeoutForWait: + def test_none_without_wait(self): + assert request_timeout_for_wait(None, ConnectionConfig()) is None + + def test_exceeds_wait_so_server_can_use_full_window(self): + assert request_timeout_for_wait(60, ConnectionConfig()) > 60 + + def test_never_shorter_than_the_configured_operation_timeout(self): + """Asking the server to wait must not shrink the budget for the upload.""" + conn_config = ConnectionConfig(operation_timeout=300.0) + assert request_timeout_for_wait(60, conn_config) > conn_config.operation_timeout + + +class TestJitteredInterval: + def test_stays_near_base_interval(self): + values = [jittered_interval(5.0) for _ in range(50)] + assert all(4.0 <= v <= 6.0 for v in values) + assert len(set(values)) > 1 + + def test_zero_stays_zero(self): + assert jittered_interval(0.0) == 0.0 + + +# --------------------------------------------------------------------------- +# Embedded transcript parsing +# --------------------------------------------------------------------------- + + +class TestExtractEmbeddedTranscript: + def test_txt_transcript(self): + response = {"id": "a1", "status": "done", "txt": "Welcome to Speechmatics"} + assert extract_embedded_transcript(response, FormatType.TXT) == "Welcome to Speechmatics" + + def test_srt_transcript(self): + response = {"id": "a1", "status": "done", "srt": "1\n00:00:00,000 --> 00:00:01,000\nHi\n"} + assert extract_embedded_transcript(response, FormatType.SRT).startswith("1\n") + + def test_json_transcript_nested_under_json_v2_key(self): + """Documented shape: {"id", "status", "json-v2": {...}}.""" + response = {"id": "uwcl3jevp3", "status": "done", "json-v2": _transcript_payload()} + result = extract_embedded_transcript(response, FormatType.JSON) + assert isinstance(result, Transcript) + assert result.transcript_text == "Hello" + + def test_json_transcript_not_read_from_other_keys(self): + """A transcript merged at the top level is not a shape the API sends.""" + response = {"id": "job-123", "status": "done", **_transcript_payload()} + assert extract_embedded_transcript(response, FormatType.JSON) is None + + def test_none_when_job_still_running(self): + assert extract_embedded_transcript({"id": "a1", "status": "created"}, FormatType.JSON) is None + + def test_none_when_format_key_absent(self): + assert extract_embedded_transcript({"id": "a1", "status": "created"}, FormatType.TXT) is None + + +# --------------------------------------------------------------------------- +# Job status handling +# --------------------------------------------------------------------------- + + +class TestJobStatus: + def test_created_status_is_supported(self): + """The synchronous API reports 'created' for a job that is still running.""" + assert JobStatus("created") == JobStatus.CREATED + + def test_expired_is_not_a_status_value(self): + """ + The API signals expiry via HTTP 410, never a 'status' field (see + JobExpiredError), so this is not a JobStatus member. + """ + with pytest.raises(ValueError): + JobStatus("expired") + + def test_created_and_running_are_active(self): + assert is_job_active(JobStatus.CREATED) + assert is_job_active(JobStatus.RUNNING) + + def test_terminal_states_are_not_active(self): + assert not is_job_active(JobStatus.DONE) + assert not is_job_active(JobStatus.REJECTED) + + +class TestJobDetailsFromSparseResponse: + def test_wait_timeout_response_parses(self): + """A job whose wait elapsed comes back as only an id and status.""" + job = JobDetails.from_dict({"id": "a1b2c3", "status": "created"}) + assert job.id == "a1b2c3" + assert job.status == JobStatus.CREATED + assert job.created_at == "" + assert job.data_name == "" + assert job.transcript is None + + +# --------------------------------------------------------------------------- +# Client behaviour: async and sync are checked side by side +# --------------------------------------------------------------------------- + + +class TestTranscribeWithWait: + def test_fetches_transcript_when_done_without_embedded_payload(self): + """A done job with no embedded transcript still resolves, via one extra call.""" + client = Client(api_key="test-key") + with patch.object(client._transport, "post") as mock_post: + with patch.object(client._transport, "get") as mock_get: + mock_post.return_value = {"id": "job-123", "status": "done"} + mock_get.return_value = _transcript_payload("Fetched") + result = client.transcribe(BytesIO(b"audio"), wait=60) + + assert result.transcript_text == "Fetched" + assert mock_get.call_count == 1 + + +class TestGetJobInfoAndTranscriptWait: + def test_get_job_info_sends_wait(self): + client = Client(api_key="test-key") + with patch.object(client._transport, "get") as mock_get: + mock_get.return_value = {"job": {"id": "job-1", "status": "done"}} + client.get_job_info("job-1", wait=30) + + _, kwargs = mock_get.call_args + assert kwargs["params"] == {"wait": "30"} + assert kwargs["timeout"] > 30 + + +class TestAsyncSyncParity: + """The two clients must produce identical results from identical responses.""" + + @pytest.mark.asyncio + async def test_public_surface_parity(self): + """Every public method on AsyncClient has a sync counterpart.""" + async_methods = {name for name in dir(AsyncClient) if not name.startswith("_")} + sync_methods = {name for name in dir(Client) if not name.startswith("_")} + assert async_methods == sync_methods + + def test_signature_parity(self): + """Matching methods accept the same keyword arguments.""" + import inspect + + for name in ("submit_job", "get_job_info", "get_transcript", "wait_for_completion", "transcribe", "list_jobs"): + async_params = inspect.signature(getattr(AsyncClient, name)).parameters + sync_params = inspect.signature(getattr(Client, name)).parameters + assert list(async_params) == list(sync_params), f"{name} signatures diverged" + + +class TestAuthenticationErrorParity: + """An invalid API key must surface as AuthenticationError on both clients.""" + + @pytest.mark.asyncio + async def test_async_401_raises_authentication_error(self): + import aiohttp + + from speechmatics.batch import AuthenticationError + from speechmatics.batch._transport import Transport + + transport = Transport("https://example.com/v2", ConnectionConfig(), StaticKeyAuth("bad-key")) + response = MagicMock(spec=aiohttp.ClientResponse) + response.status = 401 + + with pytest.raises(AuthenticationError, match="Invalid API key"): + await transport._handle_response(response) + + @pytest.mark.asyncio + async def test_async_auth_error_survives_the_request_wrapper(self): + """The outer request handler must not re-wrap AuthenticationError.""" + from speechmatics.batch import AuthenticationError + from speechmatics.batch._transport import Transport + + class _FakeRequest: + async def __aenter__(self): + return MagicMock() + + async def __aexit__(self, *args): + return None + + transport = Transport("https://example.com/v2", ConnectionConfig(), StaticKeyAuth("bad-key")) + session = MagicMock() + session.request = MagicMock(return_value=_FakeRequest()) + transport._session = session + + with patch.object( + transport, "_handle_response", new_callable=AsyncMock, side_effect=AuthenticationError("Invalid API key") + ): + with pytest.raises(AuthenticationError, match="Invalid API key"): + await transport.get("/jobs") + + +class TestBackwardCompatibility: + """Existing call patterns must behave exactly as before.""" + + @pytest.mark.asyncio + async def test_submit_job_without_wait_reports_running(self): + client = AsyncClient(api_key="test-key") + with patch.object(client._transport, "post", new_callable=AsyncMock) as mock_post: + mock_post.return_value = {"id": "job-1", "created_at": "2024-01-01T00:00:00Z"} + job = await client.submit_job(BytesIO(b"audio")) + + assert job.status == JobStatus.RUNNING + assert job.transcript is None + assert job.data_name == "audio.wav" + + +class TestSpeakerLabels: + """ + "UU" is the label the API returns for an unidentified speaker, which is what + undiarized audio produces. It must not be rendered as a speaker. + """ + + @staticmethod + def _transcript(*speakers: object) -> Transcript: + words = ["Hello", "there", "friend", "again"] + return Transcript.from_dict( + { + "format": "2.9", + "job": {"id": "j1", "created_at": "t", "data_name": "a.wav"}, + "metadata": {"created_at": "t", "type": "transcription"}, + "results": [ + { + "type": "word", + "start_time": float(i), + "end_time": float(i) + 0.5, + "alternatives": [ + { + "content": words[i], + "confidence": 0.9, + "language": "en", + **({} if speaker is None else {"speaker": speaker}), + } + ], + } + for i, speaker in enumerate(speakers) + ], + } + ) + + def test_unknown_speaker_is_not_labelled(self): + assert self._transcript("UU", "UU").transcript_text == "Hello there" + + def test_absent_speaker_is_not_labelled(self): + assert self._transcript(None, None).transcript_text == "Hello there" + + def test_identified_speakers_are_still_labelled(self): + text = self._transcript("S1", "S2").transcript_text + assert text == "SPEAKER S1: Hello\nSPEAKER S2: there" + + def test_unknown_speaker_does_not_split_identified_runs(self): + """UU segments group with each other, not against a speaker label.""" + text = self._transcript("S1", "UU", "UU", "S1").transcript_text + assert text == "SPEAKER S1: Hello\nthere friend\nSPEAKER S1: again" + + def test_matches_the_api_text_rendering_for_undiarized_audio(self): + """The json-v2 rendering must agree with the API's own txt output.""" + assert "SPEAKER" not in self._transcript("UU", "UU", "UU", "UU").transcript_text