diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 97c7395..6e15f3b 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -11,14 +11,14 @@ Link to the issue(s) this PR addresses (e.g., `#123`). - Mention any updates to documentation or tests. ## Testing -- Describe how you tested your changes (e.g., ran `python -m cookbook.main`). +- Describe how you tested your changes (e.g., ran `python -m cookbook`). - Confirm that all tests pass and no linting errors exist (`./fix-code.sh`). ## Checklist - [ ] Code follows style guidelines (`ruff`, `isort`, and optionally `black`). - [ ] Tests added or updated in the `cookbook` module. - [ ] Documentation updated (e.g., `README.md` or `docs/`). -- [ ] All tests pass (`python -m cookbook.main`). +- [ ] All tests pass (`python -m cookbook`). - [ ] No linting errors (`ruff check .` and `isort . --check-only`). ## Additional Notes diff --git a/.gitignore b/.gitignore index ab61f2f..c2906d9 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,9 @@ cover/ *.mo *.pot +# Cookbook test-suite artifacts +cookbook_output/ + # Django stuff: *.log local_settings.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 527ee21..f5a5ec6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -64,7 +64,7 @@ To contribute, you'll need: 5. **Run the Cookbook**: Verify your setup by running the test suite: ```bash - python -m cookbook.main + python -m cookbook ``` This executes the Djelia SDK Cookbook, testing translation, transcription, and TTS features. @@ -120,7 +120,7 @@ Ready to contribute code or documentation? Here's how: 5. **Test Your Changes**: Run the cookbook to verify your changes: ```bash - python -m cookbook.main + python -m cookbook ``` Ensure all tests pass and no new errors are introduced. @@ -161,7 +161,7 @@ All contributions should include tests to ensure reliability: - Add new tests for new features or bug fixes in the `cookbook` directory. - Verify tests pass with: ```bash - python -m cookbook.main + python -m cookbook ``` - If adding new dependencies, update `setup.py` (under `extras_require["test"]`) and `dev-requirements.txt`. diff --git a/README.md b/README.md index 45bfb9e..094ebb4 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,7 @@ First, let's see what languages we can work with. Simple and straightforward get your supported languages and print them: ```python -supported_languages = djelia_client.translation.get_supported_languages() +supported_languages = djelia_client.translations.list_languages() for lang in supported_languages: print(f"{lang.name}: {lang.code}") ``` @@ -141,7 +141,7 @@ import asyncio async def get_languages_test(): async with djelia_async_client as client: - supported_languages = await client.translation.get_supported_languages() + supported_languages = await client.translations.list_languages() for lang in supported_languages: print(f"{lang.name}: {lang.code}") @@ -152,14 +152,12 @@ asyncio.run(get_languages_test()) Let's translate some text between beautiful 🇲🇱 languages and others. Feel free to try different language combinations! +With the OpenAI-style API you pass the fields straight to `.create()` — no need to build a request object: + ```python -from djelia.models import TranslationRequest, Language, Versions +from djelia.models import Language, Versions -request = TranslationRequest( - text="Hello, how are you today?", - source=Language.ENGLISH, - target=Language.BAMBARA -) +text = "Hello, how are you today?" ``` ##

