diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e02a972..e414e993 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ 않는다. `hwpx.TextExtractor`·`hwpx.doc_diff`·`hwpx.validate_package`처럼 `hwpx.tools`에 사는 이름은 처음 쓸 때 경고 없이 읽고, 같은 객체다. 위 `hwpx.hwp5`와 합쳐 `import hwpx`가 읽는 hwpx 모듈은 117개에서 97개로 준다. 저장은 여전히 처음 저장할 때 패키지 검증 도구를 읽는다. +- `hwpx.tools`도 공개 이름과 하위 모듈을 처음 쓸 때 읽는다. 첫 저장은 패키지·문서 검증기만 + 읽고, 내보내기·뷰어·문서 비교·메일 머지 등은 읽지 않는다. 기존 import·객체·타입 정보와 + `doc_diff` 함수 이름을 유지한다. HWPX Markdown 변환기의 배포판 버전 조회도 변환 시점으로 + 미뤄, `import hwpx`가 `importlib.metadata`를 읽지 않는다(#386). ### 고침 diff --git a/src/hwpx/ingest/hwpx_converter.py b/src/hwpx/ingest/hwpx_converter.py index 98bd6e10..dbb64bc3 100644 --- a/src/hwpx/ingest/hwpx_converter.py +++ b/src/hwpx/ingest/hwpx_converter.py @@ -3,7 +3,6 @@ from __future__ import annotations -from importlib.metadata import PackageNotFoundError, version as package_version from typing import Any, BinaryIO from zipfile import BadZipFile, ZipFile @@ -132,6 +131,8 @@ def _tables_payload(doc: HwpxDocument) -> list[dict[str, Any]]: def _python_hwpx_version() -> str: + from importlib.metadata import PackageNotFoundError, version as package_version + try: return package_version("python-hwpx") except PackageNotFoundError: diff --git a/src/hwpx/tools/__init__.py b/src/hwpx/tools/__init__.py index cb2b1ea2..71c9a399 100644 --- a/src/hwpx/tools/__init__.py +++ b/src/hwpx/tools/__init__.py @@ -1,78 +1,85 @@ # SPDX-License-Identifier: Apache-2.0 -"""Tooling helpers for inspecting HWPX archives.""" - -from .exporter import ( - export_html, - export_markdown, - export_text, -) -from .layout_preview import ( - LayoutPreview, - PreviewPage, - render_layout_preview, -) -from .document_viewer import ( - DocumentViewer, - FIDELITY_BADGE, - render_document_viewer, -) -from .object_finder import FoundElement, ObjectFinder -from .doc_diff import ( - DOC_DIFF_REPORT_VERSION, - REFERENCE_CONSISTENCY_REPORT_VERSION, - diff_paragraphs, - doc_diff, - inspect_reference_consistency, -) -from .mail_merge import ( - MAIL_MERGE_REPORT_VERSION, - inspect_mail_merge_placeholders, - load_mail_merge_rows, - merge_template_rows, -) -from .package_validator import ( - EDITOR_OPEN_ADVISORY_ERROR_MARKERS, - EditorOpenSafetyReport, - PackageValidationIssue, - PackageValidationReport, - is_editor_open_blocking_issue, - validate_editor_open_safety, - validate_package, -) -from .page_guard import ( - DocumentMetrics, - collect_metrics, - compare_metrics, -) -from .text_extractor import ( - DEFAULT_NAMESPACES, - ParagraphInfo, - SectionInfo, - TextExtractor, - build_parent_map, - describe_element_path, - strip_namespace, -) -from .table_navigation import ( - TableCellReference, - TableFillApplied, - TableFillFailed, - TableFillResult, - TableLabelMatch, - TableLabelSearchResult, - TableMapEntry, - TableMapResult, - fill_by_path, - find_cell_by_label, - get_table_map, -) -from .validator import ( - DocumentSchemas, - ValidationIssue, - ValidationReport, - load_default_schemas, - validate_document, -) +"""Tooling helpers, loaded on first use rather than on the first save.""" + +from __future__ import annotations + +import sys as _sys +from types import ModuleType as _ModuleType +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .exporter import ( + export_html, + export_markdown, + export_text, + ) + from .layout_preview import ( + LayoutPreview, + PreviewPage, + render_layout_preview, + ) + from .document_viewer import ( + DocumentViewer, + FIDELITY_BADGE, + render_document_viewer, + ) + from .object_finder import FoundElement, ObjectFinder + from .doc_diff import ( + DOC_DIFF_REPORT_VERSION, + REFERENCE_CONSISTENCY_REPORT_VERSION, + diff_paragraphs, + doc_diff, + inspect_reference_consistency, + ) + from .mail_merge import ( + MAIL_MERGE_REPORT_VERSION, + inspect_mail_merge_placeholders, + load_mail_merge_rows, + merge_template_rows, + ) + from .package_validator import ( + EDITOR_OPEN_ADVISORY_ERROR_MARKERS, + EditorOpenSafetyReport, + PackageValidationIssue, + PackageValidationReport, + is_editor_open_blocking_issue, + validate_editor_open_safety, + validate_package, + ) + from .page_guard import ( + DocumentMetrics, + collect_metrics, + compare_metrics, + ) + from .text_extractor import ( + DEFAULT_NAMESPACES, + ParagraphInfo, + SectionInfo, + TextExtractor, + build_parent_map, + describe_element_path, + strip_namespace, + ) + from .table_navigation import ( + TableCellReference, + TableFillApplied, + TableFillFailed, + TableFillResult, + TableLabelMatch, + TableLabelSearchResult, + TableMapEntry, + TableMapResult, + fill_by_path, + find_cell_by_label, + get_table_map, + ) + from .validator import ( + DocumentSchemas, + ValidationIssue, + ValidationReport, + load_default_schemas, + validate_document, + ) __all__ = [ "inspect_reference_consistency", @@ -129,3 +136,142 @@ "FIDELITY_BADGE", "render_document_viewer", ] + + +_EXPORTS_BY_MODULE = { + "exporter": ( + "export_html", + "export_markdown", + "export_text", + ), + "layout_preview": ( + "LayoutPreview", + "PreviewPage", + "render_layout_preview", + ), + "document_viewer": ( + "DocumentViewer", + "FIDELITY_BADGE", + "render_document_viewer", + ), + "object_finder": ( + "FoundElement", + "ObjectFinder", + ), + "doc_diff": ( + "DOC_DIFF_REPORT_VERSION", + "REFERENCE_CONSISTENCY_REPORT_VERSION", + "diff_paragraphs", + "doc_diff", + "inspect_reference_consistency", + ), + "mail_merge": ( + "MAIL_MERGE_REPORT_VERSION", + "inspect_mail_merge_placeholders", + "load_mail_merge_rows", + "merge_template_rows", + ), + "package_validator": ( + "EDITOR_OPEN_ADVISORY_ERROR_MARKERS", + "EditorOpenSafetyReport", + "PackageValidationIssue", + "PackageValidationReport", + "is_editor_open_blocking_issue", + "validate_editor_open_safety", + "validate_package", + ), + "page_guard": ( + "DocumentMetrics", + "collect_metrics", + "compare_metrics", + ), + "text_extractor": ( + "DEFAULT_NAMESPACES", + "ParagraphInfo", + "SectionInfo", + "TextExtractor", + "build_parent_map", + "describe_element_path", + "strip_namespace", + ), + "table_navigation": ( + "TableCellReference", + "TableFillApplied", + "TableFillFailed", + "TableFillResult", + "TableLabelMatch", + "TableLabelSearchResult", + "TableMapEntry", + "TableMapResult", + "fill_by_path", + "find_cell_by_label", + "get_table_map", + ), + "validator": ( + "DocumentSchemas", + "ValidationIssue", + "ValidationReport", + "load_default_schemas", + "validate_document", + ), +} +_EXPORT_OWNER = { + name: module for module, names in _EXPORTS_BY_MODULE.items() for name in names +} + + +def __getattr__(name: str) -> object: + """Resolve public exports and the historically reachable submodules.""" + owner = _EXPORT_OWNER.get(name, name) + if owner == "exporter": + import hwpx.tools.exporter as module + elif owner == "layout_preview": + import hwpx.tools.layout_preview as module + elif owner == "document_viewer": + import hwpx.tools.document_viewer as module + elif owner == "object_finder": + import hwpx.tools.object_finder as module + elif owner == "doc_diff": + from .doc_diff import doc_diff as value + + globals()["doc_diff"] = value + module = _sys.modules[f"{__name__}.doc_diff"] + elif owner == "mail_merge": + import hwpx.tools.mail_merge as module + elif owner == "package_validator": + import hwpx.tools.package_validator as module + elif owner == "page_guard": + import hwpx.tools.page_guard as module + elif owner == "text_extractor": + import hwpx.tools.text_extractor as module + elif owner == "table_navigation": + import hwpx.tools.table_navigation as module + elif owner == "validator": + import hwpx.tools.validator as module + else: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + value = getattr(module, name) if name in _EXPORT_OWNER else module + globals()[name] = value + return value + + +def __dir__() -> list[str]: + return sorted(set(globals()) | set(__all__) | set(_EXPORTS_BY_MODULE)) + + +class _ToolsModule(_ModuleType): + def __setattr__(self, name: str, value: object) -> None: + # Python assigns a loaded child to its parent. Here the public doc_diff + # function has always occupied that name. Preserve it even when someone + # imports the child directly before any package export is resolved. + if ( + name == "doc_diff" + and isinstance(value, _ModuleType) + and value.__name__ == f"{__name__}.doc_diff" + ): + value = value.doc_diff + super().__setattr__(name, value) + + +_sys.modules[__name__].__class__ = _ToolsModule diff --git a/tests/test_lazy_tools_submodules.py b/tests/test_lazy_tools_submodules.py new file mode 100644 index 00000000..db603938 --- /dev/null +++ b/tests/test_lazy_tools_submodules.py @@ -0,0 +1,149 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Cold-process import costs and compatibility of the tools package.""" + +from __future__ import annotations + +from pathlib import Path +import subprocess +import sys +import textwrap + +import pytest + + +def _run(code: str) -> None: + src = str(Path(__file__).resolve().parents[1] / "src") + result = subprocess.run( + [ + sys.executable, + "-I", + "-W", + "error", + "-c", + f"import sys; sys.path.insert(0, {src!r})\n" + textwrap.dedent(code), + ], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + + +def test_tools_package_alone_loads_no_submodule() -> None: + _run(""" + import hwpx.tools + assert not any(n.startswith('hwpx.tools.') for n in sys.modules) + assert set(hwpx.tools.__all__) <= set(dir(hwpx.tools)) + assert not any(n.startswith('hwpx.tools.') for n in sys.modules) + """) + + +def test_first_save_loads_only_validators_and_still_reopens() -> None: + _run(""" + import hwpx + doc = hwpx.HwpxDocument.new() + doc.add_paragraph('First save still validates and round-trips.') + data = doc.to_bytes() + assert sorted(n for n in sys.modules if n.startswith('hwpx.tools.')) == [ + 'hwpx.tools.package_validator', 'hwpx.tools.validator' + ] + reopened = hwpx.HwpxDocument.open(data) + assert any(p.text == 'First save still validates and round-trips.' + for p in reopened.paragraphs) + """) + + +def test_plain_import_defers_metadata_but_version_lookup_still_works() -> None: + _run(""" + import hwpx + from hwpx.ingest.hwpx_converter import _python_hwpx_version + assert 'importlib.metadata' not in sys.modules + actual = _python_hwpx_version() + from importlib.metadata import PackageNotFoundError, version + try: + expected = version('python-hwpx') + except PackageNotFoundError: + expected = '0+unknown' + assert actual == expected + assert hwpx.__version__ == expected + """) + + +def test_tool_export_only_loads_its_owner() -> None: + _run(""" + import hwpx + from hwpx.tools import TextExtractor + from hwpx.tools.text_extractor import TextExtractor as direct + assert TextExtractor is direct is hwpx.TextExtractor + assert sorted(n for n in sys.modules if n.startswith('hwpx.tools.')) == [ + 'hwpx.tools.text_extractor' + ] + """) + + +def test_missing_distribution_keeps_unknown_version_fallback() -> None: + _run(""" + from unittest.mock import patch + from importlib.metadata import PackageNotFoundError + from hwpx.ingest.hwpx_converter import _python_hwpx_version + with patch('importlib.metadata.version', side_effect=PackageNotFoundError): + assert _python_hwpx_version() == '0+unknown' + """) + + +def test_all_exports_star_import_and_unknown_attribute() -> None: + _run(""" + import hwpx.tools as tools + names = list(tools.__all__) + namespace = {} + exec('from hwpx.tools import *', namespace) + assert set(names) <= set(namespace) & set(dir(tools)) + for name in names: + value = getattr(tools, name) + assert value is namespace[name] + assert vars(tools)[name] is value + assert not hasattr(tools, 'not_a_public_tool') + """) + + +@pytest.mark.parametrize( + "first", + [ + "from hwpx.tools import doc_diff", + "from hwpx.tools.doc_diff import diff_paragraphs", + "from hwpx.tools import diff_paragraphs", + "from hwpx.tools import DOC_DIFF_REPORT_VERSION", + ], +) +def test_doc_diff_function_survives_submodule_import_order(first: str) -> None: + _run( + first + + "\n" + + """ +import hwpx +from hwpx.tools import doc_diff as package_export +from hwpx.tools.doc_diff import doc_diff as direct +assert callable(package_export) +assert package_export is direct is hwpx.doc_diff +""" + ) + + +def test_existing_submodule_attribute_access() -> None: + _run(""" + import hwpx + assert hwpx.tools.validator.validate_document.__name__ == 'validate_document' + assert 'hwpx.tools.exporter' not in sys.modules + assert hwpx.tools.exporter.export_text.__name__ == 'export_text' + assert 'hwpx.tools.document_viewer' not in sys.modules + """) + + +def test_other_diff_exports_still_resolve_when_function_is_replaced() -> None: + _run(""" + import importlib + module = importlib.import_module('hwpx.tools.doc_diff') + module.doc_diff = lambda *args: {} + from hwpx.tools import diff_paragraphs + assert diff_paragraphs is module.diff_paragraphs + """)