diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70aa6b7..cd555d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,16 +46,21 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-latest, windows-latest] + include: + - os: ubuntu-latest + data_sep: ":" + - os: windows-latest + data_sep: ";" steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.11" - run: pip install -e ".[dev]" + - run: pybabel compile -d src/nexus_sync/locale -D nexus - run: pyinstaller --onefile src/nexus_sync/client/__main__.py --name nexus-sync-client - run: pyinstaller --onefile src/nexus_sync/server/__main__.py --name nexus-sync-server - - run: pyinstaller --onefile src/nexus_sync/cli/__main__.py --name nexus-cli + - run: pyinstaller --onefile src/nexus_sync/cli/__main__.py --name nexus-cli --add-data "src/nexus_sync/locale${{ matrix.data_sep }}nexus_sync/locale" - uses: actions/upload-artifact@v4 with: name: nexus-sync-${{ matrix.os }} diff --git a/Makefile b/Makefile index 0948247..eabae8f 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,11 @@ -.PHONY: all client server cli test clean +.PHONY: all client server cli test clean i18n-extract i18n-init i18n-update i18n-compile + +# CLI localization (nexus-cli strings only; daemon/server logs stay in English). +LOCALE_DIR := src/nexus_sync/locale +POT := $(LOCALE_DIR)/nexus.pot +LANG ?= ru +# PyInstaller --add-data separator is ':' on Unix, ';' on Windows. +LOCALE_DATA := $(LOCALE_DIR):nexus_sync/locale all: client server cli @@ -8,8 +15,25 @@ client: server: pyinstaller --onefile src/nexus_sync/server/__main__.py --name nexus-sync-server -cli: - pyinstaller --onefile src/nexus_sync/cli/__main__.py --name nexus-cli +cli: i18n-compile + pyinstaller --onefile src/nexus_sync/cli/__main__.py --name nexus-cli \ + --add-data "$(LOCALE_DATA)" + +# Rebuild the message template from strings wrapped in _()/gettext()/ngettext(). +i18n-extract: + pybabel extract -F babel.cfg -k _ -o $(POT) src + +# Create a catalog for a new language, e.g. `make i18n-init LANG=de`. +i18n-init: i18n-extract + pybabel init -i $(POT) -d $(LOCALE_DIR) -D nexus -l $(LANG) + +# Merge new/changed strings into existing catalogs. +i18n-update: i18n-extract + pybabel update -i $(POT) -d $(LOCALE_DIR) -D nexus + +# Compile .po catalogs to the .mo files bundled with nexus-cli. +i18n-compile: + pybabel compile -d $(LOCALE_DIR) -D nexus test: pytest diff --git a/babel.cfg b/babel.cfg new file mode 100644 index 0000000..090e5f4 --- /dev/null +++ b/babel.cfg @@ -0,0 +1,5 @@ +# Babel extraction config for CLI localization. +# Paths are relative to the extraction root passed to pybabel (the `src` dir). +# Only strings wrapped in _()/gettext()/ngettext() are extracted, so logger.* +# messages are never picked up. +[python: **.py] diff --git a/docs/cli.md b/docs/cli.md index 115c0e4..b3fd5c2 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -61,10 +61,37 @@ nexus-cli --json client linux-client nexus-cli --json command cmd_123 ``` +## Localization + +CLI output is localized with `gettext` (catalogs managed by Babel). Only +user-facing CLI strings are translatable; daemon/server log messages are +deliberately left untranslated. + +Select a language with the `NEXUS_SYNC_LANG` env var (falls back to the system +locale, then to the source English strings): + +```bash +NEXUS_SYNC_LANG=ru nexus-cli command cmd_123 +``` + +Translation sources live in `src/nexus_sync/locale//LC_MESSAGES/nexus.po`. +Workflow (requires `pip install -e ".[dev]"`): + +```bash +make i18n-extract # rebuild the .pot template from _()-wrapped strings +make i18n-update # merge new/changed strings into existing catalogs +make i18n-init LANG=de # start a new language +# edit the .po file, then: +make i18n-compile # build the .mo files shipped with the binary +``` + +`make cli` compiles catalogs automatically and bundles them into the binary. + ## Build standalone binary ```bash make cli ``` -This creates `dist/nexus-cli` through PyInstaller. +This creates `dist/nexus-cli` through PyInstaller (with localization catalogs +bundled via `--add-data`). diff --git a/pyproject.toml b/pyproject.toml index d512409..1527625 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,8 @@ dev = [ "pyinstaller>=6.0", "mypy>=1.0", "types-PyYAML>=6.0.12", - "pre-commit>=4.0" + "pre-commit>=4.0", + "Babel>=2.14.0" ] [project.scripts] @@ -34,6 +35,9 @@ nexus-cli = "nexus_sync.cli.__main__:main" [tool.setuptools.packages.find] where = ["src"] +[tool.setuptools.package-data] +nexus_sync = ["locale/**/*.mo"] + [tool.black] line-length = 100 target-version = ["py311"] diff --git a/src/nexus_sync/cli/__main__.py b/src/nexus_sync/cli/__main__.py index 0e1010e..76e2c0c 100644 --- a/src/nexus_sync/cli/__main__.py +++ b/src/nexus_sync/cli/__main__.py @@ -8,6 +8,9 @@ from collections.abc import Callable from types import TracebackType from typing import Any, Protocol, Self +from nexus_sync import i18n +from nexus_sync.i18n import _ + DEFAULT_SERVER_URL = "http://127.0.0.1:5852" API_PREFIX = "/api/v1" JsonObject = dict[str, Any] @@ -53,62 +56,62 @@ def request_json( body = response.read() except urllib.error.HTTPError as error: detail = error.read().decode(errors="replace") - raise CLIError(f"HTTP {error.code}: {detail}") from error + raise CLIError(_("HTTP {code}: {detail}").format(code=error.code, detail=detail)) from error except urllib.error.URLError as error: - raise CLIError(f"request failed: {error.reason}") from error + raise CLIError(_("request failed: {error}").format(error=error.reason)) from error except OSError as error: - raise CLIError(f"request failed: {error}") from error + raise CLIError(_("request failed: {error}").format(error=error)) from error try: result = json.loads(body.decode()) except json.JSONDecodeError as error: - raise CLIError(f"server returned invalid JSON: {error}") from error + raise CLIError(_("server returned invalid JSON: {error}").format(error=error)) from error if not isinstance(result, dict): - raise CLIError("server returned JSON that is not an object") + raise CLIError(_("server returned JSON that is not an object")) return result def format_clients(payload: JsonObject) -> str: clients = payload.get("clients", []) if not isinstance(clients, list) or not clients: - return "clients:\n- none" + return _("clients:") + "\n- " + _("none") - lines = ["clients:"] + lines = [_("clients:")] for item in clients: if not isinstance(item, dict): continue parts = [ - str(item.get("id", "")), - str(item.get("platform", "unknown")), - str(item.get("hostname", "unknown")), - f"version={item.get('version', 'unknown')}", - f"last_seen={item.get('last_seen_at', 'unknown')}", + str(item.get("id", _(""))), + str(item.get("platform", _("unknown"))), + str(item.get("hostname", _("unknown"))), + f"version={item.get('version', _('unknown'))}", + f"last_seen={item.get('last_seen_at', _('unknown'))}", ] lines.append(f"- {' '.join(parts)}") commands = _command_names(item.get("available_commands", [])) if commands: - lines.append(f" commands: {', '.join(commands)}") + lines.append(f" {_('commands:')} {', '.join(commands)}") return "\n".join(lines) def format_client(payload: JsonObject) -> str: lines = [ - f"id: {payload.get('id', '')}", - f"hostname: {payload.get('hostname', 'unknown')}", - f"platform: {payload.get('platform', 'unknown')}", - f"version: {payload.get('version', 'unknown')}", - f"created_at: {payload.get('created_at', 'unknown')}", - f"last_seen_at: {payload.get('last_seen_at', 'unknown')}", - "available_commands:", + _field_line("id", payload.get("id", _(""))), + _field_line("hostname", payload.get("hostname", _("unknown"))), + _field_line("platform", payload.get("platform", _("unknown"))), + _field_line("version", payload.get("version", _("unknown"))), + _field_line("created_at", payload.get("created_at", _("unknown"))), + _field_line("last_seen_at", payload.get("last_seen_at", _("unknown"))), + _("available_commands:"), ] commands = payload.get("available_commands", []) if not isinstance(commands, list) or not commands: - lines.append("- none") + lines.append("- " + _("none")) return "\n".join(lines) for command in commands: if isinstance(command, dict): - name = command.get("name", "") + name = command.get("name", _("")) description = command.get("description", "") suffix = f" - {description}" if description else "" lines.append(f"- {name}{suffix}") @@ -116,7 +119,7 @@ def format_client(payload: JsonObject) -> str: def format_command(payload: JsonObject, *, queued: bool = False) -> str: - lines = ["queued command:" if queued else "command:"] + lines = [_("queued command:") if queued else _("command:")] for key in ( "id", "client_id", @@ -129,43 +132,45 @@ def format_command(payload: JsonObject, *, queued: bool = False) -> str: "finished_at", ): if key in payload: - lines.append(f"{key}: {payload.get(key)}") + lines.append(_field_line(key, payload.get(key))) result = payload.get("result") if isinstance(result, dict): - lines.append("result:") + lines.append(_("result:")) if "status" in result: - lines.append(f" status: {result.get('status')}") + lines.append(" " + _field_line("status", result.get("status"))) if "return_code" in result: - lines.append(f" return_code: {result.get('return_code')}") - lines.append(" stdout:") + lines.append(" " + _field_line("return_code", result.get("return_code"))) + lines.append(" " + _("stdout:")) lines.extend(_indent_block(str(result.get("stdout", "")))) - lines.append(" stderr:") + lines.append(" " + _("stderr:")) lines.extend(_indent_block(str(result.get("stderr", "")))) return "\n".join(lines) def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(prog="nexus-cli", description="CLI for nexus-sync server API") - parser.add_argument( - "--server-url", "-s", default=DEFAULT_SERVER_URL, help="nexus-sync server URL" + parser = argparse.ArgumentParser( + prog="nexus-cli", description=_("CLI for nexus-sync server API") ) - parser.add_argument("--list", action="store_true", help="list clients") - parser.add_argument("--json", action="store_true", help="print raw JSON response") + parser.add_argument( + "--server-url", "-s", default=DEFAULT_SERVER_URL, help=_("nexus-sync server URL") + ) + parser.add_argument("--list", action="store_true", help=_("list clients")) + parser.add_argument("--json", action="store_true", help=_("print raw JSON response")) subparsers = parser.add_subparsers(dest="resource") - client = subparsers.add_parser("client", help="show client info or queue a command") - client.add_argument("id", help="client id") - client.add_argument("--run-command", metavar="NAME", help="queue a command for this client") + client = subparsers.add_parser("client", help=_("show client info or queue a command")) + client.add_argument("id", help=_("client id")) + client.add_argument("--run-command", metavar="NAME", help=_("queue a command for this client")) client.add_argument( "--timeout-seconds", type=int, default=30, - help="command timeout in seconds for --run-command", + help=_("command timeout in seconds for --run-command"), ) - command = subparsers.add_parser("command", help="show command execution info") - command.add_argument("id", help="command id") + command = subparsers.add_parser("command", help=_("show command execution info")) + command.add_argument("id", help=_("command id")) return parser @@ -174,6 +179,7 @@ def main( *, requester: Requester = request_json, ) -> int: + i18n.setup() parser = build_parser() args = parser.parse_args(argv) server_url = str(args.server_url).rstrip("/") @@ -215,7 +221,7 @@ def main( _print_payload(payload, raw_json=args.json, formatter=format_command) return 0 except CLIError as error: - print(f"nexus-cli error: {error}", file=sys.stderr) + print(_("nexus-cli error: {error}").format(error=error), file=sys.stderr) return 1 parser.print_help() @@ -234,6 +240,30 @@ def _print_payload( print(formatter(payload)) +def _field_line(key: str, value: object) -> str: + return f"{_field_label(key)}: {value}" + + +def _field_label(key: str) -> str: + labels = { + "id": _("id"), + "client_id": _("client_id"), + "hostname": _("hostname"), + "platform": _("platform"), + "version": _("version"), + "created_at": _("created_at"), + "last_seen_at": _("last_seen_at"), + "kind": _("kind"), + "name": _("name"), + "status": _("status"), + "timeout_seconds": _("timeout_seconds"), + "delivered_at": _("delivered_at"), + "finished_at": _("finished_at"), + "return_code": _("return_code"), + } + return labels.get(key, key) + + def _command_names(commands: object) -> list[str]: if not isinstance(commands, list): return [] @@ -246,7 +276,7 @@ def _command_names(commands: object) -> list[str]: def _indent_block(value: str) -> list[str]: if not value: - return [" "] + return [" " + _("")] return [f" {line}" if line else "" for line in value.rstrip("\n").splitlines()] diff --git a/src/nexus_sync/i18n.py b/src/nexus_sync/i18n.py new file mode 100644 index 0000000..949120a --- /dev/null +++ b/src/nexus_sync/i18n.py @@ -0,0 +1,56 @@ +"""CLI localization (i18n). + +User-facing CLI strings are wrapped in :func:`_` so they can be translated. +Log messages (``logger.*``) are intentionally left unwrapped and always stay +in English for grep-ability and operations. + +Translation only becomes active after :func:`setup` is called (done in the CLI +entry points). Until then, and whenever no catalog matches the requested +language, ``gettext`` falls back to returning the original (English) message. +""" + +from __future__ import annotations + +import gettext as _gettext +import os +import sys +from pathlib import Path + +DOMAIN = "nexus" +LANG_ENV = "NEXUS_SYNC_LANG" + +_translation: _gettext.NullTranslations = _gettext.NullTranslations() + + +def _locale_dir() -> str: + """Locate the compiled message catalogs, both in-source and inside a PyInstaller bundle.""" + bundled = getattr(sys, "_MEIPASS", None) + if bundled is not None: + return os.path.join(bundled, "nexus_sync", "locale") + return str(Path(__file__).resolve().parent / "locale") + + +def setup(lang: str | None = None) -> None: + """Activate the message catalog for ``lang``. + + When ``lang`` is ``None`` the ``NEXUS_SYNC_LANG`` env var is consulted, and + failing that the system locale (``LANGUAGE``/``LANG``/...) is used. Missing + catalogs fall back silently to the original English strings. + """ + global _translation + if lang is None: + lang = os.environ.get(LANG_ENV) or None + languages = [lang] if lang else None + _translation = _gettext.translation(DOMAIN, _locale_dir(), languages=languages, fallback=True) + + +def gettext(message: str) -> str: + return _translation.gettext(message) + + +def ngettext(singular: str, plural: str, n: int) -> str: + return _translation.ngettext(singular, plural, n) + + +# Conventional alias used to mark translatable strings; recognized by pybabel. +_ = gettext diff --git a/src/nexus_sync/locale/ru/LC_MESSAGES/nexus.po b/src/nexus_sync/locale/ru/LC_MESSAGES/nexus.po new file mode 100644 index 0000000..b4e9b16 --- /dev/null +++ b/src/nexus_sync/locale/ru/LC_MESSAGES/nexus.po @@ -0,0 +1,192 @@ +# Russian translations for nexus-sync. +# Copyright (C) 2026 ORGANIZATION +# This file is distributed under the same license as the nexus-sync project. +# +msgid "" +msgstr "" +"Project-Id-Version: nexus-sync 0.1.0\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2026-06-24 22:58+0300\n" +"PO-Revision-Date: 2026-06-24 23:01+0300\n" +"Last-Translator: nexus-sync\n" +"Language: ru\n" +"Language-Team: ru \n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " +"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.18.0\n" + +#: src/nexus_sync/cli/__main__.py:59 +#, python-brace-format +msgid "HTTP {code}: {detail}" +msgstr "" + +#: src/nexus_sync/cli/__main__.py:61 src/nexus_sync/cli/__main__.py:63 +#, python-brace-format +msgid "request failed: {error}" +msgstr "запрос не выполнен: {error}" + +#: src/nexus_sync/cli/__main__.py:69 +#, python-brace-format +msgid "server returned invalid JSON: {error}" +msgstr "сервер вернул некорректный JSON: {error}" + +#: src/nexus_sync/cli/__main__.py:72 +msgid "server returned JSON that is not an object" +msgstr "сервер вернул JSON, который не является объектом" + +#: src/nexus_sync/cli/__main__.py:79 src/nexus_sync/cli/__main__.py:81 +msgid "clients:" +msgstr "клиенты:" + +#: src/nexus_sync/cli/__main__.py:79 src/nexus_sync/cli/__main__.py:111 +msgid "none" +msgstr "нет" + +#: src/nexus_sync/cli/__main__.py:86 src/nexus_sync/cli/__main__.py:101 +#: src/nexus_sync/cli/__main__.py:116 +msgid "" +msgstr "<неизвестно>" + +#: src/nexus_sync/cli/__main__.py:87 src/nexus_sync/cli/__main__.py:88 +#: src/nexus_sync/cli/__main__.py:89 src/nexus_sync/cli/__main__.py:90 +#: src/nexus_sync/cli/__main__.py:102 src/nexus_sync/cli/__main__.py:103 +#: src/nexus_sync/cli/__main__.py:104 src/nexus_sync/cli/__main__.py:105 +#: src/nexus_sync/cli/__main__.py:106 +msgid "unknown" +msgstr "неизвестно" + +#: src/nexus_sync/cli/__main__.py:95 +msgid "commands:" +msgstr "команды:" + +#: src/nexus_sync/cli/__main__.py:107 +msgid "available_commands:" +msgstr "доступные команды:" + +#: src/nexus_sync/cli/__main__.py:124 +msgid "queued command:" +msgstr "команда поставлена в очередь:" + +#: src/nexus_sync/cli/__main__.py:124 +msgid "command:" +msgstr "команда:" + +#: src/nexus_sync/cli/__main__.py:141 +msgid "result:" +msgstr "результат:" + +#: src/nexus_sync/cli/__main__.py:146 +msgid "stdout:" +msgstr "" + +#: src/nexus_sync/cli/__main__.py:148 +msgid "stderr:" +msgstr "" + +#: src/nexus_sync/cli/__main__.py:155 +msgid "CLI for nexus-sync server API" +msgstr "CLI для API сервера nexus-sync" + +#: src/nexus_sync/cli/__main__.py:158 +msgid "nexus-sync server URL" +msgstr "URL сервера nexus-sync" + +#: src/nexus_sync/cli/__main__.py:160 +msgid "list clients" +msgstr "показать список клиентов" + +#: src/nexus_sync/cli/__main__.py:161 +msgid "print raw JSON response" +msgstr "вывести необработанный JSON-ответ" + +#: src/nexus_sync/cli/__main__.py:164 +msgid "show client info or queue a command" +msgstr "показать информацию о клиенте или поставить команду в очередь" + +#: src/nexus_sync/cli/__main__.py:165 +msgid "client id" +msgstr "идентификатор клиента" + +#: src/nexus_sync/cli/__main__.py:167 +msgid "queue a command for this client" +msgstr "поставить команду в очередь для этого клиента" + +#: src/nexus_sync/cli/__main__.py:173 +msgid "command timeout in seconds for --run-command" +msgstr "таймаут команды в секундах для --run-command" + +#: src/nexus_sync/cli/__main__.py:176 +msgid "show command execution info" +msgstr "показать информацию о выполнении команды" + +#: src/nexus_sync/cli/__main__.py:177 +msgid "command id" +msgstr "идентификатор команды" + +#: src/nexus_sync/cli/__main__.py:228 +#, python-brace-format +msgid "nexus-cli error: {error}" +msgstr "ошибка nexus-cli: {error}" + +#: src/nexus_sync/cli/__main__.py:253 +msgid "id" +msgstr "" + +#: src/nexus_sync/cli/__main__.py:254 +msgid "client_id" +msgstr "id клиента" + +#: src/nexus_sync/cli/__main__.py:255 +msgid "hostname" +msgstr "имя хоста" + +#: src/nexus_sync/cli/__main__.py:256 +msgid "platform" +msgstr "платформа" + +#: src/nexus_sync/cli/__main__.py:257 +msgid "version" +msgstr "версия" + +#: src/nexus_sync/cli/__main__.py:258 +msgid "created_at" +msgstr "создано" + +#: src/nexus_sync/cli/__main__.py:259 +msgid "last_seen_at" +msgstr "последняя активность" + +#: src/nexus_sync/cli/__main__.py:260 +msgid "kind" +msgstr "тип" + +#: src/nexus_sync/cli/__main__.py:261 +msgid "name" +msgstr "имя" + +#: src/nexus_sync/cli/__main__.py:262 +msgid "status" +msgstr "статус" + +#: src/nexus_sync/cli/__main__.py:263 +msgid "timeout_seconds" +msgstr "таймаут (сек)" + +#: src/nexus_sync/cli/__main__.py:264 +msgid "delivered_at" +msgstr "доставлено" + +#: src/nexus_sync/cli/__main__.py:265 +msgid "finished_at" +msgstr "завершено" + +#: src/nexus_sync/cli/__main__.py:266 +msgid "return_code" +msgstr "код возврата" + +#: src/nexus_sync/cli/__main__.py:283 +msgid "" +msgstr "<пусто>" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..a1748aa --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,13 @@ +import pytest + + +@pytest.fixture(autouse=True) +def _force_source_locale(monkeypatch: pytest.MonkeyPatch) -> None: + """Keep CLI output in the source language during tests. + + ``nexus-cli`` activates a locale from ``NEXUS_SYNC_LANG`` / the system + locale, so assertions on English output would break on a machine whose + locale is, e.g., Russian. Pinning to a language with no catalog makes + gettext fall back to the source strings regardless of the dev's environment. + """ + monkeypatch.setenv("NEXUS_SYNC_LANG", "en")