Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions src/boost_weblate/endpoint/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@
def register_plugin_urls() -> None:
"""Append this app's routes to Weblate's pattern list.

This is the supported integration path: at process
startup, append a single ``path("boost-endpoint/", ...)`` entry to
``weblate.urls.real_patterns`` so routes stay under Weblate's ``URL_PREFIX``
handling.

Exposed HTTP paths (relative to ``/boost-endpoint/``): ``info/``,
``add-or-update/``, and ``plugin-ping/`` (see ``boost_weblate.endpoint.urls``).

Weblate builds ``urlpatterns`` from module-level ``real_patterns`` (see
``weblate.urls``). Optional integrations append to ``real_patterns`` before
the ``URL_PREFIX`` wrapper is applied, so mutating that list keeps routes
Expand All @@ -42,18 +50,31 @@ def register_plugin_urls() -> None:
return

wl_urls.real_patterns.append(
path("boost-endpoint/", include("boost_weblate.endpoint.urls")),
path(
"boost-endpoint/",
include(("boost_weblate.endpoint.urls", "boost_endpoint")),
),
)
setattr(wl_urls, _PLUGIN_URLS_ATTR, True)


class BoostEndpointConfig(AppConfig):
Comment thread
whisper67265 marked this conversation as resolved.
"""Registers ``/boost-endpoint/`` on the Weblate URLconf when the app loads."""
"""Django app config for the Boost documentation translation HTTP API.

On load, :meth:`ready` calls :func:`register_plugin_urls` once (idempotent) to
mount ``/boost-endpoint/`` with ``info/``, ``add-or-update/``, and
``plugin-ping/`` routes (application namespace ``boost_endpoint`` for URL
reversing).
"""

default_auto_field = "django.db.models.BigAutoField"
name = "boost_weblate.endpoint"
label = "boost_endpoint"
verbose_name = "Boost documentation translation API"

def ready(self) -> None:
"""Register plugin URL patterns with Weblate.

Delegates to :func:`register_plugin_urls`.
"""
register_plugin_urls()
65 changes: 65 additions & 0 deletions src/boost_weblate/endpoint/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,68 @@
# SPDX-License-Identifier: BSL-1.0

"""DRF serializers for the Boost documentation translation API."""

from __future__ import annotations

from typing import Any

from rest_framework import serializers


class AddOrUpdateRequestSerializer(serializers.Serializer):
"""Serializer for add_or_update endpoint request."""

organization = serializers.CharField(
required=True,
help_text="GitHub organization name (e.g., 'CppDigest')",
)
add_or_update = serializers.DictField(
child=serializers.ListField(child=serializers.CharField()),
required=True,
allow_empty=False,
help_text=(
"Map language code -> list of submodule names. "
'E.g. {"zh_Hans": ["json", "unordered"], "ja": ["json"]}. '
"Service runs for each lang_code with its submodule array."
),
)
version = serializers.CharField(
required=True,
help_text="Boost version (e.g., 'boost-1.90.0')",
)
extensions = serializers.ListField(
child=serializers.CharField(),
required=False,
Comment thread
whisper67265 marked this conversation as resolved.
allow_null=True,
default=None,
help_text=(
"Optional list of file extensions to include (e.g. ['.adoc', '.md']). "
"Only Weblate-supported extensions in this list are scanned. "
"If None or empty, all Weblate-supported extensions are used."
),
)

def validate_add_or_update(self, value: dict[str, Any]) -> dict[str, Any]:
"""Require non-empty string language keys and non-empty submodule lists."""
errors: dict[str, str] = {}
for lang_code, submodules in value.items():
if not isinstance(lang_code, str) or lang_code.strip() == "":
errors[str(lang_code)] = (
"add_or_update: each key must be a non-empty language code; "
f"got {repr(lang_code)}"
)
continue
if not isinstance(submodules, list):
errors[str(lang_code)] = (
"add_or_update: each value must be a non-empty list of submodule "
f"names; key {lang_code!r} is not a list "
f"(got {type(submodules).__name__})."
)
elif len(submodules) == 0:
errors[str(lang_code)] = (
"add_or_update: each value must be a non-empty list of submodule "
f"names; key {lang_code!r} has an empty list."
)
Comment thread
whisper67265 marked this conversation as resolved.
if errors:
raise serializers.ValidationError(errors)
return value
38 changes: 38 additions & 0 deletions src/boost_weblate/endpoint/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,41 @@
# SPDX-License-Identifier: BSL-1.0

"""Service layer for the Boost documentation translation API."""

from __future__ import annotations

from typing import Any


class BoostComponentService:
"""Service for managing Boost documentation components (internal Django usage).

