From 6e3cd19b557fda64feaa5c8707d6f45f6218dadc Mon Sep 17 00:00:00 2001 From: gm Date: Sat, 30 May 2026 20:40:28 +0900 Subject: [PATCH] feat: initial CTStruct framework MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CTNode: 트리 기본 단위 (전위/후위/BFS 순회, 탐색, 분리) - CTTree: 트리 컨테이너 (빌더 API, TOC, filter/map/merge) - NodeType: 구조/블록/인라인/메타 노드 타입 Enum - MarkdownParser: Markdown → CTTree 파서 - MarkdownRenderer: CTTree → Markdown 렌더러 - JSONRenderer: CTTree → JSON 직렬화 - 27개 단위/통합 테스트 전체 통과 --- README.md | 148 +++++++++++ ctstruct.egg-info/PKG-INFO | 166 ++++++++++++ ctstruct.egg-info/SOURCES.txt | 21 ++ ctstruct.egg-info/dependency_links.txt | 1 + ctstruct.egg-info/requires.txt | 6 + ctstruct.egg-info/top_level.txt | 1 + ctstruct/__init__.py | 10 + ctstruct/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 472 bytes ctstruct/__pycache__/node.cpython-313.pyc | Bin 0 -> 10554 bytes ctstruct/__pycache__/tree.cpython-313.pyc | Bin 0 -> 11099 bytes ctstruct/__pycache__/types.cpython-313.pyc | Bin 0 -> 1807 bytes ctstruct/node.py | 191 ++++++++++++++ ctstruct/parsers/__init__.py | 4 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 268 bytes .../parsers/__pycache__/base.cpython-313.pyc | Bin 0 -> 1231 bytes .../__pycache__/markdown.cpython-313.pyc | Bin 0 -> 9219 bytes ctstruct/parsers/base.py | 31 +++ ctstruct/parsers/markdown.py | 172 +++++++++++++ ctstruct/renderers/__init__.py | 5 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 326 bytes .../__pycache__/base.cpython-313.pyc | Bin 0 -> 1233 bytes .../__pycache__/json_renderer.cpython-313.pyc | Bin 0 -> 1263 bytes .../__pycache__/markdown.cpython-313.pyc | Bin 0 -> 4563 bytes ctstruct/renderers/base.py | 31 +++ ctstruct/renderers/json_renderer.py | 25 ++ ctstruct/renderers/markdown.py | 88 +++++++ ctstruct/tree.py | 197 ++++++++++++++ ctstruct/types.py | 55 ++++ docs/ARCHITECTURE.md | 243 ++++++++++++++++++ pyproject.toml | 33 +++ tests/__init__.py | 0 tests/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 129 bytes .../test_node.cpython-313-pytest-9.0.3.pyc | Bin 0 -> 19148 bytes .../test_parsers.cpython-313-pytest-9.0.3.pyc | Bin 0 -> 16602 bytes .../test_tree.cpython-313-pytest-9.0.3.pyc | Bin 0 -> 16803 bytes tests/test_node.py | 93 +++++++ tests/test_parsers.py | 104 ++++++++ tests/test_tree.py | 70 +++++ 38 files changed, 1695 insertions(+) create mode 100644 README.md create mode 100644 ctstruct.egg-info/PKG-INFO create mode 100644 ctstruct.egg-info/SOURCES.txt create mode 100644 ctstruct.egg-info/dependency_links.txt create mode 100644 ctstruct.egg-info/requires.txt create mode 100644 ctstruct.egg-info/top_level.txt create mode 100644 ctstruct/__init__.py create mode 100644 ctstruct/__pycache__/__init__.cpython-313.pyc create mode 100644 ctstruct/__pycache__/node.cpython-313.pyc create mode 100644 ctstruct/__pycache__/tree.cpython-313.pyc create mode 100644 ctstruct/__pycache__/types.cpython-313.pyc create mode 100644 ctstruct/node.py create mode 100644 ctstruct/parsers/__init__.py create mode 100644 ctstruct/parsers/__pycache__/__init__.cpython-313.pyc create mode 100644 ctstruct/parsers/__pycache__/base.cpython-313.pyc create mode 100644 ctstruct/parsers/__pycache__/markdown.cpython-313.pyc create mode 100644 ctstruct/parsers/base.py create mode 100644 ctstruct/parsers/markdown.py create mode 100644 ctstruct/renderers/__init__.py create mode 100644 ctstruct/renderers/__pycache__/__init__.cpython-313.pyc create mode 100644 ctstruct/renderers/__pycache__/base.cpython-313.pyc create mode 100644 ctstruct/renderers/__pycache__/json_renderer.cpython-313.pyc create mode 100644 ctstruct/renderers/__pycache__/markdown.cpython-313.pyc create mode 100644 ctstruct/renderers/base.py create mode 100644 ctstruct/renderers/json_renderer.py create mode 100644 ctstruct/renderers/markdown.py create mode 100644 ctstruct/tree.py create mode 100644 ctstruct/types.py create mode 100644 docs/ARCHITECTURE.md create mode 100644 pyproject.toml create mode 100644 tests/__init__.py create mode 100644 tests/__pycache__/__init__.cpython-313.pyc create mode 100644 tests/__pycache__/test_node.cpython-313-pytest-9.0.3.pyc create mode 100644 tests/__pycache__/test_parsers.cpython-313-pytest-9.0.3.pyc create mode 100644 tests/__pycache__/test_tree.cpython-313-pytest-9.0.3.pyc create mode 100644 tests/test_node.py create mode 100644 tests/test_parsers.py create mode 100644 tests/test_tree.py 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 0000000000000000000000000000000000000000..8b73fb21ad5f48920b5d4f80871ed4d47659c7bb GIT binary patch literal 472 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 literal 0 HcmV?d00001 diff --git a/ctstruct/__pycache__/types.cpython-313.pyc b/ctstruct/__pycache__/types.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5c097f4ca71dfcfa3801fd0d2a0d3f83b9952a2c GIT binary patch literal 1807 zcmai#&ube;6vt#cu*GS{4pWN5HUuYI$-DjDL^1%2Oq-u!+uZ**EH zXb8UF|5*I$Jq4i`491_B6;3~g!UNiy z2~NBsc%@goV&av-tGwcsT53s)%g|G6MqxzaW>k*$?gZiTojq-M@N9Vauz0fHAN_Dp z96fq4I{LmieDdq?@UC{uCM)GAHoNgI#f>=XHPiW1&1G;}6WHRn5fNYmIaeTgH%IWc zcn#ginh=R2nInZGl_QO#3mi>wRN&|$N0S^)ade5J%N$K}G{ey>M{^v_b99BH1&$Uu zT5|K`Y73V{s@T1qz=KmdRw0D(+aMLsO%AVka_`yj=cmQw2}W-&&)%X1N|!nfjJzZc@?HS866z6XVyNPpOs`D`u@m zwTfey{z}cMY)~yRA8$FHNoB+O#4^k}#h&?@NAZeNGpJ;Fdd;d(%sR1|>|mIyn9@E4G8(Q{mWDWU24!|Q$H`Q)MSk^GP%0-lz8*+ zI9bokta8I;PIEkGwuW@1{Z+y)OtNw=VZ-mNr6*s zhmjlYb%Nt}p;vqfWccLq@W-R#=-yB4;W+wjnW}NOM|J`d>`-}Jz)>UoI*GSkQV9zo zVJ{)Mz+{3+0feeswo`{wHw`L}3)s`$4Z>EL^iQ{Qo#8uUVT%c8K-iHi4W^d*)?jL( zUmZ-%?Hg$a*7p4Uvzq_kzbg~9rIb{v>YaA0yBl<);}4)`JPC60-NWMOpwHgS;iIR^ z6nBE#5tW+IC?HfM?beqO#ob;v$U>m~cL;FJT=l3_fkcDbFV?N<8eD(dvCVU1@IFjW zn6Sl!3m_~83xmRR-xw5T`>TV(Oy2|?2ZFV=OL7`-9Kdr<*gr{<>FFg;!kUb$u!`jR zuy|ADX8L}7L~a7I3lx3`@@HNUgqOLzfdAHzaP>@jQ&_kgogpx1hJc0n`}hojIlCwd MGxzad2uw2SKRs8lqW}N^ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..12bd7979329aa5ab2c1fde26113bd6078804e76f GIT binary patch literal 268 zcmey&%ge<81a_yTvhslRV-N=hn4pZ$DnQ0mhG2$ZMsEf$#v(=q5Sz)H$&0y&8OUZ1 zX3=LVVo7J!WP8a7RI15%i_0moI5i-#s5rF<$m8=(EXq#FFVBOB6fpx86|n#bKTXzK zEJ;A6V4+(axlnar$y@C4@rgM(@$oAeJ_FS<+|t%B%Fi#+cMb_IDJo4a(N8WZhA;}i z78UEq$7kkcmc+;F6;$5hu*uC&Da}c>E8+&~1v#r&3`l%nW@Kc%%OG=?LFECr_ytML N3tU=_>_wbFDFAj}L!|%! literal 0 HcmV?d00001 diff --git a/ctstruct/parsers/__pycache__/base.cpython-313.pyc b/ctstruct/parsers/__pycache__/base.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..db5c233b5cc8a3ff47a96cdd316003c1eb941b46 GIT binary patch literal 1231 zcmZ`&&ubJ(6t4a`lQJoXlDI@=+X7H}C@}yTQOE2Ye6wfpN$8`Y-)XlZAzNcA3R5m*VT!m{_mH z)s1*=g(cTE#2+DMbW5UHYNQ=MF&yo7#u3PIgK00gP@336+Qd>Kb9ihFJkOO&+qToe zNvu>zfq;TtsI~%{t%6=KY^`A0dLj3VE}37{2U|NK|J95sXQ0E*0iDcS9T7cPP9 zY)SRXSI|-M?5Vh4W66y>$T*pM#j>{IYRI-qr4O1C#xlW_J+ zqhfA}rRp59n5ZwID&qdK_;Z29;fh$F71h_c6-!Aa6!i@z7VBdEmY84Zr;#3bzXg#A zUQmV^S8CEW1rvE4O&wi@Oe&U|G2Wpnp1e-k&Ob-s_`xd6q-0_(Odf{F^EgcULzJP( zQ-*dH*I4{$QLJLG?XD?3lQL*6#|GoaYsteUvAEnu-CnE4>l<{mPU>o;`(E)pNELm_ znX%~S0VtJ%f#ESbdoBO>Y$797D%$hqHu}@bcM>dz?kZ{QBORShOvyKjNHtPL`Y@@G zv*e7_=s$K=8EjSi@Oh0|3P@nsjjlx%_Fx|6Id`36k@KkMD<IcW}ImcQ7GrrrHOJ;=AE4XT6T)9nQqR{Pk|p4r)IpF z5>DqhpD4MUgS^>D!(RcW1Wk$d%33eye&7UCE_Z?erpC*G=i_>LYIeSC0rG8^-h=$C zH_1m;H}Z{6V(x9I)aVRwRXL+Uolu{HwcE>xlb}=z!G!R%E9*-Bh49i zK`Ndx)dT#f8P)XYZ|Ow&qnQB5fZ-N-3BHuEp(%>;QPmXv6D3ODdvf4!vsYn@J^v64 H>8bw$8uoU% literal 0 HcmV?d00001 diff --git a/ctstruct/parsers/__pycache__/markdown.cpython-313.pyc b/ctstruct/parsers/__pycache__/markdown.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..69987870ed22a1ae9d4ae4422bad56365e8c6405 GIT binary patch literal 9219 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 MSN}rbx2Wm=12V4B<^TWy literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..3bfb165bc8443bfe5c2c94dadbe850f32bb1169f GIT binary patch literal 326 zcmey&%ge<81dgYrvep6V#~=<2FhLog4Sy5va!E0S0n=8jA0MBYmst`YuUAlci^C>2 rKczG$)vkyas2k*uVqqZhftit!@h*ehT?Vzg4C;@#>>AmNxPjsT<_J_l literal 0 HcmV?d00001 diff --git a/ctstruct/renderers/__pycache__/base.cpython-313.pyc b/ctstruct/renderers/__pycache__/base.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..07537de254e55aa13a24e313ec333d1448a73938 GIT binary patch literal 1233 zcmZ`&zi$**5PtjP_RcS7oXBxRhy)MhBHP$SFolI88A~aQ_%glZ#{oMlK?-gA4uxnn-ETN>r)L+m#MoBHGN{-T{up6f^VYy_xyu zd+)XT`#D27@%-53>pEk9)1oK2uG1bzr@ICdtnel%?n4tugv@4Lohn$2L8x*SGa24Qn%ODuR9|7 zV?DXHEY#y>vbmeAZi#exSKW)y;|EmI)?;Jh++=*jox^Ib7cTkXx7GOr#LjFjwkBt1 z!S_8n+LO0qe1nk&D>2Y;6y4FlaC9(BImakjj#ge)}B|(MNtvV5&-q}$Po{IGPZKOI~-B-VD2=%0%{t_X~ z30Mfmxj>;-j&^i=0Q&f@9@ zd##(fLaWHEPg^?A6{9mPhNkJY$+V4LymeH|os2HF7@CL18!}m8syS`v;S02};m&zL zoM%X>O$Tn)`7oNea5kI&YdC@D=oSKEv1MqQ_F6YI^9^U(@JsgjKh}_Ta_y^s8JbSh F{{R!2azg+B literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..864b7384fe0bf25d72062fdc9d299e996d5fb472 GIT binary patch literal 1263 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e072095262e01d0ef246385e909a4d1b63870230 GIT binary patch literal 129 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 literal 0 HcmV?d00001 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