feat(node): add header, footer, prefix, suffix, data_type, value fields

- CTNode에 6개 신규 필드 추가
  - header    : 머리글 (노드 블록 앞 텍스트)
  - footer    : 꼬리글 (노드 블록 뒤 텍스트)
  - prefix    : 접두사 (content 직전 인라인)
  - suffix    : 접미사 (content 직후 인라인)
  - data_type : 값의 자료형 힌트 (DataType enum)
  - value     : 해석된 값 (Any — str/int/float/bool/list/dict 등)
- types.py: DataType enum 추가 (UNSET, STRING~CODE, CUSTOM)
- node.py: full_text() 메서드 추가 (6개 필드 순서대로 조합)
- tree.py: to_dict / from_dict 직렬화에 신규 필드 포함
- 테스트: test_node_fields.py 신규 추가 (15개 케이스)
- 전체 테스트 42/42 통과
This commit is contained in:
2026-05-30 21:13:22 +09:00
parent 3f26cbb687
commit fd98a4c705
9 changed files with 341 additions and 32 deletions

View File

@@ -4,7 +4,7 @@ CTStruct - 텍스트 양식을 구조화하는 트리 자료형 라이브러리
from .node import CTNode from .node import CTNode
from .tree import CTTree from .tree import CTTree
from .types import NodeType, ListStyle, Alignment from .types import NodeType, ListStyle, Alignment, DataType
__version__ = "0.1.0" __version__ = "0.1.0"
__all__ = ["CTNode", "CTTree", "NodeType", "ListStyle", "Alignment"] __all__ = ["CTNode", "CTTree", "NodeType", "ListStyle", "Alignment", "DataType"]

View File

