feat: slim JSON, EmojiMarkupRenderer, configurable delimiters

- JSONRenderer(slim=True): null·기본값·빈 attributes 필드 제거
- EmojiMarkupRenderer: CTTree → 이모지 마크업 텍스트 왕복 변환 지원
  - title_mark / section_marks 커스터마이징 가능
  - names_per_line 옵션으로 줄당 이름 수 제한
- EmojiMarkupParser: 구분자를 생성자 파라미터로 주입 가능
  - section_marks 길이 제한 없음 (임의 깊이 섹션 지원)
  - 긴 마커 우선 매칭으로 prefix 충돌 방지
- 테스트 39개 추가 (커스텀 구분자·렌더러·slim 검증)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
gm
2026-06-01 18:30:13 +09:00
parent daa590117d
commit a68a79411f
5 changed files with 356 additions and 55 deletions

View File

@@ -3,11 +3,16 @@ ctstruct/parsers/emoji_markup.py
이모지 마크업 포맷 → CTTree 파서 이모지 마크업 포맷 → CTTree 파서
지원 포맷: 지원 포맷:
제목 → 문서 제목 (root 속성) <title_mark>제목 → 문서 제목 (root 속성)
🔹1단계 섹션 → SECTION (level=1) <section_marks[0]>1단계 → SECTION (level=1)
🔸2단계 섹션 → SECTION (level=2) <section_marks[1]>2단계 → SECTION (level=2)
(카테고리) 이름 → 카테고리 태그가 붙은 LIST + LIST_ITEM들 (카테고리) 이름 → 카테고리 태그가 붙은 LIST + LIST_ITEM들
이름1 이름2 → 카테고리 없는 LIST + LIST_ITEM들 (줄 연속 가능) 이름1 이름2 → 카테고리 없는 LIST + LIST_ITEM들 (줄 연속 가능)
기본 구분자:
title_mark = ""
section_marks = ["🔹", "🔸"]
""" """
from __future__ import annotations from __future__ import annotations
@@ -21,36 +26,55 @@ from .base import BaseParser
class EmojiMarkupParser(BaseParser): class EmojiMarkupParser(BaseParser):
""" """
✅ / 🔹 / 🔸 이모지로 계층을 표현하는 텍스트 포맷 파서. 임의 구분자로 계층을 표현하는 텍스트 포맷 파서.
명단은 공백으로 구분된 이름 목록이며, 빈 줄 또는 새로운 섹션/카테고리 구분자는 이모지, 특수문자, 문자열 등 어떤 값도 사용할 수 있습니다.
마커가 나올 때까지 같은 그룹으로 묶입니다.
Args:
title_mark: 문서 제목 줄 앞에 붙는 마커 (기본 "")
section_marks: 섹션 레벨별 마커 목록 (기본 ["🔹", "🔸"])
인덱스 0 → level 1, 인덱스 1 → level 2, …
더 긴 마커가 먼저 매칭되도록 내부에서 길이 내림차순 정렬됩니다.
Examples: Examples:
>>> parser = EmojiMarkupParser() # 기본 이모지 구분자
>>> tree = parser.parse(text) parser = EmojiMarkupParser()
>>> tree.pretty()
# 마크다운 스타일 구분자
parser = EmojiMarkupParser(title_mark="# ", section_marks=["## ", "### "])
# 커스텀 구분자
parser = EmojiMarkupParser(title_mark="[제목]", section_marks=["[1]", "[2]", "[3]"])
""" """
_TITLE = "" _RE_CAT = re.compile(r'^\(([^)]+)\)\s*(.*)', re.DOTALL)
_SEC1 = "🔹"
_SEC2 = "🔸" def __init__(
_RE_CAT = re.compile(r'^\(([^)]+)\)\s*(.*)') self,
title_mark: str = "",
section_marks: list[str] | None = None,
) -> None:
self.title_mark = title_mark
self.section_marks = section_marks if section_marks is not None else ["🔹", "🔸"]
# 긴 마커를 먼저 검사해 prefix 충돌 방지 (예: "##" vs "#")
self._sorted_marks: list[tuple[str, int]] = sorted(
((m, i + 1) for i, m in enumerate(self.section_marks)),
key=lambda x: len(x[0]),
reverse=True,
)
def parse(self, text: str) -> CTTree: def parse(self, text: str) -> CTTree:
tree = CTTree() tree = CTTree()
# 현재 컨텍스트 추적 # 각 레벨별 현재 활성 섹션 노드 (인덱스 = level-1)
sec1: CTNode | None = None section_stack: list[CTNode | None] = [None] * len(self.section_marks)
sec2: CTNode | None = None
# 아직 트리에 붙이지 않은 이름 버퍼
pending_names: list[str] = [] pending_names: list[str] = []
pending_category: str | None = None pending_category: str | None = None
pending_parent: CTNode | None = None # 이름 그룹이 붙을 부모 노드 pending_parent: CTNode | None = None
def flush() -> None: def flush() -> None:
"""버퍼에 쌓인 이름을 LIST로 만들어 부모에 붙인다."""
nonlocal pending_names, pending_category, pending_parent nonlocal pending_names, pending_category, pending_parent
if not pending_names: if not pending_names:
return return
@@ -60,9 +84,16 @@ class EmojiMarkupParser(BaseParser):
lst.append(CTNode(NodeType.LIST_ITEM, name)) lst.append(CTNode(NodeType.LIST_ITEM, name))
target = pending_parent if pending_parent is not None else tree.root target = pending_parent if pending_parent is not None else tree.root
target.append(lst) target.append(lst)
pending_names = [] pending_names.clear()
pending_category = None pending_category = None
def nearest_parent(level: int) -> CTNode:
"""level 보다 얕은 가장 가까운 활성 섹션, 없으면 root."""
for i in range(level - 2, -1, -1):
if section_stack[i] is not None:
return section_stack[i] # type: ignore[return-value]
return tree.root
for raw in text.splitlines(): for raw in text.splitlines():
line = raw.strip() line = raw.strip()
@@ -70,31 +101,31 @@ class EmojiMarkupParser(BaseParser):
flush() flush()
continue continue
# ✅ 문서 제목 # 제목 마커
if line.startswith(self._TITLE): if line.startswith(self.title_mark):
flush() flush()
tree.root.set_attr("title", line[len(self._TITLE):].strip()) tree.root.set_attr("title", line[len(self.title_mark):].strip())
continue continue
# 🔹 1단계 섹션 # 섹션 마커 (긴 것 우선 매칭)
if line.startswith(self._SEC1): matched = False
for mark, level in self._sorted_marks:
if line.startswith(mark):
flush() flush()
sec1 = CTNode(NodeType.SECTION, line[len(self._SEC1):].strip(), {"level": 1}) content = line[len(mark):].strip()
sec2 = None node = CTNode(NodeType.SECTION, content, {"level": level})
pending_parent = sec1 # 현재 레벨 이하의 스택 초기화
tree.root.append(sec1) for i in range(level - 1, len(section_stack)):
section_stack[i] = None
section_stack[level - 1] = node
pending_parent = node
nearest_parent(level).append(node)
matched = True
break
if matched:
continue continue
# 🔸 2단계 섹션 # 카테고리 마커 → 새 그룹 시작
if line.startswith(self._SEC2):
flush()
sec2 = CTNode(NodeType.SECTION, line[len(self._SEC2):].strip(), {"level": 2})
pending_parent = sec2
parent = sec1 if sec1 is not None else tree.root
parent.append(sec2)
continue
# (카테고리) 이름들 — 새 카테고리이면 기존 버퍼를 먼저 flush
m = self._RE_CAT.match(line) m = self._RE_CAT.match(line)
if m: if m:
flush() flush()
@@ -104,7 +135,7 @@ class EmojiMarkupParser(BaseParser):
pending_names.extend(rest.split()) pending_names.extend(rest.split())
continue continue
# 이름 목록 줄 (카테고리 없이 연속) # 이름 목록 (연속 줄은 같은 그룹으로 병합)
pending_names.extend(line.split()) pending_names.extend(line.split())
flush() flush()