Synchronous @@ -170,8 +168,13 @@ Create that translation and see what you get: from djelia.models import TranslationResponse try: - response_sync: TranslationResponse = djelia_client.translation.translate(request=request, version=Versions.v1) - print(f"Original: {request.text}") + response_sync: TranslationResponse = djelia_client.translations.create( + text=text, + source=Language.ENGLISH, + target=Language.BAMBARA, + model=Versions.v1, + ) + print(f"Original: {text}") print(f"Translation: {response_sync.text}") except Exception as e: print(f"Translation error: {e}") @@ -185,8 +188,13 @@ Async translation because why wait around? Let's do it bro ! async def translate_async(): async with djelia_async_client as client: try: - response_async: TranslationResponse = await client.translation.translate(request=request, version=Versions.v1) - print(f"Original: {request.text}") + response_async: TranslationResponse = await client.translations.create( + text=text, + source=Language.ENGLISH, + target=Language.BAMBARA, + model=Versions.v1, + ) + print(f"Original: {text}") print(f"Translation: {response_async.text}") return response_async except Exception as e: @@ -209,9 +217,9 @@ Let's transcribe some audio files. Make sure you have an audio file ready check from djelia.models import Versions try: - transcription = djelia_client.transcription.transcribe( - audio_file=audio_file_path, - version=Versions.v2 + transcription = djelia_client.audio.transcriptions.create( + file=audio_file_path, + model=Versions.v2 ) print(f"Transcribed {len(transcription)} segments:") for segment in transcription: @@ -228,9 +236,9 @@ For the async enthusiasts: (like me, I ❤️ it) async def transcribe_async(): async with djelia_async_client as client: try: - transcription = await client.transcription.transcribe( - audio_file=audio_file_path, - version=Versions.v2 + transcription = await client.audio.transcriptions.create( + file=audio_file_path, + model=Versions.v2 ) print(f"Transcribed {len(transcription)} segments:") for segment in transcription: @@ -252,10 +260,10 @@ print("Streaming transcription (showing first 3 segments)...") segment_count = 0 try: - for segment in djelia_client.transcription.transcribe( - audio_file=audio_file_path, + for segment in djelia_client.audio.transcriptions.create( + file=audio_file_path, stream=True, - version=Versions.v2 + model=Versions.v2 ): segment_count += 1 print(f"Segment {segment_count}: [{segment.start:.2f}s]: {segment.text}") @@ -275,10 +283,10 @@ Async streaming because realtime is awesome: (bro, I'm telling you, one second async def stream_transcribe_async(): async with djelia_async_client as client: try: - stream = await client.transcription.transcribe( - audio_file=audio_file_path, + stream = await client.audio.transcriptions.create( + file=audio_file_path, stream=True, - version=Versions.v2 + model=Versions.v2 ) segment_count = 0 async for segment in stream: @@ -302,10 +310,10 @@ Want to transcribe and translate to French in one go? We've got you covered! ```python try: - french_transcription = djelia_client.transcription.transcribe( - audio_file=audio_file_path, + french_transcription = djelia_client.audio.transcriptions.create( + file=audio_file_path, translate_to_french=True, - version=Versions.v2 + model=Versions.v2 ) print(f"French translation: {french_transcription.text}") except Exception as e: @@ -318,10 +326,10 @@ except Exception as e: async def transcribe_french_async(): async with djelia_async_client as client: try: - french_transcription = await client.transcription.transcribe( - audio_file=audio_file_path, + french_transcription = await client.audio.transcriptions.create( + file=audio_file_path, translate_to_french=True, - version=Versions.v2 + model=Versions.v2 ) print(f"French translation: {french_transcription.text}") except Exception as e: @@ -339,12 +347,7 @@ Let's make some beautiful voices! Choose between numbered speakers or describe e Classic approach with speaker IDs (0-4). Simple and effective! ```python -from djelia.models import TTSRequest - -tts_request_v1 = TTSRequest( - text="Aw ni ce, i ka kɛnɛ wa?", # "Hello, how are you?" in Bambara - speaker=1 # Choose from 0, 1, 2, 3, or 4 -) +bambara_text = "Aw ni ce, i ka kɛnɛ wa?" # "Hello, how are you?" in Bambara ``` ##

Synchronous @@ -353,10 +356,11 @@ Generate that audio and save it: ```python try: - audio_file_v1 = djelia_client.tts.text_to_speech( - request=tts_request_v1, + audio_file_v1 = djelia_client.audio.speech.create( + input=bambara_text, + voice=1, # Choose from 0, 1, 2, 3, or 4 output_file="hello_v1.wav", - version=Versions.v1 + model=Versions.v1 ) print(f"Audio saved to: {audio_file_v1}") except Exception as e: @@ -371,10 +375,11 @@ Async audio generation: async def generate_audio_v1_async(): async with djelia_async_client as client: try: - audio_file_v1 = await client.tts.text_to_speech( - request=tts_request_v1, + audio_file_v1 = await client.audio.speech.create( + input=bambara_text, + voice=1, output_file="hello_v1_async.wav", - version=Versions.v1 + model=Versions.v1 ) print(f"Audio saved to: {audio_file_v1}") except Exception as e: @@ -387,14 +392,10 @@ asyncio.run(generate_audio_v1_async()) This is where it gets fun! Describe exactly how you want the voice to sound, but make sure to include one of the supported speakers: Moussa, Sekou, or Seydou. -```python -from djelia.models import TTSRequestV2 +For v2 you pass a natural-language `description` (which must name a supported speaker) instead of a numeric `voice`: -tts_request_v2 = TTSRequestV2( - text="Aw ni ce, i ka kɛnɛ wa?", - description="Seydou speaks with a warm, welcoming tone", # Must include Moussa, Sekou, or Seydou - chunk_size=1.0 # Control speech pacing (0.1 - 2.0) -) +```python +v2_description = "Seydou speaks with a warm, welcoming tone" # Must include Moussa, Sekou, or Seydou ``` > **Note:** The description field must include one of the supported speakers. For example, "Moussa speaks with a warm tone" is valid, but "Natural tone" will raise an error. @@ -405,10 +406,12 @@ Create natural sounding speech: ```python try: - audio_file_v2 = djelia_client.tts.text_to_speech( - request=tts_request_v2, + audio_file_v2 = djelia_client.audio.speech.create( + input=bambara_text, + description=v2_description, + chunk_size=1.0, # Control speech pacing (0.1 - 2.0) output_file="hello_v2.wav", - version=Versions.v2 + model=Versions.v2 ) print(f"Natural audio saved to: {audio_file_v2}") except Exception as e: @@ -423,10 +426,12 @@ Async natural speech generation: async def generate_natural_audio_async(): async with djelia_async_client as client: try: - audio_file_v2 = await client.tts.text_to_speech( - request=tts_request_v2, + audio_file_v2 = await client.audio.speech.create( + input=bambara_text, + description=v2_description, + chunk_size=1.0, output_file="hello_v2_async.wav", - version=Versions.v2 + model=Versions.v2 ) print(f"Natural audio saved to: {audio_file_v2}") except Exception as e: @@ -440,11 +445,8 @@ asyncio.run(generate_natural_audio_async()) Realtime audio generation! Get chunks as they're created (v2 only). ```python -streaming_tts_request = TTSRequestV2( - text="An filɛ ni ye yɔrɔ minna ni an ye an sigi ka a layɛ yala an bɛ ka baara min kɛ ɛsike a kɛlen don ka Ɲɛ wa, ...............", # a very long text - description="Seydou speaks clearly and naturally", - chunk_size=1.0 -) +long_text = "An filɛ ni ye yɔrɔ minna ni an ye an sigi ka a layɛ yala an bɛ ka baara min kɛ ɛsike a kɛlen don ka Ɲɛ wa, ..............." # a very long text +streaming_description = "Seydou speaks clearly and naturally" ``` > **Note:** By default, the SDK may process multiple chunks (e.g., up to 5 in some configurations). This example limits to 5 chunks for consistency, but you can adjust the limit based on your application needs. @@ -460,11 +462,13 @@ total_bytes = 0 max_chunks = 5 try: - for chunk in djelia_client.tts.text_to_speech( - request=streaming_tts_request, + for chunk in djelia_client.audio.speech.create( + input=long_text, + description=streaming_description, + chunk_size=1.0, output_file="streamed_audio.wav", stream=True, - version=Versions.v2 + model=Versions.v2 ): chunk_count += 1 total_bytes += len(chunk) @@ -485,11 +489,13 @@ Async streaming TTS because realtime is the future (oops, actually it's today async def stream_tts_async(): async with djelia_async_client as client: try: - stream = await client.tts.text_to_speech( - request=streaming_tts_request, + stream = await client.audio.speech.create( + input=long_text, + description=streaming_description, + chunk_size=1.0, output_file="streamed_audio_async.wav", stream=True, - version=Versions.v2 + model=Versions.v2 ) chunk_count = 0 total_bytes = 0 @@ -521,9 +527,9 @@ print(f"Available versions: {[str(v) for v in Versions.all_versions()]}") # Use specific version try: - transcription = djelia_client.transcription.transcribe( - audio_file=audio_file_path, - version=Versions.v2 + transcription = djelia_client.audio.transcriptions.create( + file=audio_file_path, + model=Versions.v2 ) print(f"Transcribed {len(transcription)} segments") except Exception as e: @@ -536,24 +542,23 @@ Run multiple API operations concurrently using `asyncio.gather` with the async c ```python import asyncio -from djelia.models import TranslationRequest, Language, TTSRequestV2, Versions +from djelia.models import Language, Versions async def parallel_operations(): async with DjeliaAsync(api_key=api_key) as client: try: - translation_request = TranslationRequest( - text="Hello", source=Language.ENGLISH, target=Language.BAMBARA - ) - tts_request = TTSRequestV2( - text="Aw ni ce, i ka kɛnɛ wa?", - description="Moussa speaks with a clear tone", - chunk_size=1.0 - ) - results = await asyncio.gather( - client.translation.translate(translation_request, version=Versions.v1), - client.transcription.transcribe(audio_file_path, version=Versions.v2), - client.tts.text_to_speech(tts_request, output_file="parallel_tts.wav", version=Versions.v2), + client.translations.create( + text="Hello", source=Language.ENGLISH, target=Language.BAMBARA, model=Versions.v1 + ), + client.audio.transcriptions.create(file=audio_file_path, model=Versions.v2), + client.audio.speech.create( + input="Aw ni ce, i ka kɛnɛ wa?", + description="Moussa speaks with a clear tone", + chunk_size=1.0, + output_file="parallel_tts.wav", + model=Versions.v2, + ), return_exceptions=True ) @@ -576,7 +581,12 @@ The Djelia SDK provides specific exception classes to handle errors gracefully. from djelia.utils.exceptions import AuthenticationError, APIError, ValidationError, LanguageError, SpeakerError try: - response = djelia_client.translation.translate(request=request, version=Versions.v1) + response = djelia_client.translations.create( + text="Hello, how are you today?", + source=Language.ENGLISH, + target=Language.BAMBARA, + model=Versions.v1, + ) print(f"Translation: {response.text}") except AuthenticationError as e: print(f"Authentication error (check API key): {e}") @@ -602,25 +612,35 @@ Check logs for detailed errors, and ensure your `.env` file includes a valid `DJ ##

Explore the Djelia SDK Cookbook -Want to take your Djelia SDK skills to the next level? Check out the **Djelia SDK Cookbook** for a comprehensive example that puts it all together! The cookbook demonstrates: +Want to take your Djelia SDK skills to the next level? The **Djelia SDK Cookbook** is an interactive, `rich` + `typer` powered test suite that exercises the whole OpenAI-style API. It demonstrates: -- **Full Test Suite**: Run synchronous and asynchronous tests for translation, transcription, and TTS, with detailed summaries. -- **Error Handling**: Robust try-except blocks and logging to catch and debug issues. +- **Interactive Test Suite**: Pick which groups to run (translation, transcription, TTS, streaming, parallel) and whether to run the sync tests, the async tests, or both. +- **Live Metrics**: Each API call is timed and printed the moment it completes, followed by a per-test operations table (input → response, kind, latency) and a final pass/fail summary. - **Configuration Management**: Load API keys and audio paths from a `.env` file with validation. - **Advanced Features**: Parallel API operations, version management, and streaming capabilities. -- **Modular Design**: Organized code structure for easy customization. +- **Tidy Output**: Generated audio is written under `cookbook_output/` and cleaned up automatically (pass `--keep-audio` to keep it). To run the cookbook, clone the repository, install dependencies, and execute: ```bash git clone https://github.com/djelia-org/djelia-python-sdk.git -pip install git+https://github.com/djelia-org/djelia-python-sdk.git python-dotenv - cd djelia-python-sdk -python -m cookbook.main +pip install -e ".[cookbook]" # installs the SDK plus typer, rich, python-dotenv + +# Run everything (sync + async): +python -m cookbook + +# List the available test groups: +python -m cookbook --list + +# Run only a couple of groups, sync-only: +python -m cookbook -g translation -g tts --mode sync + +# Pick groups and mode from an interactive menu: +python -m cookbook --interactive ``` -Make sure your `.env` file includes `DJELIA_API_KEY` and `TEST_AUDIO_FILE`. The cookbook is perfect for developers who want a ready-to-use template for building real-world applications with the Djelia SDK. +Make sure your `.env` file includes `DJELIA_API_KEY` (and optionally `TEST_AUDIO_FILE`). The cookbook is perfect for developers who want a ready-to-use template for building real-world applications with the Djelia SDK. ##

Wrapping Up diff --git a/cookbook/__main__.py b/cookbook/__main__.py new file mode 100644 index 0000000..f611323 --- /dev/null +++ b/cookbook/__main__.py @@ -0,0 +1,186 @@ +from __future__ import annotations + +import enum +import os +import sys +from typing import List, Optional, Tuple + +import typer +from rich.prompt import Confirm, Prompt +from rich.table import Table + +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from .config import Config +from .cookbook import GROUP_ORDER, GROUPS, DjeliaTestSuite +from .utils import Reporter, banner, console, render_summary + +app = typer.Typer( + add_completion=False, + help="Interactive test suite for the Djelia Python SDK (OpenAI-style API).", +) + + +class Mode(str, enum.Enum): + sync = "sync" + async_ = "async" + both = "both" + + +def _groups_table() -> Table: + table = Table( + title="Available test groups", header_style="bold", border_style="dim" + ) + table.add_column("#", justify="right", style="cyan") + table.add_column("Group") + table.add_column("Label") + table.add_column("Sync", justify="center") + table.add_column("Async", justify="center") + for i, key in enumerate(GROUP_ORDER, 1): + spec = GROUPS[key] + table.add_row( + str(i), + key, + str(spec["label"]), + "✓" if spec["sync"] else "—", + "✓" if spec["async"] else "—", + ) + return table + + +def _select_interactively() -> Tuple[List[str], str]: + console.print(_groups_table()) + console.print( + "[dim]Enter group numbers separated by commas (e.g. 1,3,4), " + "or press Enter for all.[/dim]" + ) + raw = Prompt.ask("Groups", default="all") + + if raw.strip().lower() in ("", "all"): + groups = list(GROUP_ORDER) + else: + groups = [] + for token in raw.split(","): + token = token.strip() + if token.isdigit() and 1 <= int(token) <= len(GROUP_ORDER): + groups.append(GROUP_ORDER[int(token) - 1]) + elif token in GROUPS: + groups.append(token) + else: + console.print(f"[yellow]Ignoring unknown group: {token}[/]") + + mode = Prompt.ask("Mode", choices=["sync", "async", "both"], default="both") + return groups, mode + + +@app.command() +def run( + group: List[str] = typer.Option( + [], + "--group", + "-g", + help=f"Test group to run (repeatable). One of: {', '.join(GROUP_ORDER)}.", + ), + mode: Mode = typer.Option( + Mode.both, "--mode", "-m", help="Run sync tests, async tests, or both." + ), + interactive: bool = typer.Option( + False, "--interactive", "-i", help="Pick groups and mode from a menu." + ), + list_groups: bool = typer.Option( + False, "--list", "-l", help="List available test groups and exit." + ), + api_key: Optional[str] = typer.Option( + None, + "--api-key", + envvar="DJELIA_API_KEY", + help="Djelia API key.", + show_default=False, + ), + audio: Optional[str] = typer.Option( + None, "--audio", "-a", help="Path to the audio file used for transcription." + ), + output_dir: str = typer.Option( + "cookbook_output", + "--output-dir", + "-o", + help="Where generated audio is written.", + ), + keep_audio: bool = typer.Option( + False, "--keep-audio", help="Keep generated audio instead of cleaning it up." + ), + max_segments: int = typer.Option( + 3, "--max-segments", help="Streaming transcription segments to display." + ), + max_chunks: int = typer.Option( + 5, "--max-chunks", help="Streaming TTS chunks to display." + ), +): + """Run the Djelia SDK test suite.""" + if list_groups: + console.print(_groups_table()) + raise typer.Exit() + + banner() + + config = Config.load() + if api_key: + config.api_key = api_key + if audio: + config.audio_file_path = audio + config.output_dir = output_dir + config.keep_audio = keep_audio + config.max_stream_segments = max_segments + config.max_stream_chunks = max_chunks + + mode_value = "async" if mode == Mode.async_ else mode.value + + if interactive: + groups, mode_value = _select_interactively() + elif group: + groups = [] + for g in group: + if g not in GROUPS: + console.print(f"[red]Unknown group:[/] {g}") + raise typer.Exit(1) + groups.append(g) + else: + groups = list(GROUP_ORDER) + + reporter = Reporter(console) + if not config.api_key: + console.print("[red]DJELIA_API_KEY is not set[/] (use --api-key or a .env file).") + raise typer.Exit(1) + + try: + suite = DjeliaTestSuite(config, reporter) + except Exception as exc: # noqa: BLE001 - surface setup errors cleanly + console.print(f"[red]Failed to initialize the client:[/] {exc}") + raise typer.Exit(1) + + if not suite.validate(): + console.print("[red]Setup validation failed. Fix the issues above.[/]") + raise typer.Exit(1) + + if not groups: + console.print("[yellow]No groups selected. Nothing to do.[/]") + raise typer.Exit() + + try: + suite.run(groups, mode_value) + except KeyboardInterrupt: + console.print("\n[yellow]Interrupted by user.[/]") + + render_summary(reporter) + + if any(r.status == "fail" for r in reporter.results): + if Confirm.ask("\n[dim]Show tracebacks for failures?[/]", default=False): + for r in reporter.results: + if r.status == "fail" and r.error: + console.print(f"\n[red bold]{r.name}[/]") + console.print(r.error) + raise typer.Exit(1) + + +if __name__ == "__main__": + app() diff --git a/cookbook/config.py b/cookbook/config.py index eccd334..27ca117 100644 --- a/cookbook/config.py +++ b/cookbook/config.py @@ -1,15 +1,26 @@ import os -from dataclasses import dataclass +from dataclasses import dataclass, field +from typing import List, Optional from dotenv import load_dotenv +# The Bambara greeting reused across every TTS test. +DEFAULT_TTS_TEXT = "Aw ni ce, i ka kɛnɛ wa?" +DEFAULT_SPEAKERS = ["Moussa", "Sekou", "Seydou"] + @dataclass class Config: - api_key: str | None = None + """Runtime configuration for the Djelia SDK test suite.""" + + api_key: Optional[str] = None audio_file_path: str = "audio.wav" + output_dir: str = "cookbook_output" + keep_audio: bool = False max_stream_segments: int = 3 max_stream_chunks: int = 5 + tts_text: str = DEFAULT_TTS_TEXT + speakers: List[str] = field(default_factory=lambda: list(DEFAULT_SPEAKERS)) @classmethod def load(cls) -> "Config": diff --git a/cookbook/cookbook.py b/cookbook/cookbook.py index 3e2fc62..dab3951 100644 --- a/cookbook/cookbook.py +++ b/cookbook/cookbook.py @@ -1,701 +1,473 @@ +from __future__ import annotations + import asyncio -import logging import os -import traceback +from pathlib import Path +from typing import Dict, List, Optional from uuid import uuid4 from djelia import Djelia, DjeliaAsync -from djelia.models import (Language, SupportedLanguageSchema, - TranslationRequest, TranslationResponse, TTSRequest, - TTSRequestV2, Versions) +from djelia.models import Language, Versions from .config import Config -from .utils import (ConsoleColor, handle_transcription_result, print_error, - print_info, print_success, print_summary, process_result) +from .utils import Reporter # ================================================ -# Djelia Cookbook +# Test registry # ================================================ - -class DjeliaCookbook: - def __init__(self, config: Config): +GROUP_ORDER: List[str] = [ + "translation", + "transcription", + "stream-transcription", + "tts", + "stream-tts", + "version", + "parallel", +] + +GROUPS: Dict[str, Dict[str, Optional[str]]] = { + "translation": { + "label": "Translation", + "sync": "translation_sync", + "async": "translation_async", + }, + "transcription": { + "label": "Transcription", + "sync": "transcription_sync", + "async": "transcription_async", + }, + "stream-transcription": { + "label": "Streaming transcription", + "sync": "streaming_transcription_sync", + "async": "streaming_transcription_async", + }, + "tts": { + "label": "Text-to-speech", + "sync": "tts_sync", + "async": "tts_async", + }, + "stream-tts": { + "label": "Streaming TTS", + "sync": "streaming_tts_sync", + "async": "streaming_tts_async", + }, + "version": { + "label": "Version management", + "sync": "version_management", + "async": None, + }, + "parallel": { + "label": "Parallel operations", + "sync": None, + "async": "parallel_operations", + }, +} + + +def _short(text: str, limit: int = 32) -> str: + text = " ".join(text.split()) + return text if len(text) <= limit else text[: limit - 1] + "…" + + +def _transcription_detail(transcription) -> str: + if isinstance(transcription, list): + if transcription: + return f"{len(transcription)} segments · '{_short(transcription[0].text)}'" + return "0 segments" + if hasattr(transcription, "text"): + return f"'{_short(transcription.text)}'" + return "unexpected format" + + +def _filesize(path: str) -> str: + try: + return f"{os.path.getsize(path):,} bytes" + except OSError: + return "written" + + +class DjeliaTestSuite: + """Exercises the OpenAI-style Djelia SDK surface and reports live metrics.""" + + def __init__(self, config: Config, reporter: Reporter): self.config = config + self.reporter = reporter self.sync_client = Djelia(api_key=config.api_key) self.async_client = DjeliaAsync(api_key=config.api_key) - self.test_results = {} - self.translation_samples = [ + self.output_dir = Path(config.output_dir) + self.output_dir.mkdir(parents=True, exist_ok=True) + self._generated: List[Path] = [] + + self.samples = [ ("Hello, how are you?", Language.ENGLISH, Language.BAMBARA), ("Bonjour, comment allez-vous?", Language.FRENCH, Language.BAMBARA), ("Good morning", Language.ENGLISH, Language.FRENCH), ] - self.bambara_tts_text = "Aw ni ce, i ka kɛnɛ wa?" - self.supported_speakers = ["Moussa", "Sekou", "Seydou"] - - # ------------------------------ - # Setup Validation - # ------------------------------ - - def validate_setup(self) -> bool: - valid = True - - if not self.config.api_key: - print_error("DJELIA_API_KEY not found") - valid = False - - if not os.path.exists(self.config.audio_file_path): - print_error(f"Audio file not found: {self.config.audio_file_path}") - valid = False - - if valid: - print_success("API Key and audio file loaded") - - return valid - - # ------------------------------ - # Translation Tests - # ------------------------------ - - def test_translation_sync(self) -> None: - test_name = "Sync Translation" - print( - f"{ConsoleColor.CYAN}\n{'SYNCHRONOUS TRANSLATION':^60}{ConsoleColor.RESET}" - ) - - try: - languages: list[SupportedLanguageSchema] = ( - self.sync_client.translation.get_supported_languages() - ) - print_success(f"Supported languages: {len(languages)}") - - for text, source, target in self.translation_samples: - request = TranslationRequest(text=text, source=source, target=target) - response: TranslationResponse = self.sync_client.translation.translate( - request=request, version=Versions.v1 - ) - print_success( - f"{source.value} → {target.value}: " - f"'{text}' → '{ConsoleColor.YELLOW}{response.text}{ConsoleColor.RESET}'" - ) - - self.test_results[test_name] = ( - "Success", - f"{len(self.translation_samples)} translations", - ) - - except Exception as e: - print_error(f"Translation error: {e}") - self.test_results[test_name] = ("Failed", str(e)) - logging.error(f"Traceback:\n{traceback.format_exc()}") - - async def test_translation_async(self) -> None: - test_name = "Async Translation" - print( - f"{ConsoleColor.PURPLE}\n{'ASYNCHRONOUS TRANSLATION':^60}{ConsoleColor.RESET}" - ) - - async with self.async_client as client: - try: - languages = await client.translation.get_supported_languages() - print_success(f"Supported languages (async): {len(languages)}") - - for text, source, target in self.translation_samples: - request = TranslationRequest( - text=text, source=source, target=target - ) - response = await client.translation.translate( - request=request, version=Versions.v1 - ) - print_success( - f"{source.value} → {target.value} (async): " - f"'{text}' → '{ConsoleColor.YELLOW}{response.text}{ConsoleColor.RESET}'" - ) - - self.test_results[test_name] = ( - "Success", - f"{len(self.translation_samples)} translations", + self.tts_text = config.tts_text + self.speakers = config.speakers + + # --------- + # Helpers # + # -------- + def _out(self, name: str) -> str: + path = self.output_dir / name + self._generated.append(path) + return str(path) + + def cleanup(self) -> None: + if self.config.keep_audio: + if self._generated: + self.reporter.info( + f"kept {len(self._generated)} audio file(s) in {self.output_dir}/" ) - - except Exception as e: - print_error(f"Async translation error: {e}") - self.test_results[test_name] = ("Failed", str(e)) - logging.error(f"Traceback:\n{traceback.format_exc()}") - - # ------------------------------ - # Transcription Tests - # ------------------------------ - - def test_transcription_sync(self) -> None: - test_name = "Sync Transcription" - print( - f"{ConsoleColor.CYAN}\n{'SYNCHRONOUS TRANSCRIPTION':^60}{ConsoleColor.RESET}" - ) - - if not os.path.exists(self.config.audio_file_path): - print_error("Audio file missing") - self.test_results[test_name] = ("Failed", "Missing audio file") return - - for version in [Versions.v1, Versions.v2]: + removed = 0 + for path in self._generated: try: - print_info(f"Testing non-streaming v{version.value}") - transcription = self.sync_client.transcription.transcribe( - self.config.audio_file_path, version=version - ) - handle_transcription_result(transcription, f"v{version.value}") - - if version == Versions.v2: - print_info("Testing French translation") - transcription_fr = self.sync_client.transcription.transcribe( - self.config.audio_file_path, - translate_to_french=True, - version=version, - ) - print_success( - f"French translation (v{version.value}): " - f"'{ConsoleColor.YELLOW}{transcription_fr.text}{ConsoleColor.RESET}'" - ) - - self.test_results[test_name] = ( - "Success", - f"v{version.value} completed", - ) - - except Exception as e: - print_error(f"Transcription error (v{version.value}): {e}") - self.test_results[test_name] = ("Failed", str(e)) - logging.error(f"Traceback:\n{traceback.format_exc()}") - - async def test_transcription_async(self) -> None: - test_name = "Async Transcription" - print( - f"{ConsoleColor.PURPLE}\n{'ASYNCHRONOUS TRANSCRIPTION':^60}{ConsoleColor.RESET}" - ) - + path.unlink() + removed += 1 + except OSError: + pass + try: + self.output_dir.rmdir() + except OSError: + pass + if removed: + self.reporter.info(f"cleaned up {removed} generated audio file(s)") + + def validate(self) -> bool: + ok = True + if not self.config.api_key: + self.reporter.fail("DJELIA_API_KEY is not set") + ok = False if not os.path.exists(self.config.audio_file_path): - print_error("Audio file missing") - self.test_results[test_name] = ("Failed", "Missing audio file") - return - - async with self.async_client as client: - for version in [Versions.v1, Versions.v2]: - try: - print_info(f"Testing non-streaming v{version.value}") - transcription = await client.transcription.transcribe( - self.config.audio_file_path, version=version - ) - handle_transcription_result( - transcription, f"v{version.value} (async)" + self.reporter.fail(f"audio file not found: {self.config.audio_file_path}") + ok = False + if ok: + self.reporter.ok("API key and audio file present") + return ok + + # -------------- + # Translation # + # ------------- + def translation_sync(self) -> None: + with self.reporter.case("Translation · sync", "translation") as case: + with case.op("languages", "supported-languages") as op: + languages = self.sync_client.translations.list_languages() + op.set(f"{len(languages)} languages") + for text, source, target in self.samples: + with case.op("translate", f"{source.value}→{target.value}") as op: + response = self.sync_client.translations.create( + text=text, source=source, target=target, model=Versions.v1 ) - - if version == Versions.v2: - print_info("Testing French translation") - transcription_fr = await client.transcription.transcribe( - self.config.audio_file_path, - translate_to_french=True, - version=version, - ) - print_success( - f"French translation (async): " - f"'{ConsoleColor.YELLOW}{transcription_fr.text}{ConsoleColor.RESET}'" + op.set(f"'{_short(text)}' → '{_short(response.text)}'") + case.done(f"{len(self.samples)} translations · {len(languages)} languages") + + async def translation_async(self) -> None: + with self.reporter.case("Translation · async", "translation") as case: + async with self.async_client as client: + with case.op("languages", "supported-languages") as op: + languages = await client.translations.list_languages() + op.set(f"{len(languages)} languages") + for text, source, target in self.samples: + with case.op("translate", f"{source.value}→{target.value}") as op: + response = await client.translations.create( + text=text, source=source, target=target, model=Versions.v1 ) - - self.test_results[test_name] = ( - "Success", - f"v{version.value} completed", + op.set(f"'{_short(text)}' → '{_short(response.text)}'") + case.done(f"{len(self.samples)} translations · {len(languages)} languages") + + # -------------- + # Transcription # + # -------------- + def transcription_sync(self) -> None: + with self.reporter.case("Transcription · sync", "transcription") as case: + for version in (Versions.v1, Versions.v2): + with case.op("transcribe", f"v{version.value}") as op: + transcription = self.sync_client.audio.transcriptions.create( + file=self.config.audio_file_path, model=version ) - - except Exception as e: - print_error(f"Async transcription error (v{version.value}): {e}") - self.test_results[test_name] = ("Failed", str(e)) - logging.error(f"Traceback:\n{traceback.format_exc()}") - - # ------------------------------ - # Streaming Transcription - # ------------------------------ - - def test_streaming_transcription_sync(self) -> None: - test_name = "Sync Streaming Transcription" - print( - f"{ConsoleColor.CYAN}\n{'SYNCHRONOUS STREAMING TRANSCRIPTION':^60}{ConsoleColor.RESET}" - ) - - if not os.path.exists(self.config.audio_file_path): - print_error("Audio file missing") - self.test_results[test_name] = ("Failed", "Missing audio file") - return - - for version in [Versions.v1, Versions.v2]: - try: - print_info(f"Testing streaming v{version.value}") - segment_count = 0 - - for segment in self.sync_client.transcription.transcribe( - self.config.audio_file_path, stream=True, version=version - ): - segment_count += 1 - print_success( - f"Segment {segment_count}: " - f"{segment.start:.2f}s-{segment.end:.2f}s: " - f"'{ConsoleColor.YELLOW}{segment.text}{ConsoleColor.RESET}'" - ) - - if segment_count >= self.config.max_stream_segments: - print_info( - f"Showing first {self.config.max_stream_segments} segments" - ) - break - - print_success(f"Streaming complete: {segment_count} segments") - + op.set(_transcription_detail(transcription)) if version == Versions.v2: - print_info("Testing streaming French translation") - segment_count = 0 - - for segment in self.sync_client.transcription.transcribe( - self.config.audio_file_path, - stream=True, - translate_to_french=True, - version=version, - ): - segment_count += 1 - text = segment.text - print_success( - f"French Segment {segment_count}: " - f"'{ConsoleColor.YELLOW}{text}{ConsoleColor.RESET}'" + with case.op("transcribe", "v2 → french") as op: + fr = self.sync_client.audio.transcriptions.create( + file=self.config.audio_file_path, + translate_to_french=True, + model=version, ) - - if segment_count >= self.config.max_stream_segments: - print_info( - f"Showing first {self.config.max_stream_segments} segments" - ) - break - - print_success( - f"French streaming complete: {segment_count} segments" - ) - - self.test_results[test_name] = ( - "Success", - f"v{version.value} completed", - ) - - except Exception as e: - print_error(f"Streaming error (v{version.value}): {e}") - self.test_results[test_name] = ("Failed", str(e)) - logging.error(f"Traceback:\n{traceback.format_exc()}") - - async def test_streaming_transcription_async(self) -> None: - test_name = "Async Streaming Transcription" - print( - f"{ConsoleColor.PURPLE}\n{'ASYNCHRONOUS STREAMING TRANSCRIPTION':^60}{ConsoleColor.RESET}" - ) - - if not os.path.exists(self.config.audio_file_path): - print_error("Audio file missing") - self.test_results[test_name] = ("Failed", "Missing audio file") - return - - async with self.async_client as client: - for version in [Versions.v1, Versions.v2]: - try: - print_info(f"Testing streaming v{version.value}") - segment_count = 0 - - generator = await client.transcription.transcribe( - self.config.audio_file_path, stream=True, version=version - ) - - async for segment in generator: - segment_count += 1 - print_success( - f"Segment {segment_count}: " - f"{segment.start:.2f}s-{segment.end:.2f}s: " - f"'{ConsoleColor.YELLOW}{segment.text}{ConsoleColor.RESET}'" + op.set(f"'{_short(fr.text)}'") + case.done("v1 + v2 (with french)") + + async def transcription_async(self) -> None: + with self.reporter.case("Transcription · async", "transcription") as case: + async with self.async_client as client: + for version in (Versions.v1, Versions.v2): + with case.op("transcribe", f"v{version.value}") as op: + transcription = await client.audio.transcriptions.create( + file=self.config.audio_file_path, model=version ) - - if segment_count >= self.config.max_stream_segments: - print_info( - f"Showing first {self.config.max_stream_segments} segments" + op.set(_transcription_detail(transcription)) + if version == Versions.v2: + with case.op("transcribe", "v2 → french") as op: + fr = await client.audio.transcriptions.create( + file=self.config.audio_file_path, + translate_to_french=True, + model=version, ) + op.set(f"'{_short(fr.text)}'") + case.done("v1 + v2 (with french)") + + # ----------------------- + # Streaming transcription # + # ----------------------- + def streaming_transcription_sync(self) -> None: + with self.reporter.case( + "Streaming transcription · sync", "stream-transcription" + ) as case: + limit = self.config.max_stream_segments + for version in (Versions.v1, Versions.v2): + with case.op("stream", f"transcribe v{version.value}") as op: + count = 0 + for segment in self.sync_client.audio.transcriptions.create( + file=self.config.audio_file_path, stream=True, model=version + ): + count += 1 + case.note( + f"seg {count} {segment.start:.2f}-{segment.end:.2f}s: " + f"{_short(segment.text)}" + ) + if count >= limit: break - - print_success(f"Streaming complete: {segment_count} segments") - - if version == Versions.v2: - print_info("Testing streaming French translation") - segment_count = 0 - - generator = await client.transcription.transcribe( - self.config.audio_file_path, - stream=True, - translate_to_french=True, - version=version, + op.set(f"{count} segments (first {limit})") + case.done("v1 + v2 streamed") + + async def streaming_transcription_async(self) -> None: + with self.reporter.case( + "Streaming transcription · async", "stream-transcription" + ) as case: + limit = self.config.max_stream_segments + async with self.async_client as client: + for version in (Versions.v1, Versions.v2): + with case.op("stream", f"transcribe v{version.value}") as op: + count = 0 + generator = await client.audio.transcriptions.create( + file=self.config.audio_file_path, stream=True, model=version ) - async for segment in generator: - segment_count += 1 - text = segment.text - print_success( - f"French Segment {segment_count}: " - f"'{ConsoleColor.YELLOW}{text}{ConsoleColor.RESET}'" + count += 1 + case.note( + f"seg {count} {segment.start:.2f}-{segment.end:.2f}s: " + f"{_short(segment.text)}" ) - - if segment_count >= self.config.max_stream_segments: - print_info( - f"Showing first {self.config.max_stream_segments} segments" - ) + if count >= limit: break - - print_success( - f"French streaming complete: {segment_count} segments" - ) - - self.test_results[test_name] = ( - "Success", - f"v{version.value} completed", - ) - - except Exception as e: - print_error(f"Async streaming error (v{version.value}): {e}") - self.test_results[test_name] = ("Failed", str(e)) - logging.error(f"Traceback:\n{traceback.format_exc()}") - - # ------------------------------ - # TTS Tests - # ------------------------------ - - def test_tts_sync(self) -> None: - test_name = "Sync TTS" - print( - f"{ConsoleColor.CYAN}\n{'SYNCHRONOUS TEXT-TO-SPEECH':^60}{ConsoleColor.RESET}" - ) - - try: - tts_request_v1 = TTSRequest(text=self.bambara_tts_text, speaker=1) - audio_file_v1 = self.sync_client.tts.text_to_speech( - request=tts_request_v1, - output_file=f"tts_sync_v1_{uuid4().hex}.wav", - version=Versions.v1, - ) - print_success( - f"TTS v1 saved: {ConsoleColor.BLUE}{audio_file_v1}{ConsoleColor.RESET}" - ) - - for speaker in self.supported_speakers: - tts_request_v2 = TTSRequestV2( - text=self.bambara_tts_text, - description=f"{speaker} speaks with natural tone", - chunk_size=1.0, - ) - audio_file_v2 = self.sync_client.tts.text_to_speech( - request=tts_request_v2, - output_file=f"tts_sync_v2_{speaker}_{uuid4().hex}.wav", - version=Versions.v2, - ) - print_success( - f"TTS v2 ({speaker}): {ConsoleColor.BLUE}{audio_file_v2}{ConsoleColor.RESET}" - ) - - self.test_results[test_name] = ( - "Success", - f"{len(self.supported_speakers)} speakers", - ) - - except Exception as e: - print_error(f"TTS error: {e}") - self.test_results[test_name] = ("Failed", str(e)) - logging.error(f"Traceback:\n{traceback.format_exc()}") - - async def test_tts_async(self) -> None: - test_name = "Async TTS" - print( - f"{ConsoleColor.PURPLE}\n{'ASYNCHRONOUS TEXT-TO-SPEECH':^60}{ConsoleColor.RESET}" - ) - - async with self.async_client as client: - try: - tts_request_v1 = TTSRequest(text=self.bambara_tts_text, speaker=1) - audio_file_v1 = await client.tts.text_to_speech( - request=tts_request_v1, - output_file=f"tts_async_v1_{uuid4().hex}.wav", - version=Versions.v1, - ) - print_success( - f"TTS v1 saved: {ConsoleColor.BLUE}{audio_file_v1}{ConsoleColor.RESET}" + op.set(f"{count} segments (first {limit})") + case.done("v1 + v2 streamed") + + # --------------- + # Text-to-speech # + # --------------- + def tts_sync(self) -> None: + with self.reporter.case("Text-to-speech · sync", "tts") as case: + with case.op("tts", "v1") as op: + path = self.sync_client.audio.speech.create( + input=self.tts_text, + voice=1, + output_file=self._out(f"tts_sync_v1_{uuid4().hex}.wav"), + model=Versions.v1, ) - - for speaker in self.supported_speakers: - tts_request_v2 = TTSRequestV2( - text=self.bambara_tts_text, + op.set(_filesize(path)) + for speaker in self.speakers: + with case.op("tts", f"v2 {speaker}") as op: + path = self.sync_client.audio.speech.create( + input=self.tts_text, description=f"{speaker} speaks with natural tone", - chunk_size=0.5, + output_file=self._out( + f"tts_sync_v2_{speaker}_{uuid4().hex}.wav" + ), + model=Versions.v2, ) - audio_file_v2 = await client.tts.text_to_speech( - request=tts_request_v2, - output_file=f"tts_async_v2_{speaker}_{uuid4().hex}.wav", - version=Versions.v2, + op.set(_filesize(path)) + case.done(f"v1 + {len(self.speakers)} v2 speakers") + + async def tts_async(self) -> None: + with self.reporter.case("Text-to-speech · async", "tts") as case: + async with self.async_client as client: + with case.op("tts", "v1") as op: + path = await client.audio.speech.create( + input=self.tts_text, + voice=1, + output_file=self._out(f"tts_async_v1_{uuid4().hex}.wav"), + model=Versions.v1, ) - print_success( - f"TTS v2 ({speaker}): {ConsoleColor.BLUE}{audio_file_v2}{ConsoleColor.RESET}" - ) - - self.test_results[test_name] = ( - "Success", - f"{len(self.supported_speakers)} speakers", - ) - - except Exception as e: - print_error(f"Async TTS error: {e}") - self.test_results[test_name] = ("Failed", str(e)) - logging.error(f"Traceback:\n{traceback.format_exc()}") - - # ------------------------------ - # Streaming TTS - # ------------------------------ - - def test_streaming_tts_sync(self) -> None: - test_name = "Sync Streaming TTS" - print( - f"{ConsoleColor.CYAN}\n{'SYNCHRONOUS STREAMING TTS':^60}{ConsoleColor.RESET}" - ) - - try: - tts_request = TTSRequestV2( - text=self.bambara_tts_text, - description=f"{self.supported_speakers[0]} speaks with natural conversational tone", - ) - chunk_count = 0 - total_bytes = 0 - - for audio_chunk in self.sync_client.tts.text_to_speech( - request=tts_request, - output_file=f"stream_tts_sync_{uuid4().hex}.wav", - stream=True, - version=Versions.v2, - ): - chunk_count += 1 - total_bytes += len(audio_chunk) - print_success(f"Chunk {chunk_count}: {len(audio_chunk):,} bytes") - - if chunk_count >= self.config.max_stream_chunks: - print_info(f"Showing first {self.config.max_stream_chunks} chunks") - break - - print_success( - f"Streaming complete: {chunk_count} chunks, " - f"{ConsoleColor.BLUE}{total_bytes:,} bytes{ConsoleColor.RESET}" - ) - self.test_results[test_name] = ("Success", f"{chunk_count} chunks") - - except Exception as e: - print_error(f"Streaming TTS error: {e}") - self.test_results[test_name] = ("Failed", str(e)) - logging.error(f"Traceback:\n{traceback.format_exc()}") - - async def test_streaming_tts_async(self) -> None: - test_name = "Async Streaming TTS" - print( - f"{ConsoleColor.PURPLE}\n{'ASYNCHRONOUS STREAMING TTS':^60}{ConsoleColor.RESET}" - ) - - async with self.async_client as client: - try: - tts_request = TTSRequestV2( - text=self.bambara_tts_text, - description=f"{self.supported_speakers[0]} speaks with clear natural tone", - ) - chunk_count = 0 - total_bytes = 0 - - generator = await client.tts.text_to_speech( - request=tts_request, - output_file=f"stream_tts_async_{uuid4().hex}.wav", - stream=True, - version=Versions.v2, - ) - - async for audio_chunk in generator: - chunk_count += 1 - total_bytes += len(audio_chunk) - print_success(f"Chunk {chunk_count}: {len(audio_chunk):,} bytes") - - if chunk_count >= self.config.max_stream_chunks: - print_info( - f"Showing first {self.config.max_stream_chunks} chunks" + op.set(_filesize(path)) + for speaker in self.speakers: + with case.op("tts", f"v2 {speaker}") as op: + path = await client.audio.speech.create( + input=self.tts_text, + description=f"{speaker} speaks with natural tone", + output_file=self._out( + f"tts_async_v2_{speaker}_{uuid4().hex}.wav" + ), + model=Versions.v2, ) + op.set(_filesize(path)) + case.done(f"v1 + {len(self.speakers)} v2 speakers") + + # -------------------------- + # Streaming text-to-speech # + # -------------------------- + def streaming_tts_sync(self) -> None: + with self.reporter.case("Streaming TTS · sync", "stream-tts") as case: + limit = self.config.max_stream_chunks + with case.op("stream", "tts v2") as op: + chunks = 0 + total = 0 + for chunk in self.sync_client.audio.speech.create( + input=self.tts_text, + description=f"{self.speakers[0]} speaks with natural conversational tone", + output_file=self._out(f"stream_tts_sync_{uuid4().hex}.wav"), + stream=True, + model=Versions.v2, + ): + chunks += 1 + total += len(chunk) + case.note(f"chunk {chunks}: {len(chunk):,} bytes") + if chunks >= limit: break - - print_success( - f"Streaming complete: {chunk_count} chunks, " - f"{ConsoleColor.BLUE}{total_bytes:,} bytes{ConsoleColor.RESET}" - ) - self.test_results[test_name] = ("Success", f"{chunk_count} chunks") - - except Exception as e: - print_error(f"Async streaming TTS error: {e}") - self.test_results[test_name] = ("Failed", str(e)) - logging.error(f"Traceback:\n{traceback.format_exc()}") - - # ------------------------------ - # Advanced Features - # ------------------------------ - - async def test_parallel_operations(self) -> None: - test_name = "Parallel Operations" - print( - f"{ConsoleColor.CYAN}\n{'PARALLEL API OPERATIONS':^60}{ConsoleColor.RESET}" - ) - - async with self.async_client as client: - try: - print_info("Executing parallel operations...") - - translation_request = TranslationRequest( - text="Hello", source=Language.ENGLISH, target=Language.BAMBARA + op.set(f"{chunks} chunks · {total:,} bytes") + case.done(f"{chunks} chunks streamed") + + async def streaming_tts_async(self) -> None: + with self.reporter.case("Streaming TTS · async", "stream-tts") as case: + limit = self.config.max_stream_chunks + async with self.async_client as client: + with case.op("stream", "tts v2") as op: + chunks = 0 + total = 0 + generator = await client.audio.speech.create( + input=self.tts_text, + description=f"{self.speakers[0]} speaks with clear natural tone", + output_file=self._out(f"stream_tts_async_{uuid4().hex}.wav"), + stream=True, + model=Versions.v2, + ) + async for chunk in generator: + chunks += 1 + total += len(chunk) + case.note(f"chunk {chunks}: {len(chunk):,} bytes") + if chunks >= limit: + break + op.set(f"{chunks} chunks · {total:,} bytes") + case.done(f"{chunks} chunks streamed") + + # -------------------- + # Version management # + # ------------------- + def version_management(self) -> None: + with self.reporter.case("Version management · sync", "version") as case: + self.reporter.info(f"latest version: {Versions.latest()}") + self.reporter.info( + f"available: {[str(v) for v in Versions.all_versions()]}" + ) + with case.op("translate", "v1") as op: + result = self.sync_client.translations.create( + text="Hello world", + source=Language.ENGLISH, + target=Language.BAMBARA, + model=Versions.v1, ) - - tts_request = TTSRequestV2( - text=self.bambara_tts_text, - description=f"{self.supported_speakers[0]} speaks with clear speaking tone", + op.set(f"'{_short(result.text)}'") + for version in (Versions.v1, Versions.v2): + with case.op("transcribe", f"v{version.value}") as op: + transcription = self.sync_client.audio.transcriptions.create( + file=self.config.audio_file_path, model=version + ) + op.set(_transcription_detail(transcription)) + with case.op("tts", "v1") as op: + path = self.sync_client.audio.speech.create( + input=self.tts_text, + voice=1, + output_file=self._out(f"tts_v1_{uuid4().hex}.wav"), + model=Versions.v1, ) - - results = await asyncio.gather( - client.translation.get_supported_languages(), - client.translation.translate( - translation_request, version=Versions.v1 - ), - client.transcription.transcribe( - self.config.audio_file_path, version=Versions.v1 - ), - client.tts.text_to_speech( - tts_request, - output_file=f"parallel_tts_{uuid4().hex}.wav", - version=Versions.v2, - ), - return_exceptions=True, + op.set(_filesize(path)) + with case.op("tts", "v2") as op: + path = self.sync_client.audio.speech.create( + input=self.tts_text, + description=f"{self.speakers[0]} speaks with natural tone", + output_file=self._out(f"tts_v2_{uuid4().hex}.wav"), + model=Versions.v2, ) - - print(f"\n{ConsoleColor.CYAN}Parallel Results:{ConsoleColor.RESET}") - process_result("Languages", results[0]) - process_result("Translation", results[1]) - process_result("Transcription", results[2]) - process_result("TTS Output", results[3]) - - if all(not isinstance(r, Exception) for r in results): - self.test_results[test_name] = ( - "Success", - "All operations completed", + op.set(_filesize(path)) + case.done("v1/v2 across translate, transcribe, tts") + + # --------------------- + # Parallel operations # + # --------------------- + async def parallel_operations(self) -> None: + with self.reporter.case("Parallel operations · async", "parallel") as case: + async with self.async_client as client: + with case.op("gather", "4 concurrent ops") as op: + results = await asyncio.gather( + client.translations.list_languages(), + client.translations.create( + text="Hello", + source=Language.ENGLISH, + target=Language.BAMBARA, + model=Versions.v1, + ), + client.audio.transcriptions.create( + file=self.config.audio_file_path, model=Versions.v1 + ), + client.audio.speech.create( + input=self.tts_text, + description=f"{self.speakers[0]} speaks with clear speaking tone", + output_file=self._out(f"parallel_tts_{uuid4().hex}.wav"), + model=Versions.v2, + ), + return_exceptions=True, ) - else: - failed = [ - type(r).__name__ for r in results if isinstance(r, Exception) - ] - self.test_results[test_name] = ( - "Failed", - f"Errors: {', '.join(failed)}", + succeeded = sum( + 1 for r in results if not isinstance(r, Exception) ) + op.set(f"{succeeded}/{len(results)} succeeded") + + names = ["languages", "translation", "transcription", "tts"] + failed = [] + for name, result in zip(names, results): + if isinstance(result, Exception): + case.note(f"[red]{name}: {result}[/]") + failed.append(name) + else: + case.note(f"{name}: {type(result).__name__}") + if failed: + raise RuntimeError(f"failed: {', '.join(failed)}") + case.done("4 concurrent operations") + + # --------------- + # Orchestration # + # --------------- + def run(self, groups: List[str], mode: str) -> None: + selected = [g for g in GROUP_ORDER if g in groups] + + for key in selected: + method = GROUPS[key]["sync"] + if mode in ("sync", "both") and method: + getattr(self, method)() + + async_methods = [ + GROUPS[key]["async"] + for key in selected + if mode in ("async", "both") and GROUPS[key]["async"] + ] + if async_methods: + asyncio.run(self._run_async(async_methods)) - except Exception as e: - print_error(f"Parallel operations error: {e}") - self.test_results[test_name] = ("Failed", str(e)) - logging.error(f"Traceback:\n{traceback.format_exc()}") - - def test_version_management(self) -> None: - test_name = "Version Management" - print(f"{ConsoleColor.CYAN}\n{'VERSION MANAGEMENT':^60}{ConsoleColor.RESET}") - - print_success( - f"Latest version: {ConsoleColor.YELLOW}{Versions.latest()}{ConsoleColor.RESET}" - ) - print_success( - f"Available versions: {ConsoleColor.GRAY}{[str(v) for v in Versions.all_versions()]}" - ) - - try: - request = TranslationRequest( - text="Hello world", source=Language.ENGLISH, target=Language.BAMBARA - ) - result = self.sync_client.translation.translate( - request=request, version=Versions.v1 - ) - print_success( - f"Translation v1: '{ConsoleColor.YELLOW}{result.text}{ConsoleColor.RESET}'" - ) - - for version in [Versions.v1, Versions.v2]: - transcription = self.sync_client.transcription.transcribe( - self.config.audio_file_path, version=version - ) - handle_transcription_result(transcription, f"v{version.value}") - - tts_v1 = self.sync_client.tts.text_to_speech( - request=TTSRequest(text=self.bambara_tts_text, speaker=1), - output_file=f"tts_v1_{uuid4().hex}.wav", - version=Versions.v1, - ) - print_success(f"TTS v1: {ConsoleColor.BLUE}{tts_v1}{ConsoleColor.RESET}") - - tts_v2 = self.sync_client.tts.text_to_speech( - request=TTSRequestV2( - text=self.bambara_tts_text, - description=f"{self.supported_speakers[0]} speaks with natural tone", - ), - output_file=f"tts_v2_{uuid4().hex}.wav", - version=Versions.v2, - ) - print_success(f"TTS v2: {ConsoleColor.BLUE}{tts_v2}{ConsoleColor.RESET}") - - self.test_results[test_name] = ("Success", "All version tests completed") - - except Exception as e: - print_error(f"Version test error: {e}") - self.test_results[test_name] = ("Failed", str(e)) - logging.error(f"Traceback:\n{traceback.format_exc()}") - - # ------------------------------ - # Main Execution - # ------------------------------ - def run(self) -> None: - print(f"\n{ConsoleColor.CYAN}{'=' * 60}{ConsoleColor.RESET}") - print( - f"{ConsoleColor.YELLOW}{'DJELIA SDK DEVELOPER COOKBOOK':^60}{ConsoleColor.RESET}" - ) - print(f"{ConsoleColor.CYAN}{'=' * 60}{ConsoleColor.RESET}") - - logging.basicConfig( - filename="djelia_cookbook.log", - level=logging.ERROR, - format="%(asctime)s - %(levelname)s - %(message)s", - ) - - if not self.validate_setup(): - return + self.cleanup() - try: - self.test_translation_sync() - self.test_transcription_sync() - self.test_tts_sync() - self.test_streaming_transcription_sync() - self.test_streaming_tts_sync() - self.test_version_management() - - asyncio.run(self.test_translation_async()) - asyncio.run(self.test_transcription_async()) - asyncio.run(self.test_tts_async()) - asyncio.run(self.test_streaming_transcription_async()) - asyncio.run(self.test_streaming_tts_async()) - asyncio.run(self.test_parallel_operations()) - - print_summary(self.test_results) - - except KeyboardInterrupt: - print_error("Execution interrupted by user") - self.test_results["Overall"] = ("Failed", "Interrupted") - print_summary(self.test_results) - except Exception as e: - print_error(f"Unexpected error: {str(e)}") - logging.error(f"Unhandled exception:\n{traceback.format_exc()}") - self.test_results["Overall"] = ("Failed", "Runtime error") - print_summary(self.test_results) + async def _run_async(self, method_names: List[str]) -> None: + for name in method_names: + await getattr(self, name)() diff --git a/cookbook/main.py b/cookbook/main.py deleted file mode 100644 index 1c4bf27..0000000 --- a/cookbook/main.py +++ /dev/null @@ -1,19 +0,0 @@ -import os -import sys - -sys.path.append( - os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -) # not really necessary but I will keep it, just au cas ou - -from .config import Config -from .cookbook import DjeliaCookbook - - -def main(): - config = Config.load() - cookbook = DjeliaCookbook(config) - cookbook.run() - - -if __name__ == "__main__": - main() diff --git a/cookbook/utils.py b/cookbook/utils.py index de6c675..7421531 100644 --- a/cookbook/utils.py +++ b/cookbook/utils.py @@ -1,75 +1,272 @@ -import logging +from __future__ import annotations -from djelia.models import FrenchTranscriptionResponse, TranscriptionSegment +import time +import traceback +from contextlib import contextmanager +from dataclasses import dataclass, field +from typing import Iterator, List, Optional -# ================================================ -# Console Utilities -# ================================================ +from rich.box import ROUNDED +from rich.console import Console +from rich.panel import Panel +from rich.rule import Rule +from rich.table import Table +from rich.text import Text + +console = Console() +STATUS_STYLE = {"pass": "green", "fail": "red", "skip": "yellow"} +STATUS_ICON = {"pass": "✓", "fail": "✗", "skip": "–"} -class ConsoleColor: - GREEN = "\033[32m" - RED = "\033[31m" - YELLOW = "\033[33m" - CYAN = "\033[36m" - PURPLE = "\033[35m" - BLUE = "\033[34m" - GRAY = "\033[37m" - RESET = "\033[0m" +# Colour per operation kind, echoed in the live line and the metrics table. +KIND_STYLE = { + "languages": "magenta", + "translate": "cyan", + "transcribe": "blue", + "stream": "yellow", + "tts": "green", + "gather": "white", +} # ================================================ -# Utility Functions +# Data model # ================================================ -def print_success(message: str) -> None: - print(f"{ConsoleColor.GREEN}✓ {message}{ConsoleColor.RESET}") +@dataclass +class OpEvent: + """A single timed SDK call within a test case.""" + + kind: str + label: str + seconds: float + detail: str = "" + ok: bool = True + +@dataclass +class TestResult: + name: str + group: str + status: str # "pass" | "fail" | "skip" + detail: str = "" + seconds: float = 0.0 + events: List[OpEvent] = field(default_factory=list) + error: Optional[str] = None # traceback, when status == "fail" -def print_error(message: str) -> None: - print(f"{ConsoleColor.RED}✗ {message}{ConsoleColor.RESET}") - logging.error(message) +class _OpHandle: + """Mutable handle a test body fills in while an operation is running.""" -def print_info(message: str) -> None: - print(f"{ConsoleColor.GRAY}ℹ {message}{ConsoleColor.RESET}") + def __init__(self, kind: str, label: str) -> None: + self.kind = kind + self.label = label + self.detail = "" + self.ok = True + def set(self, detail: str) -> None: + self.detail = detail -def print_summary(test_results: dict) -> None: - print(f"\n{ConsoleColor.CYAN}{'=' * 60}{ConsoleColor.RESET}") - print(f"{'Test Summary':^60}") - print(f"{ConsoleColor.CYAN}{'=' * 60}{ConsoleColor.RESET}") - print(f"{'Test':<40} {'Status':<10} {'Details'}") - print(f"{ConsoleColor.GRAY}{'-' * 60}{ConsoleColor.RESET}") - for test, (status, details) in sorted(test_results.items()): - color = ConsoleColor.GREEN if status == "Success" else ConsoleColor.RED - print(f"{test:<40} {color}{status:<10}{ConsoleColor.RESET} {details}") +class Case: + """One test case: owns its timed operations and reports live as they finish.""" + def __init__(self, name: str, group: str, console: Console) -> None: + self.name = name + self.group = group + self.console = console + self.events: List[OpEvent] = [] + self.status = "pass" + self.detail = "" + self.seconds = 0.0 + self.error: Optional[str] = None -def handle_transcription_result( - transcription: list[TranscriptionSegment] | FrenchTranscriptionResponse, - version_info: str, -) -> None: - if isinstance(transcription, list) and transcription: - print_success(f"Transcription {version_info}: {len(transcription)} segments") - segment = transcription[0] - print_success( - f"Sample: {segment.start:.1f}s-{segment.end:.1f}s: " - f"'{ConsoleColor.YELLOW}{segment.text}{ConsoleColor.RESET}'" + @contextmanager + def op(self, kind: str, label: str) -> Iterator[_OpHandle]: + """Time an SDK call and print a live line the moment it completes.""" + handle = _OpHandle(kind, label) + t0 = time.perf_counter() + try: + yield handle + except Exception: + handle.ok = False + raise + finally: + seconds = time.perf_counter() - t0 + self.events.append( + OpEvent(kind, handle.label, seconds, handle.detail, handle.ok) + ) + self._live(kind, handle, seconds) + + def _live(self, kind: str, handle: _OpHandle, seconds: float) -> None: + style = KIND_STYLE.get(kind, "white") + icon = "[green]✓[/]" if handle.ok else "[red]✗[/]" + detail = f" {handle.detail}" if handle.detail else "" + self.console.print( + f" {icon} [{style}]{kind:<10}[/] {handle.label:<22} " + f"[dim]{seconds:6.2f}s[/]{detail}" ) - elif hasattr(transcription, "text"): - print_success( - f"Transcription {version_info}: " - f"'{ConsoleColor.YELLOW}{transcription.text}{ConsoleColor.RESET}'" + + def note(self, message: str) -> None: + """A live, untimed progress line (e.g. a streamed segment).""" + self.console.print(f" [dim]· {message}[/]") + + def done(self, detail: str) -> None: + self.detail = detail + + +class Reporter: + """Runs test cases, streams live metrics, and collects results.""" + + def __init__(self, console: Console = console) -> None: + self.console = console + self.results: List[TestResult] = [] + + def section(self, title: str, style: str = "cyan") -> None: + self.console.print() + self.console.print(Rule(f"[bold {style}]{title}[/]", style=style)) + + def ok(self, message: str) -> None: + self.console.print(f" [green]✓[/] {message}") + + def info(self, message: str) -> None: + self.console.print(f" [dim]…[/] [dim]{message}[/]") + + def fail(self, message: str) -> None: + self.console.print(f" [red]✗[/] {message}") + + def record(self, result: TestResult) -> None: + self.results.append(result) + + @contextmanager + def case(self, name: str, group: str) -> Iterator[Case]: + """Time a test, catch failures, render its metrics table in real time.""" + self.section(name) + case = Case(name, group, self.console) + t0 = time.perf_counter() + try: + yield case + except Exception as exc: # noqa: BLE001 - report every failure + case.status = "fail" + case.detail = str(exc) + case.error = traceback.format_exc() + self.fail(f"{name}: {exc}") + case.seconds = time.perf_counter() - t0 + self._render_case_metrics(case) + self.record( + TestResult( + name=case.name, + group=case.group, + status=case.status, + detail=case.detail, + seconds=case.seconds, + events=case.events, + error=case.error, + ) ) - else: - print_error(f"Unexpected result format for {version_info}") + def _render_case_metrics(self, case: Case) -> None: + if not case.events: + return + table = Table( + box=ROUNDED, + border_style="dim", + header_style="bold", + title=f"[dim]{case.name} — operations[/dim]", + title_justify="left", + expand=True, + ) + table.add_column("Operation") + table.add_column("Kind") + table.add_column("Result") + table.add_column("Time", justify="right") + for ev in case.events: + style = KIND_STYLE.get(ev.kind, "white") + marker = "" if ev.ok else "[red]✗ [/]" + table.add_row( + f"{marker}{ev.label}", + f"[{style}]{ev.kind}[/]", + ev.detail or "—", + f"{ev.seconds:.2f}s", + ) + self.console.print(table) -def process_result(name: str, result: object) -> None: - if isinstance(result, Exception): - print_error(f"{name}: {str(result)}") - else: - print_success(f"{name}: Received {type(result).__name__}") + measured = sum(e.seconds for e in case.events) + slowest = max(case.events, key=lambda e: e.seconds) + self.console.print( + f" [dim]measured {measured:.2f}s of {case.seconds:.2f}s · " + f"slowest: {slowest.label} ({slowest.seconds:.2f}s)[/dim]" + ) + + +# ================================================ +# Rendering helpers +# ================================================ + + +def banner() -> None: + console.print( + Panel( + Text("Djelia SDK Test Suite", justify="center", style="bold yellow"), + subtitle="[dim]OpenAI-style API · translation · transcription · TTS[/dim]", + border_style="cyan", + box=ROUNDED, + ) + ) + + +def render_summary(reporter: Reporter) -> None: + results = reporter.results + if not results: + console.print("[dim]No tests were run.[/dim]") + return + + table = Table( + title="[bold]Test Summary[/bold]", + header_style="bold", + border_style="dim", + box=ROUNDED, + expand=True, + ) + table.add_column("Test") + table.add_column("Group", style="dim") + table.add_column("Ops", justify="right") + table.add_column("Status", justify="center") + table.add_column("Time", justify="right") + table.add_column("Details") + + for r in results: + style = STATUS_STYLE.get(r.status, "white") + icon = STATUS_ICON.get(r.status, "?") + table.add_row( + r.name, + r.group, + str(len(r.events)) if r.events else "—", + f"[{style}]{icon} {r.status}[/]", + f"{r.seconds:.2f}s", + r.detail or "—", + ) + + console.print() + console.print(table) + + passed = sum(1 for r in results if r.status == "pass") + failed = sum(1 for r in results if r.status == "fail") + skipped = sum(1 for r in results if r.status == "skip") + total_time = sum(r.seconds for r in results) + total_ops = sum(len(r.events) for r in results) + + summary = ( + f"[green]{passed} passed[/]" + + (f" · [red]{failed} failed[/]" if failed else "") + + (f" · [yellow]{skipped} skipped[/]" if skipped else "") + + f" [dim]· {total_ops} API calls in {total_time:.2f}s[/dim]" + ) + console.print( + Panel( + summary, + border_style="green" if not failed else "red", + box=ROUNDED, + ) + ) diff --git a/djelia/models/models.py b/djelia/models/models.py index 8bfde9b..52f142e 100644 --- a/djelia/models/models.py +++ b/djelia/models/models.py @@ -22,6 +22,40 @@ def latest(cls): def all_versions(cls): return list(cls) + @classmethod + def from_value(cls, value: "Versions | int | str") -> "Versions": + """Normalize a model/version identifier (enum, int, or str like "v2") to a Versions.""" + if isinstance(value, cls): + return value + if isinstance(value, bool): + raise ValueError( + ErrorsMessage.invalid_version.format( + value, [str(v) for v in cls] + ) + ) + if isinstance(value, int): + candidate = value + elif isinstance(value, str): + normalized = value.strip().lower().lstrip("v") + if not normalized.isdigit(): + raise ValueError( + ErrorsMessage.invalid_version.format( + value, [str(v) for v in cls] + ) + ) + candidate = int(normalized) + else: + raise ValueError( + ErrorsMessage.invalid_version.format(value, [str(v) for v in cls]) + ) + + try: + return cls(candidate) + except ValueError: + raise ValueError( + ErrorsMessage.invalid_version.format(value, [str(v) for v in cls]) + ) + def __str__(self): return f"v{self.value}" @@ -113,4 +147,8 @@ class ErrorsMessage: ) tts_v1_request_error: str = "TTSRequest required for V1" tts_v2_request_error: str = "TTSRequestV2 required for V2" + tts_v2_description_required: str = ( + "A 'description' naming a supported speaker is required for TTS V2" + ) tts_streaming_compatibility: str = "Streaming is only available for TTS V2" + invalid_version: str = "Invalid model/version {!r}, expected one of {}" diff --git a/djelia/src/client/client.py b/djelia/src/client/client.py index f044cd2..0c55c7d 100644 --- a/djelia/src/client/client.py +++ b/djelia/src/client/client.py @@ -7,28 +7,41 @@ from djelia.config.settings import Settings from djelia.src.auth import Auth -from djelia.src.services import (TTS, AsyncTranscription, AsyncTranslation, - AsyncTTS, Transcription, Translation) +from djelia.src.services import (TTS, AsyncAudio, AsyncTranscription, + AsyncTranslation, AsyncTranslations, AsyncTTS, + Audio, Transcription, Translation, + Translations) from djelia.utils.errors import api_exception, general_exception +def _build_settings( + api_key: Union[str, None], base_url: Union[str, None] +) -> Settings: + """Resolve settings, letting explicit args override environment variables. + + Fields use validation aliases, so init overrides must be passed by alias. + """ + overrides = {} + if api_key is not None: + overrides["DJELIA_API_KEY"] = api_key + if base_url is not None: + overrides["BASE_URL"] = base_url + return Settings(**overrides) + + class Djelia: def __init__( self, api_key: Union[str, None] = None, base_url: Union[str, None] = None ): - self.settings = None - if base_url is None: - self.settings = Settings() - self.base_url = self.settings.base_url - else: - self.base_url = base_url - - if api_key is None: - self.settings = Settings() - self.auth = Auth(self.settings.djelia_api_key) - else: - self.auth = Auth(api_key=api_key) + self.settings = _build_settings(api_key, base_url) + self.base_url = self.settings.base_url + self.auth = Auth(self.settings.djelia_api_key) + # OpenAI-style resources + self.translations = Translations(self) + self.audio = Audio(self) + + # Deprecated aliases (kept for backward compatibility) self.translation = Translation(self) self.transcription = Transcription(self) self.tts = TTS(self) @@ -61,19 +74,15 @@ class DjeliaAsync: def __init__( self, api_key: Union[str, None] = None, base_url: Union[str, None] = None ): - self.settings = None - if base_url is None: - self.settings = Settings() - self.base_url = self.settings.base_url - else: - self.base_url = self.base_url - - if api_key is None: - self.settings = Settings() - self.auth = Auth(self.settings.djelia_api_key) - else: - self.auth = Auth(api_key=api_key) + self.settings = _build_settings(api_key, base_url) + self.base_url = self.settings.base_url + self.auth = Auth(self.settings.djelia_api_key) + + # OpenAI-style resources + self.translations = AsyncTranslations(self) + self.audio = AsyncAudio(self) + # Deprecated aliases (kept for backward compatibility) self.translation = AsyncTranslation(self) self.transcription = AsyncTranscription(self) self.tts = AsyncTTS(self) diff --git a/djelia/src/services/__init__.py b/djelia/src/services/__init__.py index e37ffd1..60200ce 100644 --- a/djelia/src/services/__init__.py +++ b/djelia/src/services/__init__.py @@ -1,8 +1,21 @@ -from .transcription import AsyncTranscription, Transcription -from .translation import AsyncTranslation, Translation -from .tts import TTS, AsyncTTS +from .audio import AsyncAudio, Audio +from .transcription import (AsyncTranscription, AsyncTranscriptions, + Transcription, Transcriptions) +from .translation import (AsyncTranslation, AsyncTranslations, Translation, + Translations) +from .tts import TTS, AsyncSpeech, AsyncTTS, Speech __all__ = [ + # OpenAI-style resources + "Translations", + "AsyncTranslations", + "Audio", + "AsyncAudio", + "Transcriptions", + "AsyncTranscriptions", + "Speech", + "AsyncSpeech", + # Deprecated aliases "Transcription", "AsyncTranscription", "Translation", diff --git a/djelia/src/services/audio.py b/djelia/src/services/audio.py new file mode 100644 index 0000000..bd7af16 --- /dev/null +++ b/djelia/src/services/audio.py @@ -0,0 +1,21 @@ +from djelia.src.services.transcription import (AsyncTranscriptions, + Transcriptions) +from djelia.src.services.tts import AsyncSpeech, Speech + + +class Audio: + """OpenAI-style audio namespace: ``client.audio.transcriptions`` and + ``client.audio.speech``.""" + + def __init__(self, client): + self.transcriptions = Transcriptions(client) + self.speech = Speech(client) + + +class AsyncAudio: + """OpenAI-style async audio namespace: ``client.audio.transcriptions`` and + ``client.audio.speech``.""" + + def __init__(self, client): + self.transcriptions = AsyncTranscriptions(client) + self.speech = AsyncSpeech(client) diff --git a/djelia/src/services/transcription.py b/djelia/src/services/transcription.py index df83210..9b26519 100644 --- a/djelia/src/services/transcription.py +++ b/djelia/src/services/transcription.py @@ -1,5 +1,6 @@ import json import os +import warnings from collections.abc import AsyncGenerator, Generator from typing import BinaryIO @@ -10,33 +11,29 @@ TranscriptionSegment, Versions) -class Transcription: +class Transcriptions: + """OpenAI-style transcription resource: ``client.audio.transcriptions``.""" + def __init__(self, client): self.client = client - def transcribe( + def create( self, - audio_file: str | BinaryIO, - translate_to_french: bool | None = False, - stream: bool | None = False, - version: Versions | None = Versions.v2, + *, + file: str | BinaryIO, + model: Versions | int | str = Versions.v2, + translate_to_french: bool = False, + stream: bool = False, ) -> list[TranscriptionSegment] | FrenchTranscriptionResponse | Generator: - if not stream: - try: - params = {Params.translate_to_french: str(translate_to_french).lower()} - if isinstance(audio_file, str): - with open(audio_file, "rb") as f: - files = {Params.file: f} - response = self.client._make_request( - method=DjeliaRequest.transcribe.method, - endpoint=DjeliaRequest.transcribe.endpoint.format( - version.value - ), - files=files, - params=params, - ) - else: - files = {Params.file: audio_file} + version = Versions.from_value(model) + if stream: + return self._stream(file, translate_to_french, version) + + try: + params = {Params.translate_to_french: str(translate_to_french).lower()} + if isinstance(file, str): + with open(file, "rb") as f: + files = {Params.file: f} response = self.client._make_request( method=DjeliaRequest.transcribe.method, endpoint=DjeliaRequest.transcribe.endpoint.format( @@ -45,48 +42,53 @@ def transcribe( files=files, params=params, ) + else: + files = {Params.file: file} + response = self.client._make_request( + method=DjeliaRequest.transcribe.method, + endpoint=DjeliaRequest.transcribe.endpoint.format(version.value), + files=files, + params=params, + ) + except OSError as e: + raise OSError(ErrorsMessage.ioerror_read.format(str(e))) - except OSError as e: - raise OSError(ErrorsMessage.ioerror_read.format(str(e))) - - data = response.json() - return ( - FrenchTranscriptionResponse(**data) - if translate_to_french - else [TranscriptionSegment(**segment) for segment in data] - ) - - else: - return self._stream_transcribe(audio_file, translate_to_french, version) + data = response.json() + return ( + FrenchTranscriptionResponse(**data) + if translate_to_french + else [TranscriptionSegment(**segment) for segment in data] + ) - def _stream_transcribe( + def _stream( self, - audio_file: str | BinaryIO, - translate_to_french: bool = False, - version: Versions | None = Versions.v2, + file: str | BinaryIO, + translate_to_french: bool, + version: Versions, ) -> Generator[TranscriptionSegment | FrenchTranscriptionResponse, None, None]: try: params = {Params.translate_to_french: str(translate_to_french).lower()} - if isinstance(audio_file, str): - with open(audio_file, "rb") as f: + if isinstance(file, str): + with open(file, "rb") as f: files = {Params.file: f} response = self.client._make_request( - method=DjeliaRequest.transcribe.method, - endpoint=DjeliaRequest.transcribe.endpoint.format( + method=DjeliaRequest.transcribe_stream.method, + endpoint=DjeliaRequest.transcribe_stream.endpoint.format( version.value ), files=files, params=params, ) else: - files = {Params.file: audio_file} + files = {Params.file: file} response = self.client._make_request( method=DjeliaRequest.transcribe_stream.method, - endpoint=DjeliaRequest.transcribe.endpoint.format(version.value), + endpoint=DjeliaRequest.transcribe_stream.endpoint.format( + version.value + ), files=files, params=params, ) - except OSError as e: raise OSError(ErrorsMessage.ioerror_read.format(str(e))) @@ -111,65 +113,65 @@ def _stream_transcribe( continue -class AsyncTranscription: +class AsyncTranscriptions: + """OpenAI-style async transcription resource: ``client.audio.transcriptions``.""" + def __init__(self, client): self.client = client - async def transcribe( + async def create( self, - audio_file: str | BinaryIO, - translate_to_french: bool | None = False, - stream: bool | None = False, - version: Versions | None = Versions.v2, + *, + file: str | BinaryIO, + model: Versions | int | str = Versions.v2, + translate_to_french: bool = False, + stream: bool = False, ) -> list[TranscriptionSegment] | FrenchTranscriptionResponse | AsyncGenerator: - if not stream: - try: - data = aiohttp.FormData() - if isinstance(audio_file, str): - with open(audio_file, "rb") as f: - data.add_field( - Params.file, f.read(), filename=os.path.basename(audio_file) - ) - else: + version = Versions.from_value(model) + if stream: + return self._stream(file, translate_to_french, version) + + try: + data = aiohttp.FormData() + if isinstance(file, str): + with open(file, "rb") as f: data.add_field( - Params.file, audio_file.read(), filename=Params.filename + Params.file, f.read(), filename=os.path.basename(file) ) + else: + data.add_field(Params.file, file.read(), filename=Params.filename) - params = {Params.translate_to_french: str(translate_to_french).lower()} - response_data = await self.client._make_request( - method=DjeliaRequest.transcribe.method, - endpoint=DjeliaRequest.transcribe.endpoint.format(version.value), - data=data, - params=params, - ) - - except OSError as e: - raise OSError(ErrorsMessage.ioerror_read.format(str(e))) - - return ( - FrenchTranscriptionResponse(**response_data) - if translate_to_french - else [TranscriptionSegment(**segment) for segment in response_data] + params = {Params.translate_to_french: str(translate_to_french).lower()} + response_data = await self.client._make_request( + method=DjeliaRequest.transcribe.method, + endpoint=DjeliaRequest.transcribe.endpoint.format(version.value), + data=data, + params=params, ) + except OSError as e: + raise OSError(ErrorsMessage.ioerror_read.format(str(e))) - else: - return self._stream_transcribe(audio_file, translate_to_french, version) + return ( + FrenchTranscriptionResponse(**response_data) + if translate_to_french + else [TranscriptionSegment(**segment) for segment in response_data] + ) - async def _stream_transcribe( + async def _stream( self, - audio_file: str | BinaryIO, - translate_to_french: bool = False, - version: Versions | None = Versions.v2, + file: str | BinaryIO, + translate_to_french: bool, + version: Versions, ) -> AsyncGenerator[TranscriptionSegment | FrenchTranscriptionResponse, None]: try: data = aiohttp.FormData() - if isinstance(audio_file, str): - with open(audio_file, "rb") as f: + if isinstance(file, str): + with open(file, "rb") as f: data.add_field( - Params.file, f.read(), filename=os.path.basename(audio_file) + Params.file, f.read(), filename=os.path.basename(file) ) else: - data.add_field(Params.file, audio_file.read(), filename=Params.filename) + data.add_field(Params.file, file.read(), filename=Params.filename) params = {Params.translate_to_french: str(translate_to_french).lower()} response = await self.client._make_streaming_request( @@ -189,7 +191,6 @@ async def _stream_transcribe( line_str = line.decode("utf-8").strip() if line_str: segment_data = json.loads(line_str) - if isinstance(segment_data, list): for segment in segment_data: yield ( @@ -210,7 +211,6 @@ async def _stream_transcribe( response_text = await response.text() if response_text.strip(): segment_data = json.loads(response_text) - if isinstance(segment_data, list): for segment in segment_data: yield ( @@ -226,7 +226,6 @@ async def _stream_transcribe( ) except Exception: pass - except Exception as e: raise e finally: @@ -236,3 +235,57 @@ async def _stream_transcribe( except Exception: # there is a know issue here due to the server response pass + + +class Transcription: + """Deprecated. Use ``client.audio.transcriptions`` instead.""" + + def __init__(self, client): + self.client = client + + def transcribe( + self, + audio_file: str | BinaryIO, + translate_to_french: bool | None = False, + stream: bool | None = False, + version: Versions | None = Versions.v2, + ) -> list[TranscriptionSegment] | FrenchTranscriptionResponse | Generator: + warnings.warn( + "Transcription.transcribe() is deprecated; " + "use client.audio.transcriptions.create(file=...) instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.client.audio.transcriptions.create( + file=audio_file, + model=version, + translate_to_french=bool(translate_to_french), + stream=bool(stream), + ) + + +class AsyncTranscription: + """Deprecated. Use ``client.audio.transcriptions`` instead.""" + + def __init__(self, client): + self.client = client + + async def transcribe( + self, + audio_file: str | BinaryIO, + translate_to_french: bool | None = False, + stream: bool | None = False, + version: Versions | None = Versions.v2, + ) -> list[TranscriptionSegment] | FrenchTranscriptionResponse | AsyncGenerator: + warnings.warn( + "AsyncTranscription.transcribe() is deprecated; " + "use client.audio.transcriptions.create(file=...) instead.", + DeprecationWarning, + stacklevel=2, + ) + return await self.client.audio.transcriptions.create( + file=audio_file, + model=version, + translate_to_french=bool(translate_to_french), + stream=bool(stream), + ) diff --git a/djelia/src/services/translation.py b/djelia/src/services/translation.py index 5b0a294..12b1a68 100644 --- a/djelia/src/services/translation.py +++ b/djelia/src/services/translation.py @@ -1,12 +1,16 @@ -from djelia.models import (DjeliaRequest, SupportedLanguageSchema, +import warnings + +from djelia.models import (DjeliaRequest, Language, SupportedLanguageSchema, TranslationRequest, TranslationResponse, Versions) -class Translation: +class Translations: + """OpenAI-style translation resource: ``client.translations``.""" + def __init__(self, client): self.client = client - def get_supported_languages(self) -> list[SupportedLanguageSchema]: + def list_languages(self) -> list[SupportedLanguageSchema]: response = self.client._make_request( method=DjeliaRequest.get_supported_languages.method, endpoint=DjeliaRequest.get_supported_languages.endpoint.format( @@ -15,25 +19,31 @@ def get_supported_languages(self) -> list[SupportedLanguageSchema]: ) return [SupportedLanguageSchema(**lang) for lang in response.json()] - def translate( + def create( self, - request: TranslationRequest, - version: Versions | None = Versions.v1.value, + *, + text: str, + source: Language | str, + target: Language | str, + model: Versions | int | str = Versions.v1, ) -> TranslationResponse: - data = request.dict() + version = Versions.from_value(model) + request = TranslationRequest(text=text, source=source, target=target) response = self.client._make_request( method=DjeliaRequest.translate.method, endpoint=DjeliaRequest.translate.endpoint.format(version.value), - json=data, + json=request.dict(), ) return TranslationResponse(**response.json()) -class AsyncTranslation: +class AsyncTranslations: + """OpenAI-style async translation resource: ``client.translations``.""" + def __init__(self, client): self.client = client - async def get_supported_languages(self) -> list[SupportedLanguageSchema]: + async def list_languages(self) -> list[SupportedLanguageSchema]: data = await self.client._make_request( method=DjeliaRequest.get_supported_languages.method, endpoint=DjeliaRequest.get_supported_languages.endpoint.format( @@ -42,13 +52,87 @@ async def get_supported_languages(self) -> list[SupportedLanguageSchema]: ) return [SupportedLanguageSchema(**lang) for lang in data] - async def translate( - self, request: TranslationRequest, version: Versions | None = Versions.v1 + async def create( + self, + *, + text: str, + source: Language | str, + target: Language | str, + model: Versions | int | str = Versions.v1, ) -> TranslationResponse: - request_data = request.dict() + version = Versions.from_value(model) + request = TranslationRequest(text=text, source=source, target=target) data = await self.client._make_request( method=DjeliaRequest.translate.method, endpoint=DjeliaRequest.translate.endpoint.format(version.value), - json=request_data, + json=request.dict(), ) return TranslationResponse(**data) + + +class Translation: + """Deprecated. Use ``client.translations`` instead.""" + + def __init__(self, client): + self.client = client + + def get_supported_languages(self) -> list[SupportedLanguageSchema]: + warnings.warn( + "Translation.get_supported_languages() is deprecated; " + "use client.translations.list_languages() instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.client.translations.list_languages() + + def translate( + self, + request: TranslationRequest, + version: Versions | None = Versions.v1, + ) -> TranslationResponse: + warnings.warn( + "Translation.translate() is deprecated; " + "use client.translations.create(text=..., source=..., target=...) instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.client.translations.create( + text=request.text, + source=request.source, + target=request.target, + model=version, + ) + + +class AsyncTranslation: + """Deprecated. Use ``client.translations`` instead.""" + + def __init__(self, client): + self.client = client + + async def get_supported_languages(self) -> list[SupportedLanguageSchema]: + warnings.warn( + "AsyncTranslation.get_supported_languages() is deprecated; " + "use client.translations.list_languages() instead.", + DeprecationWarning, + stacklevel=2, + ) + return await self.client.translations.list_languages() + + async def translate( + self, + request: TranslationRequest, + version: Versions | None = Versions.v1, + ) -> TranslationResponse: + warnings.warn( + "AsyncTranslation.translate() is deprecated; " + "use client.translations.create(text=..., source=..., target=...) instead.", + DeprecationWarning, + stacklevel=2, + ) + return await self.client.translations.create( + text=request.text, + source=request.source, + target=request.target, + model=version, + ) diff --git a/djelia/src/services/tts.py b/djelia/src/services/tts.py index 03e9981..fbe4bc0 100644 --- a/djelia/src/services/tts.py +++ b/djelia/src/services/tts.py @@ -1,78 +1,99 @@ +import warnings from collections.abc import AsyncGenerator, Generator -# from djelia.config.settings import VALID_SPEAKER_IDS, VALID_TTS_V2_SPEAKERS from djelia.models import (DjeliaRequest, ErrorsMessage, TTSRequest, TTSRequestV2, Versions) from djelia.utils.exceptions import SpeakerError -class TTS: +def _build_request( + client, + version: Versions, + input: str, + voice: int | None, + description: str | None, + chunk_size: float, +) -> TTSRequest | TTSRequestV2: + """Build and validate the underlying TTS request for the given version.""" + if version == Versions.v1: + if voice is None: + voice = client.settings.default_speaker_id + request = TTSRequest(text=input, speaker=voice) + if request.speaker not in client.settings.valid_speaker_ids: + raise SpeakerError( + ErrorsMessage.speaker_id_error.format( + client.settings.valid_speaker_ids, request.speaker + ) + ) + return request + + if description is None: + raise ValueError(ErrorsMessage.tts_v2_description_required) + request = TTSRequestV2(text=input, description=description, chunk_size=chunk_size) + speaker_found = any( + speaker.lower() in request.description.lower() + for speaker in client.settings.valid_tts_v2_speakers + ) + if not speaker_found: + raise SpeakerError( + ErrorsMessage.speaker_description_error.format( + client.settings.valid_tts_v2_speakers + ) + ) + return request + + +class Speech: + """OpenAI-style text-to-speech resource: ``client.audio.speech``.""" + def __init__(self, client): self.client = client - def text_to_speech( + def create( self, - request: TTSRequest | TTSRequestV2, + *, + input: str, + voice: int | None = None, + description: str | None = None, + model: Versions | int | str = Versions.v1, + chunk_size: float = 1.0, + stream: bool = False, output_file: str | None = None, - stream: bool | None = False, - version: Versions | None = Versions.v1, ) -> bytes | str | Generator: - if version == Versions.v1: - if not isinstance(request, TTSRequest): - raise ValueError(ErrorsMessage.tts_v1_request_error) - if request.speaker not in self.client.settings.valid_speaker_ids: - raise SpeakerError( - ErrorsMessage.speaker_id_error.format( - self.client.settings.valid_speaker_ids, request.speaker - ) - ) - else: - if not isinstance(request, TTSRequestV2): - raise ValueError(ErrorsMessage.tts_v2_request_error) - speaker_found = any( - speaker.lower() in request.description.lower() - for speaker in self.client.settings.valid_tts_v2_speakers - ) - if not speaker_found: - raise SpeakerError( - ErrorsMessage.speaker_description_error.format( - self.client.settings.valid_tts_v2_speakers - ) - ) - - if not stream: - data = request.dict() - response = self.client._make_request( - method=DjeliaRequest.tts.method, - endpoint=DjeliaRequest.tts.endpoint.format(version.value), - json=data, - ) + version = Versions.from_value(model) + request = _build_request( + self.client, version, input, voice, description, chunk_size + ) - if output_file: - try: - with open(output_file, "wb") as f: - f.write(response.content) - return output_file - except OSError as e: - raise OSError(ErrorsMessage.ioerror_save.format(str(e))) - else: - return response.content - else: + if stream: if version == Versions.v1: - raise ValueError(ErrorsMessage.tts_streaming_conpatibility) - return self._stream_text_to_speech(request, output_file, version) + raise ValueError(ErrorsMessage.tts_streaming_compatibility) + return self._stream(request, output_file, version) - def _stream_text_to_speech( + response = self.client._make_request( + method=DjeliaRequest.tts.method, + endpoint=DjeliaRequest.tts.endpoint.format(version.value), + json=request.dict(), + ) + if output_file: + try: + with open(output_file, "wb") as f: + f.write(response.content) + return output_file + except OSError as e: + raise OSError(ErrorsMessage.ioerror_save.format(str(e))) + return response.content + + def _stream( self, request: TTSRequestV2, - output_file: str | None = None, - version: Versions | None = Versions.v2, + output_file: str | None, + version: Versions, ) -> Generator[bytes, None, None]: - data = request.dict() response = self.client._make_request( method=DjeliaRequest.tts_stream.method, endpoint=DjeliaRequest.tts_stream.endpoint.format(version.value), - json=data, + json=request.dict(), stream=True, ) @@ -91,74 +112,57 @@ def _stream_text_to_speech( raise OSError(ErrorsMessage.ioerror_save.format(str(e))) -class AsyncTTS: +class AsyncSpeech: + """OpenAI-style async text-to-speech resource: ``client.audio.speech``.""" + def __init__(self, client): self.client = client - async def text_to_speech( + async def create( self, - request: TTSRequest | TTSRequestV2, + *, + input: str, + voice: int | None = None, + description: str | None = None, + model: Versions | int | str = Versions.v1, + chunk_size: float = 1.0, + stream: bool = False, output_file: str | None = None, - stream: bool | None = False, - version: Versions | None = Versions.v1, ) -> bytes | str | AsyncGenerator: - if version == Versions.v1: - if not isinstance(request, TTSRequest): - raise ValueError(ErrorsMessage.tts_v1_request_error) - if request.speaker not in self.client.settings.valid_speaker_ids: - raise SpeakerError( - ErrorsMessage.speaker_id_error.format( - self.client.settings.valid_speaker_ids, request.speaker - ) - ) - else: - if not isinstance(request, TTSRequestV2): - raise ValueError(ErrorsMessage.tts_v2_request_error) - speaker_found = any( - speaker.lower() in request.description.lower() - for speaker in self.client.settings.valid_tts_v2_speakers - ) - if not speaker_found: - raise SpeakerError( - ErrorsMessage.speaker_description_error.format( - self.client.settings.valid_tts_v2_speakers - ) - ) - - if not stream: - request_data = request.dict() - content = await self.client._make_request( - method=DjeliaRequest.tts.method, - endpoint=DjeliaRequest.tts.endpoint.format(version.value), - json=request_data, - ) + version = Versions.from_value(model) + request = _build_request( + self.client, version, input, voice, description, chunk_size + ) - if output_file: - try: - with open(output_file, "wb") as f: - f.write(content) - return output_file - except OSError as e: - raise OSError(ErrorsMessage.ioerror_save.format(str(e))) - else: - return content - else: + if stream: if version == Versions.v1: - raise ValueError(ErrorsMessage.tts_streaming_conpatibility) - # FIXED: Remove 'await' here - async generators should not be awaited when returned - return self._stream_text_to_speech(request, output_file, version) + raise ValueError(ErrorsMessage.tts_streaming_compatibility) + return self._stream(request, output_file, version) + + content = await self.client._make_request( + method=DjeliaRequest.tts.method, + endpoint=DjeliaRequest.tts.endpoint.format(version.value), + json=request.dict(), + ) + if output_file: + try: + with open(output_file, "wb") as f: + f.write(content) + return output_file + except OSError as e: + raise OSError(ErrorsMessage.ioerror_save.format(str(e))) + return content - async def _stream_text_to_speech( + async def _stream( self, request: TTSRequestV2, - output_file: str | None = None, - version: Versions | None = Versions.v2, + output_file: str | None, + version: Versions, ) -> AsyncGenerator[bytes, None]: - request_data = request.dict() response = await self.client._make_streaming_request( method=DjeliaRequest.tts_stream.method, endpoint=DjeliaRequest.tts_stream.endpoint.format(version.value), - json=request_data, + json=request.dict(), ) audio_chunks = [] @@ -177,3 +181,87 @@ async def _stream_text_to_speech( f.write(chunk) except OSError as e: raise OSError(ErrorsMessage.ioerror_save.format(str(e))) + + +class TTS: + """Deprecated. Use ``client.audio.speech`` instead.""" + + def __init__(self, client): + self.client = client + + def text_to_speech( + self, + request: TTSRequest | TTSRequestV2, + output_file: str | None = None, + stream: bool | None = False, + version: Versions | None = Versions.v1, + ) -> bytes | str | Generator: + warnings.warn( + "TTS.text_to_speech() is deprecated; " + "use client.audio.speech.create(input=...) instead.", + DeprecationWarning, + stacklevel=2, + ) + version = Versions.from_value(version) + if version == Versions.v1: + if not isinstance(request, TTSRequest): + raise ValueError(ErrorsMessage.tts_v1_request_error) + return self.client.audio.speech.create( + input=request.text, + voice=request.speaker, + model=version, + stream=bool(stream), + output_file=output_file, + ) + if not isinstance(request, TTSRequestV2): + raise ValueError(ErrorsMessage.tts_v2_request_error) + return self.client.audio.speech.create( + input=request.text, + description=request.description, + chunk_size=request.chunk_size, + model=version, + stream=bool(stream), + output_file=output_file, + ) + + +class AsyncTTS: + """Deprecated. Use ``client.audio.speech`` instead.""" + + def __init__(self, client): + self.client = client + + async def text_to_speech( + self, + request: TTSRequest | TTSRequestV2, + output_file: str | None = None, + stream: bool | None = False, + version: Versions | None = Versions.v1, + ) -> bytes | str | AsyncGenerator: + warnings.warn( + "AsyncTTS.text_to_speech() is deprecated; " + "use client.audio.speech.create(input=...) instead.", + DeprecationWarning, + stacklevel=2, + ) + version = Versions.from_value(version) + if version == Versions.v1: + if not isinstance(request, TTSRequest): + raise ValueError(ErrorsMessage.tts_v1_request_error) + return await self.client.audio.speech.create( + input=request.text, + voice=request.speaker, + model=version, + stream=bool(stream), + output_file=output_file, + ) + if not isinstance(request, TTSRequestV2): + raise ValueError(ErrorsMessage.tts_v2_request_error) + return await self.client.audio.speech.create( + input=request.text, + description=request.description, + chunk_size=request.chunk_size, + model=version, + stream=bool(stream), + output_file=output_file, + ) diff --git a/djelia/utils/errors.py b/djelia/utils/errors.py index 7ede6c7..a258edc 100644 --- a/djelia/utils/errors.py +++ b/djelia/utils/errors.py @@ -26,9 +26,13 @@ class CodeStatusExceptions: def api_exception(code: int, error: Exception) -> Exception: - return CodeStatusExceptions.exceptions.get(code, APIError)( - ExceptionMessage.messages.get(code, ExceptionMessage.default.format(str(error))) + message = ExceptionMessage.messages.get( + code, ExceptionMessage.default.format(str(error)) ) + exception_cls = CodeStatusExceptions.exceptions.get(code, APIError) + if issubclass(exception_cls, APIError): + return exception_cls(status_code=code, message=message) + return exception_cls(message) def general_exception(error: Exception) -> Exception: diff --git a/docs/api.md b/docs/api.md index 1e85fd1..6416b76 100644 --- a/docs/api.md +++ b/docs/api.md @@ -10,12 +10,14 @@ The Djelia Python SDK interacts with the Djelia API at `https://djelia.cloud/api | Endpoint | Method | Description | SDK Method | |----------|--------|-------------|------------| -| `/api/v{version}/models/translate/supported-languages` | GET | List supported languages | `translation.get_supported_languages()` | -| `/api/v{version}/models/translate` | POST | Translate text | `translation.translate()` | -| `/api/v{version}/models/transcribe` | POST | Transcribe audio | `transcription.transcribe(stream=False)` | -| `/api/v{version}/models/transcribe/stream` | POST | Stream transcription | `transcription.transcribe(stream=True)` | -| `/api/v{version}/models/tts` | POST | Generate TTS audio | `tts.text_to_speech(stream=False)` | -| `/api/v{version}/models/tts/stream` | POST | Stream TTS audio | `tts.text_to_speech(stream=True)` | +| `/api/v{version}/models/translate/supported-languages` | GET | List supported languages | `translations.list_languages()` | +| `/api/v{version}/models/translate` | POST | Translate text | `translations.create()` | +| `/api/v{version}/models/transcribe` | POST | Transcribe audio | `audio.transcriptions.create(stream=False)` | +| `/api/v{version}/models/transcribe/stream` | POST | Stream transcription | `audio.transcriptions.create(stream=True)` | +| `/api/v{version}/models/tts` | POST | Generate TTS audio | `audio.speech.create(stream=False)` | +| `/api/v{version}/models/tts/stream` | POST | Stream TTS audio | `audio.speech.create(stream=True)` | + +> The pre-`2.0` methods (`translation.translate()`, `transcription.transcribe()`, `tts.text_to_speech()`) still work but are deprecated and emit a `DeprecationWarning`. ### Rate Limits - Contact [support@djelia.cloud](mailto:support@djelia.cloud) for rate limit details. diff --git a/setup.py b/setup.py index e15ec21..24e1918 100644 --- a/setup.py +++ b/setup.py @@ -8,9 +8,9 @@ setup( name="djelia", - version="1.1.1", + version="2.1.1", author="Djelia", - author_email="support@djelia.cloud", + author_email="sudoping01@gmail.com", description="Djelia Python SDK - Advanced AI for African Languages", long_description=long_description, long_description_content_type="text/markdown", @@ -67,6 +67,11 @@ "pytest-asyncio>=0.18.0", "python-dotenv>=0.19.0", ], + "cookbook": [ + "typer>=0.12.0", + "rich>=13.0.0", + "python-dotenv>=0.19.0", + ], }, keywords=[ "djelia",