Merge sphinx-docs (CLI i18n + Sphinx docs) into main

# Conflicts:
#	.github/workflows/ci.yml
#	docs/cli.md
This commit is contained in:
ars
2026-06-25 02:57:03 +03:00
15 changed files with 680 additions and 188 deletions
+11
View File
@@ -40,6 +40,17 @@ jobs:
- run: pip install -e ".[dev]" - run: pip install -e ".[dev]"
- run: doit test - 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: build:
name: Build (${{ matrix.os }}) name: Build (${{ matrix.os }})
needs: [format, typecheck, test] needs: [format, typecheck, test]
+35 -4
View File
@@ -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 all: client server cli
@@ -8,11 +15,35 @@ client:
server: server:
pyinstaller --onefile src/nexus_sync/server/__main__.py --name nexus-sync-server pyinstaller --onefile src/nexus_sync/server/__main__.py --name nexus-sync-server
cli: cli: i18n-compile
pyinstaller --onefile src/nexus_sync/cli/__main__.py --name nexus-cli 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: test:
pytest 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: clean:
rm -rf build dist *.spec rm -rf build dist *.spec docs/_build
+5
View File
@@ -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]
+89 -89
View File
@@ -2,15 +2,15 @@
## Transport ## Transport
- Протокол: HTTPS. - Protocol: HTTPS.
- Формат тела запроса и ответа: JSON. - Request and response body format: JSON.
- Префикс API: `/api/v1`. - API prefix: `/api/v1`.
- Формат времени: RFC 3339 / ISO 8601, например `2026-05-24T13:20:30Z`. - Time format: RFC 3339 / ISO 8601, e.g. `2026-05-24T13:20:30Z`.
- Авторизация клиента: bearer token в заголовке `Authorization`. - Client authorization: bearer token in the `Authorization` header.
Пример заголовков: Example headers:
```http ```text
Content-Type: application/json Content-Type: application/json
Authorization: Bearer <client-token> Authorization: Bearer <client-token>
``` ```
@@ -19,12 +19,12 @@ Authorization: Bearer <client-token>
### `POST /api/v1/client/heartbeat` ### `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 ### Request
@@ -45,19 +45,19 @@ Authorization: Bearer <client-token>
} }
``` ```
Поля: Fields:
- `client_id` - стабильный идентификатор, заданный при настройке клиента. Он не - `client_id` - a stable identifier set during client setup. It must not change
должен меняться при каждом рестарте. on every restart.
- `observed_at` - момент, когда клиент подготовил payload. - `observed_at` - the moment the client prepared the payload.
- `client.hostname` - текущий hostname машины. - `client.hostname` - the machine's current hostname.
- `client.platform` - платформа клиента. Желательно использовать названия, - `client.platform` - the client platform. Prefer names close to
близкие к Python/platform: `linux`, `darwin`, `windows`. Python/platform: `linux`, `darwin`, `windows`.
- `client.version` - версия nexus-sync client. - `client.version` - the nexus-sync client version.
- `state` - намеренно маленький объект. Может быть сюда позже можно добавить IP, - `state` - an intentionally small object. IP, disk usage, memory, battery
disk usage, memory, battery status и другие метрики. status and other metrics may be added here later.
- `last_command_result` - `null`, если клиенту нечего нового сообщать о - `last_command_result` - `null` if the client has nothing new to report about
выполнении команды. command execution.
### Request with command result ### Request with command result
@@ -86,17 +86,16 @@ Authorization: Bearer <client-token>
} }
``` ```
Поля результата: Result fields:
- `command_id` должен совпадать с id команды, которую сервер ранее вернул - `command_id` must match the id of the command the server previously returned
клиенту. to the client.
- `status` принимает одно из значений: `succeeded`, `failed`, `timed_out`, - `status` takes one of: `succeeded`, `failed`, `timed_out`, `rejected`.
`rejected`. - `return_code` - the process exit code, if the process was started. For
- `return_code` - exit code процесса, если процесс был запущен. Для `timed_out` or `rejected` the field may be `null` when there is no final exit
`timed_out` или `rejected` поле может быть `null`, если финального exit code code.
нет. - `stdout` and `stderr` must be size-limited by the client before sending. This
- `stdout` и `stderr` клиент должен ограничивать по размеру перед отправкой. contract does not yet fix a specific byte limit.
Конкретный лимит байт в этом контракте пока не фиксируется.
### Response without command ### Response without command
@@ -126,36 +125,37 @@ Authorization: Bearer <client-token>
} }
``` ```
Поля команды: Command fields:
- `id` генерируется сервером и должен быть уникальным. - `id` is generated by the server and must be unique.
- `kind` описывает тип executor. Пока определён только `exec`. - `kind` describes the executor type. Only `exec` is defined so far.
- `name` - имя command preset. - `name` - the command preset name.
- `args` содержит аргументы, специфичные для конкретного preset. - `args` holds arguments specific to a particular preset.
- `timeout_seconds` - максимальное время выполнения, которое сервер допускает - `timeout_seconds` - the maximum execution time the server allows for this
для этой команды. command.
Клиент обязан выполнять только те команды, которые он знает и локально The client must run only commands it knows and locally allows. Unknown or
разрешает. Неизвестные или запрещённые команды нужно возвращать как `rejected`. forbidden commands must be returned as `rejected`.
## Server-side API ## Server-side API
Эти ручки нужны серверной части/админке, чтобы видеть клиентов и ставить им These endpoints are for the server/admin side, to see clients and queue commands
команды в очередь. Клиенты напрямую используют только heartbeat. for them. Clients directly use only the heartbeat.
### `GET /api/v1/server/clients` ### `GET /api/v1/server/clients`
Возвращает список известных клиентов с последними heartbeat-данными и Returns the list of known clients with their latest heartbeat data and
`available_commands`. `available_commands`.
### `GET /api/v1/server/clients/{client_id}` ### `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` ### `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: Request:
@@ -188,66 +188,66 @@ Response:
### `GET /api/v1/server/commands/{command_id}` ### `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
Жизненный цикл команды: Command lifecycle:
1. `pending`: команда создана на сервере и ещё не доставлена клиенту. 1. `pending`: the command was created on the server and not yet delivered to the
2. `delivered`: команда была возвращена клиенту в heartbeat response. client.
3. `succeeded`: клиент сообщил об успешном выполнении. 2. `delivered`: the command was returned to the client in a heartbeat response.
4. `failed`: клиент сообщил об ошибке выполнения. 3. `succeeded`: the client reported successful execution.
5. `timed_out`: клиент сообщил о timeout. 4. `failed`: the client reported an execution error.
6. `rejected`: клиент отказался выполнять команду. 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 ## HTTP statuses
Heartbeat endpoint должен использовать такие HTTP-статусы: The heartbeat endpoint should use these HTTP statuses:
- `200 OK`: heartbeat принят; тело ответа соответствует контракту выше. - `200 OK`: heartbeat accepted; the response body matches the contract above.
- `400 Bad Request`: некорректный JSON или невалидные значения полей. - `400 Bad Request`: malformed JSON or invalid field values.
- `401 Unauthorized`: bearer token отсутствует или невалиден. - `401 Unauthorized`: the bearer token is missing or invalid.
- `403 Forbidden`: token валиден, но не имеет права действовать как этот - `403 Forbidden`: the token is valid but not allowed to act as this
`client_id`. `client_id`.
- `409 Conflict`: результат ссылается на неизвестную, уже терминальную или - `409 Conflict`: the result references an unknown, already terminal, or
несовместимую команду. incompatible command.
- `429 Too Many Requests`: клиент опрашивает сервер слишком часто. - `429 Too Many Requests`: the client polls the server too often.
- `500 Internal Server Error`: неожиданная ошибка сервера. - `500 Internal Server Error`: an unexpected server error.
Состояние выполнения команды выражается полями в JSON, а не HTTP-ошибками. Command execution state is expressed via JSON fields, not HTTP errors. For
Например, команда с exit code `1` всё равно отправляется через успешный example, a command with exit code `1` is still delivered over a successful
heartbeat request. heartbeat request.
## Polling rules ## Polling rules
Ответ сервера содержит `next_poll_after_seconds`. The server response contains `next_poll_after_seconds`.
Поведение: Behavior:
- обычный polling interval без команды: 60 секунд; - normal polling interval without a command: 60 seconds;
- polling interval после получения команды: 10 секунд; - polling interval after receiving a command: 10 seconds;
- клиентский минимальный interval: 5 секунд; - client minimum interval: 5 seconds;
- клиентский максимальный interval: 300 секунд. - client maximum interval: 300 seconds.
Клиент должен воспринимать значение сервера как рекомендацию и зажимать его в The client should treat the server value as a recommendation and clamp it to its
локальные min/max границы. local min/max bounds.
## Security constraints ## Security constraints
Необходимо соблюдать эти ограничения: The following constraints must be respected:
- не выполнять произвольные shell-строки, полученные от сервера; - do not run arbitrary shell strings received from the server;
- выполнять только локально известные command presets; - run only locally known command presets;
- требовать bearer token для клиентских endpoint'ов; - require a bearer token for client endpoints;
- включать `command_id` в каждый результат, чтобы не было неоднозначного - include `command_id` in every result to avoid ambiguous matching;
сопоставления; - store command output as logs, not as trusted control data;
- хранить command output как логи, а не как доверенные управляющие данные; - limit the size of command output before sending it to the server;
- ограничивать размер command output перед отправкой на сервер; - run every command with a timeout.
- выполнять каждую команду с 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.
+29 -2
View File
@@ -1,4 +1,4 @@
# nexus-cli # CLI
`nexus-cli` is a small non-interactive CLI for the nexus-sync server API. `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 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/<lang>/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 ## Build standalone binary
```bash ```bash
make cli 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 ## Build wheel package
+18 -15
View File
@@ -1,14 +1,17 @@
# Client # 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* *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-файле. The client runs only the commands described in its local YAML config file. Only
Серверу отправляются только `name` и `description`; поле `cmd` остаётся только `name` and `description` are sent to the server; the `cmd` field stays on the
на клиенте и не управляется сервером. client and is not controlled by the server.
Пример: Example:
```yaml ```yaml
server_url: "http://127.0.0.1:5852" server_url: "http://127.0.0.1:5852"
@@ -52,6 +55,6 @@ allowed_commands:
logging_level: "INFO" logging_level: "INFO"
``` ```
Файл ищется как `nexus.yml`/`nexus.yaml` в текущей директории, затем в The file is looked up as `nexus.yml`/`nexus.yaml` in the current directory, then
`$XDG_CONFIG_HOME`, затем в `~/.config`, затем как in `$XDG_CONFIG_HOME`, then in `~/.config`, then as
`~/.config/nexus/config.yml`/`.yaml`. `~/.config/nexus/config.yml`/`.yaml`.
+39
View File
@@ -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"
+24
View File
@@ -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
```
+43
View File
@@ -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
```
+44 -35
View File
@@ -1,50 +1,59 @@
# Server # Server
TODO: TODO:
- описать формально все API - formally describe the whole API
- добавить сами команды, возможно сделать их в виде пресетов для платформы - add the commands themselves, possibly as per-platform presets
## Основная суть ## Core idea
- API ручки. Доступ чисто по ssl (но это уже зона ответственности nginx) - 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)
- со стороны клиента должен быть настроен trust к серверу - 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. получить список всех клиентов 1. get the list of all clients
- с отдельным параметром "только активированные" - with a separate "only activated" parameter
2. получить данные по клиенту 2. get data for a client
- сюда включается вся инфа о его показателях, когда был в сети + какие команды на него доступны (да, будем их ограничивать, возможно по модели zero-trust) - this includes all info about its metrics, when it was last online, and which
3. выполнить какую-то команду commands are available for it (yes, we'll restrict them, possibly via a
- тут вопрос в том, будем ли мы ждать ответа от нашего клиента (вряд ли) zero-trust model)
4. получить токен, авторизация 3. run some command
5. добавить кастомную команду (для клиента) - 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. Прислать информацию 1. Send information
- просто стучится со своим uuid и кидает, что знает - just knocks with its uuid and sends what it knows
2. Инфо о сервере? 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)
+10 -1
View File
@@ -28,7 +28,13 @@ dev = [
"doit>=0.36.0", "doit>=0.36.0",
"mypy>=1.0", "mypy>=1.0",
"types-PyYAML>=6.0.12", "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] [project.scripts]
@@ -37,6 +43,9 @@ nexus-cli = "nexus_sync.cli.__main__:main"
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]
[tool.setuptools.package-data]
nexus_sync = ["locale/**/*.mo"]
[tool.black] [tool.black]
line-length = 100 line-length = 100
target-version = ["py311"] target-version = ["py311"]
+72 -42
View File
@@ -8,6 +8,9 @@ from collections.abc import Callable
from types import TracebackType from types import TracebackType
from typing import Any, Protocol, Self 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" DEFAULT_SERVER_URL = "http://127.0.0.1:5852"
API_PREFIX = "/api/v1" API_PREFIX = "/api/v1"
JsonObject = dict[str, Any] JsonObject = dict[str, Any]
@@ -53,62 +56,62 @@ def request_json(
body = response.read() body = response.read()
except urllib.error.HTTPError as error: except urllib.error.HTTPError as error:
detail = error.read().decode(errors="replace") 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: 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: except OSError as error:
raise CLIError(f"request failed: {error}") from error raise CLIError(_("request failed: {error}").format(error=error)) from error
try: try:
result = json.loads(body.decode()) result = json.loads(body.decode())
except json.JSONDecodeError as error: 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): 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 return result
def format_clients(payload: JsonObject) -> str: def format_clients(payload: JsonObject) -> str:
clients = payload.get("clients", []) clients = payload.get("clients", [])
if not isinstance(clients, list) or not 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: for item in clients:
if not isinstance(item, dict): if not isinstance(item, dict):
continue continue
parts = [ parts = [
str(item.get("id", "<unknown>")), str(item.get("id", _("<unknown>"))),
str(item.get("platform", "unknown")), str(item.get("platform", _("unknown"))),
str(item.get("hostname", "unknown")), str(item.get("hostname", _("unknown"))),
f"version={item.get('version', 'unknown')}", f"version={item.get('version', _('unknown'))}",
f"last_seen={item.get('last_seen_at', 'unknown')}", f"last_seen={item.get('last_seen_at', _('unknown'))}",
] ]
lines.append(f"- {' '.join(parts)}") lines.append(f"- {' '.join(parts)}")
commands = _command_names(item.get("available_commands", [])) commands = _command_names(item.get("available_commands", []))
if commands: if commands:
lines.append(f" commands: {', '.join(commands)}") lines.append(f" {_('commands:')} {', '.join(commands)}")
return "\n".join(lines) return "\n".join(lines)
def format_client(payload: JsonObject) -> str: def format_client(payload: JsonObject) -> str:
lines = [ lines = [
f"id: {payload.get('id', '<unknown>')}", _field_line("id", payload.get("id", _("<unknown>"))),
f"hostname: {payload.get('hostname', 'unknown')}", _field_line("hostname", payload.get("hostname", _("unknown"))),
f"platform: {payload.get('platform', 'unknown')}", _field_line("platform", payload.get("platform", _("unknown"))),
f"version: {payload.get('version', 'unknown')}", _field_line("version", payload.get("version", _("unknown"))),
f"created_at: {payload.get('created_at', 'unknown')}", _field_line("created_at", payload.get("created_at", _("unknown"))),
f"last_seen_at: {payload.get('last_seen_at', 'unknown')}", _field_line("last_seen_at", payload.get("last_seen_at", _("unknown"))),
"available_commands:", _("available_commands:"),
] ]
commands = payload.get("available_commands", []) commands = payload.get("available_commands", [])
if not isinstance(commands, list) or not commands: if not isinstance(commands, list) or not commands:
lines.append("- none") lines.append("- " + _("none"))
return "\n".join(lines) return "\n".join(lines)
for command in commands: for command in commands:
if isinstance(command, dict): if isinstance(command, dict):
name = command.get("name", "<unknown>") name = command.get("name", _("<unknown>"))
description = command.get("description", "") description = command.get("description", "")
suffix = f" - {description}" if description else "" suffix = f" - {description}" if description else ""
lines.append(f"- {name}{suffix}") lines.append(f"- {name}{suffix}")
@@ -116,7 +119,7 @@ def format_client(payload: JsonObject) -> str:
def format_command(payload: JsonObject, *, queued: bool = False) -> 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 ( for key in (
"id", "id",
"client_id", "client_id",
@@ -129,43 +132,45 @@ def format_command(payload: JsonObject, *, queued: bool = False) -> str:
"finished_at", "finished_at",
): ):
if key in payload: if key in payload:
lines.append(f"{key}: {payload.get(key)}") lines.append(_field_line(key, payload.get(key)))
result = payload.get("result") result = payload.get("result")
if isinstance(result, dict): if isinstance(result, dict):
lines.append("result:") lines.append(_("result:"))
if "status" in 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: if "return_code" in result:
lines.append(f" return_code: {result.get('return_code')}") lines.append(" " + _field_line("return_code", result.get("return_code")))
lines.append(" stdout:") lines.append(" " + _("stdout:"))
lines.extend(_indent_block(str(result.get("stdout", "")))) lines.extend(_indent_block(str(result.get("stdout", ""))))
lines.append(" stderr:") lines.append(" " + _("stderr:"))
lines.extend(_indent_block(str(result.get("stderr", "")))) lines.extend(_indent_block(str(result.get("stderr", ""))))
return "\n".join(lines) return "\n".join(lines)
def build_parser() -> argparse.ArgumentParser: def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="nexus-cli", description="CLI for nexus-sync server API") parser = argparse.ArgumentParser(
parser.add_argument( prog="nexus-cli", description=_("CLI for nexus-sync server API")
"--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(
parser.add_argument("--json", action="store_true", help="print raw JSON response") "--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") subparsers = parser.add_subparsers(dest="resource")
client = subparsers.add_parser("client", help="show client info or queue a command") client = subparsers.add_parser("client", help=_("show client info or queue a command"))
client.add_argument("id", help="client id") client.add_argument("id", help=_("client id"))
client.add_argument("--run-command", metavar="NAME", help="queue a command for this client") client.add_argument("--run-command", metavar="NAME", help=_("queue a command for this client"))
client.add_argument( client.add_argument(
"--timeout-seconds", "--timeout-seconds",
type=int, type=int,
default=30, 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 = subparsers.add_parser("command", help=_("show command execution info"))
command.add_argument("id", help="command id") command.add_argument("id", help=_("command id"))
return parser return parser
@@ -174,6 +179,7 @@ def main(
*, *,
requester: Requester = request_json, requester: Requester = request_json,
) -> int: ) -> int:
i18n.setup()
parser = build_parser() parser = build_parser()
args = parser.parse_args(argv) args = parser.parse_args(argv)
server_url = str(args.server_url).rstrip("/") server_url = str(args.server_url).rstrip("/")
@@ -215,7 +221,7 @@ def main(
_print_payload(payload, raw_json=args.json, formatter=format_command) _print_payload(payload, raw_json=args.json, formatter=format_command)
return 0 return 0
except CLIError as error: 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 return 1
parser.print_help() parser.print_help()
@@ -234,6 +240,30 @@ def _print_payload(
print(formatter(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]: def _command_names(commands: object) -> list[str]:
if not isinstance(commands, list): if not isinstance(commands, list):
return [] return []
@@ -246,7 +276,7 @@ def _command_names(commands: object) -> list[str]:
def _indent_block(value: str) -> list[str]: def _indent_block(value: str) -> list[str]:
if not value: if not value:
return [" <empty>"] return [" " + _("<empty>")]
return [f" {line}" if line else "" for line in value.rstrip("\n").splitlines()] return [f" {line}" if line else "" for line in value.rstrip("\n").splitlines()]
+56
View File
@@ -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
@@ -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 <LL@li.org>\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 "<unknown>"
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 "<empty>"
msgstr "<пусто>"
+13
View File
@@ -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")