View File

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

View File

@@ -0,0 +1,83 @@
"""
ctstruct/renderers/emoji_markup.py
CTTree → 이모지 마크업 텍스트 렌더러
"""
from __future__ import annotations
from ..node import CTNode
from ..tree import CTTree
from ..types import NodeType
from .base import BaseRenderer
class EmojiMarkupRenderer(BaseRenderer):
"""
CTTree 를 이모지 마크업 텍스트로 렌더링.
EmojiMarkupParser 와 같은 구분자를 사용하면 완전한 왕복 변환(roundtrip)이 보장됩니다.
Args:
title_mark: 문서 제목 앞에 붙는 마커 (기본 "")
section_marks: 섹션 레벨별 마커 목록 (기본 ["🔹", "🔸"])
인덱스 0 → level 1, 인덱스 1 → level 2, …
names_per_line: 한 줄에 출력할 최대 이름 수 (기본 0 = 제한 없음)
"""
def __init__(
self,
title_mark: str = "",
section_marks: list[str] | None = None,
names_per_line: int = 0,
) -> None:
self.title_mark = title_mark
self.section_marks = section_marks if section_marks is not None else ["🔹", "🔸"]
self.names_per_line = names_per_line
def render(self, tree: CTTree) -> str:
lines: list[str] = []
title = tree.root.attr("title")
if title:
lines.append(f"{self.title_mark}{title}")
lines.append("")
for child in tree.root.children:
lines.extend(self._render_node(child))
return "\n".join(lines).rstrip() + "\n"
# ── 내부 ─────────────────────────────────────────────────
def _render_node(self, node: CTNode) -> list[str]:
if node.node_type == NodeType.SECTION:
return self._render_section(node)
if node.node_type == NodeType.LIST:
return self._render_list(node)
return []
def _render_section(self, node: CTNode) -> list[str]:
level = node.attr("level", 1)
mark = self.section_marks[level - 1] if level <= len(self.section_marks) else ""
out = [f"{mark}{node.content or ''}"]
for child in node.children:
out.extend(self._render_node(child))
return out
def _render_list(self, node: CTNode) -> list[str]:
names = [item.content or "" for item in node.children
if item.node_type == NodeType.LIST_ITEM]
category = node.attr("category")
prefix = f"({category}) " if category else ""
if self.names_per_line > 0:
chunks = [names[i:i + self.names_per_line]
for i in range(0, len(names), self.names_per_line)]
lines = [prefix + " ".join(chunk) for chunk in chunks]
# prefix는 첫 줄에만
if len(lines) > 1:
lines = [lines[0]] + [" ".join(chunk) for chunk in chunks[1:]]
else:
lines = [prefix + " ".join(names)] if names else []
return lines + [""] # 목록 뒤 빈 줄

