From a68a79411fc4a49a5d354b276ba1d0d68d0f7c9f Mon Sep 17 00:00:00 2001 From: gm Date: Mon, 1 Jun 2026 18:30:13 +0900 Subject: [PATCH] feat: slim JSON, EmojiMarkupRenderer, configurable delimiters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- ctstruct/parsers/emoji_markup.py | 121 +++++++++++++-------- ctstruct/renderers/__init__.py | 3 +- ctstruct/renderers/emoji_markup.py | 83 +++++++++++++++ ctstruct/renderers/json_renderer.py | 47 +++++++-- tests/test_emoji_markup_parser.py | 157 +++++++++++++++++++++++++++- 5 files changed, 356 insertions(+), 55 deletions(-) create mode 100644 ctstruct/renderers/emoji_markup.py diff --git a/ctstruct/parsers/emoji_markup.py b/ctstruct/parsers/emoji_markup.py index 6764bf7..174b167 100644 --- a/ctstruct/parsers/emoji_markup.py +++ b/ctstruct/parsers/emoji_markup.py @@ -3,11 +3,16 @@ ctstruct/parsers/emoji_markup.py 이모지 마크업 포맷 → CTTree 파서 지원 포맷: - ✅제목 → 문서 제목 (root 속성) - 🔹1단계 섹션 → SECTION (level=1) - 🔸2단계 섹션 → SECTION (level=2) - (카테고리) 이름 → 카테고리 태그가 붙은 LIST + LIST_ITEM들 - 이름1 이름2 → 카테고리 없는 LIST + LIST_ITEM들 (줄 연속 가능) + 제목 → 문서 제목 (root 속성) + 1단계 → SECTION (level=1) + 2단계 → SECTION (level=2) + … + (카테고리) 이름 → 카테고리 태그가 붙은 LIST + LIST_ITEM들 + 이름1 이름2 → 카테고리 없는 LIST + LIST_ITEM들 (줄 연속 가능) + +기본 구분자: + title_mark = "✅" + section_marks = ["🔹", "🔸"] """ from __future__ import annotations @@ -21,36 +26,55 @@ from .base import BaseParser class EmojiMarkupParser(BaseParser): """ - ✅ / 🔹 / 🔸 이모지로 계층을 표현하는 텍스트 포맷 파서. + 임의 구분자로 계층을 표현하는 텍스트 포맷 파서. - 명단은 공백으로 구분된 이름 목록이며, 빈 줄 또는 새로운 섹션/카테고리 - 마커가 나올 때까지 같은 그룹으로 묶입니다. + 구분자는 이모지, 특수문자, 문자열 등 어떤 값도 사용할 수 있습니다. + + Args: + title_mark: 문서 제목 줄 앞에 붙는 마커 (기본 "✅") + section_marks: 섹션 레벨별 마커 목록 (기본 ["🔹", "🔸"]) + 인덱스 0 → level 1, 인덱스 1 → level 2, … + 더 긴 마커가 먼저 매칭되도록 내부에서 길이 내림차순 정렬됩니다. Examples: - >>> parser = EmojiMarkupParser() - >>> tree = parser.parse(text) - >>> tree.pretty() + # 기본 이모지 구분자 + parser = EmojiMarkupParser() + + # 마크다운 스타일 구분자 + parser = EmojiMarkupParser(title_mark="# ", section_marks=["## ", "### "]) + + # 커스텀 구분자 + parser = EmojiMarkupParser(title_mark="[제목]", section_marks=["[1]", "[2]", "[3]"]) """ - _TITLE = "✅" - _SEC1 = "🔹" - _SEC2 = "🔸" - _RE_CAT = re.compile(r'^\(([^)]+)\)\s*(.*)') + _RE_CAT = re.compile(r'^\(([^)]+)\)\s*(.*)', re.DOTALL) + + def __init__( + 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: tree = CTTree() - # 현재 컨텍스트 추적 - sec1: CTNode | None = None - sec2: CTNode | None = None + # 각 레벨별 현재 활성 섹션 노드 (인덱스 = level-1) + section_stack: list[CTNode | None] = [None] * len(self.section_marks) - # 아직 트리에 붙이지 않은 이름 버퍼 - pending_names: list[str] = [] - pending_category: str | None = None - pending_parent: CTNode | None = None # 이름 그룹이 붙을 부모 노드 + pending_names: list[str] = [] + pending_category: str | None = None + pending_parent: CTNode | None = None def flush() -> None: - """버퍼에 쌓인 이름을 LIST로 만들어 부모에 붙인다.""" nonlocal pending_names, pending_category, pending_parent if not pending_names: return @@ -60,9 +84,16 @@ class EmojiMarkupParser(BaseParser): lst.append(CTNode(NodeType.LIST_ITEM, name)) target = pending_parent if pending_parent is not None else tree.root target.append(lst) - pending_names = [] + pending_names.clear() 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(): line = raw.strip() @@ -70,31 +101,31 @@ class EmojiMarkupParser(BaseParser): flush() continue - # ✅ 문서 제목 - if line.startswith(self._TITLE): + # 제목 마커 + if line.startswith(self.title_mark): flush() - tree.root.set_attr("title", line[len(self._TITLE):].strip()) + tree.root.set_attr("title", line[len(self.title_mark):].strip()) continue - # 🔹 1단계 섹션 - if line.startswith(self._SEC1): - flush() - sec1 = CTNode(NodeType.SECTION, line[len(self._SEC1):].strip(), {"level": 1}) - sec2 = None - pending_parent = sec1 - tree.root.append(sec1) + # 섹션 마커 (긴 것 우선 매칭) + matched = False + for mark, level in self._sorted_marks: + if line.startswith(mark): + flush() + content = line[len(mark):].strip() + node = CTNode(NodeType.SECTION, content, {"level": level}) + # 현재 레벨 이하의 스택 초기화 + 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 - # 🔸 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) if m: flush() @@ -104,7 +135,7 @@ class EmojiMarkupParser(BaseParser): pending_names.extend(rest.split()) continue - # 이름 목록 줄 (카테고리 없이 연속) + # 이름 목록 (연속 줄은 같은 그룹으로 병합) pending_names.extend(line.split()) flush() diff --git a/ctstruct/renderers/__init__.py b/ctstruct/renderers/__init__.py index 8060cd5..2048299 100644 --- a/ctstruct/renderers/__init__.py +++ b/ctstruct/renderers/__init__.py @@ -1,5 +1,6 @@ from .base import BaseRenderer from .markdown import MarkdownRenderer from .json_renderer import JSONRenderer +from .emoji_markup import EmojiMarkupRenderer -__all__ = ["BaseRenderer", "MarkdownRenderer", "JSONRenderer"] +__all__ = ["BaseRenderer", "MarkdownRenderer", "JSONRenderer", "EmojiMarkupRenderer"] diff --git a/ctstruct/renderers/emoji_markup.py b/ctstruct/renderers/emoji_markup.py new file mode 100644 index 0000000..11a93f3 --- /dev/null +++ b/ctstruct/renderers/emoji_markup.py @@ -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 + [""] # 목록 뒤 빈 줄 diff --git a/ctstruct/renderers/json_renderer.py b/ctstruct/renderers/json_renderer.py index 84b23b5..929abb0 100644 --- a/ctstruct/renderers/json_renderer.py +++ b/ctstruct/renderers/json_renderer.py @@ -6,20 +6,53 @@ CTTree → JSON 렌더러 from __future__ import annotations import json +from typing import Any from ..tree import CTTree from .base import 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.ensure_ascii = ensure_ascii + self.slim = slim def render(self, tree: CTTree) -> str: - return json.dumps( - tree.to_dict(), - indent=self.indent, - ensure_ascii=self.ensure_ascii, - ) + data = tree.to_dict() + if self.slim: + data = self._strip(data) + 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 diff --git a/tests/test_emoji_markup_parser.py b/tests/test_emoji_markup_parser.py index 04e8c31..dcc0d27 100644 --- a/tests/test_emoji_markup_parser.py +++ b/tests/test_emoji_markup_parser.py @@ -1,11 +1,13 @@ """ tests/test_emoji_markup_parser.py -EmojiMarkupParser 단위 테스트 +EmojiMarkupParser / EmojiMarkupRenderer / JSONRenderer(slim) 단위 테스트 """ +import json import pytest from ctstruct import NodeType from ctstruct.parsers import EmojiMarkupParser +from ctstruct.renderers import EmojiMarkupRenderer, JSONRenderer SAMPLE = """\ @@ -155,10 +157,161 @@ class TestEdgeCases: assert len(lst[0].children) == 3 def test_category_names_on_separate_line(self): - # 카테고리 줄 다음에 이름이 없고 다음 줄에 이름이 있는 경우 text = "🔹항목\n(건강)\n홍길동 홍길순\n" tree = EmojiMarkupParser().parse(text) sec = tree.root.children[0] lst = next(n for n in sec.children if n.node_type == NodeType.LIST) assert lst.attr("category") == "건강" 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