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:
@@ -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)
|
||||||
|
|||||||
0
redmine_reporter/formatters/__init__.py
Normal file
0
redmine_reporter/formatters/__init__.py
Normal 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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
Reference in New Issue
Block a user