mirror of
https://github.com/usestrix/strix.git
synced 2026-10-07 02:58:26 +00:00
fix(i18n): resolve --config language, translate argparse built-ins and example titles
This commit is contained in:
parent
27cf85f849
commit
b7a4a29264
5 changed files with 207 additions and 26 deletions
|
|
@ -17,6 +17,7 @@ SUPPORTED_LANGUAGES: frozenset[str] = frozenset({"en", "es"})
|
|||
|
||||
# Module-level state
|
||||
_language: str | None = None
|
||||
_config_path: Path | None = None
|
||||
_locales: dict[str, dict[str, str]] = {}
|
||||
_lock = threading.Lock()
|
||||
_locales_dir: Path = Path(__file__).parent / "locales"
|
||||
|
|
@ -28,7 +29,7 @@ def _detect_language() -> str:
|
|||
Priority:
|
||||
1. _language (set by --language CLI flag or set_language())
|
||||
2. STRIX_LANGUAGE env var
|
||||
3. ~/.strix/cli-config.json "language" field
|
||||
3. Config file (--config override or ~/.strix/cli-config.json)
|
||||
4. LANG / LC_ALL system locale (first 2 chars)
|
||||
5. "en" default
|
||||
"""
|
||||
|
|
@ -43,7 +44,7 @@ def _detect_language() -> str:
|
|||
|
||||
# 3. Config file (canonical format: {"env": {"STRIX_LANGUAGE": "es"}})
|
||||
try:
|
||||
config_path = Path.home() / ".strix" / "cli-config.json"
|
||||
config_path = _config_path or (Path.home() / ".strix" / "cli-config.json")
|
||||
if config_path.exists():
|
||||
data = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
if isinstance(data, dict):
|
||||
|
|
@ -104,6 +105,17 @@ def set_language(lang: str | None) -> None:
|
|||
_language = _normalize_lang(lang) if lang else None
|
||||
|
||||
|
||||
def set_config_path(path: str | Path | None) -> None:
|
||||
"""Override the config file used for language resolution.
|
||||
|
||||
``--config`` selects a custom config file; its ``env.STRIX_LANGUAGE``
|
||||
should drive localization just like the default ``~/.strix/cli-config.json``.
|
||||
Pass ``None`` to clear the override.
|
||||
"""
|
||||
global _config_path # noqa: PLW0603
|
||||
_config_path = Path(path) if path else None
|
||||
|
||||
|
||||
def get_language() -> str:
|
||||
"""Get the currently resolved language."""
|
||||
return _detect_language()
|
||||
|
|
|
|||
|
|
@ -20,23 +20,59 @@ from strix.interface.utils import (
|
|||
)
|
||||
|
||||
|
||||
def _pre_resolve_language() -> None:
|
||||
"""Set language from --language/-l before argparse runs.
|
||||
# Translate argparse's built-in strings (usage, options, error, help/version)
|
||||
# through the i18n layer. argparse routes these through ``gettext.gettext``,
|
||||
# so replacing ``argparse._`` with a ``t()``-backed lookup localizes them.
|
||||
_ARGPARSE_MESSAGE_KEYS: dict[str, str] = {
|
||||
"usage: ": "cli.argparse_usage",
|
||||
"options": "cli.argparse_options",
|
||||
"show this help message and exit": "cli.argparse_help",
|
||||
"show program's version number and exit": "cli.argparse_version",
|
||||
"%(prog)s: error: %(message)s\n": "cli.argparse_error",
|
||||
"unrecognized arguments: %s": "cli.argparse_unrecognized",
|
||||
"the following arguments are required: %s": "cli.argparse_required",
|
||||
}
|
||||
|
||||
Argparse evaluates help text at parse time, so we must set the language
|
||||
BEFORE parse_args() is called. This pre-scans sys.argv for the flag.
|
||||
|
||||
def _translate_argparse(message: str) -> str:
|
||||
"""Return the localized form of an argparse built-in string."""
|
||||
key = _ARGPARSE_MESSAGE_KEYS.get(message)
|
||||
if key is None:
|
||||
return message
|
||||
translated = t(key)
|
||||
return translated if translated != key else message
|
||||
|
||||
|
||||
argparse._ = _translate_argparse # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def _pre_resolve_language() -> None:
|
||||
"""Resolve language from --language/-l and --config before argparse runs.
|
||||
|
||||
Argparse evaluates help text at parse time, so we must resolve the language
|
||||
BEFORE parse_args() is called. This pre-scans sys.argv for the flags.
|
||||
Priority matches ``_detect_language``: --language > STRIX_LANGUAGE env >
|
||||
--config file > default config > system locale.
|
||||
"""
|
||||
argv = sys.argv[1:]
|
||||
language: str | None = None
|
||||
config_path: str | None = None
|
||||
for i, arg in enumerate(argv):
|
||||
if arg in ("-l", "--language") and i + 1 < len(argv):
|
||||
from strix.i18n import set_language
|
||||
set_language(argv[i + 1])
|
||||
return
|
||||
# Handle --language=es form
|
||||
if arg.startswith("--language="):
|
||||
from strix.i18n import set_language
|
||||
set_language(arg.split("=", 1)[1])
|
||||
return
|
||||
language = argv[i + 1]
|
||||
elif arg.startswith("--language="):
|
||||
language = arg.split("=", 1)[1]
|
||||
elif arg == "--config" and i + 1 < len(argv):
|
||||
config_path = argv[i + 1]
|
||||
elif arg.startswith("--config="):
|
||||
config_path = arg.split("=", 1)[1]
|
||||
|
||||
from strix.i18n import set_config_path, set_language
|
||||
|
||||
if language:
|
||||
set_language(language)
|
||||
elif config_path:
|
||||
set_config_path(config_path)
|
||||
|
||||
|
||||
def get_version() -> str:
|
||||
|
|
@ -79,45 +115,45 @@ def parse_arguments() -> argparse.Namespace:
|
|||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=f"""
|
||||
{t("cli.examples_header")}
|
||||
# Web application penetration test
|
||||
# {t("cli.example_web_app")}
|
||||
strix --target https://example.com
|
||||
|
||||
# GitHub repository analysis
|
||||
# {t("cli.example_github")}
|
||||
strix --target https://github.com/user/repo
|
||||
strix --target git@github.com:user/repo.git
|
||||
|
||||
# Local code analysis
|
||||
# {t("cli.example_local_code")}
|
||||
strix --target ./my-project
|
||||
|
||||
# API spec test (OpenAPI/Swagger file or Postman collection export)
|
||||
# {t("cli.example_api_spec")}
|
||||
strix --target ./openapi.yaml --target https://api.example.com
|
||||
strix --target ./collection.postman_collection.json
|
||||
|
||||
# Postman collection pulled live by id (needs POSTMAN_API_KEY); optional environment
|
||||
# {t("cli.example_postman")}
|
||||
strix --target postman://<collection-uuid> --target https://api.example.com
|
||||
strix --target "postman://<collection-uuid>?env=<environment-uuid>"
|
||||
|
||||
# Domain penetration test
|
||||
# {t("cli.example_domain")}
|
||||
strix --target example.com
|
||||
|
||||
# IP address penetration test
|
||||
# {t("cli.example_ip")}
|
||||
strix --target 192.168.1.42
|
||||
|
||||
# Multiple targets (e.g., white-box testing with source and deployed app)
|
||||
# {t("cli.example_multiple")}
|
||||
strix --target https://github.com/user/repo --target https://example.com
|
||||
strix --target ./my-project --target https://staging.example.com --target https://prod.example.com
|
||||
|
||||
# Targets from a file, one target per non-empty, non-comment line
|
||||
# {t("cli.example_file")}
|
||||
strix --target-list ./targets.txt
|
||||
|
||||
# Custom instructions (inline)
|
||||
# {t("cli.example_instruction_inline")}
|
||||
strix --target example.com --instruction "Focus on authentication vulnerabilities"
|
||||
|
||||
# Custom instructions (from file)
|
||||
# {t("cli.example_instruction_file")}
|
||||
strix --target example.com --instruction-file ./instructions.txt
|
||||
strix --target https://app.com --instruction-file /path/to/detailed_instructions.md
|
||||
|
||||
# Extra files placed in the sandbox workspace
|
||||
# {t("cli.example_workspace")}
|
||||
strix --target ./my-project --workspace-file ./wordlist.txt
|
||||
strix --target https://app.com --workspace-file ./openapi.yaml:specs/openapi.yaml
|
||||
""",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,25 @@
|
|||
"cli.language_help": "Language for UI and agent responses (e.g., 'en', 'es'). Default: auto-detect from environment.",
|
||||
"cli.workspace_file_help": "Place a file from this machine into the sandbox workspace before the scan starts, for example a wordlist, an API specification, or notes. Repeat the option for more files. DEST is the path inside /workspace and defaults to the file name (for example '--workspace-file ./wordlist.txt:lists/wordlist.txt'). The file is read-only inside the sandbox and lands outside every target directory.",
|
||||
"cli.examples_header": "Examples:",
|
||||
"cli.argparse_usage": "usage: ",
|
||||
"cli.argparse_options": "options",
|
||||
"cli.argparse_help": "show this help message and exit",
|
||||
"cli.argparse_version": "show program's version number and exit",
|
||||
"cli.argparse_error": "%(prog)s: error: %(message)s\n",
|
||||
"cli.argparse_unrecognized": "unrecognized arguments: %s",
|
||||
"cli.argparse_required": "the following arguments are required: %s",
|
||||
"cli.example_web_app": "Web application penetration test",
|
||||
"cli.example_github": "GitHub repository analysis",
|
||||
"cli.example_local_code": "Local code analysis",
|
||||
"cli.example_api_spec": "API spec test (OpenAPI/Swagger file or Postman collection export)",
|
||||
"cli.example_postman": "Postman collection pulled live by id (needs POSTMAN_API_KEY); optional environment",
|
||||
"cli.example_domain": "Domain penetration test",
|
||||
"cli.example_ip": "IP address penetration test",
|
||||
"cli.example_multiple": "Multiple targets (e.g., white-box testing with source and deployed app)",
|
||||
"cli.example_file": "Targets from a file, one target per non-empty, non-comment line",
|
||||
"cli.example_instruction_inline": "Custom instructions (inline)",
|
||||
"cli.example_instruction_file": "Custom instructions (from file)",
|
||||
"cli.example_workspace": "Extra files placed in the sandbox workspace",
|
||||
"cli.update_help": "Update strix to the latest version and exit. Self-updates the standalone binary install; for pip/pipx/uv installs, prints the matching upgrade command instead.",
|
||||
"cli.version_help": "Show version and exit.",
|
||||
"cli.error_no_target": "No target specified. Use --target or --target-list.",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,25 @@
|
|||
"cli.language_help": "Idioma para la interfaz y respuestas de agentes (ej: 'en', 'es'). Por defecto: auto-detectar del entorno.",
|
||||
"cli.workspace_file_help": "Coloca un archivo de esta máquina en el workspace del sandbox antes de que comience el escaneo, por ejemplo una wordlist, una especificación de API o notas. Repite la opción para más archivos. DEST es la ruta dentro de /workspace y por defecto usa el nombre del archivo (por ejemplo '--workspace-file ./wordlist.txt:lists/wordlist.txt'). El archivo es de solo lectura dentro del sandbox y queda fuera de cada directorio de objetivo.",
|
||||
"cli.examples_header": "Ejemplos:",
|
||||
"cli.argparse_usage": "uso: ",
|
||||
"cli.argparse_options": "opciones",
|
||||
"cli.argparse_help": "muestra este mensaje de ayuda y sale",
|
||||
"cli.argparse_version": "muestra el número de versión del programa y sale",
|
||||
"cli.argparse_error": "%(prog)s: error: %(message)s\n",
|
||||
"cli.argparse_unrecognized": "argumentos no reconocidos: %s",
|
||||
"cli.argparse_required": "se requieren los siguientes argumentos: %s",
|
||||
"cli.example_web_app": "Prueba de penetración de aplicación web",
|
||||
"cli.example_github": "Análisis de repositorio de GitHub",
|
||||
"cli.example_local_code": "Análisis de código local",
|
||||
"cli.example_api_spec": "Prueba de spec API (archivo OpenAPI/Swagger o export de colección Postman)",
|
||||
"cli.example_postman": "Colección Postman obtenida en vivo por id (requiere POSTMAN_API_KEY); entorno opcional",
|
||||
"cli.example_domain": "Prueba de penetración de dominio",
|
||||
"cli.example_ip": "Prueba de penetración de dirección IP",
|
||||
"cli.example_multiple": "Múltiples objetivos (ej., prueba white-box con código fuente y app desplegada)",
|
||||
"cli.example_file": "Objetivos desde un archivo, uno por línea no vacía y no comentario",
|
||||
"cli.example_instruction_inline": "Instrucciones personalizadas (en línea)",
|
||||
"cli.example_instruction_file": "Instrucciones personalizadas (desde archivo)",
|
||||
"cli.example_workspace": "Archivos extra colocados en el workspace del sandbox",
|
||||
"cli.update_help": "Actualizar strix a la última versión y salir. Auto-actualiza la instalación binaria; para instalaciones pip/pipx/uv, muestra el comando de actualización correspondiente.",
|
||||
"cli.version_help": "Mostrar versión y salir.",
|
||||
"cli.error_no_target": "No se especificó objetivo. Use --target o --target-list.",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
|
@ -18,18 +19,22 @@ from strix.i18n import (
|
|||
_normalize_lang,
|
||||
get_language,
|
||||
get_language_directive,
|
||||
set_config_path,
|
||||
set_language,
|
||||
t,
|
||||
)
|
||||
from strix.interface import cli_args
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_i18n_state():
|
||||
"""Reset module-level state between tests."""
|
||||
mod._language = None
|
||||
mod._config_path = None
|
||||
mod._locales.clear()
|
||||
yield
|
||||
mod._language = None
|
||||
mod._config_path = None
|
||||
mod._locales.clear()
|
||||
|
||||
|
||||
|
|
@ -250,6 +255,96 @@ class TestCliHelpKeys:
|
|||
es_value = t("cli.unknown_model_hint")
|
||||
assert "por ejemplo" in es_value
|
||||
|
||||
def test_example_titles_translate(self):
|
||||
set_language("es")
|
||||
assert "aplicación web" in t("cli.example_web_app")
|
||||
assert "repositorio" in t("cli.example_github")
|
||||
assert "código local" in t("cli.example_local_code")
|
||||
assert "spec API" in t("cli.example_api_spec")
|
||||
assert "dirección IP" in t("cli.example_ip")
|
||||
assert "archivo" in t("cli.example_file")
|
||||
set_language("en")
|
||||
assert t("cli.example_web_app") == "Web application penetration test"
|
||||
|
||||
|
||||
class TestCustomConfigPath:
|
||||
"""``--config`` should drive language resolution like the default config."""
|
||||
|
||||
def test_custom_config_path_resolves_language(self, tmp_path):
|
||||
config = tmp_path / "custom.json"
|
||||
config.write_text(
|
||||
json.dumps({"env": {"STRIX_LANGUAGE": "es"}}), encoding="utf-8"
|
||||
)
|
||||
set_config_path(config)
|
||||
assert get_language() == "es"
|
||||
|
||||
def test_custom_config_path_ignored_without_env_key(self, tmp_path):
|
||||
config = tmp_path / "custom.json"
|
||||
config.write_text(json.dumps({"env": {}}), encoding="utf-8")
|
||||
set_config_path(config)
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
assert get_language() == "en"
|
||||
|
||||
def test_custom_config_missing_file_falls_back(self, tmp_path):
|
||||
set_config_path(tmp_path / "does-not-exist.json")
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
assert get_language() == "en"
|
||||
|
||||
def test_env_var_overrides_custom_config(self, tmp_path):
|
||||
config = tmp_path / "custom.json"
|
||||
config.write_text(
|
||||
json.dumps({"env": {"STRIX_LANGUAGE": "es"}}), encoding="utf-8"
|
||||
)
|
||||
set_config_path(config)
|
||||
with patch.dict(os.environ, {"STRIX_LANGUAGE": "en"}):
|
||||
assert get_language() == "en"
|
||||
|
||||
def test_explicit_language_overrides_custom_config(self, tmp_path):
|
||||
config = tmp_path / "custom.json"
|
||||
config.write_text(
|
||||
json.dumps({"env": {"STRIX_LANGUAGE": "es"}}), encoding="utf-8"
|
||||
)
|
||||
set_config_path(config)
|
||||
set_language("en")
|
||||
assert get_language() == "en"
|
||||
|
||||
|
||||
class TestArgparseBuiltins:
|
||||
"""argparse's built-in strings must translate through the i18n layer."""
|
||||
|
||||
def test_usage_translates(self):
|
||||
set_language("es")
|
||||
assert t("cli.argparse_usage") == "uso: "
|
||||
set_language("en")
|
||||
assert t("cli.argparse_usage") == "usage: "
|
||||
|
||||
def test_options_translates(self):
|
||||
set_language("es")
|
||||
assert t("cli.argparse_options") == "opciones"
|
||||
set_language("en")
|
||||
assert t("cli.argparse_options") == "options"
|
||||
|
||||
def test_help_help_translates(self):
|
||||
set_language("es")
|
||||
assert t("cli.argparse_help") == "muestra este mensaje de ayuda y sale"
|
||||
|
||||
def test_version_help_translates(self):
|
||||
set_language("es")
|
||||
assert t("cli.argparse_version") == (
|
||||
"muestra el número de versión del programa y sale"
|
||||
)
|
||||
|
||||
def test_unrecognized_arguments_translates(self):
|
||||
set_language("es")
|
||||
assert t("cli.argparse_unrecognized") == "argumentos no reconocidos: %s"
|
||||
|
||||
def test_argparse_override_is_wired(self):
|
||||
# The override routes argparse's gettext calls through t().
|
||||
set_language("es")
|
||||
assert cli_args._translate_argparse("usage: ") == "uso: "
|
||||
assert cli_args._translate_argparse("options") == "opciones"
|
||||
assert cli_args._translate_argparse("unknown string") == "unknown string"
|
||||
assert argparse._("usage: ") == "uso: "
|
||||
|
||||
|
||||
class TestSettingsLanguageField:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue