Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@

## [Unreleased]

### 바꿈

- `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개로 준다. 저장은 여전히 처음 저장할 때 패키지 검증 도구를 읽는다.

### 고침

- 쪽 수 추정(실험, `estimate_pages`)이 글과 함께 움직이는 어울림(SQUARE) 그림·도형이 본문 바닥을 넘을 때, 줄 캐시가
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/module-ownership.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion scripts/check_product_boundary.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
"5e9d06aac1d5341bf610d07b9863d5bd618551acd84537764cb3dfd4376116aa"
)
LAZY_GETATTR_SHA256 = (
"f40a6478d266d6fabb89fc67504166abd9c195605f068d408eb29cd901dbba73"
"dfcb0934faf21e2e987041f8df51a14cd3521a78c49e22a4e8835724b2c6f01e"
)
IGNORED_EMPTY_AST_FIELDS = frozenset({"type_params"})
LAZY_EXPORT_MAP_COUNT = 26
Expand Down
118 changes: 84 additions & 34 deletions src/hwpx/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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). 계약 유동.
Expand Down Expand Up @@ -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__":
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -359,35 +435,9 @@ 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 HwpxError
from .hwp5.errors import Hwp5ConversionWarning, Hwp5Error
from .errors import Hwp5ConversionWarning, Hwp5Error, HwpxError
from .mutation_report import (
MutationReport,
PreservationDowngradeError,
Expand Down
2 changes: 1 addition & 1 deletion src/hwpx/document.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -64,6 +63,7 @@
logger = logging.getLogger(__name__)

if TYPE_CHECKING:
from .hwp5.errors import Hwp5ConversionReport
from .tools.validator import ValidationReport


Expand Down
28 changes: 28 additions & 0 deletions src/hwpx/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ``<domain>-<condition>``
#: 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
Expand Down Expand Up @@ -379,6 +405,8 @@ class HwpxStateError(HwpxError, RuntimeError):
"ERROR_CODES",
"GRANDFATHERED_CODES",
"ERROR_CODE_DOMAINS",
"Hwp5ConversionWarning",
"Hwp5Error",
"HwpxError",
"HwpxLookupError",
"HwpxStateError",
Expand Down
34 changes: 12 additions & 22 deletions src/hwpx/hwp5/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion tests/data/import_breadth.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"schemaVersion": "python-hwpx.import-breadth/v1",
"maxImportedModules": 117
"maxImportedModules": 97
}
87 changes: 87 additions & 0 deletions tests/test_lazy_hwp5_import.py
Original file line number Diff line number Diff line change
@@ -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"
Loading
Loading