From fd98a4c7054175d51451a7e4025a47bd1b236919 Mon Sep 17 00:00:00 2001 From: gm Date: Sat, 30 May 2026 21:13:22 +0900 Subject: [PATCH] feat(node): add header, footer, prefix, suffix, data_type, value fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 통과 --- ctstruct/__init__.py | 4 +- ctstruct/__pycache__/__init__.cpython-313.pyc | Bin 472 -> 496 bytes ctstruct/__pycache__/node.cpython-313.pyc | Bin 10554 -> 14335 bytes ctstruct/__pycache__/tree.cpython-313.pyc | Bin 11099 -> 11992 bytes ctstruct/__pycache__/types.cpython-313.pyc | Bin 1807 -> 2608 bytes ctstruct/node.py | 100 +++++++-- ctstruct/tree.py | 38 +++- ctstruct/types.py | 37 ++++ tests/test_node_fields.py | 194 ++++++++++++++++++ 9 files changed, 341 insertions(+), 32 deletions(-) create mode 100644 tests/test_node_fields.py 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 8b73fb21ad5f48920b5d4f80871ed4d47659c7bb..42c115a92e9c4a4587282a99736b2ccbebecac7a 100644 GIT binary patch delta 143 zcmcb?{DGPGGcPX}0}$|?lgiSc$ScX{GEv=F$cwXxQ-L9vwTMffA($Vr9OAxOD delta 121 zcmeyse1n9m=2XFImUjJ>;k z#qD->+Ub|@>3rv&?{&WOopZkPeZHHVY@#6D{dw-ubykY{Ef$o-N&tP=4Zsk^Qv$`) zylxM@TPNr^g!Oy$f*yXEJ1w%2V;efrh+XdM>U4_6 z?LdN1Gd8azg9OlbZ7`D|>HsCsz$+b33;GoluM-$ve}Ltg5+LJQ!N41!lz^oJ35C=F zBX3+!3yFLpU`+~^PXf*$-o%>$pRD3pJ_Y`>tk(-kd@6iNQ@$K96Z&L69k3aQg}zcq zUrXeYg_JVoQYtBB$)z+>T8bqys`Q4H0LCRg8%DH*&w(Ct<$9TX9+dJ)=^?%#mpaM_ zS^Pu%G6F5-zr-&mP&WTC&k-nxU(Y{4pxgs_d^KN)u)6m7fUDt)VsHfnR~&;|2DtV7 ziWuB-z-{1H#^5-9BVU3T;Q@XVznNc!@KIVQ1X{t@^4s}x znRX?=gRda?5`HIdBhV^ z-2W{>U8#Pj;B0Mwmg7GCreB&n*WBUuI^ABbk~{8j`J9~0l2_m3IFGND5XbbTHr3eL z(E(cEFB{6>-w)?ONJdPitzNIte%R-Ada6yx4^*d3UQlEJSIasRbt(LX-fM^`;Ubq_iUbC@@H(ECc+ob znFyz3`JA79Hou>FHoYU<)CP}))J$1!?csX)$a&B}m>>uNBZmYo2`siA_+xyYA zR#%4uehznZxIVh(YWD!#(%$Ul!mnJBnvxn(az!U-?fG(9EfY~1x*F*{C-(u(wX1->NX^R>5#Eaq!P zJ*bok>MjzC%PR#iE|rQMuZ;I%MdJpS0eF% zW}d0rAFA6Qe6lh4)y7cW0U;N=(8J`#1X=s-gux`pJQ5kf*$JPTJC31l`yyTCYKhcU z$wAQ|X}vE}aB&;w@kT|ti3Ukuhz4n&Lk(hiMT4}xthB(vD1;;&3h|qXObW<1ktLGW z9L-^M9qNE!h}uuP;8#j^N^30|)4&#))iPUjz9MLF0b9bC##P;Z;>TdyZ35Pth~YrH+v60x zLM42Np0}6)>yNw8I!*q!h-;NaG0Roq$NkCpf0(@pL!&^ z-;$R`%~@U?s6v)oT5@U5Ao~Ozn>9ppN&XZq`?%yTwbeG-s`F(7*@6X+1jcS%nY6*F zR~B!i7!-QJDOsooY*wAb1Dx^%O_tw- zGmsWsG_*J&4sb+ehE#kK2oyUCd&68Dwd&Cv!L@=l zrSBpq7UKx22A(cnj2r5DLlbi1brk@C9@P8r_$@oD7sr4JbG?%!GIISC=sU%acM~1Rf7rRtAkL7Yp_r;flhNc=nX^<2g^rsIB8@!pI9=^KG*-;NK4RC7&I0x90{_C2oUbb#YUoT zDHu1JM{SmBxi-X$72Wake*Km2^3H)vcxzT~aEiM$!>`jB-!6c^4N9RV`;UH}%GJ8qsz zY0f|upR^o|4C)EmqpJifmqFEm8`F_yryiwSpq;(2H|+Fdy1mI1l|*qs;f8JL(MeD% zx*fRhZcUCx&h^L|2i*?vIU<*OIq=L1sqK>+hv`sW zWyrEMXx#ew$Cj-gBh()^Ze*?+HZslXwgV(ec@Zk7CkVhT%40W}ox`wy?DTPJh~?l{ zOR*CwHcCgxs!1CDZ6L19VfO8{*3!lOX0 z@QFbuH-=S-4tKND0|x<*n5wv93Z@%sdP*U?3G9+LRv$B)k{(J~Hk+PxYWHl$(&3tc zn!fF~S;~^}i~c?%4k0rE^rj=l*|cD69;mY zPf>22U8iNrc-5KBgb?u&knrR5kglFJLoa(l;h^NT>7Y0Ld~aI({u90E6&1R8FUl-P zZw%>e2hOdMcI)kWRjWWa-mcej%XwDm!_C+knIrm!1XzQ(WM{NypwtG5n(qxIXgx@f zu1(j7wu&U)V5c|eQm8dLrH+#}QuVLFUptYr1m9sGG%0E<&%n3o4tA-JWAV~YNjs${X@myl=*GL-$>DU>kJ>=Jmv2!M5r2+Mv1iP7ak;amPTV**^E6 zw4JckTIfl-Hpy^hH9#L0(Y48j4~w@hfx<`C>5%_pG7WjmPF1Z$C$vDU1Pwr3QjYha z`*o`B2aVQIsAVDSWSv-sp0;lMHK`1f&N`M0o zl-N=dyO+nC8;AC6$S?gSQifMO0HBGU>-6Lq1nFp4y&QbEOm0a!n}eynEN zQu|ts-|<%2MA=kMsC3&fJz5j8)CP^Up#N)$j(a6z4f8$28YYHwpm`+w`c(k_H0SD; z$~q10MqG;G7-)Yx@R$ReOf(tV06bbP;8Eo5?{nd|`y!Vhj;F<^dI9}5p$)QFt{9OK zO%O&_?WKY^hqNe3z@42_!vHMiiD}uo`9pd!CG3I9Fslss5%q0OuRJSJ|CF6vFbtdF$-g`@cS8DGXT(18b%&RY7Bww3^WaS68zTYC)id z(=tPL8c#OpI7>sM$q*Hu3-C=Shb(qpsPLVm<(e74!a25i_UQetO-s*F?n!Mn}s7|Uz*VfXJGt)N~Y>P}b)0mbcm` z+NanXwoTJno2MGx$)&&GM|BEkw~zSxB#l zIo5L5*pAR#w9q@Nv&(tif&zji6H|ijZgRKAwljzqn6TY^qG?M z0P-E5?$d)&?m?s0qCKEw$EEx9kSO<{QESm2G}iYp_>POLg9%nnqekp`oA3r**0_Wg z1Cyf~+NT;cC>jbRis)R?DN+o>2kN4;Uwd?RV+zG5*%QfVRCPNaDX}N0BY;)r&3tkL zddYFgo}lmw19(KvO?HSU@F_56E{+(A@Wo=+r%-kVpj5j7AQMPRqC1IC(`=t!OY`Zl zYmCqy>XNv$&^)-!cC$Kih?v-uprRv6rbM%4(~DYzEbF<=6bqY7nl48=Uw%0SEnU((#ft?0v6@Ewm% z*aeXA0Az62PkW*+Aw;&R+11`D=F1uzJSwlZYu;FpuhUS`vl=oy5Ty2Z%`PqYRhe!4 z>F+-@x$XNkgKJ0Vk)n~+BZffcXv%a(34|Qp-~K_`k9Gk-RC%{iHbNV_6C~da0LecC zKyu;$kZ!&=b{oj`_C}H3*sX_{QMay~6Y0io-9Z4W0W`%JT2$w2kipvl*hEK}S~wzW zo7#P9cuO|6aM)R3Y{`v0@B$7fJL9U%Fj#Qg+$#OqkE^O#36>Eyt>5<5ti z0wu2?g}&3#BI>=4Hn_k-=@w2SmTB$~oFup=I6NZ#lt>>J6T#6C98LIQf`JgbQ%oWk z`%MUYJm}SPU&FAPoFv8#U~9@vza)1*2b3oW8|04|T2A-33?Cgh8h9v_Wer(M`}BAk zeX;*V=`;$(wGUsbzf%9xBR_M0=$>A?&tEc9e8Dzqo5{6>a&5t!%1~-m@T&*<*n5mV zaXSr;a(eFQiU4!Te9=6eTM^8$1uc~jx(4^zp!&Qk0>>}CaPfu7XRiA`*!Q!>4;!a9 zKOWrF5M0OKDB1V7p{nk$4kan$IzH9%lcU#Z8pibj-?)8z*W~KSmdPik9+|2MT6Xm@pJe3@ zw-2=6SY9_({F93JDyEm$O=s2hCH=bWA%C$y%P)+SjT8heB|+nssqK(S9vv*Knowkk zNB7SFEbh;tJtF=Uy1H>m>$nZni}-R4#<=QG1Zt)cM?#XeeO9z9(6T_G;iHi;8rGmK zNvQ9ZAJDIAZvmB7&JnZFZ7i1!M0Zj-r@R5$*lmJ>^t=|doz-fTB2O5x6kf%w3^NRh zND3TxsI;}?-EU)46Fy36YEol$3gQ;j@i-uZ9hH(jvgAVkXnrVr)p)`rb1mgc%1l*l zsH!$tx&1ap@1Qdw-aHZ_ zf{pqvRgJ>-VJ%%0g$%zlj~5wyHK`D-nE&ua`XlncIhlWN2e0VJ$(0cf10gY?QsY0w zdW>`*V8Tyi(4@5m*dmFR*hnQh-FkFgn$e4-^pH{|4z6h&ym=7 z`X;V2j0_F1fYX+GBOe*U&L}LYUL}Hfp(_AuXliOY*3sf~A#84HdfMl3$zLo@O|9*M z$BP!(-2tCr&$M(j0{~y5{sj-H4P0;mm8HnwBTy2hl@wDkLE@7}NzRFbu?opae2e%N zs7-}o%;uGs4T}$9whl9V6(nqdOf+^19pG1ZyCk=!4nOS0>@m#tVYVN$Lzo@G>?zFf zt%Kmf>^No^6%kHihAxHhCT0_u{Q$E`$i&Pha8BUgC^eHM0M}uf$(b#+_3ZK&Of$CsEUo;236<{X+3jC8&Fp|;Xv=|6 z=|T9MG0hx=@)YyIx?sasLZ$nAb_UamrkVX}ojOtnZbr%~dv=oMu;IlaxVtN>nlk<* z<$6k}bXU(K{_JUH7gpJUo!Rag8FtA%^L(af^Y59UcsrFcq}CiKylo^{ znk|u$0Kr(0l4grkT#<~^vDIqjYPl}MZc|!B@Sd4vmw%R$#crqXRp~G{vM{0)$UaM_ z*$3|#bT~jWyX;;*%Wk1ROV4As()X%a)^smL#}?i*LdSnF&}^}^7`P5ri|`}(L6L6t zfddR8Er9l;ge=4wz^uYk4r!~Q#uE&PPQu6A`JaE>gZtH29zN6FA%woOS(f4WG?>;G(1nN delta 3852 zcmZ7(Yiv{3`5yaT$957Yek5@m$8j9zVMz0WG?WHX%45)$069skg$(1wm((QK>9HM3 zmoBW&&<%xfpo9sHR9!2OCIo_wcGI^0sI-@~CQVnet8JP#RT_nMVN~nZ{n)m5ffdjPXtBTwX#*`0n4LCKx4;~IPTE9E z5pT`dWGT7mdM!aVdr z>KF7f+D0n{=B4XsKw#zcA-bMcWn_KyVHytvrp?{Bt2YZ^>FH6zPa13Z-<5TIwW&G(vzL$Ge(eXJ zz4ng()5|CMu;~TUHz_AA;iy5<_3tH|0uWLFks+!np}J& zkDCG@&4S#LA1EzFv<9{Fb*~MPkr6c>t>JA2esk4Afpa34x%qJZ*>+tEMNP4IQe`Pt z3m-`X0KcE1AuVTwsi>{lGR89B^?Z{pWO9SZfRR6w@8U1no_nYsK0`)Uhl)xp9#x-Z zA;6PH0RD_c_5gCTq3Ld0AhcRoRJdTGEr@IrQx7FZRVD(wKPI+zxdmNv9rqOm(SoCt z_Z6OV3q_Q>NJKbE0yr#*7ur2!17B?qfpDI)l*jE;jVP=sQFSEsG;0GqD-0-m79EeTyI_LF^AKP;X$kRTr_t=_1gV1h+eCQeXOo=K}|mF)r>Nd*9$X7)UvgwK2N z9gYkSsaJnjdWi&)&TIfK>KP$$_Barh@`2Bk6_e-r^|IT}eJCDY$^Eg{N50K}?`_8I zE-2;x^2k=qN>LTD=&d)-`ER}a&Mkh<|JjQtZohb}p%b=1_$>#iOeQkK@Av!5@&K9O zpO&L#ukXo6H>13P+2N%b2h~(Ii;Y9-5#|HxQp_kKM^Y(vZ_)TM#jpAN*?PdL{M=Vt zC#(`-H5<%9R98kL!)bNNB!9TVM^5qH3a@T~U`1K4f#_+eQHm38_ZD|i(uzq8k5k&- z44Yu+F?1Pp%5sQjhffrEJBuZ$hjihI-S?m_f5z?2+qp)PawWe+4P7~Wce#sS$}^jS zuzBg{_<^b#%f0r*r>l0Yr(i~tLbATg*v|A;BOgq};@M48Mj|Yg)RX02>GQt@YlglD z3Q0`(qtc>P^7c_6?2ylVg)($Ho>4i_YXtpP~ap9^v zid0m+Xp#yYlZTHTK9T;;w`R@dOthcnTVMv$;E~*IGVnds!~C~SiygYaUJKC0(<616 zVA&owHLO1+Ns%&O=B{8iyPZ`=YLF!=W2egQ`7YOea$v+7m#Gq0z-AtQ*I8zPouq`4 zt^y85%1j;!Ia%XN?=Cw;;N+isEKg)dVb%+J1cL^fL~}sdv80s6fMf14+N2%@FiVeY zlh&ByCTazzaCmuZ3C)GsV+Lgx-y5o~%IV3;CPX9$lEDOO)}9i zqta=`FYSbS!rU4pZN>v6WYEZlPF4a2LPpKd(4>(ejf{#iaAwv{n%8;vwig?G?JZ@`bypcQt}pne;<|pVzZy-73_zhd-oUIZ*TOYdKcfIfZfnNnbsQ-2S{I;iNd;8|M^_>cyNSzs*8k^ZX(=}UC zccFEzr0F9^^GE#$W(S9Ev=1id29xvssfnD0LI)IRZoy>7WXFQ5WV-5n-PyXiK=(Ua zezEJ_T{qhI%(d_NsGZISy5~LJbFS`*t^87h*D`qd>$3$N6Y>JT(a;nSHM%^Luogv) zVly}m6~nK%ig`!lTH9VoQ2l}QHS;UAUK5o8weq>fk{o4?1+vw||It{&3!A*VP5Qp$ zgki5D5GRDkpdZ-sp$kOK91g=(hK0jgL1vg{30Hufe1gByRJ+RkM|auumh(H$?woh8 znk`v5j&yTY}{0 z{AVqmaolOWx@0l5zcSbA3$4ZbR4UEl?E4@RG6@TsF&0k=IYDLTQCskJ?Io%vcP8R0 zcwZ99;4^4W$2EDpKam*DN>wd!zH;n$Zmp+ZVfK%|!VvA}u85a6(kd|@U^ z)t4rs1a%K+H)LZM;iXx_;R9(%S`}h20)e@BSKG!4;eTaJnz{W@BAOmnH?phzT3c=K c6R8ZY3WnS-4GFl=E=maR8i8;}5QMM)1H>$1nE(I) diff --git a/ctstruct/__pycache__/tree.cpython-313.pyc b/ctstruct/__pycache__/tree.cpython-313.pyc index 7ee86e5a9245d229aa78b14661dd2541977166f7..43692483d8fba7bd1711b5ffa2a6b1436438fcc9 100644 GIT binary patch delta 3596 zcmai0eN0=|6~EVhHvR^KA;w_y08@A&2|*1_XxcymbjjL~F2<>Z1D3&$;l|kX`k@56 z)zP+eNLjlkcT$6p0+IHSOk3NOO5Gn@m#yt4ty9*NO+~MjwrTrwQlUg@${*X#xwiRe z66Hzn_wL8Y1Rm+8@7Y-3g3C)!UTEy{ecizvGVzi+5-ReM zkfqIlBtn?;!r4o1gKp_f$JM^caaFf;$F$f&4ZR@Il7;K0^VI1<-ZlWm#Jv1SQ46i& zlSM~pfY(%3@^4$axoinKn}Co(t+LFj_%|$1!Te>*xMMqxQyhn3pLIhl<$QRH$Q|o( z8d!MJ_6RL@!-QhfO&S=KVeR}CdkfvhZ`f;V+hK-13ZNU48BLcGnmU#-LthpTEMah* z12xl*&nR{CGp;%ioOOl3AW?OCyO?Trn^&YOMuW{{b9$hQCnK zB1BHK?ScUy3Mm^(MTaJ%iGr+Nl!EPp{ucRu-tOL;li#^nUOF91p31MPo6;<oBIEBL>vy0Z2l?1Bt$ijIK)dlkEGNW^ss4`y!D zN~tv=8{4Fi zeGggZNGITn*!(`iG{T0wQS6~khjm*tqq0OSnV3>DMj*-ZSyqMQ^ZeE79oZ~2@0=z7 z@Yl|roH@A!pTU{IkNvxE`FCRn#{zY8>6tV#A~O*jyz7H=Laghf(K4V5s862W&U4hO*UnyA-(?|A&(nt$Ou(jy}4-kE-*LZkbMWcBMA8b zg4Ra-VCX0|5pbJiFd5#HzB$|=gcPf8{HCfgJMeW=xHr_(CxTJ8oronT)E>st5cpl; zP~Wkm;%w(~wYWFUI*sdA7CRk{V-nC!;**PCo5)>J%n-UG8do<$dHq^D%giF2XZe+y z=Im==K;}OH5YtQE$~n`F>C&lR?0fydwF4hFcid|3SoC&IA6YgSolT#5<@w4*Z}Tgr z+ZN*Sed=$Rk6kriNr=b~%!Oyd^X-fNolE{)`>{7l7yWw*y**b;XQPGAzV&A7{6_n( zW?g~5cXn{4-0pErcQ1SSHo5umo!j-q6IdlwST$EXQ+(01Y=qug2G7bb?cIdDM||CW z<9pQ<5VFLpFfI}UdkI^ZFWAdGDsL;Eg=Qh!V{$kr%PrS%3=<1%%Zizll%1cLmcEe6 zt=^Sgq`Y?Cv*>wfTKa)?skm&~4q4-`@^7FhxLCV~{+a)=R&nEv#fDG`200chw62c+ zi3b4#{A+a!jp81r;5>C)8-*>mfvE+{9vt`?udNT#5BU@I`5qMnGtQ z)x#K*5Y}C5T1f;$mwK!RzY7vU>o| zy2wJps+aib#u|GR)#m$G8ogoz*}E_Zn~VykJVF5!VZz28^1;;P04f^CNpDvynT!o3 z)kq}2#10wF&)KwQY0UYt5%{O?$46)7|KWYo4+~4|Tfk-SBZv*dR^FU7*H%k_d>MIv zxEGn-*brpz@QY2|*=po90O*d47Sj^LW2!cqjR(mT{Jb3JzjR1j~`vkoy+GuMmEXobqTiXG;`Im1_G2Uj5Jb2;v4!A*H&endEARSBO%2OU#Oz)*RGU1jMrbW#Yq^v1sqK4RLKsDQ z6&L$}|8{%pKFnLW1h*esA};Wv5t_fUq;7d^EFGUnst4FdyuLZ;`8#R&!u+I>HhxYJ Hg>nA}yk|0P delta 2705 zcmZuz|8G-O6o0pU{kC;&x0TZM8(r7Yy?tQ}nT#)@Fz_307`jb?R8rOlbad^weTW+| z10yoUD9iN=bH>eR2qgLgOwstAXky|I>ari0Pe}AHa2qBd(ZqA^Yik=^(ogO?_ngl; z_ndRj-F)oVKHJTTiZTKIZvWsJDD#fn+~mpeNT(|(xP_1qHF<=nxm||}fEhbq@;_ASNs;<0$@aL%i0$k2pLOYCAySJi6tgU5ctt~#7-N7eQ)C(zSA3=B(2 z8vrg69(p0YA64BCXaBRRjXaFypk@G7ko1&)VI}Au07<0#aYu|R3Z=CLaV`-%K~M;2 zX3)7&M`G-vYegldNwo}7Ig#v-D>5Bmzqne6XU4RAl58A6n!^Y&0BjMitA$Cq|$S8+o%1KoN zSIdeyMreW)p1nYL-Sb*G<_fsT&F=(u+&ji5eQl2$!f?JI{L)3$bYxJPWDR#!+JczO9$LE*zQIbAh}QyVN!7+qzh8blMgK zV}i7VpWF*k& zGFfbk4#D9$7oL(Pq&a)|XLFe1ov2}R0cuA^o~tETY$ohgi_6w4LC zuJSH}LYGev%7viNOTvO=?xxRyQBl=S;-?G%c?yh zpq#ry0eh~QPEcOgvurHXwBtO`Wd{MExYw&|u6sh$ZPR`4uAlX^%)4u*3=@WP$vJn! zyw^9?KG8nyyFll>Yw|mZ>8+FYMT@~{8{4*EW&L$cofPjlg!u}Fi4~-$5wHw&3_uc9 zeSa#eiix-qhxVkhOwWQm-|1M^zw3gn@1iUW0C6oE1xNLnqhsPN(PD8gx`bu5)6O~P z$}#a}%e=jMtQ_jS5UF8_@cS?mE%j^3r)+b*REd|C#*v5s;H-Y8K18mtNkGlwTK!uQ zK81*CNXSFVAsAtAq16$z%1?e2yv@$743jU|mn%0m+{KFu?*XXR&Ym7P(JIdM84E_j z;AnfKr=xgQ8e!Tf%5X_!$Z!!EDsl{(&R1+U65z2OcTMHJlo+y;4FPhMO*crz)Ad@t zgT3i(WPYhUq)l0PTVUkiUE(vKl;cs!N4{eRrQiyzo>nIvZ@6w47IutFpM|mNX6L1V z9}B9A-I=sZIk#EsNt{Zwmi;bOkA`rG-S%JcCBi2+&`BttLBI*F>XT`uNKGu-FLVwV z^X@m&wFP5*Xf>K5wFiO!0bx`S?pRXEtg;t^j?pdXM%B6)=?)~gg76r?h@Bf0 ztYSsFxl6|nh|caJq!&>W7ajlCh=j0rr7j*bn-k6ZAt7IH3!OZ~(aAAQ*r{U=X^&5F7?>=!p&cJ%|rR^{D!2 zMf-V`^v-@I5GGV5y{L^nh$ga{glpRp-q|qUmaOfHWbKr!_2!r$SfvuqSHB#X^^*B? zD`&pmJ8T%zVdFWjWpo%%#gHSO(zTcd5qmF@j_Sy9H=Rys(G;?$vWWz;FGh6@ z@sxTzsUiD6CgK^L)Zz;|>ZaZJWE9?COg|*HB|S-O%bD~D*({JdL)=0(qoI`w+01*YAAu4!|;yeW%0*>Y=FHnqkK%Y9Z zM6kyE>S9^`$jP`y4Ecy-TwY_cC3=mimgq6$lW5oo(vrsrw!~A$bW8j{LE@I^HYTXT zcT#LCIyNIaMbg<&@;I Z0jgsJ;$oA@FF1|3zA~^fiWI2=1puHf5SRb} 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()