Skip to content

Commit ce7ef08

Browse files
DanMeonclaude
andcommitted
refactor: v0.2.0 binding 구조 — Python wrapper class + IR 합성 Python 이전 + aparse async
- Rust `_Document` thin core + Python `Document` wrapper (`__slots__` + `_from_rust` factory). 외부 API 동일. - IR 합성 (HTML/role/inline run 폴백) Rust → Python 이전. `src/ir.rs` 527→254 줄. `#[derive(IntoPyObject)]` 로 PyDict 자동 생성. - TypedDict raw payload (`python/rhwp/ir/_raw_types.py`) — nested 구조에서 BaseModel 대비 약 2.5× 빠른 internal raw record. - `Document.from_bytes(data, source_uri=None)` Rust classmethod — bytes 기반 생성, GIL 해제. - `rhwp.aparse(path)` async — `aiofiles` + `from_bytes` 조합. `[async]` optional extras (`aiofiles>=23`). - `HwpLoader.aload`/`alazy_load` override — `rhwp.aparse` 기반. - CLAUDE.md async direction 섹션 갱신 — `unsendable` + `to_thread` panic 사실 반영. - 테스트 191 → 204 (test_async / test_from_bytes / test_ir_mapper 신규). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 75d0f68 commit ce7ef08

17 files changed

Lines changed: 1256 additions & 576 deletions

CHANGELOG.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,17 @@ MINOR release — Phase 2 착수. RAG / LLM 파이프라인이 직접 소비하
3030
- 문서: `docs/roadmap/v0.2.0/ir.md` (사양), `docs/design/v0.2.0/ir-design-research.md` (7개 결정 증거), `docs/implementation/v0.2.0/stages/stage-{1..5}.md`.
3131
- 테스트: **165 passed** — IR schema/roundtrip/tables/iter/export + LangChain ir-blocks + Rust unit tests (`cargo test` 5 passed).
3232

33+
### Added — Binding 구조 개선 (Python wrapper class + async 진입점)
34+
35+
`#[pyclass(unsendable)]` 제약 안에서 가능한 최선의 binding 구조와 async 진입점을 정착. 사용자-대면 API 는 전부 보존 — 가산만 있음 (breaking 없음).
36+
37+
- **Python wrapper class 패턴**: Rust `_Document``#[pyclass(name = "_Document", module = "rhwp._rhwp", unsendable)]` thin core 로, Python `rhwp.Document``__slots__ = ("_inner",)` + `_from_rust` factory 로 thin core 를 감싸는 wrapper. 모든 메서드는 pass-through.
38+
- `Document.from_bytes(data, *, source_uri=None) -> Document` — bytes 기반 생성 classmethod (Rust `_Document::from_bytes` + `py.detach` 로 GIL 해제). 네트워크 fetch / in-memory archive / `aparse` 내부 경로용.
39+
- `rhwp.aparse(path) -> Document` async 함수 — `aiofiles` 로 파일 I/O 만 async 처리, 파싱은 event-loop 스레드에서 sync (`Document.from_bytes`). `unsendable` 제약 상 `asyncio.to_thread(parse, path)` 가 panic 하므로 이 경로가 유일하게 안전한 async 진입점.
40+
- `[async]` optional extras 추가 — `aiofiles>=23`. 미설치 시 `aparse` 호출 시점에 명시적 `ImportError` (silent fallback 없음).
41+
- `HwpLoader.aload` / `alazy_load` async override — `rhwp.aparse` 위에 구축. 공통 yield 로직 `_yield_documents` 헬퍼로 sync/async 공유.
42+
- `python/rhwp/_rhwp.pyi` 신규 — Rust extension (`_Document`, `version`, `rhwp_core_version`) 의 Python 측 타입 stub.
43+
3344
### Changed — Phase 2 계획 전환
3445

