diff --git a/redmine_reporter/cli.py b/redmine_reporter/cli.py index 0a3f6be..4e09f4c 100644 --- a/redmine_reporter/cli.py +++ b/redmine_reporter/cli.py @@ -107,25 +107,23 @@ def main(argv: Optional[List[str]] = None) -> int: ) if not formatter: - known_exts = ", ".join([".odt", ".csv", ".md", ".html"]) - print( - f"❌ Неизвестный формат файла: {output_ext!r}. Поддерживаются: {known_exts}", - file=sys.stderr, - ) - return 1 - - try: - formatter.save(rows, args.output) - print(f"✅ Report saved to {args.output}") - except ImportError as e: if output_ext == ".odt": print( "❌ odfpy is not installed. Install with: pip install odfpy", file=sys.stderr, ) else: - print(f"❌ Import error: {e}", file=sys.stderr) + known_exts = ", ".join([".odt", ".csv", ".md", ".html"]) + print( + f"❌ Неизвестный формат файла: {output_ext!r}. " + f"Поддерживаются: {known_exts}", + file=sys.stderr, + ) return 1 + + try: + formatter.save(rows, args.output) + print(f"✅ Report saved to {args.output}") except Exception as e: fmt = output_ext.lstrip(".").upper() print(f"❌ {fmt} export error: {e}", file=sys.stderr) diff --git a/redmine_reporter/formatters/__init__.py b/redmine_reporter/formatters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/redmine_reporter/formatters/factory.py b/redmine_reporter/formatters/factory.py index 3d2abd1..beee23a 100644 --- a/redmine_reporter/formatters/factory.py +++ b/redmine_reporter/formatters/factory.py @@ -5,11 +5,11 @@ from .console import CompactFormatter, TableFormatter from .csv import CSVFormatter from .html import HTMLFormatter from .markdown import MarkdownFormatter -from .odt import ODTFormatter -# Словарь для сопоставления расширений файлов с классами форматтеров +# Словарь для сопоставления расширений файлов с классами форматтеров. +# ODT намеренно отсутствует — его импорт отложен (ленивый), так как odfpy +# может быть не установлен. См. get_formatter_by_extension. FORMATTER_MAP: Dict[str, Type[Formatter]] = { - ".odt": ODTFormatter, ".csv": CSVFormatter, ".md": MarkdownFormatter, ".html": HTMLFormatter, @@ -27,10 +27,23 @@ def get_formatter_by_extension(extension: str, **kwargs) -> Optional[Formatter]: """ Возвращает экземпляр форматтера по расширению файла. Ключевые аргументы (**kwargs) передаются в конструктор форматтера. + Возвращает None для .odt, если odfpy не установлен. """ - formatter_class = FORMATTER_MAP.get(extension.lower()) + ext = extension.lower() + + formatter_class = FORMATTER_MAP.get(ext) if formatter_class: return formatter_class(**kwargs) + + # ODT требует odfpy — ленивый импорт, чтобы отсутствие зависимости + # не ломало загрузку модуля и другие форматтеры. + if ext == ".odt": + try: + from .odt import ODTFormatter + except ImportError: + return None + return ODTFormatter(**kwargs) + return None diff --git a/tests/test_cli.py b/tests/test_cli.py index faad999..5c67d7a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -94,3 +94,17 @@ def test_cli_output_without_extension(mock_fetch, tmp_path): output = str(tmp_path / "report") code = main(["--date", "2026-01-01--2026-01-31", "--output", output]) assert code == 1 + + +@mock.patch.dict(os.environ, VALID_ENV, clear=True) +@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") +@mock.patch("redmine_reporter.cli.get_formatter_by_extension") +def test_cli_odt_missing_odfpy_message(mock_gf, mock_fetch, tmp_path, capsys): + """При запросе .odt без odfpy — выход 1, понятное сообщение про odfpy.""" + mock_fetch.return_value = [] + mock_gf.return_value = None + output = str(tmp_path / "report.odt") + code = main(["--date", "2026-01-01--2026-01-31", "--output", output]) + assert code == 1 + captured = capsys.readouterr() + assert "odfpy" in captured.err diff --git a/tests/test_formatters.py b/tests/test_formatters.py index d1e8902..127edbe 100644 --- a/tests/test_formatters.py +++ b/tests/test_formatters.py @@ -128,6 +128,97 @@ def odt_formatter(): yield ODTFormatter(author="Тест Автор", from_date="2026-01-01", to_date="2026-01-31") +# -- Тесты упаковки formatters как полноценного пакета -- + + +def test_formatters_is_regular_package(): + """redmine_reporter.formatters — полноценный пакет с __init__.py, не namespace.""" + import redmine_reporter.formatters + + assert hasattr(redmine_reporter.formatters, "__file__") + assert redmine_reporter.formatters.__file__.endswith("__init__.py") + + +def test_formatters_found_by_setuptools(): + """setuptools.find_packages находит formatters как полноценный пакет.""" + import os + + from setuptools import find_packages + + project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + found = find_packages(where=project_root, include=["redmine_reporter*"]) + assert "redmine_reporter.formatters" in found + + +# -- Тесты ленивого импорта ODT (#25) -- + + +def _simulate_missing_odfpy(): + """Вспомогательная функция: подготавливает очистку кэша модулей odf и odt. + + Возвращает словарь сохранённых модулей для последующего восстановления. + """ + import sys + + saved = {} + for key in list(sys.modules.keys()): + if key == "odf" or key.startswith("odf.") or key == "redmine_reporter.formatters.odt": + saved[key] = sys.modules.pop(key) + return saved + + +def _restore_modules(saved): + import sys + + sys.modules.update(saved) + + +def test_get_formatter_odt_returns_none_without_odfpy(): + """Без odfpy — get_formatter_by_extension('.odt') возвращает None, не падает.""" + import builtins + + from redmine_reporter.formatters.factory import get_formatter_by_extension + + real_import = builtins.__import__ + + def blocking_import(name, *args, **kwargs): + if name == "odf" or name.startswith("odf."): + raise ImportError(f"No module named '{name}'") + return real_import(name, *args, **kwargs) + + saved = _simulate_missing_odfpy() + try: + with mock.patch("builtins.__import__", side_effect=blocking_import): + result = get_formatter_by_extension( + ".odt", author="test", from_date="2026-01-01", to_date="2026-01-31" + ) + assert result is None + finally: + _restore_modules(saved) + + +def test_get_formatter_csv_works_without_odfpy(): + """Без odfpy — get_formatter_by_extension('.csv') работает нормально.""" + import builtins + + from redmine_reporter.formatters.factory import get_formatter_by_extension + + real_import = builtins.__import__ + + def blocking_import(name, *args, **kwargs): + if name == "odf" or name.startswith("odf."): + raise ImportError(f"No module named '{name}'") + return real_import(name, *args, **kwargs) + + saved = _simulate_missing_odfpy() + try: + with mock.patch("builtins.__import__", side_effect=blocking_import): + result = get_formatter_by_extension(".csv") + assert result is not None + finally: + _restore_modules(saved) + + # -- Параметризованные тесты текстовых форматтеров -- TEXT_FORMATTER_FACTORIES = [