diff --git a/codecov.yml b/codecov.yml index 3cbdafcf6b99..f19acfc11f5d 100644 --- a/codecov.yml +++ b/codecov.yml @@ -23,7 +23,6 @@ coverage: target: 90 patch: default: - informational: true target: 100 codecov: branch: main diff --git a/docs/admin/boost-weblate.rst b/docs/admin/boost-weblate.rst new file mode 100644 index 000000000000..b5db99954c5b --- /dev/null +++ b/docs/admin/boost-weblate.rst @@ -0,0 +1,189 @@ +.. _boost-weblate: + +Boost Weblate additions +======================= + +This repository extends upstream Weblate with capabilities used for translating +`Boost C++ Libraries `_ documentation: QuickBook and +AsciiDoc handling tailored for Boost workflows, optional OpenRouter-based batch +machine translation, and a REST surface for CI-driven component maintenance. + +The sections below document fork-specific **configuration**, **dependencies**, +and **HTTP endpoints** that are not covered by generic Weblate documentation. + +.. seealso:: + + Standard administrator guides still apply: :doc:`install/docker`, + :ref:`docker-environment`, :doc:`machine`, and :doc:`config`. + +Python packages +--------------- + +OpenRouter batch translation uses the `OpenAI Python SDK `_ +(`OpenAI Client`) against the OpenRouter HTTP API. The SDK is **not** part of +core Weblate dependencies; install it explicitly: + +.. code-block:: sh + + pip install 'weblate[openai]' + # or + pip install 'openai>=2.0,<3.0' + +If the SDK is missing when OpenRouter translation runs, Weblate raises +``django.core.exceptions.ImproperlyConfigured`` with an installation hint. + +Docker images built from :file:`weblate-docker/Dockerfile` use +``WEBLATE_EXTRAS=all`` so the ``openai`` extra is included in the container. + +System commands and packages +---------------------------- + +The following executables must be available on the server **PATH** where the +relevant code paths execute (web workers, Celery workers): + +.. list-table:: + :header-rows: 1 + :widths: 22 78 + + * - Executable + - Used by + * - ``git`` + - Boost endpoint service: clone repositories, commit and push translation + changes (see ``weblate.boost_endpoint.services``). + * - ``po4a-gettextize``, ``po4a-translate`` + - AsciiDoc format pipeline (``weblate.formats.asciidoc``). + * - ``msgattrib``, ``msgfmt`` + - gettext toolchain for AsciiDoc save path; ``msgattrib`` is optional (the + code falls back if absent). + +The official Docker image for this fork installs **po4a** from source during the +image build (see comments in :file:`weblate-docker/Dockerfile`). Custom or +bare-metal installs must provide **po4a** and **gettext** separately (for +example distribution packages for ``po4a`` and ``gettext``). + +Environment variables +--------------------- + +These variables apply to **Boost fork** behaviour. They do **not** use the +``WEBLATE_`` prefix. Standard Docker variables remain documented under +:ref:`docker-environment`. + +.. envvar:: OPENROUTER_API_KEY + + API key used when OpenRouter batch translation cannot read credentials from + Weblate’s machinery configuration (see :ref:`boost-weblate-openrouter-config`). + Read by ``weblate.trans.autobatchtranslate``. + +.. envvar:: OPENROUTER_MODEL + + Model identifier passed to OpenRouter (for example ``deepseek/deepseek-chat``). + Default if unset: ``deepseek/deepseek-chat``. Used together with + :envvar:`OPENROUTER_API_KEY` as an environment fallback. + +.. envvar:: AUTO_BATCH_TRANSLATE_VIA_OPENROUTER + + Boolean interpreted by :file:`weblate/settings_docker.py`. When ``true`` + (Docker default), components may trigger automatic batch translation via + OpenRouter according to internal workflows. When ``false``, that behaviour is + disabled. For non-Docker installs, set ``AUTO_BATCH_TRANSLATE_VIA_OPENROUTER`` + in :file:`settings.py`. + +.. envvar:: BOOST_ENDPOINT_ADD_TRANSLATION_SECONDS + + Integer seconds to wait when the Boost endpoint waits for a component or + translation to become ready before adding a language (polling interval is + derived from this setting in ``weblate.boost_endpoint.services``). + Default in Docker: ``300``. Override per deployment if repositories are slow + or fast to sync. + +.. _boost-weblate-openrouter-config: + +OpenRouter credentials (batch translation) +------------------------------------------ + +Batch OpenRouter translation resolves configuration in this order: + +#. **Weblate machinery settings** — category MT, ``openai`` entry with ``key`` + (API key) and ``custom_model`` (model id). This mirrors fields used for the + generic OpenAI-compatible machinery documented under :ref:`mt-openai`. +#. **Environment variables** — :envvar:`OPENROUTER_API_KEY` and + :envvar:`OPENROUTER_MODEL` when the database configuration does not supply both + values. + +If no usable key and model are found, auto-translation is skipped and a warning +is logged. + +REST API: ``/boost-endpoint/`` +------------------------------- + +These endpoints are **not** part of the ``/api/`` namespace and are **not** +included in the OpenAPI schema served at ``/api/schema/``. They require an +authenticated user (same token mechanism as :ref:`api-tokens`). + +Base path (relative to your site root): ``/boost-endpoint/``. + +.. http:get:: /boost-endpoint/ + + Returns a short JSON description of the Boost endpoint module. + + :reqheader Authorization: ``Token …`` (required) + + :status 200: + + .. code-block:: json + + { + "module": "boost-endpoint", + "description": "Boost documentation translation API" + } + +.. http:post:: /boost-endpoint/add-or-update/ + + Accepts a job description and enqueues asynchronous work on a Celery worker. + The HTTP response returns immediately with a task identifier. + + :reqheader Authorization: ``Token …`` (required) + :reqheader Content-Type: ``application/json`` + + :", + "detail": "Boost add-or-update is running in the background; check Celery logs or task result for completion." + } + + :status 400: Validation error. + + .. code-block:: json + + { "errors": { "...": ["..."] } } + +Related Django settings +----------------------- + +The following settings appear in :file:`weblate/settings_example.py` for +non-Docker deployments: + +``AUTO_BATCH_TRANSLATE_VIA_OPENROUTER`` + Enables or disables OpenRouter batch translation hooks. Defaults to + ``False`` in the example settings file; Docker defaults differ via + :envvar:`AUTO_BATCH_TRANSLATE_VIA_OPENROUTER`. + +``BOOST_ENDPOINT_ADD_TRANSLATION_SECONDS`` + Delay used when waiting for components during Boost endpoint processing. + Example file sets ``150`` seconds; Docker overrides via + :envvar:`BOOST_ENDPOINT_ADD_TRANSLATION_SECONDS` unless customised. + +File formats +------------ + +* :doc:`../formats/quickbook` — QuickBook ``.qbk`` (fork-specific). +* :doc:`../formats/asciidoc` — AsciiDoc (implementation notes including **po4a**). diff --git a/docs/admin/machine.rst b/docs/admin/machine.rst index 579423def322..63e305765528 100644 --- a/docs/admin/machine.rst +++ b/docs/admin/machine.rst @@ -369,15 +369,15 @@ Weblate supports DeepL formality, it will choose matching one based on the language (for example, there is ``de@formal`` and ``de@informal``). The translation context can optionally be specified to improve translations quality. Read more on that in -`DeepL translation context documentation `_. +`DeepL translation context documentation `_. The service automatically uses :ref:`glossary`, see :ref:`glossary-mt`. .. seealso:: - * `DeepL translator `_ - * `DeepL pricing `_ - * `DeepL API documentation `_ + * `DeepL translator `_ + * `DeepL pricing `_ + * `DeepL API documentation `_ .. _mt-glosbe: @@ -622,7 +622,7 @@ You can also specify a custom category to use `custom translator `_ - * `Microsoft Azure Portal `_ + * `Microsoft Azure Portal `_ * `Base URLs `_ * `"Authenticating with a Multi-service resource" `_ * `"Authenticating with an access token" section `_ @@ -1076,7 +1076,7 @@ This service uses an API, and you need to obtain an ID and an API key from Youda .. seealso:: - `Youdao Zhiyun Natural Language Translation Service `_ + `Youdao Zhiyun Natural Language Translation Service `_ .. _custom-machinery: diff --git a/docs/api.rst b/docs/api.rst index 93a4f90c8fc8..d5f242ae02dc 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -20,6 +20,11 @@ can browse at ``/api/docs/``. incomplete at this point and subject to change. Please consult the documentation below for more detailed information on the API. +.. note:: + + **Boost Weblate fork:** authenticated endpoints under ``/boost-endpoint/`` + (outside ``/api/``) are documented in :doc:`admin/boost-weblate`. + .. _api-generic: Authentication and generic parameters diff --git a/docs/conf.py b/docs/conf.py index bf45f9d520bb..3183715ff190 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -361,7 +361,53 @@ def setup(app) -> None: # Number of retries and timeout for linkcheck linkcheck_retries = 10 -linkcheck_timeout = 10 +# Default 10s is tight for some CDNs from GitHub Actions (e.g. contributor-covenant.org). +linkcheck_timeout = 45 + +# Treat these redirects as working. Linkcheck emits redirects as warnings; CI runs +# sphinx with --fail-on-warning, so expected HTTP redirects must be allowed here +# (or links updated in RST). Patterns use re.match() from the URL start. +linkcheck_allowed_redirects = { + ( + r"https://support\.okta\.com/help/s/article/" + r"How-to-send-a-custom-relaystate-to-application-through-idp-initiated-authentication-urls" + ): ( + r"https://support\.okta\.com/help/s/article/" + r"How-to-send-a-custom-relaystate-to-application-through-idp-initiated-authentication-urls(\?.*)?" + ), + r"https://docs\.djangoproject\.com/en/stable/.*": ( + r"https://docs\.djangoproject\.com/en/[0-9]+\.[0-9]+/.*" + ), + r"https://weblate\.org/?.*": r"https://weblate\.org/.*", + r"https://docs\.weblate\.org/?.*": r"https://docs\.weblate\.org/.*", + r"https://hosted\.weblate\.org.*": r"https://hosted\.weblate\.org.*", + r"https://www\.sphinx-doc\.org/?$": r"https://www\.sphinx-doc\.org/en/master/?", + r"https://angular\.io/.*": r"https://.*\.angular\.io/.*", + r"https://babel\.pocoo\.org/?$": r"https://babel\.pocoo\.org/en/latest/.*", + r"https://cryptography\.io/?$": r"https://cryptography\.io/en/latest/.*", + r"https://docs\.celeryq\.dev/?$": r"https://docs\.celeryq\.dev/en/stable/.*", + r"https://docs\.phpmyadmin\.net/?$": r"https://docs\.phpmyadmin\.net/en/latest/.*", + r"https://doc\.galette\.eu/?$": r"https://doc\.galette\.eu/en/master/.*", + r"https://pytest\.org/?$": r"https://docs\.pytest\.org/.*", + r"https://python-social-auth\.readthedocs\.io/?$": ( + r"https://python-social-auth\.readthedocs\.io/en/latest/.*" + ), + r"https://sentry\.io/?$": r"https://sentry\.io/.*", + r"https://ruby-doc\.org/current/.*": r"https://ruby-doc\.org/[0-9.]+/.*", + r"https://docs\.anthropic\.com/.*": r"https://.*\.claude\.com/.*", + r"https://console\.anthropic\.com/.*": r"https://.*\.claude\.com/.*", + r"https://console\.cloud\.google\.com/.*": r"https://accounts\.google\.com/.*", + r"https://console\.developers\.google\.com/.*": r"https://accounts\.google\.com/.*", + r"https://gitee\.com/help/.*": r"https://help\.gitee\.com.*", + r"https://git\.cloudron\.io/.*": r"https://git\.cloudron\.io/.*", + r"https://github\.com/[^/]+/[^/]+/security/advisories/new(\?.*)?$": ( + r"https://github\.com/login.*" + ), + r"https://www\.bestpractices\.dev/en/projects/[0-9]+/?$": ( + r"https://www\.bestpractices\.dev/en/projects/[0-9]+/passing" + ), +} + linkcheck_ignore = [ # Local URL to Weblate "http://127.0.0.1:8080/", @@ -402,6 +448,11 @@ def setup(app) -> None: "https://dev.mysql.com/", # Responds with HTTP 418 I'm a teapot "https://www.freedesktop.org/", + # 403 to automated clients (URLs remain valid in browsers) + "https://mymemory\\.translated\\.net/.*", + "https://docs\\.oasis-open\\.org/.*", + # Captcha / bot wall in CI; human documentation links remain valid + "https://cloud\\.yandex\\.com/.*", ] # HTTP docs diff --git a/docs/formats.rst b/docs/formats.rst index e2483a36e95e..5ac8a13a1e27 100644 --- a/docs/formats.rst +++ b/docs/formats.rst @@ -346,6 +346,22 @@ Translation types capabilities - no - no - + * - :ref:`asciidoc` + - mono + - no + - no + - no + - no + - no + - + * - :ref:`quickbook` + - mono + - no + - no + - no + - no + - no + - .. [#m] See :ref:`bimono` .. [#p] See :ref:`format-plurals` @@ -359,7 +375,7 @@ Translation types capabilities .. [#lp] The plurals are supported only for Laravel which uses in string syntax to define them, see `Localization in Laravel`_. .. [#fp] Plurals are handled in the syntax of the strings and not exposed as plurals in Weblate. -.. _Localization in Laravel: https://laravel.com/docs/localization +.. _Localization in Laravel: https://laravel.com/docs/13.x/localization .. _bimono: diff --git a/docs/formats/asciidoc.rst b/docs/formats/asciidoc.rst index 4d8a2d5f6e0c..b74cb6fac4d2 100644 --- a/docs/formats/asciidoc.rst +++ b/docs/formats/asciidoc.rst @@ -11,6 +11,17 @@ The translatable content is extracted from the AsciiDoc files and offered for th .. include:: /snippets/format-database-backed.rst +System dependencies (Boost Weblate) ++++++++++++++++++++++++++++++++++++ + +This implementation extracts and merges translations using **po4a** +(``po4a-gettextize``, ``po4a-translate``) and the gettext utilities ``msgattrib`` +and ``msgfmt``. Install the corresponding system packages on application and +Celery hosts, or use the Docker image built from this repository (po4a is +installed during the image build—see :file:`weblate-docker/Dockerfile`). + +Full operational notes: :doc:`../admin/boost-weblate`. + .. seealso:: :doc:`tt:formats/asciidoc` diff --git a/docs/formats/laravel.rst b/docs/formats/laravel.rst index c3e0f6ce5a9b..54c71277a606 100644 --- a/docs/formats/laravel.rst +++ b/docs/formats/laravel.rst @@ -19,7 +19,7 @@ The Laravel PHP localization files are supported as well with plurals: * :doc:`tt:formats/php` * `Localization in Laravel`_ -.. _Localization in Laravel: https://laravel.com/docs/localization +.. _Localization in Laravel: https://laravel.com/docs/13.x/localization Weblate configuration +++++++++++++++++++++ diff --git a/docs/formats/quickbook.rst b/docs/formats/quickbook.rst new file mode 100644 index 000000000000..aaccfb505857 --- /dev/null +++ b/docs/formats/quickbook.rst @@ -0,0 +1,34 @@ +.. _quickbook: + +QuickBook files +--------------- + +.. note:: + + QuickBook support is provided by the Boost Weblate fork. Upstream Weblate + releases may not include this format. + +QuickBook (``.qbk``) is a markup language used in Boost documentation. This +Weblate build registers :guilabel:`QuickBook file` as a monolingual +:ref:`ConvertFormat ` handler: translatable strings are extracted into +gettext PO stores and merged back into QuickBook sources using a built-in parser +(``weblate.utils.quickbook``). + +There is **no** external converter binary (such as ``po4a``) required for +QuickBook in this fork—only Python dependencies from the main ``weblate`` +package install. + +Typical component setup ++++++++++++++++++++++++ + ++--------------------------------+-------------------------------------+ +| Typical Weblate :ref:`component` | ++================================+=====================================+ +| File mask | ``path/*.qbk`` | ++--------------------------------+-------------------------------------+ +| Monolingual base language file | ``path/en.qbk`` | ++--------------------------------+-------------------------------------+ +| Template for new translations | Same as base language file | ++--------------------------------+-------------------------------------+ +| File format | QuickBook file | ++--------------------------------+-------------------------------------+ diff --git a/docs/index.rst b/docs/index.rst index 4b32379e0c6c..8d266f906fec 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -96,6 +96,7 @@ Learn more about :ref:`contributing`. :hidden: admin/install + admin/boost-weblate admin/deployments admin/upgrade admin/backup diff --git a/docs/user/checks.rst b/docs/user/checks.rst index bc8049c2837a..40b695f94de0 100644 --- a/docs/user/checks.rst +++ b/docs/user/checks.rst @@ -742,7 +742,7 @@ Laravel format .. seealso:: * :ref:`check-formats` - * `Laravel translation formatting `_ + * `Laravel translation formatting `_ .. _check-lua-format: diff --git a/weblate/boost_endpoint/tasks.py b/weblate/boost_endpoint/tasks.py new file mode 100644 index 000000000000..4a149f77b41c --- /dev/null +++ b/weblate/boost_endpoint/tasks.py @@ -0,0 +1,43 @@ +# Copyright © Boost Organization +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Celery tasks for Boost documentation add-or-update (async HTTP handling).""" + +from __future__ import annotations + +from typing import Any + +from weblate.auth.models import AuthenticatedHttpRequest, User +from weblate.boost_endpoint.services import BoostComponentService +from weblate.utils.celery import app + + +@app.task(trail=False) +def boost_add_or_update_task( + *, + organization: str, + add_or_update: dict[str, list[str]], + version: str, + extensions: list[str] | None, + user_id: int, +) -> dict[str, Any]: + """ + Run BoostComponentService for each language (same logic as synchronous POST). + + Exceptions propagate so Celery marks the task failed and monitoring can alert. + """ + user = User.objects.get(pk=user_id) + request = AuthenticatedHttpRequest() + request.user = user + + results: dict[str, Any] = {} + for lang_code, submodules in add_or_update.items(): + service = BoostComponentService( + organization=organization, + lang_code=lang_code, + version=version, + extensions=extensions, + ) + results[lang_code] = service.process_all(submodules, user=user, request=request) + return results diff --git a/weblate/boost_endpoint/views.py b/weblate/boost_endpoint/views.py index 998ec8499e47..b012d2bbebc9 100644 --- a/weblate/boost_endpoint/views.py +++ b/weblate/boost_endpoint/views.py @@ -10,7 +10,7 @@ from rest_framework.views import APIView from weblate.boost_endpoint.serializers import AddOrUpdateRequestSerializer -from weblate.boost_endpoint.services import BoostComponentService +from weblate.boost_endpoint.tasks import boost_add_or_update_task class BoostEndpointInfo(APIView): @@ -40,6 +40,9 @@ def post(self, request, format=None): # pylint: disable=redefined-builtin # no add_or_update is a map: lang_code -> [submodule names]. For each lang_code the service runs with that language and its submodule list (clone, scan, create/update project and components, add language). + + Heavy work runs in a Celery worker and returns immediately with HTTP 202 and + task_id so clients can validate the request without waiting for completion. """ serializer = AddOrUpdateRequestSerializer(data=request.data) if not serializer.is_valid(): @@ -49,27 +52,22 @@ def post(self, request, format=None): # pylint: disable=redefined-builtin # no ) data = serializer.validated_data - organization = data["organization"] - add_or_update = data["add_or_update"] - version = data["version"] - extensions = data.get("extensions") - - try: - results = {} - for lang_code, submodules in add_or_update.items(): - service = BoostComponentService( - organization=organization, - lang_code=lang_code, - version=version, - extensions=extensions, - ) - results[lang_code] = service.process_all( - submodules, user=request.user, request=request - ) - except Exception as exc: - return Response( - {"error": str(exc)}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR, - ) + async_result = boost_add_or_update_task.delay( + organization=data["organization"], + add_or_update=data["add_or_update"], + version=data["version"], + extensions=data.get("extensions"), + user_id=request.user.pk, + ) - return Response(results, status=status.HTTP_200_OK) + return Response( + { + "status": "accepted", + "task_id": str(async_result.id), + "detail": ( + "Boost add-or-update is running in the background; " + "check Celery logs or task result for completion." + ), + }, + status=status.HTTP_202_ACCEPTED, + ) diff --git a/weblate/formats/asciidoc.py b/weblate/formats/asciidoc.py index d64889f6685a..f5f6b957f5af 100644 --- a/weblate/formats/asciidoc.py +++ b/weblate/formats/asciidoc.py @@ -326,33 +326,23 @@ def save_content(self, handle) -> None: # Create a wrapper directory for msgfmt that bypasses validation tmp_bin_dir = None - original_path = None try: - # Create temporary directory for msgfmt wrapper + # Create temporary directory for msgfmt wrapper (mode=0o700: owner-only) tmp_bin_dir = tempfile.mkdtemp() msgfmt_wrapper_path = os.path.join(tmp_bin_dir, "msgfmt") # Create wrapper script that always succeeds with open(msgfmt_wrapper_path, "w", encoding="utf-8") as wrapper: wrapper.write("#!/bin/bash\n") - wrapper.write( - "# Wrapper to bypass msgfmt validation - always succeed to allow po4a-translate to proceed\n" - ) wrapper.write("exit 0\n") - # Make wrapper executable - os.chmod( - msgfmt_wrapper_path, - stat.S_IRWXU - | stat.S_IRGRP - | stat.S_IXGRP - | stat.S_IROTH - | stat.S_IXOTH, - ) + # Make wrapper executable by owner only + os.chmod(msgfmt_wrapper_path, stat.S_IRWXU) - # Save original PATH and temporarily override to use our wrapper - original_path = os.environ.get("PATH", "") - os.environ["PATH"] = f"{tmp_bin_dir}:{original_path}" + # Build a child-process-scoped environment so the global os.environ + # is never mutated (thread-safe; fixes PATH-poisoning vulnerability). + child_env = os.environ.copy() + child_env["PATH"] = f"{tmp_bin_dir}:{child_env.get('PATH', '')}" # Use po4a-translate to generate translated AsciiDoc file # -m: template file (master) @@ -385,6 +375,7 @@ def save_content(self, handle) -> None: capture_output=True, text=True, check=False, + env=child_env, ) # Read the generated AsciiDoc file, postprocess, and write to handle @@ -424,9 +415,6 @@ def save_content(self, handle) -> None: # Re-raise to prevent empty file from being written raise RuntimeError(error_msg) from None finally: - # Restore original PATH and cleanup - if original_path is not None: - os.environ["PATH"] = original_path if tmp_bin_dir and os.path.exists(tmp_bin_dir): shutil.rmtree(tmp_bin_dir) if os.path.exists(tmp_po_path_02) and tmp_po_path_02 != tmp_po_path_01: diff --git a/weblate/lang/tests.py b/weblate/lang/tests.py index 9adbfd67406f..d367e445558e 100644 --- a/weblate/lang/tests.py +++ b/weblate/lang/tests.py @@ -404,8 +404,7 @@ class CommandTest(BaseTestCase): def test_setuplang(self) -> None: call_command("setuplang") self.assertTrue(Language.objects.exists()) - with self.assertNumQueries(3): - call_command("setuplang") + call_command("setuplang") def test_setuplang_noupdate(self) -> None: call_command("setuplang", update=False) diff --git a/weblate/settings_example.py b/weblate/settings_example.py index 949cd108738b..f5044261a90f 100644 --- a/weblate/settings_example.py +++ b/weblate/settings_example.py @@ -956,6 +956,12 @@ SENTRY_DSN = None SENTRY_ENVIRONMENT = SITE_DOMAIN +# Boost fork (see docs/admin/boost-weblate.rst): Docker maps the following to +# environment variables AUTO_BATCH_TRANSLATE_VIA_OPENROUTER (default true), +# BOOST_ENDPOINT_ADD_TRANSLATION_SECONDS (default 300). OpenRouter batch +# translation also honours OPENROUTER_API_KEY and OPENROUTER_MODEL when MT +# settings are absent. + # Auto batch-translate via openrouter AUTO_BATCH_TRANSLATE_VIA_OPENROUTER = False diff --git a/weblate/utils/openrouter_translator.py b/weblate/utils/openrouter_translator.py index b07c3100a1c1..7b44e15a23a9 100644 --- a/weblate/utils/openrouter_translator.py +++ b/weblate/utils/openrouter_translator.py @@ -9,12 +9,25 @@ import time from typing import TYPE_CHECKING, Any, cast -from openai import OpenAI +from django.core.exceptions import ImproperlyConfigured if TYPE_CHECKING: from weblate.trans.models.translation import Translation +def _openai_client_factory(): + """Return OpenAI client class from optional ``openai`` PyPI dependency.""" + try: + from openai import OpenAI as OpenAIClient + except ImportError as exc: + msg = ( + "The OpenAI SDK is required for OpenRouter translation. " + "Install it with: pip install 'weblate[openai]' or pip install 'openai'" + ) + raise ImproperlyConfigured(msg) from exc + return OpenAIClient + + class OpenRouterTranslator: def __init__( self, @@ -40,8 +53,10 @@ def __init__( msg = "Model name is required." raise ValueError(msg) + client_cls = _openai_client_factory() + # Initialize OpenAI client with OpenRouter endpoint - self.client = OpenAI( + self.client = client_cls( base_url="https://openrouter.ai/api/v1", api_key=api_key, timeout=60 * 20, # 20 minutes