3546
- 원안의 CLI 도구 (`rhwp` 바이너리) 는 **폐기**. 업스트림 `edwardkim/rhwp` 의 Rust 바이너리가 같은 이름을 점유하므로 충돌 방지 + Python 고유 가치 (RAG / LangChain 통합) 에 집중. 상세: `docs/roadmap/v0.2.0/ir.md` §방향 전환 배경.
@@ -41,6 +52,21 @@ MINOR release — Phase 2 착수. RAG / LLM 파이프라인이 직접 소비하
4152
- Python **3.9 지원 드랍**`requires-python = ">=3.10"`, `pyo3` feature 를 `abi3-py39``abi3-py310` 으로 전환, CI 매트릭스에서 `3.9` 제거. Python 3.9 는 2025-10-31 EOL 이후 보안 패치가 중단된 상태 (> 6 개월 경과). 기존 공개 API 는 전부 호환 — 3.9 사용자는 PyPI 의 `rhwp-python 0.1.x` 를 계속 사용 가능.
4253
- `rhwp.ir.schema.load_schema()``Traversable.joinpath()` 호출을 chain 패턴 (`joinpath(a).joinpath(b)`) 으로 정리 — `*descendants` 가변 인자 시그니처가 표준 라이브러리에 도입된 시점이 버전별로 달라 typeshed 기준 pyright 가 py3.9/3.10/3.11 에서 `reportCallIssue` 를 내는 문제 제거.
4354

55+
### Changed — IR 매핑 구조 (Rust → Python 이전)
56+
57+
IR 합성 (HTML 직렬화 / cell role 분류 / inline run 폴백) 을 Rust 에서 Python 으로 이전. IR 진화 시 `maturin rebuild` 회피 + Python-only 기여자 진입장벽 제거. 외부 API (`Document.to_ir()`, `to_ir_json()`, `iter_blocks` 등) 모두 동일, 캐시 identity (`doc.to_ir() is doc.to_ir()`) 유지.
58+
59+
- `src/ir.rs` 527 → 254 줄. raw 평탄화 + UTF-16↔codepoint 변환만 담당. `#[derive(IntoPyObject)]` struct 5개 (`RawDocument` / `RawParagraph` / `RawTable` / `RawCell` / `RawCharRun`) 로 PyDict 자동 생성.
60+
- `python/rhwp/ir/_mapper.py` 신규 — raw dict → `HwpDocument` 합성. Rust 에 있던 `escape_html` / `cell_role` / `table_to_html` / `build_inline_runs` 폴백 로직 전부 이전.
61+
- `python/rhwp/ir/_raw_types.py` 신규 — Rust struct 미러 `TypedDict` 5개. nested 구조에서 `BaseModel` 대비 약 2.5× 빠른 internal raw record (공식 벤치마크 기준).
62+
- `tests/test_ir_mapper.py` 신규 — Rust 에서 사라진 `#[cfg(test)]` 단위 테스트 (escape 순서, cell role 3갈래, inline run 폴백 정책) 의 Python 측 보존.
63+
- `tests/test_from_bytes.py` 신규 — bytes 기반 생성 검증.
64+
- `tests/test_async.py` 신규 — `aparse` + `aiofiles` 경로 검증 + 미설치 시 `ImportError` 검증.
65+
66+
### Documentation
67+
68+
- `CLAUDE.md` async direction 섹션 갱신 — `asyncio.to_thread(rhwp.parse, path)``unsendable` 제약 상 panic 함을 실험으로 확인. forbidden vs supported async 패턴, `aparse` + `aiofiles` 권장 경로, 향후 upstream `RefCell` 변경 시 재검토 가능성 안내.
69+
4470
### Deferred to v0.3.0+
4571

4672
- `PictureBlock` / `FormulaBlock` / `FootnoteBlock` / `ListItemBlock` / `CaptionBlock` / `TocEntryBlock` / `FieldBlock` — 현재는 미지 `kind``UnknownBlock` 폴백.

CLAUDE.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,16 @@ All rules from `~/.claude/CLAUDE.md` apply. This file adds only project-specific
3939

