Skip to content

feat(prerecorded): add AsyncTranscriber for asyncio callers - #227

Merged
he-james merged 3 commits into
masterfrom
jhe/asyncio-prerecorded
Aug 12, 2026
Merged

feat(prerecorded): add AsyncTranscriber for asyncio callers#227
he-james merged 3 commits into
masterfrom
jhe/asyncio-prerecorded

Conversation

@he-james

@he-james he-james commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Adds aai.AsyncTranscriber, the asyncio counterpart of Transcriber. Every API
call is a coroutine, so transcriptions run concurrently on one thread. Closes #80.

Rebased onto master now that #226 has landed. Two commits, reviewable separately:

  1. _base.py — no behavior change. TranscriptFields (the 27 response
    accessors), _BaseTranscript, _BaseTranscriber. Both concurrency models
    inherit them, which is the base-class sharing refactor(transcriber): move prerecorded transcription into prerecorded/v2 #226 deferred. transcript.py
    drops 190 lines.
  2. The featureasync_transcript.py, async_client.py, async_api.py in
    prerecorded/v2, plus AsyncClient beside the root Client. Same
    one-class-per-module layout as the threaded twins. Every _*Impl survives.
async with aai.AsyncTranscriber() as transcriber:
    transcript = await transcriber.transcribe("./audio.mp3")
    sentences = await transcript.get_sentences()

Four deliberate divergences from the threaded API

  1. No default AsyncClient. An httpx.AsyncClient pool belongs to the loop
    that first used it, so a global one dies on a second asyncio.run().
    AsyncTranscriber owns one per instance; pass a client to share a pool.
  2. get_by_id/delete_by_id are on the transcriber, not classmethods on the
    transcript, because the async transcript needs the transcriber's pool.
  3. Group methods never drop errors. First error raises after the batch
    settles, or return_failures=True gives (transcripts, errors). They also
    keep input order and cap in-flight work at max_concurrency (default 8).
  4. Uploads set Content-Length when the size is known, so httpx skips
    chunked encoding. Verified on httpx 0.19, 0.22, 0.24, 0.28.

Testing

433 passed, 3 failed (pyaudio, also failing on base). Green on httpx 0.22 /
0.24 / 0.28 and under both pydantic paths. mypy + ruff clean. #226's
backwards-compat test still passes. 45 new tests, including two that pin the
shared bases and two that prove real concurrency.

Not verified: no live-API run yet, and no Python 3.8 interpreter (tox starts at
3.9).

Known gap

api.py still inlines its own error strings, so the per-endpoint messages exist
in both api.py and async_api.py. _base._raise_for_status is ready; switching
api.py over is one line per function. Left out to keep the diff additive — happy
to fold it in.

Base automatically changed from ccampbell/python-prerecorded-rearchitecture to master August 11, 2026 22:48
he-james and others added 2 commits August 11, 2026 22:52
…asses

Prepare for the asyncio transcriber. This commit does not change behavior.

`streaming/v3/_base.py` holds the sync/async-agnostic core for its two clients.
`prerecorded/v2` now has the same file, so `Transcriber` and `AsyncTranscriber`
share one base instead of one copying the other.

- `_base.py`: `TranscriptFields` holds the 27 response accessors.
  `_BaseTranscript` and `_BaseTranscriber` hold the state contract and the
  config handling. `_raise_for_status`, `is_url`, `config_from_response`, and
  `TERMINAL_STATUSES` hold the logic both transports repeat.
- `transcript.py`: `Transcript` inherits `_BaseTranscript` and supplies the
  response through `_response()`. The 27 accessors and their repeated None
  checks are gone, so the file drops 190 lines.
- `client.py`: `Transcriber` and `_TranscriberImpl` inherit `_BaseTranscriber`
  and resolve a per-call config through `_resolve_config`.
- Root `client.py`: header and pool-limit construction move into
  `_build_headers` and `_build_limits`, so `AsyncClient` gets the same
  user-agent, auth header, and keepalive.

`api.py` is untouched. `_base._raise_for_status` currently serves the asyncio
transport only. Switching `api.py` over is a one-line follow-up per function,
left out here to keep this diff additive.

