commit 6e3cd19b557fda64feaa5c8707d6f45f6218dadc Author: gm Date: Sat May 30 20:40:28 2026 +0900 feat: initial CTStruct framework - CTNode: 트리 기본 단위 (전위/후위/BFS 순회, 탐색, 분리) - CTTree: 트리 컨테이너 (빌더 API, TOC, filter/map/merge) - NodeType: 구조/블록/인라인/메타 노드 타입 Enum - MarkdownParser: Markdown → CTTree 파서 - MarkdownRenderer: CTTree → Markdown 렌더러 - JSONRenderer: CTTree → JSON 직렬화 - 27개 단위/통합 테스트 전체 통과 diff --git a/README.md b/README.md new file mode 100644 index 0000000..82c0b67 --- /dev/null +++ b/README.md @@ -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 diff --git a/ctstruct.egg-info/PKG-INFO b/ctstruct.egg-info/PKG-INFO new file mode 100644 index 0000000..b55631c --- /dev/null +++ b/ctstruct.egg-info/PKG-INFO @@ -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 diff --git a/ctstruct.egg-info/SOURCES.txt b/ctstruct.egg-info/SOURCES.txt new file mode 100644 index 0000000..87f8809 --- /dev/null +++ b/ctstruct.egg-info/SOURCES.txt @@ -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 \ No newline at end of file diff --git a/ctstruct.egg-info/dependency_links.txt b/ctstruct.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ctstruct.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/ctstruct.egg-info/requires.txt b/ctstruct.egg-info/requires.txt new file mode 100644 index 0000000..a428555 --- /dev/null +++ b/ctstruct.egg-info/requires.txt @@ -0,0 +1,6 @@ + +[dev] +pytest>=8.0 +pytest-cov +ruff +mypy diff --git a/ctstruct.egg-info/top_level.txt b/ctstruct.egg-info/top_level.txt new file mode 100644 index 0000000..15ed40f --- /dev/null +++ b/ctstruct.egg-info/top_level.txt @@ -0,0 +1 @@ +ctstruct diff --git a/ctstruct/__init__.py b/ctstruct/__init__.py new file mode 100644 index 0000000..c525304 --- /dev/null +++ b/ctstruct/__init__.py @@ -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"] diff --git a/ctstruct/__pycache__/__init__.cpython-313.pyc b/ctstruct/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..8b73fb2 Binary files /dev/null and b/ctstruct/__pycache__/__init__.cpython-313.pyc differ diff --git a/ctstruct/__pycache__/node.cpython-313.pyc b/ctstruct/__pycache__/node.cpython-313.pyc new file mode 100644 index 0000000..4747687 Binary files /dev/null and b/ctstruct/__pycache__/node.cpython-313.pyc differ diff --git a/ctstruct/__pycache__/tree.cpython-313.pyc b/ctstruct/__pycache__/tree.cpython-313.pyc new file mode 100644 index 0000000..7ee86e5 Binary files /dev/null and b/ctstruct/__pycache__/tree.cpython-313.pyc differ diff --git a/ctstruct/__pycache__/types.cpython-313.pyc b/ctstruct/__pycache__/types.cpython-313.pyc new file mode 100644 index 0000000..5c097f4 Binary files /dev/null and b/ctstruct/__pycache__/types.cpython-313.pyc differ diff --git a/ctstruct/node.py b/ctstruct/node.py new file mode 100644 index 0000000..287afcb --- /dev/null +++ b/ctstruct/node.py @@ -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] diff --git a/ctstruct/parsers/__init__.py b/ctstruct/parsers/__init__.py new file mode 100644 index 0000000..caad660 --- /dev/null +++ b/ctstruct/parsers/__init__.py @@ -0,0 +1,4 @@ +from .base import BaseParser +from .markdown import MarkdownParser + +__all__ = ["BaseParser", "MarkdownParser"] diff --git a/ctstruct/parsers/__pycache__/__init__.cpython-313.pyc b/ctstruct/parsers/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..12bd797 Binary files /dev/null and b/ctstruct/parsers/__pycache__/__init__.cpython-313.pyc differ diff --git a/ctstruct/parsers/__pycache__/base.cpython-313.pyc b/ctstruct/parsers/__pycache__/base.cpython-313.pyc new file mode 100644 index 0000000..db5c233 Binary files /dev/null and b/ctstruct/parsers/__pycache__/base.cpython-313.pyc differ diff --git a/ctstruct/parsers/__pycache__/markdown.cpython-313.pyc b/ctstruct/parsers/__pycache__/markdown.cpython-313.pyc new file mode 100644 index 0000000..6998787 Binary files /dev/null and b/ctstruct/parsers/__pycache__/markdown.cpython-313.pyc differ diff --git a/ctstruct/parsers/base.py b/ctstruct/parsers/base.py new file mode 100644 index 0000000..fc50740 --- /dev/null +++ b/ctstruct/parsers/base.py @@ -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) diff --git a/ctstruct/parsers/markdown.py b/ctstruct/parsers/markdown.py new file mode 100644 index 0000000..2490fde --- /dev/null +++ b/ctstruct/parsers/markdown.py @@ -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) + ) diff --git a/ctstruct/renderers/__init__.py b/ctstruct/renderers/__init__.py new file mode 100644 index 0000000..8060cd5 --- /dev/null +++ b/ctstruct/renderers/__init__.py @@ -0,0 +1,5 @@ +from .base import BaseRenderer +from .markdown import MarkdownRenderer +from .json_renderer import JSONRenderer + +__all__ = ["BaseRenderer", "MarkdownRenderer", "JSONRenderer"] diff --git a/ctstruct/renderers/__pycache__/__init__.cpython-313.pyc b/ctstruct/renderers/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..3bfb165 Binary files /dev/null and b/ctstruct/renderers/__pycache__/__init__.cpython-313.pyc differ diff --git a/ctstruct/renderers/__pycache__/base.cpython-313.pyc b/ctstruct/renderers/__pycache__/base.cpython-313.pyc new file mode 100644 index 0000000..07537de Binary files /dev/null and b/ctstruct/renderers/__pycache__/base.cpython-313.pyc differ diff --git a/ctstruct/renderers/__pycache__/json_renderer.cpython-313.pyc b/ctstruct/renderers/__pycache__/json_renderer.cpython-313.pyc new file mode 100644 index 0000000..864b738 Binary files /dev/null and b/ctstruct/renderers/__pycache__/json_renderer.cpython-313.pyc differ diff --git a/ctstruct/renderers/__pycache__/markdown.cpython-313.pyc b/ctstruct/renderers/__pycache__/markdown.cpython-313.pyc new file mode 100644 index 0000000..7053c38 Binary files /dev/null and b/ctstruct/renderers/__pycache__/markdown.cpython-313.pyc differ diff --git a/ctstruct/renderers/base.py b/ctstruct/renderers/base.py new file mode 100644 index 0000000..f3c3781 --- /dev/null +++ b/ctstruct/renderers/base.py @@ -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) diff --git a/ctstruct/renderers/json_renderer.py b/ctstruct/renderers/json_renderer.py new file mode 100644 index 0000000..84b23b5 --- /dev/null +++ b/ctstruct/renderers/json_renderer.py @@ -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, + ) diff --git a/ctstruct/renderers/markdown.py b/ctstruct/renderers/markdown.py new file mode 100644 index 0000000..8bda3b3 --- /dev/null +++ b/ctstruct/renderers/markdown.py @@ -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 diff --git a/ctstruct/tree.py b/ctstruct/tree.py new file mode 100644 index 0000000..38171db --- /dev/null +++ b/ctstruct/tree.py @@ -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) diff --git a/ctstruct/types.py b/ctstruct/types.py new file mode 100644 index 0000000..0e0d7bb --- /dev/null +++ b/ctstruct/types.py @@ -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" diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..683c6e3 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -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 — 타입 검사 +``` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..359062f --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/__pycache__/__init__.cpython-313.pyc b/tests/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..e072095 Binary files /dev/null and b/tests/__pycache__/__init__.cpython-313.pyc differ diff --git a/tests/__pycache__/test_node.cpython-313-pytest-9.0.3.pyc b/tests/__pycache__/test_node.cpython-313-pytest-9.0.3.pyc new file mode 100644 index 0000000..55da9a1 Binary files /dev/null and b/tests/__pycache__/test_node.cpython-313-pytest-9.0.3.pyc differ diff --git a/tests/__pycache__/test_parsers.cpython-313-pytest-9.0.3.pyc b/tests/__pycache__/test_parsers.cpython-313-pytest-9.0.3.pyc new file mode 100644 index 0000000..d3e1744 Binary files /dev/null and b/tests/__pycache__/test_parsers.cpython-313-pytest-9.0.3.pyc differ diff --git a/tests/__pycache__/test_tree.cpython-313-pytest-9.0.3.pyc b/tests/__pycache__/test_tree.cpython-313-pytest-9.0.3.pyc new file mode 100644 index 0000000..67a49eb Binary files /dev/null and b/tests/__pycache__/test_tree.cpython-313-pytest-9.0.3.pyc differ diff --git a/tests/test_node.py b/tests/test_node.py new file mode 100644 index 0000000..f2a232f --- /dev/null +++ b/tests/test_node.py @@ -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] diff --git a/tests/test_parsers.py b/tests/test_parsers.py new file mode 100644 index 0000000..08a43c2 --- /dev/null +++ b/tests/test_parsers.py @@ -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" diff --git a/tests/test_tree.py b/tests/test_tree.py new file mode 100644 index 0000000..0275c1c --- /dev/null +++ b/tests/test_tree.py @@ -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