diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a50bbc..b4c0b65 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,17 @@ jobs: - run: pip install -e ".[dev]" - run: doit test + docs: + name: Docs + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - run: pip install -e ".[docs]" + - run: sphinx-build -b html -W --keep-going docs docs/_build/html + build: name: Build (${{ matrix.os }}) needs: [format, typecheck, test] diff --git a/Makefile b/Makefile index 0948247..704cf30 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,11 @@ -.PHONY: all client server cli test clean +.PHONY: all client server cli test clean docs docs-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,11 +15,35 @@ 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 +# Build the Sphinx HTML documentation into docs/_build/html. +docs: + sphinx-build -b html docs docs/_build/html + +docs-clean: + rm -rf docs/_build + clean: - rm -rf build dist *.spec + rm -rf build dist *.spec docs/_build 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/api.md b/docs/api.md index 3b964e3..c07f205 100644 --- a/docs/api.md +++ b/docs/api.md @@ -2,15 +2,15 @@ ## Transport -- Протокол: HTTPS. -- Формат тела запроса и ответа: JSON. -- Префикс API: `/api/v1`. -- Формат времени: RFC 3339 / ISO 8601, например `2026-05-24T13:20:30Z`. -- Авторизация клиента: bearer token в заголовке `Authorization`. +- Protocol: HTTPS. +- Request and response body format: JSON. +- API prefix: `/api/v1`. +- Time format: RFC 3339 / ISO 8601, e.g. `2026-05-24T13:20:30Z`. +- Client authorization: bearer token in the `Authorization` header. -Пример заголовков: +Example headers: -```http +```text Content-Type: application/json Authorization: Bearer ``` @@ -19,12 +19,12 @@ Authorization: Bearer ### `POST /api/v1/client/heartbeat` -Клиент вызывает эту ручку на каждом polling tick. Одна и та же ручка -используется для трёх сценариев: +The client calls this endpoint on every polling tick. The same endpoint serves +three scenarios: -- сообщение "я жив"; -- регулярное обновление состояния; -- отправка результата последней завершённой команды. +- an "I'm alive" message; +- a regular state update; +- reporting the result of the last finished command. ### Request @@ -45,19 +45,19 @@ Authorization: Bearer } ``` -Поля: +Fields: -- `client_id` - стабильный идентификатор, заданный при настройке клиента. Он не - должен меняться при каждом рестарте. -- `observed_at` - момент, когда клиент подготовил payload. -- `client.hostname` - текущий hostname машины. -- `client.platform` - платформа клиента. Желательно использовать названия, - близкие к Python/platform: `linux`, `darwin`, `windows`. -- `client.version` - версия nexus-sync client. -- `state` - намеренно маленький объект. Может быть сюда позже можно добавить IP, - disk usage, memory, battery status и другие метрики. -- `last_command_result` - `null`, если клиенту нечего нового сообщать о - выполнении команды. +- `client_id` - a stable identifier set during client setup. It must not change + on every restart. +- `observed_at` - the moment the client prepared the payload. +- `client.hostname` - the machine's current hostname. +- `client.platform` - the client platform. Prefer names close to + Python/platform: `linux`, `darwin`, `windows`. +- `client.version` - the nexus-sync client version. +- `state` - an intentionally small object. IP, disk usage, memory, battery + status and other metrics may be added here later. +- `last_command_result` - `null` if the client has nothing new to report about + command execution. ### Request with command result @@ -86,17 +86,16 @@ Authorization: Bearer } ``` -Поля результата: +Result fields: -- `command_id` должен совпадать с id команды, которую сервер ранее вернул - клиенту. -- `status` принимает одно из значений: `succeeded`, `failed`, `timed_out`, - `rejected`. -- `return_code` - exit code процесса, если процесс был запущен. Для - `timed_out` или `rejected` поле может быть `null`, если финального exit code - нет. -- `stdout` и `stderr` клиент должен ограничивать по размеру перед отправкой. - Конкретный лимит байт в этом контракте пока не фиксируется. +- `command_id` must match the id of the command the server previously returned + to the client. +- `status` takes one of: `succeeded`, `failed`, `timed_out`, `rejected`. +- `return_code` - the process exit code, if the process was started. For + `timed_out` or `rejected` the field may be `null` when there is no final exit + code. +- `stdout` and `stderr` must be size-limited by the client before sending. This + contract does not yet fix a specific byte limit. ### Response without command @@ -126,36 +125,37 @@ Authorization: Bearer } ``` -Поля команды: +Command fields: -- `id` генерируется сервером и должен быть уникальным. -- `kind` описывает тип executor. Пока определён только `exec`. -- `name` - имя command preset. -- `args` содержит аргументы, специфичные для конкретного preset. -- `timeout_seconds` - максимальное время выполнения, которое сервер допускает - для этой команды. +- `id` is generated by the server and must be unique. +- `kind` describes the executor type. Only `exec` is defined so far. +- `name` - the command preset name. +- `args` holds arguments specific to a particular preset. +- `timeout_seconds` - the maximum execution time the server allows for this + command. -Клиент обязан выполнять только те команды, которые он знает и локально -разрешает. Неизвестные или запрещённые команды нужно возвращать как `rejected`. +The client must run only commands it knows and locally allows. Unknown or +forbidden commands must be returned as `rejected`. ## Server-side API -Эти ручки нужны серверной части/админке, чтобы видеть клиентов и ставить им -команды в очередь. Клиенты напрямую используют только heartbeat. +These endpoints are for the server/admin side, to see clients and queue commands +for them. Clients directly use only the heartbeat. ### `GET /api/v1/server/clients` -Возвращает список известных клиентов с последними heartbeat-данными и +Returns the list of known clients with their latest heartbeat data and `available_commands`. ### `GET /api/v1/server/clients/{client_id}` -Возвращает одного клиента или `404`, если сервер ещё не видел этот `client_id`. +Returns a single client, or `404` if the server has not seen this `client_id` +yet. ### `POST /api/v1/server/clients/{client_id}/commands` -Создаёт pending-команду для клиента. Клиент получит её на следующем heartbeat. -Эта ручка принимает имя команды напрямую. +Creates a pending command for the client. The client receives it on its next +heartbeat. This endpoint accepts the command name directly. Request: @@ -188,66 +188,66 @@ Response: ### `GET /api/v1/server/commands/{command_id}` -Возвращает команду и, если клиент уже отчитался, поле `result`. +Returns the command and, if the client has already reported, the `result` field. ## Command lifecycle -Жизненный цикл команды: +Command lifecycle: -1. `pending`: команда создана на сервере и ещё не доставлена клиенту. -2. `delivered`: команда была возвращена клиенту в heartbeat response. -3. `succeeded`: клиент сообщил об успешном выполнении. -4. `failed`: клиент сообщил об ошибке выполнения. -5. `timed_out`: клиент сообщил о timeout. -6. `rejected`: клиент отказался выполнять команду. +1. `pending`: the command was created on the server and not yet delivered to the + client. +2. `delivered`: the command was returned to the client in a heartbeat response. +3. `succeeded`: the client reported successful execution. +4. `failed`: the client reported an execution error. +5. `timed_out`: the client reported a timeout. +6. `rejected`: the client refused to run the command. -Терминальные статусы: `succeeded`, `failed`, `timed_out`, `rejected`. +Terminal statuses: `succeeded`, `failed`, `timed_out`, `rejected`. ## HTTP statuses -Heartbeat endpoint должен использовать такие HTTP-статусы: +The heartbeat endpoint should use these HTTP statuses: -- `200 OK`: heartbeat принят; тело ответа соответствует контракту выше. -- `400 Bad Request`: некорректный JSON или невалидные значения полей. -- `401 Unauthorized`: bearer token отсутствует или невалиден. -- `403 Forbidden`: token валиден, но не имеет права действовать как этот +- `200 OK`: heartbeat accepted; the response body matches the contract above. +- `400 Bad Request`: malformed JSON or invalid field values. +- `401 Unauthorized`: the bearer token is missing or invalid. +- `403 Forbidden`: the token is valid but not allowed to act as this `client_id`. -- `409 Conflict`: результат ссылается на неизвестную, уже терминальную или - несовместимую команду. -- `429 Too Many Requests`: клиент опрашивает сервер слишком часто. -- `500 Internal Server Error`: неожиданная ошибка сервера. +- `409 Conflict`: the result references an unknown, already terminal, or + incompatible command. +- `429 Too Many Requests`: the client polls the server too often. +- `500 Internal Server Error`: an unexpected server error. -Состояние выполнения команды выражается полями в JSON, а не HTTP-ошибками. -Например, команда с exit code `1` всё равно отправляется через успешный +Command execution state is expressed via JSON fields, not HTTP errors. For +example, a command with exit code `1` is still delivered over a successful heartbeat request. ## Polling rules -Ответ сервера содержит `next_poll_after_seconds`. +The server response contains `next_poll_after_seconds`. -Поведение: +Behavior: -- обычный polling interval без команды: 60 секунд; -- polling interval после получения команды: 10 секунд; -- клиентский минимальный interval: 5 секунд; -- клиентский максимальный interval: 300 секунд. +- normal polling interval without a command: 60 seconds; +- polling interval after receiving a command: 10 seconds; +- client minimum interval: 5 seconds; +- client maximum interval: 300 seconds. -Клиент должен воспринимать значение сервера как рекомендацию и зажимать его в -локальные min/max границы. +The client should treat the server value as a recommendation and clamp it to its +local min/max bounds. ## Security constraints -Необходимо соблюдать эти ограничения: +The following constraints must be respected: -- не выполнять произвольные shell-строки, полученные от сервера; -- выполнять только локально известные command presets; -- требовать bearer token для клиентских endpoint'ов; -- включать `command_id` в каждый результат, чтобы не было неоднозначного - сопоставления; -- хранить command output как логи, а не как доверенные управляющие данные; -- ограничивать размер command output перед отправкой на сервер; -- выполнять каждую команду с timeout. +- do not run arbitrary shell strings received from the server; +- run only locally known command presets; +- require a bearer token for client endpoints; +- include `command_id` in every result to avoid ambiguous matching; +- store command output as logs, not as trusted control data; +- limit the size of command output before sending it to the server; +- run every command with a timeout. -Эти ограничения намеренно являются частью контракта, потому что проект -занимается удалённым управлением машинами. Дешевле строить первую реализацию -вокруг них, чем добавлять их задним числом. +These constraints are intentionally part of the contract because the project +deals with remote machine management. It is cheaper to build the first +implementation around them than to add them after the fact. diff --git a/docs/cli.md b/docs/cli.md index cf16ce1..87b7c94 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,4 +1,4 @@ -# nexus-cli +# CLI `nexus-cli` is a small non-interactive CLI for the nexus-sync server API. @@ -61,13 +61,40 @@ 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`). ## Build wheel package diff --git a/docs/client.md b/docs/client.md index 9f089f1..120be10 100644 --- a/docs/client.md +++ b/docs/client.md @@ -1,14 +1,17 @@ # Client -Основная суть - кидает на известный по ip/домену сервак свой ключ и базовую информацию о себе: hostname, местное время (что-то ещё?) +The core idea - it sends its key and basic info about itself (hostname, local +time, anything else?) to a server known by ip/domain. -В ответ может получить как простое "ок", так и команду для выполнения +In response it can get either a plain "ok" or a command to execute. -Запросы на сервер будет кидать с некоторой частотой, постепенно возрастающей, но резко снижающейся при получении команды в ответе (вдруг надо ещё что-то выполнить) +It sends requests to the server at a certain frequency, gradually increasing but +dropping sharply when a command is received in the response (in case something +else needs to be executed). -Есть установление нижнего предела на частоту (условная 1/минута) +There is a lower bound on the frequency (roughly 1/minute). -Пример работы +Example flow ``` ... @@ -25,18 +28,18 @@ client -> server: hello!, i'm $(hostname), uuid= , ts=, stdout= , stderr= , ... *waits 23s* ``` -Возможно нужна доп инфа о работе команды (код ошибки как минимум) +Additional info about command execution may be needed (at least the error code). -+ возможно на клиенте стоит ограничить набор допустимых команд -+ стоит явно задуматься о шифровании/идентификации сервера ++ the set of allowed commands should probably be limited on the client ++ encryption / server identification should be considered explicitly -## Разрешённые команды +## Allowed commands -Клиент исполняет только команды, описанные в локальном YAML config-файле. -Серверу отправляются только `name` и `description`; поле `cmd` остаётся только -на клиенте и не управляется сервером. +The client runs only the commands described in its local YAML config file. Only +`name` and `description` are sent to the server; the `cmd` field stays on the +client and is not controlled by the server. -Пример: +Example: ```yaml server_url: "http://127.0.0.1:5852" @@ -52,6 +55,6 @@ allowed_commands: logging_level: "INFO" ``` -Файл ищется как `nexus.yml`/`nexus.yaml` в текущей директории, затем в -`$XDG_CONFIG_HOME`, затем в `~/.config`, затем как +The file is looked up as `nexus.yml`/`nexus.yaml` in the current directory, then +in `$XDG_CONFIG_HOME`, then in `~/.config`, then as `~/.config/nexus/config.yml`/`.yaml`. diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..1ebabc9 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,39 @@ +"""Sphinx configuration for the nexus-sync documentation.""" + +import os +import sys + +# Make the package importable for autodoc (sources live under src/). +sys.path.insert(0, os.path.abspath("../src")) + +project = "nexus-sync" +author = "nexus-sync" +copyright = "2026, nexus-sync" +release = "0.1.0" + +extensions = [ + "myst_parser", + "sphinx.ext.autodoc", + "sphinx.ext.autosummary", + "sphinx.ext.napoleon", + "sphinx.ext.viewcode", +] + +# Pull docstrings even from undocumented members so the API pages are useful +# while docstring coverage grows. +autosummary_generate = True +autodoc_default_options = { + "members": True, + "undoc-members": True, + "show-inheritance": True, +} +autodoc_typehints = "description" + +# MyST so the existing Markdown guides render as-is. +myst_enable_extensions = ["colon_fence", "deflist"] +source_suffix = {".md": "markdown", ".rst": "restructuredtext"} + +exclude_patterns = ["_build", ".DS_Store", "Thumbs.db", "archive"] + +html_theme = "furo" +html_title = "nexus-sync" diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..2fdd304 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,24 @@ +# nexus-sync + +A utility that allows you to manage your computers from a centralized server. + +nexus-sync has three parts: a central **server** (FastAPI), a **client** agent +that runs on each managed machine and executes a whitelist of commands, and +**nexus-cli**, the operator's command-line tool. + +```{toctree} +:maxdepth: 2 +:caption: Guide + +api +cli +client +server +``` + +```{toctree} +:maxdepth: 2 +:caption: Reference + +reference +``` diff --git a/docs/reference.md b/docs/reference.md new file mode 100644 index 0000000..0fe61c8 --- /dev/null +++ b/docs/reference.md @@ -0,0 +1,43 @@ +# API Reference + +Auto-generated from the source docstrings. + +## CLI (`nexus-cli`) + +```{eval-rst} +.. automodule:: nexus_sync.cli.__main__ +``` + +## Client + +```{eval-rst} +.. automodule:: nexus_sync.client.runtime + +.. automodule:: nexus_sync.client.execute +``` + +## Server + +```{eval-rst} +.. automodule:: nexus_sync.server.app + +.. automodule:: nexus_sync.server.store + +.. automodule:: nexus_sync.server.sqlalchemy_store + +.. automodule:: nexus_sync.server.config +``` + +## Common + +```{eval-rst} +.. automodule:: nexus_sync.common.models +``` + +## Utilities + +```{eval-rst} +.. automodule:: nexus_sync.i18n + +.. automodule:: nexus_sync.utils.log_config +``` diff --git a/docs/server.md b/docs/server.md index a71056a..77193e7 100644 --- a/docs/server.md +++ b/docs/server.md @@ -1,50 +1,59 @@ # Server TODO: -- описать формально все API -- добавить сами команды, возможно сделать их в виде пресетов для платформы +- formally describe the whole API +- add the commands themselves, possibly as per-platform presets -## Основная суть +## Core idea -- API ручки. Доступ чисто по ssl (но это уже зона ответственности nginx) -- клиенты по своему некоторому ключу будут авторизовываться. - - проблема ручной настройки (но она тут будто минимальная, не так плохо) -- когда клиент что-либо присылает, в ответ ему надо кинуть команды на исполнение, если есть -- со стороны клиента должен быть настроен trust к серверу +- API endpoints. Access purely over SSL (but that's already nginx's + responsibility). +- clients authorize with some key of their own. + - the manual setup problem (but it seems minimal here, not too bad) +- when a client sends anything, the response should include commands to execute, + if any. +- the client side must be configured to trust the server. -Для сервера необходимо несколько настроек +The server needs several settings -- лимит на кол-во запросов (от одного клиента, условно) -- лимит на хранение инфо (и хранить ли старое? да, логи) - - лимит на одного юзера - - общий лимит -- кастомные команды -- отслеживание получения/выполнение команды сервером, повторные попытки, лимит попыток +- a request rate limit (per single client, roughly) +- a limit on stored info (and whether to keep old data? yes, logs) + - per-user limit + - global limit +- custom commands +- tracking command delivery/execution by the server, retries, attempt limit -## Возможности (API ручки) +## Capabilities (API endpoints) -'Понятия не имею, как описывать эти ручки (точнее, нет желания правильно их описывать)' +'No idea how to describe these endpoints (or rather, no desire to describe them +properly)' -### Ручки для админа +### Admin endpoints -Все пользователи будут считаться админами для удобства. Остальных нет +All users are treated as admins for convenience. There are no others. -1. получить список всех клиентов - - с отдельным параметром "только активированные" -2. получить данные по клиенту - - сюда включается вся инфа о его показателях, когда был в сети + какие команды на него доступны (да, будем их ограничивать, возможно по модели zero-trust) -3. выполнить какую-то команду - - тут вопрос в том, будем ли мы ждать ответа от нашего клиента (вряд ли) -4. получить токен, авторизация -5. добавить кастомную команду (для клиента) - - проблема в системах, если хочется универсального добавления. Выход - добавлять только для одного клиента (лучше во всём будет) +1. get the list of all clients + - with a separate "only activated" parameter +2. get data for a client + - this includes all info about its metrics, when it was last online, and which + commands are available for it (yes, we'll restrict them, possibly via a + zero-trust model) +3. run some command + - the question here is whether we'll wait for a response from our client + (probably not) +4. obtain a token, authorization +5. add a custom command (for a client) + - the problem is with systems if you want universal addition. The way out is + to add it only for a single client (better all around) -### Ручки для клиентов +### Client endpoints -Не уверен даже, что больше одной нужно будет +Not even sure more than one will be needed -1. Прислать информацию - - просто стучится со своим uuid и кидает, что знает -2. Инфо о сервере? - - возможно фетчить айпи (чтобы стучаться, если сертификат на месте), другие домены - - какие-то особые "правила", если пакет с их установкой потеряется (сам то пакет вряд ли потеряется, мы об этом узнаем сразу) +1. Send information + - just knocks with its uuid and sends what it knows +2. Info about the server? + - possibly fetch the ip (to knock on, if the certificate is in place), other + domains + - some special "rules" if the package with their installation gets lost (the + package itself is unlikely to be lost, we'll know immediately) diff --git a/pyproject.toml b/pyproject.toml index da8a0a7..00bf154 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,13 @@ dev = [ "doit>=0.36.0", "mypy>=1.0", "types-PyYAML>=6.0.12", - "pre-commit>=4.0" + "pre-commit>=4.0", + "Babel>=2.14.0" +] +docs = [ + "sphinx>=7.0", + "myst-parser>=2.0", + "furo>=2024.1.29" ] [project.scripts] @@ -37,6 +43,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")