4040
### Rust + Python hybrid build
4141
- After any Rust change (`src/*.rs`): `uv run maturin develop --release` before `pytest`. Without it, tests run against the stale binary
42-
- PyO3 `#[pyclass(unsendable)]`: `Document` is single-thread bound — cross-thread access raises `RuntimeError`. Worker pattern: `parse + consume` inside the worker, return primitives
43-
- GIL release via `py.detach` in `parse()` / `render_pdf()` / `export_pdf()` — keep this pattern when adding new CPU/IO-bound methods
42+
- PyO3 `#[pyclass(unsendable)]`: `_Document` is bound to its creation thread (upstream `DocumentCore` holds `RefCell` fields — `!Sync`). Same-thread worker pattern (`parse + consume + return primitives` inside one thread) works; `asyncio.to_thread(rhwp.parse, path)` does NOT — the Future resolves on the main thread and first attribute access panics with `_rhwp::document::PyDocument is unsendable, but sent to another thread`
43+
- GIL release via `py.detach` in `_Document::from_bytes` / `render_pdf()` / `export_pdf()` — keep this pattern when adding new CPU/IO-bound methods
4444
- `abi3-py310` feature: **one wheel covers 3.10–3.13+**. Don't bind to Python version-specific C API
4545

4646
### Async direction
4747
- Python-surface APIs for I/O and integrations are **async-first**: when adding LangChain / LlamaIndex / Haystack loaders, implement `aload` / `alazy_load` / async counterparts alongside sync versions
48-
- Rust core is sync; bindings may release the GIL so `asyncio.to_thread(rhwp.parse, path)` works. Document this pattern in user-facing docs rather than wrapping in Python
48+
- **Forbidden pattern**: `asyncio.to_thread(rhwp.parse, path)``_Document` is unsendable (see Rust+Python hybrid build note above), the returned Document panics on main-thread access. `async fn` in `#[pymethods]` is also incompatible (PyO3 requires `Send + 'static` futures)
49+
- **Supported async pattern**: `aparse(path)` uses `aiofiles.open()` for the file read on the event-loop thread, then calls `Document.from_bytes(data)` on the same thread. Document never crosses a thread boundary. Optional dep: `pip install rhwp[async]` — missing `aiofiles` raises `ImportError` (no silent fallback)
50+
- **Document instance-level async methods (`doc.ato_ir()` etc.) are NOT provided** — they would require thread offload which unsendable forbids. For async code, `await rhwp.aparse(path)` once, then call sync methods on the Document directly (these are fast, in-memory, GIL-holding operations)
51+
- If upstream rhwp ever replaces its `RefCell` caches with thread-safe synchronization, revisit this — `unsendable` could then be dropped, enabling true `async fn pymethods`
4952

5053
### Tests
5154
- Real HWP fixtures live in the submodule: `external/rhwp/samples/aift.hwp` (HWP5), `table-vpos-01.hwpx` (HWPX). `tests/conftest.py` + `benches/bench_gil.py` reference this path

pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ Upstream = "https://github.com/edwardkim/rhwp"
4747

