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: 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": if output_ext == ".odt":
print( print(
"❌ odfpy is not installed. Install with: pip install odfpy", "❌ odfpy is not installed. Install with: pip install odfpy",
file=sys.stderr, file=sys.stderr,
) )
else: 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 return 1
try:
formatter.save(rows, args.output)
print(f"✅ Report saved to {args.output}")
except Exception as e: except Exception as e:
fmt = output_ext.lstrip(".").upper() fmt = output_ext.lstrip(".").upper()
print(f"{fmt} export error: {e}", file=sys.stderr) 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 .csv import CSVFormatter
from .html import HTMLFormatter from .html import HTMLFormatter
from .markdown import MarkdownFormatter from .markdown import MarkdownFormatter
from .odt import ODTFormatter
# Словарь для сопоставления расширений файлов с классами форматтеров # Словарь для сопоставления расширений файлов с классами форматтеров.
# ODT намеренно отсутствует — его импорт отложен (ленивый), так как odfpy
# может быть не установлен. См. get_formatter_by_extension.
FORMATTER_MAP: Dict[str, Type[Formatter]] = { FORMATTER_MAP: Dict[str, Type[Formatter]] = {
".odt": ODTFormatter,
".csv": CSVFormatter, ".csv": CSVFormatter,
".md": MarkdownFormatter, ".md": MarkdownFormatter,
".html": HTMLFormatter, ".html": HTMLFormatter,
@@ -27,10 +27,23 @@ def get_formatter_by_extension(extension: str, **kwargs) -> Optional[Formatter]:
""" """
Возвращает экземпляр форматтера по расширению файла. Возвращает экземпляр форматтера по расширению файла.
Ключевые аргументы (**kwargs) передаются в конструктор форматтера. Ключевые аргументы (**kwargs) передаются в конструктор форматтера.
Возвращает None для .odt, если odfpy не установлен.
""" """
formatter_class = FORMATTER_MAP.get(extension.lower()) ext = extension.lower()
formatter_class = FORMATTER_MAP.get(ext)
if formatter_class: if formatter_class:
return formatter_class(**kwargs) return formatter_class(**kwargs)
# ODT требует odfpy — ленивый импорт, чтобы отсутствие зависимости
# не ломало загрузку модуля и другие форматтеры.
if ext == ".odt":
try:
from .odt import ODTFormatter
except ImportError:
return None
return ODTFormatter(**kwargs)
return None return None

View File

@@ -94,3 +94,17 @@ def test_cli_output_without_extension(mock_fetch, tmp_path):
output = str(tmp_path / "report") output = str(tmp_path / "report")
code = main(["--date", "2026-01-01--2026-01-31", "--output", output]) code = main(["--date", "2026-01-01--2026-01-31", "--output", output])
assert code == 1 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") 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 = [ TEXT_FORMATTER_FACTORIES = [