diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index b13040c..4a271c5 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -22,7 +22,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Cache pip dependencies - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ hashFiles('**/pyproject.toml') }} @@ -33,6 +33,7 @@ jobs: run: | python -m pip install --upgrade pip pip install -e ".[dev]" + pip install build - name: Lint with flake8 run: | @@ -49,44 +50,21 @@ jobs: run: | mypy src/lingodotdev + - name: Validate package builds + run: | + python -m build + - name: Test with pytest + env: + LINGODOTDEV_API_KEY: ${{ secrets.LINGODOTDEV_API_KEY }} run: | pytest --cov=src/lingodotdev --cov-report=xml --cov-report=term-missing - name: Upload coverage to Codecov if: matrix.python-version == '3.11' - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v4 with: file: ./coverage.xml flags: unittests name: codecov-umbrella - fail_ci_if_error: false - - build: - name: Build package - runs-on: ubuntu-latest - needs: test - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.11' - - - name: Install build dependencies - run: | - python -m pip install --upgrade pip - pip install build - - - name: Build package - run: | - python -m build - - - name: Upload build artifacts - uses: actions/upload-artifact@v3 - with: - name: dist - path: dist/ \ No newline at end of file + fail_ci_if_error: false \ No newline at end of file diff --git a/README.md b/README.md index d6a0982..8883030 100644 --- a/README.md +++ b/README.md @@ -1,391 +1,235 @@ # Lingo.dev Python SDK -> ๐Ÿ’ฌ **[Join our Discord community](https://lingo.dev/go/discord)** for support, discussions, and updates! +A powerful async-first localization engine that supports various content types including plain text, objects, chat sequences, and HTML documents. -[![PyPI version](https://badge.fury.io/py/lingodotdev.svg)](https://badge.fury.io/py/lingodotdev) -[![Python support](https://img.shields.io/pypi/pyversions/lingodotdev)](https://pypi.org/project/lingodotdev/) -[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) -[![Tests](https://github.com/lingodotdev/sdk-python/workflows/Pull%20Request/badge.svg)](https://github.com/lingodotdev/sdk-python/actions) -[![Coverage](https://codecov.io/gh/lingodotdev/sdk-python/branch/main/graph/badge.svg)](https://codecov.io/gh/lingodotdev/sdk-python) +## โœจ Key Features -A powerful Python SDK for the Lingo.dev localization platform. This SDK provides easy-to-use methods for localizing various content types including plain text, objects, and chat sequences. +- ๐Ÿš€ **Async-first design** for high-performance concurrent translations +- ๐Ÿ”€ **Concurrent processing** for dramatically faster bulk translations +- ๐ŸŽฏ **Multiple content types**: text, objects, chat messages, and more +- ๐ŸŒ **Auto-detection** of source languages +- โšก **Fast mode** for quick translations +- ๐Ÿ”ง **Flexible configuration** with progress callbacks +- ๐Ÿ“ฆ **Context manager** support for proper resource management -## Features +## ๐Ÿš€ Performance Benefits -- ๐ŸŒ **Multiple Content Types**: Localize text, objects, and chat sequences -- ๐Ÿš€ **Batch Processing**: Efficient handling of large content with automatic chunking -- ๐Ÿ”„ **Progress Tracking**: Optional progress callbacks for long-running operations -- ๐ŸŽฏ **Language Detection**: Automatic language recognition -- ๐Ÿ“Š **Fast Mode**: Optional fast processing for larger batches -- ๐Ÿ›ก๏ธ **Type Safety**: Full type hints and Pydantic validation -- ๐Ÿงช **Well Tested**: Comprehensive test suite with high coverage -- ๐Ÿ”ง **Easy Configuration**: Simple setup with minimal configuration required +The async implementation provides significant performance improvements: +- **Concurrent chunk processing** for large payloads +- **Batch operations** for multiple translations +- **Parallel API requests** instead of sequential ones +- **Better resource management** with httpx -## Installation +## ๐Ÿ“ฆ Installation ```bash pip install lingodotdev ``` -## Quick Start +## ๐ŸŽฏ Quick Start + +### Simple Translation ```python +import asyncio from lingodotdev import LingoDotDevEngine -# Initialize the engine -engine = LingoDotDevEngine({ - 'api_key': 'your-api-key-here' -}) - -# Localize a simple text -result = engine.localize_text( - "Hello, world!", - { - 'source_locale': 'en', - 'target_locale': 'es' - } -) -print(result) # "ยกHola, mundo!" - -# Localize an object -data = { - 'greeting': 'Hello', - 'farewell': 'Goodbye', - 'question': 'How are you?' -} +async def main(): + # Quick one-off translation (handles context management automatically) + result = await LingoDotDevEngine.quick_translate( + "Hello, world!", + api_key="your-api-key", + target_locale="es" + ) + print(result) # "ยกHola, mundo!" -result = engine.localize_object( - data, - { - 'source_locale': 'en', - 'target_locale': 'fr' - } -) -print(result) -# { -# 'greeting': 'Bonjour', -# 'farewell': 'Au revoir', -# 'question': 'Comment allez-vous?' -# } +asyncio.run(main()) ``` -## API Reference - -### LingoDotDevEngine - -#### Constructor +### Context Manager (Recommended for Multiple Operations) ```python -engine = LingoDotDevEngine(config) -``` - -**Parameters:** -- `config` (dict): Configuration dictionary with the following options: - - `api_key` (str, required): Your Lingo.dev API key - -#### Methods - -### `localize_text(text, params, progress_callback=None)` - -Localize a single text string. - -**Parameters:** -- `text` (str): The text to localize -- `params` (dict): Localization parameters - - `source_locale` (str): Source language code (e.g., 'en') - - `target_locale` (str): Target language code (e.g., 'es') -- `progress_callback` (callable): Progress callback function - -**Returns:** `str` - The localized text +import asyncio +from lingodotdev import LingoDotDevEngine -**Example:** -```python -result = engine.localize_text( - "Welcome to our application", - { - 'source_locale': 'en', - 'target_locale': 'es' +async def main(): + config = { + "api_key": "your-api-key", + "api_url": "https://engine.lingo.dev" # Optional, defaults to this } -) + + async with LingoDotDevEngine(config) as engine: + # Translate text + text_result = await engine.localize_text( + "Hello, world!", + {"target_locale": "es"} + ) + + # Translate object with concurrent processing + obj_result = await engine.localize_object( + { + "greeting": "Hello", + "farewell": "Goodbye", + "question": "How are you?" + }, + {"target_locale": "es"}, + concurrent=True # Process chunks concurrently for speed + ) + +asyncio.run(main()) ``` -### `localize_object(obj, params, progress_callback=None)` - -Localize a Python dictionary with string values. +## ๐Ÿ”ฅ Advanced Usage -**Parameters:** -- `obj` (dict): The object to localize -- `params` (dict): Localization parameters (same as `localize_text`) -- `progress_callback` (callable): Progress callback function +### Batch Processing (Multiple Target Languages) -**Returns:** `dict` - The localized object with the same structure - -**Example:** ```python -def progress_callback(progress, source_chunk, processed_chunk): - print(f"Progress: {progress}%") - -result = engine.localize_object( - { - 'title': 'My App', - 'description': 'A great application', - 'button_text': 'Click me' - }, - { - 'source_locale': 'en', - 'target_locale': 'de' - }, - progress_callback=progress_callback -) +async def batch_example(): + # Translate to multiple languages at once + results = await LingoDotDevEngine.quick_batch_translate( + "Welcome to our application", + api_key="your-api-key", + target_locales=["es", "fr", "de", "it"] + ) + # Results: ["Bienvenido...", "Bienvenue...", "Willkommen...", "Benvenuto..."] ``` -### `batch_localize_text(text, params)` - -Localize a text string to multiple target languages. - -**Parameters:** -- `text` (str): The text to localize -- `params` (dict): Batch localization parameters - - `source_locale` (str): Source language code - - `target_locales` (list): List of target language codes - -**Returns:** `list` - List of localized strings in the same order as target_locales +### Large Object Processing with Progress -**Example:** ```python -results = engine.batch_localize_text( - "Welcome to our platform", - { - 'source_locale': 'en', - 'target_locales': ['es', 'fr', 'de', 'it'] - } -) +async def progress_example(): + def progress_callback(progress, source_chunk, processed_chunk): + print(f"Progress: {progress}% - Processed {len(processed_chunk)} items") + + large_content = {f"item_{i}": f"Content {i}" for i in range(1000)} + + async with LingoDotDevEngine({"api_key": "your-api-key"}) as engine: + result = await engine.localize_object( + large_content, + {"target_locale": "es"}, + progress_callback=progress_callback, + concurrent=True # Much faster for large objects + ) ``` -### `localize_chat(chat, params, progress_callback=None)` - -Localize a chat conversation while preserving speaker names. - -**Parameters:** -- `chat` (list): List of chat messages with `name` and `text` keys -- `params` (dict): Localization parameters (same as `localize_text`) -- `progress_callback` (callable, optional): Progress callback function - -**Returns:** `list` - Localized chat messages with preserved structure +### Chat Translation -**Example:** ```python -chat = [ - {'name': 'Alice', 'text': 'Hello everyone!'}, - {'name': 'Bob', 'text': 'How are you doing?'}, - {'name': 'Charlie', 'text': 'Great, thanks for asking!'} -] - -result = engine.localize_chat( - chat, - { - 'source_locale': 'en', - 'target_locale': 'es' - } -) +async def chat_example(): + chat_messages = [ + {"name": "Alice", "text": "Hello everyone!"}, + {"name": "Bob", "text": "How is everyone doing?"}, + {"name": "Charlie", "text": "Great to see you all!"} + ] + + async with LingoDotDevEngine({"api_key": "your-api-key"}) as engine: + translated_chat = await engine.localize_chat( + chat_messages, + {"source_locale": "en", "target_locale": "es"} + ) + # Names preserved, text translated ``` -### `recognize_locale(text)` - -Detect the language of a given text. - -**Parameters:** -- `text` (str): The text to analyze - -**Returns:** `str` - The detected language code (e.g., 'en', 'es', 'fr') +### Multiple Objects Concurrently -**Example:** ```python -locale = engine.recognize_locale("Bonjour, comment allez-vous?") -print(locale) # 'fr' +async def concurrent_objects_example(): + objects = [ + {"title": "Welcome", "description": "Please sign in"}, + {"error": "Invalid input", "help": "Check your email"}, + {"success": "Account created", "next": "Continue to dashboard"} + ] + + async with LingoDotDevEngine({"api_key": "your-api-key"}) as engine: + results = await engine.batch_localize_objects( + objects, + {"target_locale": "fr"} + ) + # All objects translated concurrently ``` -### `whoami()` +### Language Detection -Get information about the current API key. - -**Returns:** `dict` or `None` - User information with 'email' and 'id' keys, or None if not authenticated - -**Example:** ```python -user_info = engine.whoami() -if user_info: - print(f"Authenticated as: {user_info['email']}") -else: - print("Not authenticated") +async def detection_example(): + async with LingoDotDevEngine({"api_key": "your-api-key"}) as engine: + detected = await engine.recognize_locale("Bonjour le monde") + print(detected) # "fr" ``` -## Error Handling - -The SDK raises the following exceptions: - -- `ValueError`: For invalid input parameters -- `RuntimeError`: For API errors and network issues -- `pydantic.ValidationError`: For configuration validation errors +## โš™๏ธ Configuration Options -**Example:** ```python -try: - result = engine.localize_text( - "Hello world", - {'target_locale': 'es'} # Missing source_locale - ) -except ValueError as e: - print(f"Invalid parameters: {e}") -except RuntimeError as e: - print(f"API error: {e}") -``` - -## Advanced Usage - -### Using Reference Translations - -You can provide reference translations to improve consistency: - -```python -reference = { - 'es': { - 'greeting': 'Hola', - 'app_name': 'Mi Aplicaciรณn' - }, - 'fr': { - 'greeting': 'Bonjour', - 'app_name': 'Mon Application' - } +config = { + "api_key": "your-api-key", # Required: Your API key + "api_url": "https://engine.lingo.dev", # Optional: API endpoint + "batch_size": 25, # Optional: Items per batch (1-250) + "ideal_batch_item_size": 250 # Optional: Target words per batch (1-2500) } - -result = engine.localize_object( - { - 'greeting': 'Hello', - 'app_name': 'My App', - 'welcome_message': 'Welcome to My App' - }, - { - 'source_locale': 'en', - 'target_locale': 'es', - 'reference': reference - } -) -``` - -### Progress Tracking - -For long-running operations, you can track progress: - -```python -def progress_callback(progress, source_chunk, processed_chunk): - print(f"Progress: {progress}%") - print(f"Processing: {len(source_chunk)} items") - print(f"Completed: {len(processed_chunk)} items") - -# Large dataset that will be processed in chunks -large_data = {f"key_{i}": f"Text content {i}" for i in range(1000)} - -result = engine.localize_object( - large_data, - { - 'source_locale': 'en', - 'target_locale': 'es' - }, - progress_callback=progress_callback -) ``` +## ๐ŸŽ›๏ธ Method Parameters -## Development +### Translation Parameters +- **source_locale**: Source language code (auto-detected if None) +- **target_locale**: Target language code (required) +- **fast**: Enable fast mode for quicker translations +- **reference**: Reference translations for context +- **concurrent**: Process chunks concurrently (faster, but no progress callbacks) -### Setup +### Performance Options +- **concurrent=True**: Enables parallel processing of chunks +- **progress_callback**: Function to track progress (disabled with concurrent=True) -```bash -git clone https://github.com/lingodotdev/sdk-python.git -cd sdk-python -pip install -e ".[dev]" -``` +## ๐Ÿ”ง Error Handling -### Running Tests - -```bash -# Run all tests -pytest - -# Run with coverage -pytest --cov=src/lingo_dev_sdk --cov-report=html - -# Run only unit tests -pytest tests/test_engine.py - -# Run integration tests (requires API key) -export LINGO_DEV_API_KEY=your-api-key -pytest tests/test_integration.py -``` - -### Code Quality - -```bash -# Format code -black . - -# Lint code -flake8 . - -# Type checking -mypy src/lingo_dev_sdk +```python +async def error_handling_example(): + try: + async with LingoDotDevEngine({"api_key": "invalid-key"}) as engine: + result = await engine.localize_text("Hello", {"target_locale": "es"}) + except ValueError as e: + print(f"Invalid request: {e}") + except RuntimeError as e: + print(f"API error: {e}") ``` -## License - -This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details. - -## Contributing - -1. Fork the repository -2. Create your feature branch (`git checkout -b feature/amazing-feature`) -3. Make your changes and add tests -4. Commit your changes using [Conventional Commits](https://www.conventionalcommits.org/): - - `feat: add new feature` - - `fix: resolve bug` - - `docs: update documentation` - - `style: format code` - - `refactor: refactor code` - - `test: add tests` - - `chore: update dependencies` -5. Push to the branch (`git push origin feature/amazing-feature`) -6. Open a Pull Request - -### Release Process +## ๐Ÿš€ Performance Tips -This project uses automated semantic releases: +1. **Use `concurrent=True`** for large objects or multiple chunks +2. **Use `batch_localize_objects()`** for multiple objects +3. **Use context managers** for multiple operations +4. **Use `quick_translate()`** for one-off translations +5. **Adjust `batch_size`** based on your content structure -- **Pull Requests**: Automatically run tests and build checks -- **Main Branch**: Automatically analyzes commit messages, bumps version, updates changelog, and publishes to PyPI -- **Commit Messages**: Must follow [Conventional Commits](https://www.conventionalcommits.org/) format - - `feat:` triggers a minor version bump (0.1.0 โ†’ 0.2.0) - - `fix:` triggers a patch version bump (0.1.0 โ†’ 0.1.1) - - `BREAKING CHANGE:` triggers a major version bump (0.1.0 โ†’ 1.0.0) +## ๐Ÿค Migration from Sync Version -### Development Workflow +The async version is a drop-in replacement with these changes: +- Add `async`/`await` to all method calls +- Use `async with` for context managers +- All methods now return awaitable coroutines -1. Create a feature branch -2. Make changes with proper commit messages -3. Open a PR (triggers CI/CD) -4. Merge to main (triggers release if applicable) -5. Automated release to PyPI +## ๐Ÿ“š API Reference -## Support +### Core Methods +- `localize_text(text, params)` - Translate text strings +- `localize_object(obj, params)` - Translate dictionary objects +- `localize_chat(chat, params)` - Translate chat messages +- `batch_localize_text(text, params)` - Translate to multiple languages +- `batch_localize_objects(objects, params)` - Translate multiple objects +- `recognize_locale(text)` - Detect language +- `whoami()` - Get API account info -- ๐Ÿ“ง Email: [hi@lingo.dev](mailto:hi@lingo.dev) -- ๐Ÿ› Issues: [GitHub Issues](https://github.com/lingodotdev/sdk-python/issues) -- ๐Ÿ“– Documentation: [https://lingo.dev/sdk](https://lingo.dev/sdk) +### Convenience Methods +- `quick_translate(content, api_key, target_locale, ...)` - One-off translation +- `quick_batch_translate(content, api_key, target_locales, ...)` - Batch translation -## Changelog +## ๐Ÿ“„ License -See [CHANGELOG.md](CHANGELOG.md) for a detailed history of changes. +Apache-2.0 License ---- +## ๐Ÿค– Support -> ๐Ÿ’ฌ **[Join our Discord community](https://lingo.dev/go/discord)** for support, discussions, and updates! \ No newline at end of file +- ๐Ÿ“š [Documentation](https://lingo.dev/docs) +- ๐Ÿ› [Issues](https://github.com/lingodotdev/sdk-python/issues) +- ๐Ÿ’ฌ [Community](https://lingo.dev/discord) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index a877d8b..4b02039 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ classifiers = [ "Topic :: Text Processing :: Linguistic", ] dependencies = [ - "requests>=2.25.0", + "httpx>=0.24.0", "pydantic>=2.0.0", "nanoid>=2.0.0", ] @@ -38,11 +38,11 @@ dependencies = [ [project.optional-dependencies] dev = [ "pytest>=7.0.0", + "pytest-asyncio>=0.21.0", "pytest-cov>=4.0.0", "black>=23.0.0", "flake8>=6.0.0", "mypy>=1.0.0", - "types-requests>=2.25.0", "python-semantic-release>=8.0.0", ] diff --git a/src/lingodotdev/engine.py b/src/lingodotdev/engine.py index b58ab13..64bfbb0 100644 --- a/src/lingodotdev/engine.py +++ b/src/lingodotdev/engine.py @@ -1,13 +1,14 @@ """ -LingoDotDevEngine implementation for Python SDK +LingoDotDevEngine implementation for Python SDK - Async version with httpx """ # mypy: disable-error-code=unreachable +import asyncio from typing import Any, Callable, Dict, List, Optional from urllib.parse import urljoin -import requests +import httpx from nanoid import generate from pydantic import BaseModel, Field, field_validator @@ -52,21 +53,41 @@ def __init__(self, config: Dict[str, Any]): config: Configuration options for the Engine """ self.config = EngineConfig(**config) - self.session = requests.Session() - self.session.headers.update( - { - "Content-Type": "application/json; charset=utf-8", - "Authorization": f"Bearer {self.config.api_key}", - } - ) + self._client: Optional[httpx.AsyncClient] = None + + async def __aenter__(self): + """Async context manager entry""" + await self._ensure_client() + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + """Async context manager exit""" + await self.close() + + async def _ensure_client(self): + """Ensure the httpx client is initialized""" + if self._client is None or self._client.is_closed: + self._client = httpx.AsyncClient( + headers={ + "Content-Type": "application/json; charset=utf-8", + "Authorization": f"Bearer {self.config.api_key}", + }, + timeout=30.0, + ) - def _localize_raw( + async def close(self): + """Close the httpx client""" + if self._client and not self._client.is_closed: + await self._client.aclose() + + async def _localize_raw( self, payload: Dict[str, Any], params: LocalizationParams, progress_callback: Optional[ Callable[[int, Dict[str, str], Dict[str, str]], None] ] = None, + concurrent: bool = False, ) -> Dict[str, str]: """ Localize content using the Lingo.dev API @@ -75,30 +96,49 @@ def _localize_raw( payload: The content to be localized params: Localization parameters progress_callback: Optional callback function to report progress (0-100) + concurrent: Whether to process chunks concurrently (faster but no progress tracking) Returns: Localized content """ + await self._ensure_client() chunked_payload = self._extract_payload_chunks(payload) - processed_payload_chunks = [] - workflow_id = generate() - for i, chunk in enumerate(chunked_payload): - percentage_completed = round(((i + 1) / len(chunked_payload)) * 100) + if concurrent and not progress_callback: + # Process chunks concurrently for better performance + tasks = [] + for chunk in chunked_payload: + task = self._localize_chunk( + params.source_locale, + params.target_locale, + {"data": chunk, "reference": params.reference}, + workflow_id, + params.fast or False, + ) + tasks.append(task) - processed_payload_chunk = self._localize_chunk( - params.source_locale, - params.target_locale, - {"data": chunk, "reference": params.reference}, - workflow_id, - params.fast or False, - ) + processed_payload_chunks = await asyncio.gather(*tasks) + else: + # Process chunks sequentially (supports progress tracking) + processed_payload_chunks = [] + for i, chunk in enumerate(chunked_payload): + percentage_completed = round(((i + 1) / len(chunked_payload)) * 100) + + processed_payload_chunk = await self._localize_chunk( + params.source_locale, + params.target_locale, + {"data": chunk, "reference": params.reference}, + workflow_id, + params.fast or False, + ) - if progress_callback: - progress_callback(percentage_completed, chunk, processed_payload_chunk) + if progress_callback: + progress_callback( + percentage_completed, chunk, processed_payload_chunk + ) - processed_payload_chunks.append(processed_payload_chunk) + processed_payload_chunks.append(processed_payload_chunk) result = {} for chunk in processed_payload_chunks: @@ -106,7 +146,7 @@ def _localize_raw( return result - def _localize_chunk( + async def _localize_chunk( self, source_locale: Optional[str], target_locale: str, @@ -127,6 +167,8 @@ def _localize_chunk( Returns: Localized chunk """ + await self._ensure_client() + assert self._client is not None # Type guard for mypy url = urljoin(self.config.api_url, "/i18n") request_data = { @@ -139,17 +181,17 @@ def _localize_chunk( request_data["reference"] = payload["reference"] try: - response = self.session.post(url, json=request_data) + response = await self._client.post(url, json=request_data) - if not response.ok: + if not response.is_success: if 500 <= response.status_code < 600: raise RuntimeError( - f"Server error ({response.status_code}): {response.reason}. " + f"Server error ({response.status_code}): {response.reason_phrase}. " f"{response.text}. This may be due to temporary service issues." ) elif response.status_code == 400: raise ValueError( - f"Invalid request ({response.status_code}): {response.reason}" + f"Invalid request ({response.status_code}): {response.reason_phrase}" ) else: raise RuntimeError(response.text) @@ -162,7 +204,7 @@ def _localize_chunk( return json_response.get("data") or {} - except requests.RequestException as e: + except httpx.RequestError as e: raise RuntimeError(f"Request failed: {str(e)}") def _extract_payload_chunks(self, payload: Dict[str, Any]) -> List[Dict[str, Any]]: @@ -216,13 +258,14 @@ def _count_words_in_record(self, payload: Any) -> int: else: return 0 - def localize_object( + async def localize_object( self, obj: Dict[str, Any], params: Dict[str, Any], progress_callback: Optional[ Callable[[int, Dict[str, str], Dict[str, str]], None] ] = None, + concurrent: bool = False, ) -> Dict[str, Any]: """ Localize a typical Python dictionary @@ -234,14 +277,17 @@ def localize_object( - target_locale: The target language code (e.g., 'es') - fast: Optional boolean to enable fast mode progress_callback: Optional callback function to report progress (0-100) + concurrent: Whether to process chunks concurrently (faster but no progress tracking) Returns: A new object with the same structure but localized string values """ localization_params = LocalizationParams(**params) - return self._localize_raw(obj, localization_params, progress_callback) + return await self._localize_raw( + obj, localization_params, progress_callback, concurrent + ) - def localize_text( + async def localize_text( self, text: str, params: Dict[str, Any], @@ -269,13 +315,13 @@ def wrapped_progress_callback( if progress_callback: progress_callback(progress) - response = self._localize_raw( + response = await self._localize_raw( {"text": text}, localization_params, wrapped_progress_callback ) return response.get("text", "") - def batch_localize_text(self, text: str, params: Dict[str, Any]) -> List[str]: + async def batch_localize_text(self, text: str, params: Dict[str, Any]) -> List[str]: """ Localize a text string to multiple target locales @@ -296,9 +342,10 @@ def batch_localize_text(self, text: str, params: Dict[str, Any]) -> List[str]: source_locale = params.get("source_locale") fast = params.get("fast", False) - responses = [] + # Create tasks for concurrent execution + tasks = [] for target_locale in target_locales: - response = self.localize_text( + task = self.localize_text( text, { "source_locale": source_locale, @@ -306,11 +353,13 @@ def batch_localize_text(self, text: str, params: Dict[str, Any]) -> List[str]: "fast": fast, }, ) - responses.append(response) + tasks.append(task) + # Execute all localization tasks concurrently + responses = await asyncio.gather(*tasks) return responses - def localize_chat( + async def localize_chat( self, chat: List[Dict[str, str]], params: Dict[str, Any], @@ -345,7 +394,7 @@ def wrapped_progress_callback( if progress_callback: progress_callback(progress) - localized = self._localize_raw( + localized = await self._localize_raw( {"chat": chat}, localization_params, wrapped_progress_callback ) @@ -356,7 +405,7 @@ def wrapped_progress_callback( return [] - def recognize_locale(self, text: str) -> str: + async def recognize_locale(self, text: str) -> str: """ Detect the language of a given text @@ -369,52 +418,198 @@ def recognize_locale(self, text: str) -> str: if not text or not text.strip(): raise ValueError("Text cannot be empty") + await self._ensure_client() + assert self._client is not None # Type guard for mypy url = urljoin(self.config.api_url, "/recognize") try: - response = self.session.post(url, json={"text": text}) + response = await self._client.post(url, json={"text": text}) - if not response.ok: + if not response.is_success: if 500 <= response.status_code < 600: raise RuntimeError( - f"Server error ({response.status_code}): {response.reason}. " + f"Server error ({response.status_code}): {response.reason_phrase}. " "This may be due to temporary service issues." ) - raise RuntimeError(f"Error recognizing locale: {response.reason}") + raise RuntimeError( + f"Error recognizing locale: {response.reason_phrase}" + ) json_response = response.json() return json_response.get("locale") or "" - except requests.RequestException as e: + except httpx.RequestError as e: raise RuntimeError(f"Request failed: {str(e)}") - def whoami(self) -> Optional[Dict[str, str]]: + async def whoami(self) -> Optional[Dict[str, str]]: """ Get information about the current API key Returns: Dictionary with 'email' and 'id' keys, or None if not authenticated """ + await self._ensure_client() + assert self._client is not None # Type guard for mypy url = urljoin(self.config.api_url, "/whoami") try: - response = self.session.post(url) + response = await self._client.post(url) - if response.ok: + if response.is_success: payload = response.json() if payload.get("email"): return {"email": payload["email"], "id": payload["id"]} if 500 <= response.status_code < 600: raise RuntimeError( - f"Server error ({response.status_code}): {response.reason}. " + f"Server error ({response.status_code}): {response.reason_phrase}. " "This may be due to temporary service issues." ) return None - except requests.RequestException as e: + except httpx.RequestError as e: # Return None for network errors, but re-raise server errors if "Server error" in str(e): raise return None + + async def batch_localize_objects( + self, objects: List[Dict[str, Any]], params: Dict[str, Any] + ) -> List[Dict[str, Any]]: + """ + Localize multiple objects concurrently + + Args: + objects: List of objects to localize + params: Localization parameters + + Returns: + List of localized objects + """ + tasks = [] + for obj in objects: + task = self.localize_object(obj, params, concurrent=True) + tasks.append(task) + + return await asyncio.gather(*tasks) + + @classmethod + async def quick_translate( + cls, + content: Any, + api_key: str, + target_locale: str, + source_locale: Optional[str] = None, + api_url: str = "https://engine.lingo.dev", + fast: bool = True, + ) -> Any: + """ + Quick one-off translation without manual context management. + Automatically handles the async context manager. + + Args: + content: Text string or dict to translate + api_key: Your Lingo.dev API key + target_locale: Target language code (e.g., 'es', 'fr') + source_locale: Source language code (optional, auto-detected if None) + api_url: API endpoint URL + fast: Enable fast mode for quicker translations + + Returns: + Translated content (same type as input) + + Example: + # Translate text + result = await LingoDotDevEngine.quick_translate( + "Hello world", + "your-api-key", + "es" + ) + + # Translate object + result = await LingoDotDevEngine.quick_translate( + {"greeting": "Hello", "farewell": "Goodbye"}, + "your-api-key", + "es" + ) + """ + config = { + "api_key": api_key, + "api_url": api_url, + } + + async with cls(config) as engine: + params = { + "source_locale": source_locale, + "target_locale": target_locale, + "fast": fast, + } + + if isinstance(content, str): + return await engine.localize_text(content, params) + elif isinstance(content, dict): + return await engine.localize_object(content, params, concurrent=True) + else: + raise ValueError("Content must be a string or dictionary") + + @classmethod + async def quick_batch_translate( + cls, + content: Any, + api_key: str, + target_locales: List[str], + source_locale: Optional[str] = None, + api_url: str = "https://engine.lingo.dev", + fast: bool = True, + ) -> List[Any]: + """ + Quick batch translation to multiple target locales. + Automatically handles the async context manager. + + Args: + content: Text string or dict to translate + api_key: Your Lingo.dev API key + target_locales: List of target language codes (e.g., ['es', 'fr', 'de']) + source_locale: Source language code (optional, auto-detected if None) + api_url: API endpoint URL + fast: Enable fast mode for quicker translations + + Returns: + List of translated content (one for each target locale) + + Example: + results = await LingoDotDevEngine.quick_batch_translate( + "Hello world", + "your-api-key", + ["es", "fr", "de"] + ) + # Results: ["Hola mundo", "Bonjour le monde", "Hallo Welt"] + """ + config = { + "api_key": api_key, + "api_url": api_url, + } + + async with cls(config) as engine: + if isinstance(content, str): + batch_params = { + "source_locale": source_locale, + "target_locales": target_locales, + "fast": fast, + } + return await engine.batch_localize_text(content, batch_params) + elif isinstance(content, dict): + # For objects, run concurrent translations to each target locale + tasks = [] + for target_locale in target_locales: + task_params = { + "source_locale": source_locale, + "target_locale": target_locale, + "fast": fast, + } + task = engine.localize_object(content, task_params, concurrent=True) + tasks.append(task) + return await asyncio.gather(*tasks) + else: + raise ValueError("Content must be a string or dictionary") diff --git a/tests/test_engine.py b/tests/test_engine.py index e06e812..d86aa69 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -3,7 +3,8 @@ """ import pytest -from unittest.mock import Mock, patch +import asyncio +from unittest.mock import Mock, patch, AsyncMock from lingodotdev import LingoDotDevEngine from lingodotdev.engine import EngineConfig @@ -54,6 +55,7 @@ def test_invalid_ideal_batch_item_size(self): EngineConfig(api_key="test_key", ideal_batch_item_size=3000) +@pytest.mark.asyncio class TestLingoDotDevEngine: """Test the LingoDotDevEngine class""" @@ -73,8 +75,13 @@ def test_initialization(self): assert self.engine.config.api_url == "https://api.test.com" assert self.engine.config.batch_size == 10 assert self.engine.config.ideal_batch_item_size == 100 - assert "Authorization" in self.engine.session.headers - assert self.engine.session.headers["Authorization"] == "Bearer test_api_key" + assert self.engine._client is None # Client not initialized yet + + async def test_async_context_manager(self): + """Test async context manager functionality""" + async with LingoDotDevEngine(self.config) as engine: + assert engine._client is not None + assert not engine._client.is_closed def test_count_words_in_record_string(self): """Test word counting in strings""" @@ -120,69 +127,69 @@ def test_extract_payload_chunks_large_payload(self): chunks = self.engine._extract_payload_chunks(payload) assert len(chunks) == 2 # Should split into 2 chunks based on batch_size=10 - @patch("lingodotdev.engine.requests.Session.post") - def test_localize_chunk_success(self, mock_post): + @patch("lingodotdev.engine.httpx.AsyncClient.post") + async def test_localize_chunk_success(self, mock_post): """Test successful chunk localization""" mock_response = Mock() - mock_response.ok = True + mock_response.is_success = True mock_response.json.return_value = {"data": {"key": "translated_value"}} mock_post.return_value = mock_response - result = self.engine._localize_chunk( + result = await self.engine._localize_chunk( "en", "es", {"data": {"key": "value"}}, "workflow_id", False ) assert result == {"key": "translated_value"} mock_post.assert_called_once() - @patch("lingodotdev.engine.requests.Session.post") - def test_localize_chunk_server_error(self, mock_post): + @patch("lingodotdev.engine.httpx.AsyncClient.post") + async def test_localize_chunk_server_error(self, mock_post): """Test server error handling in chunk localization""" mock_response = Mock() - mock_response.ok = False + mock_response.is_success = False mock_response.status_code = 500 - mock_response.reason = "Internal Server Error" + mock_response.reason_phrase = "Internal Server Error" mock_response.text = "Server error details" mock_post.return_value = mock_response with pytest.raises(RuntimeError, match="Server error"): - self.engine._localize_chunk( + await self.engine._localize_chunk( "en", "es", {"data": {"key": "value"}}, "workflow_id", False ) - @patch("lingodotdev.engine.requests.Session.post") - def test_localize_chunk_bad_request(self, mock_post): + @patch("lingodotdev.engine.httpx.AsyncClient.post") + async def test_localize_chunk_bad_request(self, mock_post): """Test bad request handling in chunk localization""" mock_response = Mock() - mock_response.ok = False + mock_response.is_success = False mock_response.status_code = 400 - mock_response.reason = "Bad Request" + mock_response.reason_phrase = "Bad Request" mock_post.return_value = mock_response with pytest.raises(ValueError, match="Invalid request \\(400\\)"): - self.engine._localize_chunk( + await self.engine._localize_chunk( "en", "es", {"data": {"key": "value"}}, "workflow_id", False ) - @patch("lingodotdev.engine.requests.Session.post") - def test_localize_chunk_streaming_error(self, mock_post): + @patch("lingodotdev.engine.httpx.AsyncClient.post") + async def test_localize_chunk_streaming_error(self, mock_post): """Test streaming error handling in chunk localization""" mock_response = Mock() - mock_response.ok = True + mock_response.is_success = True mock_response.json.return_value = {"error": "Streaming error occurred"} mock_post.return_value = mock_response with pytest.raises(RuntimeError, match="Streaming error occurred"): - self.engine._localize_chunk( + await self.engine._localize_chunk( "en", "es", {"data": {"key": "value"}}, "workflow_id", False ) @patch("lingodotdev.engine.LingoDotDevEngine._localize_raw") - def test_localize_text(self, mock_localize_raw): + async def test_localize_text(self, mock_localize_raw): """Test text localization""" mock_localize_raw.return_value = {"text": "translated_text"} - result = self.engine.localize_text( + result = await self.engine.localize_text( "hello world", {"source_locale": "en", "target_locale": "es"} ) @@ -190,11 +197,11 @@ def test_localize_text(self, mock_localize_raw): mock_localize_raw.assert_called_once() @patch("lingodotdev.engine.LingoDotDevEngine._localize_raw") - def test_localize_object(self, mock_localize_raw): + async def test_localize_object(self, mock_localize_raw): """Test object localization""" mock_localize_raw.return_value = {"greeting": "hola", "farewell": "adiรณs"} - result = self.engine.localize_object( + result = await self.engine.localize_object( {"greeting": "hello", "farewell": "goodbye"}, {"source_locale": "en", "target_locale": "es"}, ) @@ -203,11 +210,11 @@ def test_localize_object(self, mock_localize_raw): mock_localize_raw.assert_called_once() @patch("lingodotdev.engine.LingoDotDevEngine.localize_text") - def test_batch_localize_text(self, mock_localize_text): + async def test_batch_localize_text(self, mock_localize_text): """Test batch text localization""" - mock_localize_text.side_effect = ["hola", "bonjour"] + mock_localize_text.side_effect = AsyncMock(side_effect=["hola", "bonjour"]) - result = self.engine.batch_localize_text( + result = await self.engine.batch_localize_text( "hello", {"source_locale": "en", "target_locales": ["es", "fr"], "fast": True}, ) @@ -215,13 +222,13 @@ def test_batch_localize_text(self, mock_localize_text): assert result == ["hola", "bonjour"] assert mock_localize_text.call_count == 2 - def test_batch_localize_text_missing_target_locales(self): + async def test_batch_localize_text_missing_target_locales(self): """Test batch text localization with missing target_locales""" with pytest.raises(ValueError, match="target_locales is required"): - self.engine.batch_localize_text("hello", {"source_locale": "en"}) + await self.engine.batch_localize_text("hello", {"source_locale": "en"}) @patch("lingodotdev.engine.LingoDotDevEngine._localize_raw") - def test_localize_chat(self, mock_localize_raw): + async def test_localize_chat(self, mock_localize_raw): """Test chat localization""" mock_localize_raw.return_value = { "chat": [ @@ -232,7 +239,7 @@ def test_localize_chat(self, mock_localize_raw): chat = [{"name": "Alice", "text": "hello"}, {"name": "Bob", "text": "goodbye"}] - result = self.engine.localize_chat( + result = await self.engine.localize_chat( chat, {"source_locale": "en", "target_locale": "es"} ) @@ -241,101 +248,148 @@ def test_localize_chat(self, mock_localize_raw): assert result == expected mock_localize_raw.assert_called_once() - def test_localize_chat_invalid_format(self): + async def test_localize_chat_invalid_format(self): """Test chat localization with invalid message format""" invalid_chat = [{"name": "Alice"}] # Missing 'text' key with pytest.raises( ValueError, match="Each chat message must have 'name' and 'text' properties" ): - self.engine.localize_chat( + await self.engine.localize_chat( invalid_chat, {"source_locale": "en", "target_locale": "es"} ) - @patch("lingodotdev.engine.requests.Session.post") - def test_recognize_locale_success(self, mock_post): + @patch("lingodotdev.engine.httpx.AsyncClient.post") + async def test_recognize_locale_success(self, mock_post): """Test successful locale recognition""" mock_response = Mock() - mock_response.ok = True + mock_response.is_success = True mock_response.json.return_value = {"locale": "es"} mock_post.return_value = mock_response - result = self.engine.recognize_locale("Hola mundo") + result = await self.engine.recognize_locale("Hola mundo") assert result == "es" mock_post.assert_called_once() - def test_recognize_locale_empty_text(self): + async def test_recognize_locale_empty_text(self): """Test locale recognition with empty text""" with pytest.raises(ValueError, match="Text cannot be empty"): - self.engine.recognize_locale(" ") + await self.engine.recognize_locale(" ") - @patch("lingodotdev.engine.requests.Session.post") - def test_recognize_locale_server_error(self, mock_post): + @patch("lingodotdev.engine.httpx.AsyncClient.post") + async def test_recognize_locale_server_error(self, mock_post): """Test locale recognition with server error""" mock_response = Mock() - mock_response.ok = False + mock_response.is_success = False mock_response.status_code = 500 - mock_response.reason = "Internal Server Error" + mock_response.reason_phrase = "Internal Server Error" mock_post.return_value = mock_response with pytest.raises(RuntimeError, match="Server error"): - self.engine.recognize_locale("Hello world") + await self.engine.recognize_locale("Hello world") - @patch("lingodotdev.engine.requests.Session.post") - def test_whoami_success(self, mock_post): + @patch("lingodotdev.engine.httpx.AsyncClient.post") + async def test_whoami_success(self, mock_post): """Test successful whoami request""" mock_response = Mock() - mock_response.ok = True + mock_response.is_success = True mock_response.json.return_value = { "email": "test@example.com", "id": "user_123", } mock_post.return_value = mock_response - result = self.engine.whoami() + result = await self.engine.whoami() assert result == {"email": "test@example.com", "id": "user_123"} mock_post.assert_called_once() - @patch("lingodotdev.engine.requests.Session.post") - def test_whoami_unauthenticated(self, mock_post): + @patch("lingodotdev.engine.httpx.AsyncClient.post") + async def test_whoami_unauthenticated(self, mock_post): """Test whoami request when unauthenticated""" mock_response = Mock() - mock_response.ok = False + mock_response.is_success = False mock_response.status_code = 401 mock_post.return_value = mock_response - result = self.engine.whoami() + result = await self.engine.whoami() assert result is None - @patch("lingodotdev.engine.requests.Session.post") - def test_whoami_server_error(self, mock_post): + @patch("lingodotdev.engine.httpx.AsyncClient.post") + async def test_whoami_server_error(self, mock_post): """Test whoami request with server error""" mock_response = Mock() - mock_response.ok = False + mock_response.is_success = False mock_response.status_code = 500 - mock_response.reason = "Internal Server Error" + mock_response.reason_phrase = "Internal Server Error" mock_post.return_value = mock_response with pytest.raises(RuntimeError, match="Server error"): - self.engine.whoami() + await self.engine.whoami() - @patch("lingodotdev.engine.requests.Session.post") - def test_whoami_no_email(self, mock_post): + @patch("lingodotdev.engine.httpx.AsyncClient.post") + async def test_whoami_no_email(self, mock_post): """Test whoami request with no email in response""" mock_response = Mock() - mock_response.ok = True + mock_response.is_success = True mock_response.status_code = 200 mock_response.json.return_value = {} mock_post.return_value = mock_response - result = self.engine.whoami() + result = await self.engine.whoami() assert result is None + @patch("lingodotdev.engine.LingoDotDevEngine.localize_object") + async def test_batch_localize_objects(self, mock_localize_object): + """Test batch object localization""" + mock_localize_object.side_effect = AsyncMock( + side_effect=[{"greeting": "hola"}, {"farewell": "adiรณs"}] + ) + + objects = [{"greeting": "hello"}, {"farewell": "goodbye"}] + params = {"source_locale": "en", "target_locale": "es"} + + result = await self.engine.batch_localize_objects(objects, params) + + assert result == [{"greeting": "hola"}, {"farewell": "adiรณs"}] + assert mock_localize_object.call_count == 2 + + async def test_concurrent_processing(self): + """Test concurrent processing functionality""" + with patch( + "lingodotdev.engine.LingoDotDevEngine._localize_chunk" + ) as mock_chunk: + mock_chunk.return_value = {"key": "value"} + + large_payload = {f"key{i}": f"value{i}" for i in range(5)} + + # Create mock params object (Python 3.8 compatible) + mock_params = type( + "MockParams", + (), + { + "source_locale": "en", + "target_locale": "es", + "fast": False, + "reference": None, + }, + )() + + # Test concurrent mode + await self.engine._localize_raw( + large_payload, + mock_params, + concurrent=True, + ) + + # Should have called _localize_chunk multiple times concurrently + assert mock_chunk.call_count > 0 + +@pytest.mark.asyncio class TestIntegration: """Integration tests with mocked HTTP responses""" @@ -344,19 +398,19 @@ def setup_method(self): self.config = {"api_key": "test_api_key", "api_url": "https://api.test.com"} self.engine = LingoDotDevEngine(self.config) - @patch("lingodotdev.engine.requests.Session.post") - def test_full_localization_workflow(self, mock_post): + @patch("lingodotdev.engine.httpx.AsyncClient.post") + async def test_full_localization_workflow(self, mock_post): """Test full localization workflow""" # Mock the API response mock_response = Mock() - mock_response.ok = True + mock_response.is_success = True mock_response.json.return_value = { "data": {"greeting": "hola", "farewell": "adiรณs"} } mock_post.return_value = mock_response # Test object localization - result = self.engine.localize_object( + result = await self.engine.localize_object( {"greeting": "hello", "farewell": "goodbye"}, {"source_locale": "en", "target_locale": "es", "fast": True}, ) diff --git a/tests/test_integration.py b/tests/test_integration.py index 2832ff8..ce31076 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -5,45 +5,47 @@ import os import pytest -from unittest.mock import patch +from unittest.mock import patch, Mock from lingodotdev import LingoDotDevEngine # Skip integration tests if no API key is provided pytestmark = pytest.mark.skipif( - not os.getenv("LINGO_DEV_API_KEY"), - reason="Integration tests require LINGO_DEV_API_KEY environment variable", + not os.getenv("LINGODOTDEV_API_KEY"), + reason="Integration tests require LINGODOTDEV_API_KEY environment variable", ) +@pytest.mark.asyncio class TestRealAPIIntegration: """Integration tests against the real API""" def setup_method(self): """Set up test fixtures""" - api_key = os.getenv("LINGO_DEV_API_KEY") + api_key = os.getenv("LINGODOTDEV_API_KEY") if not api_key: pytest.skip("No API key provided") self.engine = LingoDotDevEngine( { "api_key": api_key, - "api_url": os.getenv("LINGO_DEV_API_URL", "https://engine.lingo.dev"), + "api_url": os.getenv("LINGODOTDEV_API_URL", "https://engine.lingo.dev"), } ) - def test_localize_text_real_api(self): + async def test_localize_text_real_api(self): """Test text localization against real API""" - result = self.engine.localize_text( - "Hello, world!", {"source_locale": "en", "target_locale": "es"} - ) + async with self.engine: + result = await self.engine.localize_text( + "Hello, world!", {"source_locale": "en", "target_locale": "es"} + ) - assert isinstance(result, str) - assert len(result) > 0 - assert result != "Hello, world!" # Should be translated + assert isinstance(result, str) + assert len(result) > 0 + assert result != "Hello, world!" # Should be translated - def test_localize_object_real_api(self): + async def test_localize_object_real_api(self): """Test object localization against real API""" test_object = { "greeting": "Hello", @@ -51,38 +53,44 @@ def test_localize_object_real_api(self): "question": "How are you?", } - result = self.engine.localize_object( - test_object, {"source_locale": "en", "target_locale": "fr"} - ) + async with self.engine: + result = await self.engine.localize_object( + test_object, {"source_locale": "en", "target_locale": "fr"} + ) - assert isinstance(result, dict) - assert len(result) == 3 - assert "greeting" in result - assert "farewell" in result - assert "question" in result + assert isinstance(result, dict) + assert len(result) == 3 + assert "greeting" in result + assert "farewell" in result + assert "question" in result - # Values should be translated - assert result["greeting"] != "Hello" - assert result["farewell"] != "Goodbye" - assert result["question"] != "How are you?" + # Values should be translated + assert result["greeting"] != "Hello" + assert result["farewell"] != "Goodbye" + assert result["question"] != "How are you?" - def test_batch_localize_text_real_api(self): + async def test_batch_localize_text_real_api(self): """Test batch text localization against real API""" - result = self.engine.batch_localize_text( - "Welcome to our application", - {"source_locale": "en", "target_locales": ["es", "fr", "de"], "fast": True}, - ) + async with self.engine: + result = await self.engine.batch_localize_text( + "Welcome to our application", + { + "source_locale": "en", + "target_locales": ["es", "fr", "de"], + "fast": True, + }, + ) - assert isinstance(result, list) - assert len(result) == 3 + assert isinstance(result, list) + assert len(result) == 3 - # Each result should be a non-empty string - for translation in result: - assert isinstance(translation, str) - assert len(translation) > 0 - assert translation != "Welcome to our application" + # Each result should be a non-empty string + for translation in result: + assert isinstance(translation, str) + assert len(translation) > 0 + assert translation != "Welcome to our application" - def test_localize_chat_real_api(self): + async def test_localize_chat_real_api(self): """Test chat localization against real API""" chat = [ {"name": "Alice", "text": "Hello everyone!"}, @@ -90,22 +98,23 @@ def test_localize_chat_real_api(self): {"name": "Charlie", "text": "I'm doing great, thanks!"}, ] - result = self.engine.localize_chat( - chat, {"source_locale": "en", "target_locale": "es"} - ) + async with self.engine: + result = await self.engine.localize_chat( + chat, {"source_locale": "en", "target_locale": "es"} + ) - assert isinstance(result, list) - assert len(result) == 3 + assert isinstance(result, list) + assert len(result) == 3 - # Check structure is preserved - for i, message in enumerate(result): - assert isinstance(message, dict) - assert "name" in message - assert "text" in message - assert message["name"] == chat[i]["name"] # Names should be preserved - assert message["text"] != chat[i]["text"] # Text should be translated + # Check structure is preserved + for i, message in enumerate(result): + assert isinstance(message, dict) + assert "name" in message + assert "text" in message + assert message["name"] == chat[i]["name"] # Names should be preserved + assert message["text"] != chat[i]["text"] # Text should be translated - def test_recognize_locale_real_api(self): + async def test_recognize_locale_real_api(self): """Test locale recognition against real API""" test_cases = [ ("Hello, how are you?", "en"), @@ -114,29 +123,31 @@ def test_recognize_locale_real_api(self): ("Guten Tag, wie geht es Ihnen?", "de"), ] - for text, expected_locale in test_cases: - result = self.engine.recognize_locale(text) - assert isinstance(result, str) - assert len(result) > 0 - # Note: We don't assert exact match as recognition might vary - # but we expect a reasonable locale code + async with self.engine: + for text, expected_locale in test_cases: + result = await self.engine.recognize_locale(text) + assert isinstance(result, str) + assert len(result) > 0 + # Note: We don't assert exact match as recognition might vary + # but we expect a reasonable locale code - def test_whoami_real_api(self): + async def test_whoami_real_api(self): """Test whoami against real API""" - result = self.engine.whoami() - - if result: # If authenticated - assert isinstance(result, dict) - assert "email" in result - assert "id" in result - assert isinstance(result["email"], str) - assert isinstance(result["id"], str) - assert "@" in result["email"] # Basic email validation - else: - # If not authenticated, should return None - assert result is None - - def test_progress_callback(self): + async with self.engine: + result = await self.engine.whoami() + + if result: # If authenticated + assert isinstance(result, dict) + assert "email" in result + assert "id" in result + assert isinstance(result["email"], str) + assert isinstance(result["id"], str) + assert "@" in result["email"] # Basic email validation + else: + # If not authenticated, should return None + assert result is None + + async def test_progress_callback(self): """Test progress callback functionality""" progress_values = [] @@ -150,51 +161,108 @@ def progress_callback(progress, source_chunk, processed_chunk): # Create a larger object to ensure chunking and progress callbacks large_object = {f"key_{i}": f"This is test text number {i}" for i in range(50)} - self.engine.localize_object( - large_object, - {"source_locale": "en", "target_locale": "es"}, - progress_callback=progress_callback, - ) + async with self.engine: + await self.engine.localize_object( + large_object, + {"source_locale": "en", "target_locale": "es"}, + progress_callback=progress_callback, + ) - assert len(progress_values) > 0 - assert max(progress_values) == 100 # Should reach 100% completion + assert len(progress_values) > 0 + assert max(progress_values) == 100 # Should reach 100% completion - def test_error_handling_invalid_locale(self): + async def test_error_handling_invalid_locale(self): """Test error handling with invalid locale""" - with pytest.raises(Exception): # Could be ValueError or RuntimeError - self.engine.localize_text( - "Hello world", - {"source_locale": "invalid_locale", "target_locale": "es"}, - ) - - def test_error_handling_empty_text(self): + async with self.engine: + with pytest.raises(Exception): # Could be ValueError or RuntimeError + await self.engine.localize_text( + "Hello world", + {"source_locale": "invalid_locale", "target_locale": "es"}, + ) + + async def test_error_handling_empty_text(self): """Test error handling with empty text""" - with pytest.raises(ValueError): - self.engine.recognize_locale("") + async with self.engine: + with pytest.raises(ValueError): + await self.engine.recognize_locale("") - def test_fast_mode(self): + async def test_fast_mode(self): """Test fast mode functionality""" text = "This is a test for fast mode translation" - # Test with fast mode enabled - result_fast = self.engine.localize_text( - text, {"source_locale": "en", "target_locale": "es", "fast": True} - ) + async with self.engine: + # Test with fast mode enabled + result_fast = await self.engine.localize_text( + text, {"source_locale": "en", "target_locale": "es", "fast": True} + ) - # Test with fast mode disabled - result_normal = self.engine.localize_text( - text, {"source_locale": "en", "target_locale": "es", "fast": False} - ) + # Test with fast mode disabled + result_normal = await self.engine.localize_text( + text, {"source_locale": "en", "target_locale": "es", "fast": False} + ) + + # Both should return valid translations + assert isinstance(result_fast, str) + assert isinstance(result_normal, str) + assert len(result_fast) > 0 + assert len(result_normal) > 0 + assert result_fast != text + assert result_normal != text + + async def test_concurrent_processing_performance(self): + """Test concurrent processing performance improvement""" + import time + + large_object = { + f"key_{i}": f"Test content number {i} for performance testing" + for i in range(10) + } - # Both should return valid translations - assert isinstance(result_fast, str) - assert isinstance(result_normal, str) - assert len(result_fast) > 0 - assert len(result_normal) > 0 - assert result_fast != text - assert result_normal != text + async with self.engine: + # Test sequential processing + start_time = time.time() + await self.engine.localize_object( + large_object, + {"source_locale": "en", "target_locale": "es"}, + concurrent=False, + ) + sequential_time = time.time() - start_time + + # Test concurrent processing + start_time = time.time() + await self.engine.localize_object( + large_object, + {"source_locale": "en", "target_locale": "es"}, + concurrent=True, + ) + concurrent_time = time.time() - start_time + + # Concurrent processing should be faster (or at least not significantly slower) + assert concurrent_time <= sequential_time * 1.5 # Allow 50% margin + + async def test_batch_localize_objects(self): + """Test batch object localization""" + objects = [ + {"greeting": "Hello", "question": "How are you?"}, + {"farewell": "Goodbye", "thanks": "Thank you"}, + {"welcome": "Welcome", "help": "Can I help you?"}, + ] + + async with self.engine: + results = await self.engine.batch_localize_objects( + objects, {"source_locale": "en", "target_locale": "es"} + ) + assert len(results) == 3 + for i, result in enumerate(results): + assert isinstance(result, dict) + # Check that structure is preserved but content is translated + for key in objects[i].keys(): + assert key in result + assert result[key] != objects[i][key] # Should be translated + +@pytest.mark.asyncio class TestMockedIntegration: """Integration tests with mocked responses for CI/CD""" @@ -204,37 +272,39 @@ def setup_method(self): {"api_key": "test_api_key", "api_url": "https://api.test.com"} ) - @patch("lingodotdev.engine.requests.Session.post") - def test_large_payload_chunking(self, mock_post): + @patch("lingodotdev.engine.httpx.AsyncClient.post") + async def test_large_payload_chunking(self, mock_post): """Test that large payloads are properly chunked""" # Mock API response - mock_response = mock_post.return_value - mock_response.ok = True + mock_response = Mock() + mock_response.is_success = True mock_response.json.return_value = {"data": {"key": "value"}} + mock_post.return_value = mock_response # Create a large payload that will be chunked large_payload = {f"key_{i}": f"value_{i}" for i in range(100)} - self.engine.localize_object( + await self.engine.localize_object( large_payload, {"source_locale": "en", "target_locale": "es"} ) # Should have been called multiple times due to chunking assert mock_post.call_count > 1 - @patch("lingodotdev.engine.requests.Session.post") - def test_reference_parameter(self, mock_post): + @patch("lingodotdev.engine.httpx.AsyncClient.post") + async def test_reference_parameter(self, mock_post): """Test that reference parameter is properly handled""" - mock_response = mock_post.return_value - mock_response.ok = True + mock_response = Mock() + mock_response.is_success = True mock_response.json.return_value = {"data": {"key": "value"}} + mock_post.return_value = mock_response reference = { "es": {"key": "valor de referencia"}, "fr": {"key": "valeur de rรฉfรฉrence"}, } - self.engine.localize_object( + await self.engine.localize_object( {"key": "value"}, {"source_locale": "en", "target_locale": "es", "reference": reference}, ) @@ -246,17 +316,18 @@ def test_reference_parameter(self, mock_post): assert "reference" in request_data assert request_data["reference"] == reference - @patch("lingodotdev.engine.requests.Session.post") - def test_workflow_id_consistency(self, mock_post): + @patch("lingodotdev.engine.httpx.AsyncClient.post") + async def test_workflow_id_consistency(self, mock_post): """Test that workflow ID is consistent across chunks""" - mock_response = mock_post.return_value - mock_response.ok = True + mock_response = Mock() + mock_response.is_success = True mock_response.json.return_value = {"data": {"key": "value"}} + mock_post.return_value = mock_response # Create a payload that will be chunked large_payload = {f"key_{i}": f"value_{i}" for i in range(50)} - self.engine.localize_object( + await self.engine.localize_object( large_payload, {"source_locale": "en", "target_locale": "es"} ) @@ -270,3 +341,36 @@ def test_workflow_id_consistency(self, mock_post): # All workflow IDs should be the same assert len(set(workflow_ids)) == 1 assert len(workflow_ids[0]) > 0 # Should be a non-empty string + + @patch("lingodotdev.engine.httpx.AsyncClient.post") + async def test_concurrent_chunk_processing(self, mock_post): + """Test concurrent chunk processing""" + import asyncio + + # Mock API response with delay to test concurrency + async def mock_response_with_delay(*args, **kwargs): + await asyncio.sleep(0.1) # Small delay + mock_resp = type("MockResponse", (), {})() + mock_resp.is_success = True + mock_resp.json = lambda: {"data": {"key": "value"}} + return mock_resp + + mock_post.side_effect = mock_response_with_delay + + # Create a payload that will be chunked + large_payload = {f"key_{i}": f"value_{i}" for i in range(10)} + + # Test concurrent processing + start_time = asyncio.get_event_loop().time() + await self.engine.localize_object( + large_payload, + {"source_locale": "en", "target_locale": "es"}, + concurrent=True, + ) + concurrent_time = asyncio.get_event_loop().time() - start_time + + # With concurrent processing, total time should be less than + # (number of chunks * delay) since requests run in parallel + # Allow some margin for test execution overhead + assert concurrent_time < (mock_post.call_count * 0.1) + 0.05 + assert mock_post.call_count > 0 # Should have been called