diff --git a/README.md b/README.md index 8d0c169..2c38cf8 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ table.set_cell_text(1, 1, str(len(document.paragraphs))) document.add_memo_with_anchor("배포 전 검토", paragraph=paragraph, memo_shape_id_ref="0") # 3) 다른 이름으로 저장 -document.save("output/example.hwpx") +document.save_to_path("output/example.hwpx") ``` `HwpxDocument.add_table()`은 문서에 정의된 테두리 채우기가 없으면 헤더 참조 목록에 "기본 실선" `borderFill`을 만들어 표와 모든 셀에 참조를 연결합니다. @@ -67,6 +67,17 @@ document.save("output/example.hwpx") 더 많은 실전 패턴은 [빠른 시작](docs/quickstart.md)과 [사용 가이드](docs/usage.md)의 "빠른 예제 모음"에서 확인할 수 있습니다. +### 저장 API 변경 안내 + +`HwpxDocument`는 저장 사용 케이스를 다음처럼 분리해 제공합니다. + +- `save_to_path(path) -> str | PathLike[str]`: 지정한 경로로 저장하고 같은 경로를 반환 +- `save_to_stream(stream) -> BinaryIO`: 파일/버퍼 스트림에 저장하고 같은 스트림을 반환 +- `to_bytes() -> bytes`: 메모리에서 직렬화한 바이트를 반환 + +기존 `save()`는 하위 호환을 위해 유지되지만 deprecated 경고를 발생시킵니다. 새 코드에서는 위 3개 메서드 사용을 권장합니다. + + ## 문서 [사용법](https://airmang.github.io/python-hwpx/) diff --git a/docs/api_reference.md b/docs/api_reference.md index 3e09e4c..c8208cd 100644 --- a/docs/api_reference.md +++ b/docs/api_reference.md @@ -129,8 +129,14 @@ #### 영속성(Persistence) -- `save(path_or_stream=None)` - - 변경된(dirty) XML 파트를 내부 패키지를 통해 직렬화하고, 변경 사항을 원본 소스, 새 경로 또는 파일과 유사한 객체에 씁니다. +- `save_to_path(path) -> str | PathLike[str]` + - 변경된 XML 파트를 지정한 경로에 저장하고 입력 경로를 그대로 반환합니다. +- `save_to_stream(stream) -> BinaryIO` + - 변경된 XML 파트를 파일과 유사한 바이너리 스트림에 저장하고 입력 스트림을 그대로 반환합니다. +- `to_bytes() -> bytes` + - 변경된 XML 파트를 직렬화한 HWPX ZIP 바이트를 반환합니다. +- `save(path_or_stream=None) -> str | PathLike[str] | BinaryIO | bytes` + - 하위 호환용 래퍼입니다. 내부에서 `save_to_path()`/`save_to_stream()`/`to_bytes()`를 호출하며 `DeprecationWarning`을 발생시킵니다. *** diff --git a/src/hwpx/document.py b/src/hwpx/document.py index 18d666f..3182b35 100644 --- a/src/hwpx/document.py +++ b/src/hwpx/document.py @@ -3,12 +3,13 @@ from __future__ import annotations import io +import warnings from datetime import datetime import logging import uuid from os import PathLike -from typing import Any, BinaryIO, Iterator +from typing import Any, BinaryIO, Iterator, overload from lxml import etree @@ -822,12 +823,62 @@ def remove_footer( target_section = self._root.sections[-1] target_section.properties.remove_footer(page_type=page_type) + def save_to_path(self, path: str | PathLike[str]) -> str | PathLike[str]: + """Persist pending changes to *path* and return the same path.""" + + updates = self._root.serialize() + result = self._package.save(path, updates) + self._root.reset_dirty() + return path if result is None else result + + def save_to_stream(self, stream: BinaryIO) -> BinaryIO: + """Persist pending changes to *stream* and return the same stream.""" + + updates = self._root.serialize() + result = self._package.save(stream, updates) + self._root.reset_dirty() + return stream if result is None else result + + def to_bytes(self) -> bytes: + """Serialize pending changes and return the HWPX archive as bytes.""" + + updates = self._root.serialize() + result = self._package.save(None, updates) + self._root.reset_dirty() + if isinstance(result, bytes): + return result + raise TypeError("package.save(None) must return bytes") + + @overload + def save(self, path_or_stream: None = None) -> bytes: ... + + @overload + def save(self, path_or_stream: str | PathLike[str]) -> str | PathLike[str]: ... + + @overload + def save(self, path_or_stream: BinaryIO) -> BinaryIO: ... + def save( self, path_or_stream: str | PathLike[str] | BinaryIO | None = None, - ) -> str | PathLike[str] | BinaryIO | bytes | None: - """Persist pending changes to *path_or_stream* or the original source.""" - updates = self._root.serialize() - result = self._package.save(path_or_stream, updates) - self._root.reset_dirty() - return result + ) -> str | PathLike[str] | BinaryIO | bytes: + """Deprecated compatibility wrapper around save_to_path/save_to_stream/to_bytes. + + Deprecated: + ``save()``는 하위 호환을 위해 유지되며 향후 제거될 수 있습니다. + - 경로 저장: ``save_to_path(path)`` + - 스트림 저장: ``save_to_stream(stream)`` + - 바이트 반환: ``to_bytes()`` + """ + + warnings.warn( + "HwpxDocument.save()는 deprecated 예정입니다. " + "save_to_path()/save_to_stream()/to_bytes() 사용을 권장합니다.", + DeprecationWarning, + stacklevel=2, + ) + if path_or_stream is None: + return self.to_bytes() + if isinstance(path_or_stream, (str, PathLike)): + return self.save_to_path(path_or_stream) + return self.save_to_stream(path_or_stream) diff --git a/tests/test_document_save_api.py b/tests/test_document_save_api.py new file mode 100644 index 0000000..a604231 --- /dev/null +++ b/tests/test_document_save_api.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from io import BytesIO +from pathlib import Path + +import pytest + +from hwpx.document import HwpxDocument + + +def test_to_bytes_returns_hwpx_bytes() -> None: + document = HwpxDocument.new() + + output = document.to_bytes() + + assert isinstance(output, bytes) + assert output.startswith(b"PK") + + +def test_save_to_path_returns_same_path(tmp_path: Path) -> None: + document = HwpxDocument.new() + output_path = tmp_path / "saved.hwpx" + + result = document.save_to_path(output_path) + + assert result == output_path + assert output_path.exists() + + +def test_save_to_stream_returns_same_stream() -> None: + document = HwpxDocument.new() + stream = BytesIO() + + result = document.save_to_stream(stream) + + assert result is stream + stream.seek(0) + assert stream.read(2) == b"PK" + + +def test_save_deprecated_wrapper_warns_and_routes_to_new_methods(tmp_path: Path) -> None: + document = HwpxDocument.new() + + with pytest.deprecated_call(match="deprecated"): + path_result = document.save(tmp_path / "wrapped-path.hwpx") + assert path_result == tmp_path / "wrapped-path.hwpx" + + with pytest.deprecated_call(match="deprecated"): + bytes_result = document.save() + assert isinstance(bytes_result, bytes) + + stream = BytesIO() + with pytest.deprecated_call(match="deprecated"): + stream_result = document.save(stream) + assert stream_result is stream