4848
[project.optional-dependencies]
4949
langchain = ["langchain-core>=0.2"]
50+
# ^ aparse() 가 aiofiles.open 으로 파일 I/O 를 async 처리하기 위해 필요.
51+
# 미설치 시 aparse 호출 시점에 ImportError — sync 경로 (parse) 는 무관
52+
async = ["aiofiles>=23"]
5053
# ^ examples 는 01~03 예제 스크립트 일괄 실행용 우산 extras — typer + langchain-core + text-splitters 합집합
5154
examples = [
5255
"typer>=0.12",
@@ -64,6 +67,8 @@ testing = [
6467
"langchain-text-splitters>=0.2",
6568
# ^ JSON Schema meta-validation (tests/test_ir_schema_export.py)
6669
"jsonschema>=4",
70+
# ^ aparse() 경로 검증 — aiofiles.open + Document.from_bytes 조합
71+
"aiofiles>=23",
6772
]
6873
linting = [
6974
{include-group = "dev"},

python/rhwp/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
"""rhwp — HWP/HWPX parser and renderer (Korean word processor format)."""
22

3-
from ._rhwp import Document, parse, rhwp_core_version, version
3+
from rhwp._rhwp import rhwp_core_version, version
4+
from rhwp.document import Document, aparse, parse
45

56
__all__ = [
67
"Document",
8+
"aparse",
79
"parse",
810
"rhwp_core_version",
911
"version",

python/rhwp/__init__.pyi

Lines changed: 4 additions & 138 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
"""rhwp — HWP/HWPX parser and renderer (Korean word processor format)."""
22

3-
from rhwp.ir.nodes import HwpDocument as _HwpDocument
3+
from rhwp.document import Document as Document
4+
from rhwp.document import aparse as aparse
5+
from rhwp.document import parse as parse
46

57
__all__ = [
68
"Document",
9+
"aparse",
710
"parse",
811
"rhwp_core_version",
912
"version",
@@ -16,140 +19,3 @@ def version() -> str:
1619
def rhwp_core_version() -> str:
1720
"""rhwp Rust 코어 버전 (예: "0.7.3")."""
1821
...
19-
20-
def parse(path: str) -> Document:
21-
"""HWP5 또는 HWPX 파일을 파싱하여 Document 반환.
22-
23-
Args:
24-
path: HWP 또는 HWPX 파일 경로.
25-
26-
Returns:
27-
파싱된 Document.
28-
29-
Raises:
30-
FileNotFoundError: 파일이 존재하지 않을 때.
31-
PermissionError: 파일 접근 권한이 없을 때.
32-
OSError: 그 외 I/O 오류.
33-
ValueError: 파일 포맷이 유효하지 않을 때.
34-
"""
35-
...
36-
37-
class Document:
38-
"""파싱된 HWP/HWPX 문서.
39-
40-
직접 생성자를 호출하거나 :func:`parse` 를 사용할 수 있다.
41-
"""
42-
43-
source_uri: str | None
44-
"""생성자에 전달된 원본 경로. IR 의 ``source.uri`` 와 동일 값 — IR 을 생성하지 않고도 출처 조회 가능."""
45-
46-
section_count: int
47-
"""섹션 수."""
48-
49-
paragraph_count: int
50-
"""전체 섹션에 걸친 총 문단 수."""
51-
52-
page_count: int
53-
"""페이지네이션 후 총 페이지 수."""
54-
55-
def __init__(self, path: str) -> None:
56-
"""HWP/HWPX 파일 경로로부터 파싱.
57-
58-
Raises:
59-
FileNotFoundError: 파일이 존재하지 않을 때.
60-
PermissionError: 파일 접근 권한이 없을 때.
61-
OSError: 그 외 I/O 오류.
62-
ValueError: 파일 포맷이 유효하지 않을 때.
63-
"""
64-
...
65-
66-
def extract_text(self) -> str:
67-
"""전체 문서의 텍스트를 개행으로 연결해 반환 (빈 문단 제외)."""
68-
...
69-
70-
def paragraphs(self) -> list[str]:
71-
"""모든 문단의 텍스트 리스트 (빈 문단 포함, len == paragraph_count)."""
72-
...
73-
74-
def render_svg(self, page: int) -> str:
75-
"""특정 페이지를 SVG 문자열로 렌더링.
76-
77-
Args:
78-
page: 0-based 페이지 인덱스.
79-
80-
Raises:
81-
ValueError: 페이지 인덱스가 범위를 벗어났거나 렌더링 실패 시.
82-
"""
83-
...
84-
85-
def render_all_svg(self) -> list[str]:
86-
"""모든 페이지를 SVG 문자열 리스트로 렌더링 (len == page_count).
87-
88-
Raises:
89-
ValueError: 렌더링 실패.
90-
"""
91-
...
92-
93-
def export_svg(self, output_dir: str, prefix: str | None = None) -> list[str]:
94-
"""모든 페이지를 SVG 파일로 저장.
95-
96-
Args:
97-
output_dir: 출력 디렉토리 (자동 생성).
98-
prefix: 파일명 접두사 (기본 "page"). 다중 페이지 시 `{prefix}_{NNN}.svg`,
99-
단일 페이지 시 `{prefix}.svg`.
100-
101-
Returns:
102-
생성된 파일 경로 리스트.
103-
104-
Raises:
105-
OSError: 디렉토리 생성 또는 파일 쓰기 실패.
106-
ValueError: 렌더링 실패.
107-
"""
108-
...
109-
110-
def render_pdf(self) -> bytes:
111-
"""전체 문서를 PDF 바이트로 렌더링 (usvg + pdf-writer 경로).
112-
113-
Raises:
114-
ValueError: SVG 렌더링 또는 PDF 변환 실패.
115-
"""
116-
...
117-
118-
def export_pdf(self, output_path: str) -> int:
119-
"""문서를 PDF 파일로 저장.
120-
121-
Returns:
122-
저장된 바이트 수.
123-
124-
Raises:
125-
OSError: 파일 쓰기 실패.
126-
ValueError: 렌더링 실패.
127-
"""
128-
...
129-
130-
def to_ir(self) -> _HwpDocument:
131-
"""문서를 Document IR (Pydantic ``HwpDocument``) 로 변환.
132-
133-
첫 호출 시 문서 트리를 순회하며 IR 을 구성한다 (10MB HWP 기준 50-200ms).
134-
결과는 인스턴스 내부에 캐시되어 재호출은 즉시 반환된다. IR 모델은
135-
``frozen=True`` 이므로 반환 객체 수정 시 ``ValidationError`` 가 발생한다.
136-
독립 사본이 필요하면 ``ir.model_copy(deep=True)`` 를 사용한다.
137-
138-
Raises:
139-
pydantic.ValidationError: 내부 구조가 스키마와 불일치할 때 (상류 버그 시).
140-
ImportError: ``rhwp.ir.nodes`` 모듈 로드 실패 시.
141-
"""
142-
...
143-
144-
def to_ir_json(self, *, indent: int | None = None) -> str:
145-
"""IR 을 JSON 문자열로 반환. ``to_ir()`` 캐시를 공유한다.
146-
147-
Args:
148-
indent: 들여쓰기 칸 수 (None 이면 한 줄 직렬화).
149-
150-
Raises:
151-
pydantic.ValidationError: IR 변환 중 스키마 불일치가 발생할 때.
152-
"""
153-
...
154-
155-
def __repr__(self) -> str: ...

python/rhwp/_rhwp.pyi

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""rhwp._rhwp — PyO3 Rust 확장의 타입 스텁.
2+
3+
``_Document`` 는 Rust thin core 로, 직접 사용하지 않는다. 사용자-대면 API 는
4+
:class:`rhwp.Document` (Python wrapper) 와 :func:`rhwp.parse` 이다.
5+
``rhwp.document`` 가 본 타입을 ``_inner`` 로 감싸고 메서드를 위임한다.
6+
"""
7+
8+
from rhwp.ir.nodes import HwpDocument
9+
10+
__all__ = [
11+
"_Document",
12+
"rhwp_core_version",
13+
"version",
14+
]
15+
16+
def version() -> str:
17+
"""rhwp Python 패키지 버전."""
18+
...
19+
20+
def rhwp_core_version() -> str:
21+
"""rhwp Rust 코어 버전."""
22+
...
23+
24+
class _Document:
25+
"""Rust thin core — Python wrapper 전용. 사용자는 :class:`rhwp.Document` 사용."""
26+
27+
source_uri: str | None
28+
section_count: int
29+
paragraph_count: int
30+
page_count: int
31+
32+
def __init__(self, path: str) -> None: ...
33+
@classmethod
34+
def from_bytes(cls, data: bytes, *, source_uri: str | None = None) -> _Document: ...
35+
def extract_text(self) -> str: ...
36+
def paragraphs(self) -> list[str]: ...
37+
def render_svg(self, page: int) -> str: ...
38+
def render_all_svg(self) -> list[str]: ...
39+
def export_svg(self, output_dir: str, prefix: str | None = None) -> list[str]: ...
40+
def render_pdf(self) -> bytes: ...
41+
def export_pdf(self, output_path: str) -> int: ...
42+
def to_ir(self) -> HwpDocument: ...
43+
def to_ir_json(self, *, indent: int | None = None) -> str: ...
44+
def __repr__(self) -> str: ...

0 commit comments

Comments
 (0)