@@ -9,7 +9,7 @@ import uuid
from typing import Any, Iterator, Optional from typing import Any, Iterator, Optional
from dataclasses import dataclass, field from dataclasses import dataclass, field
from .types import NodeType from .types import NodeType, DataType
@dataclass @dataclass
@@ -17,15 +17,33 @@ class CTNode:
""" """
CTStruct 트리의 기본 노드. CTStruct 트리의 기본 노드.
┌─────────────────────────────────────────────────────────┐
│ [header] │
│ prefix ←── content / value ──► suffix │
│ [footer] │
└─────────────────────────────────────────────────────────┘
Attributes: Attributes:
node_type : 노드의 의미적 타입 node_type : 노드의 의미적 타입 (NodeType)
content : 노드가 직접 보유하는 텍스트 (없으면 None) content : 노드가 직접 보유하는 원문 텍스트 (없으면 None)
attributes : 노드 메타정보 (level, lang, href, …) attributes : 노드 메타정보 (level, lang, href, …)
node_id : 고유 식별자 (자동 생성) node_id : 고유 식별자 (자동 생성, 8자리 hex)
parent : 부모 노드 (루트는 None) parent : 부모 노드 (루트는 None)
children : 자식 노드 목록 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 node_type : NodeType
content : Optional[str] = None content : Optional[str] = None
attributes : dict[str, Any] = field(default_factory=dict) attributes : dict[str, Any] = field(default_factory=dict)
@@ -33,7 +51,17 @@ class CTNode:
parent : Optional["CTNode"] = field(default=None, repr=False, compare=False) parent : Optional["CTNode"] = field(default=None, repr=False, compare=False)
children : list["CTNode"] = field(default_factory=list) 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": def append(self, child: "CTNode") -> "CTNode":
"""자식 노드를 추가하고 self 반환 (체이닝 지원).""" """자식 노드를 추가하고 self 반환 (체이닝 지원)."""
@@ -64,7 +92,7 @@ class CTNode:
self.parent.remove(self) self.parent.remove(self)
return self return self
# ── 탐색 ───────────────────────────────────────────────── # ── 탐색 ─────────────────────────────────────────────────
@property @property
def depth(self) -> int: def depth(self) -> int:
@@ -134,13 +162,13 @@ class CTNode:
return None return None
def find_by_id(self, node_id: str) -> Optional["CTNode"]: def find_by_id(self, node_id: str) -> Optional["CTNode"]:
"""node_id로 노드 탐색.""" """node_id 로 노드 탐색."""
for n in self.walk(): for n in self.walk():
if n.node_id == node_id: if n.node_id == node_id:
return n return n
return None return None
# ── 속성 접근 헬퍼 ─────────────────────────────────────── # ── 속성 접근 헬퍼 ───────────────────────────────────────
def attr(self, key: str, default: Any = None) -> Any: def attr(self, key: str, default: Any = None) -> Any:
return self.attributes.get(key, default) return self.attributes.get(key, default)
@@ -149,34 +177,64 @@ class CTNode:
self.attributes[key] = value self.attributes[key] = value
return self 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: def text_content(self) -> str:
""" """
자신과 모든 하위 노드의 텍스트를 이어 붙여 반환. 자신과 모든 하위 노드의 full_text() 를 이어 붙여 반환.
렌더러 없이 순수 텍스트 추출에 사용. 렌더러 없이 순수 텍스트 추출에 사용.
""" """
parts: list[str] = [] return "".join(n.full_text() for n in self.walk("pre"))
for node in self.walk("pre"):
if node.content: # ── 표현 ──────────────────────────────────────────────────
parts.append(node.content)
return "".join(parts)
def pretty(self, indent: int = 2) -> str: def pretty(self, indent: int = 2) -> str:
"""트리 구조를 사람이 읽기 좋은 형태로 출력.""" """트리 구조를 사람이 읽기 좋은 형태로 출력."""
pad = " " * (self.depth * indent) pad = " " * (self.depth * indent)
tag = self.node_type.name tag = self.node_type.name
preview = repr(self.content[:20] + "") if self.content and len(self.content) > 20 else repr(self.content) dtype = f" dtype={self.data_type.value}" if self.data_type != DataType.UNSET else ""
attrs = ""
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: if self.attributes:
attrs = " " + " ".join(f"{k}={v!r}" for k, v in self.attributes.items()) for k, v in self.attributes.items():
line = f"{pad}[{self.node_id}] {tag}{attrs}: {preview}" 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) child_lines = "\n".join(c.pretty(indent) for c in self.children)
return f"{line}\n{child_lines}" if child_lines else line return f"{line}\n{child_lines}" if child_lines else line
def __repr__(self) -> str: def __repr__(self) -> str:
return ( return (
f"CTNode(id={self.node_id!r}, type={self.node_type.name}, " 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})" f"children={len(self.children)}, content={self.content!r})"
) )

View File

@@ -8,7 +8,7 @@ from __future__ import annotations
from typing import Any, Callable, Iterator, Optional from typing import Any, Callable, Iterator, Optional
from .node import CTNode from .node import CTNode
from .types import NodeType from .types import NodeType, DataType
class CTTree: class CTTree:
@@ -154,6 +154,14 @@ class CTTree:
"type": node.node_type.name, "type": node.node_type.name,
"content": node.content, "content": node.content,
"attributes": node.attributes, "attributes": node.attributes,
# ── 텍스트 장식 ──
"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], "children": [_serialize(c) for c in node.children],
} }
return _serialize(self.root) return _serialize(self.root)
@@ -164,11 +172,23 @@ class CTTree:
tree = cls() tree = cls()
def _deserialize(d: dict) -> CTNode: 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 = CTNode(
node_type=NodeType[d["type"]], node_type = NodeType[d["type"]],
content=d.get("content"), content = d.get("content"),
attributes=d.get("attributes", {}), attributes = d.get("attributes", {}),
node_id=d.get("id", ""), 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", []): for child_data in d.get("children", []):
node.append(_deserialize(child_data)) node.append(_deserialize(child_data))

View File

@@ -53,3 +53,40 @@ class Alignment(Enum):
CENTER = "center" CENTER = "center"
RIGHT = "right" RIGHT = "right"
NONE = "none" 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" # 사용자 정의 타입

194
tests/test_node_fields.py Normal file
View File

@@ -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()