View File

@@ -6,20 +6,53 @@ CTTree → JSON 렌더러
from __future__ import annotations from __future__ import annotations
import json import json
from typing import Any
from ..tree import CTTree from ..tree import CTTree
from .base import BaseRenderer from .base import BaseRenderer
class JSONRenderer(BaseRenderer): class JSONRenderer(BaseRenderer):
"""CTTree 를 JSON 문자열로 직렬화.""" """
CTTree 를 JSON 문자열로 직렬화.
def __init__(self, indent: int = 2, ensure_ascii: bool = False) -> None: Args:
indent: 들여쓰기 공백 수 (기본 2)
ensure_ascii: ASCII 강제 인코딩 여부 (기본 False)
slim: True 이면 null·기본값 필드를 제거해 출력을 간소화
"""
def __init__(
self,
indent: int = 2,
ensure_ascii: bool = False,
slim: bool = False,
) -> None:
self.indent = indent self.indent = indent
self.ensure_ascii = ensure_ascii self.ensure_ascii = ensure_ascii
self.slim = slim
def render(self, tree: CTTree) -> str: def render(self, tree: CTTree) -> str:
return json.dumps( data = tree.to_dict()
tree.to_dict(), if self.slim:
indent=self.indent, data = self._strip(data)
ensure_ascii=self.ensure_ascii, return json.dumps(data, indent=self.indent, ensure_ascii=self.ensure_ascii)
)
# ── slim 헬퍼 ─────────────────────────────────────────────
@staticmethod
def _strip(obj: Any) -> Any:
"""None·기본값 필드를 재귀적으로 제거."""
if isinstance(obj, list):
return [JSONRenderer._strip(v) for v in obj]
if isinstance(obj, dict):
result: dict[str, Any] = {}
for k, v in obj.items():
if v is None:
continue
if k == "data_type" and v == "unset":
continue
if k == "attributes" and v == {}:
continue
result[k] = JSONRenderer._strip(v)
return result
return obj