Full ORM-backed implementation is planned; callers receive
:class:`NotImplementedError` from :meth:`process_all` until that work lands.
"""

def __init__(
self,
*,
organization: str,
lang_code: str,
version: str,
extensions: list[str] | None = None,
) -> None:
self.organization = organization
self.lang_code = lang_code
self.version = version
self.extensions = extensions

def process_all(
self,
submodules: list[str],
*,
user: Any,
request: Any = None,
) -> dict[str, Any]:
"""Clone, scan, and create/update Weblate projects and components."""
raise NotImplementedError(
"BoostComponentService.process_all is not implemented in this plugin "
"release; it will be added in a follow-up change."
)
6 changes: 6 additions & 0 deletions src/boost_weblate/endpoint/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,11 @@
app_name = "boost_endpoint"

urlpatterns = [
path("info/", views.BoostEndpointInfo.as_view(), name="info"),
path(
"add-or-update/",
views.AddOrUpdateView.as_view(),
name="add-or-update",
),
path("plugin-ping/", views.plugin_ping, name="plugin-ping"),
]
122 changes: 122 additions & 0 deletions src/boost_weblate/endpoint/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,133 @@

from __future__ import annotations

import logging

from django.http import HttpResponse
from django.views.decorators.http import require_GET
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView

from boost_weblate.endpoint.serializers import AddOrUpdateRequestSerializer
from boost_weblate.endpoint.services import BoostComponentService

logger = logging.getLogger(__name__)


@require_GET
def plugin_ping(_request):
"""Minimal health-style endpoint for URL registration smoke tests."""
return HttpResponse("ok", content_type="text/plain")


class BoostEndpointInfo(APIView):
"""Boost documentation translation API info."""

permission_classes = (IsAuthenticated,)

def get(self, request, format=None): # noqa: A002
"""Return Boost endpoint module info."""
return Response(
{
"module": "cppa-weblate-plugin",
"description": "Boost documentation translation API",
}
)


class AddOrUpdateView(APIView):
"""Add or update Boost documentation components."""

permission_classes = (IsAuthenticated,)

def post(self, request, format=None): # noqa: A002
"""
Create or update Boost documentation components.

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).
"""
serializer = AddOrUpdateRequestSerializer(data=request.data)
if not serializer.is_valid():
return Response(
{"errors": serializer.errors},
status=status.HTTP_400_BAD_REQUEST,
)

data = serializer.validated_data
organization = data["organization"]
add_or_update = data["add_or_update"]
version = data["version"]
extensions = data.get("extensions")

languages: dict[str, dict[str, object]] = {}
for lang_code, submodules in add_or_update.items():
try:
service = BoostComponentService(
organization=organization,
lang_code=lang_code,
version=version,
extensions=extensions,
)
languages[lang_code] = {
"status": "success",
"result": service.process_all(
submodules, user=request.user, request=request
),
}
except NotImplementedError as exc:
logger.warning(
"boost_weblate.endpoint.AddOrUpdateView: add-or-update not "
"implemented (organization=%s, lang_code=%s): %s",
organization,
lang_code,
exc,
)
languages[lang_code] = {
"status": "error",
"error": str(exc),
"code": "not_implemented",
}
except Exception:
logger.exception(
"boost_weblate.endpoint.AddOrUpdateView: add-or-update failed "
"(organization=%s, lang_code=%s)",
organization,
lang_code,
)
languages[lang_code] = {
"status": "error",
"error": "Internal server error",
"code": "internal_error",
}

body: dict[str, object] = {
"organization": organization,
"languages": languages,
}
has_success = any(v.get("status") == "success" for v in languages.values())
has_error = any(v.get("status") == "error" for v in languages.values())

if not has_error:
return Response(body, status=status.HTTP_200_OK)
if has_success and has_error:
return Response(body, status=status.HTTP_207_MULTI_STATUS)

if all(v.get("code") == "not_implemented" for v in languages.values()):
first_error = next(
str(v["error"])
for v in languages.values()
if v.get("status") == "error"
)
return Response(
{"detail": first_error, **body},
status=status.HTTP_501_NOT_IMPLEMENTED,
)

return Response(
{"error": "Internal server error", **body},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
3 changes: 3 additions & 0 deletions tests/endpoint/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2026 Andrew Zhang <whisper67265@outlook.com>
#
# SPDX-License-Identifier: BSL-1.0
48 changes: 48 additions & 0 deletions tests/endpoint/test_apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# SPDX-FileCopyrightText: 2026 Andrew Zhang <whisper67265@outlook.com>
#
# SPDX-License-Identifier: BSL-1.0

from __future__ import annotations

import builtins
import sys
import types

import pytest

from boost_weblate.endpoint.apps import register_plugin_urls


def test_register_plugin_urls_skips_when_weblate_urls_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
real_import = builtins.__import__

def fake_import(name: str, *args, **kwargs): # type: ignore[no-untyped-def]
if name == "weblate.urls":
raise ModuleNotFoundError("weblate.urls")
return real_import(name, *args, **kwargs)

monkeypatch.setattr(builtins, "__import__", fake_import)
# Should not raise; no fake module to inspect.
register_plugin_urls()


def test_register_plugin_urls_skips_without_real_patterns(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake = types.ModuleType("weblate.urls")
monkeypatch.setitem(sys.modules, "weblate.urls", fake)
register_plugin_urls()
assert not hasattr(fake, "real_patterns")


def test_register_plugin_urls_appends_once(monkeypatch: pytest.MonkeyPatch) -> None:
fake = types.ModuleType("weblate.urls")
fake.real_patterns = []
monkeypatch.setitem(sys.modules, "weblate.urls", fake)

register_plugin_urls()
register_plugin_urls()

assert len(fake.real_patterns) == 1
Loading
Loading