Fix package structure and lazy-import ODT formatter

Add missing __init__.py to formatters/ so setuptools.find_packages
includes the subpackage in wheel/sdist builds (#16).

Move ODTFormatter import from top-level to lazy import inside
get_formatter_by_extension() so missing odfpy no longer crashes module
load before main() runs. Remove dead except ImportError handler in
cli.py save() block; surface a clear 'odfpy is not installed' message
when the formatter factory returns None for .odt (#25).

Closes #16, closes #25
This commit is contained in:
Артём Кокос
2026-06-25 20:42:13 +07:00
parent 0e4e0f3ee2
commit 3956decd4e
5 changed files with 132 additions and 16 deletions

View File

@@ -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)

View File

View File

@@ -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

View File

@@ -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

View File

@@ -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 = [