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:
2026-05-30 20:40:28 +09:00
commit 6e3cd19b55
38 changed files with 1695 additions and 0 deletions

148
README.md Normal file
View 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 16) |
| 블록 | `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
View 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 16) |
| 블록 | `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

View 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

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,6 @@
[dev]
pytest>=8.0
pytest-cov
ruff
mypy

View File

@@ -0,0 +1 @@
ctstruct

10
ctstruct/__init__.py Normal file
View 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"]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

191
ctstruct/node.py Normal file
View 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]

View File

@@ -0,0 +1,4 @@
from .base import BaseParser
from .markdown import MarkdownParser
__all__ = ["BaseParser", "MarkdownParser"]

Binary file not shown.

Binary file not shown.

Binary file not shown.

31
ctstruct/parsers/base.py Normal file
View 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)

View 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)
)

View File

@@ -0,0 +1,5 @@
from .base import BaseRenderer
from .markdown import MarkdownRenderer
from .json_renderer import JSONRenderer
__all__ = ["BaseRenderer", "MarkdownRenderer", "JSONRenderer"]

Binary file not shown.

View 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)

View 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,
)

View 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
View 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
View 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
View 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
View 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
View File

Binary file not shown.

93
tests/test_node.py Normal file
View 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
View 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
View 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