feat: status translation overrides via YAML config

report.status_translation in YAML config overrides/extends the builtin
STATUS_TRANSLATION dictionary; without the section behavior is unchanged.

- AppConfig.report_status_translation + _resolve_str_dict (warns and
  skips non-scalar values, resolves ${VAR} references)
- Config.get_status_translation() returns merged copy (lazy import,
  builtin dict never mutated)
- build_grouped_report() accepts optional status_translation parameter
- --init-config template gains commented example, docs/CONFIG.md updated

Closes #65
This commit is contained in:
Кокос Артем Николаевич
2026-07-17 15:18:27 +07:00
parent 09f6062e8c
commit 8614062ecd
7 changed files with 332 additions and 2 deletions

View File

@@ -330,6 +330,7 @@ email:
| Поле | Тип | По умолчанию | Описание |
|---|---|---|---|
| `no_time` | bool | `false` | Не включать затраченное время в файл отчёта |
| `status_translation` | map[str,str] | `{}` | Переопределение/дополнение перевода статусов |
`report.no_time` применяется только в автоматических режимах (`--commit`, `--send`).
При ручном `--output` YAML-настройка игнорируется — там работает только CLI-флаг `--no-time`.
@@ -353,6 +354,22 @@ redmine-reporter --output report.odt
redmine-reporter --output report.odt --no-time
```
### `report.status_translation` — перевод статусов
По умолчанию статусы Redmine переводятся встроенным словарём
(`New``В работе`, `Closed``Закрыто` и т.д.). Секция
`report.status_translation` переопределяет отдельные переводы и/или
добавляет новые статусы; не указанные здесь статусы переводятся
встроенным словарём, а совсем неизвестные выводятся как есть.
```yaml
report:
status_translation:
"New": "Новая"
"Wait Release": "Ожидает релиза"
"Custom Status": "Кастомный статус"
```
## Разрешение выходного пути
Функция `resolve_output_path()` определяет итоговый путь к файлу:

View File

@@ -136,6 +136,16 @@ def _run_init_config(config_path: str, force: bool) -> int:
yaml.dump(
data, fh, allow_unicode=True, default_flow_style=False, sort_keys=False
)
fh.write(
"\n"
"# report.status_translation: переопределение/дополнение перевода\n"
"# статусов Redmine. Без секции используется встроенный словарь.\n"
"# Пример:\n"
"# report:\n"
"# status_translation:\n"
'# "New": "Новая"\n'
'# "Wait Release": "Ожидает релиза"\n'
)
path.chmod(0o600)
sections_found = [s for s in data if data[s]]
@@ -445,6 +455,7 @@ def main(argv: Optional[List[str]] = None) -> int:
issue_hours,
fill_time=not no_time,
by_activity=args.by_activity,
status_translation=Config.get_status_translation(),
)
if args.summary:

View File

@@ -4,7 +4,7 @@ import sys
from dataclasses import dataclass, field
from datetime import date, timedelta
from pathlib import Path
from typing import Union
from typing import Dict, Union
import yaml
from dotenv import load_dotenv
@@ -62,6 +62,7 @@ class AppConfig:
output_filename: str = "{author}_{from}_{to}.{ext}"
output_default_format: str = "xlsx"
report_no_time: bool = False
report_status_translation: Dict[str, str] = field(default_factory=dict)
email: EmailConfig = field(default_factory=EmailConfig)
@classmethod
@@ -111,6 +112,9 @@ class AppConfig:
output_default_format=cls._resolve_str(raw, "output", "default_format")
or "xlsx",
report_no_time=cls._resolve_bool(raw, "report", "no_time"),
report_status_translation=cls._resolve_str_dict(
raw, "report", "status_translation"
),
email=cls._resolve_email(raw),
)
@@ -140,6 +144,43 @@ class AppConfig:
return ""
return str(value)
@staticmethod
def _resolve_str_dict(raw: dict, section: str, key: str) -> Dict[str, str]:
"""Читает вложенный mapping строк из YAML-секции.
Не-mapping значение логируется с warning и игнорируется.
Ключи и значения приводятся к str, ${VAR} в значениях резолвится.
Записи со значением null или не-scalar (dict/list) пропускаются
с warning — иначе null стал бы строкой "None", а dict — repr.
"""
section_value = raw.get(section)
if not isinstance(section_value, dict):
return {}
value = section_value.get(key)
if value is None:
return {}
if not isinstance(value, dict):
logger.warning(
"Config %s.%s must be a mapping, got %s — ignoring",
section,
key,
type(value).__name__,
)
return {}
result: Dict[str, str] = {}
for k, v in value.items():
if v is None or isinstance(v, (dict, list)):
logger.warning(
"Config %s.%s entry %r must be a scalar, got %s — skipping",
section,
key,
k,
type(v).__name__,
)
continue
result[str(k)] = resolve_env_vars(str(v))
return result
@staticmethod
def _resolve_bool(raw: dict, section: str, key: str) -> bool:
value = raw.get(section, {}).get(key)
@@ -367,6 +408,18 @@ class Config:
return cls._app.report_no_time
return False
@classmethod
def get_status_translation(cls) -> Dict[str, str]:
"""Полный словарь перевода статусов: встроенный + переопределения из YAML.
Возвращает новый словарь — модульный STATUS_TRANSLATION не мутируется.
Импорт ленивый: config.py не должен тянуть redminelib при импорте.
"""
from .report_builder import STATUS_TRANSLATION
overrides = cls._app.report_status_translation if cls._app else {}
return {**STATUS_TRANSLATION, **overrides}
@classmethod
def get_email_config(cls) -> "EmailConfig | None":
"""Возвращает EmailConfig из YAML-конфига или None, если не настроен."""

View File

@@ -35,6 +35,7 @@ def build_grouped_report(
issue_hours: List[Tuple[Issue, float, Optional[Dict[str, float]]]],
fill_time: bool = True,
by_activity: bool = False,
status_translation: Optional[Dict[str, str]] = None,
) -> List[ReportRow]:
"""
Преобразует список задач с затраченным временем в плоский список строк отчёта,
@@ -42,6 +43,11 @@ def build_grouped_report(
Предусловие: issue_hours должен быть отсортирован по (project, version).
Функция выполняет сортировку самостоятельно для защиты от несортированного ввода.
status_translation: перевод статусов; None — встроенный STATUS_TRANSLATION.
Переданный словарь ЗАМЕНЯЕТ встроенный полностью (без merge): статус,
отсутствующий в нём, выводится как есть (passthrough); пустой dict —
все статусы выводятся как есть.
"""
# Защитная сортировка -- гарантирует корректную группировку независимо от порядка на входе
@@ -49,6 +55,10 @@ def build_grouped_report(
issue_hours, key=lambda x: (str(x[0].project), get_version(x[0]), x[0].id)
)
translation = (
status_translation if status_translation is not None else STATUS_TRANSLATION
)
rows: List[ReportRow] = []
prev_project: str = ""
prev_version: str = ""
@@ -58,7 +68,7 @@ def build_grouped_report(
project = str(issue.project)
version = get_version(issue)
status_en = str(issue.status)
status_ru = STATUS_TRANSLATION.get(status_en, status_en)
status_ru = translation.get(status_en, status_en)
if fill_time:
if by_activity and activities:

View File

@@ -483,6 +483,55 @@ class TestInitConfig:
assert "report" in data
assert data["report"]["no_time"] is False
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
def test_init_config_includes_status_translation_example(self, tmp_path):
"""#65: --init-config дописывает закомментированный пример status_translation.
Файл остаётся валидным YAML, активной секции status_translation нет.
"""
import yaml
config_path = tmp_path / "config.yml"
code = main(["--init-config", "--config-path", str(config_path)])
assert code == 0
text = config_path.read_text(encoding="utf-8")
assert "# status_translation:" in text
with open(config_path) as fh:
data = yaml.safe_load(fh)
assert "status_translation" not in data["report"]
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_status_translation_from_yaml_reaches_builder(mock_fetch, tmp_path):
"""#65: report.status_translation из YAML доезжает до build_grouped_report."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0, None)]
config_path = tmp_path / "config.yml"
config_path.write_text(
"report:\n"
" status_translation:\n"
' New: "Новая"\n'
' "Custom Status": "Кастом"\n'
)
with mock.patch("redmine_reporter.cli.build_grouped_report") as mock_build:
mock_build.return_value = []
code = main(
["--date", "2026-01-01--2026-01-31", "--config-path", str(config_path)]
)
assert code == 0
_, kwargs = mock_build.call_args
translation = kwargs["status_translation"]
assert translation["New"] == "Новая"
assert translation["Custom Status"] == "Кастом"
# встроенные переводы сохранились
assert translation["Closed"] == "Закрыто"
@mock.patch.dict(os.environ, {}, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")

View File

@@ -907,3 +907,169 @@ class TestReportNoTime:
Config._app = AppConfig.from_yaml(yaml_path)
assert Config.get_report_no_time() is False
# ---------------------------------------------------------------------------
# #65: report.status_translation — перевод статусов из YAML
# ---------------------------------------------------------------------------
class TestStatusTranslationFromYaml:
"""Tests for report.status_translation loading in AppConfig.from_yaml()."""
def test_status_translation_loaded_from_yaml(self):
"""report.status_translation заполняет поле AppConfig (override + новый)."""
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"report:\n"
" status_translation:\n"
' New: "Новая"\n'
' "Custom Status": "Кастом"\n'
)
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.report_status_translation == {
"New": "Новая",
"Custom Status": "Кастом",
}
def test_status_translation_missing_defaults_to_empty(self):
"""Без секции report.status_translation поле пустое."""
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("report:\n no_time: true\n")
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.report_status_translation == {}
def test_status_translation_non_mapping_warns_and_ignored(self, caplog):
"""status_translation не-mapping (строка) → warning в лог, поле {}."""
import logging
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("report:\n status_translation: 'just a string'\n")
with caplog.at_level(logging.WARNING):
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.report_status_translation == {}
assert "status_translation" in caplog.text
@mock.patch.dict(os.environ, {"STATUS_RU": "На проверке"}, clear=True)
def test_status_translation_env_var_resolved(self):
"""${VAR} в значении перевода резолвится из окружения."""
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"report:\n status_translation:\n Testing: ${STATUS_RU}\n"
)
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.report_status_translation == {"Testing": "На проверке"}
def test_status_translation_null_value_skipped_with_warning(self, caplog):
"""Значение null пропускается с warning, а не превращается в 'None'."""
import logging
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"report:\n status_translation:\n New:\n Closed: Закрыто\n"
)
with caplog.at_level(logging.WARNING):
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.report_status_translation == {"Closed": "Закрыто"}
assert "status_translation" in caplog.text
assert "New" in caplog.text
def test_status_translation_nested_dict_skipped_with_warning(self, caplog):
"""Вложенный dict в значении пропускается с warning, а не в repr."""
import logging
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text(
"report:\n"
" status_translation:\n"
" New:\n"
" nested: value\n"
" Closed: Закрыто\n"
)
with caplog.at_level(logging.WARNING):
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.report_status_translation == {"Closed": "Закрыто"}
assert "status_translation" in caplog.text
assert "New" in caplog.text
def test_status_translation_int_value_coerced_to_str(self):
"""Scalar-значения (int) по-прежнему приводятся к str."""
with tempfile.TemporaryDirectory() as tmp:
yaml_path = Path(tmp) / "config.yml"
yaml_path.write_text("report:\n status_translation:\n New: 42\n")
cfg = AppConfig.from_yaml(yaml_path)
assert cfg.report_status_translation == {"New": "42"}
class TestGetStatusTranslation:
"""Tests for Config.get_status_translation()."""
def test_no_yaml_returns_builtin(self):
"""Без YAML-конфига возвращается встроенный словарь."""
from redmine_reporter.report_builder import STATUS_TRANSLATION
Config._app = None
try:
assert Config.get_status_translation() == STATUS_TRANSLATION
finally:
Config._app = None
def test_empty_overrides_returns_builtin(self):
"""Пустые overrides — результат равен встроенному словарю."""
from redmine_reporter.report_builder import STATUS_TRANSLATION
Config._app = AppConfig()
try:
assert Config.get_status_translation() == STATUS_TRANSLATION
finally:
Config._app = None
def test_override_and_extend(self):
"""Override существующего статуса + добавление нового."""
Config._app = AppConfig(
report_status_translation={"New": "Новая", "Custom Status": "Кастом"}
)
try:
translation = Config.get_status_translation()
finally:
Config._app = None
assert translation["New"] == "Новая"
assert translation["Custom Status"] == "Кастом"
# остальные встроенные переводы не тронуты
assert translation["Closed"] == "Закрыто"
def test_result_is_a_copy(self):
"""Мутация результата не трогает модульный STATUS_TRANSLATION."""
from redmine_reporter.report_builder import STATUS_TRANSLATION
Config._app = AppConfig(report_status_translation={"New": "Новая"})
try:
translation = Config.get_status_translation()
translation["New"] = "ИЗМЕНЕНО"
translation["Junk"] = "мусор"
finally:
Config._app = None
assert STATUS_TRANSLATION["New"] == "В работе"
assert "Junk" not in STATUS_TRANSLATION

View File

@@ -277,3 +277,27 @@ def test_group_rows_preserves_row_data():
def test_group_rows_empty():
"""Пустой список — пустой словарь."""
assert group_rows_by_project_and_version([]) == {}
# -- #65: кастомный перевод статусов --
def test_build_grouped_report_custom_status_translation():
"""status_translation переопределяет встроенный словарь (#65)."""
issue = MockIssue("P", "S", "New", "v1.0", 1)
rows = build_grouped_report([(issue, 1.0)], status_translation={"New": "Новая"})
assert rows[0]["status_ru"] == "Новая"
def test_build_grouped_report_custom_status_translation_passthrough():
"""Статус вне кастомного словаря возвращается как есть (#65)."""
issue = MockIssue("P", "S", "Closed", "v1.0", 1)
rows = build_grouped_report([(issue, 1.0)], status_translation={"New": "Новая"})
assert rows[0]["status_ru"] == "Closed"
def test_build_grouped_report_none_translation_uses_builtin():
"""status_translation=None — используется встроенный словарь (#65)."""
issue = MockIssue("P", "S", "Closed", "v1.0", 1)
rows = build_grouped_report([(issue, 1.0)], status_translation=None)
assert rows[0]["status_ru"] == "Закрыто"