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