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:
@@ -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"]
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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 = ""
|
||||
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})"
|
||||
)
|
||||
|
||||
|
||||
@@ -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:
|
||||
@@ -154,6 +154,14 @@ class CTTree:
|
||||
"type": node.node_type.name,
|
||||
"content": node.content,
|
||||
"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],
|
||||
}
|
||||
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))
|
||||
|
||||
@@ -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" # 사용자 정의 타입
|
||||
|
||||
194
tests/test_node_fields.py
Normal file
194
tests/test_node_fields.py
Normal 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()
|
||||
Reference in New Issue
Block a user