From 2fd7765942475d5d1d6eda6cc01f238c94cfa6c5 Mon Sep 17 00:00:00 2001 From: airmang <38392618+airmang@users.noreply.github.com> Date: Sat, 3 Oct 2026 00:57:28 +0900 Subject: [PATCH 1/3] perf(import): stop loading hwpx.hwp5 on import hwpx import hwpx loaded hwpx.hwp5 and hwpx.hwp5.errors only to export Hwp5Error and Hwp5ConversionWarning. Both now live in hwpx.errors, and hwpx.hwp5.errors re-exports the same classes, so identity and except hwpx.Hwp5Error are unchanged. HwpxDocument imports Hwp5ConversionReport for type checking only; the converter already loaded on first .hwp read or write. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 6 ++ docs/architecture/module-ownership.json | 2 +- src/hwpx/__init__.py | 3 +- src/hwpx/document.py | 2 +- src/hwpx/errors.py | 28 ++++++++ src/hwpx/hwp5/errors.py | 34 ++++------ tests/test_lazy_hwp5_import.py | 87 +++++++++++++++++++++++++ 7 files changed, 136 insertions(+), 26 deletions(-) create mode 100644 tests/test_lazy_hwp5_import.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a49e3740..cff51991 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ ## [Unreleased] +### 바꿈 + +- `import hwpx`가 HWP 5.0 모듈(`hwpx.hwp5`)을 읽지 않는다. `.hwp`를 열거나 HWP 5.0으로 저장할 때 + 처음 읽는다. `Hwp5Error`·`Hwp5ConversionWarning`은 이제 `hwpx.errors`에 있고, `hwpx`와 + `hwpx.hwp5.errors`는 같은 클래스를 내보낸다. + ### 고침 - 쪽 수 추정(실험, `estimate_pages`)이 글과 함께 움직이는 어울림(SQUARE) 그림·도형이 본문 바닥을 넘을 때, 줄 캐시가 diff --git a/docs/architecture/module-ownership.json b/docs/architecture/module-ownership.json index 9f8ce451..98475a17 100644 --- a/docs/architecture/module-ownership.json +++ b/docs/architecture/module-ownership.json @@ -607,7 +607,7 @@ "path": "src/hwpx/hwp5/errors.py", "disposition": "core", "approvedBy": "owner-hwp5-core-2026-09-23", - "rationale": "Hwp5Error, the typed error with hwp5-* codes for HWP 5.0 input that is protected (password, distribution, DRM), damaged, not HWP 5.0 or beyond a parsing limit, and for content the HWP 5.0 writer cannot express." + "rationale": "Builders for Hwp5Error, the typed error with hwp5-* codes for HWP 5.0 input that is protected (password, distribution, DRM), damaged, not HWP 5.0 or beyond a parsing limit, and for content the HWP 5.0 writer cannot express, plus Hwp5ConversionReport. Hwp5Error and Hwp5ConversionWarning are defined in hwpx.errors so that importing hwpx does not load this package, and re-exported here." }, { "path": "src/hwpx/hwp5/fileheader.py", diff --git a/src/hwpx/__init__.py b/src/hwpx/__init__.py index 17a481b5..461260bd 100644 --- a/src/hwpx/__init__.py +++ b/src/hwpx/__init__.py @@ -386,8 +386,7 @@ def __dir__() -> list[str]: validate_package, ) from .ingest import HwpxMarkdownConverter -from .errors import HwpxError -from .hwp5.errors import Hwp5ConversionWarning, Hwp5Error +from .errors import Hwp5ConversionWarning, Hwp5Error, HwpxError from .mutation_report import ( MutationReport, PreservationDowngradeError, diff --git a/src/hwpx/document.py b/src/hwpx/document.py index 9871c7c5..18bc8275 100644 --- a/src/hwpx/document.py +++ b/src/hwpx/document.py @@ -37,7 +37,6 @@ from ._document import media as _media from ._document import persistence as _persistence from ._document.persistence import SaveFormat -from .hwp5.errors import Hwp5ConversionReport from ._document import _resolve from ._document import headings as _headings from ._document import layout as _layout @@ -64,6 +63,7 @@ logger = logging.getLogger(__name__) if TYPE_CHECKING: + from .hwp5.errors import Hwp5ConversionReport from .tools.validator import ValidationReport diff --git a/src/hwpx/errors.py b/src/hwpx/errors.py index 07b98f79..be00a160 100644 --- a/src/hwpx/errors.py +++ b/src/hwpx/errors.py @@ -111,6 +111,32 @@ class HwpxStateError(HwpxError, RuntimeError): default_code = "hwpx-state-error" +# Hwp5Error and Hwp5ConversionWarning live here rather than in hwpx.hwp5.errors +# (which re-exports them) so that ``import hwpx`` exposes them without loading the +# HWP 5.0 package; that package loads only when a .hwp is read or written. + + +class Hwp5Error(HwpxError, ValueError): + """An HWP 5.0 document cannot be read or written. + + ``code`` names the reason: ``hwp5-damaged`` (the container or its records + are broken), ``hwp5-password``, ``hwp5-distribution`` and ``hwp5-drm`` (the + body is encrypted), ``hwp5-not-hwp5`` (a compound file without an HWP 5.0 + file header), ``hwp5-version-unsupported``, ``hwp5-limit-exceeded`` (the + input exceeds a parsing limit) and ``hwp5-write-unsupported`` (the document + holds content the HWP 5.0 writer cannot express). + """ + + default_code = "hwp5-damaged" + + +class Hwp5ConversionWarning(UserWarning): + """Opening an ``.hwp`` left content out of the document model. + + The message names each kind that was not converted and how often. + """ + + #: The kebab-case ``HwpxError.code`` vocabulary. Codes are ``-`` #: where the domain names a surface area (the 6.0 namespaces plus the package-level #: concerns). This is deliberately **not** unified with the SCREAMING_SNAKE codes in @@ -379,6 +405,8 @@ class HwpxStateError(HwpxError, RuntimeError): "ERROR_CODES", "GRANDFATHERED_CODES", "ERROR_CODE_DOMAINS", + "Hwp5ConversionWarning", + "Hwp5Error", "HwpxError", "HwpxLookupError", "HwpxStateError", diff --git a/src/hwpx/hwp5/errors.py b/src/hwpx/hwp5/errors.py index a90897af..7408b62c 100644 --- a/src/hwpx/hwp5/errors.py +++ b/src/hwpx/hwp5/errors.py @@ -7,28 +7,18 @@ from dataclasses import dataclass from types import MappingProxyType -from ..errors import HwpxError - - -class Hwp5Error(HwpxError, ValueError): - """An HWP 5.0 document cannot be read or written. - - ``code`` names the reason: ``hwp5-damaged`` (the container or its records - are broken), ``hwp5-password``, ``hwp5-distribution`` and ``hwp5-drm`` (the - body is encrypted), ``hwp5-not-hwp5`` (a compound file without an HWP 5.0 - file header), ``hwp5-version-unsupported``, ``hwp5-limit-exceeded`` (the - input exceeds a parsing limit) and ``hwp5-write-unsupported`` (the document - holds content the HWP 5.0 writer cannot express). - """ - - default_code = "hwp5-damaged" - - -class Hwp5ConversionWarning(UserWarning): - """Opening an ``.hwp`` left content out of the document model. - - The message names each kind that was not converted and how often. - """ +# Defined in hwpx.errors so that ``import hwpx`` does not load this package; +# re-exported here as the same objects. +from ..errors import Hwp5ConversionWarning, Hwp5Error + +__all__ = [ + "Hwp5ConversionReport", + "Hwp5ConversionWarning", + "Hwp5Error", + "damaged", + "limit_exceeded", + "write_unsupported", +] @dataclass(frozen=True) diff --git a/tests/test_lazy_hwp5_import.py b/tests/test_lazy_hwp5_import.py new file mode 100644 index 00000000..56599f56 --- /dev/null +++ b/tests/test_lazy_hwp5_import.py @@ -0,0 +1,87 @@ +# SPDX-License-Identifier: Apache-2.0 +"""``import hwpx`` does not load the HWP 5.0 package. + +Reading and writing ``.hwp`` loads ``hwpx.hwp5`` on first use. The public names +``hwpx.Hwp5Error`` and ``hwpx.Hwp5ConversionWarning`` are the same objects +``hwpx.hwp5.errors`` exports, and catching them needs no HWP 5.0 code. +""" + +from __future__ import annotations + +import subprocess +import sys +import textwrap + + +def _run(code: str) -> str: + result = subprocess.run( + [sys.executable, "-c", textwrap.dedent(code)], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + return result.stdout.strip() + + +_HWP5_LOADED = "sorted(k for k in sys.modules if k == 'hwpx.hwp5' or k.startswith('hwpx.hwp5.'))" + + +def test_import_hwpx_loads_no_hwp5_module() -> None: + assert _run(f"import sys, hwpx; print({_HWP5_LOADED})") == "[]" + + +def test_importing_the_document_class_loads_no_hwp5_module() -> None: + assert _run(f"import sys; from hwpx import HwpxDocument; print({_HWP5_LOADED})") == "[]" + + +def test_catching_hwp5_error_needs_no_hwp5_module() -> None: + out = _run( + f""" + import sys + import hwpx + try: + raise hwpx.Hwp5Error("x") + except hwpx.Hwp5Error as error: + print(error.code, {_HWP5_LOADED}) + """ + ) + assert out == "hwp5-damaged []" + + +def test_hwp5_names_keep_their_identity() -> None: + import hwpx + from hwpx.hwp5 import errors + + assert hwpx.Hwp5Error is errors.Hwp5Error + assert hwpx.Hwp5ConversionWarning is errors.Hwp5ConversionWarning + assert issubclass(hwpx.Hwp5Error, hwpx.HwpxError) + + +def test_hwp5_loads_when_a_document_is_written_and_opened_as_hwp() -> None: + out = _run( + """ + import sys + import hwpx + document = hwpx.HwpxDocument.new() + document.add_paragraph("한글 5.0") + payload = document.to_bytes(format="hwp") + loaded_after_save = "hwpx.hwp5.writer" in sys.modules + reopened = hwpx.HwpxDocument.open(payload) + print(loaded_after_save, "한글 5.0" in reopened.text.plain()) + """ + ) + assert out == "True True" + + +def test_opening_a_damaged_compound_file_raises_hwp5_error() -> None: + out = _run( + """ + import hwpx + try: + hwpx.HwpxDocument.open(b"\\xd0\\xcf\\x11\\xe0\\xa1\\xb1\\x1a\\xe1" + bytes(600)) + except hwpx.Hwp5Error as error: + print(error.code) + """ + ) + assert out == "hwp5-damaged" From c8808d22fac11db4399723df15ac0f774dd02c9e Mon Sep 17 00:00:00 2001 From: airmang <38392618+airmang@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:17:37 +0900 Subject: [PATCH 2/3] perf(import): load hwpx.tools on first use import hwpx imported all twelve hwpx.tools modules (exporter, viewers, diff, mail merge, validators) to bind 19 stable names. Those names now resolve through the module __getattr__ on first access, with no warning, as the same objects, and are cached in the module globals; dir(hwpx), from hwpx import * and hwpx.tools keep working. A TYPE_CHECKING import keeps their static types. The lookup uses a static import, so the pinned importlib call sites and export maps are unchanged; only the __getattr__ fingerprint is re-pinned in scripts/check_product_boundary.py. import hwpx: 115 -> 97 hwpx modules. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 4 ++ scripts/check_product_boundary.py | 2 +- src/hwpx/__init__.py | 115 +++++++++++++++++++++--------- tests/test_lazy_tools_import.py | 91 +++++++++++++++++++++++ 4 files changed, 179 insertions(+), 33 deletions(-) create mode 100644 tests/test_lazy_tools_import.py diff --git a/CHANGELOG.md b/CHANGELOG.md index cff51991..c1f6e834 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ - `import hwpx`가 HWP 5.0 모듈(`hwpx.hwp5`)을 읽지 않는다. `.hwp`를 열거나 HWP 5.0으로 저장할 때 처음 읽는다. `Hwp5Error`·`Hwp5ConversionWarning`은 이제 `hwpx.errors`에 있고, `hwpx`와 `hwpx.hwp5.errors`는 같은 클래스를 내보낸다. +- `import hwpx`가 `hwpx.tools`(텍스트 추출·문서 비교·메일 머지·패키지 검증·내보내기 등)를 읽지 + 않는다. `hwpx.TextExtractor`·`hwpx.doc_diff`·`hwpx.validate_package`처럼 `hwpx.tools`에 사는 + 이름은 처음 쓸 때 경고 없이 읽고, 같은 객체다. 위 `hwpx.hwp5`와 합쳐 `import hwpx`가 읽는 + hwpx 모듈은 117개에서 97개로 준다. 저장은 여전히 처음 저장할 때 패키지 검증 도구를 읽는다. ### 고침 diff --git a/scripts/check_product_boundary.py b/scripts/check_product_boundary.py index e42672c0..7eaf8ffc 100644 --- a/scripts/check_product_boundary.py +++ b/scripts/check_product_boundary.py @@ -55,7 +55,7 @@ "5e9d06aac1d5341bf610d07b9863d5bd618551acd84537764cb3dfd4376116aa" ) LAZY_GETATTR_SHA256 = ( - "f40a6478d266d6fabb89fc67504166abd9c195605f068d408eb29cd901dbba73" + "dfcb0934faf21e2e987041f8df51a14cd3521a78c49e22a4e8835724b2c6f01e" ) IGNORED_EMPTY_AST_FIELDS = frozenset({"type_params"}) LAZY_EXPORT_MAP_COUNT = 26 diff --git a/src/hwpx/__init__.py b/src/hwpx/__init__.py index 461260bd..011fc9d1 100644 --- a/src/hwpx/__init__.py +++ b/src/hwpx/__init__.py @@ -14,7 +14,7 @@ import importlib import warnings from dataclasses import dataclass -from typing import Literal +from typing import TYPE_CHECKING, Literal def _resolve_version() -> str: @@ -28,9 +28,9 @@ def _resolve_version() -> str: # --- experimental / deprecated 최상위 표면 (지연 접근, 접근 시 경고) --------------- # -# stable 이름은 아래에서 eager import 되어 모듈 전역에 존재하므로 ``__getattr__``이 -# 호출되지 않습니다(=경고 없음). 여기 등록된 이름만 지연 해석되어 경고를 냅니다. -# 4.0.0에서 제거되는 이름은 0개 — 모두 계속 import 가능합니다. +# stable 이름은 경고 없이 해석됩니다. 대부분 아래에서 eager import 되고, ``hwpx.tools``의 +# 이름은 ``_TOOLS_STABLE_EXPORTS``가 지연 해석합니다. 여기 등록된 이름만 지연 해석되어 +# 경고를 냅니다. 4.0.0에서 제거되는 이름은 0개 — 모두 계속 import 가능합니다. _EXPERIMENTAL_EXPORTS = { # 문서 ingestion 프레임워크(임의 포맷 -> HWPX). 계약 유동. @@ -293,8 +293,8 @@ class RetiredSurface(ImportError): def __getattr__(name: str) -> object: """Resolve dynamic module attributes. - ``__version__``은 경고 없이 지연 해석하고, experimental/deprecated 이름은 - 해석 시 ``DeprecationWarning``을 냅니다. + ``__version__``과 ``hwpx.tools``의 stable 이름은 경고 없이 지연 해석하고, + experimental/deprecated 이름은 해석 시 ``DeprecationWarning``을 냅니다. """ if name == "__version__": @@ -318,6 +318,17 @@ def __getattr__(name: str) -> object: warnings.warn(_deprecated_message(name), DeprecationWarning, stacklevel=2) return getattr(importlib.import_module(module_name), name) + if name in _TOOLS_STABLE_EXPORTS or name == "tools": + # Not ``from . import tools``: that asks this module for ``tools`` first and + # re-enters this function. + import hwpx.tools as tools + + if name == "tools": + return tools + value = getattr(tools, name) + globals()[name] = value + return value + moved = _MOVED_TO_COMPANION.get(name) if moved is not None: statement = moved.import_statement(name) @@ -346,11 +357,76 @@ def __dir__() -> list[str]: return sorted( set(globals()) | set(__all__) + | {"tools"} | set(_EXPERIMENTAL_EXPORTS) | set(_DEPRECATED_EXPORTS) ) +# --- 지연 stable 최상위 표면 (첫 접근 때 해석, 경고 없음) --------------------------- +# +# ``hwpx.tools``에 사는 stable 이름은 첫 접근 때 ``__getattr__``이 그 모듈을 읽어 +# 같은 객체를 돌려주고 모듈 전역에 둡니다(경고 없음). ``import hwpx``가 도구 모듈 +# 전부(내보내기·비교·검증 등)를 읽지 않게 하려는 것입니다. ``__all__``·``dir()``· +# ``from hwpx import *``에는 그대로 있습니다. + +#: ``hwpx.tools``의 하위 모듈이 정의하고 ``hwpx.tools``가 다시 내보내는 stable 이름(텍스트 추출·개체 +#: 찾기·문서 비교·메일 머지·패키지 검증). 정적 import로 해석하므로 동적 import 지점은 +#: 늘지 않는다. +_TOOLS_STABLE_EXPORTS = frozenset( + { + "DEFAULT_NAMESPACES", + "ParagraphInfo", + "SectionInfo", + "TextExtractor", + "FoundElement", + "ObjectFinder", + "DOC_DIFF_REPORT_VERSION", + "REFERENCE_CONSISTENCY_REPORT_VERSION", + "diff_paragraphs", + "doc_diff", + "inspect_reference_consistency", + "MAIL_MERGE_REPORT_VERSION", + "inspect_mail_merge_placeholders", + "load_mail_merge_rows", + "merge_template_rows", + "EditorOpenSafetyReport", + "PackageValidationReport", + "validate_editor_open_safety", + "validate_package", + } +) + + +if TYPE_CHECKING: # type checkers see the real objects; at runtime they load lazily + from .tools.doc_diff import ( + DOC_DIFF_REPORT_VERSION, + REFERENCE_CONSISTENCY_REPORT_VERSION, + diff_paragraphs, + doc_diff, + inspect_reference_consistency, + ) + from .tools.mail_merge import ( + MAIL_MERGE_REPORT_VERSION, + inspect_mail_merge_placeholders, + load_mail_merge_rows, + merge_template_rows, + ) + from .tools.object_finder import FoundElement, ObjectFinder + from .tools.package_validator import ( + EditorOpenSafetyReport, + PackageValidationReport, + validate_editor_open_safety, + validate_package, + ) + from .tools.text_extractor import ( + DEFAULT_NAMESPACES, + ParagraphInfo, + SectionInfo, + TextExtractor, + ) + + # ``import hwpx.builder`` goes through the import system, not ``__getattr__``, # so the moved module names get their destination from a finder instead. from . import _moved_modules @@ -359,32 +435,7 @@ def __dir__() -> list[str]: # --- stable 최상위 표면 (eager import) ------------------------------------------ -from .tools.text_extractor import ( - DEFAULT_NAMESPACES, - ParagraphInfo, - SectionInfo, - TextExtractor, -) -from .tools.object_finder import FoundElement, ObjectFinder -from .tools.doc_diff import ( - DOC_DIFF_REPORT_VERSION, - REFERENCE_CONSISTENCY_REPORT_VERSION, - diff_paragraphs, - doc_diff, - inspect_reference_consistency, -) -from .tools.mail_merge import ( - MAIL_MERGE_REPORT_VERSION, - inspect_mail_merge_placeholders, - load_mail_merge_rows, - merge_template_rows, -) -from .tools.package_validator import ( - EditorOpenSafetyReport, - PackageValidationReport, - validate_editor_open_safety, - validate_package, -) +# ``hwpx.tools``의 stable 이름은 위 ``_TOOLS_STABLE_EXPORTS``가 지연 해석한다. from .ingest import HwpxMarkdownConverter from .errors import Hwp5ConversionWarning, Hwp5Error, HwpxError from .mutation_report import ( diff --git a/tests/test_lazy_tools_import.py b/tests/test_lazy_tools_import.py new file mode 100644 index 00000000..37ab2768 --- /dev/null +++ b/tests/test_lazy_tools_import.py @@ -0,0 +1,91 @@ +# SPDX-License-Identifier: Apache-2.0 +"""``import hwpx`` does not load ``hwpx.tools``. + +The stable names that live in ``hwpx.tools`` (text extraction, object finding, +document diff, mail merge, package validation) resolve on first access, with no +warning and as the same objects their modules define. ``dir(hwpx)`` and +``from hwpx import *`` still list them all. +""" + +from __future__ import annotations + +import subprocess +import sys +import textwrap + + +def _run(code: str) -> str: + result = subprocess.run( + [sys.executable, "-W", "error", "-c", textwrap.dedent(code)], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + return result.stdout.strip() + + +_TOOLS_LOADED = "sorted(k for k in sys.modules if k == 'hwpx.tools' or k.startswith('hwpx.tools.'))" + + +def test_import_hwpx_loads_no_tools_module() -> None: + assert _run(f"import sys, hwpx; print({_TOOLS_LOADED})") == "[]" + + +def test_every_stable_name_resolves_without_a_warning() -> None: + out = _run( + """ + import hwpx + missing = [name for name in hwpx.__all__ if getattr(hwpx, name, None) is None] + print(len(hwpx.__all__), missing) + """ + ) + count, missing = out.split(" ", 1) + assert int(count) > 0 + assert missing == "[]" + + +def test_star_import_and_dir_list_every_stable_name() -> None: + out = _run( + """ + import hwpx + listed = set(dir(hwpx)) + namespace = {} + exec("from hwpx import *", namespace) + print(sorted(set(hwpx.__all__) - listed), sorted(set(hwpx.__all__) - set(namespace))) + """ + ) + assert out == "[] []" + + +def test_tools_names_are_the_objects_their_modules_define() -> None: + out = _run( + """ + import importlib + import hwpx + mismatched = [] + for module_name in ( + "hwpx.tools.text_extractor", + "hwpx.tools.object_finder", + "hwpx.tools.doc_diff", + "hwpx.tools.mail_merge", + "hwpx.tools.package_validator", + ): + module = importlib.import_module(module_name) + for name in hwpx.__all__: + if name in vars(module) and getattr(hwpx, name) is not vars(module)[name]: + mismatched.append(name) + print(mismatched) + """ + ) + assert out == "[]" + + +def test_tools_subpackage_is_still_reachable_as_an_attribute() -> None: + out = _run( + """ + import hwpx + print(hwpx.tools.validator.validate_document.__name__, hwpx.tools.exporter.export_text.__name__) + """ + ) + assert out == "validate_document export_text" From caec4a43be9610feac3695cc49a9eb28dc1b93f8 Mon Sep 17 00:00:00 2001 From: airmang <38392618+airmang@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:36:16 +0900 Subject: [PATCH 3/3] ci(ratchet): tighten the import-breadth bound to 97 import hwpx no longer loads hwpx.hwp5 or hwpx.tools, so it loads 97 hwpx modules instead of 117 (scripts/size_ratchet.py --lower-bound). Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/data/import_breadth.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/data/import_breadth.json b/tests/data/import_breadth.json index 5311abba..6906445d 100644 --- a/tests/data/import_breadth.json +++ b/tests/data/import_breadth.json @@ -1,4 +1,4 @@ { "schemaVersion": "python-hwpx.import-breadth/v1", - "maxImportedModules": 117 + "maxImportedModules": 97 }