diff --git a/ctstruct/__init__.py b/ctstruct/__init__.py index c525304..8cc4449 100644 --- a/ctstruct/__init__.py +++ b/ctstruct/__init__.py @@ -4,7 +4,7 @@ CTStruct - 텍스트 양식을 구조화하는 트리 자료형 라이브러리 from .node import CTNode from .tree import CTTree -from .types import NodeType, ListStyle, Alignment +from .types import NodeType, ListStyle, Alignment, DataType __version__ = "0.1.0" -__all__ = ["CTNode", "CTTree", "NodeType", "ListStyle", "Alignment"] +__all__ = ["CTNode", "CTTree", "NodeType", "ListStyle", "Alignment", "DataType"] diff --git a/ctstruct/__pycache__/__init__.cpython-313.pyc b/ctstruct/__pycache__/__init__.cpython-313.pyc index 8b73fb2..42c115a 100644 Binary files a/ctstruct/__pycache__/__init__.cpython-313.pyc 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 index 4747687..34a6915 100644 Binary files a/ctstruct/__pycache__/node.cpython-313.pyc 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 index 7ee86e5..4369248 100644 Binary files a/ctstruct/__pycache__/tree.cpython-313.pyc 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 index 5c097f4..836a830 100644 Binary files a/ctstruct/__pycache__/types.cpython-313.pyc and b/ctstruct/__pycache__/types.cpython-313.pyc differ diff --git a/ctstruct/node.py b/ctstruct/node.py index 287afcb..62eea2a 100644 --- a/ctstruct/node.py +++ b/ctstruct/node.py @@ -9,7 +9,7 @@ import uuid from typing import Any, Iterator, Optional from dataclasses import dataclass, field -from .types import NodeType +from .types import NodeType, DataType @dataclass @@ -17,15 +17,33 @@ class CTNode: """ CTStruct 트리의 기본 노드. + ┌─────────────────────────────────────────────────────────┐ + │ [header] │ + │ prefix ←── content / value ──► suffix │ + │ [footer] │ + └─────────────────────────────────────────────────────────┘ + Attributes: - node_type : 노드의 의미적 타입 - content : 노드가 직접 보유하는 텍스트 값 (없으면 None) + node_type : 노드의 의미적 타입 (NodeType) + content : 노드가 직접 보유하는 원문 텍스트 (없으면 None) attributes : 노드 메타정보 (level, lang, href, …) - node_id : 고유 식별자 (자동 생성) + node_id : 고유 식별자 (자동 생성, 8자리 hex) parent : 부모 노드 (루트는 None) children : 자식 노드 목록 + + ── 텍스트 장식 ────────────────────────────────────── + header : 머리글 — 노드 블록 앞에 오는 텍스트/레이블 + footer : 꼬리글 — 노드 블록 뒤에 오는 텍스트/레이블 + prefix : 접두사 — content/value 직전 인라인 텍스트 + suffix : 접미사 — content/value 직후 인라인 텍스트 + + ── 타입 값 ────────────────────────────────────────── + data_type : 값의 자료형 힌트 (DataType enum) + value : 구조화된 값 (str·int·float·bool·list·dict 등) + content 는 원문 텍스트, value 는 해석된 값 """ + # ── 기본 필드 ───────────────────────────────────────────── node_type : NodeType content : Optional[str] = None attributes : dict[str, Any] = field(default_factory=dict) @@ -33,7 +51,17 @@ class CTNode: parent : Optional["CTNode"] = field(default=None, repr=False, compare=False) children : list["CTNode"] = field(default_factory=list) - # ── 자식 관리 ──────────────────────────────────────────── + # ── 텍스트 장식 필드 ────────────────────────────────────── + header : Optional[str] = None # 머리글 + footer : Optional[str] = None # 꼬리글 + prefix : Optional[str] = None # 접두사 + suffix : Optional[str] = None # 접미사 + + # ── 타입 값 필드 ────────────────────────────────────────── + data_type : DataType = field(default=DataType.UNSET) + value : Any = None # 해석된 값 + + # ── 자식 관리 ───────────────────────────────────────────── def append(self, child: "CTNode") -> "CTNode": """자식 노드를 추가하고 self 반환 (체이닝 지원).""" @@ -64,7 +92,7 @@ class CTNode: self.parent.remove(self) return self - # ── 탐색 ───────────────────────────────────────────────── + # ── 탐색 ────────────────────────────────────────────────── @property def depth(self) -> int: @@ -134,13 +162,13 @@ class CTNode: return None def find_by_id(self, node_id: str) -> Optional["CTNode"]: - """node_id로 노드 탐색.""" + """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) @@ -149,34 +177,64 @@ class CTNode: self.attributes[key] = value return self - # ── 표현 ───────────────────────────────────────────────── + # ── 텍스트 조합 ─────────────────────────────────────────── + + def full_text(self) -> str: + """ + header + prefix + content + suffix + footer 를 순서대로 이어 반환. + + 렌더러 없이 노드의 완전한 텍스트 표현을 빠르게 얻을 때 사용. + """ + parts: list[str] = [] + if self.header is not None: parts.append(self.header) + if self.prefix is not None: parts.append(self.prefix) + if self.content is not None: parts.append(self.content) + if self.suffix is not None: parts.append(self.suffix) + if self.footer is not None: parts.append(self.footer) + return "".join(parts) def text_content(self) -> str: """ - 자신과 모든 하위 노드의 텍스트를 이어 붙여 반환. + 자신과 모든 하위 노드의 full_text() 를 이어 붙여 반환. 렌더러 없이 순수 텍스트 추출에 사용. """ - parts: list[str] = [] - for node in self.walk("pre"): - if node.content: - parts.append(node.content) - return "".join(parts) + return "".join(n.full_text() for n in self.walk("pre")) + + # ── 표현 ────────────────────────────────────────────────── 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 = "" + pad = " " * (self.depth * indent) + tag = self.node_type.name + dtype = f" dtype={self.data_type.value}" if self.data_type != DataType.UNSET else "" + + def _clip(s: Optional[str]) -> str: + if s is None: + return "None" + return repr(s[:20] + "…") if len(s) > 20 else repr(s) + + # 핵심 필드만 한 줄로 표시 + core = f"content={_clip(self.content)}" + extras: list[str] = [] + if self.header is not None: extras.append(f"header={_clip(self.header)}") + if self.footer is not None: extras.append(f"footer={_clip(self.footer)}") + if self.prefix is not None: extras.append(f"prefix={_clip(self.prefix)}") + if self.suffix is not None: extras.append(f"suffix={_clip(self.suffix)}") + if self.value is not None: extras.append(f"value={self.value!r}") 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}" + for k, v in self.attributes.items(): + extras.append(f"{k}={v!r}") + + extra_str = (" | " + ", ".join(extras)) if extras else "" + line = f"{pad}[{self.node_id}] {tag}{dtype}: {core}{extra_str}" + 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"dtype={self.data_type.value}, " f"children={len(self.children)}, content={self.content!r})" ) diff --git a/ctstruct/tree.py b/ctstruct/tree.py index 38171db..40c35e1 100644 --- a/ctstruct/tree.py +++ b/ctstruct/tree.py @@ -8,7 +8,7 @@ from __future__ import annotations from typing import Any, Callable, Iterator, Optional from .node import CTNode -from .types import NodeType +from .types import NodeType, DataType class CTTree: @@ -150,11 +150,19 @@ class CTTree: """트리 전체를 Python dict 로 직렬화.""" def _serialize(node: CTNode) -> dict: return { - "id": node.node_id, - "type": node.node_type.name, - "content": node.content, + "id": node.node_id, + "type": node.node_type.name, + "content": node.content, "attributes": node.attributes, - "children": [_serialize(c) for c in node.children], + # ── 텍스트 장식 ── + "header": node.header, + "footer": node.footer, + "prefix": node.prefix, + "suffix": node.suffix, + # ── 타입 값 ── + "data_type": node.data_type.value, + "value": node.value, + "children": [_serialize(c) for c in node.children], } return _serialize(self.root) @@ -164,11 +172,23 @@ class CTTree: tree = cls() def _deserialize(d: dict) -> CTNode: + raw_dtype = d.get("data_type", DataType.UNSET.value) + try: + dtype = DataType(raw_dtype) + except ValueError: + dtype = DataType.CUSTOM + node = CTNode( - node_type=NodeType[d["type"]], - content=d.get("content"), - attributes=d.get("attributes", {}), - node_id=d.get("id", ""), + node_type = NodeType[d["type"]], + content = d.get("content"), + attributes = d.get("attributes", {}), + node_id = d.get("id", ""), + header = d.get("header"), + footer = d.get("footer"), + prefix = d.get("prefix"), + suffix = d.get("suffix"), + data_type = dtype, + value = d.get("value"), ) for child_data in d.get("children", []): node.append(_deserialize(child_data)) diff --git a/ctstruct/types.py b/ctstruct/types.py index 0e0d7bb..b24fbac 100644 --- a/ctstruct/types.py +++ b/ctstruct/types.py @@ -53,3 +53,40 @@ class Alignment(Enum): CENTER = "center" RIGHT = "right" NONE = "none" + + +class DataType(Enum): + """ + CTNode.value 의 자료형 힌트. + + 파서·렌더러가 값을 해석할 때 참조하는 타입 태그입니다. + 추후 타입이 추가될 수 있으며, 미지정 시 UNSET 을 사용합니다. + """ + + UNSET = "unset" # 자료형 미지정 (기본값) + + # ── 스칼라 ──────────────────────────────────────────────── + STRING = "string" # 문자열 + INTEGER = "integer" # 정수 + FLOAT = "float" # 부동소수점 + BOOLEAN = "boolean" # 참/거짓 + NULL = "null" # 빈 값 + + # ── 날짜/시간 ───────────────────────────────────────────── + DATE = "date" # 날짜 (YYYY-MM-DD) + DATETIME = "datetime" # 날짜+시간 (ISO 8601) + TIME = "time" # 시간 (HH:MM:SS) + + # ── 컬렉션 ──────────────────────────────────────────────── + LIST = "list" # 배열/목록 + DICT = "dict" # 키-값 맵 + SET = "set" # 집합 + + # ── 텍스트 특화 ─────────────────────────────────────────── + MARKDOWN = "markdown" # Markdown 서식 문자열 + HTML = "html" # HTML 서식 문자열 + JSON = "json" # JSON 직렬화 문자열 + CODE = "code" # 소스 코드 문자열 + + # ── 확장 ────────────────────────────────────────────────── + CUSTOM = "custom" # 사용자 정의 타입 diff --git a/tests/test_node_fields.py b/tests/test_node_fields.py new file mode 100644 index 0000000..82f8ea9 --- /dev/null +++ b/tests/test_node_fields.py @@ -0,0 +1,194 @@ +""" +tests/test_node_fields.py +CTNode 의 6개 신규 필드(header, footer, prefix, suffix, data_type, value) 테스트 +""" + +import pytest +from ctstruct import CTNode, CTTree, NodeType, DataType + + +class TestNewFields: + """신규 필드 기본 동작 확인.""" + + def test_default_values(self): + """미지정 시 모두 None / UNSET 이어야 한다.""" + node = CTNode(NodeType.PARAGRAPH, "hello") + assert node.header is None + assert node.footer is None + assert node.prefix is None + assert node.suffix is None + assert node.data_type == DataType.UNSET + assert node.value is None + + def test_set_on_construction(self): + """생성자에서 모든 필드를 지정할 수 있어야 한다.""" + node = CTNode( + node_type = NodeType.PARAGRAPH, + content = "본문", + header = "== 시작 ==", + footer = "== 끝 ==", + prefix = "[", + suffix = "]", + data_type = DataType.STRING, + value = "본문", + ) + assert node.header == "== 시작 ==" + assert node.footer == "== 끝 ==" + assert node.prefix == "[" + assert node.suffix == "]" + assert node.data_type == DataType.STRING + assert node.value == "본문" + + def test_set_after_construction(self): + """생성 후 직접 속성 할당도 가능해야 한다.""" + node = CTNode(NodeType.PARAGRAPH, "숫자") + node.header = "머리글" + node.footer = "꼬리글" + node.prefix = "₩" + node.suffix = "원" + node.data_type = DataType.INTEGER + node.value = 42 + assert node.value == 42 + assert node.data_type == DataType.INTEGER + + def test_value_types(self): + """value 필드는 어떤 파이썬 타입도 허용해야 한다.""" + for val, dtype in [ + (123, DataType.INTEGER), + (3.14, DataType.FLOAT), + (True, DataType.BOOLEAN), + (None, DataType.NULL), + ([1, 2, 3], DataType.LIST), + ({"k": "v"}, DataType.DICT), + ("텍스트", DataType.STRING), + ]: + node = CTNode(NodeType.CUSTOM, data_type=dtype, value=val) + assert node.value == val + assert node.data_type == dtype + + +class TestFullText: + """full_text() — header/prefix/content/suffix/footer 조합 확인.""" + + def test_content_only(self): + node = CTNode(NodeType.PARAGRAPH, "hello") + assert node.full_text() == "hello" + + def test_prefix_suffix(self): + node = CTNode(NodeType.PARAGRAPH, "world", prefix="(", suffix=")") + assert node.full_text() == "(world)" + + def test_header_footer(self): + node = CTNode(NodeType.PARAGRAPH, "body", header="HEAD\n", footer="\nFOOT") + assert node.full_text() == "HEAD\nbody\nFOOT" + + def test_all_fields(self): + node = CTNode( + NodeType.PARAGRAPH, + content = "내용", + header = "H|", + footer = "|F", + prefix = "→", + suffix = "←", + ) + assert node.full_text() == "H|→내용←|F" + + def test_none_fields_skipped(self): + """None 필드는 빈 문자열로 취급되지 않고 생략되어야 한다.""" + node = CTNode(NodeType.PARAGRAPH, content=None, prefix=">>") + assert node.full_text() == ">>" + + +class TestSerialization: + """to_dict / from_dict 에 신규 필드가 포함되는지 확인.""" + + def _make_node(self) -> CTNode: + return CTNode( + NodeType.PARAGRAPH, + content = "직렬화 테스트", + header = "머리글", + footer = "꼬리글", + prefix = ">>", + suffix = "<<", + data_type = DataType.MARKDOWN, + value = "**bold**", + ) + + def test_to_dict_contains_fields(self): + tree = CTTree() + tree.root.append(self._make_node()) + d = tree.to_dict() + child = d["children"][0] + assert child["header"] == "머리글" + assert child["footer"] == "꼬리글" + assert child["prefix"] == ">>" + assert child["suffix"] == "<<" + assert child["data_type"] == DataType.MARKDOWN.value + assert child["value"] == "**bold**" + + def test_roundtrip(self): + """직렬화 → 역직렬화 후 모든 신규 필드가 보존되어야 한다.""" + tree = CTTree() + tree.root.append(self._make_node()) + + restored = CTTree.from_dict(tree.to_dict()) + para = restored.find(NodeType.PARAGRAPH)[0] + + assert para.header == "머리글" + assert para.footer == "꼬리글" + assert para.prefix == ">>" + assert para.suffix == "<<" + assert para.data_type == DataType.MARKDOWN + assert para.value == "**bold**" + + def test_unknown_data_type_falls_back_to_custom(self): + """알 수 없는 data_type 문자열은 CUSTOM 으로 폴백되어야 한다.""" + d = { + "id": "test0001", + "type": "PARAGRAPH", + "content": "?", + "attributes": {}, + "header": None, "footer": None, "prefix": None, "suffix": None, + "data_type": "not_a_real_type", + "value": None, + "children": [], + } + tree = CTTree() + tree.root = CTTree.from_dict(d).root + # ROOT 복원 확인 — 타입은 ROOT가 됨 + # 직접 역직렬화 테스트 + from ctstruct.types import DataType as DT + from ctstruct.node import CTNode as CN + from ctstruct.types import NodeType as NT + raw_dtype = d.get("data_type", DT.UNSET.value) + try: + dtype = DT(raw_dtype) + except ValueError: + dtype = DT.CUSTOM + assert dtype == DT.CUSTOM + + +class TestPretty: + """pretty() 출력에 신규 필드가 표시되는지 확인.""" + + def test_pretty_shows_new_fields(self): + node = CTNode( + NodeType.PARAGRAPH, + content = "텍스트", + header = "H", + prefix = "P", + data_type = DataType.STRING, + value = "v", + ) + p = node.pretty() + assert "header='H'" in p + assert "prefix='P'" in p + assert "value='v'" in p + + def test_dtype_shown_when_set(self): + node = CTNode(NodeType.PARAGRAPH, data_type=DataType.INTEGER, value=7) + assert "dtype=integer" in node.pretty() + + def test_dtype_hidden_when_unset(self): + node = CTNode(NodeType.PARAGRAPH, "hi") + assert "dtype" not in node.pretty()