Tests: `pytest tests/unit` gives 388 passed, 3 failed. The 3 failures need
`pyaudio` and also fail on the base branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes #80.

`Transcriber.transcribe_async` returns a `concurrent.futures.Future`. That
future is not awaitable, and `.result()` blocks the event loop. An asyncio
caller must therefore route transcription through a thread pool, which the
hardware bounds.

`AsyncTranscriber` provides the same API as `Transcriber`, with coroutines. It
takes the same `TranscriptionConfig`. Its result has the same fields. It needs
one thread.

The new modules follow the layout of their threaded twins, one public class per
module, layered `async_transcript <- async_client`:

- `prerecorded/v2/async_transcript.py`: `AsyncTranscript`.
- `prerecorded/v2/async_client.py`: `AsyncTranscriber`.
- `prerecorded/v2/async_api.py`: an asyncio version of each function in
  `api.py`, raising the same error per endpoint through `_base`.
- `async_client.py` at root holds `AsyncClient`, which wraps an
  `httpx.AsyncClient`. It sits beside `Client`, because `prerecorded/v2` and
  `sync/v1` both use the root client. There is no process-wide default: the
  pool belongs to the event loop that first used it, so a global pool fails on
  a second `asyncio.run()`.

Behavior notes:

- `AsyncTranscript` has the same fields as `Transcript`. Only the methods that
  call the API are coroutines. The transcriber provides `get_by_id` and
  `delete_by_id`, because the async transcript needs the transcriber's pool.
- An upload sends a path or a file object in chunks. A worker thread reads each
  chunk. The request sets `Content-Length` when the size is known.
- `transcribe_group` and `submit_group` keep the input order, limit concurrent
  work to `max_concurrency`, and report every error. The threaded group methods
  discard errors when `return_failures` is not set.
- LeMUR remains synchronous only. `LemurSource` now accepts an
  `AsyncTranscript`.

The tests avoid every pytest-httpx API added after 0.20, because the pinned
httpx tox envs resolve pytest-httpx back that far. The group tests stub
`async_api.create_transcript` rather than the HTTP layer, since no pytest-httpx
version in the matrix can delay a mocked response.

Tests: 45 tests in `tests/unit/test_async_transcriber.py`. The full suite gives
433 passed, 3 failed on httpx 0.22, 0.24, and 0.28, and under both pydantic
paths. The 3 failures need `pyaudio` and also fail on the base branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@he-james
he-james force-pushed the jhe/asyncio-prerecorded branch from 1d1b473 to 9332da7 Compare August 11, 2026 22:53
@he-james

he-james commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

Live production verification

Same method as #226, plus a leg for the asyncio work. One shared transcript created up front, every read path deep-compared as normalized JSON. Baseline is origin/master (9a251f2), the exact parent of these two commits, so any difference is caused by this PR and nothing else.

Three legs, all green:

leg covers result
threaded parity origin/master vs this branch, using only pre-refactor import paths ✅ identical
asyncio parity the same read paths through AsyncTranscriber vs the threaded path on this branch ✅ identical
asyncio-only behavior streamed upload, group order + concurrency cap, return_failures ✅ 5/5

Byte-identical across both parity legs: all 27 accessors that commit 1 moved into TranscriptFields, config, get_sentences, get_paragraphs, VTT text, SRT-with-chars_per_caption text, word_search, a before_id-pinned list_transcripts page (ids and statuses), TranscriptGroup.get_by_ids, the redaction config, the redacted-audio URL prefix, the client-side redaction guard, and a fresh transcription's text and word count.

Asyncio-only results: upload returns an https URL; transcribe_group returns 3 in input order at max_concurrency=2 in 6.3s; return_failures accounts for every item; async fresh text matches the threaded fresh text.

15 transcripts created and deleted per run, audio capped to the first 30s via audio_end_at.

🤖 Generated with Claude Code

@he-james
he-james merged commit 59567c8 into master Aug 12, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Asynchronous transcription via asyncio

2 participants