feat: initial CTStruct framework
- CTNode: 트리 기본 단위 (전위/후위/BFS 순회, 탐색, 분리) - CTTree: 트리 컨테이너 (빌더 API, TOC, filter/map/merge) - NodeType: 구조/블록/인라인/메타 노드 타입 Enum - MarkdownParser: Markdown → CTTree 파서 - MarkdownRenderer: CTTree → Markdown 렌더러 - JSONRenderer: CTTree → JSON 직렬화 - 27개 단위/통합 테스트 전체 통과
This commit is contained in:
148
README.md
Normal file
148
README.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# CTStruct
|
||||
|
||||
> **텍스트 양식을 구조화하는 Python 트리 자료형 라이브러리**
|
||||
|
||||
CTStruct는 Markdown, 일반 텍스트, JSON 등 다양한 텍스트 포맷을 **통일된 트리 자료구조(CTTree)** 로 파싱·변환·렌더링하는 Python 라이브러리입니다.
|
||||
|
||||
```
|
||||
텍스트 입력 → [Parser] → CTTree → [Renderer] → 출력 포맷
|
||||
↑
|
||||
(탐색 / 변환 / 병합)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 특징
|
||||
|
||||
- **통일된 트리 API** — 어떤 포맷이든 `CTNode` / `CTTree` 로 표현
|
||||
- **플러그인형 파서 & 렌더러** — `BaseParser` / `BaseRenderer` 상속으로 손쉽게 확장
|
||||
- **풍부한 탐색 API** — `walk()`, `find()`, `ancestors()`, `toc()` 등
|
||||
- **불변 변환** — `filter()`, `map()` 은 새 트리를 반환하여 원본 보존
|
||||
- **직렬화** — `to_dict()` / `from_dict()` 로 JSON 직렬화 지원
|
||||
- **의존성 없음** — 표준 라이브러리만 사용
|
||||
|
||||
---
|
||||
|
||||
## 빠른 시작
|
||||
|
||||
### 설치
|
||||
|
||||
```bash
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
### 기본 사용 예시
|
||||
|
||||
```python
|
||||
from ctstruct import CTTree, CTNode, NodeType
|
||||
from ctstruct.parsers import MarkdownParser
|
||||
from ctstruct.renderers import MarkdownRenderer, JSONRenderer
|
||||
|
||||
# 1) 직접 트리 빌드
|
||||
tree = CTTree(title="나의 문서")
|
||||
sec = CTNode(NodeType.SECTION)
|
||||
sec.append(CTNode(NodeType.HEADING, "소개", {"level": 1}))
|
||||
sec.append(CTNode(NodeType.PARAGRAPH, "CTStruct 는 텍스트를 트리로 구조화합니다."))
|
||||
sec.append(CTTree.make_list(["파싱", "변환", "렌더링"]))
|
||||
tree.root.append(sec)
|
||||
|
||||
# 2) Markdown 파싱
|
||||
parser = MarkdownParser()
|
||||
tree = parser.parse(open("document.md").read())
|
||||
|
||||
# 3) 탐색
|
||||
for heading in tree.find(NodeType.HEADING):
|
||||
print(heading.attr("level"), heading.content)
|
||||
|
||||
# 목차 생성
|
||||
for entry in tree.toc():
|
||||
print(" " * (entry["level"] - 1) + entry["text"])
|
||||
|
||||
# 4) 렌더링
|
||||
md_renderer = MarkdownRenderer()
|
||||
print(md_renderer.render(tree))
|
||||
|
||||
json_renderer = JSONRenderer(indent=2)
|
||||
print(json_renderer.render(tree))
|
||||
|
||||
# 5) 트리 변환 (불변)
|
||||
filtered = tree.filter(lambda n: n.node_type != NodeType.CODE_BLOCK)
|
||||
upper = tree.map(lambda n: setattr(n, "content", (n.content or "").upper()) or None)
|
||||
```
|
||||
|
||||
### 트리 구조 시각화
|
||||
|
||||
```python
|
||||
print(tree.pretty())
|
||||
# [a1b2c3d4] ROOT: None
|
||||
# [e5f6a7b8] SECTION: None
|
||||
# [c9d0e1f2] HEADING level=1: '소개'
|
||||
# [a3b4c5d6] PARAGRAPH: 'CTStruct 는 텍스트를…'
|
||||
# [e7f8a9b0] LIST style='unordered': None
|
||||
# [c1d2e3f4] LIST_ITEM: '파싱'
|
||||
# [a5b6c7d8] LIST_ITEM: '변환'
|
||||
# [e9f0a1b2] LIST_ITEM: '렌더링'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 지원 노드 타입
|
||||
|
||||
| 카테고리 | 타입 | 설명 |
|
||||
|----------|------|------|
|
||||
| 구조 | `ROOT` | 트리 최상위 |
|
||||
| 구조 | `DOCUMENT` | 문서 단위 |
|
||||
| 구조 | `SECTION` | 헤딩 + 내용 묶음 |
|
||||
| 블록 | `HEADING` | 제목 (level 1–6) |
|
||||
| 블록 | `PARAGRAPH` | 문단 |
|
||||
| 블록 | `LIST` / `LIST_ITEM` | 목록 |
|
||||
| 블록 | `TABLE` / `TABLE_ROW` / `TABLE_CELL` | 표 |
|
||||
| 블록 | `CODE_BLOCK` | 코드 블록 |
|
||||
| 블록 | `BLOCKQUOTE` | 인용구 |
|
||||
| 블록 | `DIVIDER` | 구분선 |
|
||||
| 인라인 | `TEXT` `BOLD` `ITALIC` `CODE` `LINK` `IMAGE` | 인라인 요소 |
|
||||
| 메타 | `METADATA` | 문서 메타 (frontmatter) |
|
||||
| 메타 | `ANNOTATION` | 주석/노트 |
|
||||
| 메타 | `CUSTOM` | 사용자 정의 확장 |
|
||||
|
||||
---
|
||||
|
||||
## 프로젝트 구조
|
||||
|
||||
```
|
||||
CTStruct/
|
||||
├── ctstruct/
|
||||
│ ├── __init__.py # 공개 API
|
||||
│ ├── types.py # NodeType Enum
|
||||
│ ├── node.py # CTNode 클래스
|
||||
│ ├── tree.py # CTTree 클래스
|
||||
│ ├── parsers/
|
||||
│ │ ├── base.py # BaseParser (추상)
|
||||
│ │ └── markdown.py # MarkdownParser
|
||||
│ └── renderers/
|
||||
│ ├── base.py # BaseRenderer (추상)
|
||||
│ ├── markdown.py # MarkdownRenderer
|
||||
│ └── json_renderer.py # JSONRenderer
|
||||
├── tests/
|
||||
│ ├── test_node.py
|
||||
│ ├── test_tree.py
|
||||
│ └── test_parsers.py
|
||||
├── docs/
|
||||
│ └── ARCHITECTURE.md
|
||||
└── pyproject.toml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 테스트 실행
|
||||
|
||||
```bash
|
||||
pip install -e ".[dev]"
|
||||
pytest -v --cov=ctstruct
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 라이선스
|
||||
|
||||
MIT
|
||||
166
ctstruct.egg-info/PKG-INFO
Normal file
166
ctstruct.egg-info/PKG-INFO
Normal file
@@ -0,0 +1,166 @@
|
||||
Metadata-Version: 2.4
|
||||
Name: ctstruct
|
||||
Version: 0.1.0
|
||||
Summary: 텍스트 양식을 구조화하는 트리 자료형 라이브러리
|
||||
License: MIT
|
||||
Keywords: tree,document,parser,markdown,structured-text
|
||||
Classifier: Programming Language :: Python :: 3
|
||||
Classifier: License :: OSI Approved :: MIT License
|
||||
Classifier: Operating System :: OS Independent
|
||||
Classifier: Topic :: Text Processing :: Markup
|
||||
Requires-Python: >=3.10
|
||||
Description-Content-Type: text/markdown
|
||||
Provides-Extra: dev
|
||||
Requires-Dist: pytest>=8.0; extra == "dev"
|
||||
Requires-Dist: pytest-cov; extra == "dev"
|
||||
Requires-Dist: ruff; extra == "dev"
|
||||
Requires-Dist: mypy; extra == "dev"
|
||||
|
||||
# CTStruct
|
||||
|
||||
> **텍스트 양식을 구조화하는 Python 트리 자료형 라이브러리**
|
||||
|
||||
CTStruct는 Markdown, 일반 텍스트, JSON 등 다양한 텍스트 포맷을 **통일된 트리 자료구조(CTTree)** 로 파싱·변환·렌더링하는 Python 라이브러리입니다.
|
||||
|
||||
```
|
||||
텍스트 입력 → [Parser] → CTTree → [Renderer] → 출력 포맷
|
||||
↑
|
||||
(탐색 / 변환 / 병합)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 특징
|
||||
|
||||
- **통일된 트리 API** — 어떤 포맷이든 `CTNode` / `CTTree` 로 표현
|
||||
- **플러그인형 파서 & 렌더러** — `BaseParser` / `BaseRenderer` 상속으로 손쉽게 확장
|
||||
- **풍부한 탐색 API** — `walk()`, `find()`, `ancestors()`, `toc()` 등
|
||||
- **불변 변환** — `filter()`, `map()` 은 새 트리를 반환하여 원본 보존
|
||||
- **직렬화** — `to_dict()` / `from_dict()` 로 JSON 직렬화 지원
|
||||
- **의존성 없음** — 표준 라이브러리만 사용
|
||||
|
||||
---
|
||||
|
||||
## 빠른 시작
|
||||
|
||||
### 설치
|
||||
|
||||
```bash
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
### 기본 사용 예시
|
||||
|
||||
```python
|
||||
from ctstruct import CTTree, CTNode, NodeType
|
||||
from ctstruct.parsers import MarkdownParser
|
||||
from ctstruct.renderers import MarkdownRenderer, JSONRenderer
|
||||
|
||||
# 1) 직접 트리 빌드
|
||||
tree = CTTree(title="나의 문서")
|
||||
sec = CTNode(NodeType.SECTION)
|
||||
sec.append(CTNode(NodeType.HEADING, "소개", {"level": 1}))
|
||||
sec.append(CTNode(NodeType.PARAGRAPH, "CTStruct 는 텍스트를 트리로 구조화합니다."))
|
||||
sec.append(CTTree.make_list(["파싱", "변환", "렌더링"]))
|
||||
tree.root.append(sec)
|
||||
|
||||
# 2) Markdown 파싱
|
||||
parser = MarkdownParser()
|
||||
tree = parser.parse(open("document.md").read())
|
||||
|
||||
# 3) 탐색
|
||||
for heading in tree.find(NodeType.HEADING):
|
||||
print(heading.attr("level"), heading.content)
|
||||
|
||||
# 목차 생성
|
||||
for entry in tree.toc():
|
||||
print(" " * (entry["level"] - 1) + entry["text"])
|
||||
|
||||
# 4) 렌더링
|
||||
md_renderer = MarkdownRenderer()
|
||||
print(md_renderer.render(tree))
|
||||
|
||||
json_renderer = JSONRenderer(indent=2)
|
||||
print(json_renderer.render(tree))
|
||||
|
||||
# 5) 트리 변환 (불변)
|
||||
filtered = tree.filter(lambda n: n.node_type != NodeType.CODE_BLOCK)
|
||||
upper = tree.map(lambda n: setattr(n, "content", (n.content or "").upper()) or None)
|
||||
```
|
||||
|
||||
### 트리 구조 시각화
|
||||
|
||||
```python
|
||||
print(tree.pretty())
|
||||
# [a1b2c3d4] ROOT: None
|
||||
# [e5f6a7b8] SECTION: None
|
||||
# [c9d0e1f2] HEADING level=1: '소개'
|
||||
# [a3b4c5d6] PARAGRAPH: 'CTStruct 는 텍스트를…'
|
||||
# [e7f8a9b0] LIST style='unordered': None
|
||||
# [c1d2e3f4] LIST_ITEM: '파싱'
|
||||
# [a5b6c7d8] LIST_ITEM: '변환'
|
||||
# [e9f0a1b2] LIST_ITEM: '렌더링'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 지원 노드 타입
|
||||
|
||||
| 카테고리 | 타입 | 설명 |
|
||||
|----------|------|------|
|
||||
| 구조 | `ROOT` | 트리 최상위 |
|
||||
| 구조 | `DOCUMENT` | 문서 단위 |
|
||||
| 구조 | `SECTION` | 헤딩 + 내용 묶음 |
|
||||
| 블록 | `HEADING` | 제목 (level 1–6) |
|
||||
| 블록 | `PARAGRAPH` | 문단 |
|
||||
| 블록 | `LIST` / `LIST_ITEM` | 목록 |
|
||||
| 블록 | `TABLE` / `TABLE_ROW` / `TABLE_CELL` | 표 |
|
||||
| 블록 | `CODE_BLOCK` | 코드 블록 |
|
||||
| 블록 | `BLOCKQUOTE` | 인용구 |
|
||||
| 블록 | `DIVIDER` | 구분선 |
|
||||
| 인라인 | `TEXT` `BOLD` `ITALIC` `CODE` `LINK` `IMAGE` | 인라인 요소 |
|
||||
| 메타 | `METADATA` | 문서 메타 (frontmatter) |
|
||||
| 메타 | `ANNOTATION` | 주석/노트 |
|
||||
| 메타 | `CUSTOM` | 사용자 정의 확장 |
|
||||
|
||||
---
|
||||
|
||||
## 프로젝트 구조
|
||||
|
||||
```
|
||||
CTStruct/
|
||||
├── ctstruct/
|
||||
│ ├── __init__.py # 공개 API
|
||||
│ ├── types.py # NodeType Enum
|
||||
│ ├── node.py # CTNode 클래스
|
||||
│ ├── tree.py # CTTree 클래스
|
||||
│ ├── parsers/
|
||||
│ │ ├── base.py # BaseParser (추상)
|
||||
│ │ └── markdown.py # MarkdownParser
|
||||
│ └── renderers/
|
||||
│ ├── base.py # BaseRenderer (추상)
|
||||
│ ├── markdown.py # MarkdownRenderer
|
||||
│ └── json_renderer.py # JSONRenderer
|
||||
├── tests/
|
||||
│ ├── test_node.py
|
||||
│ ├── test_tree.py
|
||||
│ └── test_parsers.py
|
||||
├── docs/
|
||||
│ └── ARCHITECTURE.md
|
||||
└── pyproject.toml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 테스트 실행
|
||||
|
||||
```bash
|
||||
pip install -e ".[dev]"
|
||||
pytest -v --cov=ctstruct
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 라이선스
|
||||
|
||||
MIT
|
||||
21
ctstruct.egg-info/SOURCES.txt
Normal file
21
ctstruct.egg-info/SOURCES.txt
Normal file
@@ -0,0 +1,21 @@
|
||||
README.md
|
||||
pyproject.toml
|
||||
ctstruct/__init__.py
|
||||
ctstruct/node.py
|
||||
ctstruct/tree.py
|
||||
ctstruct/types.py
|
||||
ctstruct.egg-info/PKG-INFO
|
||||
ctstruct.egg-info/SOURCES.txt
|
||||
ctstruct.egg-info/dependency_links.txt
|
||||
ctstruct.egg-info/requires.txt
|
||||
ctstruct.egg-info/top_level.txt
|
||||
ctstruct/parsers/__init__.py
|
||||
ctstruct/parsers/base.py
|
||||
ctstruct/parsers/markdown.py
|
||||
ctstruct/renderers/__init__.py
|
||||
ctstruct/renderers/base.py
|
||||
ctstruct/renderers/json_renderer.py
|
||||
ctstruct/renderers/markdown.py
|
||||
tests/test_node.py
|
||||
tests/test_parsers.py
|
||||
tests/test_tree.py
|
||||
1
ctstruct.egg-info/dependency_links.txt
Normal file
1
ctstruct.egg-info/dependency_links.txt
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
6
ctstruct.egg-info/requires.txt
Normal file
6
ctstruct.egg-info/requires.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
|
||||
[dev]
|
||||
pytest>=8.0
|
||||
pytest-cov
|
||||
ruff
|
||||
mypy
|
||||
1
ctstruct.egg-info/top_level.txt
Normal file
1
ctstruct.egg-info/top_level.txt
Normal file
@@ -0,0 +1 @@
|
||||
ctstruct
|
||||
10
ctstruct/__init__.py
Normal file
10
ctstruct/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
CTStruct - 텍스트 양식을 구조화하는 트리 자료형 라이브러리
|
||||
"""
|
||||
|
||||
from .node import CTNode
|
||||
from .tree import CTTree
|
||||
from .types import NodeType, ListStyle, Alignment
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__all__ = ["CTNode", "CTTree", "NodeType", "ListStyle", "Alignment"]
|
||||
BIN
ctstruct/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
ctstruct/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
ctstruct/__pycache__/node.cpython-313.pyc
Normal file
BIN
ctstruct/__pycache__/node.cpython-313.pyc
Normal file
Binary file not shown.
BIN
ctstruct/__pycache__/tree.cpython-313.pyc
Normal file
BIN
ctstruct/__pycache__/tree.cpython-313.pyc
Normal file
Binary file not shown.
BIN
ctstruct/__pycache__/types.cpython-313.pyc
Normal file
BIN
ctstruct/__pycache__/types.cpython-313.pyc
Normal file
Binary file not shown.
191
ctstruct/node.py
Normal file
191
ctstruct/node.py
Normal file
@@ -0,0 +1,191 @@
|
||||
"""
|
||||
ctstruct/node.py
|
||||
CTNode - 트리의 기본 단위 노드
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any, Iterator, Optional
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .types import NodeType
|
||||
|
||||
|
||||
@dataclass
|
||||
class CTNode:
|
||||
"""
|
||||
CTStruct 트리의 기본 노드.
|
||||
|
||||
Attributes:
|
||||
node_type : 노드의 의미적 타입
|
||||
content : 노드가 직접 보유하는 텍스트 값 (없으면 None)
|
||||
attributes : 노드 메타정보 (level, lang, href, …)
|
||||
node_id : 고유 식별자 (자동 생성)
|
||||
parent : 부모 노드 (루트는 None)
|
||||
children : 자식 노드 목록
|
||||
"""
|
||||
|
||||
node_type : NodeType
|
||||
content : Optional[str] = None
|
||||
attributes : dict[str, Any] = field(default_factory=dict)
|
||||
node_id : str = field(default_factory=lambda: uuid.uuid4().hex[:8])
|
||||
parent : Optional["CTNode"] = field(default=None, repr=False, compare=False)
|
||||
children : list["CTNode"] = field(default_factory=list)
|
||||
|
||||
# ── 자식 관리 ────────────────────────────────────────────
|
||||
|
||||
def append(self, child: "CTNode") -> "CTNode":
|
||||
"""자식 노드를 추가하고 self 반환 (체이닝 지원)."""
|
||||
child.parent = self
|
||||
self.children.append(child)
|
||||
return self
|
||||
|
||||
def prepend(self, child: "CTNode") -> "CTNode":
|
||||
"""자식 노드를 맨 앞에 삽입."""
|
||||
child.parent = self
|
||||
self.children.insert(0, child)
|
||||
return self
|
||||
|
||||
def insert(self, index: int, child: "CTNode") -> "CTNode":
|
||||
"""지정 위치에 자식 노드 삽입."""
|
||||
child.parent = self
|
||||
self.children.insert(index, child)
|
||||
return self
|
||||
|
||||
def remove(self, child: "CTNode") -> None:
|
||||
"""자식 노드를 제거."""
|
||||
self.children.remove(child)
|
||||
child.parent = None
|
||||
|
||||
def detach(self) -> "CTNode":
|
||||
"""자신을 부모에서 분리하고 self 반환."""
|
||||
if self.parent:
|
||||
self.parent.remove(self)
|
||||
return self
|
||||
|
||||
# ── 탐색 ─────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def depth(self) -> int:
|
||||
"""루트로부터의 깊이 (루트 = 0)."""
|
||||
return 0 if self.parent is None else self.parent.depth + 1
|
||||
|
||||
@property
|
||||
def is_leaf(self) -> bool:
|
||||
return len(self.children) == 0
|
||||
|
||||
@property
|
||||
def is_root(self) -> bool:
|
||||
return self.parent is None
|
||||
|
||||
@property
|
||||
def root(self) -> "CTNode":
|
||||
"""트리 최상위 루트 노드 반환."""
|
||||
return self if self.is_root else self.parent.root # type: ignore[union-attr]
|
||||
|
||||
@property
|
||||
def siblings(self) -> list["CTNode"]:
|
||||
"""같은 부모를 가진 형제 노드 목록."""
|
||||
if self.parent is None:
|
||||
return []
|
||||
return [n for n in self.parent.children if n is not self]
|
||||
|
||||
def ancestors(self) -> Iterator["CTNode"]:
|
||||
"""부모 → 루트 방향 이터레이터."""
|
||||
node = self.parent
|
||||
while node is not None:
|
||||
yield node
|
||||
node = node.parent
|
||||
|
||||
def walk(self, order: str = "pre") -> Iterator["CTNode"]:
|
||||
"""
|
||||
트리 전체 순회.
|
||||
|
||||
Args:
|
||||
order: "pre" (전위) | "post" (후위) | "bfs" (너비 우선)
|
||||
"""
|
||||
if order == "pre":
|
||||
yield self
|
||||
for child in self.children:
|
||||
yield from child.walk("pre")
|
||||
elif order == "post":
|
||||
for child in self.children:
|
||||
yield from child.walk("post")
|
||||
yield self
|
||||
elif order == "bfs":
|
||||
queue = [self]
|
||||
while queue:
|
||||
node = queue.pop(0)
|
||||
yield node
|
||||
queue.extend(node.children)
|
||||
else:
|
||||
raise ValueError(f"Unknown traversal order: {order!r}")
|
||||
|
||||
def find(self, node_type: NodeType) -> list["CTNode"]:
|
||||
"""특정 타입의 모든 하위 노드 반환."""
|
||||
return [n for n in self.walk() if n.node_type == node_type]
|
||||
|
||||
def find_one(self, node_type: NodeType) -> Optional["CTNode"]:
|
||||
"""특정 타입의 첫 번째 하위 노드 반환."""
|
||||
for n in self.walk():
|
||||
if n.node_type == node_type:
|
||||
return n
|
||||
return None
|
||||
|
||||
def find_by_id(self, node_id: str) -> Optional["CTNode"]:
|
||||
"""node_id로 노드 탐색."""
|
||||
for n in self.walk():
|
||||
if n.node_id == node_id:
|
||||
return n
|
||||
return None
|
||||
|
||||
# ── 속성 접근 헬퍼 ───────────────────────────────────────
|
||||
|
||||
def attr(self, key: str, default: Any = None) -> Any:
|
||||
return self.attributes.get(key, default)
|
||||
|
||||
def set_attr(self, key: str, value: Any) -> "CTNode":
|
||||
self.attributes[key] = value
|
||||
return self
|
||||
|
||||
# ── 표현 ─────────────────────────────────────────────────
|
||||
|
||||
def text_content(self) -> str:
|
||||
"""
|
||||
자신과 모든 하위 노드의 텍스트를 이어 붙여 반환.
|
||||
렌더러 없이 순수 텍스트 추출에 사용.
|
||||
"""
|
||||
parts: list[str] = []
|
||||
for node in self.walk("pre"):
|
||||
if node.content:
|
||||
parts.append(node.content)
|
||||
return "".join(parts)
|
||||
|
||||
def pretty(self, indent: int = 2) -> str:
|
||||
"""트리 구조를 사람이 읽기 좋은 형태로 출력."""
|
||||
pad = " " * (self.depth * indent)
|
||||
tag = self.node_type.name
|
||||
preview = repr(self.content[:20] + "…") if self.content and len(self.content) > 20 else repr(self.content)
|
||||
attrs = ""
|
||||
if self.attributes:
|
||||
attrs = " " + " ".join(f"{k}={v!r}" for k, v in self.attributes.items())
|
||||
line = f"{pad}[{self.node_id}] {tag}{attrs}: {preview}"
|
||||
child_lines = "\n".join(c.pretty(indent) for c in self.children)
|
||||
return f"{line}\n{child_lines}" if child_lines else line
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"CTNode(id={self.node_id!r}, type={self.node_type.name}, "
|
||||
f"children={len(self.children)}, content={self.content!r})"
|
||||
)
|
||||
|
||||
def __len__(self) -> int:
|
||||
"""자식 수 반환."""
|
||||
return len(self.children)
|
||||
|
||||
def __iter__(self) -> Iterator["CTNode"]:
|
||||
return iter(self.children)
|
||||
|
||||
def __getitem__(self, index: int) -> "CTNode":
|
||||
return self.children[index]
|
||||
4
ctstruct/parsers/__init__.py
Normal file
4
ctstruct/parsers/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from .base import BaseParser
|
||||
from .markdown import MarkdownParser
|
||||
|
||||
__all__ = ["BaseParser", "MarkdownParser"]
|
||||
BIN
ctstruct/parsers/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
ctstruct/parsers/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
ctstruct/parsers/__pycache__/base.cpython-313.pyc
Normal file
BIN
ctstruct/parsers/__pycache__/base.cpython-313.pyc
Normal file
Binary file not shown.
BIN
ctstruct/parsers/__pycache__/markdown.cpython-313.pyc
Normal file
BIN
ctstruct/parsers/__pycache__/markdown.cpython-313.pyc
Normal file
Binary file not shown.
31
ctstruct/parsers/base.py
Normal file
31
ctstruct/parsers/base.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
ctstruct/parsers/base.py
|
||||
파서 추상 베이스 클래스
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from ..tree import CTTree
|
||||
|
||||
|
||||
class BaseParser(ABC):
|
||||
"""
|
||||
모든 텍스트 파서의 공통 인터페이스.
|
||||
|
||||
구현 클래스는 parse() 메서드만 오버라이드하면 됩니다.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def parse(self, text: str) -> CTTree:
|
||||
"""
|
||||
텍스트를 파싱하여 CTTree 반환.
|
||||
|
||||
Args:
|
||||
text: 파싱할 원문 텍스트
|
||||
|
||||
Returns:
|
||||
구조화된 CTTree 객체
|
||||
"""
|
||||
...
|
||||
|
||||
def __call__(self, text: str) -> CTTree:
|
||||
return self.parse(text)
|
||||
172
ctstruct/parsers/markdown.py
Normal file
172
ctstruct/parsers/markdown.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""
|
||||
ctstruct/parsers/markdown.py
|
||||
Markdown → CTTree 파서
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from ..node import CTNode
|
||||
from ..tree import CTTree
|
||||
from ..types import NodeType, ListStyle
|
||||
from .base import BaseParser
|
||||
|
||||
|
||||
class MarkdownParser(BaseParser):
|
||||
"""
|
||||
Markdown 텍스트를 CTTree 로 파싱.
|
||||
|
||||
지원 요소:
|
||||
- ATX 헤딩 (# ~ ######)
|
||||
- 문단
|
||||
- 순서없는 목록 (-, *, +)
|
||||
- 순서있는 목록 (1. 2. …)
|
||||
- 코드 블록 (``` … ```)
|
||||
- 인용구 (>)
|
||||
- 구분선 (--- / ***)
|
||||
- frontmatter (--- … ---)
|
||||
"""
|
||||
|
||||
# ── 정규식 패턴 ──────────────────────────────────────────
|
||||
_RE_HEADING = re.compile(r'^(#{1,6})\s+(.+)$')
|
||||
_RE_UL_ITEM = re.compile(r'^(\s*)[*\-+]\s+(.+)$')
|
||||
_RE_OL_ITEM = re.compile(r'^(\s*)\d+\.\s+(.+)$')
|
||||
_RE_CODE_FENCE = re.compile(r'^```(\w*)$')
|
||||
_RE_BLOCKQUOTE = re.compile(r'^>\s?(.*)$')
|
||||
_RE_DIVIDER = re.compile(r'^(\-{3,}|\*{3,}|_{3,})$')
|
||||
_RE_FRONTMATTER = re.compile(r'^---\s*$')
|
||||
|
||||
def parse(self, text: str) -> CTTree:
|
||||
tree = CTTree()
|
||||
lines = text.splitlines()
|
||||
i = 0
|
||||
|
||||
# 1) frontmatter
|
||||
metadata, i = self._parse_frontmatter(lines, i)
|
||||
if metadata:
|
||||
tree.root.set_attr("metadata", metadata)
|
||||
|
||||
# 2) 본문 파싱
|
||||
current_section: CTNode | None = None
|
||||
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
|
||||
# 빈 줄 → 스킵
|
||||
if not line.strip():
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# 코드 블록
|
||||
m = self._RE_CODE_FENCE.match(line)
|
||||
if m:
|
||||
lang = m.group(1)
|
||||
code_lines, i = self._collect_code_block(lines, i + 1)
|
||||
node = CTNode(NodeType.CODE_BLOCK, "\n".join(code_lines), {"language": lang})
|
||||
self._attach(tree, current_section, node)
|
||||
continue
|
||||
|
||||
# 헤딩
|
||||
m = self._RE_HEADING.match(line)
|
||||
if m:
|
||||
level = len(m.group(1))
|
||||
text_content = m.group(2).strip()
|
||||
heading = CTNode(NodeType.HEADING, text_content, {"level": level})
|
||||
if level == 1:
|
||||
# 새 섹션 시작
|
||||
current_section = CTNode(NodeType.SECTION)
|
||||
current_section.append(heading)
|
||||
tree.root.append(current_section)
|
||||
else:
|
||||
self._attach(tree, current_section, heading)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# 구분선
|
||||
if self._RE_DIVIDER.match(line):
|
||||
node = CTNode(NodeType.DIVIDER)
|
||||
self._attach(tree, current_section, node)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# 인용구
|
||||
m = self._RE_BLOCKQUOTE.match(line)
|
||||
if m:
|
||||
quote_lines = [m.group(1)]
|
||||
i += 1
|
||||
while i < len(lines) and self._RE_BLOCKQUOTE.match(lines[i]):
|
||||
quote_lines.append(self._RE_BLOCKQUOTE.match(lines[i]).group(1)) # type: ignore
|
||||
i += 1
|
||||
node = CTNode(NodeType.BLOCKQUOTE, "\n".join(quote_lines))
|
||||
self._attach(tree, current_section, node)
|
||||
continue
|
||||
|
||||
# 목록
|
||||
if self._RE_UL_ITEM.match(line) or self._RE_OL_ITEM.match(line):
|
||||
lst_node, i = self._parse_list(lines, i)
|
||||
self._attach(tree, current_section, lst_node)
|
||||
continue
|
||||
|
||||
# 문단
|
||||
para_lines = [line]
|
||||
i += 1
|
||||
while i < len(lines) and lines[i].strip() and not self._is_block_start(lines[i]):
|
||||
para_lines.append(lines[i])
|
||||
i += 1
|
||||
node = CTNode(NodeType.PARAGRAPH, " ".join(para_lines))
|
||||
self._attach(tree, current_section, node)
|
||||
|
||||
return tree
|
||||
|
||||
# ── 헬퍼 ─────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _attach(tree: CTTree, section: CTNode | None, node: CTNode) -> None:
|
||||
if section is not None:
|
||||
section.append(node)
|
||||
else:
|
||||
tree.root.append(node)
|
||||
|
||||
def _parse_frontmatter(self, lines: list[str], i: int) -> tuple[dict, int]:
|
||||
if i >= len(lines) or not self._RE_FRONTMATTER.match(lines[i]):
|
||||
return {}, i
|
||||
i += 1
|
||||
meta: dict = {}
|
||||
while i < len(lines) and not self._RE_FRONTMATTER.match(lines[i]):
|
||||
if ":" in lines[i]:
|
||||
k, _, v = lines[i].partition(":")
|
||||
meta[k.strip()] = v.strip()
|
||||
i += 1
|
||||
return meta, i + 1
|
||||
|
||||
def _collect_code_block(self, lines: list[str], i: int) -> tuple[list[str], int]:
|
||||
code: list[str] = []
|
||||
while i < len(lines):
|
||||
if lines[i].strip() == "```":
|
||||
return code, i + 1
|
||||
code.append(lines[i])
|
||||
i += 1
|
||||
return code, i
|
||||
|
||||
def _parse_list(self, lines: list[str], i: int) -> tuple[CTNode, int]:
|
||||
is_ordered = bool(self._RE_OL_ITEM.match(lines[i]))
|
||||
style = ListStyle.ORDERED if is_ordered else ListStyle.UNORDERED
|
||||
lst = CTNode(NodeType.LIST, attributes={"style": style.value})
|
||||
pattern = self._RE_OL_ITEM if is_ordered else self._RE_UL_ITEM
|
||||
while i < len(lines):
|
||||
m = pattern.match(lines[i])
|
||||
if not m:
|
||||
break
|
||||
lst.append(CTNode(NodeType.LIST_ITEM, m.group(2)))
|
||||
i += 1
|
||||
return lst, i
|
||||
|
||||
def _is_block_start(self, line: str) -> bool:
|
||||
return bool(
|
||||
self._RE_HEADING.match(line)
|
||||
or self._RE_CODE_FENCE.match(line)
|
||||
or self._RE_BLOCKQUOTE.match(line)
|
||||
or self._RE_DIVIDER.match(line)
|
||||
or self._RE_UL_ITEM.match(line)
|
||||
or self._RE_OL_ITEM.match(line)
|
||||
)
|
||||
5
ctstruct/renderers/__init__.py
Normal file
5
ctstruct/renderers/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from .base import BaseRenderer
|
||||
from .markdown import MarkdownRenderer
|
||||
from .json_renderer import JSONRenderer
|
||||
|
||||
__all__ = ["BaseRenderer", "MarkdownRenderer", "JSONRenderer"]
|
||||
BIN
ctstruct/renderers/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
ctstruct/renderers/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
ctstruct/renderers/__pycache__/base.cpython-313.pyc
Normal file
BIN
ctstruct/renderers/__pycache__/base.cpython-313.pyc
Normal file
Binary file not shown.
BIN
ctstruct/renderers/__pycache__/json_renderer.cpython-313.pyc
Normal file
BIN
ctstruct/renderers/__pycache__/json_renderer.cpython-313.pyc
Normal file
Binary file not shown.
BIN
ctstruct/renderers/__pycache__/markdown.cpython-313.pyc
Normal file
BIN
ctstruct/renderers/__pycache__/markdown.cpython-313.pyc
Normal file
Binary file not shown.
31
ctstruct/renderers/base.py
Normal file
31
ctstruct/renderers/base.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
ctstruct/renderers/base.py
|
||||
렌더러 추상 베이스 클래스
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from ..tree import CTTree
|
||||
|
||||
|
||||
class BaseRenderer(ABC):
|
||||
"""
|
||||
모든 렌더러의 공통 인터페이스.
|
||||
|
||||
구현 클래스는 render() 메서드를 오버라이드합니다.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def render(self, tree: CTTree) -> str:
|
||||
"""
|
||||
CTTree 를 대상 포맷 텍스트로 변환.
|
||||
|
||||
Args:
|
||||
tree: 렌더링할 CTTree
|
||||
|
||||
Returns:
|
||||
변환된 텍스트 문자열
|
||||
"""
|
||||
...
|
||||
|
||||
def __call__(self, tree: CTTree) -> str:
|
||||
return self.render(tree)
|
||||
25
ctstruct/renderers/json_renderer.py
Normal file
25
ctstruct/renderers/json_renderer.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""
|
||||
ctstruct/renderers/json_renderer.py
|
||||
CTTree → JSON 렌더러
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from ..tree import CTTree
|
||||
from .base import BaseRenderer
|
||||
|
||||
|
||||
class JSONRenderer(BaseRenderer):
|
||||
"""CTTree 를 JSON 문자열로 직렬화."""
|
||||
|
||||
def __init__(self, indent: int = 2, ensure_ascii: bool = False) -> None:
|
||||
self.indent = indent
|
||||
self.ensure_ascii = ensure_ascii
|
||||
|
||||
def render(self, tree: CTTree) -> str:
|
||||
return json.dumps(
|
||||
tree.to_dict(),
|
||||
indent=self.indent,
|
||||
ensure_ascii=self.ensure_ascii,
|
||||
)
|
||||
88
ctstruct/renderers/markdown.py
Normal file
88
ctstruct/renderers/markdown.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""
|
||||
ctstruct/renderers/markdown.py
|
||||
CTTree → Markdown 렌더러
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ..node import CTNode
|
||||
from ..tree import CTTree
|
||||
from ..types import NodeType
|
||||
from .base import BaseRenderer
|
||||
|
||||
|
||||
class MarkdownRenderer(BaseRenderer):
|
||||
"""CTTree 를 Markdown 텍스트로 렌더링."""
|
||||
|
||||
def render(self, tree: CTTree) -> str:
|
||||
lines: list[str] = []
|
||||
# frontmatter
|
||||
meta = tree.root.attr("metadata")
|
||||
if meta:
|
||||
lines.append("---")
|
||||
for k, v in meta.items():
|
||||
lines.append(f"{k}: {v}")
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
|
||||
for child in tree.root:
|
||||
lines.extend(self._render_node(child))
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
def _render_node(self, node: CTNode) -> list[str]:
|
||||
t = node.node_type
|
||||
|
||||
if t == NodeType.SECTION:
|
||||
out: list[str] = []
|
||||
for c in node:
|
||||
out.extend(self._render_node(c))
|
||||
out.append("")
|
||||
return out
|
||||
|
||||
if t == NodeType.HEADING:
|
||||
level = node.attr("level", 1)
|
||||
return [f"{'#' * level} {node.content or ''}"]
|
||||
|
||||
if t == NodeType.PARAGRAPH:
|
||||
return [node.content or ""]
|
||||
|
||||
if t == NodeType.CODE_BLOCK:
|
||||
lang = node.attr("language", "")
|
||||
return [f"```{lang}", node.content or "", "```"]
|
||||
|
||||
if t == NodeType.BLOCKQUOTE:
|
||||
return [f"> {line}" for line in (node.content or "").splitlines()]
|
||||
|
||||
if t == NodeType.DIVIDER:
|
||||
return ["---"]
|
||||
|
||||
if t == NodeType.LIST:
|
||||
out = []
|
||||
style = node.attr("style", "unordered")
|
||||
for idx, item in enumerate(node, 1):
|
||||
prefix = f"{idx}." if style == "ordered" else "-"
|
||||
out.append(f"{prefix} {item.content or ''}")
|
||||
return out
|
||||
|
||||
if t == NodeType.TABLE:
|
||||
return self._render_table(node)
|
||||
|
||||
if t == NodeType.METADATA:
|
||||
return [] # 메타 노드는 frontmatter로 처리됨
|
||||
|
||||
# 그 외 노드는 텍스트 추출
|
||||
return [node.text_content()]
|
||||
|
||||
def _render_table(self, table: CTNode) -> list[str]:
|
||||
rows = list(table)
|
||||
if not rows:
|
||||
return []
|
||||
out: list[str] = []
|
||||
for i, row in enumerate(rows):
|
||||
cells = [c.content or "" for c in row]
|
||||
out.append("| " + " | ".join(cells) + " |")
|
||||
if i == 0:
|
||||
out.append("| " + " | ".join(["---"] * len(cells)) + " |")
|
||||
return out
|
||||
197
ctstruct/tree.py
Normal file
197
ctstruct/tree.py
Normal file
@@ -0,0 +1,197 @@
|
||||
"""
|
||||
ctstruct/tree.py
|
||||
CTTree - 트리 전체를 관리하는 컨테이너
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Iterator, Optional
|
||||
|
||||
from .node import CTNode
|
||||
from .types import NodeType
|
||||
|
||||
|
||||
class CTTree:
|
||||
"""
|
||||
CTStruct 트리 컨테이너.
|
||||
|
||||
루트 노드를 통해 전체 트리를 관리하며,
|
||||
파싱·렌더링·변환 파이프라인의 중심 객체.
|
||||
|
||||
Examples:
|
||||
>>> tree = CTTree()
|
||||
>>> sec = tree.add(NodeType.SECTION)
|
||||
>>> sec.append(CTNode(NodeType.HEADING, "제목", {"level": 1}))
|
||||
>>> sec.append(CTNode(NodeType.PARAGRAPH, "본문 내용입니다."))
|
||||
>>> print(tree.pretty())
|
||||
"""
|
||||
|
||||
def __init__(self, title: str = "", metadata: Optional[dict[str, Any]] = None) -> None:
|
||||
self.root = CTNode(NodeType.ROOT)
|
||||
if title:
|
||||
self.root.set_attr("title", title)
|
||||
if metadata:
|
||||
meta_node = CTNode(NodeType.METADATA, attributes=metadata)
|
||||
self.root.append(meta_node)
|
||||
|
||||
# ── 빌더 API ─────────────────────────────────────────────
|
||||
|
||||
def add(self, node_type: NodeType,
|
||||
content: Optional[str] = None,
|
||||
**attrs: Any) -> CTNode:
|
||||
"""루트에 직접 자식 노드를 추가하고 반환."""
|
||||
node = CTNode(node_type, content, dict(attrs))
|
||||
self.root.append(node)
|
||||
return node
|
||||
|
||||
@staticmethod
|
||||
def make_heading(text: str, level: int = 1) -> CTNode:
|
||||
return CTNode(NodeType.HEADING, text, {"level": level})
|
||||
|
||||
@staticmethod
|
||||
def make_paragraph(text: str) -> CTNode:
|
||||
return CTNode(NodeType.PARAGRAPH, text)
|
||||
|
||||
@staticmethod
|
||||
def make_code_block(code: str, language: str = "") -> CTNode:
|
||||
return CTNode(NodeType.CODE_BLOCK, code, {"language": language})
|
||||
|
||||
@staticmethod
|
||||
def make_list(items: list[str], ordered: bool = False) -> CTNode:
|
||||
from .types import ListStyle
|
||||
style = ListStyle.ORDERED if ordered else ListStyle.UNORDERED
|
||||
lst = CTNode(NodeType.LIST, attributes={"style": style.value})
|
||||
for item in items:
|
||||
lst.append(CTNode(NodeType.LIST_ITEM, item))
|
||||
return lst
|
||||
|
||||
@staticmethod
|
||||
def make_table(headers: list[str], rows: list[list[str]]) -> CTNode:
|
||||
table = CTNode(NodeType.TABLE)
|
||||
header_row = CTNode(NodeType.TABLE_ROW, attributes={"is_header": True})
|
||||
for h in headers:
|
||||
header_row.append(CTNode(NodeType.TABLE_CELL, h, {"is_header": True}))
|
||||
table.append(header_row)
|
||||
for row in rows:
|
||||
tr = CTNode(NodeType.TABLE_ROW)
|
||||
for cell in row:
|
||||
tr.append(CTNode(NodeType.TABLE_CELL, cell))
|
||||
table.append(tr)
|
||||
return table
|
||||
|
||||
# ── 탐색 ─────────────────────────────────────────────────
|
||||
|
||||
def find(self, node_type: NodeType) -> list[CTNode]:
|
||||
"""트리 전체에서 특정 타입 노드 검색."""
|
||||
return self.root.find(node_type)
|
||||
|
||||
def find_by_id(self, node_id: str) -> Optional[CTNode]:
|
||||
return self.root.find_by_id(node_id)
|
||||
|
||||
def walk(self, order: str = "pre") -> Iterator[CTNode]:
|
||||
return self.root.walk(order)
|
||||
|
||||
def headings(self) -> list[CTNode]:
|
||||
return self.find(NodeType.HEADING)
|
||||
|
||||
def toc(self) -> list[dict[str, Any]]:
|
||||
"""
|
||||
문서 목차(TOC) 반환.
|
||||
Returns:
|
||||
[{"level": int, "text": str, "node_id": str}, ...]
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"level": h.attr("level", 1),
|
||||
"text": h.content or "",
|
||||
"node_id": h.node_id,
|
||||
}
|
||||
for h in self.headings()
|
||||
]
|
||||
|
||||
# ── 변환 ─────────────────────────────────────────────────
|
||||
|
||||
def filter(self, predicate: Callable[[CTNode], bool]) -> "CTTree":
|
||||
"""
|
||||
조건에 맞는 노드만 남긴 **새로운** 트리 반환.
|
||||
원본 트리는 불변.
|
||||
"""
|
||||
import copy
|
||||
new_tree = copy.deepcopy(self)
|
||||
nodes_to_remove = [
|
||||
n for n in new_tree.walk()
|
||||
if not predicate(n) and not n.is_root
|
||||
]
|
||||
for n in nodes_to_remove:
|
||||
if n.parent:
|
||||
n.parent.remove(n)
|
||||
return new_tree
|
||||
|
||||
def map(self, transform: Callable[[CTNode], None]) -> "CTTree":
|
||||
"""
|
||||
모든 노드에 transform 함수를 적용한 **새로운** 트리 반환.
|
||||
"""
|
||||
import copy
|
||||
new_tree = copy.deepcopy(self)
|
||||
for node in new_tree.walk():
|
||||
transform(node)
|
||||
return new_tree
|
||||
|
||||
def merge(self, other: "CTTree") -> "CTTree":
|
||||
"""다른 트리의 루트 자식들을 현재 트리에 병합 (in-place)."""
|
||||
import copy
|
||||
for child in other.root.children:
|
||||
self.root.append(copy.deepcopy(child))
|
||||
return self
|
||||
|
||||
# ── 직렬화 ───────────────────────────────────────────────
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""트리 전체를 Python dict 로 직렬화."""
|
||||
def _serialize(node: CTNode) -> dict:
|
||||
return {
|
||||
"id": node.node_id,
|
||||
"type": node.node_type.name,
|
||||
"content": node.content,
|
||||
"attributes": node.attributes,
|
||||
"children": [_serialize(c) for c in node.children],
|
||||
}
|
||||
return _serialize(self.root)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "CTTree":
|
||||
"""dict 에서 트리 복원."""
|
||||
tree = cls()
|
||||
|
||||
def _deserialize(d: dict) -> CTNode:
|
||||
node = CTNode(
|
||||
node_type=NodeType[d["type"]],
|
||||
content=d.get("content"),
|
||||
attributes=d.get("attributes", {}),
|
||||
node_id=d.get("id", ""),
|
||||
)
|
||||
for child_data in d.get("children", []):
|
||||
node.append(_deserialize(child_data))
|
||||
return node
|
||||
|
||||
tree.root = _deserialize(data)
|
||||
return tree
|
||||
|
||||
# ── 표현 ─────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def node_count(self) -> int:
|
||||
return sum(1 for _ in self.walk())
|
||||
|
||||
@property
|
||||
def depth(self) -> int:
|
||||
return max((n.depth for n in self.walk()), default=0)
|
||||
|
||||
def pretty(self) -> str:
|
||||
return self.root.pretty()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"CTTree(nodes={self.node_count}, depth={self.depth})"
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.root.children)
|
||||
55
ctstruct/types.py
Normal file
55
ctstruct/types.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""
|
||||
ctstruct/types.py
|
||||
노드 타입 정의 모듈
|
||||
"""
|
||||
|
||||
from enum import Enum, auto
|
||||
|
||||
|
||||
class NodeType(Enum):
|
||||
"""CTStruct 트리 노드 타입 정의."""
|
||||
|
||||
# ── 구조 노드 ────────────────────────────────────────────
|
||||
ROOT = auto() # 트리의 최상위 루트
|
||||
DOCUMENT = auto() # 문서 단위
|
||||
SECTION = auto() # 섹션 (헤딩 + 하위 내용 묶음)
|
||||
|
||||
# ── 블록 노드 ────────────────────────────────────────────
|
||||
HEADING = auto() # 제목 (level 1~6)
|
||||
PARAGRAPH = auto() # 문단
|
||||
LIST = auto() # 목록 컨테이너 (ordered / unordered)
|
||||
LIST_ITEM = auto() # 목록 항목
|
||||
TABLE = auto() # 표 컨테이너
|
||||
TABLE_ROW = auto() # 표 행
|
||||
TABLE_CELL = auto() # 표 셀
|
||||
CODE_BLOCK = auto() # 코드 블록
|
||||
BLOCKQUOTE = auto() # 인용구
|
||||
DIVIDER = auto() # 구분선
|
||||
|
||||
# ── 인라인 노드 ──────────────────────────────────────────
|
||||
TEXT = auto() # 순수 텍스트
|
||||
BOLD = auto() # 굵게
|
||||
ITALIC = auto() # 기울임
|
||||
CODE = auto() # 인라인 코드
|
||||
LINK = auto() # 하이퍼링크
|
||||
IMAGE = auto() # 이미지
|
||||
|
||||
# ── 메타 노드 ────────────────────────────────────────────
|
||||
METADATA = auto() # 문서 메타데이터 (frontmatter 등)
|
||||
ANNOTATION = auto() # 주석 / 노트
|
||||
CUSTOM = auto() # 사용자 정의 확장 노드
|
||||
|
||||
|
||||
class ListStyle(Enum):
|
||||
"""목록 스타일."""
|
||||
UNORDERED = "unordered"
|
||||
ORDERED = "ordered"
|
||||
TASK = "task"
|
||||
|
||||
|
||||
class Alignment(Enum):
|
||||
"""표 셀 정렬."""
|
||||
LEFT = "left"
|
||||
CENTER = "center"
|
||||
RIGHT = "right"
|
||||
NONE = "none"
|
||||
243
docs/ARCHITECTURE.md
Normal file
243
docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,243 @@
|
||||
# CTStruct 아키텍처 문서
|
||||
|
||||
## 1. 개요
|
||||
|
||||
CTStruct(Contextual Text Structure)는 다양한 텍스트 포맷을 **단일 트리 자료구조**로 표현하고,
|
||||
파서와 렌더러를 플러그인처럼 교체할 수 있도록 설계된 Python 라이브러리입니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 설계 목표
|
||||
|
||||
| 목표 | 설명 |
|
||||
|------|------|
|
||||
| **통일성** | Markdown, JSON, 일반 텍스트 등 모든 포맷이 동일한 CTTree API로 조작됨 |
|
||||
| **확장성** | 새 파서·렌더러를 `BaseParser` / `BaseRenderer` 상속으로 추가 가능 |
|
||||
| **불변 변환** | `filter()`, `map()` 은 원본 트리를 변경하지 않고 새 트리 반환 |
|
||||
| **의존성 최소화** | 표준 라이브러리만 사용, 외부 패키지 없음 |
|
||||
| **타입 안전성** | `NodeType` Enum 으로 잘못된 타입 사용 방지 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 핵심 구성요소
|
||||
|
||||
### 3.1 NodeType (types.py)
|
||||
|
||||
```
|
||||
NodeType
|
||||
├── 구조 노드: ROOT, DOCUMENT, SECTION
|
||||
├── 블록 노드: HEADING, PARAGRAPH, LIST, LIST_ITEM,
|
||||
│ TABLE, TABLE_ROW, TABLE_CELL,
|
||||
│ CODE_BLOCK, BLOCKQUOTE, DIVIDER
|
||||
├── 인라인 노드: TEXT, BOLD, ITALIC, CODE, LINK, IMAGE
|
||||
└── 메타 노드: METADATA, ANNOTATION, CUSTOM
|
||||
```
|
||||
|
||||
`NodeType` 은 Python `Enum` 으로 구현되어 있으며,
|
||||
노드의 **의미적 역할**을 정의합니다.
|
||||
|
||||
---
|
||||
|
||||
### 3.2 CTNode (node.py)
|
||||
|
||||
트리의 기본 단위. `dataclass` 로 구현되어 있습니다.
|
||||
|
||||
```
|
||||
CTNode
|
||||
├── node_id : str # 고유 식별자 (8자리 hex)
|
||||
├── node_type : NodeType # 의미적 타입
|
||||
├── content : str | None # 직접 보유 텍스트
|
||||
├── attributes : dict # 메타정보 (level, lang, …)
|
||||
├── parent : CTNode | None
|
||||
└── children : list[CTNode]
|
||||
```
|
||||
|
||||
#### 주요 메서드
|
||||
|
||||
| 메서드 | 설명 |
|
||||
|--------|------|
|
||||
| `append(child)` | 자식 추가 (체이닝 지원) |
|
||||
| `prepend(child)` | 맨 앞 삽입 |
|
||||
| `insert(i, child)` | 지정 위치 삽입 |
|
||||
| `remove(child)` | 자식 제거 |
|
||||
| `detach()` | 부모에서 자신을 분리 |
|
||||
| `walk(order)` | `"pre"` / `"post"` / `"bfs"` 순회 |
|
||||
| `find(type)` | 타입별 하위 노드 검색 |
|
||||
| `ancestors()` | 부모 방향 이터레이터 |
|
||||
| `text_content()` | 모든 하위 텍스트 추출 |
|
||||
| `pretty()` | 트리 시각화 문자열 |
|
||||
|
||||
---
|
||||
|
||||
### 3.3 CTTree (tree.py)
|
||||
|
||||
루트 노드를 감싸는 컨테이너. 문서 단위의 고수준 API를 제공합니다.
|
||||
|
||||
```
|
||||
CTTree
|
||||
└── root: CTNode (NodeType.ROOT)
|
||||
```
|
||||
|
||||
#### 빌더 API (정적 메서드)
|
||||
|
||||
```python
|
||||
CTTree.make_heading(text, level)
|
||||
CTTree.make_paragraph(text)
|
||||
CTTree.make_code_block(code, language)
|
||||
CTTree.make_list(items, ordered)
|
||||
CTTree.make_table(headers, rows)
|
||||
```
|
||||
|
||||
#### 탐색 API
|
||||
|
||||
```python
|
||||
tree.find(NodeType.HEADING) # 타입별 검색
|
||||
tree.find_by_id("a1b2c3d4") # ID 검색
|
||||
tree.headings() # 헤딩 목록
|
||||
tree.toc() # 목차 생성
|
||||
tree.walk(order) # 전체 순회
|
||||
```
|
||||
|
||||
#### 변환 API (원본 불변)
|
||||
|
||||
```python
|
||||
tree.filter(predicate) # 조건 필터링 → 새 CTTree
|
||||
tree.map(transform) # 변환 적용 → 새 CTTree
|
||||
tree.merge(other) # 다른 트리 병합 (in-place)
|
||||
```
|
||||
|
||||
#### 직렬화
|
||||
|
||||
```python
|
||||
d = tree.to_dict() # Python dict
|
||||
tree = CTTree.from_dict(d) # dict → CTTree
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.4 파서 (parsers/)
|
||||
|
||||
```
|
||||
BaseParser (추상)
|
||||
└── parse(text: str) → CTTree
|
||||
└── MarkdownParser
|
||||
├── frontmatter 파싱 (--- key: value ---)
|
||||
├── ATX 헤딩 (# ~ ######)
|
||||
├── 섹션 자동 분류 (H1 기준)
|
||||
├── 코드 블록 (``` … ```)
|
||||
├── 목록 (-, *, 1.)
|
||||
├── 인용구 (>)
|
||||
├── 구분선 (---)
|
||||
└── 문단 (multi-line 연결)
|
||||
```
|
||||
|
||||
**커스텀 파서 작성:**
|
||||
|
||||
```python
|
||||
from ctstruct.parsers import BaseParser
|
||||
from ctstruct import CTTree, CTNode, NodeType
|
||||
|
||||
class MyParser(BaseParser):
|
||||
def parse(self, text: str) -> CTTree:
|
||||
tree = CTTree()
|
||||
for line in text.splitlines():
|
||||
tree.add(NodeType.PARAGRAPH, line)
|
||||
return tree
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.5 렌더러 (renderers/)
|
||||
|
||||
```
|
||||
BaseRenderer (추상)
|
||||
└── render(tree: CTTree) → str
|
||||
├── MarkdownRenderer → Markdown 텍스트
|
||||
└── JSONRenderer → JSON 문자열
|
||||
```
|
||||
|
||||
**커스텀 렌더러 작성:**
|
||||
|
||||
```python
|
||||
from ctstruct.renderers import BaseRenderer
|
||||
from ctstruct import CTTree, NodeType
|
||||
|
||||
class PlainTextRenderer(BaseRenderer):
|
||||
def render(self, tree: CTTree) -> str:
|
||||
lines = []
|
||||
for node in tree.walk():
|
||||
if node.content:
|
||||
lines.append(node.content)
|
||||
return "\n".join(lines)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 데이터 흐름
|
||||
|
||||
```
|
||||
┌──────────────┐ parse() ┌──────────────┐
|
||||
│ 텍스트 입력 │ ──────────► │ CTTree │
|
||||
│ (Markdown, │ │ │
|
||||
│ JSON, etc) │ │ ┌────────┐ │ render() ┌──────────────┐
|
||||
└──────────────┘ │ │CTNode │ │ ────────────► │ 텍스트 출력 │
|
||||
│ │ ├child │ │ │ (Markdown, │
|
||||
│ │ ├child │ │ │ JSON, etc) │
|
||||
│ │ └child │ │ └──────────────┘
|
||||
│ └────────┘ │
|
||||
│ │
|
||||
│ filter() │
|
||||
│ map() │ ──► 새 CTTree
|
||||
│ merge() │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 노드 속성 규약
|
||||
|
||||
각 노드 타입별 `attributes` 딕셔너리의 키 규약:
|
||||
|
||||
| NodeType | 속성 키 | 타입 | 설명 |
|
||||
|----------|---------|------|------|
|
||||
| `HEADING` | `level` | `int` (1-6) | 헤딩 레벨 |
|
||||
| `CODE_BLOCK` | `language` | `str` | 코드 언어 |
|
||||
| `LIST` | `style` | `"ordered"` / `"unordered"` / `"task"` | 목록 스타일 |
|
||||
| `LIST_ITEM` | `checked` | `bool` | 태스크 목록 체크 여부 |
|
||||
| `TABLE_CELL` | `align` | `"left"` / `"center"` / `"right"` | 정렬 |
|
||||
| `TABLE_CELL` | `is_header` | `bool` | 헤더 셀 여부 |
|
||||
| `LINK` | `href` | `str` | URL |
|
||||
| `IMAGE` | `src` | `str` | 이미지 경로 |
|
||||
| `IMAGE` | `alt` | `str` | 대체 텍스트 |
|
||||
| `METADATA` | (자유형식) | — | frontmatter 키-값 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 확장 계획
|
||||
|
||||
| 단계 | 기능 |
|
||||
|------|------|
|
||||
| v0.2 | `PlainTextParser` — 들여쓰기/구분자 기반 텍스트 파싱 |
|
||||
| v0.2 | `HTMLRenderer` — CTTree → HTML 변환 |
|
||||
| v0.3 | `JSONParser` — JSON 구조 → CTTree |
|
||||
| v0.3 | `YAMLParser` — YAML → CTTree |
|
||||
| v0.4 | `XPathQuery` — 트리 경로 쿼리 언어 |
|
||||
| v0.5 | 스트리밍 파서 — 대용량 문서 처리 |
|
||||
| v1.0 | 완전한 CommonMark 호환 Markdown 파서 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 의존성
|
||||
|
||||
```
|
||||
Python >= 3.10
|
||||
표준 라이브러리: uuid, re, json, copy, abc, dataclasses, enum
|
||||
```
|
||||
|
||||
개발 도구 (선택):
|
||||
```
|
||||
pytest — 테스트
|
||||
pytest-cov — 커버리지
|
||||
ruff — 린터
|
||||
mypy — 타입 검사
|
||||
```
|
||||
33
pyproject.toml
Normal file
33
pyproject.toml
Normal file
@@ -0,0 +1,33 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "ctstruct"
|
||||
version = "0.1.0"
|
||||
description = "텍스트 양식을 구조화하는 트리 자료형 라이브러리"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
license = { text = "MIT" }
|
||||
keywords = ["tree", "document", "parser", "markdown", "structured-text"]
|
||||
classifiers = [
|
||||
"Programming Language :: Python :: 3",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
"Topic :: Text Processing :: Markup",
|
||||
]
|
||||
dependencies = []
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest>=8.0", "pytest-cov", "ruff", "mypy"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py310"
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.10"
|
||||
strict = true
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
BIN
tests/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
tests/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_node.cpython-313-pytest-9.0.3.pyc
Normal file
BIN
tests/__pycache__/test_node.cpython-313-pytest-9.0.3.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_parsers.cpython-313-pytest-9.0.3.pyc
Normal file
BIN
tests/__pycache__/test_parsers.cpython-313-pytest-9.0.3.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_tree.cpython-313-pytest-9.0.3.pyc
Normal file
BIN
tests/__pycache__/test_tree.cpython-313-pytest-9.0.3.pyc
Normal file
Binary file not shown.
93
tests/test_node.py
Normal file
93
tests/test_node.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""
|
||||
tests/test_node.py
|
||||
CTNode 단위 테스트
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from ctstruct import CTNode, NodeType
|
||||
|
||||
|
||||
class TestCTNodeBasic:
|
||||
def test_create_node(self):
|
||||
n = CTNode(NodeType.PARAGRAPH, "hello")
|
||||
assert n.node_type == NodeType.PARAGRAPH
|
||||
assert n.content == "hello"
|
||||
assert n.is_leaf
|
||||
assert n.is_root
|
||||
|
||||
def test_append_child(self):
|
||||
parent = CTNode(NodeType.SECTION)
|
||||
child = CTNode(NodeType.PARAGRAPH, "child")
|
||||
parent.append(child)
|
||||
assert len(parent) == 1
|
||||
assert child.parent is parent
|
||||
assert not parent.is_leaf
|
||||
|
||||
def test_depth(self):
|
||||
root = CTNode(NodeType.ROOT)
|
||||
sec = CTNode(NodeType.SECTION)
|
||||
para = CTNode(NodeType.PARAGRAPH, "text")
|
||||
root.append(sec)
|
||||
sec.append(para)
|
||||
assert root.depth == 0
|
||||
assert sec.depth == 1
|
||||
assert para.depth == 2
|
||||
|
||||
def test_detach(self):
|
||||
parent = CTNode(NodeType.SECTION)
|
||||
child = CTNode(NodeType.PARAGRAPH, "bye")
|
||||
parent.append(child)
|
||||
child.detach()
|
||||
assert len(parent) == 0
|
||||
assert child.parent is None
|
||||
|
||||
def test_walk_pre(self):
|
||||
root = CTNode(NodeType.ROOT)
|
||||
a = CTNode(NodeType.SECTION)
|
||||
b = CTNode(NodeType.PARAGRAPH, "b")
|
||||
root.append(a)
|
||||
a.append(b)
|
||||
nodes = list(root.walk("pre"))
|
||||
assert nodes[0] is root
|
||||
assert nodes[1] is a
|
||||
assert nodes[2] is b
|
||||
|
||||
def test_walk_bfs(self):
|
||||
root = CTNode(NodeType.ROOT)
|
||||
a = CTNode(NodeType.SECTION)
|
||||
b = CTNode(NodeType.PARAGRAPH, "b")
|
||||
c = CTNode(NodeType.PARAGRAPH, "c")
|
||||
root.append(a)
|
||||
root.append(c)
|
||||
a.append(b)
|
||||
bfs = list(root.walk("bfs"))
|
||||
assert bfs[0] is root
|
||||
# level 1: a, c
|
||||
assert bfs[1] is a
|
||||
assert bfs[2] is c
|
||||
# level 2: b
|
||||
assert bfs[3] is b
|
||||
|
||||
def test_find(self):
|
||||
root = CTNode(NodeType.ROOT)
|
||||
root.append(CTNode(NodeType.HEADING, "h1", {"level": 1}))
|
||||
root.append(CTNode(NodeType.PARAGRAPH, "para"))
|
||||
root.append(CTNode(NodeType.HEADING, "h2", {"level": 2}))
|
||||
headings = root.find(NodeType.HEADING)
|
||||
assert len(headings) == 2
|
||||
|
||||
def test_text_content(self):
|
||||
root = CTNode(NodeType.ROOT)
|
||||
root.append(CTNode(NodeType.PARAGRAPH, "hello "))
|
||||
root.append(CTNode(NodeType.PARAGRAPH, "world"))
|
||||
assert root.text_content() == "hello world"
|
||||
|
||||
def test_siblings(self):
|
||||
parent = CTNode(NodeType.SECTION)
|
||||
a = CTNode(NodeType.PARAGRAPH, "a")
|
||||
b = CTNode(NodeType.PARAGRAPH, "b")
|
||||
c = CTNode(NodeType.PARAGRAPH, "c")
|
||||
parent.append(a)
|
||||
parent.append(b)
|
||||
parent.append(c)
|
||||
assert b.siblings == [a, c]
|
||||
104
tests/test_parsers.py
Normal file
104
tests/test_parsers.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
tests/test_parsers.py
|
||||
파서 단위 테스트
|
||||
"""
|
||||
|
||||
from ctstruct import NodeType
|
||||
from ctstruct.parsers import MarkdownParser
|
||||
from ctstruct.renderers import MarkdownRenderer, JSONRenderer
|
||||
|
||||
|
||||
SAMPLE_MD = """\
|
||||
---
|
||||
title: 테스트 문서
|
||||
author: gm
|
||||
---
|
||||
|
||||
# 소개
|
||||
|
||||
이 문서는 CTStruct 테스트용 문서입니다.
|
||||
|
||||
## 기능 목록
|
||||
|
||||
- 트리 자료구조
|
||||
- 마크다운 파서
|
||||
- 다양한 렌더러
|
||||
|
||||
## 코드 예제
|
||||
|
||||
```python
|
||||
tree = CTTree()
|
||||
tree.add(NodeType.PARAGRAPH, "hello")
|
||||
```
|
||||
|
||||
> 이것은 인용구입니다.
|
||||
|
||||
---
|
||||
|
||||
# 결론
|
||||
|
||||
CTStruct 는 텍스트를 구조화합니다.
|
||||
"""
|
||||
|
||||
|
||||
class TestMarkdownParser:
|
||||
def setup_method(self):
|
||||
self.parser = MarkdownParser()
|
||||
self.tree = self.parser.parse(SAMPLE_MD)
|
||||
|
||||
def test_parse_returns_tree(self):
|
||||
assert self.tree is not None
|
||||
|
||||
def test_headings_parsed(self):
|
||||
headings = self.tree.find(NodeType.HEADING)
|
||||
assert len(headings) >= 3
|
||||
|
||||
def test_paragraph_parsed(self):
|
||||
paras = self.tree.find(NodeType.PARAGRAPH)
|
||||
assert len(paras) >= 1
|
||||
|
||||
def test_list_parsed(self):
|
||||
lists = self.tree.find(NodeType.LIST)
|
||||
assert len(lists) >= 1
|
||||
items = self.tree.find(NodeType.LIST_ITEM)
|
||||
assert len(items) == 3
|
||||
|
||||
def test_code_block_parsed(self):
|
||||
codes = self.tree.find(NodeType.CODE_BLOCK)
|
||||
assert len(codes) == 1
|
||||
assert codes[0].attr("language") == "python"
|
||||
|
||||
def test_blockquote_parsed(self):
|
||||
quotes = self.tree.find(NodeType.BLOCKQUOTE)
|
||||
assert len(quotes) == 1
|
||||
|
||||
def test_divider_parsed(self):
|
||||
dividers = self.tree.find(NodeType.DIVIDER)
|
||||
assert len(dividers) >= 1
|
||||
|
||||
def test_toc(self):
|
||||
toc = self.tree.toc()
|
||||
assert toc[0]["text"] == "소개"
|
||||
|
||||
|
||||
class TestMarkdownRenderer:
|
||||
def test_roundtrip(self):
|
||||
parser = MarkdownParser()
|
||||
renderer = MarkdownRenderer()
|
||||
tree = parser.parse(SAMPLE_MD)
|
||||
output = renderer.render(tree)
|
||||
# 재파싱 후 헤딩 수 일치 확인
|
||||
tree2 = parser.parse(output)
|
||||
assert len(tree.find(NodeType.HEADING)) == len(tree2.find(NodeType.HEADING))
|
||||
|
||||
|
||||
class TestJSONRenderer:
|
||||
def test_json_output(self):
|
||||
import json
|
||||
parser = MarkdownParser()
|
||||
renderer = JSONRenderer()
|
||||
tree = parser.parse(SAMPLE_MD)
|
||||
output = renderer.render(tree)
|
||||
data = json.loads(output)
|
||||
assert "type" in data
|
||||
assert data["type"] == "ROOT"
|
||||
70
tests/test_tree.py
Normal file
70
tests/test_tree.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
tests/test_tree.py
|
||||
CTTree 단위 테스트
|
||||
"""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from ctstruct import CTTree, CTNode, NodeType
|
||||
|
||||
|
||||
class TestCTTree:
|
||||
def test_add_node(self):
|
||||
tree = CTTree("My Doc")
|
||||
tree.add(NodeType.PARAGRAPH, "hello")
|
||||
assert len(tree) == 1
|
||||
|
||||
def test_make_list(self):
|
||||
lst = CTTree.make_list(["A", "B", "C"])
|
||||
assert lst.node_type == NodeType.LIST
|
||||
assert len(lst) == 3
|
||||
assert lst[0].content == "A"
|
||||
|
||||
def test_make_table(self):
|
||||
table = CTTree.make_table(
|
||||
headers=["이름", "나이"],
|
||||
rows=[["Alice", "30"], ["Bob", "25"]],
|
||||
)
|
||||
assert table.node_type == NodeType.TABLE
|
||||
assert len(table) == 3 # 헤더행 + 2행
|
||||
|
||||
def test_toc(self):
|
||||
tree = CTTree()
|
||||
tree.add(NodeType.HEADING, "서론", level=1)
|
||||
tree.add(NodeType.HEADING, "본론", level=2)
|
||||
tree.add(NodeType.HEADING, "결론", level=1)
|
||||
toc = tree.toc()
|
||||
assert len(toc) == 3
|
||||
assert toc[0]["text"] == "서론"
|
||||
|
||||
def test_to_dict_and_back(self):
|
||||
tree = CTTree("Test")
|
||||
tree.add(NodeType.PARAGRAPH, "테스트 문단")
|
||||
d = tree.to_dict()
|
||||
restored = CTTree.from_dict(d)
|
||||
paras = restored.find(NodeType.PARAGRAPH)
|
||||
assert len(paras) == 1
|
||||
assert paras[0].content == "테스트 문단"
|
||||
|
||||
def test_filter(self):
|
||||
tree = CTTree()
|
||||
tree.add(NodeType.HEADING, "H", level=1)
|
||||
tree.add(NodeType.PARAGRAPH, "P")
|
||||
filtered = tree.filter(lambda n: n.node_type != NodeType.PARAGRAPH)
|
||||
assert len(filtered.find(NodeType.PARAGRAPH)) == 0
|
||||
assert len(filtered.find(NodeType.HEADING)) == 1
|
||||
|
||||
def test_merge(self):
|
||||
t1 = CTTree()
|
||||
t1.add(NodeType.PARAGRAPH, "t1")
|
||||
t2 = CTTree()
|
||||
t2.add(NodeType.PARAGRAPH, "t2")
|
||||
t1.merge(t2)
|
||||
assert len(t1.find(NodeType.PARAGRAPH)) == 2
|
||||
|
||||
def test_node_count(self):
|
||||
tree = CTTree()
|
||||
tree.add(NodeType.PARAGRAPH, "a")
|
||||
tree.add(NodeType.PARAGRAPH, "b")
|
||||
# root(1) + 2 paragraphs
|
||||
assert tree.node_count == 3
|
||||
Reference in New Issue
Block a user