View File

@@ -1,11 +1,13 @@
""" """
tests/test_emoji_markup_parser.py tests/test_emoji_markup_parser.py
EmojiMarkupParser 단위 테스트 EmojiMarkupParser / EmojiMarkupRenderer / JSONRenderer(slim) 단위 테스트
""" """
import json
import pytest import pytest
from ctstruct import NodeType from ctstruct import NodeType
from ctstruct.parsers import EmojiMarkupParser from ctstruct.parsers import EmojiMarkupParser
from ctstruct.renderers import EmojiMarkupRenderer, JSONRenderer
SAMPLE = """\ SAMPLE = """\
@@ -155,10 +157,161 @@ class TestEdgeCases:
assert len(lst[0].children) == 3 assert len(lst[0].children) == 3
def test_category_names_on_separate_line(self): def test_category_names_on_separate_line(self):
# 카테고리 줄 다음에 이름이 없고 다음 줄에 이름이 있는 경우
text = "🔹항목\n(건강)\n홍길동 홍길순\n" text = "🔹항목\n(건강)\n홍길동 홍길순\n"
tree = EmojiMarkupParser().parse(text) tree = EmojiMarkupParser().parse(text)
sec = tree.root.children[0] sec = tree.root.children[0]
lst = next(n for n in sec.children if n.node_type == NodeType.LIST) lst = next(n for n in sec.children if n.node_type == NodeType.LIST)
assert lst.attr("category") == "건강" assert lst.attr("category") == "건강"
assert len(lst.children) == 2 assert len(lst.children) == 2
# ── 커스텀 구분자 ─────────────────────────────────────────────────────────────
class TestCustomDelimiters:
def test_markdown_style_delimiters(self):
text = "# 제목\n## 1단계\n홍길동 홍길순\n## 2단계\n홍길영\n"
parser = EmojiMarkupParser(title_mark="# ", section_marks=["## "])
tree = parser.parse(text)
assert tree.root.attr("title") == "제목"
secs = tree.find(NodeType.SECTION)
assert len(secs) == 2
assert secs[0].content == "1단계"
def test_custom_string_delimiters(self):
text = "[제목] 기도 명단\n[1] 항목A\n홍길동\n[2] 소항목\n홍길순\n"
parser = EmojiMarkupParser(title_mark="[제목] ", section_marks=["[1] ", "[2] "])
tree = parser.parse(text)
assert tree.root.attr("title") == "기도 명단"
assert tree.root.children[0].content == "항목A"
# 항목A 하위: LIST(홍길동) 다음에 SECTION level=2 "소항목"
level2 = next(n for n in tree.find(NodeType.SECTION) if n.attr("level") == 2)
assert level2.content == "소항목"
def test_three_level_sections(self):
text = "✅문서\n🔹A\n🔸B\n🔻C\n홍길동\n"
parser = EmojiMarkupParser(section_marks=["🔹", "🔸", "🔻"])
tree = parser.parse(text)
level1 = tree.root.children[0]
level2 = level1.children[0]
level3 = level2.children[0]
assert level1.attr("level") == 1
assert level2.attr("level") == 2
assert level3.attr("level") == 3
assert level3.children[0].node_type == NodeType.LIST
def test_prefix_conflict_longer_wins(self):
# "##"이 "#"보다 먼저 매칭되어야 함
text = "# 제목\n## 2단계\n홍길동\n# 다른제목\n"
parser = EmojiMarkupParser(title_mark="TITLE:", section_marks=["#", "##"])
tree = parser.parse(text)
# "## 2단계"는 level=2 "##"로 매칭 (긴 것 우선)
all_secs = tree.find(NodeType.SECTION)
level2_secs = [s for s in all_secs if s.attr("level") == 2]
assert any(s.content == "2단계" for s in level2_secs)
def test_default_delimiters_unchanged(self):
# 기본 구분자가 바뀌지 않았는지 확인
tree = EmojiMarkupParser().parse(SAMPLE)
assert tree.root.attr("title") == "예시 양식"
assert len(tree.find(NodeType.LIST_ITEM)) == 9
# ── EmojiMarkupRenderer ───────────────────────────────────────────────────────
class TestEmojiMarkupRenderer:
def test_title_rendered(self, tree):
out = EmojiMarkupRenderer().render(tree)
assert out.startswith("✅예시 양식")
def test_section_marks_rendered(self, tree):
out = EmojiMarkupRenderer().render(tree)
assert "🔹1번 항목" in out
assert "🔹2번 항목" in out
assert "🔸2-1 항목" in out
assert "🔸2-2 항목" in out
def test_names_rendered(self, tree):
out = EmojiMarkupRenderer().render(tree)
assert "홍길동" in out
assert "홍길순" in out
def test_category_rendered(self, tree):
out = EmojiMarkupRenderer().render(tree)
assert "(건강)" in out
assert "(직장)" in out
def test_roundtrip(self, tree):
out = EmojiMarkupRenderer().render(tree)
tree2 = EmojiMarkupParser().parse(out)
assert len(tree.find(NodeType.LIST_ITEM)) == len(tree2.find(NodeType.LIST_ITEM))
assert len(tree.find(NodeType.SECTION)) == len(tree2.find(NodeType.SECTION))
assert tree.root.attr("title") == tree2.root.attr("title")
def test_custom_marks_renderer(self, tree):
out = EmojiMarkupRenderer(title_mark="# ", section_marks=["## ", "### "]).render(tree)
assert out.startswith("# 예시 양식")
assert "## 1번 항목" in out
assert "### 2-1 항목" in out
def test_names_per_line(self, tree):
out = EmojiMarkupRenderer(names_per_line=3).render(tree)
# 6명이 3명씩 두 줄로 쪼개져야 함
lines = [l for l in out.splitlines() if "홍길" in l and "(" not in l]
assert len(lines) == 2
assert len(lines[0].split()) == 3
assert len(lines[1].split()) == 3
# ── JSONRenderer slim 모드 ────────────────────────────────────────────────────
class TestJSONRendererSlim:
def test_slim_removes_null_fields(self, tree):
data = json.loads(JSONRenderer(slim=True).render(tree))
def check(node):
for k, v in node.items():
assert v is not None, f"null field found: {k}"
if isinstance(v, list):
for item in v:
if isinstance(item, dict):
check(item)
check(data)
def test_slim_removes_default_data_type(self, tree):
data = json.loads(JSONRenderer(slim=True).render(tree))
def check(node):
assert "data_type" not in node or node["data_type"] != "unset"
for child in node.get("children", []):
check(child)
check(data)
def test_slim_removes_empty_attributes(self, tree):
data = json.loads(JSONRenderer(slim=True).render(tree))
def check(node):
if "attributes" in node:
assert node["attributes"] != {}, "empty attributes should be removed"
for child in node.get("children", []):
check(child)
check(data)
def test_slim_preserves_content(self, tree):
slim = json.loads(JSONRenderer(slim=True).render(tree))
full = json.loads(JSONRenderer(slim=False).render(tree))
def collect_names(node):
names = []
if node.get("type") == "LIST_ITEM":
names.append(node.get("content"))
for child in node.get("children", []):
names.extend(collect_names(child))
return names
assert collect_names(slim) == collect_names(full)
def test_non_slim_keeps_null_fields(self, tree):
data = json.loads(JSONRenderer(slim=False).render(tree))
# ROOT 노드에 content: null 이 있어야 함
assert "content" in data
assert data["content"] is None