From fb7061ad962953359cc713d8d1c2485b941dc8b0 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut
NSKqT01py}Y4+-QMU{Ons;1&ZD zcZ5$q&^?!wg#8_X{Y*E3c;pqM9jV^8{f?B%Z+S5HoSo?BNgcUXUqO}XAB~w&xtMb6 zdA6Ed)xG=di?fc3(*42_(p@J-V?Jrl%C>nz#GuG;55`?X#WpbVi@>G+3HYn(?m5(v z=1#}txYRLT2H354Wujx?enC3=*yN13fup~N(5GZ;-`#DA0)N<#p;7uAX_`D&MmyLl zlycq{8(RnwCFpaK#;a0Lln|3TR|<%w8COrQBnNLW<{;gNaeb>_boBOH(#B$)Q+_&PqUieV%=PK87b(Q^wfkLItW$3Nw5a zjDpEZMEVP5Ff{w7Cc7trg^xzgr*9`Csn9a?EKa)f(Q{ipl=MQ&V@A{uc^ouaCj0l6 z1Gc|U2(*bXNo&zmk`|bJ!`BF{rv(Qpmqwnl%qk5h#G+;BHFo d;Z^dz1V6x6@^zyuF38nZ*2{#Vc^>FbzL z{jU 1mlI~?p`ANx8r?hHiQ0e6z>PS7qZqumf924PdI_{ Cfwh0AF9|f iV6(#V5bi^U{1oT* zAw1SL|8K+N|B5U2UX9Zp@O@^IA#Z9n-HGZYroNDsuA*e4te8^O4Y58qjmN+<(kH}z zIUDXUTfUcjk||SLE5!plWU3~1rFHAH zh)I9^hoj!KbP7Kp!rjm{G}tWF2Ne<+(7%Sf^J67@WcipwDIuQ^tB46W=HzrY&2J%H z@4@z?s*I9-pCYZEGnHo#5)WAuCcJ0pIu#G4+R2y93+je=*qUx}TV*gv_KhdL+AImJ zwa- M4T}9HjFHEI {|gc%U2zUN-zTYFsPAF9f74ErFZv9 zfpIcUv;Xr~G9~UP>K`K0EYDw La@P??n1GIrp~D8^Qs fpcD%y?~t-ZIga~18~7@7uvV36`b0*m zx-3R^>+@sSclfW#{gnxkojJFe!{zWhQ?}3Vm^yNVC)gySdab?aP#>Jc;>X$#RJQm> zJ|vhW=6p+51^=UY*so82m&Qas?=5pp`Mo5RoK?@6vDTvn4J%&@rPtf)6| 9KC#ro5G6mV 4cJI|H_Y_T5A;g|qBHSDGfqoho((x3jM5z>57gKbwaL z;!1!+Qz0633B&ats!yC@agNpD;b0pTmA)c2-JmWCLZ(!pSA5Lmynm4LO&6+lo@SXX zbG*<+iJa;YKs3CQFD`;xbc(E)S0kLc`gBxQYqw!G&@|W2;jp}`+)9@U5O=`tA{A2@ zV9rZf){S>2v5~LH{FTuRsG6s*7c#Om{&QdSE|%uQ_V5NIhMsi0r&-CC>W1rmKDL@? zO?19tdvrwzMn@!zt9&-^o4GezkqY=56@1fHWK3k=_I`yk6HXm~B{BG57_&rfr#Rwn z;fyz Mkn~n8&w$rN1|(mi z|EFm(3T;@CJ?H;ue%iwY%B)*MtK=lGG$XBb2Q_SdxD62FGg<0m0@pO2w$jt763aI@ znlnMwng~2maN@#h&0s^t?8?m(nQu=rtSAIgXc%9Cn&$WaY to~FH`mgl(vwE zGS>4*{^5)=T&t>$=3h(-espS;>6h!F35)y>cT^z&ygmh7$%R$mEC2ure9Fj}$CMHu zl``se3wL{9LiWDqn^rwcL*JfS%(yBmujTLE%K*bv!^Yh?)DI3yk>ctP>-6*A`KrH6 z*UXP^QVTYG8qxe-zMT^UiJW4Gv60qbJz=v7fG`d3cTy42HB &AX6OD0% zMCk0kno!ksUwA$L5qhcRhA(B%M?=_`Z82M9n-#3Hmk*>U-GQT98X8sKtmeysvhM&G zyG{H*2t(!v8U=h!6w*r_zQ=tuhRHMvHYDmAY+)5_LR st~yqh$=dv5DIjk#oe)v5p$@pLP%0E{f{97GzhRxYm7*WikN2jcUugw ziMl5;es$+t7a3RW*^i3tI)8)3Rc$*cS8oDcrNwSOVU>=l@fteatVM0;z33LaP20>Q zN^-cJe969flZIVpdIov>+8sMf#0et&VzMMKqVq5Xper`t+6~k#2B cSHs@d>m*Ara`DFJoE1Z@EF&gML$O8)_ z5fgxiGTb=i1G*z=nD)IG_9DdD_uY2O^?sUPIeyYCcx3;Q#Ovlpuv7BPaFAPzPg@CH zTeD>0Ga?dxE`o G zpaBNh+Rj!*G)DXEA`+s~-ZK42`9K9EC;F}jdi|$`(@`m3M4C0{16)R?eYsgl4pm?F z^O4+vr;lwXU#4@E>y%-FWDH(>$&{MmY_O> pAC6O=0NH~F3YJQ1J zhcFChaH!832q&vp)fIjFZB9#oxN>=CD&Bv&Z#! LPDYXnX5WA!ce{)glII^yoCUA zCaq3-TeXgytTcmRMo-C5So^|LQEvF$5c~D6k9`=5e@lq%FIm)}Pak9{;HlpqlEc!* z{SGNyQ9jL *gk9z$TX3MIo`v zAo{_YqVx3awp}uWbX3-v?%YGdEJ>y7!#p);I3Rw6O7eSI*=jo)b=Oy|!Wu(VyglPa zadqb`6B?MPD8U5Tb|n4mB!QV}0U|?kWdbW}`D@bxI{Ny@`&XL#vmt+=K8h{oGC&3- zV^4tbvFXQ(Oo>2ALJWc*99_mxEVlb^J6VgXp@Zr ZgGllp50_@ #@1K@ukuf}=o zd{>1jZ*mVRYeEdJL=s~YbG$Hn^rv7XKIPP1jZY*`=^6=0PWCKgUtn+>$+5w(YS)(s z>WA1RJ`of^GNhms*r`JDxfgB6oU@-&M$ogt{c_wdM4$bVrziR<470XtYzQlLW?2r1 zNsf<`#K8M4oj1Jk^XE_1NplGB`Y4&Qso8jn0S(NN2Mlf`4iESe(U7o(we?`$rr uV`>IHmyl li2(Q44}hOIpUvvZ%}K>~9+C|x%9Y&{Q##-R Bl_R_wpk?OE*#dd{GQ7S>2r!=>QTB3s3PH;lJUy z5DffOS6}(}&IFcu;0K{s{Txq&3-EATewVYqc=GjjZiQK*0e`n=N=q}_BKS7p7vjm@ zG99^6Dg>80j0ZC#`%98!^SDtKiv(0Rhxy5=CBcyejOFXIRjw3wJ*3)M#ik%-QJBY~ zl013m_AHc1eznPpNkhTAk1rwFh08$7+{RquZvQr24$*nB=1Fw~Os{I@`Ad+r6!PkB zC}bA9@X1L6W=!y+#e9kwN8ZMSknZ4Xn>C5fy%G}7M;8Am)6&1f9bmx}i;q;L!R9Hb zEc-HSFD2+RPfs69)A)v_`pvmAPD+?AU|YZAQRY*(aqsksh);TCQRf*CdcCFR= 4`qvM#L<$Db}cGg`dqM`x_$5A6puJUh{*>K4}Ki-I!aI8B+d%e|vcS3?%EeC0Y zm!u7eT~yC6WrXXvZ@70Ms=UX`7f6{skp$ewgpdSoDyP*219#y&<8TwvpQibu?ae{C zISp&5+3(-FjO_iw00w`pLp;uBKd@7tgs~*^p~zk%ngjgJ##vL#0h{efrQQhPcRI{^ zo`cH{_^hL+k@@?5904H$w$WnAot+P|!?H!^b(f) FT zo}w(md8GS;3&AZ`A~t;}%THdvy0ug$1^l8H6q5HxZ_OJ$%;hJleU3N0>0uME7*W9K z*)Rs+V!U%wG13Bw!gugT@wv3*2d7MWtVA>wL1EI(qXb+X9_*sw+Bpb9xyb#Dihv*( zc~q`qdiMuqt}3jsxQz%1uR?pgt(@fME8o{F*;RKvq`2<@#sj$QmCHS+xsF}tf&hTw z1VwFgPi0Bpi0t5uGYwTbr}~5W^Zzdl2^yY;L}Q~IibQcrURA#7(=YTH(b@Ve?a;d7 z-xT?$*6Tu+Q{^W?yaR+gr^tG&McHqaMI+oyG8guIIGAklJX(k_fa3QA#46$6ZMjoH z(cxiLKROCXu#H5{>E606>C4dvNcSTv_65Q-{JdH;vZ9ET*RweulvkX2=KE4YTcaL# zNs(AxC(x#79c;`$S>1_!d{ZOkJYtfN#uYTcO8< 4L{QMpbR8=rQb1NjFiGklnM#|d*l Au0;P z5 %g?=S HWQ^$Je$Rqeyag)Ous_{g6xtjFaw3Bdq z2t uIi=Q~O z{+0pR%$F$ELor!%a#JIG&v#yNC?6oBP3!j=32dff_Mtw;219&3`bbWN5zb$H{x?Pf zU_f6Z@SPSu**qjSnkbB14=oyS5&IQ3?8H0?$qe$Eq`y3dwrU5SyIe{$R4O46Wmg&o z4RB~}TW$@_4!5z`mK2A6mY!p43=RqIMoh02iy4uU(d2k7CNIsxLJoR-8vAQMjPGA9 zMW!ji-+NGxH4e3yh)#K>9*9RN bwqGs1PwAw)_5-%!XNvu)KxE&AS8nSKyIaa`auzjag&PzZ~)>*O+oPw%fk zvD0dNiny~9>)+nPzD`k-rk@=bSK}WMV%U3X&DuP_%o6QbTkMFS9#dqCVP;8inU6t& zn*EGc0OYM2?D$H317E_Sz!&KOmQduU7IhlSR3u4?GSa@O6AwdHw=2V?B`^+WD9GTv zxIxsO-?|eRdGFf;EI+FpX4Hre&y?9WXo)qsf+UDNAwD1pLs|Bxv<^bS>+fqqrsUCn zZIujVjA)#S>LQUeQW=;^W|d{@S0*C7-pHr)$AteKpb3=~^w4bG_Jgz9sX|U|z6w;Y zH-0}mu~1SEm>f*GbbXVX;{gG3hc<~BNKFfV@H=S6_l$V3ryFoR&!IcmYRX#HBQk7c zOSA(X>GV$Ib#=I`2xkj{4qbvM!7AJTCp+QivmN3bu7>a0w`qAXZcZUfrm0MQLjlC( z?&ztWfz_J-4xLTkO=mLzQDt}`*g!_J^mVtMB#No@M QJ5`#U=XsICE3%wr+$+l1^ufH_^1Qh0Rg0xEd3V&@EJBn9Kc1RL zAV~UJRM8h17|Qs^JjRR*sdSa^-kk)>lhaEeNi4MLxSf9ekQa3*qR6vZ((3BGZtqZ( zk#(V+J1dy0MftOVjc8?7hx;&v>O{ol{w+3o$iShMX%v6 {zdnz2^TP7#fWF z4~8Z&NuPoBZ+!*Qk%&Eb(RhL>|M93hFmm8zKm|?5{IelV2(1ka2Yr%SbbI@xzt!H# zSdlSk|8G5MJ3X&tZ~IBZ!N<+4aSQQGuZAVHS# pz8O{Y!b zciZJ?wiVg!(SWZd5hWo@xgK*{H5fC$pyeYn4Gg=^I&vToYR(^Y@r>-Z5@C;fs3{G5 z jXglC@3_ltT)?gg?A0sT0PhiRJs+vkxV^DI}!EemO|OnPVw z;ocp`eU2rH{Az809vEw&P;#fZSPgtS(e5j-C`_fE{W3;fbcd*9W{YyCkGGDp;n;g; zcHzlV)6%Fb2iBZ8V+N$&|5C2F_@Ex0s9O`2wkz>GdD5}f6jri$Ic+@T?{{~In{k$V z;y-oCTeLU3epM}Zesy rH_U^jAYcdpiA-C zp5W)FM|lOHTi=TA1Z-7LD3$QZM528FVs8{aGL(D f@ST5P(GT4TA(TPtMPVsP zEZKOQURc5c*!fx+{7Gh_Wg4U&3pi`8(6i(n-xjnf>P7R7*0KzbkR=iPYR?1*>>hyH z&IIJ9c(Oc xyyqs_luV@~=Ahs+V`rsyo~K#N5y9FiN7|UnBkiFtsIbOe7*O z@9bR>z8iw^U6y8Y@-^*>MdgIL9IAKYBY7K=Exv5)UQ`<`Aa6>Ny#8ntOGXI#bGj)U z;W=|&xiu*IiT%CwI?JYTtFR3c6>qDn>y*=a`H4p{YQ}*iT+VCY>b%;wG2}$vi6U&4 z48ix|WgM46A5fWn*E~V#e68lQb7C2i^qlRt&JOQJfY-r0^M 7<67wT2l2zSakkwJrmlrxJLc36YPcX zvPYV*)VZ9~Kv4VMn$?gqMNJ9lLEhCEuz@~lo~L}&XM0ioM+mq5Tn7l@KCKUPk^ljj zEDEbTC`VP`2T#i89in5&&h)-S4( N*xvne$4txM9e3^Js$M2 zCL^=x@Z2wL^)v -1P9!hm(s#~q-%H8EhyCl;qlwSsdZw$l)e6fYa z{YNg2GY^2*W8HJW!ZV{N0~L=-uD!+&Mt8XiloN-)SR{zXzfR z&@-3!qwC_@Orh^HY>2|s*9M4EIo)bTxqd@4G7HS7s}gG+EC4C5i8AQ1JeLTE3LI{D zub_hphXy4Y?5J=0@bf?6HW*uAUi!?oTV-OrK-ZotPN+uhB6!ZGFVP5D)JIZ$TJ^aV z80mWRs;%6k+>LE=5AM7pEanu#P*3>Nr9t#3p8fsXbuxL_`*J0IeJczy6z6)R_)or$ zah)UGutHCh8>mq0N%O4`dbxN&&v&s1Y1UQ FM2&&x?CH8 z#{=_u&A}+cG=Qn8#Ini76`Mw#^=~ l7rHASXOM|Y7*H;23}-i%LxDD zJ>c4(GcwjK^#48^`?nFX|8;)s&j)8*$8xb}J?^xhcQ qIF+BC*kC# FXtRnL;|Y_}{tvC+ zY?yo+7ybLcY(16{)b9C=!h}bPt=9D#@a5Z$#~%NjMzuG9y~lN)#-MX~^l2nv`Ge$t znns0a|2d6{J@G|a8W`^mj!xWn8&5l8 toJcqhl@s9&cmH5T`UpjC@nf{+>f>7d }TLvcu{|h#OBCC0H2esrqTWqavSewyEmAwulb@1`Zw!LM&-mD+N$oubF~Y zc{JNvUlXi 9+dqh2I>!CYy|GF z$i6>nU-{C{=h4Y~r!~mzX*KZ;LCXI1_bJ?f6c-V71h_`C4Zt;~d2b5;j9Hh9{ |Vbi(Fmw6A2DqHkVa6=LTp{Q0L-~%XTaQ;e7yZ#JOZU5^S z>i e8F`~S2a0mA=psFs>r&+32ALm}dvAN^aU{}@> KzL OK|aBARQOU3t+s6nMVk7~z5X(7ADoO`YojtuxcAIILdnFa^ZF94{Q$ zi*1LZ_jv$~pf6pqozMCWqYA!g(5*es15ADs&6FGH9e5~_z7?5$>C<0qtDY_K&s_L! znfhw1!W~BO^F?(bN3-X|Uf^{~@#(~HtwY0Xw(q&m_TsFa`E1M1Z6EeT4a@r@;fW ^O>;>ZchBFESl_NEQ?^IeN^qmhw!u<5H6vv{(FCPTw zSvs=9`-BI@i2s)7QOZ+!O)^x_TZ^%cr}6-zzri26@}0QEuRSGU_{A*OhaN)L?optP zfPG;5C;TxmE~SzseDGytv51=Q-%imNUhPbcd@E+4eF*@GP!2w^byUZ+eSQ+dd(brp zlYsi{H-)a>m4V=QZUfS1S`Sh7oFbq;mnTazYdmXq3@^g2hg`9`w@~+DiX26IbmF7W z+YRD_dmzBai$p|znE+@I@HrS?m(e4*o?!NJ*r;#JqbMS~ 36&YGZszT2&|!zv2o zo2!3>hL`PtfPz;aJhP45N2x7dYn3~{Iz0Q~DIMB$1cEyTo%K9oK^pWV9>{uBN*-pb z*z_t+EooL4D)jA&B6zINN?Bde-}*%0h8)i+u)0RI6Pw#%pQ$6dM$ u6&-^UAt89cab)N3}(sa(~e3C-U%zWqEM>JkmD2k{7 zkW3c6uV_;dlI~=0^yc+@_&3EOq2d5rKnr~WxW!>@I}}!2v5TeDPKiI}M!sL4|4qVi zUvXNoQTDr&1Bn{QI&~ufY+8%+l$^Bnbb?HsrJWt;Ay>l$xv{1311EczKn?V`N{VT; z6caErW~yG?CPlex9@m}@qsOeC?`n>U1O0K(v-~0B8io^xH7@}gWJyKe&D`N4bGGAv zTjx;h37MRQb0vOAKMRJ&Cl2N%Be (9RK`_fvDuJ%L16jY0oP;h+;Ck-a@B ztvBoNMZ0P;Dxb|9%J0}JN&0Uy*rXI8U#a`qJ7g=_OyAL=5>Yh;06`f(*CKh-(9|0% zs|V9L3i0=+j2FMVP}L3nng7ILZM7t@Ui7&ECpJLGj*(%$sx<+8%)1)2xnIxLIRi<7 znO*`CIBo8{HSi9A4>nQOKHT!Oxki*lMKa_yi+OmBS~-GCQQ<4@kHrfPALweR$h=G0 zq$$=n7I| zoOkWB_q(5G@8_H&iiiG>u0BHV{3yq(aL4J}a7+2`I@k2h!>3k)BL1luD)lOI>#qz+ zj{edZUDCa_SNF!~k0) !oiGHET5V5l&NA|E)V@=owJK z DhMpeS1YG&nk*4X{=?!Qqr4#EYr!e;0;7+F)m$~0e zP8yvC9)s=4)z$h+e)^Qoe?ojS4jOsB2JsPBA1ZXsV1{OypC?b5m;g0hFz6Rmwz=V} z-0eR<6(&x&G;t0cntShXGtjZ`;O--PrZ+*;(XG$VDu*1O{Gme6gkCK+x+t?0T7^vB zZwv|)e>))A5ruN=f6OF-MEAgyI>f(sBdJporHrxfSb}ZqfTz#-+f0A`@v{4eH67!J z=Rbu7LBZ*p7lXqcejs=~@7|pC+vY8vCz7e}Oj0`!gFB;b>n^xvWtH8uOXlZuZkm4T z`1RH6>1eccaY^_{8<1b=S$yhEbt%2n!PP}vr$2Z#*lfuZnzH(y#DzF4hF{*v8w7AL z%uo=&4vJrzeYTWSPJRwhr0S(O6 z1+(+!#Y4oz9~xj zVdOcSHxYukSaiHpmS;nkZjNNWz)Ofie6zTiob^2~T;vI=yc;5I|d`Q^U33 zg3DWT<69UKUZ;a=z(h~Ni}%!be!U&PxT<^GM5>pp=3IbAT~WnPx;w!<{0=u?=e;I5 z@_RL|FNR5Kj;aDPY`6C6?BjOZU&N1bZ*>TQYzae=*H-zxH3C&2>JGr|=& MmU^xefbLDOq|9EF(TAF-iNq3??+w~= a{?x{G`CX**-`R=o{L7 zKSmonSh|sGC?$gJo7)y>C!|#Rnb)JrGpF^wt6mdW&n^i!XniTA#ONU*$5@rQ@9x`} zxy3EHo{liQ??Z5UXQRQQCm4RE3f&x?9yi~lD}MNp1Escbq}ou#ecf}A?&ZkfE>_&r zJlJuEMfYhV714g*B_j0Vc~Y}ea*5xSaoRCMsYS3euF}WJ>J~8@7jpK#^dqUV9lT+u z)>ypiG&|jkBQq}lph^S1sAcrNeA$+P?M)%|Dlj98Q=zRcl>MYhN=aULE%$!1pVd z5iNqLs-y+Fusz^aI}EnvKC8`4D>30N{*$QD--e f4PlR zao3=M+3<{pgqz=wG52}^@GYJ>cgH&HTvq4%etwM9QFz@VJJ1?#z!&eWKGoOd<{}Js zt_couuumsqAlY=5Xl9*ks4^kQev4E~RC8`0Yis9MGB}|O?c=qig<&>=w?z_nxogn= zYqG!fYddE=tOJ|%2RxpmRZBa%y6M3a@HarM?=D~2q&2Ynn%0BHe;Q~VwmiQ@>pPD> z_h{1V)0>RZJBAIT7Bvo#*@J!XagKx_OU;;J9IMdkaGOiLYgb5JNJ~!eUGLFjhKZty zTr>r^UayP N?a+1Fa2kA2%?wEyuo=8QeND z{H86#bB7<~*3bfnI=gP?8L2W8_!H2QBLz?k3?|eSlAR6ztw &h8$pmKbH%BiICPC4{Oq5Y7kmW8%(H&M+{R=q?nSQ}_jF&UbO zzfXjVdcsFfy+(3LapPA8H134$q+w1z+1n2(R+C2uUfZ{?DE$?FQB`9pRZ1>%7!|`* zL=I&oH1dM&aHDFuXc %CagDZz{wxa)uus7~p21P8rRrPogxB-;zzWdFZR~sZTw% zkyopzP<3)Z)=r*B6jJ#<>L4N-b<0+dXhX>oX8fK*NU?=9`{KrA$J$Cu)_;s% `=qPr%G6A$R`XVd2nWK9)b1+BJG`u0~psLyNdKZm+$7yBGkQ8wj@=}lf zqNhu@!XBHp4%2-8bZz30Hr(yXCQN&TtgEIh-Z*RSu(NdmEJMhC+B_zHTfbu)0vHV2 zQ2RW4vCD>>j 6qJ0ku z%n7Gb2`_@<683imvh6%?KrwTesBygLOlBeS=*~iYk=_ZUSaxLpB3 uxo4gy~%_raU(Uv03rZ>J`| BC@$x*I3WhT%`z8u&yL<8RBE!Oq`*ec!qn!e9q%(lG%`FwREm81 zW3L5FxD^$<^4A R^d*Pm>(w2SKnmYm)W*Mp@8JL62Y=Zh!m%
d#o#_@2AU% =0=o(7jz&tXI^Nzs(f$T`ZAbx! z-ghBGDi{;|z==;Fb7#@TaQDm>kCbMB56w4UrgVNW_sXBu$Us>m)I%H<5~f)TzSl*B zofYrIW8CuIES#(virzg_p4jt*`hXtK`mS5Xn_$ug;;r;f2=lN6AfiXSR6^pivc$g0 zvXgRmWNaeYnNe{7nRv*xA8(x_k@?NMbVxx0`wv;t&t>r0_ `6K6EtNXs)6aAASx1!1tfbuQ$G=Q#&W(cBH7)>a~^*)qDc^UuP50MEmutxMVeE zdMLeW^ai+SK|UEje{PMH$>oH0(}=Sj9T_(iCn9l(f-sSl6`wEAdbhISYFe|jlP`v< zWfA4Y^RFHK(8zV!aZ!kAP1s`Vjh_mPhWP#UjHs00(lBd5OE88@XIhMb{MCYz)}?7i zDbH>{O&E)LS}p%aM;cnhrF0tR3exOr@cft21F_y4@*`K-)qAH)JvQ@w2j}ibu`T%T z#j7QeyG7m11rwMCT7}ExuNV4Q82Pt#7;_Q-rVXhqegqrwvahYR)J1iZq#I31xi{8d zaIm}Fsm-veH@YV%WZ4-bOznhU!K-@73GGEZhv<;60mMCv!@P-rxe-%QM1xVjHiEY* zPc+w2&T-*a(np@!)B!fv?cCKhi1OZOMXe F5B{SCUPW09BO)sAqV%&mXDBofc&vkoP0I;o1%s>*2W<^E zTbYyG)>b)^bwk%x2)AcBT$4#$yJg xXnepw7P2JsUPri@_fMgXA;l{Bew37zKZ)Wmpg9;!bSGluh}I%P+# zS It>eECD}Z5(BS^Amo#%nR7(+a0Zh3wxaTN zuO`lKUq*W^CVlXjudj9Lsn{#csQ{*2UmW~DXb7xRwo1m&9rnb;wOoqN8^9k3N37y` zNh{;Ff >sOfE=x+=xw$3f$n*1TX_ z+|v5EeXdbBz|I6_Bt{zO40c{PC{ZBqwQm&fD`gQ7F1C uNpFM>wEwqX9fj z_LT*zM5pkCXo~8%#@AW4;H;c3l1wCBA*)R6+7DfWORekbXSw)u;A#rlz@pa#qZmHw zk&NilG5TKlyEGp-uj}cxl}lze{G3eyQd&u1?1e@`Q~0xkr7YgPR+ZVKfku_tA;g`n z@75t|#|@Qw!(Z7FYEF+O_tCJuy;$}IP+Rp%d|qhG;}3(i(|5tBlV}^5vvn9Vq0xs6 z(IYG)@=$S8P0^*KQ5*@1UVhtxEMj?vj3mhcHI>r*4i{y4hjS%uU2~=uU7)D0QJvxO z`%y_|hdSb8@=#BEp05CW#?wqE3Xw&n;O^1c!|hMX=d8;aAgPm-FRo1i+11|OnC^aF znaLHbem^N~xO=`c-h4Ll&ZY80)yvt@Z692v_ls|_9>r^by>^k)*S#_2#X8%T%tQ!; z5GK9eh$M|Kx^8X}LYw|8#O2P%07szp@bVnE;{jhO04D=aO%x6qTLDa3_8T%I+;1oC z=^BxadrZ_FK05c+ske+4)svY(o$Yo;>JDS=!HE2xnIxe%0d4t|rQAh>j=_Cs#jN^} zBb}8izF*9cx7ZNQzyBsz%6sMt0MXev63%Uh^BQpky@@un)J2rvS@H+G YI~KY z#0#D0$say04f(+?WvdSfd7v~!@aBRmZu@7kQ`1*7vFAId8$z7*2*`TL>Gm1J<)p|X z@1nl&)$C^gg~eXY*=U^=W- DgM%Li47>*hjA@2n@C#R>AVoNR@%G8A>v zMDK1%)X_1&Md~{&qe$T&o)y2RQG U3+d>TO{<#nF_SMNe=s6a z@088ODL?)LBVOK)CQ>usUf*fVnF)~U6QLGW&WDP+p-`7Q`BFxzU)!+6qAq?^Gmh@j z0?K?W4wJNx@db1?bSRITY`~yvR=qzR&p*o-q@qE>T?W+$Ng|;Kw@`xz_7hXrW83Bj z$He1_W)nuK5#DEo+Urdv{#QPuVLyC!4-|^-;h+D(A)#T{9*jGbw%N$8DvmloP?z0K z4)h=V*{`Iq{iFYLR}EQse^ M5UJ=@YO(+sb5!PV2H=9`<3{u>_+Js|)9 literal 0 HcmV?d00001 diff --git a/doc/locale/fr/LC_MESSAGES/features/common/historypanel.po b/doc/locale/fr/LC_MESSAGES/features/common/historypanel.po new file mode 100644 index 000000000..afcb4a95e --- /dev/null +++ b/doc/locale/fr/LC_MESSAGES/features/common/historypanel.po @@ -0,0 +1,301 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) 2023, DataLab Platform Developers +# This file is distributed under the same license as the DataLab package. +# FIRST AUTHOR , 2026. +# +msgid "" +msgstr "" +"Project-Id-Version: DataLab \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-05-16 11:30+0200\n" +"PO-Revision-Date: 2026-05-16 11:35+0200\n" +"Last-Translator: DataLab Platform Developers\n" +"Language: fr\n" +"Language-Team: fr \n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.18.0\n" + +msgid "" +"History Panel in DataLab, the open-source scientific data analysis and " +"visualization platform" +msgstr "" +"Panneau historique de DataLab, la plateforme open-source d'analyse et de " +"visualisation de données scientifiques" + +msgid "" +"DataLab, history, record, replay, session, scientific, data, analysis, " +"visualization, platform" +msgstr "" +"DataLab, historique, enregistrement, rejeu, session, scientifique, données, " +"analyse, visualisation, plateforme" + +msgid "History Panel" +msgstr "Panneau historique" + +msgid "Overview" +msgstr "Vue d'ensemble" + +msgid "" +"The \"History Panel\" records the sequence of actions performed by the " +"user on signals and images, organized into **sessions**. Each session is " +"a chronological list of either:" +msgstr "" +"Le « Panneau historique » enregistre la séquence des actions effectuées " +"par l'utilisateur sur les signaux et les images, organisée en " +"**sessions**. Chaque session est une liste chronologique constituée " +"soit :" + +msgid "" +"**UI actions** (creating a new signal, removing selected objects, saving " +"the workspace to HDF5, ...), or" +msgstr "" +"d'**actions de l'interface** (création d'un nouveau signal, suppression " +"des objets sélectionnés, enregistrement de l'espace de travail au format " +"HDF5, ...), soit" + +msgid "" +"**computations** (FFT, average, Gaussian fit, ...) dispatched through the" +" Sigima processor." +msgstr "" +"de **calculs** (FFT, moyenne, ajustement gaussien, ...) exécutés via le " +"processeur Sigima." + +msgid "A recorded session can be:" +msgstr "Une session enregistrée peut être :" + +msgid "" +"**Replayed**, either entirely or starting from a selected action, to " +"reproduce the exact same sequence on the current workspace;" +msgstr "" +"**rejouée**, soit intégralement, soit à partir d'une action sélectionnée, " +"afin de reproduire exactement la même séquence sur l'espace de travail " +"courant ;" + +msgid "" +"**Restored to a given selection state** without re-executing anything, to" +" quickly jump back to a previous working context;" +msgstr "" +"**restaurée dans un état de sélection donné** sans rien réexécuter, afin " +"de revenir rapidement à un contexte de travail antérieur ;" + +msgid "" +"**Saved to a standalone history file** (``.dlhist``) or **embedded in the" +" workspace** when saving to HDF5, so that the full processing chain " +"travels with the data." +msgstr "" +"**sauvegardée dans un fichier d'historique autonome** (``.dlhist``) ou " +"**intégrée à l'espace de travail** lors de l'enregistrement au format " +"HDF5, de sorte que toute la chaîne de traitement accompagne les données." + +msgid "" +"The History Panel after recording a representative session: create three " +"signals (Voigt, Lorentzian, Lorentzian), remove one of them, create a " +"Gaussian signal, compute the average, add Gaussian noise to the result " +"and run a Gaussian fit." +msgstr "" +"Le Panneau historique après l'enregistrement d'une session représentative :" +" création de trois signaux (Voigt, lorentzien, lorentzien), suppression " +"de l'un d'eux, création d'un signal gaussien, calcul de la moyenne, " +"ajout d'un bruit gaussien au résultat et ajustement par une gaussienne." + +msgid "Toolbar" +msgstr "Barre d'outils" + +msgid "The toolbar at the top of the panel exposes the following actions:" +msgstr "La barre d'outils en haut du panneau expose les actions suivantes :" + +msgid "" +"|record| **Record mode**: toggle the recording of new actions. When off, " +"no new entry is added to the history (existing sessions are preserved)." +msgstr "" +"|record| **Mode enregistrement** : active ou désactive l'enregistrement " +"des nouvelles actions. Lorsqu'il est désactivé, aucune nouvelle entrée " +"n'est ajoutée à l'historique (les sessions existantes sont conservées)." + +msgid "record" +msgstr "record" + +msgid "" +"|replay| **Replay**: replay the selected action (or the whole session if " +"a session row is selected) without changing the current workspace " +"selection beforehand." +msgstr "" +"|replay| **Rejouer** : rejoue l'action sélectionnée (ou la session " +"entière si une ligne de session est sélectionnée) sans modifier au " +"préalable la sélection courante de l'espace de travail." + +msgid "replay" +msgstr "replay" + +msgid "" +"|restore_selection| **Restore selection**: only re-select the objects " +"that were selected when the action was originally executed; no " +"computation is re-run." +msgstr "" +"|restore_selection| **Restaurer la sélection** : ré-sélectionne " +"uniquement les objets qui étaient sélectionnés lors de l'exécution " +"initiale de l'action ; aucun calcul n'est ré-exécuté." + +msgid "restore_selection" +msgstr "restore_selection" + +msgid "" +"|restore_and_replay| **Restore selection and replay**: combine the two " +"previous actions -- restore the selection first, then replay." +msgstr "" +"|restore_and_replay| **Restaurer la sélection et rejouer** : combine les " +"deux actions précédentes -- restaure d'abord la sélection, puis rejoue." + +msgid "restore_and_replay" +msgstr "restore_and_replay" + +msgid "" +"|edit_mode| **Edit mode**: when on, replaying a computation opens the " +"parameters dialog so the user can tweak the parameters before re-running." +msgstr "" +"|edit_mode| **Mode édition** : lorsqu'il est activé, rejouer un calcul " +"ouvre la boîte de dialogue des paramètres afin que l'utilisateur puisse " +"les ajuster avant de relancer l'exécution." + +msgid "edit_mode" +msgstr "edit_mode" + +msgid "" +"|delete| **Delete**: remove the selected actions or sessions from the " +"history." +msgstr "" +"|delete| **Supprimer** : retire de l'historique les actions ou sessions " +"sélectionnées." + +msgid "delete" +msgstr "delete" + +msgid "" +"Double-clicking on an action row in the tree is equivalent to \"Restore " +"selection and replay\"." +msgstr "" +"Un double-clic sur la ligne d'une action dans l'arborescence équivaut à " +"« Restaurer la sélection et rejouer »." + +msgid "Tree view" +msgstr "Arborescence" + +msgid "The tree view organizes recorded actions into expandable sessions:" +msgstr "" +"L'arborescence organise les actions enregistrées dans des sessions " +"dépliables :" + +msgid "" +"Each top-level row is a **session**, automatically created when recording" +" is enabled and a new application context is started." +msgstr "" +"Chaque ligne de premier niveau est une **session**, créée automatiquement " +"lorsque l'enregistrement est activé et qu'un nouveau contexte " +"d'application est démarré." + +msgid "" +"Each child row is an **action**, with its title, date/time and a " +"description summarising the parameters (for computations) or the call " +"(for UI actions)." +msgstr "" +"Chaque ligne enfant est une **action**, accompagnée de son titre, de sa " +"date et de son heure, ainsi que d'une description résumant les " +"paramètres (pour les calculs) ou l'appel (pour les actions de l'interface)." + +msgid "" +"The selection of one or several rows drives which actions are targeted by" +" the toolbar buttons." +msgstr "" +"La sélection d'une ou de plusieurs lignes détermine les actions ciblées " +"par les boutons de la barre d'outils." + +msgid "Session replay across workspaces" +msgstr "Rejeu d'une session entre espaces de travail" + +msgid "" +"A full session can be replayed on a workspace that no longer contains the" +" objects originally referenced by the recorded actions -- typically after" +" loading a saved session into a fresh workspace. In that case, the panel " +"**remaps the recorded object identifiers** to the newly-created ones on " +"the fly:" +msgstr "" +"Une session complète peut être rejouée sur un espace de travail qui ne " +"contient plus les objets référencés à l'origine par les actions " +"enregistrées -- typiquement après le chargement d'une session sauvegardée " +"dans un espace de travail vierge. Dans ce cas, le panneau **réassocie à " +"la volée les identifiants d'objets enregistrés** aux nouveaux " +"identifiants créés :" + +msgid "" +"UI actions creating new objects (e.g. *New signal*) enqueue the freshly " +"created identifiers;" +msgstr "" +"les actions de l'interface qui créent de nouveaux objets (par exemple " +"*Nouveau signal*) empilent les identifiants fraîchement créés ;" + +msgid "" +"subsequent computations claim the identifiers they need from that queue, " +"in the same order as the original recording;" +msgstr "" +"les calculs ultérieurs récupèrent dans cette file les identifiants dont " +"ils ont besoin, dans l'ordre de l'enregistrement initial ;" + +msgid "" +"UI actions removing objects keep the queue in sync with the live " +"workspace contents, so chained creation/removal sequences replay " +"correctly." +msgstr "" +"les actions de l'interface qui suppriment des objets maintiennent la " +"file synchronisée avec le contenu réel de l'espace de travail, de sorte " +"que les séquences enchaînées de création et de suppression se rejouent " +"correctement." + +msgid "" +"This makes it possible, for instance, to record a full processing chain " +"on one dataset, save it, then re-apply the exact same chain on a " +"different but structurally identical input." +msgstr "" +"Cela permet par exemple d'enregistrer une chaîne de traitement complète " +"sur un jeu de données, de la sauvegarder, puis de la ré-appliquer " +"telle quelle à une entrée différente mais structurellement identique." + +msgid "Persistence" +msgstr "Persistance" + +msgid "The history can be persisted in two complementary ways:" +msgstr "L'historique peut être persisté de deux manières complémentaires :" + +msgid "" +"**Embedded in the workspace**: when the workspace is saved to HDF5 " +"(``File > Save to HDF5 file``), the History Panel content is " +"automatically saved alongside the signals and images. Reloading the " +"workspace restores the recorded sessions." +msgstr "" +"**Intégré à l'espace de travail** : lorsque l'espace de travail est " +"enregistré au format HDF5 (``Fichier > Enregistrer dans un fichier " +"HDF5``), le contenu du Panneau historique est automatiquement sauvegardé " +"aux côtés des signaux et des images. Le rechargement de l'espace de " +"travail restaure les sessions enregistrées." + +msgid "" +"**Standalone history file**: the panel can also be serialised to a " +"dedicated ``.dlhist`` file, which is convenient to share or version a " +"processing chain independently of the data it was applied to." +msgstr "" +"**Fichier d'historique autonome** : le panneau peut également être " +"sérialisé dans un fichier ``.dlhist`` dédié, ce qui est pratique pour " +"partager ou versionner une chaîne de traitement indépendamment des " +"données auxquelles elle a été appliquée." + +msgid "" +"Replaying a session that depends on external files (e.g. opening a " +"dataset from disk) will only succeed if those files are still available " +"at the same locations as when the session was recorded." +msgstr "" +"Le rejeu d'une session qui dépend de fichiers externes (par exemple " +"l'ouverture d'un jeu de données depuis le disque) ne réussira que si " +"ces fichiers sont toujours disponibles aux mêmes emplacements qu'au " +"moment de l'enregistrement de la session." diff --git a/doc/update_screenshots.py b/doc/update_screenshots.py index 2ae380d1e..8858bb1ce 100644 --- a/doc/update_screenshots.py +++ b/doc/update_screenshots.py @@ -6,6 +6,7 @@ from datalab import config from datalab.tests.features.applauncher import launcher1_app_test +from datalab.tests.features.common import history_panel_app_test from datalab.tests.features.utilities import settings_unit_test from datalab.tests.scenarios import beautiful_app @@ -17,4 +18,5 @@ beautiful_app.run_beautiful_scenario(screenshots=True) beautiful_app.run_blob_detection_on_flower_image(screenshots=True) settings_unit_test.capture_settings_screenshots() + history_panel_app_test.test_history_panel(screenshots=True) print("done.") From 389613beb36da05fdce01eea210cf88592ed63e0 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut Date: Sat, 16 May 2026 12:19:12 +0200 Subject: [PATCH 16/24] feat(short-id): implement clickable short ID links in object titles (cherry picked from commit 680786176ea1176fa26153ae05ae852ec6a91b84) --- datalab/gui/objectview.py | 159 ++++++++++++- datalab/objectmodel.py | 40 ++++ .../common/title_short_id_unit_test.py | 62 ++++++ datalab/widgets/titledelegate.py | 209 ++++++++++++++++++ 4 files changed, 465 insertions(+), 5 deletions(-) create mode 100644 datalab/tests/features/common/title_short_id_unit_test.py create mode 100644 datalab/widgets/titledelegate.py diff --git a/datalab/gui/objectview.py b/datalab/gui/objectview.py index 1fd4891a5..ecb4c0ff9 100644 --- a/datalab/gui/objectview.py +++ b/datalab/gui/objectview.py @@ -46,8 +46,14 @@ from sigima.objects import ImageObj, SignalObj from datalab.config import _ -from datalab.objectmodel import ObjectGroup, get_short_id, get_uuid +from datalab.objectmodel import ( + ObjectGroup, + find_short_ids_in_title, + get_short_id, + get_uuid, +) from datalab.utils.qthelpers import block_signals +from datalab.widgets.titledelegate import ClickableTitleDelegate if TYPE_CHECKING: from typing import Any @@ -97,6 +103,9 @@ def __init__(self, parent: QW.QWidget, objmodel: ObjectModel) -> None: self.itemDoubleClicked.connect(self.item_double_clicked) self.header().setSectionResizeMode(QW.QHeaderView.Interactive) self.itemChanged.connect(lambda item: self.resizeColumnToContents(0)) + self._title_delegate = ClickableTitleDelegate(self) + self.setItemDelegateForColumn(0, self._title_delegate) + self.viewport().setMouseTracking(True) def __str__(self) -> str: """Return string representation""" @@ -201,16 +210,114 @@ def get_sel_groups(self) -> list[ObjectGroup]: """Return selected groups""" return self.objmodel.get_groups(self.get_sel_group_uuids()) - @staticmethod + def _resolve_short_id( + self, short_id: str + ) -> tuple[str, SignalObj | ImageObj | ObjectGroup] | None: + """Resolve a short ID embedded in a title to ``(panel_str, obj)``. + + Default implementation only looks up the tree's own model and returns + an empty ``panel_str``. Subclasses with access to several panels + should override this method. + """ + obj = self.objmodel.find_by_short_id(short_id) + if obj is None: + return None + return ("", obj) + + def _build_short_id_tooltip(self, text: str) -> str: + """Return an HTML tooltip fragment listing the source objects + referenced by short IDs embedded in ``text``, or an empty string when + no such reference is found.""" + matches = find_short_ids_in_title(text) + rows: list[str] = [] + seen: set[str] = set() + for idx, (start, _end, sid) in enumerate(matches): + if idx == 0 and start == 0: + # Skip the leading " :" prefix + continue + if sid in seen: + continue + seen.add(sid) + resolved = self._resolve_short_id(sid) + if resolved is None: + continue + panel_str, obj = resolved + kind = ( + _("group") + if isinstance(obj, ObjectGroup) + else _("signal") + if isinstance(obj, SignalObj) + else _("image") + ) + suffix = f" \u00b7 {panel_str}" if panel_str else "" + rows.append(f"{sid} \u2192 {obj.title} ({kind}{suffix})") + if not rows: + return "" + title = _("Source objects") + return ( + f" {title}:
" + ) + def __update_item( - item: QW.QTreeWidgetItem, obj: SignalObj | ImageObj | ObjectGroup + self, item: QW.QTreeWidgetItem, obj: SignalObj | ImageObj | ObjectGroup ) -> None: """Update item""" - item.setText(0, f"{get_short_id(obj)}: {obj.title}") + text = f"{get_short_id(obj)}: {obj.title}" + item.setText(0, text) + tooltip_parts: list[str] = [] + sid_tooltip = self._build_short_id_tooltip(text) + if sid_tooltip: + tooltip_parts.append(sid_tooltip) if isinstance(obj, (SignalObj, ImageObj)): - item.setToolTip(0, metadata_to_html(obj.metadata)) + meta_tooltip = metadata_to_html(obj.metadata) + if meta_tooltip: + tooltip_parts.append(meta_tooltip) + item.setToolTip(0, "".join(tooltip_parts)) item.setData(0, QC.Qt.UserRole, get_uuid(obj)) + def _handle_short_id_click(self, short_id: str) -> None: + """Handle a click on a short-ID hyperlink. Default implementation + selects the matching object within the tree's own model.""" + resolved = self._resolve_short_id(short_id) + if resolved is None: + return + _panel_str, obj = resolved + self.set_current_item_id(get_uuid(obj)) + + def _short_id_at(self, pos: QC.QPoint) -> str | None: + """Return the short ID under viewport position ``pos``, or ``None`` + when the cursor is not over an anchor.""" + index = self.indexAt(pos) + if not index.isValid() or index.column() != 0: + return None + rect = self.visualRect(index) + option = self.viewOptions() + option.rect = rect + return self._title_delegate.anchor_at(index, rect, pos, option) + + # pylint: disable=invalid-name + def mousePressEvent(self, event: QG.QMouseEvent) -> None: + """Reimplement Qt method to handle short-ID hyperlink clicks.""" + if event.button() == QC.Qt.LeftButton: + short_id = self._short_id_at(event.pos()) + if short_id is not None: + event.accept() + self._handle_short_id_click(short_id) + return + super().mousePressEvent(event) + + # pylint: disable=invalid-name + def mouseMoveEvent(self, event: QG.QMouseEvent) -> None: + """Reimplement Qt method to update the cursor over short-ID anchors.""" + short_id = self._short_id_at(event.pos()) + viewport = self.viewport() + if short_id is not None: + viewport.setCursor(QC.Qt.PointingHandCursor) + else: + viewport.unsetCursor() + super().mouseMoveEvent(event) + def populate_tree(self) -> None: """Populate tree with objects""" uuid = self.get_current_item_id() @@ -387,6 +494,48 @@ def __init__(self, parent: BaseDataPanel, objmodel: ObjectModel) -> None: self.__dragged_groups: list[QW.QListWidgetItem] = [] self.__dragged_expanded_states: dict[QW.QListWidgetItem, bool] = {} + def _resolve_short_id( + self, short_id: str + ) -> tuple[str, SignalObj | ImageObj | ObjectGroup] | None: + """Resolve a short ID across the signal *and* image panels of the main + window, so titles can reference objects living in either panel. + """ + panel: BaseDataPanel = self.parent() + mainwindow = getattr(panel, "mainwindow", None) + candidates: list[tuple[str, ObjectModel]] = [] + if mainwindow is not None: + sigpanel = getattr(mainwindow, "signalpanel", None) + if sigpanel is not None: + candidates.append(("signal", sigpanel.objmodel)) + imgpanel = getattr(mainwindow, "imagepanel", None) + if imgpanel is not None: + candidates.append(("image", imgpanel.objmodel)) + else: + candidates.append((panel.PANEL_STR_ID, self.objmodel)) + # Prefer the panel that owns this view (so a self-reference resolves + # locally), then fall back to the other panel. + own = panel.PANEL_STR_ID + candidates.sort(key=lambda c: 0 if c[0] == own else 1) + for panel_str, model in candidates: + obj = model.find_by_short_id(short_id) + if obj is not None: + return (panel_str, obj) + return None + + def _handle_short_id_click(self, short_id: str) -> None: + """Select the referenced object, switching active panel if needed.""" + resolved = self._resolve_short_id(short_id) + if resolved is None: + return + panel_str, obj = resolved + panel: BaseDataPanel = self.parent() + mainwindow = getattr(panel, "mainwindow", None) + if mainwindow is not None and panel_str and panel_str != panel.PANEL_STR_ID: + mainwindow.set_current_panel(panel_str) + mainwindow.select_objects([get_uuid(obj)], panel=panel_str) + else: + self.set_current_item_id(get_uuid(obj)) + def paintEvent(self, event): # pylint: disable=C0103 """Reimplement Qt method""" super().paintEvent(event) diff --git a/datalab/objectmodel.py b/datalab/objectmodel.py index a7b8296d6..5976c3ae2 100644 --- a/datalab/objectmodel.py +++ b/datalab/objectmodel.py @@ -110,6 +110,24 @@ def patch_title_with_ids( ) from exc +#: Regex matching short IDs as embedded in computation titles +#: (e.g. ``s001``, ``i012``, ``gs003``, ``gi007``). +SHORT_ID_REGEX = re.compile(r"\b(g?[si])(\d{3})\b") + + +def find_short_ids_in_title(title: str) -> list[tuple[int, int, str]]: + """Return a list of ``(start, end, short_id)`` tuples for every short ID + occurrence found in ``title``. + + Args: + title: title string to scan + + Returns: + List of ``(start, end, short_id)`` tuples, sorted by ``start``. + """ + return [(m.start(), m.end(), m.group(0)) for m in SHORT_ID_REGEX.finditer(title)] + + class ObjectGroup: """Represents a DataLab object group @@ -292,6 +310,28 @@ def get_object_or_group(self, uuid: str) -> SignalObj | ImageObj | ObjectGroup: return group raise KeyError(f"Object or group with uuid {uuid} not found") + def find_by_short_id( + self, short_id: str + ) -> SignalObj | ImageObj | ObjectGroup | None: + """Return the object or group whose short ID matches ``short_id``, + or ``None`` if no match is found in this model. + + Args: + short_id: short ID to look up (e.g. ``"s001"``, ``"i012"``, + ``"gs003"`` or ``"gi007"``). + + Returns: + The matching :class:`sigima.SignalObj`, :class:`sigima.ImageObj` + or :class:`ObjectGroup` instance, or ``None``. + """ + for group in self._groups: + if get_short_id(group) == short_id: + return group + for obj in self._objects.values(): + if get_short_id(obj) == short_id: + return obj + return None + def get_group(self, uuid: str) -> ObjectGroup: """Return group with uuid""" for group in self._groups: diff --git a/datalab/tests/features/common/title_short_id_unit_test.py b/datalab/tests/features/common/title_short_id_unit_test.py new file mode 100644 index 000000000..23fe3e966 --- /dev/null +++ b/datalab/tests/features/common/title_short_id_unit_test.py @@ -0,0 +1,62 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +""" +Short ID title link unit test. + +Validates the helpers underpinning the clickable short-ID feature in +:mod:`datalab.gui.objectview`: + +- :func:`datalab.objectmodel.find_short_ids_in_title` +- :func:`datalab.widgets.titledelegate._build_html` +""" + +# pylint: disable=invalid-name # Allows short reference names like x, y, ... + +from __future__ import annotations + +from datalab.objectmodel import find_short_ids_in_title +from datalab.widgets.titledelegate import SHORT_ID_URL_SCHEME, _build_html + + +def test_find_short_ids_in_title_basic() -> None: + """``find_short_ids_in_title`` extracts every short ID with its bounds.""" + matches = find_short_ids_in_title("s003: average(s001, s002)") + assert [m[2] for m in matches] == ["s003", "s001", "s002"] + assert matches[0][0] == 0 # leading short ID starts at offset 0 + + +def test_find_short_ids_in_title_mixed_kinds() -> None: + """Image, group and signal short IDs are all detected.""" + matches = find_short_ids_in_title("i012: derived(s001, gi003)") + assert [m[2] for m in matches] == ["i012", "s001", "gi003"] + + +def test_find_short_ids_in_title_no_false_positives() -> None: + """Random ``letter+digits`` patterns are not mistaken for short IDs.""" + # `s12345` has too many digits; `s1` has too few. + assert find_short_ids_in_title("s12345 then s1") == [] + # Substrings inside a word must not match either. + assert find_short_ids_in_title("class s001abc") == [] + + +def test_build_html_skips_leading_short_id() -> None: + """The leading ``s001:`` part is rendered as plain text.""" + html = _build_html("s003: average(s001, s002)") + # Leading "s003" must NOT be wrapped in an anchor + assert html.startswith("s003") + assert f'href="{SHORT_ID_URL_SCHEME}:s003"' not in html + # But s001 and s002 inside the body must be anchors + assert f'href="{SHORT_ID_URL_SCHEME}:s001"' in html + assert f'href="{SHORT_ID_URL_SCHEME}:s002"' in html + + +def test_build_html_escapes_text() -> None: + """Surrounding text is HTML-escaped to avoid markup injection.""" + html = _build_html("s003:
" + f"{'
'.join(rows)}& friends") + assert "<not a tag>" in html + assert "&" in html + + +def test_build_html_no_short_ids_returns_plain_text() -> None: + """Without short IDs, the output is just the escaped text.""" + assert _build_html("Just a title") == "Just a title" diff --git a/datalab/widgets/titledelegate.py b/datalab/widgets/titledelegate.py new file mode 100644 index 000000000..7930d1525 --- /dev/null +++ b/datalab/widgets/titledelegate.py @@ -0,0 +1,209 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +""" +Title delegate +============== + +The :mod:`datalab.widgets.titledelegate` module provides a +:class:`QStyledItemDelegate` that renders object tree titles as rich text and +turns embedded short IDs (e.g. ``s001``, ``i012``) into clickable hyperlinks. + +.. autoclass:: ClickableTitleDelegate +""" + +# pylint: disable=invalid-name # Allows short reference names like x, y, ... + +from __future__ import annotations + +from html import escape +from typing import TYPE_CHECKING + +from qtpy import QtCore as QC +from qtpy import QtGui as QG +from qtpy import QtWidgets as QW + +from datalab.objectmodel import find_short_ids_in_title + +if TYPE_CHECKING: + pass + + +#: URL scheme used in anchors emitted by :class:`ClickableTitleDelegate`. +SHORT_ID_URL_SCHEME = "dlb-shortid" + + +def _build_html(text: str) -> str: + """Build the HTML representation of ``text`` with short IDs wrapped in + anchors. + + The first short ID occurrence is always rendered as plain text: object + titles in DataLab tree views are formatted as ``" : "`` and + making the leading ``s001`` clickable would just re-select the current + item. + + Args: + text: raw item text (e.g. ``"s003: average(s001, s002)"``). + + Returns: + HTML string. + """ + matches = find_short_ids_in_title(text) + if not matches: + return escape(text) + out: list[str] = [] + cursor = 0 + for idx, (start, end, sid) in enumerate(matches): + out.append(escape(text[cursor:start])) + if idx == 0 and start == 0: + # Leading "s001:" — keep as plain text + out.append(escape(sid)) + else: + out.append(f'{escape(sid)}') + cursor = end + out.append(escape(text[cursor:])) + return "".join(out) + + +def _make_text_document( + text: str, + option: QW.QStyleOptionViewItem, + link_color: QG.QColor, + text_color: QG.QColor | None = None, +) -> QG.QTextDocument: + """Return a :class:`QTextDocument` rendering ``text`` with the styling + inherited from ``option``. + + ``link_color`` (and optionally ``text_color``) are baked into the + document's default style sheet, because :class:`QTextDocument` resolves + anchor colors at parse time — the painting palette has no effect on + them. + """ + doc = QG.QTextDocument() + doc.setDefaultFont(option.font) + doc.setDocumentMargin(0) + css_parts = [f"a {{ color: {link_color.name()}; text-decoration: underline; }}"] + if text_color is not None: + css_parts.append(f"body, p, span {{ color: {text_color.name()}; }}") + doc.setDefaultStyleSheet(" ".join(css_parts)) + doc.setHtml(_build_html(text)) + return doc + + +class ClickableTitleDelegate(QW.QStyledItemDelegate): + """Item delegate that renders object titles with clickable short IDs. + + The delegate uses a :class:`QTextDocument` to render an HTML version of the + item's display text in which each embedded short ID — apart from the + leading one — is wrapped in an anchor pointing at ``dlb-shortid: ``. + + Hit-testing is performed by :meth:`anchor_at`, which is meant to be called + from the host view's ``mousePressEvent`` / ``mouseMoveEvent``. + """ + + def __init__(self, parent: QW.QAbstractItemView) -> None: + super().__init__(parent) + + # pylint: disable=invalid-name + def paint( + self, + painter: QG.QPainter, + option: QW.QStyleOptionViewItem, + index: QC.QModelIndex, + ) -> None: + """Reimplement Qt method to paint the item via a QTextDocument.""" + text = index.data(QC.Qt.DisplayRole) or "" + if not isinstance(text, str) or not find_short_ids_in_title(text): + super().paint(painter, option, index) + return + opt = QW.QStyleOptionViewItem(option) + self.initStyleOption(opt, index) + # Let the style draw the background, focus rect and decoration + # (icon), but not the text. + opt.text = "" + style = opt.widget.style() if opt.widget else QW.QApplication.style() + style.drawControl(QW.QStyle.CE_ItemViewItem, opt, painter, opt.widget) + + text_rect = style.subElementRect(QW.QStyle.SE_ItemViewItemText, opt, opt.widget) + palette = option.palette + selected = bool(option.state & QW.QStyle.State_Selected) + # ``QPalette.Highlight`` is the theme's accent color (vivid in both + # light and dark modes) — much more readable than the default + # ``QPalette.Link`` role, which many themes leave at Qt's hard-coded + # dark blue. + accent = palette.color(QG.QPalette.Active, QG.QPalette.Highlight) + if selected: + text_color = palette.color(QG.QPalette.Active, QG.QPalette.HighlightedText) + # On dark themes the selection background *is* the accent color, + # so a plain accent-colored link would vanish: blend it 50/50 + # with ``HighlightedText`` (typically white) to obtain a lighter + # tint that still reads as the same hue. On light themes the + # accent stays distinguishable on the highlight background, so + # we keep the unselected color for visual consistency. + base_is_light = ( + palette.color(QG.QPalette.Active, QG.QPalette.Base).lightness() > 128 + ) + if base_is_light: + link_color = accent + else: + link_color = QG.QColor( + (accent.red() + text_color.red()) // 2, + (accent.green() + text_color.green()) // 2, + (accent.blue() + text_color.blue()) // 2, + ) + else: + text_color = palette.color(QG.QPalette.Active, QG.QPalette.Text) + link_color = accent + doc = _make_text_document(text, option, link_color, text_color) + doc.setTextWidth(text_rect.width()) + painter.save() + painter.translate(text_rect.topLeft()) + ctx = QG.QAbstractTextDocumentLayout.PaintContext() + clip = QC.QRectF(0, 0, text_rect.width(), text_rect.height()) + ctx.clip = clip + painter.setClipRect(clip) + doc.documentLayout().draw(painter, ctx) + painter.restore() + + # pylint: disable=invalid-name + def sizeHint( + self, option: QW.QStyleOptionViewItem, index: QC.QModelIndex + ) -> QC.QSize: + """Reimplement Qt method to size items consistently with default text + rendering.""" + return super().sizeHint(option, index) + + def anchor_at( + self, + index: QC.QModelIndex, + item_rect: QC.QRect, + pos: QC.QPoint, + option: QW.QStyleOptionViewItem, + ) -> str | None: + """Return the short ID under cursor position ``pos`` (in viewport + coordinates) for ``index``, or ``None`` if the cursor is not over any + anchor. + + Args: + index: model index of the item under cursor + item_rect: visual rectangle of the item in the viewport + pos: cursor position in viewport coordinates + option: style option (already initialized for the item) + """ + text = index.data(QC.Qt.DisplayRole) or "" + if not isinstance(text, str) or not find_short_ids_in_title(text): + return None + opt = QW.QStyleOptionViewItem(option) + opt.rect = item_rect + self.initStyleOption(opt, index) + style = opt.widget.style() if opt.widget else QW.QApplication.style() + text_rect = style.subElementRect(QW.QStyle.SE_ItemViewItemText, opt, opt.widget) + if not text_rect.contains(pos): + return None + # Color does not influence hit-testing — pass any value. + doc = _make_text_document(text, option, QG.QColor("black")) + doc.setTextWidth(text_rect.width()) + local = QC.QPointF(pos - text_rect.topLeft()) + href = doc.documentLayout().anchorAt(local) + if href and href.startswith(f"{SHORT_ID_URL_SCHEME}:"): + return href[len(SHORT_ID_URL_SCHEME) + 1 :] + return None From 5100ee5fe0a61c11cd67c4e4743eb704fa6182d5 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut Date: Sun, 17 May 2026 12:31:00 +0200 Subject: [PATCH 17/24] feat(translations): update French translations for various UI elements and add new history panel entries --- datalab/locale/fr/LC_MESSAGES/datalab.po | 116 ++++++++++++----------- 1 file changed, 62 insertions(+), 54 deletions(-) diff --git a/datalab/locale/fr/LC_MESSAGES/datalab.po b/datalab/locale/fr/LC_MESSAGES/datalab.po index 73cc817ff..7e49681a5 100644 --- a/datalab/locale/fr/LC_MESSAGES/datalab.po +++ b/datalab/locale/fr/LC_MESSAGES/datalab.po @@ -162,6 +162,7 @@ msgstr "Supprimer" msgid "Rename" msgstr "Renommer" + msgid "Rename the selected conversation (F2)." msgstr "Renommer la conversation sélectionnée (F2)." @@ -583,7 +584,7 @@ msgid "Show results" msgstr "Afficher les résultats" msgid "Results label" -msgstr "Etiquette des résultats" +msgstr "Étiquette des résultats" msgid "Show or hide the merged result label on the plot" msgstr "Afficher ou masquer l'étiquette de résultat fusionnée sur le graphique" @@ -1304,9 +1305,8 @@ msgstr "Métadonnées de l'objet" msgid "(click on Metadata button for more details)" msgstr "(cliquer sur le bouton Métadonnées pour plus de détails)" -#, fuzzy msgid "group" -msgstr "Groupe" +msgstr "groupe" msgid "Source objects" msgstr "Objets source" @@ -1472,7 +1472,7 @@ msgid "Conversion" msgstr "Conversion" msgid "Duplicate object or group" -msgstr "Dupliquer l'objet ou le groupe %s" +msgstr "Dupliquer l'objet ou le groupe" msgid "Select what to keep from the clipboard.
Result shapes and annotations, if kept, will be merged with existing ones. All other metadata will be replaced." msgstr "Sélectionnez ce que vous souhaitez conserver dans le presse-papier.
Les formes graphiques et les annotations, si conservées, seront fusionnées avec celles existantes. Toutes les autres métadonnées seront remplacées." @@ -1640,34 +1640,6 @@ msgstr "Annotation ajoutée" msgid "The label has been added as an annotation. You can edit or remove it using the annotation editing window.
Choosing to ignore this message will prevent it from being displayed again." msgstr "L'étiquette a été ajoutée comme annotation. Vous pouvez la modifier ou la supprimer en utilisant la fenêtre d'édition des annotations.
Ignorer ce message empêchera son affichage ultérieur." -msgid "Console toolbar" -msgstr "Barre d'outils de la console" - -msgid "Clear console" -msgstr "Effacer la console" - -msgid "Export console output to file..." -msgstr "Exporter la sortie console vers un fichier..." - -msgid "↓ Resume autoscroll" -msgstr "↓ Reprendre le défilement" - -msgid "Export console" -msgstr "Exporter la console" - -msgid "Recent macros" -msgstr "Macros récentes" - -msgid "Clear all" -msgstr "Tout effacer" - -#, fuzzy -msgid "Untitled" -msgstr "(sans titre)" - -msgid "Clear all recent macros?" -msgstr "Effacer toutes les macros récentes ?" - #, python-format msgid "Failed to deserialize history DataSet kwarg %r." msgstr "Échec de la désérialisation de l'argument DataSet de l'historique %r." @@ -1696,11 +1668,58 @@ msgstr "Date et heure" msgid "Description" msgstr "Description" + msgid "Title" msgstr "Titre" + +msgid "History panel" +msgstr "Panneau d'historique" + +msgid "Edit mode" +msgstr "Mode d'édition" + +msgid "Record mode" +msgstr "Mode d'enregistrement" + +msgid "Replay" +msgstr "Rejouer" + +msgid "Restore selection" +msgstr "Restaurer la sélection" + +msgid "Restore selection and replay" +msgstr "Restaurer la sélection et rejouer" + +msgid "The current workspace state is not compatible with the action." +msgstr "L'état actuel de l'espace de travail n'est pas compatible avec l'action." + +msgid "Delete actions" +msgstr "Supprimer les actions" + +msgid "Do you really want to delete the selected action and all the next ones?" +msgstr "Voulez-vous vraiment supprimer l'action sélectionnée et toutes les suivantes ?" + +msgid "New image" +msgstr "Nouvelle image" + +msgid "Recent macros" +msgstr "Macros récentes" + +msgid "Clear all" +msgstr "Tout effacer" + +msgid "Untitled" +msgstr "Sans titre" + +msgid "Clear all recent macros?" +msgstr "Effacer toutes les macros récentes ?" + msgid "Macro panel" msgstr "Panneau des macros" +msgid "Clear console" +msgstr "Effacer la console" + msgid "-***- Macro Console -***-" msgstr "-***- Console des macros -***-" @@ -1749,7 +1768,6 @@ msgstr "● Macro en cours d'exécution" msgid "○ Idle" msgstr "○ Inactif" -#, python-format msgid "Restore unsaved macros" msgstr "Restaurer les macros non enregistrées" @@ -2484,7 +2502,7 @@ msgid "Allan variance" msgstr "Variance d'Allan" msgid "Allan deviation" -msgstr "Ecart-type d'Allan" +msgstr "Écart-type d'Allan" msgid "Overlapping Allan variance" msgstr "Variance d’Allan avec recouvrement" @@ -2499,7 +2517,7 @@ msgid "Total variance" msgstr "Variance totale" msgid "Time deviation" -msgstr "Ecart-type temporel" +msgstr "Écart-type temporel" msgid "X-Y mode" msgstr "Mode X-Y" @@ -2983,31 +3001,31 @@ msgid "Maximum number of columns to display in merged result label." msgstr "Nombre maximum de colonnes à afficher dans l'étiquette de résultat fusionnée." msgid "Show the merged result label by default" -msgstr "Afficher le titre de résultat fusionné par défaut" +msgstr "Afficher l'étiquette de résultat fusionnée par défaut" msgid "Result label" msgstr "Étiquette des résultats" msgid "Show the merged result label on the plot by default. Can be toggled per-object using the checkbox in the Properties panel." -msgstr "Etiquette des résultats fusionnés sur le graphique par défaut. Peut être activée ou désactivée par objet à l'aide de la case à cocher dans le panneau des propriétés." +msgstr "Afficher l'étiquette des résultats fusionnés sur le graphique par défaut. Peut être activée ou désactivée par objet à l'aide de la case à cocher dans le panneau des propriétés." msgid "Show marker labels in result tables" -msgstr "" +msgstr "Afficher les étiquettes de marqueurs dans les tables de résultats" msgid "Marker labels" -msgstr "Etiquettes des marqueurs" +msgstr "Étiquettes des marqueurs" msgid "Prepend a marker-label column to result tables for marker results (XY/X/Y markers), so each row can be matched with the corresponding cross or dashed cursor drawn on the plot. XY markers use numeric labels (#1, #2, ...), axis markers use letters (a, b, c, ...)." msgstr "Ajouter une colonne d'étiquettes de marqueurs aux tables de résultats pour les résultats de marqueurs (marqueurs XY/X/Y), afin que chaque ligne puisse être associée au curseur croisé ou en pointillés correspondant dessiné sur le graphique. Les marqueurs XY utilisent des étiquettes numériques (#1, #2, ...), les marqueurs d'axe utilisent des lettres (a, b, c, ...)." msgid "Process isolation enable status" -msgstr "Etat d'activation de l'isolation de processus" +msgstr "État d'activation de l'isolation de processus" msgid "RPC server enable status" -msgstr "Etat d'activation du serveur XML-RPC" +msgstr "État d'activation du serveur XML-RPC" msgid "Console enable status" -msgstr "Etat d'activation de la console" +msgstr "État d'activation de la console" msgid "Third-party plugins path" msgstr "Chemin des plugins tiers" @@ -3453,7 +3471,7 @@ msgid "Base line" msgstr "Ligne de base" msgid "Std-dev" -msgstr "Ecart-type" +msgstr "Écart-type" msgid "Mean" msgstr "Moyenne" @@ -3560,9 +3578,6 @@ msgstr "Afficher le tableau" msgid "Path" msgstr "Chemin" -msgid "Description" -msgstr "Description" - msgid "Textual preview" msgstr "Aperçu textuel" @@ -3844,9 +3859,6 @@ msgstr "Tout sélectionner" msgid "Adding data to the plot" msgstr "Ajout des données au graphique" -msgid "Title" -msgstr "Titre" - msgid "X label" msgstr "Titre X" @@ -3872,7 +3884,7 @@ msgid "Set the labels and units for the imported data" msgstr "Définir les titres et unités pour les données importées" msgid "The following title, labels and units will be applied to the data.
Note: Leave empty the labels and units to keep the default values (i.e. the values which were inferred from the data)." -msgstr "Les titres, étiquettes et unités suivants seront appliqués aux données.
Note : Laisser vide les étiquettes et unités pour conserver les valeurs par défaut (c'est-à-dire les valeurs qui ont été déduites des données)." +msgstr "Les titres, étiquettes et unités suivants seront appliqués aux données.
Note : Laisser vide les étiquettes et unités pour conserver les valeurs par défaut (c'est-à-dire les valeurs qui ont été déduites des données)." msgid "clipboard" msgstr "presse-papiers" @@ -3907,9 +3919,6 @@ msgstr "Merci de cliquer sur le bouton 'Ignorer' pour ignorer cet avertissement msgid "Back" msgstr "Précédent" -msgid "Next" -msgstr "Suivant" - msgid "Finish" msgstr "Terminer" @@ -3987,4 +3996,3 @@ msgstr "Aucun contour n'a été trouvé pour la plage de niveaux sélectionnée. msgid "Show contour plot..." msgstr "Afficher le tracé de contours..." - From 03c7bbc5d41eb0dc4f5b7ce1d271860075d5fcfc Mon Sep 17 00:00:00 2001 From: Pierre RaybautDate: Tue, 19 May 2026 13:30:01 +0200 Subject: [PATCH 18/24] Fix pylint warnings --- datalab/gui/panel/history.py | 25 ++++++++----------- .../tests/features/common/history_app_test.py | 3 +++ .../common/history_replay_app_test.py | 2 ++ 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/datalab/gui/panel/history.py b/datalab/gui/panel/history.py index b7d764a64..ac043afb8 100644 --- a/datalab/gui/panel/history.py +++ b/datalab/gui/panel/history.py @@ -10,12 +10,15 @@ import functools import html +import inspect import os import warnings from contextlib import contextmanager from typing import TYPE_CHECKING, Any, Callable, Generator from uuid import uuid4 +import sigima.proc.image +import sigima.proc.signal from guidata.configtools import get_icon from guidata.dataset.conv import dataset_to_json, json_to_dataset from guidata.dataset.datatypes import DataSet @@ -107,14 +110,16 @@ def get_datetime_str() -> str: return QC.QDateTime.currentDateTime().toString("yyyy-MM-dd hh:mm:ss") -def add_to_history(kwargs_names: list[str] = [], title: str | None = None): +def add_to_history(kwargs_names: list[str] | None = None, title: str | None = None): """Method decorator to add the method call to the history panel as a UI entry. Args: kwargs_names: List of keyword arguments to add to the history action. - Defaults to []. + Defaults to None. title: Title of the history action. Defaults to None. """ + if kwargs_names is None: + kwargs_names = [] def add_to_history_decorator(func): """Decorator function""" @@ -333,13 +338,7 @@ def _resolve_panel(self, mainwindow: DLMainWindow): def _resolve_callable(self) -> Callable | None: """Best-effort lookup of the underlying callable, for description only.""" if self.kind == self.KIND_COMPUTE and self.func_name: - try: - # Lazy import to avoid cycles at module import time. - import sigima.proc.image as sigimg # noqa: F401 - import sigima.proc.signal as sigsig # noqa: F401 - except Exception: # pylint: disable=broad-except - return None - for module in (sigsig, sigimg): + for module in (sigima.proc.signal, sigima.proc.image): func = getattr(module, self.func_name, None) if callable(func): return func @@ -467,8 +466,6 @@ def _replay_ui(self, mainwindow: DLMainWindow, edit: bool) -> None: call_kwargs = dict(self.kwargs) # Inject edit mode if the method supports it try: - import inspect - sig = inspect.signature(method) if self.FUNC_EDIT_MODE in sig.parameters: call_kwargs[self.FUNC_EDIT_MODE] = edit @@ -626,7 +623,7 @@ def save(self, mainwindow: DLMainWindow) -> None: obj.title for obj in panel.objmodel if get_uuid(obj) in sel_uuids ] - def is_current_state_compatible( + def is_current_state_compatible( # pylint: disable=unused-argument self, mainwindow: DLMainWindow, restore_selection: bool ) -> bool: """Check if the current workspace state is compatible with the saved state. @@ -1379,8 +1376,7 @@ def __getitem__(self, nb: int) -> HistoryAction: def __iter__(self) -> Generator[HistoryAction, None, None]: """Iterate over objects""" for session in self.__history_sessions: - for action in session.actions: - yield action + yield from session.actions def create_new_session(self) -> None: """Create a new history list""" @@ -1507,7 +1503,6 @@ def add_entry( action_title: str, save_state: bool, func: Callable, - *args, **kwargs, ) -> None: """Legacy entry-point kept as a compatibility shim. diff --git a/datalab/tests/features/common/history_app_test.py b/datalab/tests/features/common/history_app_test.py index 98089055e..bf50d08e3 100644 --- a/datalab/tests/features/common/history_app_test.py +++ b/datalab/tests/features/common/history_app_test.py @@ -170,11 +170,14 @@ def test_history_app(): # underlying ``HistorySession.remove_action`` path used by it. target = new_session_entry target_session: HistorySession | None = None + + # pylint: disable=protected-access for session in history._HistoryPanel__history_sessions: # noqa: SLF001 if target in session.actions: target_session = session break assert target_session is not None + n_before = len(history) target_session.remove_action(target) assert len(history) < n_before diff --git a/datalab/tests/features/common/history_replay_app_test.py b/datalab/tests/features/common/history_replay_app_test.py index a900d5982..6b349d43b 100644 --- a/datalab/tests/features/common/history_replay_app_test.py +++ b/datalab/tests/features/common/history_replay_app_test.py @@ -387,6 +387,7 @@ def test_full_session_replay_remaps_uuids_for_n_to_1(): panel.processor.run_feature(sips.average) assert len(panel.objmodel) == 4 + # pylint: disable=protected-access session = history._HistoryPanel__history_sessions[-1] # noqa: SLF001 # Reset to an empty workspace, then replay the whole session. @@ -422,6 +423,7 @@ def test_full_session_replay_with_intermediate_removal(): panel.processor.run_feature(sips.average) assert len(panel.objmodel) == 3 + # pylint: disable=protected-access session = history._HistoryPanel__history_sessions[-1] # noqa: SLF001 # Reset to an empty workspace, then replay the whole session. From d5eae1e834d2ad5b835ac0bc9aea11ad6d95e1be Mon Sep 17 00:00:00 2001 From: Duy Anh Philippe PHAM Date: Wed, 3 Jun 2026 13:05:09 +0200 Subject: [PATCH 19/24] Update History Panel --- datalab/gui/main.py | 4 +- datalab/gui/panel/base.py | 288 ++- datalab/gui/panel/history.py | 3670 +++++++++++++++++++++++++++++++-- datalab/gui/processor/base.py | 1176 +++++++---- datalab/h5/native.py | 2 +- 5 files changed, 4569 insertions(+), 571 deletions(-) diff --git a/datalab/gui/main.py b/datalab/gui/main.py index cb579a769..709604cd7 100644 --- a/datalab/gui/main.py +++ b/datalab/gui/main.py @@ -2018,8 +2018,10 @@ def toggle_show_first_only(self, state: bool) -> None: def reset_all(self) -> None: """Reset all application data""" for panel in self.panels: - if panel is not None: + if panel is not None and panel is not self.historypanel: panel.remove_all_objects() + if self.historypanel is not None: + self.historypanel.start_new_session_after_workspace_reset() @remote_controlled def remove_object(self, force: bool = False) -> None: diff --git a/datalab/gui/panel/base.py b/datalab/gui/panel/base.py index 4e960467a..810d75d2e 100644 --- a/datalab/gui/panel/base.py +++ b/datalab/gui/panel/base.py @@ -9,6 +9,7 @@ from __future__ import annotations import abc +import copy import glob import os import os.path as osp @@ -90,6 +91,7 @@ get_number, get_short_id, get_uuid, + patch_title_with_ids, set_number, set_uuid, ) @@ -207,6 +209,13 @@ def __init__(self, panel: BaseDataPanel, objclass: SignalObj | ImageObj) -> None self.processing_param_editor: gdq.DataSetEditGroupBox | None = None self.current_processing_obj: SignalObj | ImageObj | None = None self.processing_scroll: QW.QScrollArea | None = None + # Auto-recompute toggle (session-only state, not persisted to Conf). + self.__auto_recompute_enabled: bool = False + self.__auto_recompute_timer = QC.QTimer(self) + self.__auto_recompute_timer.setSingleShot(True) + self.__auto_recompute_timer.timeout.connect( + self.__auto_recompute_trigger + ) # Properties tab self.properties = gdq.DataSetEditGroupBox("", objclass) @@ -725,6 +734,23 @@ def setup_processing_tab( editor.SIG_APPLY_BUTTON_CLICKED.connect(self.apply_processing_parameters) editor.set_apply_button_state(False) + # Hook into the per-edit change callback to support auto-recompute. + # ``DataSetEditLayout.change_callback`` is called whenever any widget + # value changes; wrap it so we can also (re)start the debounce timer. + try: + inner_layout = editor.edit # DataSetEditLayout instance + original_change_cb = inner_layout.change_callback + + def _wrapped_change_cb() -> None: + if original_change_cb is not None: + original_change_cb() + if self.__auto_recompute_enabled: + self.__auto_recompute_timer.start(300) + + inner_layout.change_callback = _wrapped_change_cb + except AttributeError: + pass + # Store reference to be able to retrieve it later self.processing_param_editor = editor @@ -751,7 +777,21 @@ def setup_processing_tab( QW.QSizePolicy.Expanding, QW.QSizePolicy.Preferred ) - self.processing_scroll.setWidget(editor) + # Build the tab content: editor + "Auto-recompute" checkbox. + container = QW.QWidget() + vbox = QW.QVBoxLayout(container) + vbox.setContentsMargins(0, 0, 0, 0) + vbox.addWidget(editor) + auto_cb = QW.QCheckBox(_("Auto-recompute on edit"), container) + auto_cb.setToolTip( + _("Automatically re-run processing when parameters are modified") + ) + auto_cb.setChecked(self.__auto_recompute_enabled) + auto_cb.toggled.connect(self.__set_auto_recompute_enabled) + vbox.addWidget(auto_cb) + vbox.addStretch(1) + + self.processing_scroll.setWidget(container) self.tabwidget.insertTab( insert_index, self.processing_scroll, @@ -781,6 +821,24 @@ def __get_processor_associated_to( return self.panel.mainwindow.signalpanel.processor return self.panel.mainwindow.imagepanel.processor + def __set_auto_recompute_enabled(self, enabled: bool) -> None: + """Toggle auto-recompute mode (session-only, not persisted).""" + self.__auto_recompute_enabled = bool(enabled) + if not self.__auto_recompute_enabled: + self.__auto_recompute_timer.stop() + + def __auto_recompute_trigger(self) -> None: + """Debounced callback: push widget values then re-run processing.""" + if not self.__auto_recompute_enabled: + return + editor = self.processing_param_editor + if editor is None: + return + # ``editor.set()`` synchronises widget values to the dataset and emits + # ``SIG_APPLY_BUTTON_CLICKED`` which is already wired to + # ``apply_processing_parameters``. + editor.set(check=False) + def apply_processing_parameters( self, obj: SignalObj | ImageObj | None = None, interactive: bool = True ) -> ProcessingReport: @@ -864,49 +922,116 @@ def apply_processing_parameters( else: report.success = True - # Update the current object in-place with data from new object - obj.title = new_obj.title - if isinstance(obj, SignalObj): - obj.xydata = new_obj.xydata - else: # ImageObj - obj.data = new_obj.data - # Invalidate ROI mask cache when image dimensions may have changed - # (the mask is computed based on image shape, so it must be recomputed) - obj.invalidate_maskdata_cache() - - # Update metadata with new processing parameters - updated_proc_params = ProcessingParameters( - func_name=proc_params.func_name, - pattern=proc_params.pattern, - param=param, - source_uuid=proc_params.source_uuid, - ) - insert_processing_parameters(obj, updated_proc_params) - - # Auto-recompute analysis if the object had analysis parameters - # Since the data has changed, any analysis results are now invalid - # Use the processor for the current object's type (not source object's type) - obj_processor = self.__get_processor_associated_to(obj) - obj_processor.auto_recompute_analysis(obj) - - # Update the tree view item and refresh plot - obj_uuid = get_uuid(obj) - self.panel.objview.update_item(obj_uuid) - self.panel.refresh_plot(obj_uuid, update_items=True, force=True) - - # Update the Properties tab to reflect the new object properties - # (e.g., data type, dimensions, etc.) - self.__update_properties_dataset(obj) - - # Refresh the Processing tab with the new parameters - # Don't reset parameters from source object - keep the user's values - # Set the Processing tab as current to keep it visible after refresh - QC.QTimer.singleShot( - 0, - lambda: self.setup_processing_tab( - obj, reset_params=False, set_current=True - ), - ) + hpanel = getattr(self.panel.mainwindow, "historypanel", None) + is_edit_mode = hpanel is not None and hpanel.is_edit_mode() + + if is_edit_mode: + # --- Edit mode: mutate obj in-place, cascade downstream --- + + # Update the current object in-place with data from new object + obj.title = new_obj.title + if isinstance(obj, SignalObj): + obj.xydata = new_obj.xydata + else: # ImageObj + obj.data = new_obj.data + # Invalidate ROI mask cache when image dimensions may + # have changed (mask depends on image shape) + obj.invalidate_maskdata_cache() + + # Update metadata with new processing parameters + updated_proc_params = ProcessingParameters( + func_name=proc_params.func_name, + pattern=proc_params.pattern, + param=param, + source_uuid=proc_params.source_uuid, + ) + insert_processing_parameters(obj, updated_proc_params) + + # Propagate the edited param to the History panel: + # Mutate the matching existing action (snapshot originals + # first), refresh its tree display, then cascade recompute + # to downstream actions so the chain stays consistent with + # the new parameters. + action = hpanel.find_action_for_output( + get_uuid(obj), proc_params.func_name + ) + if action is not None: + action.snapshot_kwargs() + action.kwargs["param"] = copy.deepcopy(param) + hpanel.refresh_action(action) + hpanel.recompute_cascade(action) + + # Auto-recompute analysis (data changed, results invalid) + obj_processor = self.__get_processor_associated_to(obj) + obj_processor.auto_recompute_analysis(obj) + + # Update the tree view item and refresh plot + obj_uuid = get_uuid(obj) + self.panel.objview.update_item(obj_uuid) + self.panel.refresh_plot(obj_uuid, update_items=True, force=True) + + # Update the Properties tab to reflect the new object + self.__update_properties_dataset(obj) + # Refresh the displayed processing history (Properties tab + # description) so the parameter change is visible immediately + self.display_processing_history(obj) + + # Refresh the Processing tab with the new parameters + QC.QTimer.singleShot( + 0, + lambda: self.setup_processing_tab( + obj, reset_params=False, set_current=True + ), + ) + else: + # --- Non-edit mode: create a new independent object --- + + # Patch title with source object IDs (like menu-driven compute) + patch_title_with_ids(new_obj, [obj], get_short_id) + + # Store processing metadata on the new object + # pylint: disable=import-outside-toplevel + from datalab.gui.processor.base import ( + build_processing_parameters, + ) + + new_pp = build_processing_parameters( + proc_params.func_name, + proc_params.pattern, + param=copy.deepcopy(param), + source_uuid=proc_params.source_uuid, + ) + insert_processing_parameters(new_obj, new_pp) + + # Mark as freshly processed so the Processing tab is shown + self.mark_as_freshly_processed(new_obj) + + # Add the new object to the same group as the source object + group_id = self.panel.objmodel.get_object_group_id(obj) + self.panel.add_object(new_obj, group_id=group_id, set_current=True) + + # Record a brand-new history entry with the new object UUID + if hpanel is not None: + # Retrieve plugin_origin so replay without the plugin + # produces a rich error message (C3) instead of generic. + plugin_origin = None + try: + processor = self.__get_processor_associated_to(new_obj) + feature = processor.get_feature(proc_params.func_name) + plugin_origin = feature.plugin_origin + except (ValueError, AttributeError): + pass + hpanel.add_compute_entry_from_pp( + new_obj.title, + new_pp, + panel_str=self.panel.PANEL_STR_ID, + output_uuids=[get_uuid(new_obj)], + plugin_origin=plugin_origin, + ) + + # Auto-recompute analysis on the new object + obj_processor = self.__get_processor_associated_to(new_obj) + obj_processor.auto_recompute_analysis(new_obj) if isinstance(obj, SignalObj): report.message = _("Signal was reprocessed.") @@ -932,6 +1057,7 @@ class AbstractPanel(QW.QSplitter, metaclass=AbstractPanelMeta): H5_PREFIX = "" SIG_OBJECT_ADDED = QC.Signal() SIG_OBJECT_REMOVED = QC.Signal() + SIG_OBJECT_MODIFIED = QC.Signal() @abc.abstractmethod def __init__(self, parent): @@ -1117,7 +1243,7 @@ def on_button_click( """, ] ) - NonModalInfoDialog(parent, "Pattern help", text).show() + NonModalInfoDialog(parent, _("Pattern help"), text).show() def get_extension_choices(self, _item=None, _value=None): """Return list of available extensions for choice item.""" @@ -1258,7 +1384,7 @@ def on_help_button_click( """, ] ) - NonModalInfoDialog(parent, "Pattern help", text).show() + NonModalInfoDialog(parent, _("Pattern help"), text).show() def get_conversion_choices(self, _item=None, _value=None): """Return list of available conversion choices.""" @@ -1592,6 +1718,7 @@ def set_object(self, obj: TypeObj) -> None: # immediately if the modified object is currently selected. self.objview.item_selection_changed() self.refresh_plot("selected", update_items=True, force=True) + self.SIG_OBJECT_MODIFIED.emit() def remove_all_objects(self) -> None: """Remove all objects""" @@ -1859,6 +1986,14 @@ def add_metadata(self, param: AddMetadataParam | None = None) -> None: # Save settings to config Conf.io.add_metadata_settings.set(param) + self.mainwindow.historypanel.add_ui_entry( + _("Add metadata"), + target=self.PANEL_STR_ID + "panel", + method_name="add_metadata", + save_state=True, + param=param, + ) + # Build values for all selected objects values = param.build_values(sel_objects) @@ -1871,19 +2006,49 @@ def add_metadata(self, param: AddMetadataParam | None = None) -> None: "selected", update_items=True, only_visible=False, only_existing=True ) - def copy_roi(self) -> None: - """Copy regions of interest""" - obj = self.objview.get_sel_objects()[0] - self.__roi_clipboard = obj.roi.copy() + def copy_roi(self, roi_data=None) -> None: + """Copy regions of interest - def paste_roi(self) -> None: - """Paste regions of interest""" + Args: + roi_data: ROI snapshot for replay. When ``None`` (interactive use), + the ROI is read from the currently selected object. + """ + if roi_data is None: + obj = self.objview.get_sel_objects()[0] + roi_data = obj.roi.copy() + self.__roi_clipboard = roi_data.copy() + self.mainwindow.historypanel.add_ui_entry( + _("Copy regions of interest from selected %s") + % (_("signal") if self.PANEL_STR_ID == "signal" else _("image")), + target=self.PANEL_STR_ID + "panel", + method_name="copy_roi", + save_state=True, + roi_data=roi_data, + ) + + def paste_roi(self, roi_data=None) -> None: + """Paste regions of interest + + Args: + roi_data: ROI snapshot for replay. When ``None`` (interactive use), + the clipboard populated by :meth:`copy_roi` is used. + """ + if roi_data is None: + roi_data = self.__roi_clipboard + self.mainwindow.historypanel.add_ui_entry( + _("Paste regions of interest into selected %s") + % (_("signal") if self.PANEL_STR_ID == "signal" else _("image")), + target=self.PANEL_STR_ID + "panel", + method_name="paste_roi", + save_state=True, + roi_data=roi_data, + ) sel_objects = self.objview.get_sel_objects(include_groups=True) for obj in sel_objects: if obj.roi is None: - obj.roi = self.__roi_clipboard.copy() + obj.roi = roi_data.copy() else: - obj.roi = obj.roi.combine_with(self.__roi_clipboard) + obj.roi = obj.roi.combine_with(roi_data) self.selection_changed(update_items=True) self.refresh_plot( "selected", update_items=True, only_visible=False, only_existing=True @@ -1905,11 +2070,15 @@ def remove_object(self, force: bool = False) -> None: ) if answer == QW.QMessageBox.No: return + # IMPORTANT: save_state=True is required so that the selection of objects + # being deleted is captured. On replay, the captured selection (translated + # through uuid_remap) is restored before remove_object runs, ensuring that + # the correct object is removed instead of whatever is currently selected. self.mainwindow.historypanel.add_ui_entry( _("Remove selected objects"), target=self.PANEL_STR_ID + "panel", method_name="remove_object", - save_state=False, + save_state=True, force=force, ) sel_objects = self.objview.get_sel_objects(include_groups=True) @@ -2382,6 +2551,14 @@ def save_to_directory(self, param: SaveToDirectoryParam | None = None) -> None: Conf.main.base_dir.set(param.directory) + self.mainwindow.historypanel.add_ui_entry( + _("Save to directory"), + target=self.PANEL_STR_ID + "panel", + method_name="save_to_directory", + save_state=True, + param=param, + ) + with create_progress_bar(self, _("Saving..."), max_=len(objs)) as progress: for i, (path, obj) in enumerate(param.generate_filepath_obj_pairs(objs)): progress.setValue(i + 1) @@ -2639,6 +2816,7 @@ def properties_changed(self) -> None: # Update the stored original values to reflect the new state # This ensures subsequent changes are compared against the current values self.objprop.update_original_values() + self.SIG_OBJECT_MODIFIED.emit() def recompute_processing(self) -> None: """Recompute/rerun selected objects or group with stored processing parameters. diff --git a/datalab/gui/panel/history.py b/datalab/gui/panel/history.py index ac043afb8..9d0f45715 100644 --- a/datalab/gui/panel/history.py +++ b/datalab/gui/panel/history.py @@ -1,4 +1,4 @@ -# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ .. History panel (see parent package :mod:`datalab.gui.panel`) @@ -11,12 +11,17 @@ import functools import html import inspect +import json +import logging import os +import os.path as osp import warnings from contextlib import contextmanager +from copy import deepcopy from typing import TYPE_CHECKING, Any, Callable, Generator from uuid import uuid4 +import numpy as np import sigima.proc.image import sigima.proc.signal from guidata.configtools import get_icon @@ -25,46 +30,139 @@ from guidata.qthelpers import add_actions, create_action from guidata.widgets.dockable import DockableWidgetMixin from qtpy import QtCore as QC +from qtpy import QtGui as QG from qtpy import QtWidgets as QW +from qtpy.compat import getopenfilename, getsavefilename +from sigima.objects import ImageObj, SignalObj -from datalab.config import _ +from datalab.config import Conf, _ from datalab.gui import ObjItf from datalab.gui.panel.base import AbstractPanel from datalab.objectmodel import get_uuid +from datalab.utils.qthelpers import qt_try_loadsave_file, save_restore_stds if TYPE_CHECKING: + import guidata.dataset as gds + from datalab.gui.main import DLMainWindow from datalab.gui.panel.base import BaseDataPanel - from datalab.gui.processor.base import BaseProcessor + from datalab.gui.processor.base import BaseProcessor, FeatureNotFoundError from datalab.h5.native import NativeH5Reader, NativeH5Writer # Keys used in the kwargs dict to mark DataSet payloads, so that the # serialization layer can round-trip them as JSON strings instead of pickling # arbitrary Python objects. +HISTORY_SCHEMA_VERSION = 1 +# Per-action schema. Bumped to 4 to persist the ``_saved_kwargs`` snapshot +# used by the Edit mode Restore feature: persisting it across save/reload +# lets the user revert edits even after closing and re-opening the session, +# as long as Edit mode has not been definitively committed (toggled off). +# Schema version 3 introduced the ``plugin_origin`` field used to track the +# originating plugin of a compute action (see ``BaseProcessor.add_feature``). +# Sessions/actions with ``schema_version <= 2`` are still supported: the +# loader leaves ``plugin_origin`` as ``None`` and missing-plugin replay +# simply produces a generic ``FeatureNotFoundError`` instead of a +# plugin-aware warning. Schema version 2 introduced the ``output_uuids`` +# field used by the bijective action ↔ output mapping (see +# ``HistoryPanel._output_to_action``). Sessions/actions with +# ``schema_version <= 3`` leave ``_saved_kwargs`` as ``None`` on load +# (no pending edits to restore). +HISTORY_ACTION_SCHEMA_VERSION = 4 _DATASET_MARKER = "__dataset_json__" _DATASET_LIST_MARKER = "__dataset_list_json__" +_ROI_MARKER = "__roi_json__" + +_logger = logging.getLogger(__name__) + + +def _numpy_to_json_safe(obj: Any) -> Any: + """Recursively convert numpy arrays to lists for JSON serialization.""" + if isinstance(obj, np.ndarray): + return obj.tolist() + if isinstance(obj, dict): + return {k: _numpy_to_json_safe(v) for k, v in obj.items()} + if isinstance(obj, list): + return [_numpy_to_json_safe(i) for i in obj] + return obj + + +def _encode_roi(roi: Any) -> str: + """Encode a sigima ROI object to a JSON string via ``to_dict()``.""" + from sigima.objects.base import BaseROI # local to avoid circular import + + if not isinstance(roi, BaseROI): + raise TypeError(f"Expected BaseROI instance, got {type(roi)!r}") + roi_dict = _numpy_to_json_safe(roi.to_dict()) + # Store the concrete class so we can reconstruct on decode. + payload = { + "module": type(roi).__module__, + "class": type(roi).__qualname__, + "data": roi_dict, + } + return json.dumps(payload) + + +def _decode_roi(encoded: str) -> Any: + """Decode a JSON string back to a sigima ROI object. + + Only classes from trusted ``sigima.`` modules that are actual + :class:`sigima.objects.base.BaseROI` subclasses are allowed. + + Raises: + ValueError: If the module is not a trusted sigima module or the + resolved class is not a BaseROI subclass. + """ + import importlib + + from sigima.objects.base import BaseROI # local to avoid circular import + + _TRUSTED_ROI_MODULE_PREFIX = "sigima." + + payload = json.loads(encoded) + module_name = payload["module"] + class_name = payload["class"] + + if not module_name.startswith(_TRUSTED_ROI_MODULE_PREFIX): + raise ValueError( + f"Untrusted ROI module {module_name!r}: " + f"only modules under {_TRUSTED_ROI_MODULE_PREFIX!r} are allowed" + ) + + mod = importlib.import_module(module_name) + cls = getattr(mod, class_name) + + if not (isinstance(cls, type) and issubclass(cls, BaseROI)): + raise ValueError( + f"{module_name}.{class_name} is not a BaseROI subclass" + ) + + return cls.from_dict(payload["data"]) def _encode_kwargs(kwargs: dict[str, Any]) -> dict[str, Any]: - """Encode kwargs for HDF5 storage: replace ``DataSet`` and ``list[DataSet]`` - values with marker dicts holding their JSON representation. + """Encode kwargs for HDF5 storage: replace ``DataSet``, ``list[DataSet]``, + and sigima ROI values with marker dicts holding their JSON representation. All other values must already be HDF5-friendly primitives (str, int, float, bool, list/tuple of the same). Args: - kwargs: Raw kwargs dict (may contain ``DataSet`` instances). + kwargs: Raw kwargs dict (may contain ``DataSet`` or ROI instances). Returns: - A new dict with ``DataSet`` values wrapped in marker dicts. + A new dict with special values wrapped in marker dicts. """ + from sigima.objects.base import BaseROI # local to avoid circular import + encoded: dict[str, Any] = {} for key, value in kwargs.items(): if value is None: continue if isinstance(value, DataSet): encoded[key] = {_DATASET_MARKER: dataset_to_json(value)} + elif isinstance(value, BaseROI): + encoded[key] = {_ROI_MARKER: _encode_roi(value)} elif ( isinstance(value, list) and value @@ -90,6 +188,13 @@ def _decode_kwargs(kwargs: dict[str, Any]) -> dict[str, Any]: _("Failed to deserialize history DataSet kwarg %r.") % key ) decoded[key] = None + elif isinstance(value, dict) and _ROI_MARKER in value: + try: + decoded[key] = _decode_roi(value[_ROI_MARKER]) + except Exception as exc: + raise ValueError( + f"Failed to deserialize history ROI kwarg {key!r}: {exc}" + ) from exc elif isinstance(value, dict) and _DATASET_LIST_MARKER in value: try: decoded[key] = [ @@ -105,6 +210,25 @@ def _decode_kwargs(kwargs: dict[str, Any]) -> dict[str, Any]: return decoded +def _copy_history_value(value: Any) -> Any: + """Return an independent copy of a history-serializable value.""" + from sigima.objects.base import BaseROI # local to avoid circular import + + if callable(value): + raise TypeError("History duplication does not support callable kwargs") + if isinstance(value, DataSet): + return json_to_dataset(dataset_to_json(value)) + if isinstance(value, BaseROI): + return _decode_roi(_encode_roi(value)) + if isinstance(value, dict): + return {key: _copy_history_value(item) for key, item in value.items()} + if isinstance(value, list): + return [_copy_history_value(item) for item in value] + if isinstance(value, tuple): + return tuple(_copy_history_value(item) for item in value) + return deepcopy(value) + + def get_datetime_str() -> str: """Return current date and time as a string""" return QC.QDateTime.currentDateTime().toString("yyyy-MM-dd hh:mm:ss") @@ -182,6 +306,9 @@ class HistoryAction(ObjItf): KIND_UI = "ui" FUNC_EDIT_MODE = "edit" # Name of the function parameter to enable edit mode + # Methods that create new data objects. During non-persistent (output-suppressed) + # replay, these UI actions are skipped so the panel object count stays stable. + UI_CREATION_METHODS: frozenset[str] = frozenset({"new_object"}) def __init__( self, @@ -197,6 +324,7 @@ def __init__( # --- common -------------------------------------------------------- kwargs: dict[str, Any] | None = None, state: WorkspaceState | None = None, + plugin_origin: dict[str, Any] | None = None, ) -> None: super().__init__() self.__title = title or "" @@ -215,10 +343,131 @@ def __init__( self.state = WorkspaceState() if state is None else state self.dtstr: str = get_datetime_str() self.uuid: str = str(uuid4()) + self.schema_version: int = HISTORY_ACTION_SCHEMA_VERSION + # UUIDs of the data objects produced by this action (bijective mapping + # maintained by :class:`HistoryPanel`). Populated post-compute via + # :meth:`HistoryPanel.register_action_outputs`. Empty for ``1_to_0`` + # patterns, for UI actions, and for legacy (schema_version=1) sessions + # loaded from disk (the heuristic fallback then takes over). + self.output_uuids: list[str] = [] + # Plugin origin descriptor for compute actions (None for built-in + # Sigima/DataLab features). Populated at registration time by + # :meth:`BaseProcessor.add_feature` and propagated through + # ``add_compute_entry_from_pp``. See + # :func:`datalab.gui.processor.base._detect_plugin_origin` for shape. + # Persisted as a JSON string in HDF5 (schema_version >= 3). + self.plugin_origin: dict[str, Any] | None = plugin_origin + # Transient flag (NOT serialized): set during a cascade recompute to + # display a "stale" visual marker in the tree. Cleared once the + # action has been recomputed. + self.is_stale: bool = False + # Snapshot of original kwargs before edit-mode modification. + # Set lazily when the first edit-mode change touches this action. + # Persisted to HDF5 (schema_version >= 4) so that the Restore + # action still works after a save/reload cycle while Edit mode is + # active. Cleared by ``discard_snapshot`` (definitive commit when + # toggling Edit mode off) or ``restore_kwargs`` (Restore button). + self._saved_kwargs: dict[str, Any] | None = None + + def snapshot_kwargs(self) -> None: + """Save a copy of the current kwargs as the pre-edit baseline. + + No-op if a snapshot already exists (preserves the original baseline + across multiple edit-mode replays). + """ + if self._saved_kwargs is None: + self._saved_kwargs = { + key: _copy_history_value(value) + for key, value in self.kwargs.items() + } + + def restore_kwargs(self) -> None: + """Restore kwargs from the saved snapshot and clear the snapshot.""" + if self._saved_kwargs is not None: + self.kwargs = self._saved_kwargs + self._saved_kwargs = None + + def discard_snapshot(self) -> None: + """Discard the saved snapshot (accept current kwargs as definitive).""" + self._saved_kwargs = None + + @property + def has_pending_edits(self) -> bool: + """Return True if this action has unsaved edit-mode changes.""" + return self._saved_kwargs is not None def regenerate_uuid(self): """Regenerate UUID after loading from a file (no-op: per-action UUID).""" + def copy(self, title_suffix: str | None = None) -> HistoryAction: + """Return an independent copy of this history action.""" + state = self.state.copy() + title = self.title + if title_suffix: + title = f"{title} {title_suffix}" + new_action = HistoryAction( + title=title, + kind=self.kind, + panel_str=self.panel_str, + func_name=self.func_name, + pattern=self.pattern, + target=self.target, + method_name=self.method_name, + kwargs={ + key: _copy_history_value(value) + for key, value in self.kwargs.items() + }, + state=state, + ) + new_action.output_uuids = list(self.output_uuids) + # Note: _saved_kwargs is intentionally NOT propagated to the copy. + # Copying an action acts as an implicit commit (no pending edits). + return new_action + + def copy_with_uuid_remap( + self, uuid_remap: dict[str, dict[str, str]] + ) -> HistoryAction: + """Return a copy of this action with all captured UUIDs rewritten. + + Args: + uuid_remap: Per-panel mapping ``{panel_str: {old_uuid: new_uuid}}`` + used to translate captured UUIDs to the cloned objects created by + the Duplicate operation. + + Returns: + A new independent :class:`HistoryAction` with remapped UUIDs. + """ + new_action = self.copy() + # Rewrite state.selection + for pstr, uuids in new_action.state.selection.items(): + pmap = uuid_remap.get(pstr, {}) + new_action.state.selection[pstr] = [pmap.get(u, u) for u in uuids] + # Rewrite state.object_metadata keys + for pstr, metadata in new_action.state.object_metadata.items(): + pmap = uuid_remap.get(pstr, {}) + new_action.state.object_metadata[pstr] = { + pmap.get(uuid, uuid): val for uuid, val in metadata.items() + } + # Rewrite obj2_uuids in kwargs + obj2 = new_action.kwargs.get("obj2_uuids") + if obj2: + if isinstance(obj2, str): + obj2 = [obj2] + pstr = new_action.panel_str or "" + pmap = uuid_remap.get(pstr, {}) + rewritten = [pmap.get(u, u) for u in obj2] + new_action.kwargs["obj2_uuids"] = ( + rewritten[0] if len(rewritten) == 1 else rewritten + ) + # Rewrite output_uuids — they reference the target panel. + if new_action.output_uuids: + pstr = new_action.panel_str or "" + pmap = uuid_remap.get(pstr, {}) + new_action.output_uuids = [ + pmap.get(u, u) for u in new_action.output_uuids + ] + return new_action + @property def title(self) -> str: """Return object title""" @@ -263,17 +512,39 @@ def __fallback_doc(self) -> str: def description_summary(self) -> str: """Return a short, single-line summary of the description (collapsed view). - For DataSet parameters, uses the dataset titles; otherwise falls back to - the first non-empty line of the full description. + For DataSet parameters, uses the dataset title followed by a compact + representation of its public fields ("name=value, ..."). Falls back to + the first non-empty line of the full description when no DataSet is + present. """ - titles: list[str] = [] + summaries: list[str] = [] for param in self.__iter_param_kwargs(): if isinstance(param, DataSet): - title = param.get_title() - if title: - titles.append(title) - if titles: - return ", ".join(titles) + title = param.get_title() or "" + # Collect "name=value" for each non-private item of the DataSet. + pairs: list[str] = [] + for item in param.get_items(): + name = item.get_name() + if name.startswith("_"): + continue + try: + value = item.get_value(param) + except Exception: # pylint: disable=broad-except + continue + # Format floats compactly, leave other reprs as-is + if isinstance(value, float): + value_str = f"{value:g}" + else: + value_str = str(value) + pairs.append(f"{name}={value_str}") + if pairs: + summaries.append( + f"{title}: {', '.join(pairs)}" if title else ", ".join(pairs) + ) + elif title: + summaries.append(title) + if summaries: + return " | ".join(summaries) for line in self.description.splitlines(): stripped = line.strip() if stripped: @@ -283,6 +554,7 @@ def description_summary(self) -> str: @property def description_html(self) -> str: """Return rich-text (HTML) description used for the expanded view.""" + # Normal path parts: list[str] = [] no_parameters = True for param in self.__iter_param_kwargs(): @@ -300,6 +572,110 @@ def description_html(self) -> str: return html.escape(text).replace("\n", "
") return "" + def to_macro_code( + self, + step_index: int, + input_var: str, + imports: set[str], + obj2_var: str | None = None, + ) -> tuple[list[str], str | None]: + """Return Python source lines for this action as a standalone sigima call. + + Args: + step_index: Step number for variable naming. + input_var: Name of the input variable from the previous step. + imports: Mutable set of import statements accumulated by the caller. + obj2_var: Resolved variable name for the second operand (2-to-1 + pattern). When ``None``, the second operand is left as a + placeholder. + + Returns: + Tuple of (code_lines, output_var_name). ``output_var_name`` is + ``None`` for UI-kind actions (no data output). + """ + if self.kind != self.KIND_COMPUTE: + return [f"# (UI) {self.title} [skipped]"], None + + lines: list[str] = [] + output_var = f"result_{step_index}" + + # Determine the sigima module alias + if self.panel_str == "signal": + mod_alias = "sips" + imports.add("import sigima.proc.signal as sips") + elif self.panel_str == "image": + mod_alias = "sipi" + imports.add("import sigima.proc.image as sipi") + else: + lines.append( + f"# {self.title} [unknown panel: {self.panel_str}]" + ) + return lines, None + + lines.append(f"# Step {step_index}: {self.title}") + + param = self.kwargs.get("param") + param_var: str | None = None + + if param is not None and isinstance(param, DataSet): + param_var = f"param_{step_index}" + param_class = type(param).__qualname__ + param_module = type(param).__module__ + imports.add(f"from {param_module} import {param_class}") + lines.append(f"{param_var} = {param_class}()") + # Reconstruct each attribute + for item in param._items: # noqa: SLF001 + attr_name = item._name # noqa: SLF001 + value = getattr(param, attr_name, None) + if value is not None: + lines.append( + f"{param_var}.{attr_name} = {value!r}" + ) + + # Build the function call + func_call = f"{mod_alias}.{self.func_name}" + if self.pattern in ("1_to_1", "1_to_0"): + if param_var: + lines.append( + f"{output_var} = {func_call}" + f"({input_var}, {param_var})" + ) + else: + lines.append( + f"{output_var} = {func_call}({input_var})" + ) + elif self.pattern == "n_to_1": + if param_var: + lines.append( + f"{output_var} = {func_call}" + f"([{input_var}], {param_var})" + ) + else: + lines.append( + f"{output_var} = {func_call}([{input_var}])" + ) + elif self.pattern == "2_to_1": + second = obj2_var or "... # TODO: provide second operand" + if param_var: + lines.append( + f"{output_var} = {func_call}" + f"({input_var}, {second}, {param_var})" + ) + else: + lines.append( + f"{output_var} = {func_call}" + f"({input_var}, {second})" + ) + elif self.pattern == "1_to_n": + lines.append( + f"{output_var} = {func_call}({input_var})" + ) + else: + lines.append(f"# Unknown pattern {self.pattern!r}") + return lines, None + + return lines, output_var + # ------------------------------------------------------------------ # Workspace-state delegation # ------------------------------------------------------------------ @@ -378,6 +754,28 @@ def replay( """ if uuid_remap is None: uuid_remap = {} + # Suppress history capture during replay to avoid recording + # synthetic entries when the processor re-executes features. + # The context manager is reentrant, so nesting with + # HistoryPanel.replay_restore_actions() is safe. + hpanel = getattr(mainwindow, "historypanel", None) + if hpanel is not None: + ctx = hpanel.replaying() + else: + from contextlib import nullcontext + + ctx = nullcontext() + with ctx: + self._replay_inner(mainwindow, restore_selection, edit, uuid_remap) + + def _replay_inner( + self, + mainwindow: DLMainWindow, + restore_selection: bool, + edit: bool, + uuid_remap: dict[str, dict[str, str]], + ) -> None: + """Inner replay logic, always called under the replaying guard.""" if self.kind == self.KIND_COMPUTE: # Compute actions are selection-driven: restore the captured # selection (translated through ``uuid_remap`` for session @@ -409,6 +807,12 @@ def _translate_state(self, uuid_remap: dict[str, dict[str, str]]) -> WorkspaceSt translated.selection[panel_str] = [panel_map.get(u, u) for u in uuids] translated.states = dict(self.state.states) translated.titles = dict(self.state.titles) + for panel_str, metadata in self.state.object_metadata.items(): + panel_map = uuid_remap.get(panel_str, {}) + translated.object_metadata[panel_str] = { + panel_map.get(uuid, uuid): dict(signature) + for uuid, signature in metadata.items() + } return translated def _replay_compute( @@ -426,19 +830,22 @@ def _replay_compute( processor = panel.processor feature = processor.get_feature(self.func_name) run_kwargs: dict[str, Any] = {self.FUNC_EDIT_MODE: edit} + param = self.kwargs.get("param") if self.pattern in {"1_to_1", "1_to_0", "n_to_1"}: if param is not None: run_kwargs["param"] = param + if self.pattern == "n_to_1" and "pairwise" in self.kwargs: + run_kwargs["pairwise"] = self.kwargs["pairwise"] elif self.pattern == "2_to_1": uuids = self.kwargs.get("obj2_uuids") or [] if isinstance(uuids, str): uuids = [uuids] # Translate captured UUIDs through ``uuid_remap`` (session replay). - # ``uuid_remap`` keys are ``panel.PANEL_STR`` (matches - # ``WorkspaceState.selection`` keys), not the - # ``HistoryAction.panel_str`` (PANEL_STR_ID). - panel_map = (uuid_remap or {}).get(panel.PANEL_STR, {}) + # ``uuid_remap`` keys are ``panel.PANEL_STR_ID`` (matches + # ``WorkspaceState.selection`` keys and + # ``HistoryAction.panel_str``). + panel_map = (uuid_remap or {}).get(panel.PANEL_STR_ID, {}) uuids = [panel_map.get(u, u) for u in uuids] objs2 = [ obj @@ -452,6 +859,8 @@ def _replay_compute( run_kwargs["obj2"] = objs2[0] if len(objs2) == 1 else objs2 if param is not None: run_kwargs["param"] = param + if "pairwise" in self.kwargs: + run_kwargs["pairwise"] = self.kwargs["pairwise"] elif self.pattern == "1_to_n": params = self.kwargs.get("params") or [] run_kwargs["params"] = params @@ -461,7 +870,41 @@ def _replay_compute( def _replay_ui(self, mainwindow: DLMainWindow, edit: bool) -> None: """Replay a UI-kind action by calling ``target.method_name(**kwargs)``.""" + hpanel = mainwindow.historypanel + if ( + hpanel is not None + and hpanel.is_output_suppressed() + and self.method_name in self.UI_CREATION_METHODS + ): + return # Skip creation UI during non-persistent replay target = self._resolve_target(mainwindow) + # Safety guard for destructive UI actions: if the action would delete + # objects but the captured selection no longer resolves to existing + # UUIDs in the target panel, skip the call rather than delete whatever + # is currently selected (which would silently destroy unrelated data). + DESTRUCTIVE_METHODS = {"remove_object", "remove_group", "delete_all_objects"} + if self.method_name in DESTRUCTIVE_METHODS: + if target is None: + _logger.warning( + "Skipping destructive replay '%s': target '%s' not found", + self.method_name, self.target, + ) + return + panel_str = getattr(target, "PANEL_STR_ID", None) + if panel_str and self.state and self.state.selection.get(panel_str): + existing_uuids = { + get_uuid(o) + for o in getattr(target, "objmodel", []) + if o is not None + } + captured = set(self.state.selection.get(panel_str, [])) + if not (captured & existing_uuids): + _logger.warning( + "Skipping destructive replay '%s': none of the captured " + "UUIDs %s exist in panel '%s' anymore", + self.method_name, list(captured), panel_str, + ) + return method = getattr(target, self.method_name) call_kwargs = dict(self.kwargs) # Inject edit mode if the method supports it @@ -479,6 +922,8 @@ def _replay_ui(self, mainwindow: DLMainWindow, edit: bool) -> None: def serialize(self, writer: NativeH5Writer) -> None: """Serialize this action.""" + with writer.group("schema_version"): + writer.write(self.schema_version) with writer.group("kind"): writer.write(self.kind) with writer.group("title"): @@ -502,6 +947,29 @@ def serialize(self, writer: NativeH5Writer) -> None: if encoded: with writer.group("kwargs"): writer.write_dict(encoded) + # ``saved_kwargs`` (schema_version >= 4): persisted Edit mode + # snapshot so the Restore button keeps working after save/reload. + # Skipped (group omitted) when no pending edits exist, keeping the + # on-disk layout byte-identical to schema v3 in the common case. + if self._saved_kwargs is not None: + encoded_saved = _encode_kwargs(self._saved_kwargs) + # Write the group unconditionally (even when empty) so that the + # round-trip preserves the distinction between None (no pending + # edits) and {} (degenerate empty snapshot, keeps has_pending_edits). + with writer.group("saved_kwargs"): + writer.write_dict(encoded_saved) + # Only emit ``output_uuids`` when non-empty: the schema_version field + # already distinguishes v2 (with bijective mapping) from legacy v1. + # Empty lists are skipped to avoid h5py edge cases with empty arrays. + if self.output_uuids: + with writer.group("output_uuids"): + writer.write(list(self.output_uuids)) + # ``plugin_origin`` (schema_version >= 3): stored as a JSON string so + # the HDF5 schema stays trivially round-trippable. Skipped when None + # to keep built-in-only sessions byte-identical to schema v2. + if self.plugin_origin is not None: + with writer.group("plugin_origin"): + writer.write(json.dumps(self.plugin_origin)) with writer.group("state"): self.state.serialize(writer) with writer.group("dtstr"): @@ -509,6 +977,9 @@ def serialize(self, writer: NativeH5Writer) -> None: def deserialize(self, reader: NativeH5Reader) -> None: """Deserialize this action.""" + self.schema_version = reader.read( + "schema_version", default=HISTORY_SCHEMA_VERSION + ) with reader.group("kind"): self.kind = reader.read_any() with reader.group("title"): @@ -531,6 +1002,47 @@ def deserialize(self, reader: NativeH5Reader) -> None: self.kwargs = _decode_kwargs(raw) else: self.kwargs = {} + # ``saved_kwargs`` was introduced in schema_version=4. Legacy + # actions (v1, v2, v3) leave it as ``None`` — no Edit mode + # snapshot to restore. + if "saved_kwargs" in current.attrs or "saved_kwargs" in current: + with reader.group("saved_kwargs"): + raw_saved = reader.read_dict() + self._saved_kwargs = _decode_kwargs(raw_saved) + else: + self._saved_kwargs = None + # ``output_uuids`` was introduced in schema_version=2. Legacy actions + # leave it empty; consumers fall back to the heuristic matcher. + if "output_uuids" in current.attrs or "output_uuids" in current: + with reader.group("output_uuids"): + raw_outputs = reader.read_any() + if raw_outputs is None: + self.output_uuids = [] + else: + self.output_uuids = [str(u) for u in raw_outputs] + else: + self.output_uuids = [] + # ``plugin_origin`` was introduced in schema_version=3. Legacy actions + # (v1, v2) leave it as ``None``: a subsequent replay of a missing + # plugin function will then surface a generic ``FeatureNotFoundError`` + # instead of the richer plugin-aware warning. + if "plugin_origin" in current.attrs or "plugin_origin" in current: + with reader.group("plugin_origin"): + raw_origin = reader.read_any() + if raw_origin in (None, ""): + self.plugin_origin = None + else: + try: + self.plugin_origin = json.loads(raw_origin) + except (TypeError, ValueError): + _logger.warning( + "Failed to decode plugin_origin for action %s; " + "falling back to None.", + self.uuid, + ) + self.plugin_origin = None + else: + self.plugin_origin = None with reader.group("state"): self.state.deserialize(reader) with reader.group("dtstr"): @@ -560,6 +1072,19 @@ def __init__(self) -> None: # value is the list of titles of the objects in the panel. The title is only # informative and is not used to determine if two objects have the same state. self.titles: dict[str, list[str]] = {} + # Structured data signatures of selected objects, keyed by panel name and UUID. + # This is the current schema used for compatibility checks. Missing metadata + # means a pre-Gate-2 history and falls back to UUID-existence validation. + self.object_metadata: dict[str, dict[str, dict[str, Any]]] = {} + + def copy(self) -> WorkspaceState: + """Return an independent copy of this workspace state.""" + state = WorkspaceState() + state.selection = deepcopy(self.selection) + state.states = deepcopy(self.states) + state.titles = deepcopy(self.titles) + state.object_metadata = deepcopy(self.object_metadata) + return state def serialize(self, writer: NativeH5Writer) -> None: """Serialize this workspace state @@ -573,6 +1098,8 @@ def serialize(self, writer: NativeH5Writer) -> None: writer.write_dict(self.states) with writer.group("titles"): writer.write_dict(self.titles) + with writer.group("object_metadata"): + writer.write_dict(self.object_metadata) def deserialize(self, reader: NativeH5Reader) -> None: """Deserialize this workspace state @@ -586,6 +1113,19 @@ def deserialize(self, reader: NativeH5Reader) -> None: self.states = reader.read_dict() with reader.group("titles"): self.titles = reader.read_dict() + current = reader.h5 + for option in reader.option: + current = current[option] + if "object_metadata" in current.attrs or "object_metadata" in current: + with reader.group("object_metadata"): + self.object_metadata = reader.read_dict() + else: + self.object_metadata = {} + # Normalize legacy translated keys to stable panel identifiers. + self.selection = self._normalize_panel_keys(self.selection) + self.states = self._normalize_panel_keys(self.states) + self.titles = self._normalize_panel_keys(self.titles) + self.object_metadata = self._normalize_panel_keys(self.object_metadata) def get_current_selection(self, mainwindow: DLMainWindow) -> dict[str, list[str]]: """Get the current selection in the workspace, keyed by panel name and @@ -599,12 +1139,62 @@ def get_current_selection(self, mainwindow: DLMainWindow) -> dict[str, list[str] """ selection: dict[str, list[str]] = {} for panel in (mainwindow.signalpanel, mainwindow.imagepanel): - selection[panel.PANEL_STR] = [ + selection[panel.PANEL_STR_ID] = [ get_uuid(obj) for obj in panel.objview.get_sel_objects(include_groups=True) ] return selection + @staticmethod + def get_object_metadata(obj: Any) -> dict[str, Any]: + """Return a stable data signature for an object.""" + data = getattr(obj, "data", None) + shape = getattr(data, "shape", None) + if shape is None: + return {} + shape = [int(size) for size in shape] + ndim = getattr(data, "ndim", len(shape)) + return {"shape": shape, "ndim": int(ndim)} + + @staticmethod + def _normalize_object_metadata(metadata: dict[str, Any]) -> dict[str, Any]: + """Normalize object metadata loaded from HDF5 for comparison.""" + shape = metadata.get("shape") + if shape is None: + return {} + shape = [int(size) for size in shape] + ndim = metadata.get("ndim", len(shape)) + return {"shape": shape, "ndim": int(ndim)} + + # Mapping from legacy translated panel keys to stable identifiers. + # Covers the English translations; other locales are handled by the + # catch-all ``"signal"``/``"image"`` substring heuristic below. + _LEGACY_PANEL_KEY_MAP: dict[str, str] = { + "Signal Panel": "signal", + "Image Panel": "image", + } + + @classmethod + def _normalize_panel_key(cls, key: str) -> str: + """Map a potentially translated panel key to its stable identifier.""" + if key in ("signal", "image"): + return key + mapped = cls._LEGACY_PANEL_KEY_MAP.get(key) + if mapped is not None: + return mapped + # Heuristic for non-English translations: look for the stable ID + # substring in the key (e.g. "Panneau signal" → "signal"). + lowered = key.lower() + for stable_id in ("signal", "image"): + if stable_id in lowered: + return stable_id + return key + + @classmethod + def _normalize_panel_keys(cls, d: dict) -> dict: + """Return *d* with all top-level keys normalized to stable panel IDs.""" + return {cls._normalize_panel_key(k): v for k, v in d.items()} + def save(self, mainwindow: DLMainWindow) -> None: """Save the current workspace state @@ -612,16 +1202,28 @@ def save(self, mainwindow: DLMainWindow) -> None: mainwindow: DataLab's main window """ self.selection = self.get_current_selection(mainwindow) + self.object_metadata = {} for panel in (mainwindow.signalpanel, mainwindow.imagepanel): - sel_uuids = self.selection[panel.PANEL_STR] - self.states[panel.PANEL_STR] = [ + sel_uuids = self.selection[panel.PANEL_STR_ID] + self.states[panel.PANEL_STR_ID] = [ str(obj.data.shape) for obj in panel.objmodel if get_uuid(obj) in sel_uuids ] - self.titles[panel.PANEL_STR] = [ + self.titles[panel.PANEL_STR_ID] = [ obj.title for obj in panel.objmodel if get_uuid(obj) in sel_uuids ] + # Store metadata for ALL panel objects (not just selected) so that + # the dict key order captures the full panel ordering. During + # session replay the key order lets us sort old UUIDs by their + # original panel position, which prevents non-commutative 2_to_1 + # operand swaps in the positional-fallback code path. + # ``is_current_state_compatible`` only checks *selected* UUIDs, so + # the extra entries are harmless for compatibility validation. + self.object_metadata[panel.PANEL_STR_ID] = { + get_uuid(obj): self.get_object_metadata(obj) + for obj in panel.objmodel + } def is_current_state_compatible( # pylint: disable=unused-argument self, mainwindow: DLMainWindow, restore_selection: bool @@ -629,9 +1231,10 @@ def is_current_state_compatible( # pylint: disable=unused-argument """Check if the current workspace state is compatible with the saved state. Compatibility means that **every** UUID recorded in the saved selection - still exists in the corresponding panel. The data shape is no longer - used to discriminate (it is informative only): a missing UUID is the - only failure mode. + still exists in the corresponding panel. When structured object metadata + is available (current schema), each selected object's data shape and + dimensions must also match the saved signature. Histories without this + metadata fall back to legacy UUID-existence validation. Args: mainwindow: DataLab's main window @@ -640,16 +1243,24 @@ def is_current_state_compatible( # pylint: disable=unused-argument selection -- it only depends on object existence. Returns: - True if every saved UUID still exists in its panel, False otherwise. + True if every saved UUID still exists in its panel and saved + metadata, when available, still matches. """ if not self.selection: return True for panel in (mainwindow.signalpanel, mainwindow.imagepanel): - saved_uuids = self.selection.get(panel.PANEL_STR, []) + saved_uuids = self.selection.get(panel.PANEL_STR_ID, []) existing_uuids = set(panel.objmodel.get_object_ids()) + saved_metadata = self.object_metadata.get(panel.PANEL_STR_ID, {}) for uuid in saved_uuids: if uuid not in existing_uuids: return False + if uuid in saved_metadata: + current = self.get_object_metadata(panel.objmodel[uuid]) + current = self._normalize_object_metadata(current) + saved = self._normalize_object_metadata(saved_metadata[uuid]) + if saved and current != saved: + return False return True def restore(self, mainwindow: DLMainWindow) -> None: @@ -669,7 +1280,7 @@ def restore(self, mainwindow: DLMainWindow) -> None: "Current workspace state is not compatible with saved state" ) for panel in (mainwindow.signalpanel, mainwindow.imagepanel): - uuids = self.selection.get(panel.PANEL_STR, []) + uuids = self.selection.get(panel.PANEL_STR_ID, []) if uuids: panel.objview.select_objects(uuids) @@ -693,6 +1304,7 @@ def __init__(self, title: str = "", number: int = 0) -> None: self.number = number self.dtstr: str = get_datetime_str() self.actions: list[HistoryAction] = [] + self.schema_version: int = HISTORY_SCHEMA_VERSION def add_action(self, action: HistoryAction) -> None: """Add an action to the history session @@ -702,6 +1314,37 @@ def add_action(self, action: HistoryAction) -> None: """ self.actions.append(action) + def copy( + self, title: str | None = None, action_title_suffix: str | None = None + ) -> HistorySession: + """Return an independent copy of this history session.""" + session = HistorySession(title=title or self.title, number=self.number) + session.actions = [ + action.copy(title_suffix=action_title_suffix) for action in self.actions + ] + return session + + def copy_with_uuid_remap( + self, title: str, uuid_remap: dict[str, dict[str, str]] + ) -> HistorySession: + """Return a copy of this session with all UUIDs rewritten via ``uuid_remap``. + + Used by the Duplicate operation to build an independent session whose + captured object references point to the cloned data objects. + + Args: + title: Title for the new session. + uuid_remap: Per-panel mapping ``{panel_str: {old_uuid: new_uuid}}``. + + Returns: + A new :class:`HistorySession` with all captured UUIDs remapped. + """ + session = HistorySession(title=title, number=self.number) + session.actions = [ + action.copy_with_uuid_remap(uuid_remap) for action in self.actions + ] + return session + def is_current_state_compatible( self, mainwindow: DLMainWindow, restore_selection: bool ) -> bool: @@ -745,76 +1388,193 @@ def replay( # captured selection (and ``obj2_uuids``) into the freshly-created # UUIDs of the current replay, so chained ``n_to_1`` / ``2_to_1`` / # ``1_to_n`` actions operate on the correct inputs. Keys are - # ``panel.PANEL_STR`` (matches ``WorkspaceState.selection`` keys). + # ``panel.PANEL_STR_ID`` (matches ``WorkspaceState.selection`` keys). panels = (mainwindow.signalpanel, mainwindow.imagepanel) - # Map ``HistoryAction.panel_str`` (PANEL_STR_ID, e.g. ``"signal"``) - # to the corresponding ``panel.PANEL_STR`` used in remap keys. - id_to_pstr = { - mainwindow.signalpanel.PANEL_STR_ID: mainwindow.signalpanel.PANEL_STR, - mainwindow.imagepanel.PANEL_STR_ID: mainwindow.imagepanel.PANEL_STR, - } - uuid_remap: dict[str, dict[str, str]] = {p.PANEL_STR: {} for p in panels} + uuid_remap: dict[str, dict[str, str]] = {p.PANEL_STR_ID: {} for p in panels} # FIFO of newly-created UUIDs not yet claimed by a remap entry -- # required because most creation UI actions (e.g. ``new_signal``) # are recorded with ``save_state=False`` (empty captured selection), # so we cannot pair captured-vs-new UUIDs by position at UI time. # Subsequent compute actions claim from this queue on demand. - unclaimed: dict[str, list[str]] = {p.PANEL_STR: [] for p in panels} + unclaimed: dict[str, list[str]] = {p.PANEL_STR_ID: [] for p in panels} + def _claim_unmapped( + pstr: str, + old_uuids: list[str], + action: HistoryAction, + ) -> None: + """Claim unclaimed new UUIDs for *old_uuids* not yet in uuid_remap. + + Uses title matching (scanning the full unclaimed queue) followed by + panel-order index alignment to deterministically pair old UUIDs + to the correct new UUIDs, regardless of creation order. + """ + # Collect unmapped UUIDs (deduplicated, preserving first-seen order). + all_unmapped: list[str] = [] + seen: set[str] = set() + for u in old_uuids: + if u not in seen and u not in uuid_remap.get(pstr, {}): + all_unmapped.append(u) + seen.add(u) + if not all_unmapped: + return + # Re-sort by recorded panel position when available. + panel_order = list( + action.state.object_metadata.get(pstr, {}).keys() + ) + if panel_order and all(u in panel_order for u in all_unmapped): + all_unmapped.sort(key=panel_order.index) + queue = unclaimed.get(pstr) or [] + if not queue: + return + # Build old UUID → title from captured state and object_metadata. + sel_uuids = action.state.selection.get(pstr, []) + sel_titles = action.state.titles.get(pstr, []) + old_titles: dict[str, str] = {} + for _u, _t in zip(sel_uuids, sel_titles): + if _u in seen: + old_titles[_u] = _t + obj_meta = action.state.object_metadata.get(pstr, {}) + for _u in all_unmapped: + if _u not in old_titles and _u in obj_meta: + meta = obj_meta[_u] + if isinstance(meta, dict) and "title" in meta: + old_titles[_u] = meta["title"] + # Build new UUID → title from the live panel (full queue). + new_titles: dict[str, str] = {} + panel_obj = None + for p in panels: + if p.PANEL_STR_ID == pstr: + panel_obj = p + break + if panel_obj is not None: + for nu in queue: + try: + new_titles[nu] = panel_obj.objmodel[nu].title + except KeyError: + pass + # Phase 1: title matching against the FULL queue. + assigned_old: set[str] = set() + assigned_new: set[str] = set() + for ou in all_unmapped: + if ou not in old_titles: + continue + title = old_titles[ou] + candidates = [ + nu + for nu in queue + if nu not in assigned_new + and new_titles.get(nu) == title + ] + if len(candidates) == 1: + uuid_remap.setdefault(pstr, {})[ou] = candidates[0] + assigned_old.add(ou) + assigned_new.add(candidates[0]) + # Phase 2: positional fallback using panel-order alignment. + # Two modes depending on whether the remaining queue covers all + # free recorded panel slots: + # + # A) Absolute index alignment (len(rem_queue) == len(free_indices)): + # Each free panel_order index maps 1-to-1 to a queue slot. + # This ensures e.g. the second-created object maps to the + # second queue entry even when only a subset of old UUIDs + # needs claiming. + # + # B) Relative order fallback (queue is a strict subset): + # The queue only contains later compute-created objects while + # earlier full-panel entries are absent. Absolute alignment + # would leave non-first old UUIDs unmapped. Instead, zip + # rem_old (already sorted by panel order) with rem_queue + # sequentially. + rem_old = [u for u in all_unmapped if u not in assigned_old] + if rem_old and panel_order: + rem_queue = [u for u in queue if u not in assigned_new] + # Find which panel_order indices are "free" (unclaimed). + free_indices: list[int] = [] + for idx, po_uuid in enumerate(panel_order): + if po_uuid not in uuid_remap.get(pstr, {}): + if po_uuid not in assigned_old: + free_indices.append(idx) + if len(rem_queue) == len(free_indices): + # Mode A: absolute index alignment. + idx_to_new: dict[int, str] = {} + for qi, fi in enumerate(free_indices): + if qi < len(rem_queue): + idx_to_new[fi] = rem_queue[qi] + for ou in rem_old: + if ou in panel_order: + idx = panel_order.index(ou) + if idx in idx_to_new: + nu = idx_to_new[idx] + uuid_remap.setdefault(pstr, {})[ou] = nu + assigned_new.add(nu) + else: + # Mode B: relative order fallback. + for ou, nu in zip(rem_old, rem_queue): + uuid_remap.setdefault(pstr, {})[ou] = nu + assigned_new.add(nu) + elif rem_old: + # No panel_order available: sequential fallback. + rem_queue = [u for u in queue if u not in assigned_new] + for ou, nu in zip(rem_old, rem_queue): + uuid_remap.setdefault(pstr, {})[ou] = nu + assigned_new.add(nu) + # Remove all assigned new UUIDs from the unclaimed queue. + if assigned_new: + unclaimed[pstr] = [u for u in queue if u not in assigned_new] + for action in self.actions[:]: - before = {p.PANEL_STR: set(p.objmodel.get_object_ids()) for p in panels} + before = {p.PANEL_STR_ID: set(p.objmodel.get_object_ids()) for p in panels} if action.kind == HistoryAction.KIND_COMPUTE: # Lazy-resolve any captured UUIDs missing from the remap by - # claiming from ``unclaimed`` (FIFO, panel-local). - pstr = id_to_pstr.get(action.panel_str or "", "") + # claiming from ``unclaimed`` (deterministic: title + panel-order). + pstr = action.panel_str or "" captured = action.state.selection.get(pstr, []) - for old_uuid in captured: - if old_uuid in uuid_remap.get(pstr, {}): - continue - queue = unclaimed.get(pstr) or [] - if queue: - uuid_remap.setdefault(pstr, {})[old_uuid] = queue.pop(0) - # 2_to_1: also claim ``obj2_uuids`` from the queue if unknown. if action.pattern == "2_to_1": + # For 2_to_1: collect ALL unmapped old UUIDs from both + # captured selection and obj2_uuids in one batch so + # operand order is preserved by the helper. obj2 = action.kwargs.get("obj2_uuids") or [] if isinstance(obj2, str): obj2 = [obj2] - for old_uuid in obj2: - if old_uuid in uuid_remap.get(pstr, {}): - continue - queue = unclaimed.get(pstr) or [] - if queue: - uuid_remap.setdefault(pstr, {})[old_uuid] = queue.pop(0) + _claim_unmapped(pstr, list(obj2) + list(captured), action) + else: + # For all other compute patterns (1_to_1, n_to_1, etc.): + # use the same deterministic helper. + _claim_unmapped(pstr, list(captured), action) action.replay( mainwindow, restore_selection=restore_selection, edit=edit, uuid_remap=uuid_remap, ) - if action.kind == HistoryAction.KIND_UI: - for panel in panels: - pstr = panel.PANEL_STR - current_ids = set(panel.objmodel.get_object_ids()) - new_uuids = [ - u - for u in panel.objmodel.get_object_ids() - if u not in before[pstr] + # Post-action bookkeeping: track new/removed UUIDs for *every* + # action kind so that later actions consuming compute-created + # outputs can resolve them through ``uuid_remap`` / ``unclaimed``. + for panel in panels: + pstr = panel.PANEL_STR_ID + current_ids = set(panel.objmodel.get_object_ids()) + new_uuids = [ + u + for u in panel.objmodel.get_object_ids() + if u not in before[pstr] + ] + # Drop vanished UUIDs from the unclaimed queue and the + # reverse remap entries (e.g. ``Remove selected objects``): + # this keeps the FIFO claim in sync with the live panel + # contents during chained creation/removal replays. + removed_uuids = before[pstr] - current_ids + if removed_uuids: + unclaimed[pstr] = [ + u for u in unclaimed.get(pstr, []) if u not in removed_uuids ] - # Drop vanished UUIDs from the unclaimed queue and the - # reverse remap entries (e.g. ``Remove selected objects``): - # this keeps the FIFO claim in sync with the live panel - # contents during chained creation/removal replays. - removed_uuids = before[pstr] - current_ids - if removed_uuids: - unclaimed[pstr] = [ - u for u in unclaimed.get(pstr, []) if u not in removed_uuids - ] - panel_map = uuid_remap.get(pstr, {}) - for old_key in [ - k for k, v in panel_map.items() if v in removed_uuids - ]: - panel_map.pop(old_key, None) - if not new_uuids: - continue + panel_map = uuid_remap.get(pstr, {}) + for old_key in [ + k for k, v in panel_map.items() if v in removed_uuids + ]: + panel_map.pop(old_key, None) + if not new_uuids: + continue + if action.kind == HistoryAction.KIND_UI: captured = action.state.selection.get(pstr, []) if captured: # Captured post-action selection available: pair @@ -829,6 +1589,35 @@ def replay( # No captured selection (typical of ``new_signal``): # queue all new UUIDs for lazy claiming. unclaimed.setdefault(pstr, []).extend(new_uuids) + else: + # Compute actions: queue all newly-created UUIDs so + # later actions can lazily claim them. Do NOT map + # captured input UUIDs to output UUIDs — compute + # inputs and outputs are semantically different. + unclaimed.setdefault(pstr, []).extend(new_uuids) + + # Visually close the replay: select the output of the last compute + # action so the user sees the final result highlighted in the panel. + # Without this, the very last action's output is never selected + # (intermediate actions are implicitly "closed" by the next + # iteration's input restore). + if self.actions: + last = self.actions[-1] + if last.kind == HistoryAction.KIND_COMPUTE: + hpanel = getattr(mainwindow, "historypanel", None) + if hpanel is not None: + output_uuid = hpanel._action_output_uuid(last) + if output_uuid: + panel_str = last.panel_str or "" + panel_map = uuid_remap.get(panel_str, {}) + mapped_uuid = panel_map.get(output_uuid, output_uuid) + for panel in panels: + if panel.PANEL_STR_ID == panel_str: + try: + panel.objview.select_objects([mapped_uuid]) + except KeyError: + pass + break def serialize(self, writer: NativeH5Writer) -> None: """Serialize this history session @@ -836,6 +1625,8 @@ def serialize(self, writer: NativeH5Writer) -> None: Args: writer: Writer """ + with writer.group("schema_version"): + writer.write(self.schema_version) with writer.group("title"): writer.write(self.title) with writer.group("number"): @@ -850,6 +1641,9 @@ def deserialize(self, reader: NativeH5Reader) -> None: Args: reader: Reader """ + self.schema_version = reader.read( + "schema_version", default=HISTORY_SCHEMA_VERSION + ) with reader.group("title"): self.title = reader.read_any() with reader.group("number"): @@ -954,6 +1748,7 @@ class HistoryTree(QW.QTreeWidget): """Tree widget for the history panel""" DESCRIPTION_COLUMN = 2 + COMPATIBILITY_ROLE = QC.Qt.UserRole + 1 def __init__(self, parent: QW.QWidget) -> None: """Create a new history tree widget""" @@ -998,8 +1793,8 @@ def __install_description_widget( item.setText(self.DESCRIPTION_COLUMN, "") self.setItemWidget(item, self.DESCRIPTION_COLUMN, widget) - @staticmethod - def action_to_tree_item(action: HistoryAction) -> QW.QTreeWidgetItem: + @classmethod + def action_to_tree_item(cls, action: HistoryAction) -> QW.QTreeWidgetItem: """Convert an action to a tree item Args: @@ -1012,8 +1807,40 @@ def action_to_tree_item(action: HistoryAction) -> QW.QTreeWidgetItem: # installed by ``HistoryTree`` once the item is inserted in the tree. item = QW.QTreeWidgetItem([action.title, action.dtstr, ""]) item.setData(0, QC.Qt.UserRole, action.uuid) + item.setData(0, cls.COMPATIBILITY_ROLE, True) return item + def update_compatibility_states( + self, history_sessions: list[HistorySession], mainwindow: DLMainWindow + ) -> None: + """Update action item visual state from workspace compatibility.""" + default_brush = QG.QBrush() + disabled_brush = QG.QBrush( + self.palette().color(QG.QPalette.Disabled, QG.QPalette.Text) + ) + compatible_tip = _("Action is compatible with the current workspace state.") + incompatible_tip = _( + "Action is not compatible with the current workspace state." + ) + for i in range(self.topLevelItemCount()): + session_item = self.topLevelItem(i) + for j in range(session_item.childCount()): + child = session_item.child(j) + uuid = child.data(0, QC.Qt.UserRole) + action = self.get_action_from_uuid(uuid, history_sessions) + compatible = action.is_current_state_compatible( + mainwindow, restore_selection=True + ) + child.setData(0, self.COMPATIBILITY_ROLE, compatible) + brush = default_brush if compatible else disabled_brush + icon = get_icon("apply.svg") if compatible else get_icon("delete.svg") + child.setIcon(0, icon) + for col in range(self.columnCount()): + child.setForeground(col, brush) + child.setToolTip( + col, compatible_tip if compatible else incompatible_tip + ) + def __forget_orphan_expanded_states( self, history_sessions: list[HistorySession] ) -> None: @@ -1037,6 +1864,7 @@ def populate_tree(self, history_sessions: list[HistorySession]) -> None: self.clear() for session in history_sessions: ritem = QW.QTreeWidgetItem([session.title, session.dtstr]) + ritem.setData(0, self.COMPATIBILITY_ROLE, True) self.addTopLevelItem(ritem) for action in session.actions: child = self.action_to_tree_item(action) @@ -1063,6 +1891,33 @@ def add_action_to_tree(self, action: HistoryAction) -> None: ritem.addChild(item) self.__install_description_widget(item, action) + def refresh_action_item(self, action: HistoryAction) -> None: + """Refresh the tree item corresponding to ``action``. + + Re-installs the description widget so it reflects the current + ``action.kwargs`` (e.g. after the user edited a ``param`` via the + Processing tab of the Signal/Image panel). Also applies a light + orange background when ``action.is_stale`` is True, to signal that + the action is currently being recomputed in a cascade. + """ + target_uuid = action.uuid + stale_brush = QG.QBrush(QG.QColor(255, 220, 150)) # light orange + normal_brush = QG.QBrush() + iterator = QW.QTreeWidgetItemIterator(self) + while iterator.value(): + item = iterator.value() + if item.data(0, QC.Qt.UserRole) == target_uuid: + # Remove and re-install the collapsible description widget so + # it reflects the mutated ``action.kwargs``. + self.removeItemWidget(item, self.DESCRIPTION_COLUMN) + self.__install_description_widget(item, action) + brush = stale_brush if action.is_stale else normal_brush + for col in range(self.columnCount()): + item.setBackground(col, brush) + self.scheduleDelayedItemsLayout() + return + iterator += 1 + def get_action_from_uuid( self, uuid: str, history_sessions: list[HistorySession] ) -> HistoryAction: @@ -1121,6 +1976,73 @@ def get_selected_actions( return selected +class WorkspaceStateWidget(QW.QWidget): + """Side-by-side tables showing the workspace state captured by a history action. + + Left table: signals (title + data shape). + Right table: images (title + data shape/dimensions). + """ + + def __init__(self, parent: QW.QWidget | None = None) -> None: + super().__init__(parent) + self._signal_table = QW.QTableWidget(0, 2, self) + self._signal_table.setHorizontalHeaderLabels([_("Signal"), _("Shape")]) + self._signal_table.horizontalHeader().setStretchLastSection(True) + self._signal_table.setEditTriggers(QW.QAbstractItemView.NoEditTriggers) + self._signal_table.setSelectionMode(QW.QAbstractItemView.NoSelection) + self._signal_table.verticalHeader().hide() + + self._image_table = QW.QTableWidget(0, 2, self) + self._image_table.setHorizontalHeaderLabels([_("Image"), _("Dimensions")]) + self._image_table.horizontalHeader().setStretchLastSection(True) + self._image_table.setEditTriggers(QW.QAbstractItemView.NoEditTriggers) + self._image_table.setSelectionMode(QW.QAbstractItemView.NoSelection) + self._image_table.verticalHeader().hide() + + splitter = QW.QSplitter(QC.Qt.Horizontal, self) + splitter.addWidget(self._signal_table) + splitter.addWidget(self._image_table) + layout = QW.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(splitter) + + def update_from_state(self, state: WorkspaceState | None) -> None: + """Populate tables from a WorkspaceState.""" + self._signal_table.setRowCount(0) + self._image_table.setRowCount(0) + if state is None: + return + self._populate_table(self._signal_table, state, "signal") + self._populate_table(self._image_table, state, "image") + + @staticmethod + def _populate_table( + table: QW.QTableWidget, state: WorkspaceState, panel_key: str + ) -> None: + """Fill a table from the state for a given panel key.""" + titles = state.titles.get(panel_key, []) + shapes = state.states.get(panel_key, []) + metadata = state.object_metadata.get(panel_key, {}) + uuids = state.selection.get(panel_key, []) + # Use metadata keyed by UUID when available + rows: list[tuple[str, str]] = [] + for i, uuid in enumerate(uuids): + title = titles[i] if i < len(titles) else uuid[:8] + meta = metadata.get(uuid, {}) + shape = meta.get("shape") + if shape is not None: + shape_str = " × ".join(str(s) for s in shape) + elif i < len(shapes): + shape_str = shapes[i] + else: + shape_str = "—" + rows.append((title, shape_str)) + table.setRowCount(len(rows)) + for row_idx, (title, shape_str) in enumerate(rows): + table.setItem(row_idx, 0, QW.QTableWidgetItem(title)) + table.setItem(row_idx, 1, QW.QTableWidgetItem(shape_str)) + + class HistoryPanel(AbstractPanel, DockableWidgetMixin): """History panel""" @@ -1146,13 +2068,28 @@ def __init__(self, parent: DLMainWindow) -> None: # history with synthetic entries triggered by their own internal # `compute_*` calls. Use `replaying()` as a context manager. self.__replaying = False + self.__output_suppressed = False + # Guard flag used by `__sync_panel_selection` to prevent re-entry + # while the data panels are being updated programmatically. + self.__syncing = False + self._cascade_in_progress = False self.__delete_action: QW.QAction | None = None + self.__duplicate_action: QW.QAction | None = None + self.__step_prev_action: QW.QAction | None = None + self.__step_next_action: QW.QAction | None = None + self.__restore_selection_action: QW.QAction | None = None + self.__edit_action: QW.QAction | None = None self.__menu_actions: list[QW.QAction] = self.__create_menu_actions() self.mainwindow = parent self.tree = HistoryTree(self) self.tree.customContextMenuRequested.connect(self.show_context_menu) self.tree.itemDoubleClicked.connect(self.replay_restore_actions) + self.tree.itemSelectionChanged.connect(self.__sync_panel_selection) + self.tree.itemSelectionChanged.connect(self.__update_actions_state) + self.tree.itemSelectionChanged.connect(self.__update_state_widget) + + self.__state_widget = WorkspaceStateWidget(self) toolbar = QW.QToolBar(self) add_actions(toolbar, self.__menu_actions) @@ -1160,6 +2097,7 @@ def __init__(self, parent: DLMainWindow) -> None: layout = QW.QVBoxLayout() layout.addWidget(toolbar) layout.addWidget(self.tree) + layout.addWidget(self.__state_widget) layout.setContentsMargins(0, 0, 0, 0) widget.setLayout(layout) @@ -1167,12 +2105,92 @@ def __init__(self, parent: DLMainWindow) -> None: self.__history_sessions: list[HistorySession] = [] self.__session_increment = 0 + # Bijective action ↔ output mapping (C1). Both dicts are kept in sync + # by :meth:`register_action_outputs` and pruned by + # :meth:`_prune_output_mapping` when objects are removed from a panel. + # Reconstructed at HDF5 load time from each action's ``output_uuids``. + self._action_output_uuids: dict[str, list[str]] = {} + self._output_to_action: dict[str, str] = {} + # Warnings collected during ``recompute_cascade`` (and the helpers + # it dispatches to). Aggregated into a single user-facing dialog + # at the end of the cascade so deleted output objects / missing + # sources / unsupported patterns do not spam the user. + self._cascade_warnings: list[str] = [] + # Actions detected as broken (missing plugin) during a cascade run. + # Transient — intentionally not persisted: recalculated from scratch + # on each cascade run, similar to ``is_stale``. + # The visual stale marker is *not* cleared for these so the user + # sees them flagged in the tree until the plugin is reinstalled and + # the cascade is re-run successfully. + self._broken_actions: set[str] = set() + # Re-entrancy guard for :meth:`_reconnect_chain_after_removal` (see + # method docstring): ``recompute_cascade`` pumps the event loop and + # could deliver a queued ``SIG_OBJECT_REMOVED`` mid-reconnection. + self.__reconnecting = False + for panel in (self.mainwindow.signalpanel, self.mainwindow.imagepanel): + panel.SIG_OBJECT_ADDED.connect(self.refresh_compatibility_items) + panel.SIG_OBJECT_ADDED.connect(self.__refresh_obj_ids_snapshot) + panel.SIG_OBJECT_REMOVED.connect(self.refresh_compatibility_items) + panel.SIG_OBJECT_REMOVED.connect( + functools.partial(self._reconnect_chain_after_removal, panel) + ) + panel.SIG_OBJECT_REMOVED.connect(self._prune_output_mapping) + panel.SIG_OBJECT_MODIFIED.connect(self.refresh_compatibility_items) + self.__refresh_obj_ids_snapshot() self.__update_actions_state() + self.refresh_compatibility_items() + + def __refresh_obj_ids_snapshot(self) -> None: + """Cache the current object ids of both data panels. + + ``SIG_OBJECT_REMOVED`` carries no payload, so the set of just-deleted + objects is recovered by diffing this snapshot against the live model + inside :meth:`_reconnect_chain_after_removal`. + """ + self.__obj_ids_snapshot = { + self.mainwindow.signalpanel.PANEL_STR_ID: set( + self.mainwindow.signalpanel.objmodel.get_object_ids() + ), + self.mainwindow.imagepanel.PANEL_STR_ID: set( + self.mainwindow.imagepanel.objmodel.get_object_ids() + ), + } def __update_actions_state(self) -> None: """Update the enabled state of menu actions depending on history content.""" - if self.__delete_action is not None: - self.__delete_action.setEnabled(len(self) > 0) + has_history = len(self) > 0 + for action in ( + self.__delete_action, + self.__duplicate_action, + ): + if action is not None: + action.setEnabled(has_history) + if self.__step_prev_action is not None: + self.__step_prev_action.setEnabled(self.__can_step_prev()) + if self.__step_next_action is not None: + self.__step_next_action.setEnabled(self.__can_step_next()) + if self.__restore_selection_action is not None: + self.__restore_selection_action.setEnabled( + self.__edit_mode or self._has_any_pending_edits() + ) + + def _has_any_pending_edits(self) -> bool: + """Return True if any action across all sessions has a pending + Edit mode snapshot (i.e. uncommitted edits that Restore can revert). + """ + return any( + action.has_pending_edits + for session in self.__history_sessions + for action in session.actions + ) + + def __update_state_widget(self) -> None: + """Update the workspace state widget from the currently selected action.""" + action = self.__current_action() + if action is not None: + self.__state_widget.update_from_state(action.state) + else: + self.__state_widget.update_from_state(None) def __create_menu_actions(self) -> list[QW.QAction]: """Create menu actions for the history panel @@ -1187,6 +2205,7 @@ def __create_menu_actions(self) -> list[QW.QAction]: icon=get_icon("edit_mode.svg"), ) edit_action.setChecked(self.__edit_mode) + self.__edit_action = edit_action record_action = create_action( self, _("Record mode"), @@ -1194,14 +2213,88 @@ def __create_menu_actions(self) -> list[QW.QAction]: icon=get_icon("record.svg"), ) record_action.setChecked(self.__record_mode) + new_session_action = create_action( + self, + _("New session"), + self.create_new_session, + icon=get_icon("libre-gui-add.svg"), + tip=_("Start a new history session"), + ) + open_action = create_action( + self, + _("Open history file..."), + triggered=lambda checked=False: self.open_dlhist_file(), + icon=get_icon("fileopen_h5.svg"), + tip=_("Open history from a standalone .dlhist file"), + ) + save_action = create_action( + self, + _("Save history file..."), + triggered=lambda checked=False: self.save_to_dlhist_file(), + icon=get_icon("filesave_h5.svg"), + tip=_("Save history to a standalone .dlhist file"), + ) self.__delete_action = create_action( self, _("Delete"), - self.delete_actions, + self.delete_selected, icon=get_icon("delete.svg"), ) + self.__duplicate_action = create_action( + self, + _("Duplicate"), + self.duplicate_selected_entries, + icon=get_icon("duplicate.svg"), + tip=_("Duplicate selected history action/session"), + ) + self.__step_prev_action = create_action( + self, + _("Previous step"), + triggered=self._step_prev, + icon=get_icon("libre-gui-arrow-left.svg"), + tip=_("Select the previous action in the current session"), + shortcut=QG.QKeySequence("Ctrl+Left"), + ) + self.__step_next_action = create_action( + self, + _("Next step"), + triggered=self._step_next, + icon=get_icon("libre-gui-arrow-right.svg"), + tip=_("Select the next action in the current session"), + shortcut=QG.QKeySequence("Ctrl+Right"), + ) + generate_macro_action = create_action( + self, + _("Generate macro"), + self.generate_macro, + icon=get_icon("console.svg"), + tip=_("Generate a Python macro script from history"), + ) + remove_incompatible_action = create_action( + self, + _("Remove incompatible"), + self.remove_incompatible_actions, + icon=get_icon("edit/delete_all.svg"), + tip=_("Remove actions incompatible with the current workspace"), + ) + self.__restore_selection_action = create_action( + self, + _("Restore parameters"), + lambda: self.replay_restore_actions( + restore_selection=True, replay=False + ), + icon=get_icon("restore_selection.svg"), + tip=_("Restore original parameters (discard edit-mode changes)"), + ) return [ record_action, + new_session_action, + None, + open_action, + save_action, + None, + self.__step_prev_action, + self.__step_next_action, None, create_action( self, @@ -1209,32 +2302,57 @@ def __create_menu_actions(self) -> list[QW.QAction]: lambda: self.replay_restore_actions(restore_selection=False), icon=get_icon("replay.svg"), ), - create_action( - self, - _("Restore selection"), - lambda: self.replay_restore_actions( - restore_selection=True, replay=False - ), - icon=get_icon("restore_selection.svg"), - ), - create_action( - self, - _("Restore selection and replay"), - self.replay_restore_actions, - icon=get_icon("restore_and_replay.svg"), - ), + self.__restore_selection_action, edit_action, None, + self.__duplicate_action, + generate_macro_action, + None, + remove_incompatible_action, self.__delete_action, ] def toggle_edit_mode(self, checked: bool) -> None: - """Toggle edit mode + """Toggle edit mode. + + Toggling Edit mode off is a **definitive commit**: all parameter + changes performed during the session become permanent and Restore + is no longer available for them. When pending edits exist, the + user is asked to confirm; refusing leaves Edit mode enabled. Args: - checked: True if the edit mode is checked, False otherwise + checked: True if the edit mode is checked, False otherwise. """ + if not checked and self._has_any_pending_edits(): + reply = QW.QMessageBox.question( + self.mainwindow, + _("Commit edit mode changes?"), + _( + "You are about to exit Edit mode.\n\n" + "All parameter changes made during this session will be " + "permanently kept.\n" + "This action cannot be undone — Restore will no longer " + "be available.\n\n" + "Do you want to continue?" + ), + QW.QMessageBox.Yes | QW.QMessageBox.No, + QW.QMessageBox.No, + ) + if reply != QW.QMessageBox.Yes: + # Re-check the action without triggering toggle_edit_mode + # again (blockSignals prevents recursion). + if self.__edit_action is not None: + self.__edit_action.blockSignals(True) + self.__edit_action.setChecked(True) + self.__edit_action.blockSignals(False) + return self.__edit_mode = checked + if not checked: + # Exiting edit mode: accept all pending edits (discard snapshots) + for session in self.__history_sessions: + for action in session.actions: + action.discard_snapshot() + self.__update_actions_state() def toggle_record_mode(self, checked: bool) -> None: """Toggle record mode @@ -1244,6 +2362,10 @@ def toggle_record_mode(self, checked: bool) -> None: """ self.__record_mode = checked + def is_edit_mode(self) -> bool: + """Return True when the History panel is in edit (parameter testing) mode.""" + return self.__edit_mode + @contextmanager def replaying(self) -> Generator[None, None, None]: """Context manager suppressing history capture during its scope. @@ -1264,12 +2386,33 @@ def is_replaying(self) -> bool: """Return True when an external replay/recompute is in progress.""" return self.__replaying + @contextmanager + def output_suppressed(self) -> Generator[None, None, None]: + """Context manager suppressing compute outputs during its scope. + + When active, :meth:`BaseProcessor._add_object_to_appropriate_panel` + and :meth:`BaseProcessor._create_group_for_result` become no-ops so + that History Panel replay can execute computations without altering + Signal/Image panel object counts. Reentrant-safe. + """ + previous = self.__output_suppressed + self.__output_suppressed = True + try: + yield + finally: + self.__output_suppressed = previous + + def is_output_suppressed(self) -> bool: + """Return True when compute outputs must not be added to panels.""" + return self.__output_suppressed + def show_context_menu(self, pos: QC.QPoint) -> None: """Show the context menu Args: pos: Position of the context menu """ + self.refresh_compatibility_items() menu = QW.QMenu() add_actions(menu, self.__menu_actions) menu.exec_(self.tree.mapToGlobal(pos)) @@ -1292,10 +2435,29 @@ def get_action_from_uuid(self, uuid: str) -> HistoryAction: def replay_restore_actions( self, replay: bool = True, restore_selection: bool = True ) -> None: - """Replay and/or restore selection for the selected actions""" - for session_or_action in self.tree.get_selected_actions_or_sessions( - self.__history_sessions - ): + """Replay and/or restore selection for the selected actions. + + When nothing is selected in the tree, replays the entire last session. + Replay is non-persistent: compute actions run but their outputs are + NOT added to Signal/Image panels. UI-creation actions are always + skipped during replay because source objects already exist. + """ + self.refresh_compatibility_items() + selected = self.tree.get_selected_actions_or_sessions(self.__history_sessions) + if not selected: + if not self.__history_sessions: + return + # Nothing selected → replay the last session + selected = [self.__history_sessions[-1]] + for session_or_action in selected: + # B4: if a stale action is Played, recompute its cascade in-place + # instead of running the standard non-persistent replay. + if ( + isinstance(session_or_action, HistoryAction) + and session_or_action.is_stale + ): + self.recompute_cascade(session_or_action) + continue if not session_or_action.is_current_state_compatible( self.mainwindow, restore_selection=restore_selection ): @@ -1306,59 +2468,1938 @@ def replay_restore_actions( ) return if replay: - with self.replaying(): - session_or_action.replay( - self.mainwindow, - restore_selection=restore_selection, - edit=self.__edit_mode, + if self.__edit_mode and isinstance( + session_or_action, HistoryAction + ): + self._edit_mode_replay(session_or_action) + elif self.__edit_mode and isinstance( + session_or_action, HistorySession + ): + self._view_only_session_replay( + session_or_action, restore_selection ) + else: + with self.replaying(), self.output_suppressed(): + session_or_action.replay( + self.mainwindow, + restore_selection=restore_selection, + edit=self.__edit_mode, + ) elif restore_selection: - session_or_action.restore(self.mainwindow) + # Restore button (replay=False, restore_selection=True): + # if Edit mode is active OR there are persisted pending + # edits (e.g. after a save/reload), revert the parameter + # snapshots in place. Otherwise behave as a workspace + # selection restore. + if self.__edit_mode or self._has_any_pending_edits(): + self._restore_action_params(session_or_action) + else: + session_or_action.restore(self.mainwindow) + + def _prompt_edit_action_params(self, action: HistoryAction) -> bool | None: + """Open the parameter dialog for *action* according to its pattern. - def delete_actions(self) -> None: - """Delete the selected actions""" - # Ask for confirmation as this will delete the action and all subsequent actions - reply = QW.QMessageBox.question( - self.mainwindow, - _("Delete actions"), - _( - "Do you really want to delete the selected action " - "and all the next ones?" - ), - QW.QMessageBox.Yes | QW.QMessageBox.No, - QW.QMessageBox.No, - ) - if reply == QW.QMessageBox.Yes: - for action in self.tree.get_selected_actions(self.__history_sessions): - for session in self.__history_sessions: - if action in session.actions: - session.remove_action(action) - self.tree.populate_tree(self.__history_sessions) - self.__update_actions_state() + Returns: + ``True`` – user accepted; ``action.kwargs`` mutated, snapshot taken. + ``False`` – user cancelled the dialog. + ``None`` – nothing to edit (no param/params, or unsupported pattern). + """ + import copy # pylint: disable=import-outside-toplevel + + pattern = action.pattern + if pattern in {"1_to_1", "1_to_0", "n_to_1", "2_to_1"}: + param = action.kwargs.get("param") + if param is None: + return None + edited = copy.deepcopy(param) + if not edited.edit(parent=self.mainwindow): + return False + action.snapshot_kwargs() + action.kwargs["param"] = edited + return True + if pattern == "1_to_n": + params = action.kwargs.get("params") or [] + if not params: + return None + edited_params = [copy.deepcopy(p) for p in params] + # Local import: ``gds`` is not used elsewhere in this module. + import guidata.dataset as gds # pylint: disable=import-outside-toplevel + + group = gds.DataSetGroup(edited_params, title=_("Parameters")) + if not group.edit(parent=self.mainwindow): + return False + action.snapshot_kwargs() + action.kwargs["params"] = edited_params + return True + # multiple_1_to_1 or any unknown pattern: nothing to edit. + return None - def serialize_to_hdf5(self, writer: NativeH5Writer) -> None: - """Serialize whole panel to a HDF5 file + def _edit_mode_replay(self, action: HistoryAction) -> None: + """Replay a single action in edit mode: open param dialog, update + kwargs on accept, recompute in-place and cascade downstream. - Args: - writer: HDF5 writer + Supports every compute pattern (1_to_1, 1_to_n, n_to_1, 2_to_1, + 1_to_0). ``multiple_1_to_1`` is currently not supported: the dialog + is skipped for that action but the rest of the chain is still + processed. + + UI actions and pattern-less entries fall back to normal replay. + + The parameter dialog is opened for the root action AND for every + downstream action in the cascade, in topological order. If the + user cancels ANY dialog, all snapshots already created in this + pass are rolled back and nothing is recomputed. """ - writer.write_object_list(self.__history_sessions, self.H5_PREFIX) + if action.kind != HistoryAction.KIND_COMPUTE or action.pattern is None: + with self.replaying(), self.output_suppressed(): + action.replay(self.mainwindow, restore_selection=True, edit=True) + return - def deserialize_from_hdf5( - self, reader: NativeH5Reader, reset_all: bool = False + chain: list[HistoryAction] = [action] + self.get_downstream_actions( + action + ) + edited_actions: list[HistoryAction] = [] + for a in chain: + result = self._prompt_edit_action_params(a) + if result is False: + # User cancelled – rollback every snapshot taken so far. + for done in edited_actions: + done.restore_kwargs() + self.tree.refresh_action_item(done) + return + if result is True: + edited_actions.append(a) + + for a in edited_actions: + self.tree.refresh_action_item(a) + + # Recompute root in-place, then cascade with the pre-computed + # descendants list. Re-using the chain avoids a second call to + # ``get_downstream_actions`` whose result could diverge after the + # root output's metadata has been rewritten by the root recompute. + downstream = chain[1:] + self._recompute_action_in_place(action) + self.recompute_cascade(action, descendants=downstream) + + # Belt-and-suspenders refresh: ensure the tree description widgets + # and the Signal/Image panels reflect the final state for every + # action in the chain (root included). + for a in chain: + self.tree.refresh_action_item(a) + QW.QApplication.processEvents() + + def _show_readonly_param_dialog( + self, dataset: gds.DataSet | gds.DataSetGroup ) -> None: - """Deserialize whole panel from a HDF5 file + """Show a parameter dialog identical to the edit dialog but read-only. + + Builds the same guidata dialog as edit mode (``DataSetEditDialog`` for a + single dataset, ``DataSetGroupEditDialog`` for a group) so the appearance + and title match exactly, then disables every input field (and its label) + so the parameters are displayed but cannot be modified. The OK button is + kept so the dialog can be dismissed. + + Args: + dataset: The dataset (or dataset group) whose parameters are shown. + """ + # Local import: not used elsewhere in this module. + import guidata.dataset as gds # pylint: disable=import-outside-toplevel + from guidata.dataset.qtwidgets import ( # pylint: disable=import-outside-toplevel + DataSetEditDialog, + DataSetGroupEditDialog, + ) + + # A DataSetGroup (1_to_n) needs the tabbed group dialog; a single + # DataSet uses the standard edit dialog. Both expose ``edit_layout``. + if isinstance(dataset, gds.DataSetGroup): + dialog = DataSetGroupEditDialog(dataset, parent=self.mainwindow) + else: + dialog = DataSetEditDialog(dataset, parent=self.mainwindow) + for edl in dialog.edit_layout: + for widget in edl.widgets: + if widget.group is not None: + widget.group.setEnabled(False) + if widget.label is not None: + widget.label.setEnabled(False) + dialog.exec() + + def _view_only_session_replay( + self, + session: HistorySession, + restore_selection: bool, + ) -> None: + """Replay a session in edit mode with read-only parameter dialogs. + + When edit mode is active and a full session is replayed, editable + dialogs would be misleading because modifications cannot be propagated + through the cascade. Instead, show each compute action's parameters in + a dialog that looks identical to the edit dialog but whose fields are + disabled, and keep the History tree and the data panel synchronized with + the action being shown. The session is then replayed with ``edit=False``. + """ + import copy # pylint: disable=import-outside-toplevel + + # Local import: ``gds`` is not used elsewhere in this module. + import guidata.dataset as gds # pylint: disable=import-outside-toplevel + + for action in session.actions: + if action.kind != HistoryAction.KIND_COMPUTE: + continue + pattern = action.pattern + # Sync the History tree and the data panel to this action so the + # user follows the replay step by step (same behaviour as edit mode). + self.__select_action_in_tree(action) + QW.QApplication.processEvents() + if pattern in {"1_to_1", "1_to_0", "n_to_1", "2_to_1"}: + param = action.kwargs.get("param") + if param is not None: + self._show_readonly_param_dialog(copy.deepcopy(param)) + elif pattern == "1_to_n": + params = action.kwargs.get("params") or [] + if params: + group = gds.DataSetGroup( + [copy.deepcopy(p) for p in params], + title=_("Parameters"), + ) + self._show_readonly_param_dialog(group) + + with self.replaying(), self.output_suppressed(): + session.replay( + self.mainwindow, + restore_selection=restore_selection, + edit=False, + ) + + def _restore_action_params( + self, item: HistoryAction | HistorySession + ) -> None: + """Restore original kwargs from snapshot and recompute in-place. + + Used by the Restore action in edit mode to discard parameter + changes and revert to the pre-edit state. + """ + actions: list[HistoryAction] + if isinstance(item, HistorySession): + actions = [ + a for a in item.actions if a.kind == HistoryAction.KIND_COMPUTE + ] + else: + actions = [item] + for action in actions: + if not action.has_pending_edits: + continue + action.restore_kwargs() + self.tree.refresh_action_item(action) + self._recompute_action_in_place(action) + self.recompute_cascade(action) + # Snapshots may have been consumed: refresh button states so the + # Restore action disables itself once no pending edits remain + # (relevant outside Edit mode after a save/reload restore). + self.__update_actions_state() + + def _find_parent_session(self, action: HistoryAction) -> HistorySession | None: + """Return the session that contains ``action``, or None if not found. + + Args: + action: Action to search for. + + Returns: + Parent :class:`HistorySession`, or ``None``. + """ + for session in self.__history_sessions: + if action in session.actions: + return session + return None + + # ------------------------------------------------------------------ + # Sync History tree selection → Signal/Image panel (B1) + # ------------------------------------------------------------------ + + def __resolve_panel_for_action( + self, action: HistoryAction + ) -> BaseDataPanel | None: + """Return the data panel targeted by ``action``, or ``None``.""" + if action.kind != HistoryAction.KIND_COMPUTE: + return None + if action.panel_str == "signal": + return self.mainwindow.signalpanel + if action.panel_str == "image": + return self.mainwindow.imagepanel + return None + + def __find_output_object_uuid( + self, panel: BaseDataPanel, action: HistoryAction + ) -> str | None: + """Find the UUID of the output object produced by ``action`` in ``panel``. + + Primary path: consult the bijective ``_action_output_uuids`` mapping + populated when the action was recorded (or rebuilt from HDF5 at load). + Returns the first registered output that still exists in ``panel``. + + Fallback path (legacy v1 sessions, or actions whose outputs were + re-created without registration): search the panel for an object whose + ``processing_parameters`` metadata has ``source_uuid`` matching one of + the action's recorded selection UUIDs and whose ``func_name`` equals + the action's ``func_name``. + """ + # Primary: bijective mapping (C1). + registered = self._action_output_uuids.get(action.uuid) + if registered: + existing_ids = set(panel.objmodel.get_object_ids()) + for out_uuid in registered: + if out_uuid in existing_ids: + return out_uuid + # Fallback heuristic for legacy sessions. + if action.func_name is None: + return None + from datalab.gui.processor.base import ( # pylint: disable=import-outside-toplevel + extract_processing_parameters, + ) + + recorded_uuids = set(action.state.selection.get(panel.PANEL_STR_ID, [])) + if not recorded_uuids: + return None + for obj in panel.objmodel: + pp = extract_processing_parameters(obj) + if pp is None or pp.func_name != action.func_name: + continue + if pp.source_uuid is not None and pp.source_uuid in recorded_uuids: + return get_uuid(obj) + if pp.source_uuids is not None and recorded_uuids.intersection( + pp.source_uuids + ): + return get_uuid(obj) + return None + + def find_action_for_output( + self, output_uuid: str, func_name: str + ) -> HistoryAction | None: + """Find the :class:`HistoryAction` that produced ``output_uuid``. + + Primary path: consult the bijective ``_output_to_action`` mapping. This + is exact and resolves the ambiguity of repeated applications of the + same ``func_name`` on the same source. + + Fallback path: searches all sessions (most-recent first) using the + heuristic ``(func_name, source_uuid)`` matching for legacy v1 sessions + without a registered output mapping. + + Args: + output_uuid: UUID of the output object (signal or image). + func_name: Processing function name expected on the action. + + Returns: + The matching action, or ``None`` if no match is found. + """ + if not self.__history_sessions: + return None + # Primary: bijective mapping (C1). + action_uuid = self._output_to_action.get(output_uuid) + if action_uuid is not None: + for session in self.__history_sessions: + for action in session.actions: + if action.uuid == action_uuid: + # Sanity check: func_name must still match. + if action.func_name == func_name: + return action + return None + # Fallback heuristic for legacy sessions. + from datalab.gui.processor.base import ( # pylint: disable=import-outside-toplevel + extract_processing_parameters, + ) + + # Find which panel contains output_uuid + panel: BaseDataPanel | None = None + output_obj = None + for p in (self.mainwindow.signalpanel, self.mainwindow.imagepanel): + try: + output_obj = p.objmodel[output_uuid] + panel = p + break + except KeyError: + continue + if panel is None or output_obj is None: + return None + pp = extract_processing_parameters(output_obj) + if pp is None or pp.func_name != func_name: + return None + target_source_uuid = pp.source_uuid + if target_source_uuid is None: + return None + # Search every session (most-recent first) instead of only [-1]. + # Uniqueness is guaranteed by target_source_uuid (remapped per-session + # during duplication). + for current_session in reversed(self.__history_sessions): + for action in reversed(current_session.actions): + if action.kind != HistoryAction.KIND_COMPUTE: + continue + if action.func_name != func_name: + continue + if action.panel_str != panel.PANEL_STR_ID: + continue + captured = action.state.selection.get(panel.PANEL_STR_ID, []) + if captured and captured[0] == target_source_uuid: + return action + return None + + def refresh_action(self, action: HistoryAction) -> None: + """Refresh the tree display for ``action`` after its kwargs were mutated. + + Used by :meth:`ObjectProp.apply_processing_parameters` to update the + Description column when the user edits a ``param`` from the Processing + tab of the Signal/Image panel. + """ + self.tree.refresh_action_item(action) + + # ------------------------------------------------------------------ + # B4: Cascade recompute of downstream actions after a param edit + # ------------------------------------------------------------------ + + def _get_session_of(self, action: HistoryAction) -> HistorySession | None: + """Return the session that contains ``action``, or None.""" + for session in self.__history_sessions: + if action in session.actions: + return session + return None + + def _action_output_uuid(self, action: HistoryAction) -> str | None: + """Return the UUID of the object produced by ``action``, or ``None``. + + Scans the target panel's object model for an object whose + :class:`ProcessingParameters` metadata matches the action's + ``func_name`` and one of its captured source UUIDs. + """ + panel = self.__resolve_panel_for_action(action) + if panel is None: + return None + return self.__find_output_object_uuid(panel, action) + + def _action_consumes_any( + self, action: HistoryAction, uuids: set[str] + ) -> bool: + """Return True if ``action``'s input UUIDs intersect ``uuids``.""" + if action.kind != HistoryAction.KIND_COMPUTE: + return False + pstr = action.panel_str or "" + captured: set[str] = set(action.state.selection.get(pstr, [])) + obj2 = action.kwargs.get("obj2_uuids") + if obj2: + if isinstance(obj2, str): + captured.add(obj2) + else: + captured.update(obj2) + return bool(captured & uuids) + + def _collect_downstream_uuids(self, action: HistoryAction) -> set[str]: + """Return the transitive closure of output UUIDs descending from + ``action`` within the current session (excluding ``action`` itself). + """ + if not self.__history_sessions: + return set() + current = self._get_session_of(action) + if current is None: + return set() + root_out = self._action_output_uuid(action) + if root_out is None: + return set() + closure: set[str] = {root_out} + # Walk only actions positioned strictly after ``action`` in the + # current session, in chronological order. + idx = current.actions.index(action) + for downstream in current.actions[idx + 1 :]: + if downstream.kind != HistoryAction.KIND_COMPUTE: + continue + if not self._action_consumes_any(downstream, closure): + continue + out_uuid = self._action_output_uuid(downstream) + if out_uuid is not None: + closure.add(out_uuid) + closure.discard(root_out) + return closure + + def get_downstream_actions( + self, action: HistoryAction + ) -> list[HistoryAction]: + """Return the actions of the current session that depend (transitively) + on ``action``'s output, in topological order (direct children first). + """ + if not self.__history_sessions: + return [] + current = self._get_session_of(action) + if current is None: + return [] + root_out = self._action_output_uuid(action) + if root_out is None: + return [] + closure: set[str] = {root_out} + downstream: list[HistoryAction] = [] + idx = current.actions.index(action) + for candidate in current.actions[idx + 1 :]: + if candidate.kind != HistoryAction.KIND_COMPUTE: + continue + if not self._action_consumes_any(candidate, closure): + continue + downstream.append(candidate) + out_uuid = self._action_output_uuid(candidate) + if out_uuid is not None: + closure.add(out_uuid) + return downstream + + # ------------------------------------------------------------------ + # In-place recompute dispatcher (C2): one helper per pattern. + # + # All helpers retrieve the existing output object(s) via the bijective + # ``_action_output_uuids`` mapping (C1), recompute via the processor's + # ``recompute_*`` methods (which do not register history nor add to the + # panel), then update the existing object in place (data + title + + # metadata) and refresh the view. Missing output objects (deleted by + # the user) are reported via :attr:`_cascade_warnings`. + # ------------------------------------------------------------------ + + def _resolve_target_outputs( + self, panel: BaseDataPanel, action: HistoryAction + ) -> tuple[list[str], list[str]]: + """Return ``(existing, missing)`` UUIDs registered for ``action``. + + Args: + panel: Data panel owning the action's outputs. + action: History action whose outputs must be resolved. + + Returns: + A pair of UUID lists: those still present in ``panel`` (in + registration order) and those that were deleted. + """ + registered = list(self._action_output_uuids.get(action.uuid, [])) + existing_ids = set(panel.objmodel.get_object_ids()) + existing: list[str] = [u for u in registered if u in existing_ids] + missing: list[str] = [u for u in registered if u not in existing_ids] + return existing, missing + + def _update_obj_in_place( + self, + target_obj: SignalObj | ImageObj, + new_obj: SignalObj | ImageObj, + ) -> None: + """Copy data + title + metadata from ``new_obj`` onto ``target_obj``. + + Preserves the target's identity (UUID, panel position, references) + while reflecting all user-visible changes produced by a recompute. + + Args: + target_obj: Existing object to mutate in place. + new_obj: Fresh object produced by a ``recompute_*`` call. + """ + target_obj.title = new_obj.title + if isinstance(target_obj, SignalObj): + target_obj.xydata = new_obj.xydata + else: + target_obj.data = new_obj.data + target_obj.invalidate_maskdata_cache() + # Replace compute-related metadata with the fresh set. In Edit mode + # the object is updated in place (not recreated), so stale analysis + # result keys (Geometry_*, Table_*) or obsolete metadata options from + # the previous compute would persist with a simple ``update()``. + # We clear and repopulate, but preserve the target's ``__uuid`` which + # is its identity key managed by the object model. + try: + saved_uuid = target_obj.metadata.get("__uuid") + saved_number = target_obj.metadata.get("__number") + target_obj.metadata.clear() + target_obj.metadata.update(new_obj.metadata) + if saved_uuid is not None: + target_obj.metadata["__uuid"] = saved_uuid + if saved_number is not None: + target_obj.metadata["__number"] = saved_number + except AttributeError: + pass + + def _refresh_target( + self, panel: BaseDataPanel, output_uuid: str + ) -> None: + """Refresh tree item + plot for ``output_uuid`` in ``panel``. + + Also updates the Properties panel when the refreshed object is + currently selected, marks the object as freshly processed so the + Processing tab is shown, and emits ``SIG_OBJECT_MODIFIED`` so + that compatibility icons are refreshed. + """ + panel.objview.update_item(output_uuid) + panel.refresh_plot(output_uuid, update_items=True, force=True) + + # Update the Properties panel if the recomputed object is selected + try: + obj = panel.objmodel[output_uuid] + except KeyError: + obj = None + if obj is not None: + if obj is panel.objview.get_current_object(): + panel.objprop.update_properties_from(obj, force_tab="processing") + else: + # Mark as freshly processed so the Processing tab opens on select + panel.objprop.mark_as_freshly_processed(obj) + + # Notify listeners (e.g. compatibility icons in history tree) + panel.SIG_OBJECT_MODIFIED.emit() + + def _record_missing_outputs( + self, action: HistoryAction, missing: list[str] + ) -> None: + """Log + queue a user-facing warning for deleted output objects.""" + if not missing: + return + name = action.func_name or action.title or action.uuid + _logger.warning( + "Cascade recompute: %d output(s) missing for action %s (%s).", + len(missing), + action.uuid, + name, + ) + self._cascade_warnings.append( + _( + "Action %s has been edited but its target output object(s) " + "no longer exist — skipping." + ) + % name + ) + + def _recompute_action_in_place(self, action: HistoryAction) -> None: + """Re-run ``action`` on the existing output object(s) (same UUIDs). + + Dispatches to a per-pattern helper. Missing target outputs are + recorded in :attr:`_cascade_warnings` and silently skipped so the + rest of the cascade can keep running. + + When the underlying processor feature is missing (e.g. the originating + plugin was uninstalled), :class:`FeatureNotFoundError` is caught here + and the action is flagged as broken (``is_stale`` left at ``True`` + beyond the cascade so the visual marker persists). A localised warning + including the plugin origin and required parameter class is appended + to :attr:`_cascade_warnings`. The cascade continues with the remaining + actions. + + Args: + action: History action to recompute in place. + """ + if action.kind != HistoryAction.KIND_COMPUTE: + return + method = { + "1_to_1": self._recompute_1_to_1_in_place, + "1_to_n": self._recompute_1_to_n_in_place, + "n_to_1": self._recompute_n_to_1_in_place, + "2_to_1": self._recompute_2_to_1_in_place, + "1_to_0": self._recompute_1_to_0_in_place, + }.get(action.pattern or "") + if method is None: + _logger.warning( + "Cascade recompute: unsupported pattern %r for action %s.", + action.pattern, + action.uuid, + ) + self._cascade_warnings.append( + _("Action %s uses pattern %r which is not recomputable yet.") + % (action.func_name or action.uuid, action.pattern) + ) + return + from datalab.gui.processor.base import ( # pylint: disable=import-outside-toplevel + FeatureNotFoundError, + ) + + try: + method(action) + except FeatureNotFoundError as exc: + self._handle_missing_feature(action, exc) + except Exception as exc: # pylint: disable=broad-except + _logger.exception( + "Cascade recompute failed for action %s (%s): %s", + action.uuid, + action.func_name, + exc, + ) + self._cascade_warnings.append( + _("Recompute failed for action %s: %s") + % (action.func_name or action.uuid, exc) + ) + + def _handle_missing_feature( + self, action: HistoryAction, exc: "FeatureNotFoundError" + ) -> None: + """Flag ``action`` as broken (missing plugin) and queue a user warning. + + Args: + action: Action whose feature could not be resolved. + exc: The raised :class:`FeatureNotFoundError`. + ``action.plugin_origin`` is the authoritative source. + ``exc.plugin_origin`` is kept only as a safety net for future + paths where ``get_feature`` might be called without forwarding + the action's ``plugin_origin``. + """ + action.is_stale = True + self._broken_actions.add(action.uuid) + plugin_origin = action.plugin_origin or exc.plugin_origin or {} + directory = (plugin_origin.get("directory") if plugin_origin else None) or "?" + param = action.kwargs.get("param") + paramclass = ( + exc.paramclass_name + or (type(param).__name__ if param is not None else "—") + ) + func_name = action.func_name or exc.func_name or action.uuid + # Format validated with the operator: "{directory}/plugins:{func_name}" + location = f"{directory}/plugins:{func_name}" + _logger.warning( + "Cascade recompute: plugin missing for action %s (%s) — %s.", + action.uuid, + func_name, + location, + ) + self._cascade_warnings.append( + _( + "Action %(name)s skipped: plugin '%(loc)s' is missing.\n" + "Required parameter class: %(param)s\n" + "Reinstall the plugin to re-enable this action." + ) + % {"name": func_name, "loc": location, "param": paramclass} + ) + + def _recompute_1_to_1_in_place(self, action: HistoryAction) -> None: + """Recompute a single 1-to-1 action in place.""" + panel = self.__resolve_panel_for_action(action) + if panel is None: + return + from datalab.gui.processor.base import ( # pylint: disable=import-outside-toplevel + ProcessingParameters, + extract_processing_parameters, + insert_processing_parameters, + ) + + existing, missing = self._resolve_target_outputs(panel, action) + # Fall back to legacy heuristic when bijective mapping is unavailable. + if not existing and not missing: + legacy = self.__find_output_object_uuid(panel, action) + if legacy is not None: + existing = [legacy] + self._record_missing_outputs(action, missing) + if not existing: + return + output_uuid = existing[0] + try: + output_obj = panel.objmodel[output_uuid] + except KeyError: + return + pp = extract_processing_parameters(output_obj) + if pp is None or pp.source_uuid is None: + return + try: + source_obj = panel.objmodel[pp.source_uuid] + except KeyError: + self._cascade_warnings.append( + _("Action %s: source object was deleted — skipping.") + % (action.func_name or action.uuid) + ) + return + param = action.kwargs.get("param") + new_obj = panel.processor.recompute_1_to_1( + action.func_name, source_obj, param, + plugin_origin=action.plugin_origin, + ) + if new_obj is None: + return + self._update_obj_in_place(output_obj, new_obj) + insert_processing_parameters( + output_obj, + ProcessingParameters( + func_name=pp.func_name, + pattern=pp.pattern, + param=param if param is not None else pp.param, + source_uuid=pp.source_uuid, + ), + ) + panel.processor.auto_recompute_analysis(output_obj) + self._refresh_target(panel, output_uuid) + + def _recompute_1_to_n_in_place(self, action: HistoryAction) -> None: + """Recompute a 1-to-n action in place: replace each of the N outputs.""" + panel = self.__resolve_panel_for_action(action) + if panel is None: + return + from datalab.gui.processor.base import ( # pylint: disable=import-outside-toplevel + ProcessingParameters, + extract_processing_parameters, + insert_processing_parameters, + ) + + existing, missing = self._resolve_target_outputs(panel, action) + self._record_missing_outputs(action, missing) + if not existing: + return + # All outputs of a 1_to_n share the same source. + try: + first_obj = panel.objmodel[existing[0]] + except KeyError: + return + pp = extract_processing_parameters(first_obj) + if pp is None or pp.source_uuid is None: + return + try: + source_obj = panel.objmodel[pp.source_uuid] + except KeyError: + self._cascade_warnings.append( + _("Action %s: source object was deleted — skipping.") + % (action.func_name or action.uuid) + ) + return + params = action.kwargs.get("params") or [] + if not params: + return + new_objs = panel.processor.recompute_1_to_n( + action.func_name, source_obj, params, + plugin_origin=action.plugin_origin, + ) + if not new_objs: + return + # Map each output to its (re)computed counterpart by index. If the + # cardinality changed (e.g. function now produces fewer outputs), + # we update what we can and report the rest as missing. + n = min(len(existing), len(new_objs)) + for idx in range(n): + out_uuid = existing[idx] + try: + out_obj = panel.objmodel[out_uuid] + except KeyError: + continue + new_obj = new_objs[idx] + self._update_obj_in_place(out_obj, new_obj) + new_param = params[idx] if idx < len(params) else None + insert_processing_parameters( + out_obj, + ProcessingParameters( + func_name=action.func_name, + pattern="1-to-n", + param=new_param, + source_uuid=pp.source_uuid, + ), + ) + panel.processor.auto_recompute_analysis(out_obj) + self._refresh_target(panel, out_uuid) + if len(new_objs) != len(existing): + _logger.warning( + "1-to-n cardinality changed for action %s: %d outputs, %d existing.", + action.uuid, + len(new_objs), + len(existing), + ) + + def _recompute_n_to_1_in_place(self, action: HistoryAction) -> None: + """Recompute an n-to-1 action in place.""" + panel = self.__resolve_panel_for_action(action) + if panel is None: + return + from datalab.gui.processor.base import ( # pylint: disable=import-outside-toplevel + ProcessingParameters, + extract_processing_parameters, + insert_processing_parameters, + ) + + existing, missing = self._resolve_target_outputs(panel, action) + self._record_missing_outputs(action, missing) + if not existing: + return + output_uuid = existing[0] + try: + output_obj = panel.objmodel[output_uuid] + except KeyError: + return + pp = extract_processing_parameters(output_obj) + source_uuids: list[str] = [] + if pp is not None and pp.source_uuids: + source_uuids = list(pp.source_uuids) + else: + source_uuids = list( + action.state.selection.get(panel.PANEL_STR_ID, []) + ) + src_objs: list[SignalObj | ImageObj] = [] + for uuid in source_uuids: + try: + src_objs.append(panel.objmodel[uuid]) + except KeyError: + continue + if not src_objs: + self._cascade_warnings.append( + _("Action %s: all source objects were deleted — skipping.") + % (action.func_name or action.uuid) + ) + return + param = action.kwargs.get("param") + new_obj = panel.processor.recompute_n_to_1( + action.func_name, src_objs, param, + plugin_origin=action.plugin_origin, + ) + if new_obj is None: + return + self._update_obj_in_place(output_obj, new_obj) + insert_processing_parameters( + output_obj, + ProcessingParameters( + func_name=action.func_name, + pattern="n-to-1", + param=param, + source_uuids=[get_uuid(o) for o in src_objs], + ), + ) + panel.processor.auto_recompute_analysis(output_obj) + self._refresh_target(panel, output_uuid) + + def _recompute_2_to_1_in_place(self, action: HistoryAction) -> None: + """Recompute a 2-to-1 action in place (single or pairwise).""" + panel = self.__resolve_panel_for_action(action) + if panel is None: + return + from datalab.gui.processor.base import ( # pylint: disable=import-outside-toplevel + ProcessingParameters, + extract_processing_parameters, + insert_processing_parameters, + ) + + existing, missing = self._resolve_target_outputs(panel, action) + self._record_missing_outputs(action, missing) + if not existing: + return + param = action.kwargs.get("param") + obj2_uuids = action.kwargs.get("obj2_uuids") or [] + if isinstance(obj2_uuids, str): + obj2_uuids = [obj2_uuids] + pairwise = bool(action.kwargs.get("pairwise")) + # In pairwise mode, expect one output per (obj1, obj2) pair. + # In single-operand mode, every output uses the same obj2. + recorded_inputs = list(action.state.selection.get(panel.PANEL_STR_ID, [])) + for idx, out_uuid in enumerate(existing): + try: + output_obj = panel.objmodel[out_uuid] + except KeyError: + continue + pp = extract_processing_parameters(output_obj) + src_uuids = ( + list(pp.source_uuids) + if pp is not None and pp.source_uuids + else (recorded_inputs[idx : idx + 1] + obj2_uuids[idx : idx + 1] + if pairwise + else recorded_inputs[idx : idx + 1] + obj2_uuids[:1]) + ) + if len(src_uuids) < 2: + self._cascade_warnings.append( + _("Action %s: missing source(s) for output #%d — skipping.") + % (action.func_name or action.uuid, idx + 1) + ) + continue + try: + obj1 = panel.objmodel[src_uuids[0]] + obj2 = panel.objmodel[src_uuids[1]] + except KeyError: + self._cascade_warnings.append( + _("Action %s: source object(s) were deleted — skipping.") + % (action.func_name or action.uuid) + ) + continue + new_obj = panel.processor.recompute_2_to_1( + action.func_name, obj1, obj2, param, + plugin_origin=action.plugin_origin, + ) + if new_obj is None: + continue + self._update_obj_in_place(output_obj, new_obj) + insert_processing_parameters( + output_obj, + ProcessingParameters( + func_name=action.func_name, + pattern="2-to-1", + param=param, + source_uuids=[get_uuid(obj1), get_uuid(obj2)], + ), + ) + panel.processor.auto_recompute_analysis(output_obj) + self._refresh_target(panel, out_uuid) + + def _recompute_1_to_0_in_place(self, action: HistoryAction) -> None: + """Recompute a 1-to-0 analysis on each source object in place.""" + panel = self.__resolve_panel_for_action(action) + if panel is None: + return + # 1-to-0 produces no data object; recompute on each captured source + # so analysis metadata stays consistent with the (possibly updated) + # data of upstream actions. + sources = list(action.state.selection.get(panel.PANEL_STR_ID, [])) + if not sources: + return + param = action.kwargs.get("param") + missing: list[str] = [] + for uuid in sources: + try: + src_obj = panel.objmodel[uuid] + except KeyError: + missing.append(uuid) + continue + panel.processor.recompute_1_to_0( + action.func_name, src_obj, param, + plugin_origin=action.plugin_origin, + ) + self._refresh_target(panel, uuid) + if missing: + self._cascade_warnings.append( + _("Action %s: %d analysed object(s) were deleted — skipping.") + % (action.func_name or action.uuid, len(missing)) + ) + + def recompute_cascade( + self, + root_action: HistoryAction, + descendants: list[HistoryAction] | None = None, + ) -> None: + """Recompute ``root_action``'s descendants in the current session + in-place (on existing UUIDs). + + Each action involved is flagged ``is_stale`` (light-orange background + in the tree) for the duration of the recompute, then cleared. The + root action itself is normally NOT recomputed here (the caller has + already updated its output object). For a stale Play, ``root_action`` + is included so its own flag is cleared. + + Every supported pattern (``1_to_1``, ``1_to_n``, ``n_to_1``, + ``2_to_1``, ``1_to_0``) is dispatched through + :meth:`_recompute_action_in_place`. Missing or unsupported items + are reported via a single end-of-cascade warning dialog. + + Actions whose underlying processor feature is missing (e.g. plugin + uninstalled) keep ``is_stale = True`` after the cascade completes, + so the visual marker persists until the plugin is reinstalled. + + Args: + root_action: Root action whose descendants must be recomputed. + descendants: Pre-computed downstream actions. When ``None`` + (default), :meth:`get_downstream_actions` is called + internally. Passing a pre-computed list avoids a redundant + graph traversal when the caller has already resolved the + chain (e.g. :meth:`_edit_mode_replay`). + """ + if descendants is None: + descendants = self.get_downstream_actions(root_action) + if root_action.is_stale: + descendants = [root_action] + descendants + # Re-entrancy guard. + if getattr(self, "_cascade_in_progress", False): + self._flush_cascade_warnings() + return + if not descendants: + # Still surface any warnings accumulated by a prior standalone + # ``_recompute_action_in_place`` call (e.g. root recompute in + # ``_edit_mode_replay``) before bailing out. + self._flush_cascade_warnings() + return + # Reset the broken set: only the current cascade's outcomes count. + self._broken_actions.clear() + self._cascade_in_progress = True + try: + for action in descendants: + action.is_stale = True + self.tree.refresh_action_item(action) + QW.QApplication.processEvents() + for action in descendants: + try: + self._recompute_action_in_place(action) + finally: + # Keep the stale marker for actions flagged as broken + # (missing plugin) so the user can spot them in the tree. + if action.uuid not in self._broken_actions: + action.is_stale = False + self.tree.refresh_action_item(action) + QW.QApplication.processEvents() + finally: + for action in descendants: + if action.is_stale and action.uuid not in self._broken_actions: + action.is_stale = False + self.tree.refresh_action_item(action) + self._cascade_in_progress = False + self._flush_cascade_warnings() + + def _flush_cascade_warnings(self) -> None: + """Show + clear accumulated cascade warnings (no-op when empty).""" + if self._cascade_warnings: + QW.QMessageBox.warning( + self.mainwindow, + _("Cascade recompute"), + _("Some downstream actions could not be recomputed:") + + "\n\n• " + + "\n• ".join(self._cascade_warnings), + ) + self._cascade_warnings = [] + + + def __existing_input_uuids( + self, panel: BaseDataPanel, action: HistoryAction + ) -> list[str]: + """Return recorded input UUIDs that still exist in ``panel``.""" + recorded = action.state.selection.get(panel.PANEL_STR_ID, []) + existing: list[str] = [] + for uuid in recorded: + try: + panel.objmodel[uuid] + except KeyError: + continue + existing.append(uuid) + return existing + + def __sync_panel_selection(self) -> None: + """Sync data panel selection from the currently selected tree item.""" + if self.__replaying or self.__syncing: + return + item = self.tree.currentItem() + if item is None or not item.isSelected(): + return + if item.parent() is None: + # Session-level selection: peek the first compute action + index = self.tree.indexOfTopLevelItem(item) + if index < 0 or index >= len(self.__history_sessions): + return + session = self.__history_sessions[index] + action = next( + (a for a in session.actions if a.kind == HistoryAction.KIND_COMPUTE), + None, + ) + if action is None: + return + else: + uuid = item.data(0, QC.Qt.UserRole) + try: + action = self.tree.get_action_from_uuid( + uuid, self.__history_sessions + ) + except ValueError: + return + + panel = self.__resolve_panel_for_action(action) + if panel is None: + return + + target_uuids: list[str] = [] + output_uuid = self.__find_output_object_uuid(panel, action) + if output_uuid is not None: + target_uuids = [output_uuid] + else: + target_uuids = self.__existing_input_uuids(panel, action) + + if not target_uuids: + return + + self.__syncing = True + try: + with QC.QSignalBlocker(panel.objview): + panel.objview.select_objects(target_uuids) + self.mainwindow.set_current_panel(panel) + finally: + self.__syncing = False + + # ------------------------------------------------------------------ + # Step-by-step navigation (B2) + # ------------------------------------------------------------------ + + def __current_action(self) -> HistoryAction | None: + """Return the action currently selected in the tree, or ``None``.""" + item = self.tree.currentItem() + if item is None or item.parent() is None: + return None + uuid = item.data(0, QC.Qt.UserRole) + try: + return self.tree.get_action_from_uuid(uuid, self.__history_sessions) + except ValueError: + return None + + def __current_session(self) -> HistorySession | None: + """Return the session relevant for step navigation.""" + item = self.tree.currentItem() + if item is not None: + if item.parent() is None: + index = self.tree.indexOfTopLevelItem(item) + if 0 <= index < len(self.__history_sessions): + return self.__history_sessions[index] + else: + action = self.__current_action() + if action is not None: + return self._find_parent_session(action) + if self.__history_sessions: + return self.__history_sessions[-1] + return None + + def __can_step_prev(self) -> bool: + """Return True if a previous action exists in the current session.""" + session = self.__current_session() + if session is None or not session.actions: + return False + action = self.__current_action() + if action is None or action not in session.actions: + return False + return session.actions.index(action) > 0 + + def __can_step_next(self) -> bool: + """Return True if a next action exists in the current session.""" + session = self.__current_session() + if session is None or not session.actions: + return False + action = self.__current_action() + if action is None or action not in session.actions: + # No action selected — Next would land on the first one. + return True + return session.actions.index(action) < len(session.actions) - 1 + + def __select_action_in_tree(self, action: HistoryAction) -> None: + """Select ``action`` in the tree (triggers `__sync_panel_selection`).""" + for i in range(self.tree.topLevelItemCount()): + sess_item = self.tree.topLevelItem(i) + for j in range(sess_item.childCount()): + child = sess_item.child(j) + if child.data(0, QC.Qt.UserRole) == action.uuid: + self.tree.clearSelection() + self.tree.setCurrentItem(child) + child.setSelected(True) + return + + def _step_prev(self) -> None: + """Select the previous action in the current session.""" + if not self.__can_step_prev(): + return + session = self.__current_session() + action = self.__current_action() + idx = session.actions.index(action) + self.__select_action_in_tree(session.actions[idx - 1]) + self.__update_actions_state() + + def _step_next(self) -> None: + """Select the next action in the current session.""" + if not self.__can_step_next(): + return + session = self.__current_session() + action = self.__current_action() + if action is None or action not in session.actions: + target = session.actions[0] + else: + target = session.actions[session.actions.index(action) + 1] + self.__select_action_in_tree(target) + self.__update_actions_state() + + def duplicate_selected_entries(self) -> None: + """Duplicate selected sessions (with their data) into new independent sessions. + + For each selected session (or the parent session of a selected action), + all referenced data objects are deep-copied into a new group and the + session is duplicated with all UUID references rewritten to the clones. + The result is an independent, editable and replayable session. + """ + selected = self.tree.get_selected_actions_or_sessions(self.__history_sessions) + if not selected: + return + # Normalise: resolve individual actions to their parent session, deduplicate. + sessions_to_dup: list[HistorySession] = [] + seen: set[int] = set() + for item in selected: + if isinstance(item, HistorySession): + session = item + else: + session = self._find_parent_session(item) + if session is None: + continue + if id(session) not in seen: + seen.add(id(session)) + sessions_to_dup.append(session) + + copy_suffix = _("Copy") + new_sessions: list[HistorySession] = [] + panel_map = { + "signal": self.mainwindow.signalpanel, + "image": self.mainwindow.imagepanel, + } + + for session in sessions_to_dup: + # 1. Collect all UUIDs referenced by this session + uuids_by_panel: dict[str, set[str]] = {} + for action in session.actions: + for pstr, uuids in action.state.selection.items(): + uuids_by_panel.setdefault(pstr, set()).update(uuids) + for pstr, metadata in action.state.object_metadata.items(): + uuids_by_panel.setdefault(pstr, set()).update(metadata.keys()) + obj2 = action.kwargs.get("obj2_uuids") + if obj2: + pstr = action.panel_str or "" + if isinstance(obj2, str): + obj2 = [obj2] + uuids_by_panel.setdefault(pstr, set()).update(obj2) + # Output UUIDs produced by this action (e.g. result of a + # compute step). Without this, the last action's outputs + # would be missing because no subsequent state captures them. + if action.output_uuids: + pstr = action.panel_str or "" + uuids_by_panel.setdefault(pstr, set()).update( + action.output_uuids + ) + + # 2. Clone objects and build uuid_remap + uuid_remap: dict[str, dict[str, str]] = {} + clones_by_pstr: dict[str, list] = {} + group_title = f"{copy_suffix} - {session.title}" + for pstr, uuids in uuids_by_panel.items(): + panel = panel_map.get(pstr) + if panel is None: + continue + uuid_remap[pstr] = {} + existing_ids = set(panel.objmodel.get_object_ids()) + clones = [] + # Iterate in panel order (not set order) to preserve + # the topological object ordering in the duplicated group. + ordered_ids = [ + u for u in panel.objmodel.get_object_ids() if u in uuids + ] + for old_uuid in ordered_ids: + if old_uuid not in existing_ids: + continue + obj = panel.objmodel[old_uuid] + clone = deepcopy(obj) + new_uuid = str(uuid4()) + # SignalObj/ImageObj store UUID via metadata option + try: + clone.set_metadata_option("uuid", new_uuid) + except AttributeError: + clone.uuid = new_uuid + uuid_remap[pstr][old_uuid] = new_uuid + clones.append(clone) + clones_by_pstr[pstr] = clones + if clones: + group_id = get_uuid(panel.add_group(group_title)) + for clone in clones: + panel.add_object(clone, group_id=group_id) + + # Second pass: remap source UUIDs in cloned objects' + # processing_parameters so reprocessing in the Processing tab + # uses the cloned source, not the original. + from datalab.gui.processor.base import ( # pylint: disable=import-outside-toplevel + PROCESSING_PARAMETERS_OPTION, + ProcessingParameters, + ) + + for pstr_inner, clones_inner in clones_by_pstr.items(): + pmap = uuid_remap.get(pstr_inner, {}) + if not pmap: + continue + for clone in clones_inner: + try: + pp_dict = clone.get_metadata_option( + PROCESSING_PARAMETERS_OPTION + ) + except (AttributeError, ValueError): + continue + if not pp_dict: + continue + try: + pp = ProcessingParameters.from_dict(pp_dict) + except Exception: # pylint: disable=broad-except + continue + changed = False + if pp.source_uuid is not None and pp.source_uuid in pmap: + pp.source_uuid = pmap[pp.source_uuid] + changed = True + if pp.source_uuids is not None: + new_src = [pmap.get(u, u) for u in pp.source_uuids] + if new_src != pp.source_uuids: + pp.source_uuids = new_src + changed = True + if changed: + try: + clone.set_metadata_option( + PROCESSING_PARAMETERS_OPTION, pp.to_dict() + ) + except (AttributeError, ValueError): + pass + + # 3. Build the new session with remapped UUIDs + self.__session_increment += 1 + title = f"{session.title} {copy_suffix}" + new_session = session.copy_with_uuid_remap( + title=title, uuid_remap=uuid_remap + ) + new_session.number = self.__session_increment + new_sessions.append(new_session) + + # Register output mappings for cloned actions so that + # _resolve_target_outputs / get_downstream_actions work on + # the duplicated session (same logic as read_h5_data). + for action in new_session.actions: + if action.output_uuids: + self._action_output_uuids[action.uuid] = list( + action.output_uuids + ) + for out_uuid in action.output_uuids: + self._output_to_action[out_uuid] = action.uuid + + # Insert each duplicated session immediately after its original. + offset = 0 + for original_session, new_session in zip(sessions_to_dup, new_sessions): + idx = self.__history_sessions.index(original_session) + self.__history_sessions.insert(idx + 1 + offset, new_session) + offset += 1 + self.tree.populate_tree(self.__history_sessions) + self.__select_sessions(new_sessions) + self.refresh_compatibility_items() + self.__update_actions_state() + + def generate_macro(self) -> None: + """Generate a standalone Python script from selected history entries. + + The generated script uses sigima functions directly with proper variable + chaining. Object references (UUIDs) are resolved to variable names so + that 2-to-1 operations reference the correct intermediate result. + The script is copied to the clipboard and the user is notified. + """ + selected = self.tree.get_selected_actions_or_sessions( + self.__history_sessions + ) + actions: list[HistoryAction] = [] + if not selected: + for session in self.__history_sessions: + actions.extend(session.actions) + else: + for item in selected: + if isinstance(item, HistorySession): + actions.extend(item.actions) + else: + actions.append(item) + if not actions: + return + + # Filter to compute-only actions for the pipeline + compute_actions = [ + a for a in actions if a.kind == HistoryAction.KIND_COMPUTE + ] + if not compute_actions: + QW.QMessageBox.information( + self.mainwindow, + _("Generate macro"), + _("No compute actions to export."), + ) + return + + # Determine input type from first action + first_panel = compute_actions[0].panel_str + if first_panel == "signal": + obj_type = "SignalObj" + obj_import = "from sigima.objects import SignalObj" + else: + obj_type = "ImageObj" + obj_import = "from sigima.objects import ImageObj" + + imports: set[str] = set() + imports.add(obj_import) + body_lines: list[str] = [] + + # UUID → variable mapping for resolving object references. + # Populated with input UUIDs ("src", "src_2", ...) and enriched + # with each step's output UUID after code generation. + uuid_to_var: dict[str, str] = {} + + # Extra input parameters discovered during generation (second + # operands that are not produced by any previous step). + extra_inputs: list[str] = [] + + # Seed the mapping with the first action's input selection. + first_sel = compute_actions[0].state.selection.get( + compute_actions[0].panel_str, [] + ) + for i, uuid in enumerate(first_sel): + var = "src" if i == 0 else f"src_{i + 1}" + uuid_to_var[uuid] = var + + step = 0 + current_var = "src" + + for action in compute_actions: + step += 1 + + # Resolve input variable from the action's selection UUIDs. + sel_uuids = action.state.selection.get( + action.panel_str or "", [] + ) + if sel_uuids and sel_uuids[0] in uuid_to_var: + input_var = uuid_to_var[sel_uuids[0]] + else: + input_var = current_var + + # Resolve second operand for 2-to-1 patterns. + obj2_var: str | None = None + if action.pattern == "2_to_1": + obj2_uuids = action.kwargs.get("obj2_uuids", []) + if isinstance(obj2_uuids, str): + obj2_uuids = [obj2_uuids] + if obj2_uuids: + obj2_uuid = obj2_uuids[0] + if obj2_uuid in uuid_to_var: + obj2_var = uuid_to_var[obj2_uuid] + else: + # External input — add as function parameter. + obj2_var = f"obj2_{step}" + uuid_to_var[obj2_uuid] = obj2_var + extra_inputs.append(obj2_var) + + code_lines, output_var = action.to_macro_code( + step, input_var, imports, obj2_var=obj2_var + ) + body_lines.extend(code_lines) + body_lines.append("") + + if output_var is not None: + current_var = output_var + # Map the output UUID so subsequent steps can reference it. + output_uuid = self._action_output_uuid(action) + if output_uuid: + uuid_to_var[output_uuid] = output_var + # Also register any new UUIDs from the action's selection + # that we haven't seen yet (secondary selections). + for uuid in sel_uuids[1:]: + if uuid not in uuid_to_var: + uuid_to_var[uuid] = input_var + + # Build the function signature with extra inputs. + params_str = f"src: {obj_type}" + for extra in extra_inputs: + params_str += f", {extra}: {obj_type}" + + # Assemble the full script + sorted_imports = sorted(imports) + script_lines: list[str] = [ + '"""', + "DataLab — standalone processing pipeline", + f"Generated from history ({len(compute_actions)} steps)", + '"""', + "", + ] + script_lines.extend(sorted_imports) + script_lines.append("") + script_lines.append("") + script_lines.append( + f"def process({params_str}) -> {obj_type}:" + ) + script_lines.append( + ' """Apply the recorded processing pipeline."""' + ) + for line in body_lines: + script_lines.append(f" {line}" if line else "") + script_lines.append(f" return {current_var}") + script_lines.append("") + script_lines.append("") + script_lines.append('if __name__ == "__main__":') + script_lines.append( + " # Standalone execution: run from DataLab's Macro panel." + ) + script_lines.append(" # Operates on the current object of the target panel.") + script_lines.append( + " from datalab.control.proxy import RemoteProxy" + ) + script_lines.append("") + script_lines.append(" proxy = RemoteProxy()") + panel_str = compute_actions[0].panel_str or ( + "signal" if obj_type == "SignalObj" else "image" + ) + script_lines.append(f' proxy.set_current_panel("{panel_str}")') + script_lines.append(" src = proxy.get_object()") + script_lines.append(" if src is None:") + script_lines.append( + f' raise RuntimeError("No current object in panel: {panel_str}")' + ) + if extra_inputs: + n_extra = len(extra_inputs) + script_lines.append( + " _uuids = [u for u in proxy.get_sel_object_uuids()" + " if u != src.uuid]" + ) + script_lines.append(f" if len(_uuids) < {n_extra}:") + script_lines.append( + " raise RuntimeError(" + ) + script_lines.append( + f' "Pipeline needs {n_extra} extra selected' + ' object(s) besides the current one"' + ) + script_lines.append(" )") + for idx, extra in enumerate(extra_inputs): + script_lines.append( + f" {extra} = proxy.get_object(" + f'_uuids[{idx}], "{panel_str}")' + ) + extra_args = "".join(f", {e}" for e in extra_inputs) + script_lines.append(f" result = process(src{extra_args})") + script_lines.append(" proxy.add_object(result)") + script_lines.append(' print(f"Pipeline applied: {result.title}")') + script_lines.append("") + + script = "\n".join(script_lines) + QW.QApplication.clipboard().setText(script) + QW.QMessageBox.information( + self.mainwindow, + _("Generate macro"), + _("Macro script copied to clipboard (%d actions).") + % len(compute_actions), + ) + + def __select_sessions(self, sessions: list[HistorySession]) -> None: + """Select top-level tree items matching ``sessions``.""" + self.tree.clearSelection() + for session in sessions: + index = self.__history_sessions.index(session) + item = self.tree.topLevelItem(index) + item.setSelected(True) + self.tree.setCurrentItem(item) + + def delete_selected(self) -> None: + """Delete the selected actions or sessions (with confirmation). + + When a top-level session is selected, the entire session is deleted. + When individual actions are selected, they and all subsequent actions + in their parent session are removed. After deletion, the first + available item in the tree is selected automatically. + """ + selected = self.tree.get_selected_actions_or_sessions(self.__history_sessions) + if not selected: + return + has_individual_actions = any( + isinstance(item, HistoryAction) for item in selected + ) + if has_individual_actions: + msg = _( + "Do you really want to delete the selected items?\n\n" + "Note: deleting an action also removes all subsequent " + "actions in the same session." + ) + else: + msg = _("Do you really want to delete the selected items?") + reply = QW.QMessageBox.question( + self.mainwindow, + _("Delete"), + msg, + QW.QMessageBox.Yes | QW.QMessageBox.No, + QW.QMessageBox.No, + ) + if reply != QW.QMessageBox.Yes: + return + sessions_to_remove: set[int] = set() + for item in selected: + if isinstance(item, HistorySession): + sessions_to_remove.add(id(item)) + else: + # Individual action: remove from its parent session + for session in self.__history_sessions: + if item in session.actions: + session.remove_action(item) + if not session.actions: + sessions_to_remove.add(id(session)) + break + self.__history_sessions = [ + s for s in self.__history_sessions if id(s) not in sessions_to_remove + ] + self.tree.populate_tree(self.__history_sessions) + self.refresh_compatibility_items() + self.__update_actions_state() + # Auto-select the first available item after deletion + if self.tree.topLevelItemCount() > 0: + first = self.tree.topLevelItem(0) + self.tree.setCurrentItem(first) + first.setSelected(True) + + def remove_incompatible_actions(self) -> None: + """Remove all actions whose workspace state is incompatible. + + Shows a confirmation dialog listing how many actions will be removed, + then purges them from their sessions. Empty sessions are also removed. + """ + incompatible: list[tuple[HistorySession, HistoryAction]] = [] + for session in self.__history_sessions: + for action in session.actions: + if not action.is_current_state_compatible( + self.mainwindow, restore_selection=True + ): + incompatible.append((session, action)) + if not incompatible: + QW.QMessageBox.information( + self.mainwindow, + _("Remove incompatible"), + _("All actions are compatible with the current workspace."), + ) + return + reply = QW.QMessageBox.question( + self.mainwindow, + _("Remove incompatible"), + _("%d incompatible action(s) will be removed. Continue?") + % len(incompatible), + QW.QMessageBox.Yes | QW.QMessageBox.No, + QW.QMessageBox.No, + ) + if reply != QW.QMessageBox.Yes: + return + for session, action in incompatible: + if action in session.actions: + session.actions.remove(action) + # Remove empty sessions + self.__history_sessions = [ + s for s in self.__history_sessions if s.actions + ] + self.tree.populate_tree(self.__history_sessions) + self.refresh_compatibility_items() + self.__update_actions_state() + + def save_to_dlhist_file(self, filename: str | None = None) -> bool: + """Save the History Panel content to a standalone ``.dlhist`` file. + + Args: + filename: History filename. If None, a file dialog is opened. + + Returns: + True if the history was saved, False if the operation was canceled. + """ + if filename is None: + basedir = Conf.main.base_dir.get() + with save_restore_stds(): + filename, _filt = getsavefilename( + self, _("Save history file"), basedir, self.FILE_FILTERS + ) + if not filename: + return False + if osp.splitext(filename)[1] == "": + filename += ".dlhist" + with qt_try_loadsave_file(self.parentWidget(), filename, "save"): + Conf.main.base_dir.set(filename) + from datalab.h5.native import NativeH5Writer # pylint: disable=C0415 + + with NativeH5Writer(filename) as writer: + # Make the .dlhist file self-contained: store the signal and + # image panel objects (all of them) alongside the history, so + # that reopening restores both the data objects and the history + # that references them. Each section is read back by its own + # H5_PREFIX key, so the write order is not significant. + self.mainwindow.signalpanel.serialize_to_hdf5(writer) + self.mainwindow.imagepanel.serialize_to_hdf5(writer) + self.serialize_to_hdf5(writer) + return True + + def open_dlhist_file(self, filename: str | None = None) -> bool: + """Open a standalone ``.dlhist`` file into the History Panel. + + Args: + filename: History filename. If None, a file dialog is opened. + + Returns: + True if the history was loaded, False if the operation was canceled. + """ + if filename is None: + basedir = Conf.main.base_dir.get() + with save_restore_stds(): + filename, _filt = getopenfilename( + self, _("Open history file"), basedir, self.FILE_FILTERS + ) + if not filename: + return False + with qt_try_loadsave_file(self.parentWidget(), filename, "load"): + Conf.main.base_dir.set(filename) + from datalab.h5.native import NativeH5Reader # pylint: disable=C0415 + + with NativeH5Reader(filename) as reader: + # A self-contained .dlhist file stores the signal and image + # panel objects in addition to the history sessions. The way + # they are restored depends on whether the workspace is already + # in use (data objects OR history): a pristine workspace is + # loaded directly while preserving UUIDs, otherwise the file + # is imported as new groups/sessions. + workspace_in_use = ( + self.mainwindow.signalpanel.objmodel.get_object_ids() + or self.mainwindow.imagepanel.objmodel.get_object_ids() + or bool(self.__history_sessions) + ) + if workspace_in_use: + # Workspace not empty: import the objects into new groups + # with fresh UUIDs and append the history as new sessions + # whose references are remapped to the imported objects. + self.__import_dlhist_into_new_session(reader) + else: + # Workspace empty: load directly, preserving original UUIDs + # (reset_all=True) so that history references stay valid. + self.mainwindow.signalpanel.deserialize_from_hdf5( + reader, reset_all=True + ) + self.mainwindow.imagepanel.deserialize_from_hdf5( + reader, reset_all=True + ) + self.deserialize_from_hdf5(reader) + return True + + def __import_dlhist_into_new_session(self, reader: NativeH5Reader) -> None: + """Import a ``.dlhist`` file into new groups and new history sessions. + + Used when the workspace already contains objects: the file's signal and + image objects are imported into fresh groups with regenerated UUIDs, and + the history sessions are appended as new independent sessions whose action + references are remapped to the freshly imported objects. + + Args: + reader: HDF5 reader positioned on a ``.dlhist`` file. + """ + panel_map = { + "signal": self.mainwindow.signalpanel, + "image": self.mainwindow.imagepanel, + } + uuid_remap: dict[str, dict[str, str]] = {} + imported_by_pstr: dict[str, list] = {} + # 1. Import objects from each panel (each panel is read by its own + # H5_PREFIX key). Read each object preserving its original UUID to + # capture the old->new mapping, then assign a fresh UUID so that the + # imported objects keep an independent identity. + for pstr, panel in panel_map.items(): + uuid_remap[pstr] = {} + imported: list = [] + imported_by_pstr[pstr] = imported + if panel.H5_PREFIX not in reader.h5: + continue + with reader.group(panel.H5_PREFIX): + for name in reader.h5.get(panel.H5_PREFIX, []): + with reader.group(name): + group = panel.add_group("") + with reader.group("title"): + group.title = reader.read_str() + for obj_name in reader.h5.get( + f"{panel.H5_PREFIX}/{name}", [] + ): + obj = panel.deserialize_object_from_hdf5( + reader, obj_name, reset_all=True + ) + old_uuid = get_uuid(obj) + new_uuid = str(uuid4()) + # SignalObj/ImageObj store UUID via metadata option + try: + obj.set_metadata_option("uuid", new_uuid) + except AttributeError: + obj.uuid = new_uuid + uuid_remap[pstr][old_uuid] = new_uuid + panel.add_object( + obj, get_uuid(group), set_current=False + ) + imported.append(obj) + panel.selection_changed() + # 2. Remap source UUIDs in imported objects' processing_parameters so + # that reprocessing in the Processing tab uses the imported sources, + # not the originals (same logic as duplicate_selected_entries). + from datalab.gui.processor.base import ( # pylint: disable=import-outside-toplevel + PROCESSING_PARAMETERS_OPTION, + ProcessingParameters, + ) + + for pstr, objs in imported_by_pstr.items(): + pmap = uuid_remap.get(pstr, {}) + if not pmap: + continue + for obj in objs: + try: + pp_dict = obj.get_metadata_option(PROCESSING_PARAMETERS_OPTION) + except (AttributeError, ValueError): + continue + if not pp_dict: + continue + try: + pp = ProcessingParameters.from_dict(pp_dict) + except Exception: # pylint: disable=broad-except + continue + changed = False + if pp.source_uuid is not None and pp.source_uuid in pmap: + pp.source_uuid = pmap[pp.source_uuid] + changed = True + if pp.source_uuids is not None: + new_src = [pmap.get(u, u) for u in pp.source_uuids] + if new_src != pp.source_uuids: + pp.source_uuids = new_src + changed = True + if changed: + try: + obj.set_metadata_option( + PROCESSING_PARAMETERS_OPTION, pp.to_dict() + ) + except (AttributeError, ValueError): + pass + # 3. Import history sessions as new independent sessions whose captured + # UUIDs are remapped to the imported objects. + if self.H5_PREFIX not in reader.h5: + return + sessions = reader.read_object_list(self.H5_PREFIX, HistorySession) or [] + imported_suffix = _("Imported") + new_sessions: list[HistorySession] = [] + for session in sessions: + self.__session_increment += 1 + title = f"{session.title} {imported_suffix}" + new_session = session.copy_with_uuid_remap( + title=title, uuid_remap=uuid_remap + ) + new_session.number = self.__session_increment + new_sessions.append(new_session) + # Register output mappings for imported actions so that + # _resolve_target_outputs / get_downstream_actions work. + for action in new_session.actions: + if action.output_uuids: + self._action_output_uuids[action.uuid] = list( + action.output_uuids + ) + for out_uuid in action.output_uuids: + self._output_to_action[out_uuid] = action.uuid + self.__history_sessions.extend(new_sessions) + self.tree.populate_tree(self.__history_sessions) + self.refresh_compatibility_items() + self.__update_actions_state() + + def refresh_compatibility_items(self, *args: Any) -> None: + """Refresh action item compatibility markers in the tree.""" + del args + self.tree.update_compatibility_states(self.__history_sessions, self.mainwindow) + + def serialize_to_hdf5(self, writer: NativeH5Writer) -> None: + """Serialize whole panel to a HDF5 file + + Args: + writer: HDF5 writer + """ + writer.write_object_list(self.__history_sessions, self.H5_PREFIX) + + def deserialize_from_hdf5( + self, reader: NativeH5Reader, reset_all: bool = False + ) -> None: + """Deserialize whole panel from a HDF5 file Args: reader: HDF5 reader reset_all: Unused (kept for compatibility with panel API) """ + if self.H5_PREFIX not in reader.h5: + self.__history_sessions = [] + self.__session_increment = 0 + self.tree.populate_tree(self.__history_sessions) + self.__update_actions_state() + return self.__history_sessions: list[HistorySession] = ( reader.read_object_list(self.H5_PREFIX, HistorySession) or [] ) if self.__history_sessions: self.__session_increment = self.__history_sessions[-1].number + # Rebuild the bijective mapping from the loaded actions. Legacy + # (v1) actions have empty ``output_uuids`` and contribute nothing + # to the index — the heuristic fallback handles them. + self._action_output_uuids = {} + self._output_to_action = {} + for session in self.__history_sessions: + for action in session.actions: + if action.output_uuids: + self._action_output_uuids[action.uuid] = list( + action.output_uuids + ) + for out_uuid in action.output_uuids: + self._output_to_action[out_uuid] = action.uuid self.tree.populate_tree(self.__history_sessions) + self.refresh_compatibility_items() self.__update_actions_state() def __len__(self) -> int: @@ -1384,6 +4425,12 @@ def create_new_session(self) -> None: session = HistorySession(number=self.__session_increment) self.__history_sessions.append(session) self.tree.populate_tree(self.__history_sessions) + self.refresh_compatibility_items() + + def start_new_session_after_workspace_reset(self) -> None: + """Start a new history session after a workspace reset, when useful.""" + if self.__history_sessions and self.__history_sessions[-1].actions: + self.create_new_session() def add_compute_entry( self, @@ -1392,8 +4439,10 @@ def add_compute_entry( func_name: str, pattern: str, save_state: bool = True, + output_uuids: list[str] | None = None, + plugin_origin: dict[str, Any] | None = None, **kwargs: Any, - ) -> None: + ) -> HistoryAction | None: """Record a *compute* action in the current history session. Args: @@ -1405,26 +4454,48 @@ def add_compute_entry( ``"1_to_n"``, ``"multiple_1_to_1"`` (the latter is recorded for traceability but not replayable). save_state: If True, capture the workspace state for replay. + output_uuids: Optional list of UUIDs of the data objects produced by + this action. When known at call time, prefer passing it here so the + bijective mapping is initialised in one step. Most callers do not + know the outputs yet and instead wrap the compute call with + :meth:`capture_outputs` (or call :meth:`register_action_outputs` + explicitly afterwards) using the returned action. + plugin_origin: Optional plugin origin descriptor (see + :func:`datalab.gui.processor.base._detect_plugin_origin`). ``None`` + for built-in Sigima/DataLab features. **kwargs: Extra primitive kwargs (``param``, ``obj2_uuids``, ``obj2_name``, ``pairwise``, ``params`` (list of DataSet), ``func_names`` (list of str), ...). ``DataSet`` instances are serialised as JSON. + + Returns: + The created :class:`HistoryAction`, or ``None`` if recording is + disabled (record mode off or replay in progress). """ if not self.__record_mode or self.__replaying: - return + return None state = WorkspaceState() if save_state: state.save(self.mainwindow) + # Deep-copy kwargs so each action owns independent parameter + # instances. Without this, consecutive applications of the same + # function (e.g. two gaussian_filter calls with different sigma) + # would share a single DataSet object and editing one action's + # parameters would silently mutate the other. action = HistoryAction( title=action_title, kind=HistoryAction.KIND_COMPUTE, panel_str=panel_str, func_name=func_name, pattern=pattern, - kwargs=kwargs, + kwargs=deepcopy(kwargs), state=state, + plugin_origin=plugin_origin, ) self.add_object(action) + if output_uuids is not None: + self.register_action_outputs(action, output_uuids) + return action def add_compute_entry_from_pp( self, @@ -1432,8 +4503,10 @@ def add_compute_entry_from_pp( pp: Any, # ProcessingParameters (avoid circular import) panel_str: str, save_state: bool = True, + output_uuids: list[str] | None = None, + plugin_origin: dict[str, Any] | None = None, **extras: Any, - ) -> None: + ) -> HistoryAction | None: """Record a *compute* action derived from a ``ProcessingParameters``. Bridges the dash-form pattern used in object metadata @@ -1447,23 +4520,329 @@ def add_compute_entry_from_pp( instance describing the operation. panel_str: ``"signal"`` or ``"image"``. save_state: If True, capture the workspace state for replay. + output_uuids: Optional list of UUIDs of the data objects produced + by this action (see :meth:`add_compute_entry`). + plugin_origin: Optional plugin origin descriptor (see + :meth:`add_compute_entry`). **extras: Additional history-only kwargs (``obj2_uuids``, ``obj2_name``, ``pairwise``, ``params``, ``func_names``…). + + Returns: + The created :class:`HistoryAction`, or ``None`` if recording is + disabled. """ hist_pattern = pp.pattern.replace("-", "_") kwargs: dict[str, Any] = {} if pp.param is not None and "param" not in extras and "params" not in extras: kwargs["param"] = pp.param kwargs.update(extras) - self.add_compute_entry( + return self.add_compute_entry( action_title, panel_str=panel_str, func_name=pp.func_name, pattern=hist_pattern, save_state=save_state, + output_uuids=output_uuids, + plugin_origin=plugin_origin, **kwargs, ) + def register_action_outputs( + self, action: HistoryAction, output_uuids: list[str] + ) -> None: + """Register the data objects produced by ``action``. + + Maintains the bijective ``action → outputs`` and ``output → action`` + mappings. May be called multiple times for a given action (later calls + replace earlier ones, e.g. after a cascade recompute). + + Args: + action: The history action that produced the outputs. + output_uuids: UUIDs of the produced data objects (empty for + ``1_to_0`` analysis patterns and for UI actions that did not + create new objects). + """ + # Drop previous outputs for this action from the reverse index. + previous = self._action_output_uuids.get(action.uuid, []) + for prev_uuid in previous: + if self._output_to_action.get(prev_uuid) == action.uuid: + self._output_to_action.pop(prev_uuid, None) + new_outputs = list(output_uuids) + # Ownership transfer: if an output_uuid already belongs to a + # *different* action, remove it from that action's output list so the + # forward mapping stays consistent. The HistoryAction object's + # ``output_uuids`` attribute is NOT updated here because traversing all + # sessions to locate the object would be expensive; the panel-level + # dicts are the source of truth. + for out_uuid in new_outputs: + old_action_uuid = self._output_to_action.get(out_uuid) + if old_action_uuid is not None and old_action_uuid != action.uuid: + old_list = self._action_output_uuids.get(old_action_uuid) + if old_list is not None: + try: + old_list.remove(out_uuid) + except ValueError: + pass + if not old_list: + del self._action_output_uuids[old_action_uuid] + _logger.debug( + "Output %s transferred from action %s to %s", + out_uuid, + old_action_uuid, + action.uuid, + ) + action.output_uuids = list(new_outputs) + self._action_output_uuids[action.uuid] = new_outputs + for out_uuid in new_outputs: + self._output_to_action[out_uuid] = action.uuid + + @contextmanager + def capture_outputs( + self, action: HistoryAction | None + ) -> Generator[None, None, None]: + """Context manager: snapshot panel object IDs and record diffs as outputs. + + Use around any compute call when the produced UUIDs are not known + upfront. On exit, every newly-added object (signal or image) is + registered as an output of ``action`` via + :meth:`register_action_outputs`. No-op when ``action`` is ``None`` + (recording disabled). + + Args: + action: The history action being processed, or ``None``. + """ + if action is None: + yield + return + panels = (self.mainwindow.signalpanel, self.mainwindow.imagepanel) + before = { + p.PANEL_STR_ID: set(p.objmodel.get_object_ids()) for p in panels + } + try: + yield + finally: + new_uuids: list[str] = [] + for p in panels: + before_p = before[p.PANEL_STR_ID] + for uid in p.objmodel.get_object_ids(): + if uid not in before_p: + new_uuids.append(uid) + self.register_action_outputs(action, new_uuids) + + def _prune_output_mapping(self) -> None: + """Drop entries of :attr:`_output_to_action` whose object no longer exists. + + Connected to each data panel's ``SIG_OBJECT_REMOVED`` so that the + reverse index stays consistent with the live workspace. The forward + ``_action_output_uuids`` mapping is intentionally left intact: it + records the *historical* outputs of each action (useful for replay + and cascade introspection even after an output was deleted). + """ + if not self._output_to_action: + return + alive: set[str] = set() + for panel in (self.mainwindow.signalpanel, self.mainwindow.imagepanel): + alive.update(panel.objmodel.get_object_ids()) + stale = [u for u in self._output_to_action if u not in alive] + for u in stale: + self._output_to_action.pop(u, None) + + def _reconnect_chain_after_removal(self, panel: BaseDataPanel) -> None: + """Reconnect the processing chain after object(s) were deleted from a + data panel, like removing a link from a linked list. + + Each deleted object that was an intermediate processing result has its + downstream consumers reconnected to its own source (the first source for + multi-source operations) and recomputed in cascade, so the chain keeps + producing consistent results. Cases that cannot be reconnected (the + deleted object has no valid source) are reported in a single warning but + the deletion is always kept. + + Connected to each data panel's ``SIG_OBJECT_REMOVED`` (bound to the + panel via ``functools.partial``). Runs before :meth:`_prune_output_mapping` + so the bijective output map is still available. + """ + pstr = panel.PANEL_STR_ID + previous = self.__obj_ids_snapshot.get(pstr, set()) + current = set(panel.objmodel.get_object_ids()) + removed = previous - current + if not removed or self.__reconnecting: + return + self.__reconnecting = True + try: + warnings: list[str] = [] + roots_to_recompute: list[HistoryAction] = [] + for x_uuid in removed: + self.__reconnect_single_removed( + panel, x_uuid, warnings, roots_to_recompute + ) + for action in roots_to_recompute: + self._recompute_action_in_place(action) + self.recompute_cascade(action) + if warnings: + QW.QMessageBox.warning( + self.mainwindow, + _("Delete"), + _( + "Some operations could not be reconnected after " + "deletion:" + ) + + "\n\n• " + + "\n• ".join(warnings), + ) + self.tree.populate_tree(self.__history_sessions) + self.refresh_compatibility_items() + self.__update_actions_state() + finally: + self.__reconnecting = False + self.__refresh_obj_ids_snapshot() + + def __reconnect_single_removed( + self, + panel: BaseDataPanel, + x_uuid: str, + warnings: list[str], + roots_to_recompute: list[HistoryAction], + ) -> None: + """Reconnect consumers of a single deleted object ``x_uuid``. + + Appends a localized message to ``warnings`` when reconnection is not + possible, and appends each consumer's producing action to + ``roots_to_recompute`` so the caller can recompute the cascade. + """ + from datalab.gui.processor.base import ( # pylint: disable=import-outside-toplevel + ProcessingParameters, + extract_processing_parameters, + insert_processing_parameters, + ) + + pstr = panel.PANEL_STR_ID + # 1. Producing action of the deleted object (to learn its source). + action_a = None + action_a_uuid = self._output_to_action.get(x_uuid) + if action_a_uuid is not None: + for session in self.__history_sessions: + for a in session.actions: + if a.uuid == action_a_uuid: + action_a = a + break + if action_a is not None: + break + # 2. Consumers: objects whose processing references x_uuid as a source. + consumers: list[tuple[Any, Any]] = [] + for obj in panel.objmodel: + pp = extract_processing_parameters(obj) + if pp is None: + continue + if pp.source_uuid == x_uuid or ( + pp.source_uuids and x_uuid in pp.source_uuids + ): + consumers.append((obj, pp)) + if not consumers: + # Leaf deletion: nothing downstream to reconnect. + return + # 3. Source S of the deleted object (first source for multi-source ops). + s_uuid: str | None = None + if action_a is not None: + sel = action_a.state.selection.get(pstr, []) + if sel: + s_uuid = sel[0] + alive_ids = set(panel.objmodel.get_object_ids()) + if s_uuid is None or s_uuid not in alive_ids: + label = ( + action_a.title + or action_a.func_name + if action_a is not None + else x_uuid + ) + warnings.append( + _( + "“%s” has dependent operations but no valid source to " + "reconnect to — downstream results are left unchanged." + ) + % label + ) + return + # 4. Reconnect each consumer: replace x_uuid -> s_uuid in its pp and in + # its producing action's recorded inputs, then queue it for recompute. + for obj, pp in consumers: + new_source_uuid = ( + s_uuid if pp.source_uuid == x_uuid else pp.source_uuid + ) + new_source_uuids = pp.source_uuids + if pp.source_uuids and x_uuid in pp.source_uuids: + new_source_uuids = [ + s_uuid if u == x_uuid else u for u in pp.source_uuids + ] + insert_processing_parameters( + obj, + ProcessingParameters( + func_name=pp.func_name, + pattern=pp.pattern, + param=pp.param, + source_uuid=new_source_uuid, + source_uuids=new_source_uuids, + ), + ) + if pp.func_name: + action_b = self.find_action_for_output( + get_uuid(obj), pp.func_name + ) + if action_b is not None: + self.__rewrite_action_source( + action_b, pstr, x_uuid, s_uuid + ) + if action_b not in roots_to_recompute: + roots_to_recompute.append(action_b) + # 5. Drop the deleted node's action if all its outputs are gone. + if action_a is not None: + outs = self._action_output_uuids.get(action_a.uuid, []) + if not any(o in alive_ids for o in outs): + self.__remove_single_action(action_a) + + def __rewrite_action_source( + self, + action: HistoryAction, + pstr: str, + old_uuid: str, + new_uuid: str, + ) -> None: + """Replace ``old_uuid`` with ``new_uuid`` in an action's recorded inputs. + + Updates both the captured selection and the ``obj2_uuids`` kwarg (for + 2_to_1 actions) so future replays/recomputes use the new source. + """ + sel = action.state.selection.get(pstr) + if sel: + action.state.selection[pstr] = [ + new_uuid if u == old_uuid else u for u in sel + ] + obj2 = action.kwargs.get("obj2_uuids") + if isinstance(obj2, str): + if obj2 == old_uuid: + action.kwargs["obj2_uuids"] = new_uuid + elif obj2: + action.kwargs["obj2_uuids"] = [ + new_uuid if u == old_uuid else u for u in obj2 + ] + + def __remove_single_action(self, action: HistoryAction) -> None: + """Remove a single action from its session (splice, not truncate). + + Also drops the action's entries from the bijective output maps, and + removes the parent session if it becomes empty. + """ + for session in self.__history_sessions: + if action in session.actions: + session.actions.remove(action) + outs = self._action_output_uuids.pop(action.uuid, []) + for out_uuid in outs: + if self._output_to_action.get(out_uuid) == action.uuid: + self._output_to_action.pop(out_uuid, None) + if not session.actions: + self.__history_sessions.remove(session) + break + def add_ui_entry( self, action_title: str, @@ -1488,12 +4867,14 @@ def add_ui_entry( state = WorkspaceState() if save_state: state.save(self.mainwindow) + # Deep-copy kwargs to ensure independent parameter ownership + # (same rationale as in add_compute_entry). action = HistoryAction( title=action_title, kind=HistoryAction.KIND_UI, target=target, method_name=method_name, - kwargs=kwargs, + kwargs=deepcopy(kwargs), state=state, ) self.add_object(action) @@ -1541,9 +4922,12 @@ def add_object(self, obj: HistoryAction) -> None: self.__history_sessions[-1].add_action(obj) self.tree.add_action_to_tree(obj) self.tree.rearrange_tree() + self.refresh_compatibility_items() self.__update_actions_state() def remove_all_objects(self): """Remove all objects""" super().remove_all_objects() + self._action_output_uuids.clear() + self._output_to_action.clear() self.__update_actions_state() diff --git a/datalab/gui/processor/base.py b/datalab/gui/processor/base.py index 5507ad00d..412b3a1fa 100644 --- a/datalab/gui/processor/base.py +++ b/datalab/gui/processor/base.py @@ -1,4 +1,4 @@ -# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ .. Base processor object (see parent package :mod:`datalab.gui.processor`) @@ -9,10 +9,12 @@ from __future__ import annotations import abc +import inspect import multiprocessing +import os.path as osp import time import warnings -from dataclasses import asdict, dataclass +from dataclasses import asdict, dataclass, field from enum import Enum, auto from multiprocessing.pool import Pool from typing import TYPE_CHECKING, Any, Callable, Generic, Literal, Optional @@ -527,6 +529,158 @@ def is_pairwise_mode() -> bool: return state +class FeatureNotFoundError(ValueError): + """Raised when a computing feature cannot be resolved by name or callable. + + Inherits from :class:`ValueError` to preserve backward compatibility with + callers that already catch ``ValueError`` on lookup failures. + + Attributes: + func_name: Name (or repr) of the missing feature. + plugin_origin: Optional plugin origin descriptor captured at registration + time. See :func:`_detect_plugin_origin` for the dict shape. + paramclass_name: Optional name of the required parameter class (for + diagnostic display). + """ + + def __init__( + self, + func_name: str, + plugin_origin: dict[str, Any] | None = None, + paramclass_name: str | None = None, + ) -> None: + self.func_name = func_name + self.plugin_origin = plugin_origin + self.paramclass_name = paramclass_name + super().__init__(self._build_message()) + + def _build_message(self) -> str: + """Build the default exception message.""" + if self.plugin_origin: + po = self.plugin_origin + param = self.paramclass_name or "—" + return ( + f"Cannot replay action: function '{self.func_name}' from plugin " + f"'{po.get('plugin_class')}' (module: {po.get('module')}, " + f"directory: {po.get('directory')}) is not available. " + f"Required parameter class: {param}. " + "Please reinstall or check the plugin." + ) + return f"Unknown computing feature: {self.func_name}" + + +# Module name prefixes considered as built-in (not plugin) origins. +_BUILTIN_MODULE_PREFIXES: tuple[str, ...] = ( + "sigima", + "datalab", + "numpy", + "scipy", + "skimage", + "guidata", + "plotpy", + "qtpy", + "builtins", + "__main__", +) + + +def _detect_plugin_origin(func: Callable) -> dict[str, Any] | None: + """Detect whether ``func`` originates from a DataLab plugin. + + Inspects ``func.__module__`` and compares it against registered plugins + (:class:`datalab.plugins.PluginRegistry`). Falls back to a heuristic for + modules that are clearly not from the DataLab/Sigima/scientific-Python + built-in surface (then treated as "anonymous" plugin origin). + + **Wrapper-aware**: when *func* is a Sigima wrapper (e.g. + ``Wrap1to1Func``), its ``__module__`` points to the wrapper class's + module (``sigima.proc.image.base``), not to the user-supplied function. + The method therefore probes ``func.__wrapped__`` (``functools.wraps`` + convention) and ``func.func`` (Sigima ``Wrap1to1Func`` / signal + ``Wrap1to1Func`` attribute) to recover the *inner* function and uses + that function's ``__module__`` for origin detection. + + Args: + func: Computation function to inspect. + + Returns: + A dict ``{"plugin_class", "module", "directory", "version"}`` if the + function originates from a plugin, otherwise ``None``. + """ + # Build a list of candidate functions to inspect, starting with the + # innermost wrapped function so that plugin origins are detected even + # when the outer callable belongs to a built-in module (e.g. sigima). + candidates: list[Callable] = [] + inner = getattr(func, "__wrapped__", None) or getattr(func, "func", None) + if inner is not None and callable(inner): + candidates.append(inner) + candidates.append(func) + + module_name = "" + for candidate in candidates: + mod = getattr(candidate, "__module__", "") or "" + if mod: + top = mod.split(".", 1)[0] + if top not in _BUILTIN_MODULE_PREFIXES: + module_name = mod + break + if not module_name: + # All candidates are built-in; fall back to the outer func's module + # so the rest of the logic can still run (and return None). + module_name = getattr(func, "__module__", "") or "" + if not module_name: + return None + # Local import to avoid a circular dependency at module load time. + try: + from datalab.plugins import ( # pylint: disable=import-outside-toplevel + PluginRegistry, + ) + except ImportError: + PluginRegistry = None # type: ignore[assignment] + + if PluginRegistry is not None: + for plugin in PluginRegistry.get_plugins(): + plugin_module = plugin.__class__.__module__ + if module_name == plugin_module or module_name.startswith( + plugin_module + "." + ): + directory: str | None = None + try: + directory = osp.basename( + osp.dirname(inspect.getfile(plugin.__class__)) + ) + except (TypeError, OSError): + pass + version: str | None = None + info = getattr(plugin, "info", None) + if info is not None: + version = getattr(info, "version", None) + return { + "plugin_class": plugin.__class__.__name__, + "module": module_name, + "directory": directory, + "version": version, + } + + # Heuristic fallback: anything not from a known built-in prefix is + # treated as an anonymous plugin origin (e.g. user macros, third-party + # functions wrapped through ``compute_1_to_1`` directly). + top = module_name.split(".", 1)[0] + if top and top not in _BUILTIN_MODULE_PREFIXES: + directory = None + try: + directory = osp.basename(osp.dirname(inspect.getfile(func))) + except (TypeError, OSError): + pass + return { + "plugin_class": None, + "module": module_name, + "directory": directory, + "version": None, + } + return None + + @dataclass class ComputingFeature: """Computing feature dataclass. @@ -541,6 +695,9 @@ class ComputingFeature: edit: whether to edit the parameters obj2_name: name of the second object skip_xarray_compat: whether to skip X-array compatibility check for this feature + plugin_origin: optional plugin origin descriptor (auto-detected at + :meth:`BaseProcessor.add_feature` time). ``None`` for built-in + (Sigima/DataLab) features. """ pattern: Literal["1_to_1", "1_to_0", "1_to_n", "n_to_1", "2_to_1"] @@ -552,6 +709,7 @@ class ComputingFeature: edit: Optional[bool] = None obj2_name: Optional[str] = None skip_xarray_compat: Optional[bool] = None + plugin_origin: Optional[dict[str, Any]] = field(default=None) def __post_init__(self): """Validate the function after initialization.""" @@ -776,6 +934,9 @@ def _add_object_to_appropriate_panel( If False, non-native objects are added to default group. Set to False when group_id is from the source panel and object goes to a different panel. """ + hpanel = getattr(self.mainwindow, "historypanel", None) + if hpanel is not None and hpanel.is_output_suppressed(): + return is_new_obj_native = isinstance(new_obj, self.panel.PARAMCLASS) if is_new_obj_native: self.panel.add_object(new_obj, group_id=group_id) @@ -787,7 +948,7 @@ def _add_object_to_appropriate_panel( def _create_group_for_result( self, new_obj: SignalObj | ImageObj, group_name: str - ) -> str: + ) -> str | None: """Create a group in the appropriate panel for the result object. For native objects, creates group in current panel. For non-native objects, @@ -798,8 +959,11 @@ def _create_group_for_result( group_name: Name for the new group Returns: - UUID of the created group + UUID of the created group. """ + hpanel = getattr(self.mainwindow, "historypanel", None) + if hpanel is not None and hpanel.is_output_suppressed(): + return None is_new_obj_native = isinstance(new_obj, self.panel.PARAMCLASS) if is_new_obj_native: return get_uuid(self.panel.add_group(group_name)) @@ -1030,7 +1194,7 @@ def auto_recompute_analysis( feature = self.get_feature(proc_params.func_name) # Recompute the analysis operation silently, only for this specific object - # (not all selected objects, to avoid O(n²) behavior when called in a loop). + # (not all selected objects, to avoid O(n²) behavior when called in a loop). # The history-panel ``replaying()`` guard suppresses the synthetic entry # that ``compute_1_to_0`` would otherwise add for this internally-triggered # recomputation. @@ -1083,6 +1247,7 @@ def recompute_1_to_1( func_name: str, obj: SignalObj | ImageObj, param: gds.DataSet | None = None, + plugin_origin: dict[str, Any] | None = None, ) -> SignalObj | ImageObj | None: """Recompute a 1-to-1 processing operation without adding result to panel. @@ -1095,18 +1260,22 @@ def recompute_1_to_1( func_name: Name of the processing function obj: Source object to process param: Processing parameters (optional) + plugin_origin: Optional plugin origin descriptor (propagated to + :meth:`get_feature` for richer error reporting). Returns: New processed object (not added to panel), or None if cancelled or error Raises: - ValueError: If function is not found in registry + FeatureNotFoundError: If function is not found in registry. """ # Get the function from the registry - try: - feature = self.get_feature(func_name) - except ValueError as exc: - raise ValueError(f"Function '{func_name}' not found in registry") from exc + paramclass_name = type(param).__name__ if param is not None else None + feature = self.get_feature( + func_name, + plugin_origin=plugin_origin, + paramclass_name=paramclass_name, + ) func = feature.function @@ -1135,6 +1304,185 @@ def recompute_1_to_1( patch_title_with_ids(new_obj, [obj], get_short_id) return new_obj + # ------------------------------------------------------------------ + # In-place recompute helpers used by the History panel cascade + # (Edit mode tweaks + downstream propagation). They mirror their + # ``compute_*`` counterparts but: + # - never add results to a panel (caller updates targets in place); + # - never record a history entry (cascade runs under ``replaying``); + # - never insert :class:`ProcessingParameters` (caller does, so that + # ``source_uuid`` / ``source_uuids`` stay consistent with the + # existing output object identity). + # ------------------------------------------------------------------ + + def recompute_1_to_n( + self, + func_name: str, + obj: SignalObj | ImageObj, + params: list[gds.DataSet], + plugin_origin: dict[str, Any] | None = None, + ) -> list[SignalObj | ImageObj] | None: + """Recompute a 1-to-n processing operation without adding results to panel. + + Args: + func_name: Name of the processing function. + obj: Source object to process. + params: List of N parameter datasets (one per output). + plugin_origin: Optional plugin origin descriptor. + + Returns: + List of N new objects (in input order), or ``None`` if cancelled + or an unrecoverable error occurred. Shorter lists are possible + when individual sub-calls return ``None``. + """ + paramclass_name = ( + type(params[0]).__name__ if params and params[0] is not None else None + ) + feature = self.get_feature( + func_name, + plugin_origin=plugin_origin, + paramclass_name=paramclass_name, + ) + func = feature.function + results: list[SignalObj | ImageObj] = [] + with create_progress_bar( + self.panel, _("Recomputing..."), max_=len(params) + ) as progress: + for idx, param in enumerate(params): + progress.setValue(idx) + progress.setLabelText(_("Processing object with updated parameters...")) + args = (obj, param) if param is not None else (obj,) + comp_out = self.__exec_func(func, args, progress) + if comp_out is None: + return None + new_obj = self.handle_output(comp_out, _("Recomputing"), progress) + if new_obj is None: + continue + if isinstance(new_obj, (SignalObj, ImageObj)): + self._handle_keep_results(new_obj) + patch_title_with_ids(new_obj, [obj], get_short_id) + results.append(new_obj) + return results + + def recompute_n_to_1( + self, + func_name: str, + objs: list[SignalObj | ImageObj], + param: gds.DataSet | None = None, + plugin_origin: dict[str, Any] | None = None, + ) -> SignalObj | ImageObj | None: + """Recompute an n-to-1 processing operation without adding result to panel. + + Args: + func_name: Name of the processing function. + objs: Source object list to aggregate. + param: Processing parameters (optional). + plugin_origin: Optional plugin origin descriptor. + + Returns: + New aggregated object, or ``None`` if cancelled / errored. + + .. note:: + Pairwise mode is not handled here: each pairwise output is a + distinct single-output recompute -- the caller is expected to + split the work per output and iterate. + """ + paramclass_name = type(param).__name__ if param is not None else None + feature = self.get_feature( + func_name, + plugin_origin=plugin_origin, + paramclass_name=paramclass_name, + ) + func = feature.function + with create_progress_bar(self.panel, _("Recomputing..."), max_=1) as progress: + progress.setValue(0) + progress.setLabelText(_("Processing object with updated parameters...")) + args = (objs, param) if param is not None else (objs,) + comp_out = self.__exec_func(func, args, progress) + if comp_out is None: + return None + new_obj = self.handle_output(comp_out, _("Recomputing"), progress) + if new_obj is None: + return None + if isinstance(new_obj, (SignalObj, ImageObj)): + self._handle_keep_results(new_obj) + self._merge_geometry_results_for_n_to_1(new_obj, objs) + patch_title_with_ids(new_obj, objs, get_short_id) + return new_obj + + def recompute_2_to_1( + self, + func_name: str, + obj1: SignalObj | ImageObj, + obj2: SignalObj | ImageObj, + param: gds.DataSet | None = None, + plugin_origin: dict[str, Any] | None = None, + ) -> SignalObj | ImageObj | None: + """Recompute a 2-to-1 processing operation without adding result to panel. + + Args: + func_name: Name of the processing function. + obj1: First source object. + obj2: Second source object. + param: Processing parameters (optional). + plugin_origin: Optional plugin origin descriptor. + + Returns: + New combined object, or ``None`` if cancelled / errored. + """ + paramclass_name = type(param).__name__ if param is not None else None + feature = self.get_feature( + func_name, + plugin_origin=plugin_origin, + paramclass_name=paramclass_name, + ) + func = feature.function + with create_progress_bar(self.panel, _("Recomputing..."), max_=1) as progress: + progress.setValue(0) + progress.setLabelText(_("Processing object with updated parameters...")) + args = (obj1, obj2, param) if param is not None else (obj1, obj2) + comp_out = self.__exec_func(func, args, progress) + if comp_out is None: + return None + new_obj = self.handle_output(comp_out, _("Recomputing"), progress) + if new_obj is None: + return None + if isinstance(new_obj, (SignalObj, ImageObj)): + self._handle_keep_results(new_obj) + patch_title_with_ids(new_obj, [obj1, obj2], get_short_id) + return new_obj + + def recompute_1_to_0( + self, + func_name: str, + obj: SignalObj | ImageObj, + param: gds.DataSet | None = None, + plugin_origin: dict[str, Any] | None = None, + ) -> None: + """Recompute a 1-to-0 analysis on ``obj`` in place. + + Reuses :meth:`compute_1_to_0` with ``target_objs=[obj]`` under the + history-panel ``replaying`` guard so no synthetic history entry is + recorded. The analysis result is written to ``obj``'s metadata. + + Args: + func_name: Name of the analysis function. + obj: Object whose analysis must be refreshed. + param: Analysis parameters (optional). + plugin_origin: Optional plugin origin descriptor. + """ + paramclass_name = type(param).__name__ if param is not None else None + feature = self.get_feature( + func_name, + plugin_origin=plugin_origin, + paramclass_name=paramclass_name, + ) + historypanel = self.mainwindow.historypanel + with historypanel.replaying(), Conf.proc.show_result_dialog.temp(False): + self.compute_1_to_0( + feature.function, param, edit=False, target_objs=[obj] + ) + def _compute_1_to_1_subroutine( self, funcs: list[Callable], params: list, title: str ) -> None: @@ -1279,7 +1627,7 @@ def compute_1_to_1( comment: str | None = None, edit: bool | None = None, ) -> None: - """Generic processing method: 1 object in → 1 object out. + """Generic processing method: 1 object in → 1 object out. Applies a function independently to each selected object in the active panel. The result of each computation is a new object appended to the same panel. @@ -1310,10 +1658,14 @@ def compute_1_to_1( if edit and not param.edit(parent=self.mainwindow): return pp = build_processing_parameters(func.__name__, "1-to-1", param=param) - self.mainwindow.historypanel.add_compute_entry_from_pp( - title or func.__name__, pp, panel_str=self.panel.PANEL_STR_ID + action = self.mainwindow.historypanel.add_compute_entry_from_pp( + title or func.__name__, + pp, + panel_str=self.panel.PANEL_STR_ID, + plugin_origin=self._get_plugin_origin_for(func), ) - self._compute_1_to_1_subroutine([func], [param], title) + with self.mainwindow.historypanel.capture_outputs(action): + self._compute_1_to_1_subroutine([func], [param], title) def compute_multiple_1_to_1( self, @@ -1322,7 +1674,7 @@ def compute_multiple_1_to_1( title: str | None = None, edit: bool | None = None, ) -> None: - """Generic processing method: 1 object in → n objects out. + """Generic processing method: 1 object in → n objects out. Applies multiple functions to each selected object, generating multiple outputs per object. The resulting objects are appended to the active panel. @@ -1338,7 +1690,7 @@ def compute_multiple_1_to_1( .. note:: With k selected objects and n outputs per function, - the method produces k × n outputs. + the method produces k × n outputs. .. note:: This method does not support pairwise mode. @@ -1354,14 +1706,18 @@ def compute_multiple_1_to_1( pp = build_processing_parameters( funcs[0].__name__ if funcs else "", "multiple-1-to-1" ) - self.mainwindow.historypanel.add_compute_entry_from_pp( + action = self.mainwindow.historypanel.add_compute_entry_from_pp( title or "compute_multiple_1_to_1", pp, panel_str=self.panel.PANEL_STR_ID, func_names=[f.__name__ for f in funcs], params=params if any(p is not None for p in params) else None, + plugin_origin=( + self._get_plugin_origin_for(funcs[0]) if funcs else None + ), ) - self._compute_1_to_1_subroutine(funcs, params, title) + with self.mainwindow.historypanel.capture_outputs(action): + self._compute_1_to_1_subroutine(funcs, params, title) def compute_1_to_n( self, @@ -1370,7 +1726,7 @@ def compute_1_to_n( title: str | None = None, edit: bool | None = None, ) -> None: - """Generic processing method: 1 object in → n objects out. + """Generic processing method: 1 object in → n objects out. Applies a single function to each selected object, with n different parameters set, thus generating n outputs per object. The resulting objects are appended to @@ -1387,7 +1743,7 @@ def compute_1_to_n( .. note:: With k selected objects and n parameter sets, - the method produces k × n outputs. + the method produces k × n outputs. .. note:: This method does not support pairwise mode. @@ -1398,13 +1754,15 @@ def compute_1_to_n( if not group.edit(parent=self.mainwindow): return pp = build_processing_parameters(func.__name__, "1-to-n") - self.mainwindow.historypanel.add_compute_entry_from_pp( + action = self.mainwindow.historypanel.add_compute_entry_from_pp( title or func.__name__, pp, panel_str=self.panel.PANEL_STR_ID, params=params, + plugin_origin=self._get_plugin_origin_for(func), ) - self._compute_1_to_1_subroutine([func] * len(params), params, title) + with self.mainwindow.historypanel.capture_outputs(action): + self._compute_1_to_1_subroutine([func] * len(params), params, title) def compute_1_to_0( self, @@ -1416,7 +1774,7 @@ def compute_1_to_0( edit: bool | None = None, target_objs: list[SignalObj | ImageObj] | None = None, ) -> ResultData: - """Generic processing method: 1 object in → no object out. + """Generic processing method: 1 object in → no object out. Applies a function to each selected object (or specified target objects), returning metadata or measurement results (e.g. peak coordinates, statistical @@ -1459,9 +1817,16 @@ def compute_1_to_0( current_obj = self.panel.objview.get_current_object() title = func.__name__ if title is None else title pp_history = build_processing_parameters(func.__name__, "1-to-0", param=param) - self.mainwindow.historypanel.add_compute_entry_from_pp( - title, pp_history, panel_str=self.panel.PANEL_STR_ID + action = self.mainwindow.historypanel.add_compute_entry_from_pp( + title, + pp_history, + panel_str=self.panel.PANEL_STR_ID, + plugin_origin=self._get_plugin_origin_for(func), ) + # 1-to-0: no data object is produced. Register an empty output list so + # the bijective mapping records the action even with zero outputs. + if action is not None: + self.mainwindow.historypanel.register_action_outputs(action, []) refresh_needed = False with create_progress_bar(self.panel, title, max_=len(objs)) as progress: rdata = ResultData() @@ -1539,7 +1904,7 @@ def compute_n_to_1( edit: bool | None = None, pairwise: bool | None = None, ) -> None: - """Generic processing method: n objects in → 1 object out. + """Generic processing method: n objects in → 1 object out. Aggregates multiple selected objects into a single result using the provided function. In pairwise mode, applies the function to object pairs (grouped by @@ -1574,202 +1939,211 @@ def compute_n_to_1( name = func.__name__ pp_history = build_processing_parameters(name, "n-to-1", param=param) - self.mainwindow.historypanel.add_compute_entry_from_pp( + action = self.mainwindow.historypanel.add_compute_entry_from_pp( name, pp_history, panel_str=self.panel.PANEL_STR_ID, pairwise=pairwise, + plugin_origin=self._get_plugin_origin_for(func), ) - if pairwise: - src_grps, src_gids, src_objs, _nbobj, valid = ( - self.__get_src_grps_gids_objs_nbobj_valid(min_group_nb=2) - ) - if not valid: - return - dst_gname = ( - f"{name}({','.join([get_short_id(grp) for grp in src_grps])})|pairwise" - ) - group_exclusive = len(self.panel.objview.get_sel_groups()) != 0 - if not group_exclusive: - # This is not a group exclusive selection - dst_gname += "[...]" - # Delay group creation until after first result to determine target panel - dst_gid = None - n_pairs = len(src_objs[src_gids[0]]) - max_i_pair = min( - n_pairs, max(len(src_objs[get_uuid(grp)]) for grp in src_grps) - ) - # Track "Yes to All" choice for this compute operation - auto_interpolate_for_operation = False + with self.mainwindow.historypanel.capture_outputs(action): + if pairwise: + src_grps, src_gids, src_objs, _nbobj, valid = ( + self.__get_src_grps_gids_objs_nbobj_valid(min_group_nb=2) + ) + if not valid: + return + dst_gname = ( + f"{name}({','.join([get_short_id(grp) for grp in src_grps])})" + "|pairwise" + ) + group_exclusive = len(self.panel.objview.get_sel_groups()) != 0 + if not group_exclusive: + # This is not a group exclusive selection + dst_gname += "[...]" + # Delay group creation until after first result + # to determine target panel + dst_gid = None + n_pairs = len(src_objs[src_gids[0]]) + max_i_pair = min( + n_pairs, max(len(src_objs[get_uuid(grp)]) for grp in src_grps) + ) + # Track "Yes to All" choice for this compute operation + auto_interpolate_for_operation = False - with create_progress_bar(self.panel, title, max_=n_pairs) as progress: - for i_pair, src_obj1 in enumerate(src_objs[src_gids[0]][:max_i_pair]): - progress.setValue(i_pair + 1) - progress.setLabelText(title) - src_objs_pair = [src_obj1] - for src_gid in src_gids[1:]: - src_obj = src_objs[src_gid][i_pair] - src_objs_pair.append(src_obj) - - # Check signal x-array compatibility for n-to-1 operations - if auto_interpolate_for_operation: - # "Yes to All" selected, automatically interpolate - # by temporarily changing the configuration - with Conf.proc.xarray_compat_behavior.temp("interpolate"): + with create_progress_bar(self.panel, title, max_=n_pairs) as progress: + for i_pair, src_obj1 in enumerate( + src_objs[src_gids[0]][:max_i_pair] + ): + progress.setValue(i_pair + 1) + progress.setLabelText(title) + src_objs_pair = [src_obj1] + for src_gid in src_gids[1:]: + src_obj = src_objs[src_gid][i_pair] + src_objs_pair.append(src_obj) + + # Check signal x-array compatibility for n-to-1 operations + if auto_interpolate_for_operation: + # "Yes to All" selected, automatically interpolate + # by temporarily changing the configuration + with Conf.proc.xarray_compat_behavior.temp("interpolate"): + result = self._check_signal_xarray_compatibility( + src_objs_pair, progress=progress + ) + else: + # Normal compatibility check with dialog result = self._check_signal_xarray_compatibility( src_objs_pair, progress=progress ) - else: - # Normal compatibility check with dialog - result = self._check_signal_xarray_compatibility( - src_objs_pair, progress=progress - ) - if result is None: - # User canceled or compatibility check failed - return - - checked_objs, yes_to_all_selected = result - if yes_to_all_selected: - auto_interpolate_for_operation = True - - src_objs_pair = checked_objs - if param is None: - args = (src_objs_pair,) - else: - args = (src_objs_pair, param) - result = self.__exec_func(func, args, progress) - if result is None: - break - new_obj = self.handle_output( - result, _("Calculating: %s") % title, progress - ) - if new_obj is None: - break - assert isinstance(new_obj, (SignalObj, ImageObj)) + if result is None: + # User canceled or compatibility check failed + return + + checked_objs, yes_to_all_selected = result + if yes_to_all_selected: + auto_interpolate_for_operation = True + + src_objs_pair = checked_objs + if param is None: + args = (src_objs_pair,) + else: + args = (src_objs_pair, param) + result = self.__exec_func(func, args, progress) + if result is None: + break + new_obj = self.handle_output( + result, _("Calculating: %s") % title, progress + ) + if new_obj is None: + break + assert isinstance(new_obj, (SignalObj, ImageObj)) - patch_title_with_ids(new_obj, src_objs_pair, get_short_id) + patch_title_with_ids(new_obj, src_objs_pair, get_short_id) - # Handle keep_results and geometry result merging - self._handle_keep_results(new_obj) - self._merge_geometry_results_for_n_to_1(new_obj, src_objs_pair) + # Handle keep_results and geometry result merging + self._handle_keep_results(new_obj) + self._merge_geometry_results_for_n_to_1(new_obj, src_objs_pair) - # Store lightweight processing metadata (non-interactive) - proc_params = ProcessingParameters( - func_name=name, - pattern="n-to-1", - param=param, - source_uuids=[get_uuid(obj) for obj in src_objs_pair], - ) - insert_processing_parameters(new_obj, proc_params) + # Store lightweight processing metadata (non-interactive) + proc_params = ProcessingParameters( + func_name=name, + pattern="n-to-1", + param=param, + source_uuids=[get_uuid(obj) for obj in src_objs_pair], + ) + insert_processing_parameters(new_obj, proc_params) - # Create destination group on first result, in appropriate panel - if dst_gid is None: - dst_gid = self._create_group_for_result(new_obj, dst_gname) + # Create destination group on first result, in appropriate panel + if dst_gid is None: + dst_gid = self._create_group_for_result(new_obj, dst_gname) - self._add_object_to_appropriate_panel(new_obj, group_id=dst_gid) + self._add_object_to_appropriate_panel(new_obj, group_id=dst_gid) - else: - # In single operand mode, we create a single object for all selected objects + else: + # In single operand mode, we create a single object + # for all selected objects + + # [src_objs dictionary] keys: old group id, values: list of old objects + src_objs: dict[str, list[SignalObj | ImageObj]] = {} + + grps = self.panel.objview.get_sel_groups() + dst_group_name = None + if grps: + # (Group exclusive selection) + # At least one group is selected: create a new group + dst_gname = f"{name}({','.join([get_uuid(grp) for grp in grps])})" + # Delay group creation until after first result + dst_gid = None + dst_group_name = dst_gname # Store name for later use + else: + # (Object exclusive selection) + # No group is selected: use each object's group + dst_gid = None - # [src_objs dictionary] keys: old group id, values: list of old objects - src_objs: dict[str, list[SignalObj | ImageObj]] = {} + for src_obj in objs: + src_gid = objmodel.get_object_group_id(src_obj) + src_objs.setdefault(src_gid, []).append(src_obj) - grps = self.panel.objview.get_sel_groups() - dst_group_name = None - if grps: - # (Group exclusive selection) - # At least one group is selected: create a new group - dst_gname = f"{name}({','.join([get_uuid(grp) for grp in grps])})" - # Delay group creation until after first result - dst_gid = None - dst_group_name = dst_gname # Store name for later use - else: - # (Object exclusive selection) - # No group is selected: use each object's group - dst_gid = None + # Track "Yes to All" choice for this compute operation + auto_interpolate_for_operation = False - for src_obj in objs: - src_gid = objmodel.get_object_group_id(src_obj) - src_objs.setdefault(src_gid, []).append(src_obj) - - # Track "Yes to All" choice for this compute operation - auto_interpolate_for_operation = False - - with create_progress_bar(self.panel, title, max_=len(objs)) as progress: - progress.setValue(0) - progress.setLabelText(title) - for src_gid, src_obj_list in src_objs.items(): - # Check signal x-array compatibility for n-to-1 operations - if auto_interpolate_for_operation: - # "Yes to All" selected, automatically interpolate - with Conf.proc.xarray_compat_behavior.temp("interpolate"): + with create_progress_bar(self.panel, title, max_=len(objs)) as progress: + progress.setValue(0) + progress.setLabelText(title) + for src_gid, src_obj_list in src_objs.items(): + # Check signal x-array compatibility for n-to-1 operations + if auto_interpolate_for_operation: + # "Yes to All" selected, automatically interpolate + with Conf.proc.xarray_compat_behavior.temp("interpolate"): + result = self._check_signal_xarray_compatibility( + src_obj_list, progress=progress + ) + else: + # Normal compatibility check with dialog result = self._check_signal_xarray_compatibility( src_obj_list, progress=progress ) - else: - # Normal compatibility check with dialog - result = self._check_signal_xarray_compatibility( - src_obj_list, progress=progress - ) - if result is None: - # User canceled or compatibility check failed - return + if result is None: + # User canceled or compatibility check failed + return - checked_objs, yes_to_all_selected = result - if yes_to_all_selected: - auto_interpolate_for_operation = True + checked_objs, yes_to_all_selected = result + if yes_to_all_selected: + auto_interpolate_for_operation = True - src_obj_list = checked_objs + src_obj_list = checked_objs - if param is None: - args = (src_obj_list,) - else: - args = (src_obj_list, param) - result = self.__exec_func(func, args, progress) - if result is None: - break - new_obj = self.handle_output( - result, _("Calculating: %s") % title, progress - ) - if new_obj is None: - break - assert isinstance(new_obj, (SignalObj, ImageObj)) + if param is None: + args = (src_obj_list,) + else: + args = (src_obj_list, param) + result = self.__exec_func(func, args, progress) + if result is None: + break + new_obj = self.handle_output( + result, _("Calculating: %s") % title, progress + ) + if new_obj is None: + break + assert isinstance(new_obj, (SignalObj, ImageObj)) - group_id = dst_gid if dst_gid is not None else src_gid - patch_title_with_ids(new_obj, src_obj_list, get_short_id) + group_id = dst_gid if dst_gid is not None else src_gid + patch_title_with_ids(new_obj, src_obj_list, get_short_id) - # Handle keep_results and geometry result merging - self._handle_keep_results(new_obj) - self._merge_geometry_results_for_n_to_1(new_obj, src_obj_list) + # Handle keep_results and geometry result merging + self._handle_keep_results(new_obj) + self._merge_geometry_results_for_n_to_1(new_obj, src_obj_list) - # Store lightweight processing metadata (non-interactive) - proc_params = ProcessingParameters( - func_name=name, - pattern="n-to-1", - param=param, - source_uuids=[get_uuid(obj) for obj in src_obj_list], - ) - insert_processing_parameters(new_obj, proc_params) + # Store lightweight processing metadata (non-interactive) + proc_params = ProcessingParameters( + func_name=name, + pattern="n-to-1", + param=param, + source_uuids=[get_uuid(obj) for obj in src_obj_list], + ) + insert_processing_parameters(new_obj, proc_params) - # Create destination group on first result, in appropriate panel - use_group_for_non_native = False - if dst_gid is None and dst_group_name is not None: - dst_gid = self._create_group_for_result(new_obj, dst_group_name) - group_id = dst_gid - use_group_for_non_native = True + # Create destination group on first result, in appropriate panel + use_group_for_non_native = False + if dst_gid is None and dst_group_name is not None: + dst_gid = self._create_group_for_result( + new_obj, dst_group_name + ) + group_id = dst_gid + use_group_for_non_native = True - self._add_object_to_appropriate_panel( - new_obj, - group_id=group_id, - use_group_for_non_native=use_group_for_non_native, - ) + self._add_object_to_appropriate_panel( + new_obj, + group_id=group_id, + use_group_for_non_native=use_group_for_non_native, + ) - # Select newly created group, if any - if dst_gid is not None: - self.panel.objview.set_current_item_id(dst_gid) + # Select newly created group, if any + if dst_gid is not None: + self.panel.objview.set_current_item_id(dst_gid) def compute_2_to_1( self, @@ -1789,7 +2163,7 @@ def compute_2_to_1( skip_xarray_compat: bool | None = None, pairwise: bool | None = None, ) -> None: - """Generic processing method: binary operation 1+1 → 1. + """Generic processing method: binary operation 1+1 → 1. Applies a binary function between each selected object and a second operand. Supports both single operand mode (same operand for all objects) @@ -1866,126 +2240,135 @@ def compute_2_to_1( pp_history = build_processing_parameters( func.__name__, "2-to-1", param=param ) - self.mainwindow.historypanel.add_compute_entry_from_pp( + action = self.mainwindow.historypanel.add_compute_entry_from_pp( title or func.__name__, pp_history, panel_str=self.panel.PANEL_STR_ID, obj2_uuids=[get_uuid(obj) for obj in objs2], obj2_name=obj2_name, pairwise=True, + plugin_origin=self._get_plugin_origin_for(func), ) - n_pairs = len(src_objs[src_gids[0]]) - max_i_pair = min( - n_pairs, max(len(src_objs[get_uuid(grp)]) for grp in src_grps) - ) - grp2_id = objmodel.get_object_group_id(objs2[0]) - grp2 = objmodel.get_group(grp2_id) - - # Initialize pair mapping for potential interpolations - pair_maps = {} - - # Check x-array compatibility for signal processing (pairwise mode) - if self._is_signal_panel() and not skip_xarray_compat: - # Check compatibility between objects from both groups - all_pairs = [] - for src_gid in src_gids: - for i_pair in range(max_i_pair): - src_obj1 = src_objs[src_gid][i_pair] - src_obj2 = objs2[i_pair] - if isinstance(src_obj1, SignalObj) and isinstance( - src_obj2, SignalObj - ): - all_pairs.append((src_obj1, src_obj2)) - - # Track "Yes to All" choice for this compute operation - auto_interpolate_for_operation = False - - # Check all pairs for compatibility and create interpolation maps - for src_obj1, src_obj2 in all_pairs: - if auto_interpolate_for_operation: - # "Yes to All" selected, automatically interpolate - with Conf.proc.xarray_compat_behavior.temp("interpolate"): + with self.mainwindow.historypanel.capture_outputs(action): + n_pairs = len(src_objs[src_gids[0]]) + max_i_pair = min( + n_pairs, max(len(src_objs[get_uuid(grp)]) for grp in src_grps) + ) + grp2_id = objmodel.get_object_group_id(objs2[0]) + grp2 = objmodel.get_group(grp2_id) + + # Initialize pair mapping for potential interpolations + pair_maps = {} + + # Check x-array compatibility for signal processing (pairwise mode) + if self._is_signal_panel() and not skip_xarray_compat: + # Check compatibility between objects from both groups + all_pairs = [] + for src_gid in src_gids: + for i_pair in range(max_i_pair): + src_obj1 = src_objs[src_gid][i_pair] + src_obj2 = objs2[i_pair] + if isinstance(src_obj1, SignalObj) and isinstance( + src_obj2, SignalObj + ): + all_pairs.append((src_obj1, src_obj2)) + + # Track "Yes to All" choice for this compute operation + auto_interpolate_for_operation = False + + # Check all pairs for compatibility and create interpolation maps + for src_obj1, src_obj2 in all_pairs: + if auto_interpolate_for_operation: + # "Yes to All" selected, automatically interpolate + with Conf.proc.xarray_compat_behavior.temp("interpolate"): + result = self._check_signal_xarray_compatibility( + [src_obj1, src_obj2] + ) + else: + # Normal compatibility check with dialog result = self._check_signal_xarray_compatibility( [src_obj1, src_obj2] ) - else: - # Normal compatibility check with dialog - result = self._check_signal_xarray_compatibility( - [src_obj1, src_obj2] - ) - - if result is None: - return # User cancelled or error occurred - checked_pair, yes_to_all_selected = result - if yes_to_all_selected: - auto_interpolate_for_operation = True - - # Store mapping for this specific pair - pair_maps[(src_obj1, src_obj2)] = checked_pair - - with create_progress_bar(self.panel, title, max_=len(src_gids)) as progress: - for i_group, src_gid in enumerate(src_gids): - progress.setValue(i_group + 1) - progress.setLabelText(title) - if group_exclusive: - # This is a group exclusive selection - src_grp = objmodel.get_group(src_gid) - grp_short_ids = [get_uuid(grp) for grp in (src_grp, grp2)] - dst_gname = f"{name}({','.join(grp_short_ids)})|pairwise" - else: - dst_gname = f"{name}[...]" - # Delay group creation until after first result - dst_gid = None - for i_pair in range(max_i_pair): - orig_obj1, orig_obj2 = src_objs[src_gid][i_pair], objs2[i_pair] - - # Use interpolated signals if available, keep original refs - actual_obj1, actual_obj2 = orig_obj1, orig_obj2 - if (orig_obj1, orig_obj2) in pair_maps: - interpolated_pair = pair_maps[(orig_obj1, orig_obj2)] - actual_obj1 = interpolated_pair[0] - actual_obj2 = interpolated_pair[1] - - args = [actual_obj1, actual_obj2] - if param is not None: - args.append(param) - result = self.__exec_func(func, tuple(args), progress) if result is None: - break - new_obj = self.handle_output( - result, _("Calculating: %s") % title, progress - ) - if new_obj is None: - continue - assert isinstance(new_obj, (SignalObj, ImageObj)) - - # Use original objects for title generation - patch_title_with_ids( - new_obj, [orig_obj1, orig_obj2], get_short_id - ) + return # User cancelled or error occurred + + checked_pair, yes_to_all_selected = result + if yes_to_all_selected: + auto_interpolate_for_operation = True + + # Store mapping for this specific pair + pair_maps[(src_obj1, src_obj2)] = checked_pair + + with create_progress_bar( + self.panel, title, max_=len(src_gids) + ) as progress: + for i_group, src_gid in enumerate(src_gids): + progress.setValue(i_group + 1) + progress.setLabelText(title) + if group_exclusive: + # This is a group exclusive selection + src_grp = objmodel.get_group(src_gid) + grp_short_ids = [get_uuid(grp) for grp in (src_grp, grp2)] + dst_gname = f"{name}({','.join(grp_short_ids)})|pairwise" + else: + dst_gname = f"{name}[...]" + # Delay group creation until after first result + dst_gid = None + for i_pair in range(max_i_pair): + orig_obj1 = src_objs[src_gid][i_pair] + orig_obj2 = objs2[i_pair] + + # Use interpolated signals if available, keep original refs + actual_obj1, actual_obj2 = orig_obj1, orig_obj2 + if (orig_obj1, orig_obj2) in pair_maps: + interpolated_pair = pair_maps[(orig_obj1, orig_obj2)] + actual_obj1 = interpolated_pair[0] + actual_obj2 = interpolated_pair[1] + + args = [actual_obj1, actual_obj2] + if param is not None: + args.append(param) + result = self.__exec_func(func, tuple(args), progress) + if result is None: + break + new_obj = self.handle_output( + result, _("Calculating: %s") % title, progress + ) + if new_obj is None: + continue + assert isinstance(new_obj, (SignalObj, ImageObj)) - # Handle keep_results logic for 2_to_1 operations - self._handle_keep_results(new_obj) + # Use original objects for title generation + patch_title_with_ids( + new_obj, [orig_obj1, orig_obj2], get_short_id + ) - # Store lightweight processing metadata (non-interactive) - proc_params = ProcessingParameters( - func_name=name, - pattern="2-to-1", - param=param, - source_uuids=[ - get_uuid(orig_obj1), - get_uuid(orig_obj2), - ], - ) - insert_processing_parameters(new_obj, proc_params) + # Handle keep_results logic for 2_to_1 operations + self._handle_keep_results(new_obj) + + # Store lightweight processing metadata (non-interactive) + proc_params = ProcessingParameters( + func_name=name, + pattern="2-to-1", + param=param, + source_uuids=[ + get_uuid(orig_obj1), + get_uuid(orig_obj2), + ], + ) + insert_processing_parameters(new_obj, proc_params) - # Create destination group on first result, in appropriate panel - if dst_gid is None: - dst_gid = self._create_group_for_result(new_obj, dst_gname) + # Create dest group on first result + if dst_gid is None: + dst_gid = self._create_group_for_result( + new_obj, dst_gname + ) - self._add_object_to_appropriate_panel(new_obj, group_id=dst_gid) + self._add_object_to_appropriate_panel( + new_obj, group_id=dst_gid + ) else: if not objs2: @@ -2003,97 +2386,103 @@ def compute_2_to_1( pp_history = build_processing_parameters( func.__name__, "2-to-1", param=param ) - self.mainwindow.historypanel.add_compute_entry_from_pp( + action = self.mainwindow.historypanel.add_compute_entry_from_pp( title or func.__name__, pp_history, panel_str=self.panel.PANEL_STR_ID, obj2_uuids=[get_uuid(obj2)], obj2_name=obj2_name, pairwise=False, + plugin_origin=self._get_plugin_origin_for(func), ) - # Initialize signal mapping for potential interpolations - signal_map = {} - - # Check x-array compatibility for signal processing (single operand mode) - orig_obj2 = obj2 # Keep reference to original obj2 for title generation - if ( - self._is_signal_panel() - and isinstance(obj2, SignalObj) - and not skip_xarray_compat - ): - signal_objs = [obj for obj in objs if isinstance(obj, SignalObj)] - if signal_objs: - # Check compatibility and get potentially interpolated signals - result = self._check_signal_xarray_compatibility( - signal_objs + [obj2] - ) - if result is None: - return # User cancelled or error occurred - - checked_objs, _yes_to_all_selected = result - # Note: In single operand mode, "Yes to All" doesn't apply - # since there's only one compatibility check - - # Replace obj2 with the potentially interpolated version - obj2 = checked_objs[-1] # obj2 was added last + with self.mainwindow.historypanel.capture_outputs(action): + # Initialize signal mapping for potential interpolations + signal_map = {} + + # Check x-array compatibility for signal processing + # (single operand mode) + orig_obj2 = obj2 # Keep reference to original obj2 for title generation + if ( + self._is_signal_panel() + and isinstance(obj2, SignalObj) + and not skip_xarray_compat + ): + signal_objs = [obj for obj in objs if isinstance(obj, SignalObj)] + if signal_objs: + # Check compatibility and get potentially interpolated signals + result = self._check_signal_xarray_compatibility( + signal_objs + [obj2] + ) + if result is None: + return # User cancelled or error occurred - # Create a mapping of original to interpolated signals - for orig_obj, checked_obj in zip(signal_objs, checked_objs[:-1]): - signal_map[orig_obj] = checked_obj + checked_objs, _yes_to_all_selected = result + # Note: In single operand mode, "Yes to All" doesn't apply + # since there's only one compatibility check - with create_progress_bar(self.panel, title, max_=len(objs)) as progress: - for index, obj in enumerate(objs): - progress.setValue(index + 1) - progress.setLabelText(title) + # Replace obj2 with the potentially interpolated version + obj2 = checked_objs[-1] # obj2 was added last - # Use interpolated signal if available - actual_obj = obj - if ( - self._is_signal_panel() - and isinstance(obj, SignalObj) - and obj in signal_map - ): - actual_obj = signal_map[obj] + # Create a mapping of original to interpolated signals + for orig_obj, checked_obj in zip( + signal_objs, checked_objs[:-1] + ): + signal_map[orig_obj] = checked_obj + + with create_progress_bar(self.panel, title, max_=len(objs)) as progress: + for index, obj in enumerate(objs): + progress.setValue(index + 1) + progress.setLabelText(title) + + # Use interpolated signal if available + actual_obj = obj + if ( + self._is_signal_panel() + and isinstance(obj, SignalObj) + and obj in signal_map + ): + actual_obj = signal_map[obj] - args = ( - (actual_obj, obj2) - if param is None - else (actual_obj, obj2, param) - ) - result = self.__exec_func(func, args, progress) - if result is None: - break - new_obj = self.handle_output( - result, _("Calculating: %s") % title, progress - ) - if new_obj is None: - continue - assert isinstance(new_obj, (SignalObj, ImageObj)) + args = ( + (actual_obj, obj2) + if param is None + else (actual_obj, obj2, param) + ) + result = self.__exec_func(func, args, progress) + if result is None: + break + new_obj = self.handle_output( + result, _("Calculating: %s") % title, progress + ) + if new_obj is None: + continue + assert isinstance(new_obj, (SignalObj, ImageObj)) - group_id = objmodel.get_object_group_id(obj) - # Use original objects for title generation - patch_title_with_ids(new_obj, [obj, orig_obj2], get_short_id) + group_id = objmodel.get_object_group_id(obj) + # Use original objects for title generation + patch_title_with_ids(new_obj, [obj, orig_obj2], get_short_id) - # Handle keep_results logic for 2_to_1 operations - self._handle_keep_results(new_obj) + # Handle keep_results logic for 2_to_1 operations + self._handle_keep_results(new_obj) - # Store lightweight processing metadata (non-interactive) - proc_params = ProcessingParameters( - func_name=name, - pattern="2-to-1", - param=param, - source_uuids=[ - get_uuid(obj), - get_uuid(orig_obj2), - ], - ) - insert_processing_parameters(new_obj, proc_params) + # Store lightweight processing metadata (non-interactive) + proc_params = ProcessingParameters( + func_name=name, + pattern="2-to-1", + param=param, + source_uuids=[ + get_uuid(obj), + get_uuid(orig_obj2), + ], + ) + insert_processing_parameters(new_obj, proc_params) - # group_id is from source panel, don't use for non-native objects - self._add_object_to_appropriate_panel( - new_obj, group_id=group_id, use_group_for_non_native=False - ) + # group_id is from source panel, don't use + # for non-native objects + self._add_object_to_appropriate_panel( + new_obj, group_id=group_id, use_group_for_non_native=False + ) def register_1_to_1( self, @@ -2285,27 +2674,66 @@ def register_2_to_1( def add_feature(self, feature: ComputingFeature) -> None: """Add a computing feature to the registry. + Auto-detects the plugin origin from ``feature.function.__module__`` and + stores it on the feature (see :func:`_detect_plugin_origin`). + Args: feature: ComputingFeature instance to add. """ + if feature.function is not None and feature.plugin_origin is None: + feature.plugin_origin = _detect_plugin_origin(feature.function) self.computing_registry[feature.function] = feature - def get_feature(self, function_or_name: Callable | str) -> ComputingFeature: + def _get_plugin_origin_for(self, func: Callable) -> dict[str, Any] | None: + """Return the plugin origin descriptor for ``func`` if known. + + Falls back to a fresh detection if ``func`` is not in the registry. + + Args: + func: Computation function. + + Returns: + Plugin origin dict, or ``None`` for built-in functions. + """ + feature = self.computing_registry.get(func) + if feature is not None: + return feature.plugin_origin + return _detect_plugin_origin(func) + + def get_feature( + self, + function_or_name: Callable | str, + plugin_origin: dict[str, Any] | None = None, + paramclass_name: str | None = None, + ) -> ComputingFeature: """Get a computing feature by name or function. Args: function_or_name: Name of the feature or the function itself. + plugin_origin: Optional plugin origin descriptor used to enrich the + :class:`FeatureNotFoundError` raised when the feature is unknown. + paramclass_name: Optional name of the required parameter class, also + used to enrich the error message. Returns: Computing feature instance. + + Raises: + FeatureNotFoundError: If no matching feature is registered. The + exception subclasses :class:`ValueError` to preserve backward + compatibility with existing callers. """ try: return self.computing_registry[function_or_name] - except KeyError as exc: + except KeyError: for _func, feature in self.computing_registry.items(): if feature.name == function_or_name: return feature - raise ValueError(f"Unknown computing feature: {function_or_name}") from exc + raise FeatureNotFoundError( + str(function_or_name), + plugin_origin=plugin_origin, + paramclass_name=paramclass_name, + ) @qt_try_except() def run_feature( @@ -2372,6 +2800,9 @@ def run_feature( assert isinstance(param, (gds.DataSet, type(None))), ( f"For pattern '{pattern}', 'param' must be a DataSet or None" ) + compute_kwargs = {} + if pattern == "n_to_1": + compute_kwargs["pairwise"] = kwargs.pop("pairwise", None) return compute_method( feature.function, param=param, @@ -2379,6 +2810,7 @@ def run_feature( title=title, comment=comment, edit=edit, + **compute_kwargs, ) if pattern == "2_to_1": obj2 = kwargs.pop("obj2", args[0] if args else None) @@ -2390,6 +2822,7 @@ def run_feature( assert isinstance(param, (gds.DataSet, type(None))), ( "For pattern '2_to_1', 'param' must be a DataSet or None" ) + pairwise = kwargs.pop("pairwise", None) return self.compute_2_to_1( obj2, feature.obj2_name or _("Second operand"), @@ -2400,6 +2833,7 @@ def run_feature( comment=comment, edit=edit, skip_xarray_compat=feature.skip_xarray_compat, + pairwise=pairwise, ) if pattern == "1_to_n": params = kwargs.get("params", args[0] if args else []) diff --git a/datalab/h5/native.py b/datalab/h5/native.py index 78aaff0b8..5004c5774 100644 --- a/datalab/h5/native.py +++ b/datalab/h5/native.py @@ -139,7 +139,7 @@ def read( Returns: The read value. """ - val = super().read(group_name) + val = super().read(group_name, func=func, instance=instance, default=default) if isinstance(val, str) and val.startswith(H5_CALLABLE_PREFIX): return self.deserialize_func_or_class(val) return val From 2aef1dcf44b33dea93efe7f6d6e7ccfd103991c8 Mon Sep 17 00:00:00 2001 From: Duy Anh Philippe PHAMDate: Wed, 3 Jun 2026 13:06:23 +0200 Subject: [PATCH 20/24] Update doc and translation --- datalab/locale/fr/LC_MESSAGES/datalab.po | 215 ++++++++++- doc/features/common/historypanel.rst | 139 ++++++- .../features/common/historypanel.po | 357 +++++++++++++++--- .../LC_MESSAGES/release_notes/release_1.03.po | 67 +++- doc/release_notes/release_1.03.md | 6 + 5 files changed, 705 insertions(+), 79 deletions(-) diff --git a/datalab/locale/fr/LC_MESSAGES/datalab.po b/datalab/locale/fr/LC_MESSAGES/datalab.po index 7e49681a5..4b4b85108 100644 --- a/datalab/locale/fr/LC_MESSAGES/datalab.po +++ b/datalab/locale/fr/LC_MESSAGES/datalab.po @@ -65,6 +65,9 @@ msgstr "Assistant IA prêt. Configurez votre fournisseur dans le menu Edition > msgid "You" msgstr "Vous" +msgid "Assistant" +msgstr "Assistant" + msgid "Save to Macros" msgstr "Enregistrer dans les macros" @@ -1341,6 +1344,9 @@ msgstr "Propriétés" msgid "Parameters for function `%s`" msgstr "Paramètres pour la fonction `%s`" +msgid "Created" +msgstr "Créé" + msgid "Original object" msgstr "Objet original" @@ -1376,6 +1382,12 @@ msgstr "" msgid "Processing Parameters" msgstr "Paramètres de traitement" +msgid "Auto-recompute on edit" +msgstr "Recalcul automatique lors de l'édition" + +msgid "Automatically re-run processing when parameters are modified" +msgstr "Relancer automatiquement le traitement à chaque modification de paramètre" + msgid "No processing object available." msgstr "Aucun objet de traitement disponible." @@ -1420,6 +1432,9 @@ msgstr "Autres métadonnées" msgid "Save to directory" msgstr "Enregistrer dans un répertoire" +msgid "Pattern help" +msgstr "Aide sur le motif" + msgid "Directory" msgstr "Répertoire" @@ -1672,32 +1687,203 @@ msgstr "Description" msgid "Title" msgstr "Titre" +msgid "Action is compatible with the current workspace state." +msgstr "L'action est compatible avec l'état actuel de l'espace de travail." + +msgid "Action is not compatible with the current workspace state." +msgstr "L'action n'est pas compatible avec l'état actuel de l'espace de travail." + +msgid "Signal" +msgstr "Signal" + +msgid "Shape" +msgstr "Forme" + +msgid "Image" +msgstr "Image" + +msgid "Dimensions" +msgstr "Dimensions" + msgid "History panel" msgstr "Panneau d'historique" +msgid "History files" +msgstr "Fichiers d'historique" + msgid "Edit mode" msgstr "Mode d'édition" msgid "Record mode" msgstr "Mode d'enregistrement" +msgid "New session" +msgstr "Nouvelle session" + +msgid "Start a new history session" +msgstr "Démarrer une nouvelle session d'historique" + +msgid "Open history file..." +msgstr "Ouvrir un fichier d'historique..." + +msgid "Open history from a standalone .dlhist file" +msgstr "Ouvrir l'historique depuis un fichier .dlhist autonome" + +msgid "Save history file..." +msgstr "Enregistrer un fichier d'historique..." + +msgid "Save history to a standalone .dlhist file" +msgstr "Enregistrer l'historique dans un fichier .dlhist autonome" + +msgid "Duplicate selected history action/session" +msgstr "Dupliquer l'action ou la session d'historique sélectionnée" + +msgid "Previous step" +msgstr "Étape précédente" + +msgid "Select the previous action in the current session" +msgstr "Sélectionner l'action précédente dans la session courante" + +msgid "Next step" +msgstr "Étape suivante" + +msgid "Select the next action in the current session" +msgstr "Sélectionner l'action suivante dans la session courante" + +msgid "Generate macro" +msgstr "Générer une macro" + +msgid "Generate a Python macro script from history" +msgstr "Générer un script macro Python à partir de l'historique" + +msgid "Remove incompatible" +msgstr "Supprimer les incompatibles" + +msgid "Remove actions incompatible with the current workspace" +msgstr "Supprimer les actions incompatibles avec l'espace de travail courant" + +msgid "Restore parameters" +msgstr "Restaurer les paramètres" + +msgid "Restore original parameters (discard edit-mode changes)" +msgstr "Restaurer les paramètres d'origine (annuler les modifications du mode d'édition)" + msgid "Replay" msgstr "Rejouer" -msgid "Restore selection" -msgstr "Restaurer la sélection" +msgid "Commit edit mode changes?" +msgstr "Valider les modifications du mode d'édition ?" -msgid "Restore selection and replay" -msgstr "Restaurer la sélection et rejouer" +msgid "" +"You are about to exit Edit mode.\n" +"\n" +"All parameter changes made during this session will be permanently kept.\n" +"This action cannot be undone — Restore will no longer be available.\n" +"\n" +"Do you want to continue?" +msgstr "" +"Vous êtes sur le point de quitter le mode d'édition.\n" +"\n" +"Toutes les modifications de paramètres effectuées lors de cette session seront conservées de façon permanente.\n" +"Cette action est irréversible — la fonction Restaurer ne sera plus disponible.\n" +"\n" +"Souhaitez-vous continuer ?" msgid "The current workspace state is not compatible with the action." msgstr "L'état actuel de l'espace de travail n'est pas compatible avec l'action." -msgid "Delete actions" -msgstr "Supprimer les actions" +msgid "Parameters" +msgstr "Paramètres" + +#, python-format +msgid "Action %s has been edited but its target output object(s) no longer exist — skipping." +msgstr "L'action %s a été modifiée mais son ou ses objets de sortie cibles n'existent plus — ignorée." -msgid "Do you really want to delete the selected action and all the next ones?" -msgstr "Voulez-vous vraiment supprimer l'action sélectionnée et toutes les suivantes ?" +#, python-format +msgid "Action %s uses pattern %r which is not recomputable yet." +msgstr "L'action %s utilise le motif %r qui n'est pas encore recalculable." + +#, python-format +msgid "Recompute failed for action %s: %s" +msgstr "Échec du recalcul pour l'action %s : %s" + +#, python-format +msgid "" +"Action %(name)s skipped: plugin '%(loc)s' is missing.\n" +"Required parameter class: %(param)s\n" +"Reinstall the plugin to re-enable this action." +msgstr "" +"Action %(name)s ignorée : le plugin '%(loc)s' est manquant.\n" +"Classe de paramètre requise : %(param)s\n" +"Réinstallez le plugin pour réactiver cette action." + +#, python-format +msgid "Action %s: source object was deleted — skipping." +msgstr "Action %s : l'objet source a été supprimé — ignorée." + +#, python-format +msgid "Action %s: all source objects were deleted — skipping." +msgstr "Action %s : tous les objets source ont été supprimés — ignorée." + +#, python-format +msgid "Action %s: missing source(s) for output #%d — skipping." +msgstr "Action %s : source(s) manquante(s) pour la sortie n°%d — ignorée." + +#, python-format +msgid "Action %s: source object(s) were deleted — skipping." +msgstr "Action %s : le ou les objets source ont été supprimés — ignorée." + +#, python-format +msgid "Action %s: %d analysed object(s) were deleted — skipping." +msgstr "Action %s : %d objet(s) analysé(s) ont été supprimés — ignorée." + +msgid "Cascade recompute" +msgstr "Recalcul en cascade" + +msgid "Some downstream actions could not be recomputed:" +msgstr "Certaines actions en aval n'ont pas pu être recalculées :" + +msgid "No compute actions to export." +msgstr "Aucune action de calcul à exporter." + +#, python-format +msgid "Macro script copied to clipboard (%d actions)." +msgstr "Script macro copié dans le presse-papiers (%d actions)." + +msgid "" +"Do you really want to delete the selected items?\n" +"\n" +"Note: deleting an action also removes all subsequent actions in the same session." +msgstr "" +"Voulez-vous vraiment supprimer les éléments sélectionnés ?\n" +"\n" +"Note : la suppression d'une action supprime également toutes les actions suivantes de la même session." + +msgid "Do you really want to delete the selected items?" +msgstr "Voulez-vous vraiment supprimer les éléments sélectionnés ?" + +msgid "All actions are compatible with the current workspace." +msgstr "Toutes les actions sont compatibles avec l'espace de travail courant." + +#, python-format +msgid "%d incompatible action(s) will be removed. Continue?" +msgstr "%d action(s) incompatible(s) vont être supprimée(s). Continuer ?" + +msgid "Save history file" +msgstr "Enregistrer un fichier d'historique" + +msgid "Open history file" +msgstr "Ouvrir un fichier d'historique" + +msgid "Imported" +msgstr "Importé" + +msgid "Some operations could not be reconnected after deletion:" +msgstr "Certaines opérations n'ont pas pu être reconnectées après la suppression :" + +#, python-format +msgid "“%s” has dependent operations but no valid source to reconnect to — downstream results are left unchanged." +msgstr "« %s » possède des opérations dépendantes mais aucune source valide à laquelle se reconnecter — les résultats en aval restent inchangés." msgid "New image" msgstr "Nouvelle image" @@ -1717,6 +1903,9 @@ msgstr "Effacer toutes les macros récentes ?" msgid "Macro panel" msgstr "Panneau des macros" +msgid "Python files" +msgstr "Fichiers Python" + msgid "Clear console" msgstr "Effacer la console" @@ -1831,6 +2020,9 @@ msgstr "Les modifications seront appliquées après avoir cliqué sur OK et rech msgid "The following directories are scanned at startup for plugins:" msgstr "Les répertoires suivants sont analysés au démarrage pour détecter les plugins :" +msgid "from" +msgstr "depuis" + msgid "No plugin search path is currently active." msgstr "Aucun chemin de recherche de plugins n'est actuellement actif." @@ -1906,9 +2098,6 @@ msgstr "En mode 'pairwise', vous devez sélectionner des objets dans au moins de msgid "In pairwise mode, you need to select the same number of objects in each group." msgstr "En mode 'pairwise', vous devez sélectionner le même nombre d'objets dans chaque groupe." -msgid "Parameters" -msgstr "Paramètres" - #, python-format msgid "Calculating: %s" msgstr "Calcul : %s" @@ -3647,6 +3836,9 @@ msgstr "Distance minimale :" msgid "Signal peak detection" msgstr "Détection de pics 1D" +msgid "Peaks:" +msgstr "Pics :" + msgid "Internal console" msgstr "Console interne" @@ -3996,3 +4188,4 @@ msgstr "Aucun contour n'a été trouvé pour la plage de niveaux sélectionnée. msgid "Show contour plot..." msgstr "Afficher le tracé de contours..." + diff --git a/doc/features/common/historypanel.rst b/doc/features/common/historypanel.rst index 0400d7136..7f1ecf594 100644 --- a/doc/features/common/historypanel.rst +++ b/doc/features/common/historypanel.rst @@ -21,8 +21,10 @@ list of either: A recorded session can be: -- **Replayed**, either entirely or starting from a selected action, to - reproduce the exact same sequence on the current workspace; +- **Replayed** in validation mode, without adding new signal/image outputs to + the workspace; +- **Duplicated and applied**, to create an explicit comparison branch with new + outputs in the signal/image panels; - **Restored to a given selection state** without re-executing anything, to quickly jump back to a previous working context; - **Saved to a standalone history file** (``.dlhist``) or **embedded in the @@ -48,6 +50,16 @@ The toolbar at the top of the panel exposes the following actions: :height: 24px :class: dark-light no-scaled-link +.. |open_history| image:: ../../../datalab/data/icons/io/fileopen_h5.svg + :width: 24px + :height: 24px + :class: dark-light no-scaled-link + +.. |save_history| image:: ../../../datalab/data/icons/io/filesave_h5.svg + :width: 24px + :height: 24px + :class: dark-light no-scaled-link + .. |replay| image:: ../../../datalab/data/icons/replay.svg :width: 24px :height: 24px @@ -58,12 +70,22 @@ The toolbar at the top of the panel exposes the following actions: :height: 24px :class: dark-light no-scaled-link -.. |restore_and_replay| image:: ../../../datalab/data/icons/restore_and_replay.svg +.. |edit_mode| image:: ../../../datalab/data/icons/edit_mode.svg :width: 24px :height: 24px :class: dark-light no-scaled-link -.. |edit_mode| image:: ../../../datalab/data/icons/edit_mode.svg +.. |duplicate| image:: ../../../datalab/data/icons/edit/duplicate.svg + :width: 24px + :height: 24px + :class: dark-light no-scaled-link + +.. |step_prev| image:: ../../../datalab/data/icons/libre-gui-arrow-left.svg + :width: 24px + :height: 24px + :class: dark-light no-scaled-link + +.. |step_next| image:: ../../../datalab/data/icons/libre-gui-arrow-right.svg :width: 24px :height: 24px :class: dark-light no-scaled-link @@ -73,25 +95,53 @@ The toolbar at the top of the panel exposes the following actions: :height: 24px :class: dark-light no-scaled-link +.. |generate_macro| image:: ../../../datalab/data/icons/console.svg + :width: 24px + :height: 24px + :class: dark-light no-scaled-link + +.. |remove_incompatible| image:: ../../../datalab/data/icons/edit/delete_all.svg + :width: 24px + :height: 24px + :class: dark-light no-scaled-link + - |record| **Record mode**: toggle the recording of new actions. When off, no new entry is added to the history (existing sessions are preserved). -- |replay| **Replay**: replay the selected action (or the whole session if a - session row is selected) without changing the current workspace selection - beforehand. +- |open_history| **Open history file**: load recorded sessions from a standalone + ``.dlhist`` file. +- |save_history| **Save history file**: save the current recorded sessions to a + standalone ``.dlhist`` file. +- |replay| **Replay**: validate/replay the selected action (or the whole + session if a session row is selected) without changing the current workspace + selection beforehand and without adding new outputs to the signal/image + panels. - |restore_selection| **Restore selection**: only re-select the objects that were selected when the action was originally executed; no computation is re-run. -- |restore_and_replay| **Restore selection and replay**: combine the two - previous actions -- restore the selection first, then replay. - |edit_mode| **Edit mode**: when on, replaying a computation opens the parameters dialog so the user can tweak the parameters before re-running. + When replaying a *whole session*, the parameter dialogs open in a + **read-only** mode — all fields are shown with their recorded values but + cannot be edited. +- |duplicate| **Duplicate**: copy the selected action or session into a new + history session. The copied parameters are independent from the original + record. +- |generate_macro| **Generate macro**: generate a Python macro script from the + selected actions (or all actions if nothing is selected). The generated script + is copied to the clipboard. +- |remove_incompatible| **Remove incompatible**: remove all actions whose + workspace state is no longer compatible with the current workspace. A + confirmation dialog shows how many actions will be removed. - |delete| **Delete**: remove the selected actions or sessions from the history. +- |step_prev| **Previous step**: select the preceding action in the current + session (keyboard shortcut: :kbd:`Ctrl+Left`). +- |step_next| **Next step**: select the following action in the current + session (keyboard shortcut: :kbd:`Ctrl+Right`). .. note:: - Double-clicking on an action row in the tree is equivalent to "Restore - selection and replay". + Double-clicking on an action row in the tree is equivalent to **Replay**. Tree view --------- @@ -106,6 +156,24 @@ The tree view organizes recorded actions into expandable sessions: The selection of one or several rows drives which actions are targeted by the toolbar buttons. +Actions that are not compatible with the current workspace state (for example +because a referenced object identifier no longer exists, or because its data +shape changed) are shown with a disabled foreground and an explanatory tooltip. +They cannot be replayed until the workspace matches the recorded state again. + +Workspace state display +----------------------- + +Below the action tree, a split-view widget shows the **workspace state** +captured at the time of the selected action: + +- **Left table**: lists the signals that were selected, with their data shape. +- **Right table**: lists the images that were selected, with their dimensions. + +This information helps the user understand the context in which each action +was originally executed and diagnose compatibility issues when replaying +sessions on a different workspace. + Session replay across workspaces -------------------------------- @@ -135,12 +203,55 @@ The history can be persisted in two complementary ways: (``File > Save to HDF5 file``), the History Panel content is automatically saved alongside the signals and images. Reloading the workspace restores the recorded sessions. -- **Standalone history file**: the panel can also be serialised to a - dedicated ``.dlhist`` file, which is convenient to share or version a - processing chain independently of the data it was applied to. +- **Standalone history file** (``.dlhist``): the file embeds both the + recorded sessions **and** all signal/image objects referenced by those + sessions. This makes the file fully self-contained: + + - Opening a ``.dlhist`` into an **empty workspace** loads sessions and + objects directly, restoring the workspace to its recorded state. + - Opening a ``.dlhist`` into a **non-empty workspace** creates new + signal/image groups for the imported objects (with remapped identifiers + to avoid collisions) and appends new history sessions that reference + those fresh identifiers. .. warning:: Replaying a session that depends on external files (e.g. opening a dataset from disk) will only succeed if those files are still available at the same locations as when the session was recorded. + +Chain reconnection on deletion +------------------------------- + +When a result object is deleted from the **signal or image panel** (not +from the History Panel tree), and that object was produced by a recorded +processing step, the History Panel automatically reconnects the processing +chain: + +- All downstream steps that consumed the deleted object are rewired to use + the source of the deleted step as their new input. +- For ``2_to_1`` operations (e.g. *difference*), the first source is used + for reconnection. +- If no valid source can be determined (e.g. the source itself was already + deleted), a warning is displayed listing the unreconnectable operations, + but the deletion is allowed to proceed. + +This behaviour mirrors removing a link from a chain: the adjacent links +reconnect to preserve the processing flow. + +.. note:: + + Reconnection is only triggered by deletions initiated from the + signal/image panels. Deleting an action directly from the History Panel + tree removes it and all subsequent actions in that session. + +Auto-recompute +-------------- + +.. note:: + + When a result object is selected in the signal/image panel and it has + processing parameters (i.e. was produced by a 1-to-1 computation), a + **Processing** tab appears in the Properties panel. Checking + **Auto-recompute on edit** in that tab will re-run the computation + automatically 300 ms after any parameter modification. diff --git a/doc/locale/fr/LC_MESSAGES/features/common/historypanel.po b/doc/locale/fr/LC_MESSAGES/features/common/historypanel.po index afcb4a95e..8183e3c0c 100644 --- a/doc/locale/fr/LC_MESSAGES/features/common/historypanel.po +++ b/doc/locale/fr/LC_MESSAGES/features/common/historypanel.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: DataLab \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-05-16 11:30+0200\n" +"POT-Creation-Date: 2026-06-02 15:49+0200\n" "PO-Revision-Date: 2026-05-16 11:35+0200\n" "Last-Translator: DataLab Platform Developers\n" "Language: fr\n" @@ -29,8 +29,8 @@ msgid "" "DataLab, history, record, replay, session, scientific, data, analysis, " "visualization, platform" msgstr "" -"DataLab, historique, enregistrement, rejeu, session, scientifique, données, " -"analyse, visualisation, plateforme" +"DataLab, historique, enregistrement, rejeu, session, scientifique, " +"données, analyse, visualisation, plateforme" msgid "History Panel" msgstr "Panneau historique" @@ -45,8 +45,8 @@ msgid "" msgstr "" "Le « Panneau historique » enregistre la séquence des actions effectuées " "par l'utilisateur sur les signaux et les images, organisée en " -"**sessions**. Chaque session est une liste chronologique constituée " -"soit :" +"**sessions**. Chaque session est une liste chronologique constituée soit " +":" msgid "" "**UI actions** (creating a new signal, removing selected objects, saving " @@ -67,12 +67,18 @@ msgid "A recorded session can be:" msgstr "Une session enregistrée peut être :" msgid "" -"**Replayed**, either entirely or starting from a selected action, to " -"reproduce the exact same sequence on the current workspace;" +"**Replayed** in validation mode, without adding new signal/image outputs " +"to the workspace;" msgstr "" -"**rejouée**, soit intégralement, soit à partir d'une action sélectionnée, " -"afin de reproduire exactement la même séquence sur l'espace de travail " -"courant ;" +"**rejouée** en mode validation, sans ajouter de nouvelles sorties " +"signal/image à l'espace de travail ;" + +msgid "" +"**Duplicated and applied**, to create an explicit comparison branch with " +"new outputs in the signal/image panels;" +msgstr "" +"**dupliquée et appliquée**, afin de créer une branche de comparaison " +"explicite avec de nouvelles sorties dans les panneaux signal/image ;" msgid "" "**Restored to a given selection state** without re-executing anything, to" @@ -96,9 +102,9 @@ msgid "" "Gaussian signal, compute the average, add Gaussian noise to the result " "and run a Gaussian fit." msgstr "" -"Le Panneau historique après l'enregistrement d'une session représentative :" -" création de trois signaux (Voigt, lorentzien, lorentzien), suppression " -"de l'un d'eux, création d'un signal gaussien, calcul de la moyenne, " +"Le Panneau historique après l'enregistrement d'une session représentative" +" : création de trois signaux (Voigt, lorentzien, lorentzien), suppression" +" de l'un d'eux, création d'un signal gaussien, calcul de la moyenne, " "ajout d'un bruit gaussien au résultat et ajustement par une gaussienne." msgid "Toolbar" @@ -119,13 +125,35 @@ msgid "record" msgstr "record" msgid "" -"|replay| **Replay**: replay the selected action (or the whole session if " -"a session row is selected) without changing the current workspace " -"selection beforehand." +"|open_history| **Open history file**: load recorded sessions from a " +"standalone ``.dlhist`` file." +msgstr "" +"|open_history| **Ouvrir un fichier d'historique** : charge des sessions " +"enregistrées depuis un fichier ``.dlhist`` autonome." + +msgid "open_history" +msgstr "open_history" + +msgid "" +"|save_history| **Save history file**: save the current recorded sessions " +"to a standalone ``.dlhist`` file." +msgstr "" +"|save_history| **Enregistrer le fichier d'historique** : enregistre les " +"sessions actuellement enregistrées dans un fichier ``.dlhist`` autonome." + +msgid "save_history" +msgstr "save_history" + +msgid "" +"|replay| **Replay**: validate/replay the selected action (or the whole " +"session if a session row is selected) without changing the current " +"workspace selection beforehand and without adding new outputs to the " +"signal/image panels." msgstr "" -"|replay| **Rejouer** : rejoue l'action sélectionnée (ou la session " -"entière si une ligne de session est sélectionnée) sans modifier au " -"préalable la sélection courante de l'espace de travail." +"|replay| **Rejouer** : valide/rejoue l'action sélectionnée (ou la session" +" entière si une ligne de session est sélectionnée) sans modifier au " +"préalable la sélection courante de l'espace de travail et sans ajouter de" +" nouvelles sorties aux panneaux signal/image." msgid "replay" msgstr "replay" @@ -142,16 +170,6 @@ msgstr "" msgid "restore_selection" msgstr "restore_selection" -msgid "" -"|restore_and_replay| **Restore selection and replay**: combine the two " -"previous actions -- restore the selection first, then replay." -msgstr "" -"|restore_and_replay| **Restaurer la sélection et rejouer** : combine les " -"deux actions précédentes -- restaure d'abord la sélection, puis rejoue." - -msgid "restore_and_replay" -msgstr "restore_and_replay" - msgid "" "|edit_mode| **Edit mode**: when on, replaying a computation opens the " "parameters dialog so the user can tweak the parameters before re-running." @@ -163,6 +181,43 @@ msgstr "" msgid "edit_mode" msgstr "edit_mode" +msgid "" +"|duplicate| **Duplicate**: copy the selected action or session into a new" +" history session. The copied parameters are independent from the original" +" record." +msgstr "" +"|duplicate| **Dupliquer** : copie l'action ou la session sélectionnée " +"dans une nouvelle session d'historique. Les paramètres copiés sont " +"indépendants de l'enregistrement d'origine." + +msgid "duplicate" +msgstr "duplicate" + +msgid "" +"|generate_macro| **Generate macro**: generate a Python macro script from " +"the selected actions (or all actions if nothing is selected). The " +"generated script is copied to the clipboard." +msgstr "" +"|generate_macro| **Générer une macro** : génère un script macro Python à " +"partir des actions sélectionnées (ou de toutes les actions si rien n'est " +"sélectionné). Le script généré est copié dans le presse-papiers." + +msgid "generate_macro" +msgstr "generate_macro" + +msgid "" +"|remove_incompatible| **Remove incompatible**: remove all actions whose " +"workspace state is no longer compatible with the current workspace. A " +"confirmation dialog shows how many actions will be removed." +msgstr "" +"|remove_incompatible| **Supprimer les incompatibles** : supprime toutes " +"les actions dont l'état de l'espace de travail n'est plus compatible avec " +"l'espace de travail actuel. Une boîte de dialogue de confirmation indique " +"combien d'actions seront supprimées." + +msgid "remove_incompatible" +msgstr "remove_incompatible" + msgid "" "|delete| **Delete**: remove the selected actions or sessions from the " "history." @@ -174,11 +229,29 @@ msgid "delete" msgstr "delete" msgid "" -"Double-clicking on an action row in the tree is equivalent to \"Restore " -"selection and replay\"." +"|step_prev| **Previous step**: select the preceding action in the current" +" session (keyboard shortcut: :kbd:`Ctrl+Left`)." +msgstr "" +"|step_prev| **Étape précédente** : sélectionne l'action précédente dans " +"la session courante (raccourci clavier : :kbd:`Ctrl+Gauche`)." + +msgid "step_prev" +msgstr "step_prev" + +msgid "" +"|step_next| **Next step**: select the following action in the current " +"session (keyboard shortcut: :kbd:`Ctrl+Right`)." +msgstr "" +"|step_next| **Étape suivante** : sélectionne l'action suivante dans la " +"session courante (raccourci clavier : :kbd:`Ctrl+Droite`)." + +msgid "step_next" +msgstr "step_next" + +msgid "Double-clicking on an action row in the tree is equivalent to **Replay**." msgstr "" "Un double-clic sur la ligne d'une action dans l'arborescence équivaut à " -"« Restaurer la sélection et rejouer »." +"**Rejouer**." msgid "Tree view" msgstr "Arborescence" @@ -192,8 +265,8 @@ msgid "" "Each top-level row is a **session**, automatically created when recording" " is enabled and a new application context is started." msgstr "" -"Chaque ligne de premier niveau est une **session**, créée automatiquement " -"lorsque l'enregistrement est activé et qu'un nouveau contexte " +"Chaque ligne de premier niveau est une **session**, créée automatiquement" +" lorsque l'enregistrement est activé et qu'un nouveau contexte " "d'application est démarré." msgid "" @@ -202,8 +275,8 @@ msgid "" "(for UI actions)." msgstr "" "Chaque ligne enfant est une **action**, accompagnée de son titre, de sa " -"date et de son heure, ainsi que d'une description résumant les " -"paramètres (pour les calculs) ou l'appel (pour les actions de l'interface)." +"date et de son heure, ainsi que d'une description résumant les paramètres" +" (pour les calculs) ou l'appel (pour les actions de l'interface)." msgid "" "The selection of one or several rows drives which actions are targeted by" @@ -212,6 +285,55 @@ msgstr "" "La sélection d'une ou de plusieurs lignes détermine les actions ciblées " "par les boutons de la barre d'outils." +msgid "" +"Actions that are not compatible with the current workspace state (for " +"example because a referenced object identifier no longer exists, or " +"because its data shape changed) are shown with a disabled foreground and " +"an explanatory tooltip. They cannot be replayed until the workspace " +"matches the recorded state again." +msgstr "" +"Les actions qui ne sont pas compatibles avec l'état courant de l'espace " +"de travail (par exemple parce qu'un identifiant d'objet référencé " +"n'existe plus, ou parce que la forme de ses données a changé) sont " +"affichées avec un texte désactivé et une infobulle explicative. Elles ne " +"peuvent pas être rejouées tant que l'espace de travail ne correspond pas " +"de nouveau à l'état enregistré." + +msgid "Workspace state display" +msgstr "Affichage de l'état de l'espace de travail" + +msgid "" +"Below the action tree, a split-view widget shows the **workspace state** " +"captured at the time of the selected action:" +msgstr "" +"Sous l'arborescence des actions, un widget en vue divisée affiche " +"l'**état de l'espace de travail** tel qu'il était au moment de l'action " +"sélectionnée :" + +msgid "" +"**Left table**: lists the signals that were selected, with their data " +"shape." +msgstr "" +"**Tableau de gauche** : liste les signaux qui étaient sélectionnés, avec " +"leur forme de données." + +msgid "" +"**Right table**: lists the images that were selected, with their " +"dimensions." +msgstr "" +"**Tableau de droite** : liste les images qui étaient sélectionnées, avec " +"leurs dimensions." + +msgid "" +"This information helps the user understand the context in which each " +"action was originally executed and diagnose compatibility issues when " +"replaying sessions on a different workspace." +msgstr "" +"Ces informations aident l'utilisateur à comprendre le contexte dans " +"lequel chaque action a été exécutée à l'origine et à diagnostiquer les " +"problèmes de compatibilité lors du rejeu de sessions sur un espace de " +"travail différent." + msgid "Session replay across workspaces" msgstr "Rejeu d'une session entre espaces de travail" @@ -224,8 +346,8 @@ msgid "" msgstr "" "Une session complète peut être rejouée sur un espace de travail qui ne " "contient plus les objets référencés à l'origine par les actions " -"enregistrées -- typiquement après le chargement d'une session sauvegardée " -"dans un espace de travail vierge. Dans ce cas, le panneau **réassocie à " +"enregistrées -- typiquement après le chargement d'une session sauvegardée" +" dans un espace de travail vierge. Dans ce cas, le panneau **réassocie à " "la volée les identifiants d'objets enregistrés** aux nouveaux " "identifiants créés :" @@ -248,9 +370,9 @@ msgid "" "workspace contents, so chained creation/removal sequences replay " "correctly." msgstr "" -"les actions de l'interface qui suppriment des objets maintiennent la " -"file synchronisée avec le contenu réel de l'espace de travail, de sorte " -"que les séquences enchaînées de création et de suppression se rejouent " +"les actions de l'interface qui suppriment des objets maintiennent la file" +" synchronisée avec le contenu réel de l'espace de travail, de sorte que " +"les séquences enchaînées de création et de suppression se rejouent " "correctement." msgid "" @@ -259,8 +381,8 @@ msgid "" "different but structurally identical input." msgstr "" "Cela permet par exemple d'enregistrer une chaîne de traitement complète " -"sur un jeu de données, de la sauvegarder, puis de la ré-appliquer " -"telle quelle à une entrée différente mais structurellement identique." +"sur un jeu de données, de la sauvegarder, puis de la ré-appliquer telle " +"quelle à une entrée différente mais structurellement identique." msgid "Persistence" msgstr "Persistance" @@ -296,6 +418,149 @@ msgid "" "at the same locations as when the session was recorded." msgstr "" "Le rejeu d'une session qui dépend de fichiers externes (par exemple " -"l'ouverture d'un jeu de données depuis le disque) ne réussira que si " -"ces fichiers sont toujours disponibles aux mêmes emplacements qu'au " -"moment de l'enregistrement de la session." +"l'ouverture d'un jeu de données depuis le disque) ne réussira que si ces " +"fichiers sont toujours disponibles aux mêmes emplacements qu'au moment de" +" l'enregistrement de la session." + +msgid "Auto-recompute" +msgstr "Recalcul automatique" + +msgid "" +"When a result object is selected in the signal/image panel and it has " +"processing parameters (i.e. was produced by a 1-to-1 computation), a " +"**Processing** tab appears in the Properties panel. Checking **Auto-" +"recompute on edit** in that tab will re-run the computation automatically" +" 300 ms after any parameter modification." +msgstr "" +"Lorsqu'un objet résultat est sélectionné dans le panneau signal/image et " +"qu'il possède des paramètres de traitement (c'est-à-dire qu'il a été " +"produit par un calcul 1-à-1), un onglet **Traitement** apparaît dans le " +"panneau Propriétés. Cocher **Recalcul automatique lors de l'édition** " +"dans cet onglet relancera automatiquement le calcul 300 ms après toute " +"modification d'un paramètre." + +msgid "" +"When replaying a *whole session*, the parameter dialogs open in a " +"**read-only** mode — all fields are shown with their recorded values but " +"cannot be edited." +msgstr "" +"Lors du rejeu d'une *session entière*, les boîtes de dialogue des " +"paramètres s'ouvrent en mode **lecture seule** — tous les champs " +"affichent les valeurs enregistrées mais ne peuvent pas être édités." + +msgid "Chain reconnection on deletion" +msgstr "Reconnexion de la chaîne lors d'une suppression" + +msgid "" +"When a result object is deleted from the **signal or image panel** (not " +"from the History Panel tree), and that object was produced by a recorded " +"processing step, the History Panel automatically reconnects the processing " +"chain:" +msgstr "" +"Lorsqu'un objet résultat est supprimé depuis le **panneau signal ou image** " +"(et non depuis l'arborescence du panneau historique), et que cet objet a " +"été produit par une étape de traitement enregistrée, le Panneau historique " +"reconnecte automatiquement la chaîne de traitement :" + +msgid "" +"All downstream steps that consumed the deleted object are rewired to use " +"the source of the deleted step as their new input." +msgstr "" +"Toutes les étapes aval qui consommaient l'objet supprimé sont recâblées " +"pour utiliser la source de l'étape supprimée comme nouvelle entrée." + +msgid "" +"For ``2_to_1`` operations (e.g. *difference*), the first source is used " +"for reconnection." +msgstr "" +"Pour les opérations ``2_to_1`` (par exemple *différence*), la première " +"source est utilisée pour la reconnexion." + +msgid "" +"If no valid source can be determined (e.g. the source itself was already " +"deleted), a warning is displayed listing the unreconnectable operations, " +"but the deletion is allowed to proceed." +msgstr "" +"Si aucune source valide ne peut être déterminée (par exemple la source " +"elle-même a déjà été supprimée), un avertissement est affiché listant les " +"opérations non reconnectables, mais la suppression est néanmoins autorisée." + +msgid "" +"This behaviour mirrors removing a link from a chain: the adjacent links " +"reconnect to preserve the processing flow." +msgstr "" +"Ce comportement reproduit la suppression d'un maillon d'une chaîne : les " +"maillons adjacents se reconnectent pour préserver le flux de traitement." + +msgid "" +"Reconnection is only triggered by deletions initiated from the " +"signal/image panels. Deleting an action directly from the History Panel " +"tree removes it and all subsequent actions in that session." +msgstr "" +"La reconnexion n'est déclenchée que par les suppressions initiées depuis " +"les panneaux signal/image. Supprimer une action directement depuis " +"l'arborescence du Panneau historique la retire ainsi que toutes les " +"actions suivantes de cette session." + +msgid "" +"**Standalone history file** (``.dlhist``): the file embeds both the " +"recorded sessions **and** all signal/image objects referenced by those " +"sessions. This makes the file fully self-contained:" +msgstr "" +"**Fichier d'historique autonome** (``.dlhist``) : le fichier embarque à " +"la fois les sessions enregistrées **et** tous les objets signal/image " +"référencés par ces sessions. Cela rend le fichier entièrement autonome :" + +msgid "" +"Opening a ``.dlhist`` into an **empty workspace** loads sessions and " +"objects directly, restoring the workspace to its recorded state." +msgstr "" +"Ouvrir un fichier ``.dlhist`` dans un **espace de travail vide** charge " +"les sessions et les objets directement, restaurant l'espace de travail " +"dans son état enregistré." + +msgid "" +"Opening a ``.dlhist`` into a **non-empty workspace** creates new " +"signal/image groups for the imported objects (with remapped identifiers " +"to avoid collisions) and appends new history sessions that reference " +"those fresh identifiers." +msgstr "" +"Ouvrir un fichier ``.dlhist`` dans un **espace de travail non vide** crée " +"de nouveaux groupes signal/image pour les objets importés (avec des " +"identifiants remappés pour éviter les collisions) et ajoute de nouvelles " +"sessions d'historique référençant ces nouveaux identifiants." + +#~ msgid "" +#~ "|restore_and_replay| **Restore selection and " +#~ "replay**: combine the two previous " +#~ "actions -- restore the selection first," +#~ " then replay." +#~ msgstr "" +#~ "|restore_and_replay| **Restaurer la sélection " +#~ "et rejouer** : combine les deux " +#~ "actions précédentes -- restaure d'abord " +#~ "la sélection, puis rejoue." + +#~ msgid "restore_and_replay" +#~ msgstr "restore_and_replay" + +#~ msgid "" +#~ "|replay| **Apply duplicate**: replay the " +#~ "selected duplicate persistently, adding " +#~ "comparison outputs to the signal/image " +#~ "panels. Outputs without an existing " +#~ "destination group are placed in a " +#~ "``Replay duplicate`` group, and their " +#~ "titles are prefixed with ``Replay " +#~ "duplicate -``." +#~ msgstr "" +#~ "|replay| **Appliquer le duplicata** : " +#~ "rejoue le duplicata sélectionné de façon" +#~ " persistante, en ajoutant des sorties " +#~ "de comparaison aux panneaux signal/image. " +#~ "Les sorties sans groupe de destination" +#~ " existant sont placées dans un groupe" +#~ " ``Rejeu du duplicata``, et leurs " +#~ "titres sont préfixés par ``Rejeu du " +#~ "duplicata -``." + diff --git a/doc/locale/fr/LC_MESSAGES/release_notes/release_1.03.po b/doc/locale/fr/LC_MESSAGES/release_notes/release_1.03.po index 6019a4299..809cc8ff5 100644 --- a/doc/locale/fr/LC_MESSAGES/release_notes/release_1.03.po +++ b/doc/locale/fr/LC_MESSAGES/release_notes/release_1.03.po @@ -5,11 +5,17 @@ # msgid "" msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2026-05-28 17:03+0200\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" "Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.18.0\n" msgid "Version 1.3" msgstr "Version 1.3" @@ -23,14 +29,59 @@ msgstr "✨ Nouvelles fonctionnalités" msgid "**Third-party plugin discovery via environment variable:**" msgstr "**Découverte des plugins tiers via une variable d'environnement :**" -msgid "Added support for the `DATALAB_PLUGINS` environment variable, allowing one or more directories to be specified as additional plugin search paths" -msgstr "Ajout de la prise en charge de la variable d'environnement `DATALAB_PLUGINS`, permettant de spécifier un ou plusieurs répertoires comme chemins de recherche supplémentaires pour les plugins" +msgid "" +"Added support for the `DATALAB_PLUGINS` environment variable, allowing " +"one or more directories to be specified as additional plugin search paths" +msgstr "" +"Ajout de la prise en charge de la variable d'environnement " +"`DATALAB_PLUGINS`, permettant de spécifier un ou plusieurs répertoires " +"comme chemins de recherche supplémentaires pour les plugins" -msgid "Multiple directories can be listed using the OS path separator (`;` on Windows, `:` on Linux/macOS), following the same convention as `PYTHONPATH`" -msgstr "Plusieurs répertoires peuvent être listés en utilisant le séparateur de chemin du système d'exploitation (`;` sous Windows, `:` sous Linux/macOS), selon la même convention que `PYTHONPATH`" +msgid "" +"Multiple directories can be listed using the OS path separator (`;` on " +"Windows, `:` on Linux/macOS), following the same convention as " +"`PYTHONPATH`" +msgstr "" +"Plusieurs répertoires peuvent être listés en utilisant le séparateur de " +"chemin du système d'exploitation (`;` sous Windows, `:` sous " +"Linux/macOS), selon la même convention que `PYTHONPATH`" -msgid "Listed directories are appended to the existing plugin search paths at startup and are picked up automatically by the plugin discovery mechanism" -msgstr "Les répertoires listés sont ajoutés aux chemins de recherche existants au démarrage et sont automatiquement pris en compte par le mécanisme de découverte des plugins" +msgid "" +"Listed directories are appended to the existing plugin search paths at " +"startup and are picked up automatically by the plugin discovery mechanism" +msgstr "" +"Les répertoires listés sont ajoutés aux chemins de recherche existants au" +" démarrage et sont automatiquement pris en compte par le mécanisme de " +"découverte des plugins" + +msgid "" +"Non-existent directories are silently skipped (a warning is recorded in " +"the log file), so a stale environment variable on another machine will " +"not prevent DataLab from starting" +msgstr "" +"Les répertoires inexistants sont ignorés silencieusement (un " +"avertissement est consigné dans le fichier journal), ainsi une variable " +"d'environnement obsolète sur une autre machine n'empêchera pas le " +"démarrage de DataLab" -msgid "Non-existent directories are silently skipped (a warning is recorded in the log file), so a stale environment variable on another machine will not prevent DataLab from starting" -msgstr "Les répertoires inexistants sont ignorés silencieusement (un avertissement est consigné dans le fichier journal), ainsi une variable d'environnement obsolète sur une autre machine n'empêchera pas le démarrage de DataLab" +msgid "**History Panel sessions:**" +msgstr "**Sessions du panneau d'historique :**" + +msgid "" +"Added serialized and replayable history sessions with workspace-state " +"validation" +msgstr "" +"Ajout de sessions d'historique sérialisées et rejouables, avec validation " +"de l'état de l'espace de travail" + +msgid "" +"Added `.dlhist` import/export support and separated reset sessions from " +"regular history sessions" +msgstr "" +"Ajout de la prise en charge de l'import/export `.dlhist` et séparation des " +"sessions de réinitialisation des sessions d'historique ordinaires" + +msgid "Improved replay compatibility reporting for clearer user feedback" +msgstr "" +"Amélioration du rapport de compatibilité de relecture pour fournir un retour " +"utilisateur plus clair" diff --git a/doc/release_notes/release_1.03.md b/doc/release_notes/release_1.03.md index 87c809b1b..ac317ab42 100644 --- a/doc/release_notes/release_1.03.md +++ b/doc/release_notes/release_1.03.md @@ -10,3 +10,9 @@ * Multiple directories can be listed using the OS path separator (`;` on Windows, `:` on Linux/macOS), following the same convention as `PYTHONPATH` * Listed directories are appended to the existing plugin search paths at startup and are picked up automatically by the plugin discovery mechanism * Non-existent directories are silently skipped (a warning is recorded in the log file), so a stale environment variable on another machine will not prevent DataLab from starting + +**History Panel sessions:** + +* Added serialized and replayable history sessions with workspace-state validation +* Added `.dlhist` import/export support and separated reset sessions from regular history sessions +* Improved replay compatibility reporting for clearer user feedback From 3a55ab06d9a87de57cee78cb83cd72412bb04a4e Mon Sep 17 00:00:00 2001 From: Duy Anh Philippe PHAM Date: Thu, 4 Jun 2026 17:10:31 +0200 Subject: [PATCH 21/24] feat(history): refactor history panel and replay workflow Split the history feature into dedicated modules, refresh the GUI wiring and widgets, and update the related tests and translations. --- datalab/config.py | 6 + datalab/gui/historysession_ops.py | 312 ++ datalab/gui/historytools_ops.py | 456 ++ datalab/gui/panel/history.py | 4933 ----------------- datalab/gui/panel/history/__init__.py | 21 + datalab/gui/panel/history/chain.py | 375 ++ .../gui/panel/history/interactive_replay.py | 195 + datalab/gui/panel/history/panel.py | 902 +++ datalab/gui/panel/history/recompute.py | 495 ++ datalab/h5/history.py | 263 + datalab/history/__init__.py | 23 + datalab/history/action.py | 781 +++ datalab/history/core.py | 235 + datalab/history/session.py | 392 ++ datalab/history/workspace_state.py | 249 + datalab/locale/fr/LC_MESSAGES/datalab.po | 202 +- .../auto_analysis_recompute_unit_test.py | 4 + .../tests/features/common/history_app_test.py | 31 +- .../common/history_contract_unit_test.py | 214 - .../common/history_replay_app_test.py | 448 -- datalab/tests/features/common/history_test.py | 1508 +++++ .../common/interactive_processing_test.py | 20 + datalab/widgets/historydescription.py | 90 + datalab/widgets/historytree.py | 249 + datalab/widgets/workspacestate_widget.py | 82 + 25 files changed, 6778 insertions(+), 5708 deletions(-) create mode 100644 datalab/gui/historysession_ops.py create mode 100644 datalab/gui/historytools_ops.py delete mode 100644 datalab/gui/panel/history.py create mode 100644 datalab/gui/panel/history/__init__.py create mode 100644 datalab/gui/panel/history/chain.py create mode 100644 datalab/gui/panel/history/interactive_replay.py create mode 100644 datalab/gui/panel/history/panel.py create mode 100644 datalab/gui/panel/history/recompute.py create mode 100644 datalab/h5/history.py create mode 100644 datalab/history/__init__.py create mode 100644 datalab/history/action.py create mode 100644 datalab/history/core.py create mode 100644 datalab/history/session.py create mode 100644 datalab/history/workspace_state.py delete mode 100644 datalab/tests/features/common/history_contract_unit_test.py delete mode 100644 datalab/tests/features/common/history_replay_app_test.py create mode 100644 datalab/tests/features/common/history_test.py create mode 100644 datalab/widgets/historydescription.py create mode 100644 datalab/widgets/historytree.py create mode 100644 datalab/widgets/workspacestate_widget.py diff --git a/datalab/config.py b/datalab/config.py index 68996035f..0a8c4e525 100644 --- a/datalab/config.py +++ b/datalab/config.py @@ -295,6 +295,11 @@ class ProcSection(conf.Section, metaclass=conf.SectionMeta): # - False: do not ignore warnings ignore_warnings = conf.Option() + # Automatically start recording history at DataLab launch: + # - True: history recording is enabled at startup (default) + # - False: user must enable it manually via the History panel toolbar + history_auto_record = conf.Option() + # X-array compatibility behavior for multi-signal computations: # - "ask": ask user for confirmation when x-arrays are incompatible (default) # - "interpolate": automatically interpolate when x-arrays are incompatible @@ -582,6 +587,7 @@ def initialize(): Conf.proc.keep_results.get(False) Conf.proc.show_result_dialog.get(True) Conf.proc.ignore_warnings.get(False) + Conf.proc.history_auto_record.get(True) Conf.proc.xarray_compat_behavior.get("ask") Conf.proc.small_mono_font.get((configtools.MONOSPACE, 8, False)) # View section diff --git a/datalab/gui/historysession_ops.py b/datalab/gui/historysession_ops.py new file mode 100644 index 000000000..67102cdf6 --- /dev/null +++ b/datalab/gui/historysession_ops.py @@ -0,0 +1,312 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +"""Helpers for History panel session recording and indexing.""" + +from __future__ import annotations + +import logging +from contextlib import contextmanager +from copy import deepcopy +from typing import TYPE_CHECKING, Any, Callable, Generator + +from datalab.history import HistoryAction, HistorySession, WorkspaceState +from datalab.history.core import _resolve_self_target + +if TYPE_CHECKING: + from datalab.gui.panel.history import HistoryPanel + +_logger = logging.getLogger(__name__) + + +def create_new_session(panel: HistoryPanel) -> None: + """Create a new history list""" + panel.session_increment += 1 + session = HistorySession(number=panel.session_increment) + panel.history_sessions.append(session) + panel.tree.populate_tree(panel.history_sessions) + panel.refresh_compatibility_items() + + +def start_new_session_after_workspace_reset(panel: HistoryPanel) -> None: + """Start a new history session after a workspace reset, when useful.""" + if panel.history_sessions and panel.history_sessions[-1].actions: + panel.create_new_session() + + +def add_compute_entry( + panel: HistoryPanel, + action_title: str, + panel_str: str, + func_name: str, + pattern: str, + save_state: bool = True, + output_uuids: list[str] | None = None, + plugin_origin: dict[str, Any] | None = None, + **kwargs: Any, +) -> HistoryAction | None: + """Record a *compute* action in the current history session. + + Args: + action_title: Title shown in the history tree. + panel_str: ``"signal"`` or ``"image"``. + func_name: Sigima feature name (resolvable via + :meth:`BaseProcessor.get_feature`). + pattern: One of ``"1_to_1"``, ``"1_to_0"``, ``"n_to_1"``, ``"2_to_1"``, + ``"1_to_n"``, ``"multiple_1_to_1"`` (the latter is recorded for + traceability but not replayable). + save_state: If True, capture the workspace state for replay. + output_uuids: Optional list of UUIDs of the data objects produced by + this action. When known at call time, prefer passing it here so the + bijective mapping is initialised in one step. Most callers do not + know the outputs yet and instead wrap the compute call with + :meth:`capture_outputs` (or call :meth:`register_action_outputs` + explicitly afterwards) using the returned action. + plugin_origin: Optional plugin origin descriptor (see + :func:`datalab.gui.processor.base._detect_plugin_origin`). ``None`` + for built-in Sigima/DataLab features. + **kwargs: Extra primitive kwargs (``param``, ``obj2_uuids``, + ``obj2_name``, ``pairwise``, ``params`` (list of DataSet), + ``func_names`` (list of str), ...). ``DataSet`` instances are + serialised as JSON. + + Returns: + The created :class:`HistoryAction`, or ``None`` if recording is + disabled (record mode off or replay in progress). + """ + if not panel.record_mode_enabled or panel.is_replaying(): + return None + state = WorkspaceState() + if save_state: + state.save(panel.mainwindow) + # Deep-copy kwargs so each action owns independent parameter + # instances. Without this, consecutive applications of the same + # function (e.g. two gaussian_filter calls with different sigma) + # would share a single DataSet object and editing one action's + # parameters would silently mutate the other. + action = HistoryAction( + title=action_title, + kind=HistoryAction.KIND_COMPUTE, + panel_str=panel_str, + func_name=func_name, + pattern=pattern, + kwargs=deepcopy(kwargs), + state=state, + plugin_origin=plugin_origin, + ) + panel.add_object(action) + if output_uuids is not None: + panel.register_action_outputs(action, output_uuids) + return action + + +def add_compute_entry_from_pp( + panel: HistoryPanel, + action_title: str, + pp: Any, # ProcessingParameters (avoid circular import) + panel_str: str, + save_state: bool = True, + output_uuids: list[str] | None = None, + plugin_origin: dict[str, Any] | None = None, + **extras: Any, +) -> HistoryAction | None: + """Record a *compute* action derived from a ``ProcessingParameters``. + + Bridges the dash-form pattern used in object metadata + (``"1-to-1"`` …) with the underscore form expected by + :class:`HistoryAction` (``"1_to_1"`` …) so that both sides share + a single identity (``func_name`` / ``pattern`` / ``param``). + + Args: + action_title: Title shown in the history tree. + pp: :class:`~datalab.gui.processor.base.ProcessingParameters` + instance describing the operation. + panel_str: ``"signal"`` or ``"image"``. + save_state: If True, capture the workspace state for replay. + output_uuids: Optional list of UUIDs of the data objects produced + by this action (see :meth:`add_compute_entry`). + plugin_origin: Optional plugin origin descriptor (see + :meth:`add_compute_entry`). + **extras: Additional history-only kwargs (``obj2_uuids``, + ``obj2_name``, ``pairwise``, ``params``, ``func_names``…). + + Returns: + The created :class:`HistoryAction`, or ``None`` if recording is + disabled. + """ + hist_pattern = pp.pattern.replace("-", "_") + kwargs: dict[str, Any] = {} + if pp.param is not None and "param" not in extras and "params" not in extras: + kwargs["param"] = pp.param + kwargs.update(extras) + return panel.add_compute_entry( + action_title, + panel_str=panel_str, + func_name=pp.func_name, + pattern=hist_pattern, + save_state=save_state, + output_uuids=output_uuids, + plugin_origin=plugin_origin, + **kwargs, + ) + + +def register_action_outputs( + panel: HistoryPanel, action: HistoryAction, output_uuids: list[str] +) -> None: + """Register the data objects produced by ``action``. + + Maintains the bijective ``action → outputs`` and ``output → action`` + mappings. May be called multiple times for a given action (later calls + replace earlier ones, e.g. after a cascade recompute). + + Args: + action: The history action that produced the outputs. + output_uuids: UUIDs of the produced data objects (empty for + ``1_to_0`` analysis patterns and for UI actions that did not + create new objects). + """ + # Drop previous outputs for this action from the reverse index. + previous = panel._action_output_uuids.get(action.uuid, []) + for prev_uuid in previous: + if panel._output_to_action.get(prev_uuid) == action.uuid: + panel._output_to_action.pop(prev_uuid, None) + new_outputs = list(output_uuids) + # Ownership transfer: if an output_uuid already belongs to a + # *different* action, remove it from that action's output list so the + # forward mapping stays consistent. The HistoryAction object's + # ``output_uuids`` attribute is NOT updated here because traversing all + # sessions to locate the object would be expensive; the panel-level + # dicts are the source of truth. + for out_uuid in new_outputs: + old_action_uuid = panel._output_to_action.get(out_uuid) + if old_action_uuid is not None and old_action_uuid != action.uuid: + old_list = panel._action_output_uuids.get(old_action_uuid) + if old_list is not None: + try: + old_list.remove(out_uuid) + except ValueError: + pass + if not old_list: + del panel._action_output_uuids[old_action_uuid] + _logger.debug( + "Output %s transferred from action %s to %s", + out_uuid, + old_action_uuid, + action.uuid, + ) + action.output_uuids = list(new_outputs) + panel._action_output_uuids[action.uuid] = new_outputs + for out_uuid in new_outputs: + panel._output_to_action[out_uuid] = action.uuid + + +@contextmanager +def capture_outputs( + panel: HistoryPanel, action: HistoryAction | None +) -> Generator[None, None, None]: + """Context manager: snapshot panel object IDs and record diffs as outputs. + + Use around any compute call when the produced UUIDs are not known + upfront. On exit, every newly-added object (signal or image) is + registered as an output of ``action`` via + :meth:`register_action_outputs`. No-op when ``action`` is ``None`` + (recording disabled). + + Args: + action: The history action being processed, or ``None``. + """ + if action is None: + yield + return + panels = (panel.mainwindow.signalpanel, panel.mainwindow.imagepanel) + before = {p.PANEL_STR_ID: set(p.objmodel.get_object_ids()) for p in panels} + try: + yield + finally: + new_uuids: list[str] = [] + for p in panels: + before_p = before[p.PANEL_STR_ID] + for uid in p.objmodel.get_object_ids(): + if uid not in before_p: + new_uuids.append(uid) + panel.register_action_outputs(action, new_uuids) + + +def add_ui_entry( + panel: HistoryPanel, + action_title: str, + target: str, + method_name: str, + save_state: bool = True, + **kwargs: Any, +) -> None: + """Record a *UI* action in the current history session. + + Args: + action_title: Title shown in the history tree. + target: One of ``"mainwindow"``, ``"signalpanel"``, ``"imagepanel"``, + ``"historypanel"`` -- attribute path on the main window. + method_name: Method name to call on ``target`` at replay time. + save_state: If True, capture the workspace state for replay. + **kwargs: Method keyword arguments. ``DataSet`` instances are + serialised as JSON; other values must be HDF5-friendly primitives. + """ + if not panel.record_mode_enabled or panel.is_replaying(): + return + state = WorkspaceState() + if save_state: + state.save(panel.mainwindow) + # Deep-copy kwargs to ensure independent parameter ownership + # (same rationale as in add_compute_entry). + action = HistoryAction( + title=action_title, + kind=HistoryAction.KIND_UI, + target=target, + method_name=method_name, + kwargs=deepcopy(kwargs), + state=state, + ) + panel.add_object(action) + + +def add_entry( + panel: HistoryPanel, + action_title: str, + save_state: bool, + func: Callable, + **kwargs, +) -> None: + """Legacy entry-point kept as a compatibility shim. + + Most call sites have been migrated to :meth:`add_compute_entry` or + :meth:`add_ui_entry`. The remaining paths -- and the + :func:`add_to_history` decorator -- still call ``add_entry`` with a + bound method; we infer the ``(target, method_name)`` from the bound + ``func.__self__`` and route to :meth:`add_ui_entry`. + """ + if not panel.record_mode_enabled or panel.is_replaying(): + return + target = None + if hasattr(func, "__self__"): + target = _resolve_self_target(func.__self__) + if target is None: + # Cannot route safely -- skip rather than pickle a Callable. + return + panel.add_ui_entry( + action_title, + target=target, + method_name=func.__name__, + save_state=save_state, + **kwargs, + ) + + +def add_object(panel: HistoryPanel, obj: HistoryAction) -> None: + """Add object to panel""" + if not panel.history_sessions: + panel.create_new_session() + panel.history_sessions[-1].add_action(obj) + panel.tree.add_action_to_tree(obj) + panel.tree.rearrange_tree() + panel.refresh_compatibility_items() + panel.update_actions_state() diff --git a/datalab/gui/historytools_ops.py b/datalab/gui/historytools_ops.py new file mode 100644 index 000000000..3e326df92 --- /dev/null +++ b/datalab/gui/historytools_ops.py @@ -0,0 +1,456 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +"""Helpers for History panel session tools.""" + +from __future__ import annotations + +from copy import deepcopy +from typing import TYPE_CHECKING +from uuid import uuid4 + +from qtpy import QtWidgets as QW + +from datalab.config import _ +from datalab.env import execenv +from datalab.gui.processor.base import ( + PROCESSING_PARAMETERS_OPTION, + ProcessingParameters, +) +from datalab.history import HistoryAction, HistorySession +from datalab.objectmodel import get_uuid + +if TYPE_CHECKING: + from datalab.gui.panel.history import HistoryPanel + + +def duplicate_selected_entries(panel: HistoryPanel) -> None: + """Duplicate selected sessions (with their data) into new independent sessions. + + For each selected session (or the parent session of a selected action), + all referenced data objects are deep-copied into a new group and the + session is duplicated with all UUID references rewritten to the clones. + The result is an independent, editable and replayable session. + """ + selected = panel.tree.get_selected_actions_or_sessions(panel.history_sessions) + if not selected: + return + # Normalise: resolve individual actions to their parent session, deduplicate. + sessions_to_dup: list[HistorySession] = [] + seen: set[int] = set() + for item in selected: + if isinstance(item, HistorySession): + session = item + else: + session = panel.find_parent_session(item) + if session is None: + continue + if id(session) not in seen: + seen.add(id(session)) + sessions_to_dup.append(session) + + copy_suffix = _("Copy") + new_sessions: list[HistorySession] = [] + panel_map = { + "signal": panel.mainwindow.signalpanel, + "image": panel.mainwindow.imagepanel, + } + + for session in sessions_to_dup: + # 1. Collect all UUIDs referenced by this session + uuids_by_panel: dict[str, set[str]] = {} + for action in session.actions: + for pstr, uuids in action.state.selection.items(): + uuids_by_panel.setdefault(pstr, set()).update(uuids) + for pstr, metadata in action.state.object_metadata.items(): + uuids_by_panel.setdefault(pstr, set()).update(metadata.keys()) + obj2 = action.kwargs.get("obj2_uuids") + if obj2: + pstr = action.panel_str or "" + if isinstance(obj2, str): + obj2 = [obj2] + uuids_by_panel.setdefault(pstr, set()).update(obj2) + # Output UUIDs produced by this action (e.g. result of a + # compute step). Without this, the last action's outputs + # would be missing because no subsequent state captures them. + if action.output_uuids: + pstr = action.panel_str or "" + uuids_by_panel.setdefault(pstr, set()).update(action.output_uuids) + + # 2. Clone objects and build uuid_remap + uuid_remap: dict[str, dict[str, str]] = {} + clones_by_pstr: dict[str, list] = {} + group_title = f"{copy_suffix} - {session.title}" + for pstr, uuids in uuids_by_panel.items(): + data_panel = panel_map.get(pstr) + if data_panel is None: + continue + uuid_remap[pstr] = {} + existing_ids = set(data_panel.objmodel.get_object_ids()) + clones = [] + # Iterate in panel order (not set order) to preserve + # the topological object ordering in the duplicated group. + ordered_ids = [ + u for u in data_panel.objmodel.get_object_ids() if u in uuids + ] + for old_uuid in ordered_ids: + if old_uuid not in existing_ids: + continue + obj = data_panel.objmodel[old_uuid] + clone = deepcopy(obj) + new_uuid = str(uuid4()) + # SignalObj/ImageObj store UUID via metadata option + try: + clone.set_metadata_option("uuid", new_uuid) + except AttributeError: + clone.uuid = new_uuid + uuid_remap[pstr][old_uuid] = new_uuid + clones.append(clone) + clones_by_pstr[pstr] = clones + if clones: + group_id = get_uuid(data_panel.add_group(group_title)) + for clone in clones: + data_panel.add_object(clone, group_id=group_id) + + # Second pass: remap source UUIDs in cloned objects' + # processing_parameters so reprocessing in the Processing tab + # uses the cloned source, not the original. + for pstr_inner, clones_inner in clones_by_pstr.items(): + pmap = uuid_remap.get(pstr_inner, {}) + if not pmap: + continue + for clone in clones_inner: + try: + pp_dict = clone.get_metadata_option(PROCESSING_PARAMETERS_OPTION) + except (AttributeError, ValueError): + continue + if not pp_dict: + continue + try: + pp = ProcessingParameters.from_dict(pp_dict) + except (TypeError, ValueError, AttributeError): + continue + changed = False + if pp.source_uuid is not None and pp.source_uuid in pmap: + pp.source_uuid = pmap[pp.source_uuid] + changed = True + if pp.source_uuids is not None: + new_src = [pmap.get(u, u) for u in pp.source_uuids] + if new_src != pp.source_uuids: + pp.source_uuids = new_src + changed = True + if changed: + try: + clone.set_metadata_option( + PROCESSING_PARAMETERS_OPTION, pp.to_dict() + ) + except (AttributeError, ValueError): + pass + + # 3. Build the new session with remapped UUIDs + panel.session_increment += 1 + title = f"{session.title} {copy_suffix}" + new_session = session.copy_with_uuid_remap(title=title, uuid_remap=uuid_remap) + new_session.number = panel.session_increment + new_sessions.append(new_session) + + # Register output mappings for cloned actions so that + # _resolve_target_outputs / get_downstream_actions work on + # the duplicated session (same logic as read_h5_data). + for action in new_session.actions: + if action.output_uuids: + panel._action_output_uuids[action.uuid] = list(action.output_uuids) + for out_uuid in action.output_uuids: + panel._output_to_action[out_uuid] = action.uuid + + # Insert each duplicated session immediately after its original. + offset = 0 + for original_session, new_session in zip(sessions_to_dup, new_sessions): + idx = panel.history_sessions.index(original_session) + panel.history_sessions.insert(idx + 1 + offset, new_session) + offset += 1 + panel.tree.populate_tree(panel.history_sessions) + panel.select_sessions(new_sessions) + panel.refresh_compatibility_items() + panel.update_actions_state() + + +def generate_macro(panel: HistoryPanel) -> None: + """Generate a standalone Python script from selected history entries. + + The generated script uses sigima functions directly with proper variable + chaining. Object references (UUIDs) are resolved to variable names so + that 2-to-1 operations reference the correct intermediate result. + The script is copied to the clipboard and the user is notified. + """ + selected = panel.tree.get_selected_actions_or_sessions(panel.history_sessions) + actions: list[HistoryAction] = [] + if not selected: + for session in panel.history_sessions: + actions.extend(session.actions) + else: + for item in selected: + if isinstance(item, HistorySession): + actions.extend(item.actions) + else: + actions.append(item) + if not actions: + return + + # Filter to compute-only actions for the pipeline + compute_actions = [a for a in actions if a.kind == HistoryAction.KIND_COMPUTE] + if not compute_actions: + if not execenv.unattended: + QW.QMessageBox.information( + panel.mainwindow, + _("Generate macro"), + _("No compute actions to export."), + ) + return + + # Determine input type from first action + first_panel = compute_actions[0].panel_str + if first_panel == "signal": + obj_type = "SignalObj" + obj_import = "from sigima.objects import SignalObj" + else: + obj_type = "ImageObj" + obj_import = "from sigima.objects import ImageObj" + + imports: set[str] = set() + imports.add(obj_import) + body_lines: list[str] = [] + + # UUID → variable mapping for resolving object references. + # Populated with input UUIDs ("src", "src_2", ...) and enriched + # with each step's output UUID after code generation. + uuid_to_var: dict[str, str] = {} + + # Extra input parameters discovered during generation (second + # operands that are not produced by any previous step). + extra_inputs: list[str] = [] + + # Seed the mapping with the first action's input selection. + first_sel = compute_actions[0].state.selection.get(compute_actions[0].panel_str, []) + for i, uuid in enumerate(first_sel): + var = "src" if i == 0 else f"src_{i + 1}" + uuid_to_var[uuid] = var + + step = 0 + current_var = "src" + + for action in compute_actions: + step += 1 + + # Resolve input variable from the action's selection UUIDs. + sel_uuids = action.state.selection.get(action.panel_str or "", []) + if sel_uuids and sel_uuids[0] in uuid_to_var: + input_var = uuid_to_var[sel_uuids[0]] + else: + input_var = current_var + + # Resolve second operand for 2-to-1 patterns. + obj2_var: str | None = None + if action.pattern == "2_to_1": + obj2_uuids = action.kwargs.get("obj2_uuids", []) + if isinstance(obj2_uuids, str): + obj2_uuids = [obj2_uuids] + if obj2_uuids: + obj2_uuid = obj2_uuids[0] + if obj2_uuid in uuid_to_var: + obj2_var = uuid_to_var[obj2_uuid] + else: + # External input — add as function parameter. + obj2_var = f"obj2_{step}" + uuid_to_var[obj2_uuid] = obj2_var + extra_inputs.append(obj2_var) + + code_lines, output_var = action.to_macro_code( + step, input_var, imports, obj2_var=obj2_var + ) + body_lines.extend(code_lines) + body_lines.append("") + + if output_var is not None: + current_var = output_var + # Map the output UUID so subsequent steps can reference it. + output_uuid = panel.action_output_uuid(action) + if output_uuid: + uuid_to_var[output_uuid] = output_var + # Also register any new UUIDs from the action's selection + # that we haven't seen yet (secondary selections). + for uuid in sel_uuids[1:]: + if uuid not in uuid_to_var: + uuid_to_var[uuid] = input_var + + # Build the function signature with extra inputs. + params_str = f"src: {obj_type}" + for extra in extra_inputs: + params_str += f", {extra}: {obj_type}" + + # Assemble the full script + sorted_imports = sorted(imports) + script_lines: list[str] = [ + '"""', + "DataLab — standalone processing pipeline", + f"Generated from history ({len(compute_actions)} steps)", + '"""', + "", + ] + script_lines.extend(sorted_imports) + script_lines.append("") + script_lines.append("") + script_lines.append(f"def process({params_str}) -> {obj_type}:") + script_lines.append(' """Apply the recorded processing pipeline."""') + for line in body_lines: + script_lines.append(f" {line}" if line else "") + script_lines.append(f" return {current_var}") + script_lines.append("") + script_lines.append("") + script_lines.append('if __name__ == "__main__":') + script_lines.append(" # Standalone execution: run from DataLab's Macro panel.") + script_lines.append(" # Operates on the current object of the target panel.") + script_lines.append(" from datalab.control.proxy import RemoteProxy") + script_lines.append("") + script_lines.append(" proxy = RemoteProxy()") + panel_str = compute_actions[0].panel_str or ( + "signal" if obj_type == "SignalObj" else "image" + ) + script_lines.append(f' proxy.set_current_panel("{panel_str}")') + script_lines.append(" src = proxy.get_object()") + script_lines.append(" if src is None:") + script_lines.append( + f' raise RuntimeError("No current object in panel: {panel_str}")' + ) + if extra_inputs: + n_extra = len(extra_inputs) + script_lines.append( + " _uuids = [u for u in proxy.get_sel_object_uuids() if u != src.uuid]" + ) + script_lines.append(f" if len(_uuids) < {n_extra}:") + script_lines.append(" raise RuntimeError(") + script_lines.append( + f' "Pipeline needs {n_extra} extra selected' + ' object(s) besides the current one"' + ) + script_lines.append(" )") + for idx, extra in enumerate(extra_inputs): + script_lines.append( + f' {extra} = proxy.get_object(_uuids[{idx}], "{panel_str}")' + ) + extra_args = "".join(f", {e}" for e in extra_inputs) + script_lines.append(f" result = process(src{extra_args})") + script_lines.append(" proxy.add_object(result)") + script_lines.append(' print(f"Pipeline applied: {result.title}")') + script_lines.append("") + + script = "\n".join(script_lines) + QW.QApplication.clipboard().setText(script) + if not execenv.unattended: + QW.QMessageBox.information( + panel.mainwindow, + _("Generate macro"), + _("Macro script copied to clipboard (%d actions).") % len(compute_actions), + ) + + +def delete_selected(panel: HistoryPanel) -> None: + """Delete the selected actions or sessions (with confirmation). + + When a top-level session is selected, the entire session is deleted. + When individual actions are selected, they and all subsequent actions + in their parent session are removed. After deletion, the first + available item in the tree is selected automatically. + """ + selected = panel.tree.get_selected_actions_or_sessions(panel.history_sessions) + if not selected: + return + has_individual_actions = any(isinstance(item, HistoryAction) for item in selected) + if has_individual_actions: + msg = _( + "Do you really want to delete the selected items?\n\n" + "Note: deleting an action also removes all subsequent " + "actions in the same session." + ) + else: + msg = _("Do you really want to delete the selected items?") + reply = ( + QW.QMessageBox.Yes + if execenv.unattended + else QW.QMessageBox.question( + panel.mainwindow, + _("Delete"), + msg, + QW.QMessageBox.Yes | QW.QMessageBox.No, + QW.QMessageBox.No, + ) + ) + if reply != QW.QMessageBox.Yes: + return + sessions_to_remove: set[int] = set() + for item in selected: + if isinstance(item, HistorySession): + sessions_to_remove.add(id(item)) + else: + # Individual action: remove from its parent session + for session in panel.history_sessions: + if item in session.actions: + session.remove_action(item) + if not session.actions: + sessions_to_remove.add(id(session)) + break + panel.history_sessions = [ + s for s in panel.history_sessions if id(s) not in sessions_to_remove + ] + panel.tree.populate_tree(panel.history_sessions) + panel.refresh_compatibility_items() + panel.update_actions_state() + # Auto-select the first available item after deletion + if panel.tree.topLevelItemCount() > 0: + first = panel.tree.topLevelItem(0) + panel.tree.setCurrentItem(first) + first.setSelected(True) + + +def remove_incompatible_actions(panel: HistoryPanel) -> None: + """Remove all actions whose workspace state is incompatible. + + Shows a confirmation dialog listing how many actions will be removed, + then purges them from their sessions. Empty sessions are also removed. + """ + incompatible: list[tuple[HistorySession, HistoryAction]] = [] + for session in panel.history_sessions: + for action in session.actions: + if not action.is_current_state_compatible( + panel.mainwindow, restore_selection=True + ): + incompatible.append((session, action)) + if not incompatible: + if not execenv.unattended: + QW.QMessageBox.information( + panel.mainwindow, + _("Remove incompatible"), + _("All actions are compatible with the current workspace."), + ) + return + reply = ( + QW.QMessageBox.Yes + if execenv.unattended + else QW.QMessageBox.question( + panel.mainwindow, + _("Remove incompatible"), + _("%d incompatible action(s) will be removed. Continue?") + % len(incompatible), + QW.QMessageBox.Yes | QW.QMessageBox.No, + QW.QMessageBox.No, + ) + ) + if reply != QW.QMessageBox.Yes: + return + for session, action in incompatible: + if action in session.actions: + session.actions.remove(action) + # Remove empty sessions + panel.history_sessions = [s for s in panel.history_sessions if s.actions] + panel.tree.populate_tree(panel.history_sessions) + panel.refresh_compatibility_items() + panel.update_actions_state() diff --git a/datalab/gui/panel/history.py b/datalab/gui/panel/history.py deleted file mode 100644 index 9d0f45715..000000000 --- a/datalab/gui/panel/history.py +++ /dev/null @@ -1,4933 +0,0 @@ -# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. - -""" -.. History panel (see parent package :mod:`datalab.gui.panel`) -""" - -# pylint: disable=invalid-name # Allows short reference names like x, y, ... - -from __future__ import annotations - -import functools -import html -import inspect -import json -import logging -import os -import os.path as osp -import warnings -from contextlib import contextmanager -from copy import deepcopy -from typing import TYPE_CHECKING, Any, Callable, Generator -from uuid import uuid4 - -import numpy as np -import sigima.proc.image -import sigima.proc.signal -from guidata.configtools import get_icon -from guidata.dataset.conv import dataset_to_json, json_to_dataset -from guidata.dataset.datatypes import DataSet -from guidata.qthelpers import add_actions, create_action -from guidata.widgets.dockable import DockableWidgetMixin -from qtpy import QtCore as QC -from qtpy import QtGui as QG -from qtpy import QtWidgets as QW -from qtpy.compat import getopenfilename, getsavefilename -from sigima.objects import ImageObj, SignalObj - -from datalab.config import Conf, _ -from datalab.gui import ObjItf -from datalab.gui.panel.base import AbstractPanel -from datalab.objectmodel import get_uuid -from datalab.utils.qthelpers import qt_try_loadsave_file, save_restore_stds - -if TYPE_CHECKING: - import guidata.dataset as gds - - from datalab.gui.main import DLMainWindow - from datalab.gui.panel.base import BaseDataPanel - from datalab.gui.processor.base import BaseProcessor, FeatureNotFoundError - from datalab.h5.native import NativeH5Reader, NativeH5Writer - - -# Keys used in the kwargs dict to mark DataSet payloads, so that the -# serialization layer can round-trip them as JSON strings instead of pickling -# arbitrary Python objects. -HISTORY_SCHEMA_VERSION = 1 -# Per-action schema. Bumped to 4 to persist the ``_saved_kwargs`` snapshot -# used by the Edit mode Restore feature: persisting it across save/reload -# lets the user revert edits even after closing and re-opening the session, -# as long as Edit mode has not been definitively committed (toggled off). -# Schema version 3 introduced the ``plugin_origin`` field used to track the -# originating plugin of a compute action (see ``BaseProcessor.add_feature``). -# Sessions/actions with ``schema_version <= 2`` are still supported: the -# loader leaves ``plugin_origin`` as ``None`` and missing-plugin replay -# simply produces a generic ``FeatureNotFoundError`` instead of a -# plugin-aware warning. Schema version 2 introduced the ``output_uuids`` -# field used by the bijective action ↔ output mapping (see -# ``HistoryPanel._output_to_action``). Sessions/actions with -# ``schema_version <= 3`` leave ``_saved_kwargs`` as ``None`` on load -# (no pending edits to restore). -HISTORY_ACTION_SCHEMA_VERSION = 4 -_DATASET_MARKER = "__dataset_json__" -_DATASET_LIST_MARKER = "__dataset_list_json__" -_ROI_MARKER = "__roi_json__" - -_logger = logging.getLogger(__name__) - - -def _numpy_to_json_safe(obj: Any) -> Any: - """Recursively convert numpy arrays to lists for JSON serialization.""" - if isinstance(obj, np.ndarray): - return obj.tolist() - if isinstance(obj, dict): - return {k: _numpy_to_json_safe(v) for k, v in obj.items()} - if isinstance(obj, list): - return [_numpy_to_json_safe(i) for i in obj] - return obj - - -def _encode_roi(roi: Any) -> str: - """Encode a sigima ROI object to a JSON string via ``to_dict()``.""" - from sigima.objects.base import BaseROI # local to avoid circular import - - if not isinstance(roi, BaseROI): - raise TypeError(f"Expected BaseROI instance, got {type(roi)!r}") - roi_dict = _numpy_to_json_safe(roi.to_dict()) - # Store the concrete class so we can reconstruct on decode. - payload = { - "module": type(roi).__module__, - "class": type(roi).__qualname__, - "data": roi_dict, - } - return json.dumps(payload) - - -def _decode_roi(encoded: str) -> Any: - """Decode a JSON string back to a sigima ROI object. - - Only classes from trusted ``sigima.`` modules that are actual - :class:`sigima.objects.base.BaseROI` subclasses are allowed. - - Raises: - ValueError: If the module is not a trusted sigima module or the - resolved class is not a BaseROI subclass. - """ - import importlib - - from sigima.objects.base import BaseROI # local to avoid circular import - - _TRUSTED_ROI_MODULE_PREFIX = "sigima." - - payload = json.loads(encoded) - module_name = payload["module"] - class_name = payload["class"] - - if not module_name.startswith(_TRUSTED_ROI_MODULE_PREFIX): - raise ValueError( - f"Untrusted ROI module {module_name!r}: " - f"only modules under {_TRUSTED_ROI_MODULE_PREFIX!r} are allowed" - ) - - mod = importlib.import_module(module_name) - cls = getattr(mod, class_name) - - if not (isinstance(cls, type) and issubclass(cls, BaseROI)): - raise ValueError( - f"{module_name}.{class_name} is not a BaseROI subclass" - ) - - return cls.from_dict(payload["data"]) - - -def _encode_kwargs(kwargs: dict[str, Any]) -> dict[str, Any]: - """Encode kwargs for HDF5 storage: replace ``DataSet``, ``list[DataSet]``, - and sigima ROI values with marker dicts holding their JSON representation. - - All other values must already be HDF5-friendly primitives (str, int, float, - bool, list/tuple of the same). - - Args: - kwargs: Raw kwargs dict (may contain ``DataSet`` or ROI instances). - - Returns: - A new dict with special values wrapped in marker dicts. - """ - from sigima.objects.base import BaseROI # local to avoid circular import - - encoded: dict[str, Any] = {} - for key, value in kwargs.items(): - if value is None: - continue - if isinstance(value, DataSet): - encoded[key] = {_DATASET_MARKER: dataset_to_json(value)} - elif isinstance(value, BaseROI): - encoded[key] = {_ROI_MARKER: _encode_roi(value)} - elif ( - isinstance(value, list) - and value - and all(isinstance(item, DataSet) for item in value) - ): - encoded[key] = { - _DATASET_LIST_MARKER: [dataset_to_json(item) for item in value] - } - else: - encoded[key] = value - return encoded - - -def _decode_kwargs(kwargs: dict[str, Any]) -> dict[str, Any]: - """Inverse of :func:`_encode_kwargs`.""" - decoded: dict[str, Any] = {} - for key, value in kwargs.items(): - if isinstance(value, dict) and _DATASET_MARKER in value: - try: - decoded[key] = json_to_dataset(value[_DATASET_MARKER]) - except Exception: # pylint: disable=broad-except - warnings.warn( - _("Failed to deserialize history DataSet kwarg %r.") % key - ) - decoded[key] = None - elif isinstance(value, dict) and _ROI_MARKER in value: - try: - decoded[key] = _decode_roi(value[_ROI_MARKER]) - except Exception as exc: - raise ValueError( - f"Failed to deserialize history ROI kwarg {key!r}: {exc}" - ) from exc - elif isinstance(value, dict) and _DATASET_LIST_MARKER in value: - try: - decoded[key] = [ - json_to_dataset(item) for item in value[_DATASET_LIST_MARKER] - ] - except Exception: # pylint: disable=broad-except - warnings.warn( - _("Failed to deserialize history DataSet-list kwarg %r.") % key - ) - decoded[key] = [] - else: - decoded[key] = value - return decoded - - -def _copy_history_value(value: Any) -> Any: - """Return an independent copy of a history-serializable value.""" - from sigima.objects.base import BaseROI # local to avoid circular import - - if callable(value): - raise TypeError("History duplication does not support callable kwargs") - if isinstance(value, DataSet): - return json_to_dataset(dataset_to_json(value)) - if isinstance(value, BaseROI): - return _decode_roi(_encode_roi(value)) - if isinstance(value, dict): - return {key: _copy_history_value(item) for key, item in value.items()} - if isinstance(value, list): - return [_copy_history_value(item) for item in value] - if isinstance(value, tuple): - return tuple(_copy_history_value(item) for item in value) - return deepcopy(value) - - -def get_datetime_str() -> str: - """Return current date and time as a string""" - return QC.QDateTime.currentDateTime().toString("yyyy-MM-dd hh:mm:ss") - - -def add_to_history(kwargs_names: list[str] | None = None, title: str | None = None): - """Method decorator to add the method call to the history panel as a UI entry. - - Args: - kwargs_names: List of keyword arguments to add to the history action. - Defaults to None. - title: Title of the history action. Defaults to None. - """ - if kwargs_names is None: - kwargs_names = [] - - def add_to_history_decorator(func): - """Decorator function""" - - @functools.wraps(func) - def method_wrapper(*args, **kwargs): - """Decorator wrapper function""" - self: BaseDataPanel | BaseProcessor = args[0] - history: HistoryPanel = self.mainwindow.historypanel - histkwargs = {k: kwargs[k] for k in kwargs_names if k in kwargs} - target = _resolve_self_target(self) - if target is not None: - history.add_ui_entry( - kwargs.get("title", title) or func.__name__, - target=target, - method_name=func.__name__, - save_state=kwargs.get("save_state", True), - **histkwargs, - ) - return func(*args, **kwargs) - - return method_wrapper - - return add_to_history_decorator - - -def _resolve_self_target(self_obj: Any) -> str | None: - """Resolve a 'self' instance to a string target understood by replay. - - Used by the legacy ``@add_to_history`` decorator. Returns None when no - safe routing is possible (in which case the entry is skipped). - """ - panel_str = getattr(self_obj, "PANEL_STR_ID", None) - if panel_str == "signal": - return "signalpanel" - if panel_str == "image": - return "imagepanel" - return None - - -# --------------------------------------------------------------------------- -# HistoryAction -# --------------------------------------------------------------------------- - - -class HistoryAction(ObjItf): - """Object representing an action in the history panel. - - An action is a serialisable description of either a *compute* call (resolved - via the panel processor's feature registry) or a *UI* call (resolved as a - method on a known target -- ``mainwindow``/``signalpanel``/``imagepanel``). - - No Python ``Callable`` is ever pickled: a compute action is identified by - ``(panel_str, func_name, pattern)`` and a UI action by ``(target, - method_name)``. ``DataSet`` payloads inside ``kwargs`` are serialised with - :func:`guidata.dataset.conv.dataset_to_json`. - """ - - KIND_COMPUTE = "compute" - KIND_UI = "ui" - - FUNC_EDIT_MODE = "edit" # Name of the function parameter to enable edit mode - # Methods that create new data objects. During non-persistent (output-suppressed) - # replay, these UI actions are skipped so the panel object count stays stable. - UI_CREATION_METHODS: frozenset[str] = frozenset({"new_object"}) - - def __init__( - self, - title: str = "", - kind: str = KIND_UI, - # --- compute-only -------------------------------------------------- - panel_str: str | None = None, - func_name: str | None = None, - pattern: str | None = None, - # --- ui-only ------------------------------------------------------- - target: str | None = None, - method_name: str | None = None, - # --- common -------------------------------------------------------- - kwargs: dict[str, Any] | None = None, - state: WorkspaceState | None = None, - plugin_origin: dict[str, Any] | None = None, - ) -> None: - super().__init__() - self.__title = title or "" - self.kind = kind - # Compute kind: - self.panel_str = panel_str - self.func_name = func_name - self.pattern = pattern - # UI kind: - self.target = target - self.method_name = method_name - # Common: - self.kwargs: dict[str, Any] = ( - {} if kwargs is None else {k: v for k, v in kwargs.items() if v is not None} - ) - self.state = WorkspaceState() if state is None else state - self.dtstr: str = get_datetime_str() - self.uuid: str = str(uuid4()) - self.schema_version: int = HISTORY_ACTION_SCHEMA_VERSION - # UUIDs of the data objects produced by this action (bijective mapping - # maintained by :class:`HistoryPanel`). Populated post-compute via - # :meth:`HistoryPanel.register_action_outputs`. Empty for ``1_to_0`` - # patterns, for UI actions, and for legacy (schema_version=1) sessions - # loaded from disk (the heuristic fallback then takes over). - self.output_uuids: list[str] = [] - # Plugin origin descriptor for compute actions (None for built-in - # Sigima/DataLab features). Populated at registration time by - # :meth:`BaseProcessor.add_feature` and propagated through - # ``add_compute_entry_from_pp``. See - # :func:`datalab.gui.processor.base._detect_plugin_origin` for shape. - # Persisted as a JSON string in HDF5 (schema_version >= 3). - self.plugin_origin: dict[str, Any] | None = plugin_origin - # Transient flag (NOT serialized): set during a cascade recompute to - # display a "stale" visual marker in the tree. Cleared once the - # action has been recomputed. - self.is_stale: bool = False - # Snapshot of original kwargs before edit-mode modification. - # Set lazily when the first edit-mode change touches this action. - # Persisted to HDF5 (schema_version >= 4) so that the Restore - # action still works after a save/reload cycle while Edit mode is - # active. Cleared by ``discard_snapshot`` (definitive commit when - # toggling Edit mode off) or ``restore_kwargs`` (Restore button). - self._saved_kwargs: dict[str, Any] | None = None - - def snapshot_kwargs(self) -> None: - """Save a copy of the current kwargs as the pre-edit baseline. - - No-op if a snapshot already exists (preserves the original baseline - across multiple edit-mode replays). - """ - if self._saved_kwargs is None: - self._saved_kwargs = { - key: _copy_history_value(value) - for key, value in self.kwargs.items() - } - - def restore_kwargs(self) -> None: - """Restore kwargs from the saved snapshot and clear the snapshot.""" - if self._saved_kwargs is not None: - self.kwargs = self._saved_kwargs - self._saved_kwargs = None - - def discard_snapshot(self) -> None: - """Discard the saved snapshot (accept current kwargs as definitive).""" - self._saved_kwargs = None - - @property - def has_pending_edits(self) -> bool: - """Return True if this action has unsaved edit-mode changes.""" - return self._saved_kwargs is not None - - def regenerate_uuid(self): - """Regenerate UUID after loading from a file (no-op: per-action UUID).""" - - def copy(self, title_suffix: str | None = None) -> HistoryAction: - """Return an independent copy of this history action.""" - state = self.state.copy() - title = self.title - if title_suffix: - title = f"{title} {title_suffix}" - new_action = HistoryAction( - title=title, - kind=self.kind, - panel_str=self.panel_str, - func_name=self.func_name, - pattern=self.pattern, - target=self.target, - method_name=self.method_name, - kwargs={ - key: _copy_history_value(value) - for key, value in self.kwargs.items() - }, - state=state, - ) - new_action.output_uuids = list(self.output_uuids) - # Note: _saved_kwargs is intentionally NOT propagated to the copy. - # Copying an action acts as an implicit commit (no pending edits). - return new_action - - def copy_with_uuid_remap( - self, uuid_remap: dict[str, dict[str, str]] - ) -> HistoryAction: - """Return a copy of this action with all captured UUIDs rewritten. - - Args: - uuid_remap: Per-panel mapping ``{panel_str: {old_uuid: new_uuid}}`` - used to translate captured UUIDs to the cloned objects created by - the Duplicate operation. - - Returns: - A new independent :class:`HistoryAction` with remapped UUIDs. - """ - new_action = self.copy() - # Rewrite state.selection - for pstr, uuids in new_action.state.selection.items(): - pmap = uuid_remap.get(pstr, {}) - new_action.state.selection[pstr] = [pmap.get(u, u) for u in uuids] - # Rewrite state.object_metadata keys - for pstr, metadata in new_action.state.object_metadata.items(): - pmap = uuid_remap.get(pstr, {}) - new_action.state.object_metadata[pstr] = { - pmap.get(uuid, uuid): val for uuid, val in metadata.items() - } - # Rewrite obj2_uuids in kwargs - obj2 = new_action.kwargs.get("obj2_uuids") - if obj2: - if isinstance(obj2, str): - obj2 = [obj2] - pstr = new_action.panel_str or "" - pmap = uuid_remap.get(pstr, {}) - rewritten = [pmap.get(u, u) for u in obj2] - new_action.kwargs["obj2_uuids"] = ( - rewritten[0] if len(rewritten) == 1 else rewritten - ) - # Rewrite output_uuids — they reference the target panel. - if new_action.output_uuids: - pstr = new_action.panel_str or "" - pmap = uuid_remap.get(pstr, {}) - new_action.output_uuids = [ - pmap.get(u, u) for u in new_action.output_uuids - ] - return new_action - - @property - def title(self) -> str: - """Return object title""" - return self.__title - - # ------------------------------------------------------------------ - # Description rendering (used by the tree view) - # ------------------------------------------------------------------ - - def __iter_param_kwargs(self) -> Generator[Any, None, None]: - """Yield kwargs values whose name ends with ``param`` (typically DataSets).""" - for kwname, value in self.kwargs.items(): - if kwname.endswith("param") and value is not None: - yield value - - @property - def description(self) -> str: - """Return object description (string representing function parameters)""" - desc = "" - no_parameters = True - for param in self.__iter_param_kwargs(): - if desc: - desc += os.linesep - desc += str(param) - no_parameters = False - if desc or no_parameters: - if desc: - return desc - # Fall back to a textual hint of the resolved callable - return self.__fallback_doc() - - def __fallback_doc(self) -> str: - """Return a single-line docstring for the underlying call, if available.""" - try: - func = self._resolve_callable() - except Exception: # pylint: disable=broad-except - return "" - doc = getattr(func, "__doc__", None) or "" - return doc.splitlines()[0] if doc else "" - - @property - def description_summary(self) -> str: - """Return a short, single-line summary of the description (collapsed view). - - For DataSet parameters, uses the dataset title followed by a compact - representation of its public fields ("name=value, ..."). Falls back to - the first non-empty line of the full description when no DataSet is - present. - """ - summaries: list[str] = [] - for param in self.__iter_param_kwargs(): - if isinstance(param, DataSet): - title = param.get_title() or "" - # Collect "name=value" for each non-private item of the DataSet. - pairs: list[str] = [] - for item in param.get_items(): - name = item.get_name() - if name.startswith("_"): - continue - try: - value = item.get_value(param) - except Exception: # pylint: disable=broad-except - continue - # Format floats compactly, leave other reprs as-is - if isinstance(value, float): - value_str = f"{value:g}" - else: - value_str = str(value) - pairs.append(f"{name}={value_str}") - if pairs: - summaries.append( - f"{title}: {', '.join(pairs)}" if title else ", ".join(pairs) - ) - elif title: - summaries.append(title) - if summaries: - return " | ".join(summaries) - for line in self.description.splitlines(): - stripped = line.strip() - if stripped: - return stripped - return "" - - @property - def description_html(self) -> str: - """Return rich-text (HTML) description used for the expanded view.""" - # Normal path - parts: list[str] = [] - no_parameters = True - for param in self.__iter_param_kwargs(): - no_parameters = False - if isinstance(param, DataSet): - parts.append(param.to_html()) - else: - parts.append(html.escape(str(param)).replace("\n", "
")) - if parts: - return "
".join(parts) - if no_parameters: - text = self.description - if not text: - return "" - return html.escape(text).replace("\n", "
") - return "" - - def to_macro_code( - self, - step_index: int, - input_var: str, - imports: set[str], - obj2_var: str | None = None, - ) -> tuple[list[str], str | None]: - """Return Python source lines for this action as a standalone sigima call. - - Args: - step_index: Step number for variable naming. - input_var: Name of the input variable from the previous step. - imports: Mutable set of import statements accumulated by the caller. - obj2_var: Resolved variable name for the second operand (2-to-1 - pattern). When ``None``, the second operand is left as a - placeholder. - - Returns: - Tuple of (code_lines, output_var_name). ``output_var_name`` is - ``None`` for UI-kind actions (no data output). - """ - if self.kind != self.KIND_COMPUTE: - return [f"# (UI) {self.title} [skipped]"], None - - lines: list[str] = [] - output_var = f"result_{step_index}" - - # Determine the sigima module alias - if self.panel_str == "signal": - mod_alias = "sips" - imports.add("import sigima.proc.signal as sips") - elif self.panel_str == "image": - mod_alias = "sipi" - imports.add("import sigima.proc.image as sipi") - else: - lines.append( - f"# {self.title} [unknown panel: {self.panel_str}]" - ) - return lines, None - - lines.append(f"# Step {step_index}: {self.title}") - - param = self.kwargs.get("param") - param_var: str | None = None - - if param is not None and isinstance(param, DataSet): - param_var = f"param_{step_index}" - param_class = type(param).__qualname__ - param_module = type(param).__module__ - imports.add(f"from {param_module} import {param_class}") - lines.append(f"{param_var} = {param_class}()") - # Reconstruct each attribute - for item in param._items: # noqa: SLF001 - attr_name = item._name # noqa: SLF001 - value = getattr(param, attr_name, None) - if value is not None: - lines.append( - f"{param_var}.{attr_name} = {value!r}" - ) - - # Build the function call - func_call = f"{mod_alias}.{self.func_name}" - if self.pattern in ("1_to_1", "1_to_0"): - if param_var: - lines.append( - f"{output_var} = {func_call}" - f"({input_var}, {param_var})" - ) - else: - lines.append( - f"{output_var} = {func_call}({input_var})" - ) - elif self.pattern == "n_to_1": - if param_var: - lines.append( - f"{output_var} = {func_call}" - f"([{input_var}], {param_var})" - ) - else: - lines.append( - f"{output_var} = {func_call}([{input_var}])" - ) - elif self.pattern == "2_to_1": - second = obj2_var or "... # TODO: provide second operand" - if param_var: - lines.append( - f"{output_var} = {func_call}" - f"({input_var}, {second}, {param_var})" - ) - else: - lines.append( - f"{output_var} = {func_call}" - f"({input_var}, {second})" - ) - elif self.pattern == "1_to_n": - lines.append( - f"{output_var} = {func_call}({input_var})" - ) - else: - lines.append(f"# Unknown pattern {self.pattern!r}") - return lines, None - - return lines, output_var - - # ------------------------------------------------------------------ - # Workspace-state delegation - # ------------------------------------------------------------------ - - def is_current_state_compatible( - self, mainwindow: DLMainWindow, restore_selection: bool - ) -> bool: - """Check if the current workspace state is compatible with the saved state.""" - return self.state.is_current_state_compatible(mainwindow, restore_selection) - - def restore(self, mainwindow: DLMainWindow) -> None: - """Restore the associated workspace state.""" - self.state.restore(mainwindow) - - # ------------------------------------------------------------------ - # Replay - # ------------------------------------------------------------------ - - def _resolve_target(self, mainwindow: DLMainWindow) -> Any: - """Resolve the target object (UI kind) from the mainwindow.""" - attr = self.target or "mainwindow" - if attr == "mainwindow": - return mainwindow - return getattr(mainwindow, attr) - - def _resolve_panel(self, mainwindow: DLMainWindow): - """Resolve the data panel for a compute action.""" - if self.panel_str == "signal": - return mainwindow.signalpanel - if self.panel_str == "image": - return mainwindow.imagepanel - raise ValueError( - f"Unknown panel_str {self.panel_str!r} for compute history action" - ) - - def _resolve_callable(self) -> Callable | None: - """Best-effort lookup of the underlying callable, for description only.""" - if self.kind == self.KIND_COMPUTE and self.func_name: - for module in (sigima.proc.signal, sigima.proc.image): - func = getattr(module, self.func_name, None) - if callable(func): - return func - return None - - def _resolve_obj_by_uuid(self, mainwindow: DLMainWindow, uuid: str) -> Any | None: - """Look up an object by UUID across both data panels.""" - for panel in (mainwindow.signalpanel, mainwindow.imagepanel): - try: - return panel.objmodel[uuid] - except KeyError: - continue - return None - - def replay( - self, - mainwindow: DLMainWindow, - restore_selection: bool, - edit: bool, - uuid_remap: dict[str, dict[str, str]] | None = None, - ) -> None: - """Replay the action. - - Args: - mainwindow: DataLab's main window - restore_selection: True to restore the workspace selection before replaying - a UI-kind action. Ignored for compute-kind actions: their semantics - depends on which objects are selected (e.g. ``n_to_1`` aggregators - such as ``average`` require their captured multi-object selection), - so the captured selection is always restored before running the - computation. - edit: if True, always open the dialog boxes to edit parameters; if False, - use the parameters captured when the action was recorded - uuid_remap: optional per-panel mapping ``{panel_str: {old_uuid: new_uuid}}`` - used during full-session replay to translate captured UUIDs to the - freshly-created ones. Defaults to an empty (identity) mapping. - """ - if uuid_remap is None: - uuid_remap = {} - # Suppress history capture during replay to avoid recording - # synthetic entries when the processor re-executes features. - # The context manager is reentrant, so nesting with - # HistoryPanel.replay_restore_actions() is safe. - hpanel = getattr(mainwindow, "historypanel", None) - if hpanel is not None: - ctx = hpanel.replaying() - else: - from contextlib import nullcontext - - ctx = nullcontext() - with ctx: - self._replay_inner(mainwindow, restore_selection, edit, uuid_remap) - - def _replay_inner( - self, - mainwindow: DLMainWindow, - restore_selection: bool, - edit: bool, - uuid_remap: dict[str, dict[str, str]], - ) -> None: - """Inner replay logic, always called under the replaying guard.""" - if self.kind == self.KIND_COMPUTE: - # Compute actions are selection-driven: restore the captured - # selection (translated through ``uuid_remap`` for session - # replays) whenever it is still resolvable so chained replays - # (especially ``n_to_1`` / ``2_to_1`` / ``1_to_n`` patterns) - # operate on the original input objects rather than on whatever - # the previous action left selected. When the captured UUIDs no - # longer exist (e.g. heuristic remap missed an object), fall - # back to the current selection -- replay may still fail - # downstream, but with the native processor error rather than - # an opaque ``WorkspaceState`` incompatibility. - translated = self._translate_state(uuid_remap) - if translated.is_current_state_compatible(mainwindow, False): - translated.restore(mainwindow) - self._replay_compute(mainwindow, edit, uuid_remap) - else: - if restore_selection: - self.state.restore(mainwindow) - self._replay_ui(mainwindow, edit) - - def _translate_state(self, uuid_remap: dict[str, dict[str, str]]) -> WorkspaceState: - """Return a copy of ``self.state`` whose captured UUIDs have been - translated through ``uuid_remap`` (identity when no mapping).""" - if not uuid_remap: - return self.state - translated = WorkspaceState() - for panel_str, uuids in self.state.selection.items(): - panel_map = uuid_remap.get(panel_str, {}) - translated.selection[panel_str] = [panel_map.get(u, u) for u in uuids] - translated.states = dict(self.state.states) - translated.titles = dict(self.state.titles) - for panel_str, metadata in self.state.object_metadata.items(): - panel_map = uuid_remap.get(panel_str, {}) - translated.object_metadata[panel_str] = { - panel_map.get(uuid, uuid): dict(signature) - for uuid, signature in metadata.items() - } - return translated - - def _replay_compute( - self, - mainwindow: DLMainWindow, - edit: bool, - uuid_remap: dict[str, dict[str, str]] | None = None, - ) -> None: - """Replay a compute-kind action via ``processor.run_feature``.""" - if self.pattern == "multiple_1_to_1": - raise NotImplementedError( - _("Replaying compound 'multiple_1_to_1' actions is not supported yet.") - ) - panel = self._resolve_panel(mainwindow) - processor = panel.processor - feature = processor.get_feature(self.func_name) - run_kwargs: dict[str, Any] = {self.FUNC_EDIT_MODE: edit} - - param = self.kwargs.get("param") - if self.pattern in {"1_to_1", "1_to_0", "n_to_1"}: - if param is not None: - run_kwargs["param"] = param - if self.pattern == "n_to_1" and "pairwise" in self.kwargs: - run_kwargs["pairwise"] = self.kwargs["pairwise"] - elif self.pattern == "2_to_1": - uuids = self.kwargs.get("obj2_uuids") or [] - if isinstance(uuids, str): - uuids = [uuids] - # Translate captured UUIDs through ``uuid_remap`` (session replay). - # ``uuid_remap`` keys are ``panel.PANEL_STR_ID`` (matches - # ``WorkspaceState.selection`` keys and - # ``HistoryAction.panel_str``). - panel_map = (uuid_remap or {}).get(panel.PANEL_STR_ID, {}) - uuids = [panel_map.get(u, u) for u in uuids] - objs2 = [ - obj - for obj in (self._resolve_obj_by_uuid(mainwindow, u) for u in uuids) - if obj is not None - ] - if not objs2: - raise ValueError( - _("Cannot replay 2-to-1 action: source object(s) missing.") - ) - run_kwargs["obj2"] = objs2[0] if len(objs2) == 1 else objs2 - if param is not None: - run_kwargs["param"] = param - if "pairwise" in self.kwargs: - run_kwargs["pairwise"] = self.kwargs["pairwise"] - elif self.pattern == "1_to_n": - params = self.kwargs.get("params") or [] - run_kwargs["params"] = params - else: - raise ValueError(f"Unknown compute pattern: {self.pattern!r}") - processor.run_feature(feature, **run_kwargs) - - def _replay_ui(self, mainwindow: DLMainWindow, edit: bool) -> None: - """Replay a UI-kind action by calling ``target.method_name(**kwargs)``.""" - hpanel = mainwindow.historypanel - if ( - hpanel is not None - and hpanel.is_output_suppressed() - and self.method_name in self.UI_CREATION_METHODS - ): - return # Skip creation UI during non-persistent replay - target = self._resolve_target(mainwindow) - # Safety guard for destructive UI actions: if the action would delete - # objects but the captured selection no longer resolves to existing - # UUIDs in the target panel, skip the call rather than delete whatever - # is currently selected (which would silently destroy unrelated data). - DESTRUCTIVE_METHODS = {"remove_object", "remove_group", "delete_all_objects"} - if self.method_name in DESTRUCTIVE_METHODS: - if target is None: - _logger.warning( - "Skipping destructive replay '%s': target '%s' not found", - self.method_name, self.target, - ) - return - panel_str = getattr(target, "PANEL_STR_ID", None) - if panel_str and self.state and self.state.selection.get(panel_str): - existing_uuids = { - get_uuid(o) - for o in getattr(target, "objmodel", []) - if o is not None - } - captured = set(self.state.selection.get(panel_str, [])) - if not (captured & existing_uuids): - _logger.warning( - "Skipping destructive replay '%s': none of the captured " - "UUIDs %s exist in panel '%s' anymore", - self.method_name, list(captured), panel_str, - ) - return - method = getattr(target, self.method_name) - call_kwargs = dict(self.kwargs) - # Inject edit mode if the method supports it - try: - sig = inspect.signature(method) - if self.FUNC_EDIT_MODE in sig.parameters: - call_kwargs[self.FUNC_EDIT_MODE] = edit - except (TypeError, ValueError): - pass - method(**call_kwargs) - - # ------------------------------------------------------------------ - # Serialisation -- no Callable is ever pickled - # ------------------------------------------------------------------ - - def serialize(self, writer: NativeH5Writer) -> None: - """Serialize this action.""" - with writer.group("schema_version"): - writer.write(self.schema_version) - with writer.group("kind"): - writer.write(self.kind) - with writer.group("title"): - writer.write(self.__title) - if self.panel_str is not None: - with writer.group("panel_str"): - writer.write(self.panel_str) - if self.func_name is not None: - with writer.group("func_name"): - writer.write(self.func_name) - if self.pattern is not None: - with writer.group("pattern"): - writer.write(self.pattern) - if self.target is not None: - with writer.group("target"): - writer.write(self.target) - if self.method_name is not None: - with writer.group("method_name"): - writer.write(self.method_name) - encoded = _encode_kwargs(self.kwargs) - if encoded: - with writer.group("kwargs"): - writer.write_dict(encoded) - # ``saved_kwargs`` (schema_version >= 4): persisted Edit mode - # snapshot so the Restore button keeps working after save/reload. - # Skipped (group omitted) when no pending edits exist, keeping the - # on-disk layout byte-identical to schema v3 in the common case. - if self._saved_kwargs is not None: - encoded_saved = _encode_kwargs(self._saved_kwargs) - # Write the group unconditionally (even when empty) so that the - # round-trip preserves the distinction between None (no pending - # edits) and {} (degenerate empty snapshot, keeps has_pending_edits). - with writer.group("saved_kwargs"): - writer.write_dict(encoded_saved) - # Only emit ``output_uuids`` when non-empty: the schema_version field - # already distinguishes v2 (with bijective mapping) from legacy v1. - # Empty lists are skipped to avoid h5py edge cases with empty arrays. - if self.output_uuids: - with writer.group("output_uuids"): - writer.write(list(self.output_uuids)) - # ``plugin_origin`` (schema_version >= 3): stored as a JSON string so - # the HDF5 schema stays trivially round-trippable. Skipped when None - # to keep built-in-only sessions byte-identical to schema v2. - if self.plugin_origin is not None: - with writer.group("plugin_origin"): - writer.write(json.dumps(self.plugin_origin)) - with writer.group("state"): - self.state.serialize(writer) - with writer.group("dtstr"): - writer.write(self.dtstr) - - def deserialize(self, reader: NativeH5Reader) -> None: - """Deserialize this action.""" - self.schema_version = reader.read( - "schema_version", default=HISTORY_SCHEMA_VERSION - ) - with reader.group("kind"): - self.kind = reader.read_any() - with reader.group("title"): - self.__title = reader.read_any() - # Optional descriptors are written conditionally; check existence in - # the underlying HDF5 group before reading to avoid leaking ``__seq`` - # frames on the option stack via guidata's read_any fallback path. - current = reader.h5 - for option in reader.option: - current = current.require_group(option) - for attr in ("panel_str", "func_name", "pattern", "target", "method_name"): - if attr in current.attrs or attr in current: - with reader.group(attr): - setattr(self, attr, reader.read_any()) - else: - setattr(self, attr, None) - if "kwargs" in current.attrs or "kwargs" in current: - with reader.group("kwargs"): - raw = reader.read_dict() - self.kwargs = _decode_kwargs(raw) - else: - self.kwargs = {} - # ``saved_kwargs`` was introduced in schema_version=4. Legacy - # actions (v1, v2, v3) leave it as ``None`` — no Edit mode - # snapshot to restore. - if "saved_kwargs" in current.attrs or "saved_kwargs" in current: - with reader.group("saved_kwargs"): - raw_saved = reader.read_dict() - self._saved_kwargs = _decode_kwargs(raw_saved) - else: - self._saved_kwargs = None - # ``output_uuids`` was introduced in schema_version=2. Legacy actions - # leave it empty; consumers fall back to the heuristic matcher. - if "output_uuids" in current.attrs or "output_uuids" in current: - with reader.group("output_uuids"): - raw_outputs = reader.read_any() - if raw_outputs is None: - self.output_uuids = [] - else: - self.output_uuids = [str(u) for u in raw_outputs] - else: - self.output_uuids = [] - # ``plugin_origin`` was introduced in schema_version=3. Legacy actions - # (v1, v2) leave it as ``None``: a subsequent replay of a missing - # plugin function will then surface a generic ``FeatureNotFoundError`` - # instead of the richer plugin-aware warning. - if "plugin_origin" in current.attrs or "plugin_origin" in current: - with reader.group("plugin_origin"): - raw_origin = reader.read_any() - if raw_origin in (None, ""): - self.plugin_origin = None - else: - try: - self.plugin_origin = json.loads(raw_origin) - except (TypeError, ValueError): - _logger.warning( - "Failed to decode plugin_origin for action %s; " - "falling back to None.", - self.uuid, - ) - self.plugin_origin = None - else: - self.plugin_origin = None - with reader.group("state"): - self.state.deserialize(reader) - with reader.group("dtstr"): - self.dtstr = reader.read_any() - - -class WorkspaceState: - """Object representing the workspace state at a given time. - - The workspace state stores the per-panel selection of objects by **UUID** - (robust against reordering, renaming or interleaved insertions). For - informative display, it also retains the data shape and title of each - selected object at the time of capture. - """ - - def __init__(self) -> None: - """Create a new workspace state""" - # The selection is stored as a dictionary where the key is the panel name - # and the value is the list of UUIDs of selected objects. - self.selection: dict[str, list[str]] = {} - # The states are stored as a dictionary where the key is the panel name - # and the value is the list of states (str) of the objects in the panel. The - # state is a string containing the object data shape (kept for informative - # display only -- not used for selection matching anymore). - self.states: dict[str, list[str]] = {} - # The titles are stored as a dictionary where the key is the panel name and the - # value is the list of titles of the objects in the panel. The title is only - # informative and is not used to determine if two objects have the same state. - self.titles: dict[str, list[str]] = {} - # Structured data signatures of selected objects, keyed by panel name and UUID. - # This is the current schema used for compatibility checks. Missing metadata - # means a pre-Gate-2 history and falls back to UUID-existence validation. - self.object_metadata: dict[str, dict[str, dict[str, Any]]] = {} - - def copy(self) -> WorkspaceState: - """Return an independent copy of this workspace state.""" - state = WorkspaceState() - state.selection = deepcopy(self.selection) - state.states = deepcopy(self.states) - state.titles = deepcopy(self.titles) - state.object_metadata = deepcopy(self.object_metadata) - return state - - def serialize(self, writer: NativeH5Writer) -> None: - """Serialize this workspace state - - Args: - writer: Writer - """ - with writer.group("selection"): - writer.write_dict(self.selection) - with writer.group("states"): - writer.write_dict(self.states) - with writer.group("titles"): - writer.write_dict(self.titles) - with writer.group("object_metadata"): - writer.write_dict(self.object_metadata) - - def deserialize(self, reader: NativeH5Reader) -> None: - """Deserialize this workspace state - - Args: - reader: Reader - """ - with reader.group("selection"): - self.selection = reader.read_dict() - with reader.group("states"): - self.states = reader.read_dict() - with reader.group("titles"): - self.titles = reader.read_dict() - current = reader.h5 - for option in reader.option: - current = current[option] - if "object_metadata" in current.attrs or "object_metadata" in current: - with reader.group("object_metadata"): - self.object_metadata = reader.read_dict() - else: - self.object_metadata = {} - # Normalize legacy translated keys to stable panel identifiers. - self.selection = self._normalize_panel_keys(self.selection) - self.states = self._normalize_panel_keys(self.states) - self.titles = self._normalize_panel_keys(self.titles) - self.object_metadata = self._normalize_panel_keys(self.object_metadata) - - def get_current_selection(self, mainwindow: DLMainWindow) -> dict[str, list[str]]: - """Get the current selection in the workspace, keyed by panel name and - valued by the list of selected object UUIDs. - - Args: - mainwindow: DataLab's main window - - Returns: - Current selection in the workspace, by panel name → list of UUIDs. - """ - selection: dict[str, list[str]] = {} - for panel in (mainwindow.signalpanel, mainwindow.imagepanel): - selection[panel.PANEL_STR_ID] = [ - get_uuid(obj) - for obj in panel.objview.get_sel_objects(include_groups=True) - ] - return selection - - @staticmethod - def get_object_metadata(obj: Any) -> dict[str, Any]: - """Return a stable data signature for an object.""" - data = getattr(obj, "data", None) - shape = getattr(data, "shape", None) - if shape is None: - return {} - shape = [int(size) for size in shape] - ndim = getattr(data, "ndim", len(shape)) - return {"shape": shape, "ndim": int(ndim)} - - @staticmethod - def _normalize_object_metadata(metadata: dict[str, Any]) -> dict[str, Any]: - """Normalize object metadata loaded from HDF5 for comparison.""" - shape = metadata.get("shape") - if shape is None: - return {} - shape = [int(size) for size in shape] - ndim = metadata.get("ndim", len(shape)) - return {"shape": shape, "ndim": int(ndim)} - - # Mapping from legacy translated panel keys to stable identifiers. - # Covers the English translations; other locales are handled by the - # catch-all ``"signal"``/``"image"`` substring heuristic below. - _LEGACY_PANEL_KEY_MAP: dict[str, str] = { - "Signal Panel": "signal", - "Image Panel": "image", - } - - @classmethod - def _normalize_panel_key(cls, key: str) -> str: - """Map a potentially translated panel key to its stable identifier.""" - if key in ("signal", "image"): - return key - mapped = cls._LEGACY_PANEL_KEY_MAP.get(key) - if mapped is not None: - return mapped - # Heuristic for non-English translations: look for the stable ID - # substring in the key (e.g. "Panneau signal" → "signal"). - lowered = key.lower() - for stable_id in ("signal", "image"): - if stable_id in lowered: - return stable_id - return key - - @classmethod - def _normalize_panel_keys(cls, d: dict) -> dict: - """Return *d* with all top-level keys normalized to stable panel IDs.""" - return {cls._normalize_panel_key(k): v for k, v in d.items()} - - def save(self, mainwindow: DLMainWindow) -> None: - """Save the current workspace state - - Args: - mainwindow: DataLab's main window - """ - self.selection = self.get_current_selection(mainwindow) - self.object_metadata = {} - for panel in (mainwindow.signalpanel, mainwindow.imagepanel): - sel_uuids = self.selection[panel.PANEL_STR_ID] - self.states[panel.PANEL_STR_ID] = [ - str(obj.data.shape) - for obj in panel.objmodel - if get_uuid(obj) in sel_uuids - ] - self.titles[panel.PANEL_STR_ID] = [ - obj.title for obj in panel.objmodel if get_uuid(obj) in sel_uuids - ] - # Store metadata for ALL panel objects (not just selected) so that - # the dict key order captures the full panel ordering. During - # session replay the key order lets us sort old UUIDs by their - # original panel position, which prevents non-commutative 2_to_1 - # operand swaps in the positional-fallback code path. - # ``is_current_state_compatible`` only checks *selected* UUIDs, so - # the extra entries are harmless for compatibility validation. - self.object_metadata[panel.PANEL_STR_ID] = { - get_uuid(obj): self.get_object_metadata(obj) - for obj in panel.objmodel - } - - def is_current_state_compatible( # pylint: disable=unused-argument - self, mainwindow: DLMainWindow, restore_selection: bool - ) -> bool: - """Check if the current workspace state is compatible with the saved state. - - Compatibility means that **every** UUID recorded in the saved selection - still exists in the corresponding panel. When structured object metadata - is available (current schema), each selected object's data shape and - dimensions must also match the saved signature. Histories without this - metadata fall back to legacy UUID-existence validation. - - Args: - mainwindow: DataLab's main window - restore_selection: Unused (kept for API symmetry). With UUID-based - identity, the compatibility check no longer depends on the current - selection -- it only depends on object existence. - - Returns: - True if every saved UUID still exists in its panel and saved - metadata, when available, still matches. - """ - if not self.selection: - return True - for panel in (mainwindow.signalpanel, mainwindow.imagepanel): - saved_uuids = self.selection.get(panel.PANEL_STR_ID, []) - existing_uuids = set(panel.objmodel.get_object_ids()) - saved_metadata = self.object_metadata.get(panel.PANEL_STR_ID, {}) - for uuid in saved_uuids: - if uuid not in existing_uuids: - return False - if uuid in saved_metadata: - current = self.get_object_metadata(panel.objmodel[uuid]) - current = self._normalize_object_metadata(current) - saved = self._normalize_object_metadata(saved_metadata[uuid]) - if saved and current != saved: - return False - return True - - def restore(self, mainwindow: DLMainWindow) -> None: - """Restore the workspace state by selecting the recorded UUIDs. - - Args: - mainwindow: DataLab's main window - - Raises: - ValueError: If at least one of the saved UUIDs no longer exists in - its panel. - """ - if not self.selection: - return - if not self.is_current_state_compatible(mainwindow, False): - raise ValueError( - "Current workspace state is not compatible with saved state" - ) - for panel in (mainwindow.signalpanel, mainwindow.imagepanel): - uuids = self.selection.get(panel.PANEL_STR_ID, []) - if uuids: - panel.objview.select_objects(uuids) - - -class HistorySession: - """Object representing a history session, i.e. a list of actions. - - A history session is a list of actions that can be replayed in the same order - as they were added to the history session. The history session can be saved to - a file and loaded from a file. - - Args: - title: Title of the history session - number: Number of the history session - """ - - def __init__(self, title: str = "", number: int = 0) -> None: - """Create a new history session""" - prefix = _("Session") - self.title = title if title else f"{prefix} {number:03d}" - self.number = number - self.dtstr: str = get_datetime_str() - self.actions: list[HistoryAction] = [] - self.schema_version: int = HISTORY_SCHEMA_VERSION - - def add_action(self, action: HistoryAction) -> None: - """Add an action to the history session - - Args: - action: Action to add - """ - self.actions.append(action) - - def copy( - self, title: str | None = None, action_title_suffix: str | None = None - ) -> HistorySession: - """Return an independent copy of this history session.""" - session = HistorySession(title=title or self.title, number=self.number) - session.actions = [ - action.copy(title_suffix=action_title_suffix) for action in self.actions - ] - return session - - def copy_with_uuid_remap( - self, title: str, uuid_remap: dict[str, dict[str, str]] - ) -> HistorySession: - """Return a copy of this session with all UUIDs rewritten via ``uuid_remap``. - - Used by the Duplicate operation to build an independent session whose - captured object references point to the cloned data objects. - - Args: - title: Title for the new session. - uuid_remap: Per-panel mapping ``{panel_str: {old_uuid: new_uuid}}``. - - Returns: - A new :class:`HistorySession` with all captured UUIDs remapped. - """ - session = HistorySession(title=title, number=self.number) - session.actions = [ - action.copy_with_uuid_remap(uuid_remap) for action in self.actions - ] - return session - - def is_current_state_compatible( - self, mainwindow: DLMainWindow, restore_selection: bool - ) -> bool: - """Check if the current workspace state is compatible with the saved state - - Args: - mainwindow: DataLab's main window - restore_selection: True to restore the selection before checking the state - - Returns: - bool: True if the current workspace state is compatible with the saved state - """ - if self.actions: - return self.actions[0].is_current_state_compatible( - mainwindow, restore_selection - ) - return True - - def restore(self, mainwindow: DLMainWindow) -> None: - """Restore the state of the workspace associated to the first action of session - - Args: - mainwindow: DataLab's main window - """ - if self.actions: - self.actions[0].restore(mainwindow) - - def replay( - self, mainwindow: DLMainWindow, restore_selection: bool, edit: bool - ) -> None: - """Replay the history session - - Args: - mainwindow: DataLab's main window - restore_selection: True to restore the workspace selection before replaying - edit: if True, always open the dialog boxes to edit parameters, if False, - use the parameters passed when creating the action - """ - # Per-panel ``{old_uuid: new_uuid}`` mapping, populated as UI actions - # create new objects. Used by compute actions to translate their - # captured selection (and ``obj2_uuids``) into the freshly-created - # UUIDs of the current replay, so chained ``n_to_1`` / ``2_to_1`` / - # ``1_to_n`` actions operate on the correct inputs. Keys are - # ``panel.PANEL_STR_ID`` (matches ``WorkspaceState.selection`` keys). - panels = (mainwindow.signalpanel, mainwindow.imagepanel) - uuid_remap: dict[str, dict[str, str]] = {p.PANEL_STR_ID: {} for p in panels} - # FIFO of newly-created UUIDs not yet claimed by a remap entry -- - # required because most creation UI actions (e.g. ``new_signal``) - # are recorded with ``save_state=False`` (empty captured selection), - # so we cannot pair captured-vs-new UUIDs by position at UI time. - # Subsequent compute actions claim from this queue on demand. - unclaimed: dict[str, list[str]] = {p.PANEL_STR_ID: [] for p in panels} - def _claim_unmapped( - pstr: str, - old_uuids: list[str], - action: HistoryAction, - ) -> None: - """Claim unclaimed new UUIDs for *old_uuids* not yet in uuid_remap. - - Uses title matching (scanning the full unclaimed queue) followed by - panel-order index alignment to deterministically pair old UUIDs - to the correct new UUIDs, regardless of creation order. - """ - # Collect unmapped UUIDs (deduplicated, preserving first-seen order). - all_unmapped: list[str] = [] - seen: set[str] = set() - for u in old_uuids: - if u not in seen and u not in uuid_remap.get(pstr, {}): - all_unmapped.append(u) - seen.add(u) - if not all_unmapped: - return - # Re-sort by recorded panel position when available. - panel_order = list( - action.state.object_metadata.get(pstr, {}).keys() - ) - if panel_order and all(u in panel_order for u in all_unmapped): - all_unmapped.sort(key=panel_order.index) - queue = unclaimed.get(pstr) or [] - if not queue: - return - # Build old UUID → title from captured state and object_metadata. - sel_uuids = action.state.selection.get(pstr, []) - sel_titles = action.state.titles.get(pstr, []) - old_titles: dict[str, str] = {} - for _u, _t in zip(sel_uuids, sel_titles): - if _u in seen: - old_titles[_u] = _t - obj_meta = action.state.object_metadata.get(pstr, {}) - for _u in all_unmapped: - if _u not in old_titles and _u in obj_meta: - meta = obj_meta[_u] - if isinstance(meta, dict) and "title" in meta: - old_titles[_u] = meta["title"] - # Build new UUID → title from the live panel (full queue). - new_titles: dict[str, str] = {} - panel_obj = None - for p in panels: - if p.PANEL_STR_ID == pstr: - panel_obj = p - break - if panel_obj is not None: - for nu in queue: - try: - new_titles[nu] = panel_obj.objmodel[nu].title - except KeyError: - pass - # Phase 1: title matching against the FULL queue. - assigned_old: set[str] = set() - assigned_new: set[str] = set() - for ou in all_unmapped: - if ou not in old_titles: - continue - title = old_titles[ou] - candidates = [ - nu - for nu in queue - if nu not in assigned_new - and new_titles.get(nu) == title - ] - if len(candidates) == 1: - uuid_remap.setdefault(pstr, {})[ou] = candidates[0] - assigned_old.add(ou) - assigned_new.add(candidates[0]) - # Phase 2: positional fallback using panel-order alignment. - # Two modes depending on whether the remaining queue covers all - # free recorded panel slots: - # - # A) Absolute index alignment (len(rem_queue) == len(free_indices)): - # Each free panel_order index maps 1-to-1 to a queue slot. - # This ensures e.g. the second-created object maps to the - # second queue entry even when only a subset of old UUIDs - # needs claiming. - # - # B) Relative order fallback (queue is a strict subset): - # The queue only contains later compute-created objects while - # earlier full-panel entries are absent. Absolute alignment - # would leave non-first old UUIDs unmapped. Instead, zip - # rem_old (already sorted by panel order) with rem_queue - # sequentially. - rem_old = [u for u in all_unmapped if u not in assigned_old] - if rem_old and panel_order: - rem_queue = [u for u in queue if u not in assigned_new] - # Find which panel_order indices are "free" (unclaimed). - free_indices: list[int] = [] - for idx, po_uuid in enumerate(panel_order): - if po_uuid not in uuid_remap.get(pstr, {}): - if po_uuid not in assigned_old: - free_indices.append(idx) - if len(rem_queue) == len(free_indices): - # Mode A: absolute index alignment. - idx_to_new: dict[int, str] = {} - for qi, fi in enumerate(free_indices): - if qi < len(rem_queue): - idx_to_new[fi] = rem_queue[qi] - for ou in rem_old: - if ou in panel_order: - idx = panel_order.index(ou) - if idx in idx_to_new: - nu = idx_to_new[idx] - uuid_remap.setdefault(pstr, {})[ou] = nu - assigned_new.add(nu) - else: - # Mode B: relative order fallback. - for ou, nu in zip(rem_old, rem_queue): - uuid_remap.setdefault(pstr, {})[ou] = nu - assigned_new.add(nu) - elif rem_old: - # No panel_order available: sequential fallback. - rem_queue = [u for u in queue if u not in assigned_new] - for ou, nu in zip(rem_old, rem_queue): - uuid_remap.setdefault(pstr, {})[ou] = nu - assigned_new.add(nu) - # Remove all assigned new UUIDs from the unclaimed queue. - if assigned_new: - unclaimed[pstr] = [u for u in queue if u not in assigned_new] - - for action in self.actions[:]: - before = {p.PANEL_STR_ID: set(p.objmodel.get_object_ids()) for p in panels} - if action.kind == HistoryAction.KIND_COMPUTE: - # Lazy-resolve any captured UUIDs missing from the remap by - # claiming from ``unclaimed`` (deterministic: title + panel-order). - pstr = action.panel_str or "" - captured = action.state.selection.get(pstr, []) - if action.pattern == "2_to_1": - # For 2_to_1: collect ALL unmapped old UUIDs from both - # captured selection and obj2_uuids in one batch so - # operand order is preserved by the helper. - obj2 = action.kwargs.get("obj2_uuids") or [] - if isinstance(obj2, str): - obj2 = [obj2] - _claim_unmapped(pstr, list(obj2) + list(captured), action) - else: - # For all other compute patterns (1_to_1, n_to_1, etc.): - # use the same deterministic helper. - _claim_unmapped(pstr, list(captured), action) - action.replay( - mainwindow, - restore_selection=restore_selection, - edit=edit, - uuid_remap=uuid_remap, - ) - # Post-action bookkeeping: track new/removed UUIDs for *every* - # action kind so that later actions consuming compute-created - # outputs can resolve them through ``uuid_remap`` / ``unclaimed``. - for panel in panels: - pstr = panel.PANEL_STR_ID - current_ids = set(panel.objmodel.get_object_ids()) - new_uuids = [ - u - for u in panel.objmodel.get_object_ids() - if u not in before[pstr] - ] - # Drop vanished UUIDs from the unclaimed queue and the - # reverse remap entries (e.g. ``Remove selected objects``): - # this keeps the FIFO claim in sync with the live panel - # contents during chained creation/removal replays. - removed_uuids = before[pstr] - current_ids - if removed_uuids: - unclaimed[pstr] = [ - u for u in unclaimed.get(pstr, []) if u not in removed_uuids - ] - panel_map = uuid_remap.get(pstr, {}) - for old_key in [ - k for k, v in panel_map.items() if v in removed_uuids - ]: - panel_map.pop(old_key, None) - if not new_uuids: - continue - if action.kind == HistoryAction.KIND_UI: - captured = action.state.selection.get(pstr, []) - if captured: - # Captured post-action selection available: pair - # captured UUIDs with new UUIDs by position. - for old_uuid, new_uuid in zip(captured, new_uuids): - uuid_remap.setdefault(pstr, {})[old_uuid] = new_uuid - # Any extra newly-created UUIDs go to the queue. - unclaimed.setdefault(pstr, []).extend( - new_uuids[len(captured) :] - ) - else: - # No captured selection (typical of ``new_signal``): - # queue all new UUIDs for lazy claiming. - unclaimed.setdefault(pstr, []).extend(new_uuids) - else: - # Compute actions: queue all newly-created UUIDs so - # later actions can lazily claim them. Do NOT map - # captured input UUIDs to output UUIDs — compute - # inputs and outputs are semantically different. - unclaimed.setdefault(pstr, []).extend(new_uuids) - - # Visually close the replay: select the output of the last compute - # action so the user sees the final result highlighted in the panel. - # Without this, the very last action's output is never selected - # (intermediate actions are implicitly "closed" by the next - # iteration's input restore). - if self.actions: - last = self.actions[-1] - if last.kind == HistoryAction.KIND_COMPUTE: - hpanel = getattr(mainwindow, "historypanel", None) - if hpanel is not None: - output_uuid = hpanel._action_output_uuid(last) - if output_uuid: - panel_str = last.panel_str or "" - panel_map = uuid_remap.get(panel_str, {}) - mapped_uuid = panel_map.get(output_uuid, output_uuid) - for panel in panels: - if panel.PANEL_STR_ID == panel_str: - try: - panel.objview.select_objects([mapped_uuid]) - except KeyError: - pass - break - - def serialize(self, writer: NativeH5Writer) -> None: - """Serialize this history session - - Args: - writer: Writer - """ - with writer.group("schema_version"): - writer.write(self.schema_version) - with writer.group("title"): - writer.write(self.title) - with writer.group("number"): - writer.write(self.number) - with writer.group("dtstr"): - writer.write(self.dtstr) - writer.write_object_list(self.actions, "actions") - - def deserialize(self, reader: NativeH5Reader) -> None: - """Deserialize this history session - - Args: - reader: Reader - """ - self.schema_version = reader.read( - "schema_version", default=HISTORY_SCHEMA_VERSION - ) - with reader.group("title"): - self.title = reader.read_any() - with reader.group("number"): - self.number = reader.read_any() - with reader.group("dtstr"): - self.dtstr = reader.read_any() - self.actions = reader.read_object_list("actions", HistoryAction) - - def remove_action(self, action: HistoryAction) -> None: - """Remove an action from the history session - - This implies removing all subsequent actions. If action is not found, this - fails silently. - - Args: - action: Action to remove - """ - if action in self.actions: - index = self.actions.index(action) - self.actions = self.actions[:index] - - -class CollapsibleDescriptionWidget(QW.QWidget): - """Compact, expandable cell widget for the history Description column. - - Shows a single-line summary by default; a chevron toggle reveals the full - HTML description (mirroring the *Properties* tab rendering). - """ - - toggled = QC.Signal(bool) - - def __init__( - self, - summary: str, - html_text: str, - expanded: bool = False, - parent: QW.QWidget | None = None, - ) -> None: - super().__init__(parent) - self._summary = summary - self._html = html_text - self._expanded = expanded - - self._toggle = QW.QToolButton(self) - self._toggle.setAutoRaise(True) - self._toggle.setCheckable(True) - self._toggle.setFocusPolicy(QC.Qt.NoFocus) - self._toggle.setArrowType(QC.Qt.RightArrow) - self._toggle.setToolTip(_("Show details")) - - self._label = QW.QLabel(self) - self._label.setTextFormat(QC.Qt.RichText) - self._label.setWordWrap(True) - self._label.setTextInteractionFlags(QC.Qt.TextSelectableByMouse) - self._label.setAlignment(QC.Qt.AlignTop | QC.Qt.AlignLeft) - - layout = QW.QHBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(2) - layout.addWidget(self._toggle, 0, QC.Qt.AlignTop) - layout.addWidget(self._label, 1) - - # Hide the toggle when there is nothing more to show than the summary. - if not self._html or self._html_matches_summary(): - self._toggle.setVisible(False) - - self._toggle.toggled.connect(self._on_toggled) - self._refresh() - - def _html_matches_summary(self) -> bool: - """Return True when the HTML rendering would not add information.""" - return self._html.strip() == html.escape(self._summary).strip() - - def _on_toggled(self, checked: bool) -> None: - self._expanded = checked - self._refresh() - self.toggled.emit(checked) - - def _refresh(self) -> None: - if self._expanded: - self._toggle.setArrowType(QC.Qt.DownArrow) - self._toggle.setToolTip(_("Hide details")) - self._label.setText(self._html or html.escape(self._summary)) - else: - self._toggle.setArrowType(QC.Qt.RightArrow) - self._toggle.setToolTip(_("Show details")) - self._label.setText(html.escape(self._summary)) - self.updateGeometry() - - def is_expanded(self) -> bool: - """Return current expanded state.""" - return self._expanded - - def set_expanded(self, expanded: bool) -> None: - """Programmatically set the expanded state.""" - if expanded == self._expanded: - return - self._toggle.setChecked(expanded) - - -class HistoryTree(QW.QTreeWidget): - """Tree widget for the history panel""" - - DESCRIPTION_COLUMN = 2 - COMPATIBILITY_ROLE = QC.Qt.UserRole + 1 - - def __init__(self, parent: QW.QWidget) -> None: - """Create a new history tree widget""" - super().__init__(parent) - self.setHeaderLabels([_("Title"), _("Date and time"), _("Description")]) - self.setContextMenuPolicy(QC.Qt.CustomContextMenu) - self.setSelectionMode(QW.QAbstractItemView.ContiguousSelection) - self.setUniformRowHeights(False) - header = self.header() - header.setSectionResizeMode(self.DESCRIPTION_COLUMN, QW.QHeaderView.Stretch) - # Per-action expanded state, preserved across repopulate (delete/replay). - self.__expanded_state: dict[str, bool] = {} - - def __on_description_toggled(self, uuid: str, expanded: bool) -> None: - """Remember the expanded state of a description cell.""" - self.__expanded_state[uuid] = expanded - # Force the tree to recompute row heights now that the label content - # has changed. - self.scheduleDelayedItemsLayout() - - def __install_description_widget( - self, item: QW.QTreeWidgetItem, action: HistoryAction - ) -> None: - """Attach the collapsible description widget to ``item`` (column 2). - - The item must already be inserted in the tree before calling this. - """ - expanded = self.__expanded_state.get(action.uuid, False) - widget = CollapsibleDescriptionWidget( - action.description_summary, - action.description_html, - expanded=expanded, - parent=self, - ) - widget.toggled.connect( - lambda checked, uuid=action.uuid: self.__on_description_toggled( - uuid, checked - ) - ) - # Clear any text the item may carry for that column to avoid double - # rendering behind the widget. - item.setText(self.DESCRIPTION_COLUMN, "") - self.setItemWidget(item, self.DESCRIPTION_COLUMN, widget) - - @classmethod - def action_to_tree_item(cls, action: HistoryAction) -> QW.QTreeWidgetItem: - """Convert an action to a tree item - - Args: - action: Action to convert - - Returns: - QW.QTreeWidgetItem: Tree item - """ - # Description column is left empty: a CollapsibleDescriptionWidget is - # installed by ``HistoryTree`` once the item is inserted in the tree. - item = QW.QTreeWidgetItem([action.title, action.dtstr, ""]) - item.setData(0, QC.Qt.UserRole, action.uuid) - item.setData(0, cls.COMPATIBILITY_ROLE, True) - return item - - def update_compatibility_states( - self, history_sessions: list[HistorySession], mainwindow: DLMainWindow - ) -> None: - """Update action item visual state from workspace compatibility.""" - default_brush = QG.QBrush() - disabled_brush = QG.QBrush( - self.palette().color(QG.QPalette.Disabled, QG.QPalette.Text) - ) - compatible_tip = _("Action is compatible with the current workspace state.") - incompatible_tip = _( - "Action is not compatible with the current workspace state." - ) - for i in range(self.topLevelItemCount()): - session_item = self.topLevelItem(i) - for j in range(session_item.childCount()): - child = session_item.child(j) - uuid = child.data(0, QC.Qt.UserRole) - action = self.get_action_from_uuid(uuid, history_sessions) - compatible = action.is_current_state_compatible( - mainwindow, restore_selection=True - ) - child.setData(0, self.COMPATIBILITY_ROLE, compatible) - brush = default_brush if compatible else disabled_brush - icon = get_icon("apply.svg") if compatible else get_icon("delete.svg") - child.setIcon(0, icon) - for col in range(self.columnCount()): - child.setForeground(col, brush) - child.setToolTip( - col, compatible_tip if compatible else incompatible_tip - ) - - def __forget_orphan_expanded_states( - self, history_sessions: list[HistorySession] - ) -> None: - """Drop expanded-state entries for actions that no longer exist.""" - live_uuids = { - action.uuid for session in history_sessions for action in session.actions - } - self.__expanded_state = { - uuid: state - for uuid, state in self.__expanded_state.items() - if uuid in live_uuids - } - - def populate_tree(self, history_sessions: list[HistorySession]) -> None: - """Populate the history tree widget - - Args: - history_sessions: List of history sessions - """ - self.__forget_orphan_expanded_states(history_sessions) - self.clear() - for session in history_sessions: - ritem = QW.QTreeWidgetItem([session.title, session.dtstr]) - ritem.setData(0, self.COMPATIBILITY_ROLE, True) - self.addTopLevelItem(ritem) - for action in session.actions: - child = self.action_to_tree_item(action) - ritem.addChild(child) - self.__install_description_widget(child, action) - self.expandAll() - for col in (0, 1): - self.resizeColumnToContents(col) - - def rearrange_tree(self) -> None: - """Rearrange the history tree widget""" - self.expandAll() - for col in (0, 1): - self.resizeColumnToContents(col) - - def add_action_to_tree(self, action: HistoryAction) -> None: - """Add an action to the history tree widget - - Args: - action: Action to add - """ - item = self.action_to_tree_item(action) - ritem = self.topLevelItem(self.topLevelItemCount() - 1) - ritem.addChild(item) - self.__install_description_widget(item, action) - - def refresh_action_item(self, action: HistoryAction) -> None: - """Refresh the tree item corresponding to ``action``. - - Re-installs the description widget so it reflects the current - ``action.kwargs`` (e.g. after the user edited a ``param`` via the - Processing tab of the Signal/Image panel). Also applies a light - orange background when ``action.is_stale`` is True, to signal that - the action is currently being recomputed in a cascade. - """ - target_uuid = action.uuid - stale_brush = QG.QBrush(QG.QColor(255, 220, 150)) # light orange - normal_brush = QG.QBrush() - iterator = QW.QTreeWidgetItemIterator(self) - while iterator.value(): - item = iterator.value() - if item.data(0, QC.Qt.UserRole) == target_uuid: - # Remove and re-install the collapsible description widget so - # it reflects the mutated ``action.kwargs``. - self.removeItemWidget(item, self.DESCRIPTION_COLUMN) - self.__install_description_widget(item, action) - brush = stale_brush if action.is_stale else normal_brush - for col in range(self.columnCount()): - item.setBackground(col, brush) - self.scheduleDelayedItemsLayout() - return - iterator += 1 - - def get_action_from_uuid( - self, uuid: str, history_sessions: list[HistorySession] - ) -> HistoryAction: - """Get the action from its UUID - - Args: - uuid: Action UUID - history_sessions: List of history sessions - - Returns: - HistoryAction: Action - """ - for session in history_sessions: - for action in session.actions: - if action.uuid == uuid: - return action - raise ValueError("Action not found") - - def get_selected_actions_or_sessions( - self, history_sessions: list[HistorySession] - ) -> list[HistoryAction | HistorySession]: - """Get the selected actions or sessions - - Args: - history_sessions: List of history sessions - - Returns: - list[HistoryAction | HistorySession]: List of selected actions or sessions - """ - selected: list[HistoryAction | HistorySession] = [] - for item in self.selectedItems(): - if item.parent() is None: - index = self.indexOfTopLevelItem(item) - selected.append(history_sessions[index]) - else: - uuid = item.data(0, QC.Qt.UserRole) - selected.append(self.get_action_from_uuid(uuid, history_sessions)) - return selected - - def get_selected_actions( - self, history_sessions: list[HistorySession] - ) -> list[HistoryAction]: - """Get the selected actions - - Args: - history_sessions: List of history sessions - - Returns: - list[HistoryAction]: List of selected actions - """ - selected: list[HistoryAction] = [] - for item in self.selectedItems(): - if item.parent() is not None: - uuid = item.data(0, QC.Qt.UserRole) - selected.append(self.get_action_from_uuid(uuid, history_sessions)) - return selected - - -class WorkspaceStateWidget(QW.QWidget): - """Side-by-side tables showing the workspace state captured by a history action. - - Left table: signals (title + data shape). - Right table: images (title + data shape/dimensions). - """ - - def __init__(self, parent: QW.QWidget | None = None) -> None: - super().__init__(parent) - self._signal_table = QW.QTableWidget(0, 2, self) - self._signal_table.setHorizontalHeaderLabels([_("Signal"), _("Shape")]) - self._signal_table.horizontalHeader().setStretchLastSection(True) - self._signal_table.setEditTriggers(QW.QAbstractItemView.NoEditTriggers) - self._signal_table.setSelectionMode(QW.QAbstractItemView.NoSelection) - self._signal_table.verticalHeader().hide() - - self._image_table = QW.QTableWidget(0, 2, self) - self._image_table.setHorizontalHeaderLabels([_("Image"), _("Dimensions")]) - self._image_table.horizontalHeader().setStretchLastSection(True) - self._image_table.setEditTriggers(QW.QAbstractItemView.NoEditTriggers) - self._image_table.setSelectionMode(QW.QAbstractItemView.NoSelection) - self._image_table.verticalHeader().hide() - - splitter = QW.QSplitter(QC.Qt.Horizontal, self) - splitter.addWidget(self._signal_table) - splitter.addWidget(self._image_table) - layout = QW.QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(splitter) - - def update_from_state(self, state: WorkspaceState | None) -> None: - """Populate tables from a WorkspaceState.""" - self._signal_table.setRowCount(0) - self._image_table.setRowCount(0) - if state is None: - return - self._populate_table(self._signal_table, state, "signal") - self._populate_table(self._image_table, state, "image") - - @staticmethod - def _populate_table( - table: QW.QTableWidget, state: WorkspaceState, panel_key: str - ) -> None: - """Fill a table from the state for a given panel key.""" - titles = state.titles.get(panel_key, []) - shapes = state.states.get(panel_key, []) - metadata = state.object_metadata.get(panel_key, {}) - uuids = state.selection.get(panel_key, []) - # Use metadata keyed by UUID when available - rows: list[tuple[str, str]] = [] - for i, uuid in enumerate(uuids): - title = titles[i] if i < len(titles) else uuid[:8] - meta = metadata.get(uuid, {}) - shape = meta.get("shape") - if shape is not None: - shape_str = " × ".join(str(s) for s in shape) - elif i < len(shapes): - shape_str = shapes[i] - else: - shape_str = "—" - rows.append((title, shape_str)) - table.setRowCount(len(rows)) - for row_idx, (title, shape_str) in enumerate(rows): - table.setItem(row_idx, 0, QW.QTableWidgetItem(title)) - table.setItem(row_idx, 1, QW.QTableWidgetItem(shape_str)) - - -class HistoryPanel(AbstractPanel, DockableWidgetMixin): - """History panel""" - - LOCATION = QC.Qt.RightDockWidgetArea - PANEL_STR = _("History panel") - - H5_PREFIX = "DataLab_His" - - SIG_OBJECT_MODIFIED = QC.Signal() - - FILE_FILTERS = f"{_('History files')} (*.dlhist)" - - def __init__(self, parent: DLMainWindow) -> None: - super().__init__(parent) - self.setWindowTitle(self.PANEL_STR) - self.setWindowIcon(get_icon("history.svg")) - self.setOrientation(QC.Qt.Vertical) - - self.__record_mode = False - self.__edit_mode = False - # When `__replaying` is True, calls to `add_entry` are silently - # ignored. This prevents replay/recompute paths from polluting the - # history with synthetic entries triggered by their own internal - # `compute_*` calls. Use `replaying()` as a context manager. - self.__replaying = False - self.__output_suppressed = False - # Guard flag used by `__sync_panel_selection` to prevent re-entry - # while the data panels are being updated programmatically. - self.__syncing = False - self._cascade_in_progress = False - self.__delete_action: QW.QAction | None = None - self.__duplicate_action: QW.QAction | None = None - self.__step_prev_action: QW.QAction | None = None - self.__step_next_action: QW.QAction | None = None - self.__restore_selection_action: QW.QAction | None = None - self.__edit_action: QW.QAction | None = None - self.__menu_actions: list[QW.QAction] = self.__create_menu_actions() - - self.mainwindow = parent - self.tree = HistoryTree(self) - self.tree.customContextMenuRequested.connect(self.show_context_menu) - self.tree.itemDoubleClicked.connect(self.replay_restore_actions) - self.tree.itemSelectionChanged.connect(self.__sync_panel_selection) - self.tree.itemSelectionChanged.connect(self.__update_actions_state) - self.tree.itemSelectionChanged.connect(self.__update_state_widget) - - self.__state_widget = WorkspaceStateWidget(self) - - toolbar = QW.QToolBar(self) - add_actions(toolbar, self.__menu_actions) - widget = QW.QWidget(self) - layout = QW.QVBoxLayout() - layout.addWidget(toolbar) - layout.addWidget(self.tree) - layout.addWidget(self.__state_widget) - layout.setContentsMargins(0, 0, 0, 0) - widget.setLayout(layout) - - self.addWidget(widget) - - self.__history_sessions: list[HistorySession] = [] - self.__session_increment = 0 - # Bijective action ↔ output mapping (C1). Both dicts are kept in sync - # by :meth:`register_action_outputs` and pruned by - # :meth:`_prune_output_mapping` when objects are removed from a panel. - # Reconstructed at HDF5 load time from each action's ``output_uuids``. - self._action_output_uuids: dict[str, list[str]] = {} - self._output_to_action: dict[str, str] = {} - # Warnings collected during ``recompute_cascade`` (and the helpers - # it dispatches to). Aggregated into a single user-facing dialog - # at the end of the cascade so deleted output objects / missing - # sources / unsupported patterns do not spam the user. - self._cascade_warnings: list[str] = [] - # Actions detected as broken (missing plugin) during a cascade run. - # Transient — intentionally not persisted: recalculated from scratch - # on each cascade run, similar to ``is_stale``. - # The visual stale marker is *not* cleared for these so the user - # sees them flagged in the tree until the plugin is reinstalled and - # the cascade is re-run successfully. - self._broken_actions: set[str] = set() - # Re-entrancy guard for :meth:`_reconnect_chain_after_removal` (see - # method docstring): ``recompute_cascade`` pumps the event loop and - # could deliver a queued ``SIG_OBJECT_REMOVED`` mid-reconnection. - self.__reconnecting = False - for panel in (self.mainwindow.signalpanel, self.mainwindow.imagepanel): - panel.SIG_OBJECT_ADDED.connect(self.refresh_compatibility_items) - panel.SIG_OBJECT_ADDED.connect(self.__refresh_obj_ids_snapshot) - panel.SIG_OBJECT_REMOVED.connect(self.refresh_compatibility_items) - panel.SIG_OBJECT_REMOVED.connect( - functools.partial(self._reconnect_chain_after_removal, panel) - ) - panel.SIG_OBJECT_REMOVED.connect(self._prune_output_mapping) - panel.SIG_OBJECT_MODIFIED.connect(self.refresh_compatibility_items) - self.__refresh_obj_ids_snapshot() - self.__update_actions_state() - self.refresh_compatibility_items() - - def __refresh_obj_ids_snapshot(self) -> None: - """Cache the current object ids of both data panels. - - ``SIG_OBJECT_REMOVED`` carries no payload, so the set of just-deleted - objects is recovered by diffing this snapshot against the live model - inside :meth:`_reconnect_chain_after_removal`. - """ - self.__obj_ids_snapshot = { - self.mainwindow.signalpanel.PANEL_STR_ID: set( - self.mainwindow.signalpanel.objmodel.get_object_ids() - ), - self.mainwindow.imagepanel.PANEL_STR_ID: set( - self.mainwindow.imagepanel.objmodel.get_object_ids() - ), - } - - def __update_actions_state(self) -> None: - """Update the enabled state of menu actions depending on history content.""" - has_history = len(self) > 0 - for action in ( - self.__delete_action, - self.__duplicate_action, - ): - if action is not None: - action.setEnabled(has_history) - if self.__step_prev_action is not None: - self.__step_prev_action.setEnabled(self.__can_step_prev()) - if self.__step_next_action is not None: - self.__step_next_action.setEnabled(self.__can_step_next()) - if self.__restore_selection_action is not None: - self.__restore_selection_action.setEnabled( - self.__edit_mode or self._has_any_pending_edits() - ) - - def _has_any_pending_edits(self) -> bool: - """Return True if any action across all sessions has a pending - Edit mode snapshot (i.e. uncommitted edits that Restore can revert). - """ - return any( - action.has_pending_edits - for session in self.__history_sessions - for action in session.actions - ) - - def __update_state_widget(self) -> None: - """Update the workspace state widget from the currently selected action.""" - action = self.__current_action() - if action is not None: - self.__state_widget.update_from_state(action.state) - else: - self.__state_widget.update_from_state(None) - - def __create_menu_actions(self) -> list[QW.QAction]: - """Create menu actions for the history panel - - Returns: - list[QW.QAction]: List of menu actions - """ - edit_action = create_action( - self, - _("Edit mode"), - toggled=self.toggle_edit_mode, - icon=get_icon("edit_mode.svg"), - ) - edit_action.setChecked(self.__edit_mode) - self.__edit_action = edit_action - record_action = create_action( - self, - _("Record mode"), - toggled=self.toggle_record_mode, - icon=get_icon("record.svg"), - ) - record_action.setChecked(self.__record_mode) - new_session_action = create_action( - self, - _("New session"), - self.create_new_session, - icon=get_icon("libre-gui-add.svg"), - tip=_("Start a new history session"), - ) - open_action = create_action( - self, - _("Open history file..."), - triggered=lambda checked=False: self.open_dlhist_file(), - icon=get_icon("fileopen_h5.svg"), - tip=_("Open history from a standalone .dlhist file"), - ) - save_action = create_action( - self, - _("Save history file..."), - triggered=lambda checked=False: self.save_to_dlhist_file(), - icon=get_icon("filesave_h5.svg"), - tip=_("Save history to a standalone .dlhist file"), - ) - self.__delete_action = create_action( - self, - _("Delete"), - self.delete_selected, - icon=get_icon("delete.svg"), - ) - self.__duplicate_action = create_action( - self, - _("Duplicate"), - self.duplicate_selected_entries, - icon=get_icon("duplicate.svg"), - tip=_("Duplicate selected history action/session"), - ) - self.__step_prev_action = create_action( - self, - _("Previous step"), - triggered=self._step_prev, - icon=get_icon("libre-gui-arrow-left.svg"), - tip=_("Select the previous action in the current session"), - shortcut=QG.QKeySequence("Ctrl+Left"), - ) - self.__step_next_action = create_action( - self, - _("Next step"), - triggered=self._step_next, - icon=get_icon("libre-gui-arrow-right.svg"), - tip=_("Select the next action in the current session"), - shortcut=QG.QKeySequence("Ctrl+Right"), - ) - generate_macro_action = create_action( - self, - _("Generate macro"), - self.generate_macro, - icon=get_icon("console.svg"), - tip=_("Generate a Python macro script from history"), - ) - remove_incompatible_action = create_action( - self, - _("Remove incompatible"), - self.remove_incompatible_actions, - icon=get_icon("edit/delete_all.svg"), - tip=_("Remove actions incompatible with the current workspace"), - ) - self.__restore_selection_action = create_action( - self, - _("Restore parameters"), - lambda: self.replay_restore_actions( - restore_selection=True, replay=False - ), - icon=get_icon("restore_selection.svg"), - tip=_("Restore original parameters (discard edit-mode changes)"), - ) - return [ - record_action, - new_session_action, - None, - open_action, - save_action, - None, - self.__step_prev_action, - self.__step_next_action, - None, - create_action( - self, - _("Replay"), - lambda: self.replay_restore_actions(restore_selection=False), - icon=get_icon("replay.svg"), - ), - self.__restore_selection_action, - edit_action, - None, - self.__duplicate_action, - generate_macro_action, - None, - remove_incompatible_action, - self.__delete_action, - ] - - def toggle_edit_mode(self, checked: bool) -> None: - """Toggle edit mode. - - Toggling Edit mode off is a **definitive commit**: all parameter - changes performed during the session become permanent and Restore - is no longer available for them. When pending edits exist, the - user is asked to confirm; refusing leaves Edit mode enabled. - - Args: - checked: True if the edit mode is checked, False otherwise. - """ - if not checked and self._has_any_pending_edits(): - reply = QW.QMessageBox.question( - self.mainwindow, - _("Commit edit mode changes?"), - _( - "You are about to exit Edit mode.\n\n" - "All parameter changes made during this session will be " - "permanently kept.\n" - "This action cannot be undone — Restore will no longer " - "be available.\n\n" - "Do you want to continue?" - ), - QW.QMessageBox.Yes | QW.QMessageBox.No, - QW.QMessageBox.No, - ) - if reply != QW.QMessageBox.Yes: - # Re-check the action without triggering toggle_edit_mode - # again (blockSignals prevents recursion). - if self.__edit_action is not None: - self.__edit_action.blockSignals(True) - self.__edit_action.setChecked(True) - self.__edit_action.blockSignals(False) - return - self.__edit_mode = checked - if not checked: - # Exiting edit mode: accept all pending edits (discard snapshots) - for session in self.__history_sessions: - for action in session.actions: - action.discard_snapshot() - self.__update_actions_state() - - def toggle_record_mode(self, checked: bool) -> None: - """Toggle record mode - - Args: - checked: True if the record mode is checked, False otherwise - """ - self.__record_mode = checked - - def is_edit_mode(self) -> bool: - """Return True when the History panel is in edit (parameter testing) mode.""" - return self.__edit_mode - - @contextmanager - def replaying(self) -> Generator[None, None, None]: - """Context manager suppressing history capture during its scope. - - Used by replay / recompute paths to avoid double-capture: the - generic ``compute_*`` methods of the processor would otherwise - register synthetic entries every time recompute or replay - triggers them. - """ - previous = self.__replaying - self.__replaying = True - try: - yield - finally: - self.__replaying = previous - - def is_replaying(self) -> bool: - """Return True when an external replay/recompute is in progress.""" - return self.__replaying - - @contextmanager - def output_suppressed(self) -> Generator[None, None, None]: - """Context manager suppressing compute outputs during its scope. - - When active, :meth:`BaseProcessor._add_object_to_appropriate_panel` - and :meth:`BaseProcessor._create_group_for_result` become no-ops so - that History Panel replay can execute computations without altering - Signal/Image panel object counts. Reentrant-safe. - """ - previous = self.__output_suppressed - self.__output_suppressed = True - try: - yield - finally: - self.__output_suppressed = previous - - def is_output_suppressed(self) -> bool: - """Return True when compute outputs must not be added to panels.""" - return self.__output_suppressed - - def show_context_menu(self, pos: QC.QPoint) -> None: - """Show the context menu - - Args: - pos: Position of the context menu - """ - self.refresh_compatibility_items() - menu = QW.QMenu() - add_actions(menu, self.__menu_actions) - menu.exec_(self.tree.mapToGlobal(pos)) - - def get_action_from_uuid(self, uuid: str) -> HistoryAction: - """Get the action from its UUID - - Args: - uuid: Action UUID - - Returns: - HistoryAction: Action - """ - for session in self.__history_sessions: - for action in session.actions: - if action.uuid == uuid: - return action - raise ValueError("Action not found") - - def replay_restore_actions( - self, replay: bool = True, restore_selection: bool = True - ) -> None: - """Replay and/or restore selection for the selected actions. - - When nothing is selected in the tree, replays the entire last session. - Replay is non-persistent: compute actions run but their outputs are - NOT added to Signal/Image panels. UI-creation actions are always - skipped during replay because source objects already exist. - """ - self.refresh_compatibility_items() - selected = self.tree.get_selected_actions_or_sessions(self.__history_sessions) - if not selected: - if not self.__history_sessions: - return - # Nothing selected → replay the last session - selected = [self.__history_sessions[-1]] - for session_or_action in selected: - # B4: if a stale action is Played, recompute its cascade in-place - # instead of running the standard non-persistent replay. - if ( - isinstance(session_or_action, HistoryAction) - and session_or_action.is_stale - ): - self.recompute_cascade(session_or_action) - continue - if not session_or_action.is_current_state_compatible( - self.mainwindow, restore_selection=restore_selection - ): - QW.QMessageBox.critical( - self.mainwindow, - _("Error"), - _("The current workspace state is not compatible with the action."), - ) - return - if replay: - if self.__edit_mode and isinstance( - session_or_action, HistoryAction - ): - self._edit_mode_replay(session_or_action) - elif self.__edit_mode and isinstance( - session_or_action, HistorySession - ): - self._view_only_session_replay( - session_or_action, restore_selection - ) - else: - with self.replaying(), self.output_suppressed(): - session_or_action.replay( - self.mainwindow, - restore_selection=restore_selection, - edit=self.__edit_mode, - ) - elif restore_selection: - # Restore button (replay=False, restore_selection=True): - # if Edit mode is active OR there are persisted pending - # edits (e.g. after a save/reload), revert the parameter - # snapshots in place. Otherwise behave as a workspace - # selection restore. - if self.__edit_mode or self._has_any_pending_edits(): - self._restore_action_params(session_or_action) - else: - session_or_action.restore(self.mainwindow) - - def _prompt_edit_action_params(self, action: HistoryAction) -> bool | None: - """Open the parameter dialog for *action* according to its pattern. - - Returns: - ``True`` – user accepted; ``action.kwargs`` mutated, snapshot taken. - ``False`` – user cancelled the dialog. - ``None`` – nothing to edit (no param/params, or unsupported pattern). - """ - import copy # pylint: disable=import-outside-toplevel - - pattern = action.pattern - if pattern in {"1_to_1", "1_to_0", "n_to_1", "2_to_1"}: - param = action.kwargs.get("param") - if param is None: - return None - edited = copy.deepcopy(param) - if not edited.edit(parent=self.mainwindow): - return False - action.snapshot_kwargs() - action.kwargs["param"] = edited - return True - if pattern == "1_to_n": - params = action.kwargs.get("params") or [] - if not params: - return None - edited_params = [copy.deepcopy(p) for p in params] - # Local import: ``gds`` is not used elsewhere in this module. - import guidata.dataset as gds # pylint: disable=import-outside-toplevel - - group = gds.DataSetGroup(edited_params, title=_("Parameters")) - if not group.edit(parent=self.mainwindow): - return False - action.snapshot_kwargs() - action.kwargs["params"] = edited_params - return True - # multiple_1_to_1 or any unknown pattern: nothing to edit. - return None - - def _edit_mode_replay(self, action: HistoryAction) -> None: - """Replay a single action in edit mode: open param dialog, update - kwargs on accept, recompute in-place and cascade downstream. - - Supports every compute pattern (1_to_1, 1_to_n, n_to_1, 2_to_1, - 1_to_0). ``multiple_1_to_1`` is currently not supported: the dialog - is skipped for that action but the rest of the chain is still - processed. - - UI actions and pattern-less entries fall back to normal replay. - - The parameter dialog is opened for the root action AND for every - downstream action in the cascade, in topological order. If the - user cancels ANY dialog, all snapshots already created in this - pass are rolled back and nothing is recomputed. - """ - if action.kind != HistoryAction.KIND_COMPUTE or action.pattern is None: - with self.replaying(), self.output_suppressed(): - action.replay(self.mainwindow, restore_selection=True, edit=True) - return - - chain: list[HistoryAction] = [action] + self.get_downstream_actions( - action - ) - edited_actions: list[HistoryAction] = [] - for a in chain: - result = self._prompt_edit_action_params(a) - if result is False: - # User cancelled – rollback every snapshot taken so far. - for done in edited_actions: - done.restore_kwargs() - self.tree.refresh_action_item(done) - return - if result is True: - edited_actions.append(a) - - for a in edited_actions: - self.tree.refresh_action_item(a) - - # Recompute root in-place, then cascade with the pre-computed - # descendants list. Re-using the chain avoids a second call to - # ``get_downstream_actions`` whose result could diverge after the - # root output's metadata has been rewritten by the root recompute. - downstream = chain[1:] - self._recompute_action_in_place(action) - self.recompute_cascade(action, descendants=downstream) - - # Belt-and-suspenders refresh: ensure the tree description widgets - # and the Signal/Image panels reflect the final state for every - # action in the chain (root included). - for a in chain: - self.tree.refresh_action_item(a) - QW.QApplication.processEvents() - - def _show_readonly_param_dialog( - self, dataset: gds.DataSet | gds.DataSetGroup - ) -> None: - """Show a parameter dialog identical to the edit dialog but read-only. - - Builds the same guidata dialog as edit mode (``DataSetEditDialog`` for a - single dataset, ``DataSetGroupEditDialog`` for a group) so the appearance - and title match exactly, then disables every input field (and its label) - so the parameters are displayed but cannot be modified. The OK button is - kept so the dialog can be dismissed. - - Args: - dataset: The dataset (or dataset group) whose parameters are shown. - """ - # Local import: not used elsewhere in this module. - import guidata.dataset as gds # pylint: disable=import-outside-toplevel - from guidata.dataset.qtwidgets import ( # pylint: disable=import-outside-toplevel - DataSetEditDialog, - DataSetGroupEditDialog, - ) - - # A DataSetGroup (1_to_n) needs the tabbed group dialog; a single - # DataSet uses the standard edit dialog. Both expose ``edit_layout``. - if isinstance(dataset, gds.DataSetGroup): - dialog = DataSetGroupEditDialog(dataset, parent=self.mainwindow) - else: - dialog = DataSetEditDialog(dataset, parent=self.mainwindow) - for edl in dialog.edit_layout: - for widget in edl.widgets: - if widget.group is not None: - widget.group.setEnabled(False) - if widget.label is not None: - widget.label.setEnabled(False) - dialog.exec() - - def _view_only_session_replay( - self, - session: HistorySession, - restore_selection: bool, - ) -> None: - """Replay a session in edit mode with read-only parameter dialogs. - - When edit mode is active and a full session is replayed, editable - dialogs would be misleading because modifications cannot be propagated - through the cascade. Instead, show each compute action's parameters in - a dialog that looks identical to the edit dialog but whose fields are - disabled, and keep the History tree and the data panel synchronized with - the action being shown. The session is then replayed with ``edit=False``. - """ - import copy # pylint: disable=import-outside-toplevel - - # Local import: ``gds`` is not used elsewhere in this module. - import guidata.dataset as gds # pylint: disable=import-outside-toplevel - - for action in session.actions: - if action.kind != HistoryAction.KIND_COMPUTE: - continue - pattern = action.pattern - # Sync the History tree and the data panel to this action so the - # user follows the replay step by step (same behaviour as edit mode). - self.__select_action_in_tree(action) - QW.QApplication.processEvents() - if pattern in {"1_to_1", "1_to_0", "n_to_1", "2_to_1"}: - param = action.kwargs.get("param") - if param is not None: - self._show_readonly_param_dialog(copy.deepcopy(param)) - elif pattern == "1_to_n": - params = action.kwargs.get("params") or [] - if params: - group = gds.DataSetGroup( - [copy.deepcopy(p) for p in params], - title=_("Parameters"), - ) - self._show_readonly_param_dialog(group) - - with self.replaying(), self.output_suppressed(): - session.replay( - self.mainwindow, - restore_selection=restore_selection, - edit=False, - ) - - def _restore_action_params( - self, item: HistoryAction | HistorySession - ) -> None: - """Restore original kwargs from snapshot and recompute in-place. - - Used by the Restore action in edit mode to discard parameter - changes and revert to the pre-edit state. - """ - actions: list[HistoryAction] - if isinstance(item, HistorySession): - actions = [ - a for a in item.actions if a.kind == HistoryAction.KIND_COMPUTE - ] - else: - actions = [item] - for action in actions: - if not action.has_pending_edits: - continue - action.restore_kwargs() - self.tree.refresh_action_item(action) - self._recompute_action_in_place(action) - self.recompute_cascade(action) - # Snapshots may have been consumed: refresh button states so the - # Restore action disables itself once no pending edits remain - # (relevant outside Edit mode after a save/reload restore). - self.__update_actions_state() - - def _find_parent_session(self, action: HistoryAction) -> HistorySession | None: - """Return the session that contains ``action``, or None if not found. - - Args: - action: Action to search for. - - Returns: - Parent :class:`HistorySession`, or ``None``. - """ - for session in self.__history_sessions: - if action in session.actions: - return session - return None - - # ------------------------------------------------------------------ - # Sync History tree selection → Signal/Image panel (B1) - # ------------------------------------------------------------------ - - def __resolve_panel_for_action( - self, action: HistoryAction - ) -> BaseDataPanel | None: - """Return the data panel targeted by ``action``, or ``None``.""" - if action.kind != HistoryAction.KIND_COMPUTE: - return None - if action.panel_str == "signal": - return self.mainwindow.signalpanel - if action.panel_str == "image": - return self.mainwindow.imagepanel - return None - - def __find_output_object_uuid( - self, panel: BaseDataPanel, action: HistoryAction - ) -> str | None: - """Find the UUID of the output object produced by ``action`` in ``panel``. - - Primary path: consult the bijective ``_action_output_uuids`` mapping - populated when the action was recorded (or rebuilt from HDF5 at load). - Returns the first registered output that still exists in ``panel``. - - Fallback path (legacy v1 sessions, or actions whose outputs were - re-created without registration): search the panel for an object whose - ``processing_parameters`` metadata has ``source_uuid`` matching one of - the action's recorded selection UUIDs and whose ``func_name`` equals - the action's ``func_name``. - """ - # Primary: bijective mapping (C1). - registered = self._action_output_uuids.get(action.uuid) - if registered: - existing_ids = set(panel.objmodel.get_object_ids()) - for out_uuid in registered: - if out_uuid in existing_ids: - return out_uuid - # Fallback heuristic for legacy sessions. - if action.func_name is None: - return None - from datalab.gui.processor.base import ( # pylint: disable=import-outside-toplevel - extract_processing_parameters, - ) - - recorded_uuids = set(action.state.selection.get(panel.PANEL_STR_ID, [])) - if not recorded_uuids: - return None - for obj in panel.objmodel: - pp = extract_processing_parameters(obj) - if pp is None or pp.func_name != action.func_name: - continue - if pp.source_uuid is not None and pp.source_uuid in recorded_uuids: - return get_uuid(obj) - if pp.source_uuids is not None and recorded_uuids.intersection( - pp.source_uuids - ): - return get_uuid(obj) - return None - - def find_action_for_output( - self, output_uuid: str, func_name: str - ) -> HistoryAction | None: - """Find the :class:`HistoryAction` that produced ``output_uuid``. - - Primary path: consult the bijective ``_output_to_action`` mapping. This - is exact and resolves the ambiguity of repeated applications of the - same ``func_name`` on the same source. - - Fallback path: searches all sessions (most-recent first) using the - heuristic ``(func_name, source_uuid)`` matching for legacy v1 sessions - without a registered output mapping. - - Args: - output_uuid: UUID of the output object (signal or image). - func_name: Processing function name expected on the action. - - Returns: - The matching action, or ``None`` if no match is found. - """ - if not self.__history_sessions: - return None - # Primary: bijective mapping (C1). - action_uuid = self._output_to_action.get(output_uuid) - if action_uuid is not None: - for session in self.__history_sessions: - for action in session.actions: - if action.uuid == action_uuid: - # Sanity check: func_name must still match. - if action.func_name == func_name: - return action - return None - # Fallback heuristic for legacy sessions. - from datalab.gui.processor.base import ( # pylint: disable=import-outside-toplevel - extract_processing_parameters, - ) - - # Find which panel contains output_uuid - panel: BaseDataPanel | None = None - output_obj = None - for p in (self.mainwindow.signalpanel, self.mainwindow.imagepanel): - try: - output_obj = p.objmodel[output_uuid] - panel = p - break - except KeyError: - continue - if panel is None or output_obj is None: - return None - pp = extract_processing_parameters(output_obj) - if pp is None or pp.func_name != func_name: - return None - target_source_uuid = pp.source_uuid - if target_source_uuid is None: - return None - # Search every session (most-recent first) instead of only [-1]. - # Uniqueness is guaranteed by target_source_uuid (remapped per-session - # during duplication). - for current_session in reversed(self.__history_sessions): - for action in reversed(current_session.actions): - if action.kind != HistoryAction.KIND_COMPUTE: - continue - if action.func_name != func_name: - continue - if action.panel_str != panel.PANEL_STR_ID: - continue - captured = action.state.selection.get(panel.PANEL_STR_ID, []) - if captured and captured[0] == target_source_uuid: - return action - return None - - def refresh_action(self, action: HistoryAction) -> None: - """Refresh the tree display for ``action`` after its kwargs were mutated. - - Used by :meth:`ObjectProp.apply_processing_parameters` to update the - Description column when the user edits a ``param`` from the Processing - tab of the Signal/Image panel. - """ - self.tree.refresh_action_item(action) - - # ------------------------------------------------------------------ - # B4: Cascade recompute of downstream actions after a param edit - # ------------------------------------------------------------------ - - def _get_session_of(self, action: HistoryAction) -> HistorySession | None: - """Return the session that contains ``action``, or None.""" - for session in self.__history_sessions: - if action in session.actions: - return session - return None - - def _action_output_uuid(self, action: HistoryAction) -> str | None: - """Return the UUID of the object produced by ``action``, or ``None``. - - Scans the target panel's object model for an object whose - :class:`ProcessingParameters` metadata matches the action's - ``func_name`` and one of its captured source UUIDs. - """ - panel = self.__resolve_panel_for_action(action) - if panel is None: - return None - return self.__find_output_object_uuid(panel, action) - - def _action_consumes_any( - self, action: HistoryAction, uuids: set[str] - ) -> bool: - """Return True if ``action``'s input UUIDs intersect ``uuids``.""" - if action.kind != HistoryAction.KIND_COMPUTE: - return False - pstr = action.panel_str or "" - captured: set[str] = set(action.state.selection.get(pstr, [])) - obj2 = action.kwargs.get("obj2_uuids") - if obj2: - if isinstance(obj2, str): - captured.add(obj2) - else: - captured.update(obj2) - return bool(captured & uuids) - - def _collect_downstream_uuids(self, action: HistoryAction) -> set[str]: - """Return the transitive closure of output UUIDs descending from - ``action`` within the current session (excluding ``action`` itself). - """ - if not self.__history_sessions: - return set() - current = self._get_session_of(action) - if current is None: - return set() - root_out = self._action_output_uuid(action) - if root_out is None: - return set() - closure: set[str] = {root_out} - # Walk only actions positioned strictly after ``action`` in the - # current session, in chronological order. - idx = current.actions.index(action) - for downstream in current.actions[idx + 1 :]: - if downstream.kind != HistoryAction.KIND_COMPUTE: - continue - if not self._action_consumes_any(downstream, closure): - continue - out_uuid = self._action_output_uuid(downstream) - if out_uuid is not None: - closure.add(out_uuid) - closure.discard(root_out) - return closure - - def get_downstream_actions( - self, action: HistoryAction - ) -> list[HistoryAction]: - """Return the actions of the current session that depend (transitively) - on ``action``'s output, in topological order (direct children first). - """ - if not self.__history_sessions: - return [] - current = self._get_session_of(action) - if current is None: - return [] - root_out = self._action_output_uuid(action) - if root_out is None: - return [] - closure: set[str] = {root_out} - downstream: list[HistoryAction] = [] - idx = current.actions.index(action) - for candidate in current.actions[idx + 1 :]: - if candidate.kind != HistoryAction.KIND_COMPUTE: - continue - if not self._action_consumes_any(candidate, closure): - continue - downstream.append(candidate) - out_uuid = self._action_output_uuid(candidate) - if out_uuid is not None: - closure.add(out_uuid) - return downstream - - # ------------------------------------------------------------------ - # In-place recompute dispatcher (C2): one helper per pattern. - # - # All helpers retrieve the existing output object(s) via the bijective - # ``_action_output_uuids`` mapping (C1), recompute via the processor's - # ``recompute_*`` methods (which do not register history nor add to the - # panel), then update the existing object in place (data + title + - # metadata) and refresh the view. Missing output objects (deleted by - # the user) are reported via :attr:`_cascade_warnings`. - # ------------------------------------------------------------------ - - def _resolve_target_outputs( - self, panel: BaseDataPanel, action: HistoryAction - ) -> tuple[list[str], list[str]]: - """Return ``(existing, missing)`` UUIDs registered for ``action``. - - Args: - panel: Data panel owning the action's outputs. - action: History action whose outputs must be resolved. - - Returns: - A pair of UUID lists: those still present in ``panel`` (in - registration order) and those that were deleted. - """ - registered = list(self._action_output_uuids.get(action.uuid, [])) - existing_ids = set(panel.objmodel.get_object_ids()) - existing: list[str] = [u for u in registered if u in existing_ids] - missing: list[str] = [u for u in registered if u not in existing_ids] - return existing, missing - - def _update_obj_in_place( - self, - target_obj: SignalObj | ImageObj, - new_obj: SignalObj | ImageObj, - ) -> None: - """Copy data + title + metadata from ``new_obj`` onto ``target_obj``. - - Preserves the target's identity (UUID, panel position, references) - while reflecting all user-visible changes produced by a recompute. - - Args: - target_obj: Existing object to mutate in place. - new_obj: Fresh object produced by a ``recompute_*`` call. - """ - target_obj.title = new_obj.title - if isinstance(target_obj, SignalObj): - target_obj.xydata = new_obj.xydata - else: - target_obj.data = new_obj.data - target_obj.invalidate_maskdata_cache() - # Replace compute-related metadata with the fresh set. In Edit mode - # the object is updated in place (not recreated), so stale analysis - # result keys (Geometry_*, Table_*) or obsolete metadata options from - # the previous compute would persist with a simple ``update()``. - # We clear and repopulate, but preserve the target's ``__uuid`` which - # is its identity key managed by the object model. - try: - saved_uuid = target_obj.metadata.get("__uuid") - saved_number = target_obj.metadata.get("__number") - target_obj.metadata.clear() - target_obj.metadata.update(new_obj.metadata) - if saved_uuid is not None: - target_obj.metadata["__uuid"] = saved_uuid - if saved_number is not None: - target_obj.metadata["__number"] = saved_number - except AttributeError: - pass - - def _refresh_target( - self, panel: BaseDataPanel, output_uuid: str - ) -> None: - """Refresh tree item + plot for ``output_uuid`` in ``panel``. - - Also updates the Properties panel when the refreshed object is - currently selected, marks the object as freshly processed so the - Processing tab is shown, and emits ``SIG_OBJECT_MODIFIED`` so - that compatibility icons are refreshed. - """ - panel.objview.update_item(output_uuid) - panel.refresh_plot(output_uuid, update_items=True, force=True) - - # Update the Properties panel if the recomputed object is selected - try: - obj = panel.objmodel[output_uuid] - except KeyError: - obj = None - if obj is not None: - if obj is panel.objview.get_current_object(): - panel.objprop.update_properties_from(obj, force_tab="processing") - else: - # Mark as freshly processed so the Processing tab opens on select - panel.objprop.mark_as_freshly_processed(obj) - - # Notify listeners (e.g. compatibility icons in history tree) - panel.SIG_OBJECT_MODIFIED.emit() - - def _record_missing_outputs( - self, action: HistoryAction, missing: list[str] - ) -> None: - """Log + queue a user-facing warning for deleted output objects.""" - if not missing: - return - name = action.func_name or action.title or action.uuid - _logger.warning( - "Cascade recompute: %d output(s) missing for action %s (%s).", - len(missing), - action.uuid, - name, - ) - self._cascade_warnings.append( - _( - "Action %s has been edited but its target output object(s) " - "no longer exist — skipping." - ) - % name - ) - - def _recompute_action_in_place(self, action: HistoryAction) -> None: - """Re-run ``action`` on the existing output object(s) (same UUIDs). - - Dispatches to a per-pattern helper. Missing target outputs are - recorded in :attr:`_cascade_warnings` and silently skipped so the - rest of the cascade can keep running. - - When the underlying processor feature is missing (e.g. the originating - plugin was uninstalled), :class:`FeatureNotFoundError` is caught here - and the action is flagged as broken (``is_stale`` left at ``True`` - beyond the cascade so the visual marker persists). A localised warning - including the plugin origin and required parameter class is appended - to :attr:`_cascade_warnings`. The cascade continues with the remaining - actions. - - Args: - action: History action to recompute in place. - """ - if action.kind != HistoryAction.KIND_COMPUTE: - return - method = { - "1_to_1": self._recompute_1_to_1_in_place, - "1_to_n": self._recompute_1_to_n_in_place, - "n_to_1": self._recompute_n_to_1_in_place, - "2_to_1": self._recompute_2_to_1_in_place, - "1_to_0": self._recompute_1_to_0_in_place, - }.get(action.pattern or "") - if method is None: - _logger.warning( - "Cascade recompute: unsupported pattern %r for action %s.", - action.pattern, - action.uuid, - ) - self._cascade_warnings.append( - _("Action %s uses pattern %r which is not recomputable yet.") - % (action.func_name or action.uuid, action.pattern) - ) - return - from datalab.gui.processor.base import ( # pylint: disable=import-outside-toplevel - FeatureNotFoundError, - ) - - try: - method(action) - except FeatureNotFoundError as exc: - self._handle_missing_feature(action, exc) - except Exception as exc: # pylint: disable=broad-except - _logger.exception( - "Cascade recompute failed for action %s (%s): %s", - action.uuid, - action.func_name, - exc, - ) - self._cascade_warnings.append( - _("Recompute failed for action %s: %s") - % (action.func_name or action.uuid, exc) - ) - - def _handle_missing_feature( - self, action: HistoryAction, exc: "FeatureNotFoundError" - ) -> None: - """Flag ``action`` as broken (missing plugin) and queue a user warning. - - Args: - action: Action whose feature could not be resolved. - exc: The raised :class:`FeatureNotFoundError`. - ``action.plugin_origin`` is the authoritative source. - ``exc.plugin_origin`` is kept only as a safety net for future - paths where ``get_feature`` might be called without forwarding - the action's ``plugin_origin``. - """ - action.is_stale = True - self._broken_actions.add(action.uuid) - plugin_origin = action.plugin_origin or exc.plugin_origin or {} - directory = (plugin_origin.get("directory") if plugin_origin else None) or "?" - param = action.kwargs.get("param") - paramclass = ( - exc.paramclass_name - or (type(param).__name__ if param is not None else "—") - ) - func_name = action.func_name or exc.func_name or action.uuid - # Format validated with the operator: "{directory}/plugins:{func_name}" - location = f"{directory}/plugins:{func_name}" - _logger.warning( - "Cascade recompute: plugin missing for action %s (%s) — %s.", - action.uuid, - func_name, - location, - ) - self._cascade_warnings.append( - _( - "Action %(name)s skipped: plugin '%(loc)s' is missing.\n" - "Required parameter class: %(param)s\n" - "Reinstall the plugin to re-enable this action." - ) - % {"name": func_name, "loc": location, "param": paramclass} - ) - - def _recompute_1_to_1_in_place(self, action: HistoryAction) -> None: - """Recompute a single 1-to-1 action in place.""" - panel = self.__resolve_panel_for_action(action) - if panel is None: - return - from datalab.gui.processor.base import ( # pylint: disable=import-outside-toplevel - ProcessingParameters, - extract_processing_parameters, - insert_processing_parameters, - ) - - existing, missing = self._resolve_target_outputs(panel, action) - # Fall back to legacy heuristic when bijective mapping is unavailable. - if not existing and not missing: - legacy = self.__find_output_object_uuid(panel, action) - if legacy is not None: - existing = [legacy] - self._record_missing_outputs(action, missing) - if not existing: - return - output_uuid = existing[0] - try: - output_obj = panel.objmodel[output_uuid] - except KeyError: - return - pp = extract_processing_parameters(output_obj) - if pp is None or pp.source_uuid is None: - return - try: - source_obj = panel.objmodel[pp.source_uuid] - except KeyError: - self._cascade_warnings.append( - _("Action %s: source object was deleted — skipping.") - % (action.func_name or action.uuid) - ) - return - param = action.kwargs.get("param") - new_obj = panel.processor.recompute_1_to_1( - action.func_name, source_obj, param, - plugin_origin=action.plugin_origin, - ) - if new_obj is None: - return - self._update_obj_in_place(output_obj, new_obj) - insert_processing_parameters( - output_obj, - ProcessingParameters( - func_name=pp.func_name, - pattern=pp.pattern, - param=param if param is not None else pp.param, - source_uuid=pp.source_uuid, - ), - ) - panel.processor.auto_recompute_analysis(output_obj) - self._refresh_target(panel, output_uuid) - - def _recompute_1_to_n_in_place(self, action: HistoryAction) -> None: - """Recompute a 1-to-n action in place: replace each of the N outputs.""" - panel = self.__resolve_panel_for_action(action) - if panel is None: - return - from datalab.gui.processor.base import ( # pylint: disable=import-outside-toplevel - ProcessingParameters, - extract_processing_parameters, - insert_processing_parameters, - ) - - existing, missing = self._resolve_target_outputs(panel, action) - self._record_missing_outputs(action, missing) - if not existing: - return - # All outputs of a 1_to_n share the same source. - try: - first_obj = panel.objmodel[existing[0]] - except KeyError: - return - pp = extract_processing_parameters(first_obj) - if pp is None or pp.source_uuid is None: - return - try: - source_obj = panel.objmodel[pp.source_uuid] - except KeyError: - self._cascade_warnings.append( - _("Action %s: source object was deleted — skipping.") - % (action.func_name or action.uuid) - ) - return - params = action.kwargs.get("params") or [] - if not params: - return - new_objs = panel.processor.recompute_1_to_n( - action.func_name, source_obj, params, - plugin_origin=action.plugin_origin, - ) - if not new_objs: - return - # Map each output to its (re)computed counterpart by index. If the - # cardinality changed (e.g. function now produces fewer outputs), - # we update what we can and report the rest as missing. - n = min(len(existing), len(new_objs)) - for idx in range(n): - out_uuid = existing[idx] - try: - out_obj = panel.objmodel[out_uuid] - except KeyError: - continue - new_obj = new_objs[idx] - self._update_obj_in_place(out_obj, new_obj) - new_param = params[idx] if idx < len(params) else None - insert_processing_parameters( - out_obj, - ProcessingParameters( - func_name=action.func_name, - pattern="1-to-n", - param=new_param, - source_uuid=pp.source_uuid, - ), - ) - panel.processor.auto_recompute_analysis(out_obj) - self._refresh_target(panel, out_uuid) - if len(new_objs) != len(existing): - _logger.warning( - "1-to-n cardinality changed for action %s: %d outputs, %d existing.", - action.uuid, - len(new_objs), - len(existing), - ) - - def _recompute_n_to_1_in_place(self, action: HistoryAction) -> None: - """Recompute an n-to-1 action in place.""" - panel = self.__resolve_panel_for_action(action) - if panel is None: - return - from datalab.gui.processor.base import ( # pylint: disable=import-outside-toplevel - ProcessingParameters, - extract_processing_parameters, - insert_processing_parameters, - ) - - existing, missing = self._resolve_target_outputs(panel, action) - self._record_missing_outputs(action, missing) - if not existing: - return - output_uuid = existing[0] - try: - output_obj = panel.objmodel[output_uuid] - except KeyError: - return - pp = extract_processing_parameters(output_obj) - source_uuids: list[str] = [] - if pp is not None and pp.source_uuids: - source_uuids = list(pp.source_uuids) - else: - source_uuids = list( - action.state.selection.get(panel.PANEL_STR_ID, []) - ) - src_objs: list[SignalObj | ImageObj] = [] - for uuid in source_uuids: - try: - src_objs.append(panel.objmodel[uuid]) - except KeyError: - continue - if not src_objs: - self._cascade_warnings.append( - _("Action %s: all source objects were deleted — skipping.") - % (action.func_name or action.uuid) - ) - return - param = action.kwargs.get("param") - new_obj = panel.processor.recompute_n_to_1( - action.func_name, src_objs, param, - plugin_origin=action.plugin_origin, - ) - if new_obj is None: - return - self._update_obj_in_place(output_obj, new_obj) - insert_processing_parameters( - output_obj, - ProcessingParameters( - func_name=action.func_name, - pattern="n-to-1", - param=param, - source_uuids=[get_uuid(o) for o in src_objs], - ), - ) - panel.processor.auto_recompute_analysis(output_obj) - self._refresh_target(panel, output_uuid) - - def _recompute_2_to_1_in_place(self, action: HistoryAction) -> None: - """Recompute a 2-to-1 action in place (single or pairwise).""" - panel = self.__resolve_panel_for_action(action) - if panel is None: - return - from datalab.gui.processor.base import ( # pylint: disable=import-outside-toplevel - ProcessingParameters, - extract_processing_parameters, - insert_processing_parameters, - ) - - existing, missing = self._resolve_target_outputs(panel, action) - self._record_missing_outputs(action, missing) - if not existing: - return - param = action.kwargs.get("param") - obj2_uuids = action.kwargs.get("obj2_uuids") or [] - if isinstance(obj2_uuids, str): - obj2_uuids = [obj2_uuids] - pairwise = bool(action.kwargs.get("pairwise")) - # In pairwise mode, expect one output per (obj1, obj2) pair. - # In single-operand mode, every output uses the same obj2. - recorded_inputs = list(action.state.selection.get(panel.PANEL_STR_ID, [])) - for idx, out_uuid in enumerate(existing): - try: - output_obj = panel.objmodel[out_uuid] - except KeyError: - continue - pp = extract_processing_parameters(output_obj) - src_uuids = ( - list(pp.source_uuids) - if pp is not None and pp.source_uuids - else (recorded_inputs[idx : idx + 1] + obj2_uuids[idx : idx + 1] - if pairwise - else recorded_inputs[idx : idx + 1] + obj2_uuids[:1]) - ) - if len(src_uuids) < 2: - self._cascade_warnings.append( - _("Action %s: missing source(s) for output #%d — skipping.") - % (action.func_name or action.uuid, idx + 1) - ) - continue - try: - obj1 = panel.objmodel[src_uuids[0]] - obj2 = panel.objmodel[src_uuids[1]] - except KeyError: - self._cascade_warnings.append( - _("Action %s: source object(s) were deleted — skipping.") - % (action.func_name or action.uuid) - ) - continue - new_obj = panel.processor.recompute_2_to_1( - action.func_name, obj1, obj2, param, - plugin_origin=action.plugin_origin, - ) - if new_obj is None: - continue - self._update_obj_in_place(output_obj, new_obj) - insert_processing_parameters( - output_obj, - ProcessingParameters( - func_name=action.func_name, - pattern="2-to-1", - param=param, - source_uuids=[get_uuid(obj1), get_uuid(obj2)], - ), - ) - panel.processor.auto_recompute_analysis(output_obj) - self._refresh_target(panel, out_uuid) - - def _recompute_1_to_0_in_place(self, action: HistoryAction) -> None: - """Recompute a 1-to-0 analysis on each source object in place.""" - panel = self.__resolve_panel_for_action(action) - if panel is None: - return - # 1-to-0 produces no data object; recompute on each captured source - # so analysis metadata stays consistent with the (possibly updated) - # data of upstream actions. - sources = list(action.state.selection.get(panel.PANEL_STR_ID, [])) - if not sources: - return - param = action.kwargs.get("param") - missing: list[str] = [] - for uuid in sources: - try: - src_obj = panel.objmodel[uuid] - except KeyError: - missing.append(uuid) - continue - panel.processor.recompute_1_to_0( - action.func_name, src_obj, param, - plugin_origin=action.plugin_origin, - ) - self._refresh_target(panel, uuid) - if missing: - self._cascade_warnings.append( - _("Action %s: %d analysed object(s) were deleted — skipping.") - % (action.func_name or action.uuid, len(missing)) - ) - - def recompute_cascade( - self, - root_action: HistoryAction, - descendants: list[HistoryAction] | None = None, - ) -> None: - """Recompute ``root_action``'s descendants in the current session - in-place (on existing UUIDs). - - Each action involved is flagged ``is_stale`` (light-orange background - in the tree) for the duration of the recompute, then cleared. The - root action itself is normally NOT recomputed here (the caller has - already updated its output object). For a stale Play, ``root_action`` - is included so its own flag is cleared. - - Every supported pattern (``1_to_1``, ``1_to_n``, ``n_to_1``, - ``2_to_1``, ``1_to_0``) is dispatched through - :meth:`_recompute_action_in_place`. Missing or unsupported items - are reported via a single end-of-cascade warning dialog. - - Actions whose underlying processor feature is missing (e.g. plugin - uninstalled) keep ``is_stale = True`` after the cascade completes, - so the visual marker persists until the plugin is reinstalled. - - Args: - root_action: Root action whose descendants must be recomputed. - descendants: Pre-computed downstream actions. When ``None`` - (default), :meth:`get_downstream_actions` is called - internally. Passing a pre-computed list avoids a redundant - graph traversal when the caller has already resolved the - chain (e.g. :meth:`_edit_mode_replay`). - """ - if descendants is None: - descendants = self.get_downstream_actions(root_action) - if root_action.is_stale: - descendants = [root_action] + descendants - # Re-entrancy guard. - if getattr(self, "_cascade_in_progress", False): - self._flush_cascade_warnings() - return - if not descendants: - # Still surface any warnings accumulated by a prior standalone - # ``_recompute_action_in_place`` call (e.g. root recompute in - # ``_edit_mode_replay``) before bailing out. - self._flush_cascade_warnings() - return - # Reset the broken set: only the current cascade's outcomes count. - self._broken_actions.clear() - self._cascade_in_progress = True - try: - for action in descendants: - action.is_stale = True - self.tree.refresh_action_item(action) - QW.QApplication.processEvents() - for action in descendants: - try: - self._recompute_action_in_place(action) - finally: - # Keep the stale marker for actions flagged as broken - # (missing plugin) so the user can spot them in the tree. - if action.uuid not in self._broken_actions: - action.is_stale = False - self.tree.refresh_action_item(action) - QW.QApplication.processEvents() - finally: - for action in descendants: - if action.is_stale and action.uuid not in self._broken_actions: - action.is_stale = False - self.tree.refresh_action_item(action) - self._cascade_in_progress = False - self._flush_cascade_warnings() - - def _flush_cascade_warnings(self) -> None: - """Show + clear accumulated cascade warnings (no-op when empty).""" - if self._cascade_warnings: - QW.QMessageBox.warning( - self.mainwindow, - _("Cascade recompute"), - _("Some downstream actions could not be recomputed:") - + "\n\n• " - + "\n• ".join(self._cascade_warnings), - ) - self._cascade_warnings = [] - - - def __existing_input_uuids( - self, panel: BaseDataPanel, action: HistoryAction - ) -> list[str]: - """Return recorded input UUIDs that still exist in ``panel``.""" - recorded = action.state.selection.get(panel.PANEL_STR_ID, []) - existing: list[str] = [] - for uuid in recorded: - try: - panel.objmodel[uuid] - except KeyError: - continue - existing.append(uuid) - return existing - - def __sync_panel_selection(self) -> None: - """Sync data panel selection from the currently selected tree item.""" - if self.__replaying or self.__syncing: - return - item = self.tree.currentItem() - if item is None or not item.isSelected(): - return - if item.parent() is None: - # Session-level selection: peek the first compute action - index = self.tree.indexOfTopLevelItem(item) - if index < 0 or index >= len(self.__history_sessions): - return - session = self.__history_sessions[index] - action = next( - (a for a in session.actions if a.kind == HistoryAction.KIND_COMPUTE), - None, - ) - if action is None: - return - else: - uuid = item.data(0, QC.Qt.UserRole) - try: - action = self.tree.get_action_from_uuid( - uuid, self.__history_sessions - ) - except ValueError: - return - - panel = self.__resolve_panel_for_action(action) - if panel is None: - return - - target_uuids: list[str] = [] - output_uuid = self.__find_output_object_uuid(panel, action) - if output_uuid is not None: - target_uuids = [output_uuid] - else: - target_uuids = self.__existing_input_uuids(panel, action) - - if not target_uuids: - return - - self.__syncing = True - try: - with QC.QSignalBlocker(panel.objview): - panel.objview.select_objects(target_uuids) - self.mainwindow.set_current_panel(panel) - finally: - self.__syncing = False - - # ------------------------------------------------------------------ - # Step-by-step navigation (B2) - # ------------------------------------------------------------------ - - def __current_action(self) -> HistoryAction | None: - """Return the action currently selected in the tree, or ``None``.""" - item = self.tree.currentItem() - if item is None or item.parent() is None: - return None - uuid = item.data(0, QC.Qt.UserRole) - try: - return self.tree.get_action_from_uuid(uuid, self.__history_sessions) - except ValueError: - return None - - def __current_session(self) -> HistorySession | None: - """Return the session relevant for step navigation.""" - item = self.tree.currentItem() - if item is not None: - if item.parent() is None: - index = self.tree.indexOfTopLevelItem(item) - if 0 <= index < len(self.__history_sessions): - return self.__history_sessions[index] - else: - action = self.__current_action() - if action is not None: - return self._find_parent_session(action) - if self.__history_sessions: - return self.__history_sessions[-1] - return None - - def __can_step_prev(self) -> bool: - """Return True if a previous action exists in the current session.""" - session = self.__current_session() - if session is None or not session.actions: - return False - action = self.__current_action() - if action is None or action not in session.actions: - return False - return session.actions.index(action) > 0 - - def __can_step_next(self) -> bool: - """Return True if a next action exists in the current session.""" - session = self.__current_session() - if session is None or not session.actions: - return False - action = self.__current_action() - if action is None or action not in session.actions: - # No action selected — Next would land on the first one. - return True - return session.actions.index(action) < len(session.actions) - 1 - - def __select_action_in_tree(self, action: HistoryAction) -> None: - """Select ``action`` in the tree (triggers `__sync_panel_selection`).""" - for i in range(self.tree.topLevelItemCount()): - sess_item = self.tree.topLevelItem(i) - for j in range(sess_item.childCount()): - child = sess_item.child(j) - if child.data(0, QC.Qt.UserRole) == action.uuid: - self.tree.clearSelection() - self.tree.setCurrentItem(child) - child.setSelected(True) - return - - def _step_prev(self) -> None: - """Select the previous action in the current session.""" - if not self.__can_step_prev(): - return - session = self.__current_session() - action = self.__current_action() - idx = session.actions.index(action) - self.__select_action_in_tree(session.actions[idx - 1]) - self.__update_actions_state() - - def _step_next(self) -> None: - """Select the next action in the current session.""" - if not self.__can_step_next(): - return - session = self.__current_session() - action = self.__current_action() - if action is None or action not in session.actions: - target = session.actions[0] - else: - target = session.actions[session.actions.index(action) + 1] - self.__select_action_in_tree(target) - self.__update_actions_state() - - def duplicate_selected_entries(self) -> None: - """Duplicate selected sessions (with their data) into new independent sessions. - - For each selected session (or the parent session of a selected action), - all referenced data objects are deep-copied into a new group and the - session is duplicated with all UUID references rewritten to the clones. - The result is an independent, editable and replayable session. - """ - selected = self.tree.get_selected_actions_or_sessions(self.__history_sessions) - if not selected: - return - # Normalise: resolve individual actions to their parent session, deduplicate. - sessions_to_dup: list[HistorySession] = [] - seen: set[int] = set() - for item in selected: - if isinstance(item, HistorySession): - session = item - else: - session = self._find_parent_session(item) - if session is None: - continue - if id(session) not in seen: - seen.add(id(session)) - sessions_to_dup.append(session) - - copy_suffix = _("Copy") - new_sessions: list[HistorySession] = [] - panel_map = { - "signal": self.mainwindow.signalpanel, - "image": self.mainwindow.imagepanel, - } - - for session in sessions_to_dup: - # 1. Collect all UUIDs referenced by this session - uuids_by_panel: dict[str, set[str]] = {} - for action in session.actions: - for pstr, uuids in action.state.selection.items(): - uuids_by_panel.setdefault(pstr, set()).update(uuids) - for pstr, metadata in action.state.object_metadata.items(): - uuids_by_panel.setdefault(pstr, set()).update(metadata.keys()) - obj2 = action.kwargs.get("obj2_uuids") - if obj2: - pstr = action.panel_str or "" - if isinstance(obj2, str): - obj2 = [obj2] - uuids_by_panel.setdefault(pstr, set()).update(obj2) - # Output UUIDs produced by this action (e.g. result of a - # compute step). Without this, the last action's outputs - # would be missing because no subsequent state captures them. - if action.output_uuids: - pstr = action.panel_str or "" - uuids_by_panel.setdefault(pstr, set()).update( - action.output_uuids - ) - - # 2. Clone objects and build uuid_remap - uuid_remap: dict[str, dict[str, str]] = {} - clones_by_pstr: dict[str, list] = {} - group_title = f"{copy_suffix} - {session.title}" - for pstr, uuids in uuids_by_panel.items(): - panel = panel_map.get(pstr) - if panel is None: - continue - uuid_remap[pstr] = {} - existing_ids = set(panel.objmodel.get_object_ids()) - clones = [] - # Iterate in panel order (not set order) to preserve - # the topological object ordering in the duplicated group. - ordered_ids = [ - u for u in panel.objmodel.get_object_ids() if u in uuids - ] - for old_uuid in ordered_ids: - if old_uuid not in existing_ids: - continue - obj = panel.objmodel[old_uuid] - clone = deepcopy(obj) - new_uuid = str(uuid4()) - # SignalObj/ImageObj store UUID via metadata option - try: - clone.set_metadata_option("uuid", new_uuid) - except AttributeError: - clone.uuid = new_uuid - uuid_remap[pstr][old_uuid] = new_uuid - clones.append(clone) - clones_by_pstr[pstr] = clones - if clones: - group_id = get_uuid(panel.add_group(group_title)) - for clone in clones: - panel.add_object(clone, group_id=group_id) - - # Second pass: remap source UUIDs in cloned objects' - # processing_parameters so reprocessing in the Processing tab - # uses the cloned source, not the original. - from datalab.gui.processor.base import ( # pylint: disable=import-outside-toplevel - PROCESSING_PARAMETERS_OPTION, - ProcessingParameters, - ) - - for pstr_inner, clones_inner in clones_by_pstr.items(): - pmap = uuid_remap.get(pstr_inner, {}) - if not pmap: - continue - for clone in clones_inner: - try: - pp_dict = clone.get_metadata_option( - PROCESSING_PARAMETERS_OPTION - ) - except (AttributeError, ValueError): - continue - if not pp_dict: - continue - try: - pp = ProcessingParameters.from_dict(pp_dict) - except Exception: # pylint: disable=broad-except - continue - changed = False - if pp.source_uuid is not None and pp.source_uuid in pmap: - pp.source_uuid = pmap[pp.source_uuid] - changed = True - if pp.source_uuids is not None: - new_src = [pmap.get(u, u) for u in pp.source_uuids] - if new_src != pp.source_uuids: - pp.source_uuids = new_src - changed = True - if changed: - try: - clone.set_metadata_option( - PROCESSING_PARAMETERS_OPTION, pp.to_dict() - ) - except (AttributeError, ValueError): - pass - - # 3. Build the new session with remapped UUIDs - self.__session_increment += 1 - title = f"{session.title} {copy_suffix}" - new_session = session.copy_with_uuid_remap( - title=title, uuid_remap=uuid_remap - ) - new_session.number = self.__session_increment - new_sessions.append(new_session) - - # Register output mappings for cloned actions so that - # _resolve_target_outputs / get_downstream_actions work on - # the duplicated session (same logic as read_h5_data). - for action in new_session.actions: - if action.output_uuids: - self._action_output_uuids[action.uuid] = list( - action.output_uuids - ) - for out_uuid in action.output_uuids: - self._output_to_action[out_uuid] = action.uuid - - # Insert each duplicated session immediately after its original. - offset = 0 - for original_session, new_session in zip(sessions_to_dup, new_sessions): - idx = self.__history_sessions.index(original_session) - self.__history_sessions.insert(idx + 1 + offset, new_session) - offset += 1 - self.tree.populate_tree(self.__history_sessions) - self.__select_sessions(new_sessions) - self.refresh_compatibility_items() - self.__update_actions_state() - - def generate_macro(self) -> None: - """Generate a standalone Python script from selected history entries. - - The generated script uses sigima functions directly with proper variable - chaining. Object references (UUIDs) are resolved to variable names so - that 2-to-1 operations reference the correct intermediate result. - The script is copied to the clipboard and the user is notified. - """ - selected = self.tree.get_selected_actions_or_sessions( - self.__history_sessions - ) - actions: list[HistoryAction] = [] - if not selected: - for session in self.__history_sessions: - actions.extend(session.actions) - else: - for item in selected: - if isinstance(item, HistorySession): - actions.extend(item.actions) - else: - actions.append(item) - if not actions: - return - - # Filter to compute-only actions for the pipeline - compute_actions = [ - a for a in actions if a.kind == HistoryAction.KIND_COMPUTE - ] - if not compute_actions: - QW.QMessageBox.information( - self.mainwindow, - _("Generate macro"), - _("No compute actions to export."), - ) - return - - # Determine input type from first action - first_panel = compute_actions[0].panel_str - if first_panel == "signal": - obj_type = "SignalObj" - obj_import = "from sigima.objects import SignalObj" - else: - obj_type = "ImageObj" - obj_import = "from sigima.objects import ImageObj" - - imports: set[str] = set() - imports.add(obj_import) - body_lines: list[str] = [] - - # UUID → variable mapping for resolving object references. - # Populated with input UUIDs ("src", "src_2", ...) and enriched - # with each step's output UUID after code generation. - uuid_to_var: dict[str, str] = {} - - # Extra input parameters discovered during generation (second - # operands that are not produced by any previous step). - extra_inputs: list[str] = [] - - # Seed the mapping with the first action's input selection. - first_sel = compute_actions[0].state.selection.get( - compute_actions[0].panel_str, [] - ) - for i, uuid in enumerate(first_sel): - var = "src" if i == 0 else f"src_{i + 1}" - uuid_to_var[uuid] = var - - step = 0 - current_var = "src" - - for action in compute_actions: - step += 1 - - # Resolve input variable from the action's selection UUIDs. - sel_uuids = action.state.selection.get( - action.panel_str or "", [] - ) - if sel_uuids and sel_uuids[0] in uuid_to_var: - input_var = uuid_to_var[sel_uuids[0]] - else: - input_var = current_var - - # Resolve second operand for 2-to-1 patterns. - obj2_var: str | None = None - if action.pattern == "2_to_1": - obj2_uuids = action.kwargs.get("obj2_uuids", []) - if isinstance(obj2_uuids, str): - obj2_uuids = [obj2_uuids] - if obj2_uuids: - obj2_uuid = obj2_uuids[0] - if obj2_uuid in uuid_to_var: - obj2_var = uuid_to_var[obj2_uuid] - else: - # External input — add as function parameter. - obj2_var = f"obj2_{step}" - uuid_to_var[obj2_uuid] = obj2_var - extra_inputs.append(obj2_var) - - code_lines, output_var = action.to_macro_code( - step, input_var, imports, obj2_var=obj2_var - ) - body_lines.extend(code_lines) - body_lines.append("") - - if output_var is not None: - current_var = output_var - # Map the output UUID so subsequent steps can reference it. - output_uuid = self._action_output_uuid(action) - if output_uuid: - uuid_to_var[output_uuid] = output_var - # Also register any new UUIDs from the action's selection - # that we haven't seen yet (secondary selections). - for uuid in sel_uuids[1:]: - if uuid not in uuid_to_var: - uuid_to_var[uuid] = input_var - - # Build the function signature with extra inputs. - params_str = f"src: {obj_type}" - for extra in extra_inputs: - params_str += f", {extra}: {obj_type}" - - # Assemble the full script - sorted_imports = sorted(imports) - script_lines: list[str] = [ - '"""', - "DataLab — standalone processing pipeline", - f"Generated from history ({len(compute_actions)} steps)", - '"""', - "", - ] - script_lines.extend(sorted_imports) - script_lines.append("") - script_lines.append("") - script_lines.append( - f"def process({params_str}) -> {obj_type}:" - ) - script_lines.append( - ' """Apply the recorded processing pipeline."""' - ) - for line in body_lines: - script_lines.append(f" {line}" if line else "") - script_lines.append(f" return {current_var}") - script_lines.append("") - script_lines.append("") - script_lines.append('if __name__ == "__main__":') - script_lines.append( - " # Standalone execution: run from DataLab's Macro panel." - ) - script_lines.append(" # Operates on the current object of the target panel.") - script_lines.append( - " from datalab.control.proxy import RemoteProxy" - ) - script_lines.append("") - script_lines.append(" proxy = RemoteProxy()") - panel_str = compute_actions[0].panel_str or ( - "signal" if obj_type == "SignalObj" else "image" - ) - script_lines.append(f' proxy.set_current_panel("{panel_str}")') - script_lines.append(" src = proxy.get_object()") - script_lines.append(" if src is None:") - script_lines.append( - f' raise RuntimeError("No current object in panel: {panel_str}")' - ) - if extra_inputs: - n_extra = len(extra_inputs) - script_lines.append( - " _uuids = [u for u in proxy.get_sel_object_uuids()" - " if u != src.uuid]" - ) - script_lines.append(f" if len(_uuids) < {n_extra}:") - script_lines.append( - " raise RuntimeError(" - ) - script_lines.append( - f' "Pipeline needs {n_extra} extra selected' - ' object(s) besides the current one"' - ) - script_lines.append(" )") - for idx, extra in enumerate(extra_inputs): - script_lines.append( - f" {extra} = proxy.get_object(" - f'_uuids[{idx}], "{panel_str}")' - ) - extra_args = "".join(f", {e}" for e in extra_inputs) - script_lines.append(f" result = process(src{extra_args})") - script_lines.append(" proxy.add_object(result)") - script_lines.append(' print(f"Pipeline applied: {result.title}")') - script_lines.append("") - - script = "\n".join(script_lines) - QW.QApplication.clipboard().setText(script) - QW.QMessageBox.information( - self.mainwindow, - _("Generate macro"), - _("Macro script copied to clipboard (%d actions).") - % len(compute_actions), - ) - - def __select_sessions(self, sessions: list[HistorySession]) -> None: - """Select top-level tree items matching ``sessions``.""" - self.tree.clearSelection() - for session in sessions: - index = self.__history_sessions.index(session) - item = self.tree.topLevelItem(index) - item.setSelected(True) - self.tree.setCurrentItem(item) - - def delete_selected(self) -> None: - """Delete the selected actions or sessions (with confirmation). - - When a top-level session is selected, the entire session is deleted. - When individual actions are selected, they and all subsequent actions - in their parent session are removed. After deletion, the first - available item in the tree is selected automatically. - """ - selected = self.tree.get_selected_actions_or_sessions(self.__history_sessions) - if not selected: - return - has_individual_actions = any( - isinstance(item, HistoryAction) for item in selected - ) - if has_individual_actions: - msg = _( - "Do you really want to delete the selected items?\n\n" - "Note: deleting an action also removes all subsequent " - "actions in the same session." - ) - else: - msg = _("Do you really want to delete the selected items?") - reply = QW.QMessageBox.question( - self.mainwindow, - _("Delete"), - msg, - QW.QMessageBox.Yes | QW.QMessageBox.No, - QW.QMessageBox.No, - ) - if reply != QW.QMessageBox.Yes: - return - sessions_to_remove: set[int] = set() - for item in selected: - if isinstance(item, HistorySession): - sessions_to_remove.add(id(item)) - else: - # Individual action: remove from its parent session - for session in self.__history_sessions: - if item in session.actions: - session.remove_action(item) - if not session.actions: - sessions_to_remove.add(id(session)) - break - self.__history_sessions = [ - s for s in self.__history_sessions if id(s) not in sessions_to_remove - ] - self.tree.populate_tree(self.__history_sessions) - self.refresh_compatibility_items() - self.__update_actions_state() - # Auto-select the first available item after deletion - if self.tree.topLevelItemCount() > 0: - first = self.tree.topLevelItem(0) - self.tree.setCurrentItem(first) - first.setSelected(True) - - def remove_incompatible_actions(self) -> None: - """Remove all actions whose workspace state is incompatible. - - Shows a confirmation dialog listing how many actions will be removed, - then purges them from their sessions. Empty sessions are also removed. - """ - incompatible: list[tuple[HistorySession, HistoryAction]] = [] - for session in self.__history_sessions: - for action in session.actions: - if not action.is_current_state_compatible( - self.mainwindow, restore_selection=True - ): - incompatible.append((session, action)) - if not incompatible: - QW.QMessageBox.information( - self.mainwindow, - _("Remove incompatible"), - _("All actions are compatible with the current workspace."), - ) - return - reply = QW.QMessageBox.question( - self.mainwindow, - _("Remove incompatible"), - _("%d incompatible action(s) will be removed. Continue?") - % len(incompatible), - QW.QMessageBox.Yes | QW.QMessageBox.No, - QW.QMessageBox.No, - ) - if reply != QW.QMessageBox.Yes: - return - for session, action in incompatible: - if action in session.actions: - session.actions.remove(action) - # Remove empty sessions - self.__history_sessions = [ - s for s in self.__history_sessions if s.actions - ] - self.tree.populate_tree(self.__history_sessions) - self.refresh_compatibility_items() - self.__update_actions_state() - - def save_to_dlhist_file(self, filename: str | None = None) -> bool: - """Save the History Panel content to a standalone ``.dlhist`` file. - - Args: - filename: History filename. If None, a file dialog is opened. - - Returns: - True if the history was saved, False if the operation was canceled. - """ - if filename is None: - basedir = Conf.main.base_dir.get() - with save_restore_stds(): - filename, _filt = getsavefilename( - self, _("Save history file"), basedir, self.FILE_FILTERS - ) - if not filename: - return False - if osp.splitext(filename)[1] == "": - filename += ".dlhist" - with qt_try_loadsave_file(self.parentWidget(), filename, "save"): - Conf.main.base_dir.set(filename) - from datalab.h5.native import NativeH5Writer # pylint: disable=C0415 - - with NativeH5Writer(filename) as writer: - # Make the .dlhist file self-contained: store the signal and - # image panel objects (all of them) alongside the history, so - # that reopening restores both the data objects and the history - # that references them. Each section is read back by its own - # H5_PREFIX key, so the write order is not significant. - self.mainwindow.signalpanel.serialize_to_hdf5(writer) - self.mainwindow.imagepanel.serialize_to_hdf5(writer) - self.serialize_to_hdf5(writer) - return True - - def open_dlhist_file(self, filename: str | None = None) -> bool: - """Open a standalone ``.dlhist`` file into the History Panel. - - Args: - filename: History filename. If None, a file dialog is opened. - - Returns: - True if the history was loaded, False if the operation was canceled. - """ - if filename is None: - basedir = Conf.main.base_dir.get() - with save_restore_stds(): - filename, _filt = getopenfilename( - self, _("Open history file"), basedir, self.FILE_FILTERS - ) - if not filename: - return False - with qt_try_loadsave_file(self.parentWidget(), filename, "load"): - Conf.main.base_dir.set(filename) - from datalab.h5.native import NativeH5Reader # pylint: disable=C0415 - - with NativeH5Reader(filename) as reader: - # A self-contained .dlhist file stores the signal and image - # panel objects in addition to the history sessions. The way - # they are restored depends on whether the workspace is already - # in use (data objects OR history): a pristine workspace is - # loaded directly while preserving UUIDs, otherwise the file - # is imported as new groups/sessions. - workspace_in_use = ( - self.mainwindow.signalpanel.objmodel.get_object_ids() - or self.mainwindow.imagepanel.objmodel.get_object_ids() - or bool(self.__history_sessions) - ) - if workspace_in_use: - # Workspace not empty: import the objects into new groups - # with fresh UUIDs and append the history as new sessions - # whose references are remapped to the imported objects. - self.__import_dlhist_into_new_session(reader) - else: - # Workspace empty: load directly, preserving original UUIDs - # (reset_all=True) so that history references stay valid. - self.mainwindow.signalpanel.deserialize_from_hdf5( - reader, reset_all=True - ) - self.mainwindow.imagepanel.deserialize_from_hdf5( - reader, reset_all=True - ) - self.deserialize_from_hdf5(reader) - return True - - def __import_dlhist_into_new_session(self, reader: NativeH5Reader) -> None: - """Import a ``.dlhist`` file into new groups and new history sessions. - - Used when the workspace already contains objects: the file's signal and - image objects are imported into fresh groups with regenerated UUIDs, and - the history sessions are appended as new independent sessions whose action - references are remapped to the freshly imported objects. - - Args: - reader: HDF5 reader positioned on a ``.dlhist`` file. - """ - panel_map = { - "signal": self.mainwindow.signalpanel, - "image": self.mainwindow.imagepanel, - } - uuid_remap: dict[str, dict[str, str]] = {} - imported_by_pstr: dict[str, list] = {} - # 1. Import objects from each panel (each panel is read by its own - # H5_PREFIX key). Read each object preserving its original UUID to - # capture the old->new mapping, then assign a fresh UUID so that the - # imported objects keep an independent identity. - for pstr, panel in panel_map.items(): - uuid_remap[pstr] = {} - imported: list = [] - imported_by_pstr[pstr] = imported - if panel.H5_PREFIX not in reader.h5: - continue - with reader.group(panel.H5_PREFIX): - for name in reader.h5.get(panel.H5_PREFIX, []): - with reader.group(name): - group = panel.add_group("") - with reader.group("title"): - group.title = reader.read_str() - for obj_name in reader.h5.get( - f"{panel.H5_PREFIX}/{name}", [] - ): - obj = panel.deserialize_object_from_hdf5( - reader, obj_name, reset_all=True - ) - old_uuid = get_uuid(obj) - new_uuid = str(uuid4()) - # SignalObj/ImageObj store UUID via metadata option - try: - obj.set_metadata_option("uuid", new_uuid) - except AttributeError: - obj.uuid = new_uuid - uuid_remap[pstr][old_uuid] = new_uuid - panel.add_object( - obj, get_uuid(group), set_current=False - ) - imported.append(obj) - panel.selection_changed() - # 2. Remap source UUIDs in imported objects' processing_parameters so - # that reprocessing in the Processing tab uses the imported sources, - # not the originals (same logic as duplicate_selected_entries). - from datalab.gui.processor.base import ( # pylint: disable=import-outside-toplevel - PROCESSING_PARAMETERS_OPTION, - ProcessingParameters, - ) - - for pstr, objs in imported_by_pstr.items(): - pmap = uuid_remap.get(pstr, {}) - if not pmap: - continue - for obj in objs: - try: - pp_dict = obj.get_metadata_option(PROCESSING_PARAMETERS_OPTION) - except (AttributeError, ValueError): - continue - if not pp_dict: - continue - try: - pp = ProcessingParameters.from_dict(pp_dict) - except Exception: # pylint: disable=broad-except - continue - changed = False - if pp.source_uuid is not None and pp.source_uuid in pmap: - pp.source_uuid = pmap[pp.source_uuid] - changed = True - if pp.source_uuids is not None: - new_src = [pmap.get(u, u) for u in pp.source_uuids] - if new_src != pp.source_uuids: - pp.source_uuids = new_src - changed = True - if changed: - try: - obj.set_metadata_option( - PROCESSING_PARAMETERS_OPTION, pp.to_dict() - ) - except (AttributeError, ValueError): - pass - # 3. Import history sessions as new independent sessions whose captured - # UUIDs are remapped to the imported objects. - if self.H5_PREFIX not in reader.h5: - return - sessions = reader.read_object_list(self.H5_PREFIX, HistorySession) or [] - imported_suffix = _("Imported") - new_sessions: list[HistorySession] = [] - for session in sessions: - self.__session_increment += 1 - title = f"{session.title} {imported_suffix}" - new_session = session.copy_with_uuid_remap( - title=title, uuid_remap=uuid_remap - ) - new_session.number = self.__session_increment - new_sessions.append(new_session) - # Register output mappings for imported actions so that - # _resolve_target_outputs / get_downstream_actions work. - for action in new_session.actions: - if action.output_uuids: - self._action_output_uuids[action.uuid] = list( - action.output_uuids - ) - for out_uuid in action.output_uuids: - self._output_to_action[out_uuid] = action.uuid - self.__history_sessions.extend(new_sessions) - self.tree.populate_tree(self.__history_sessions) - self.refresh_compatibility_items() - self.__update_actions_state() - - def refresh_compatibility_items(self, *args: Any) -> None: - """Refresh action item compatibility markers in the tree.""" - del args - self.tree.update_compatibility_states(self.__history_sessions, self.mainwindow) - - def serialize_to_hdf5(self, writer: NativeH5Writer) -> None: - """Serialize whole panel to a HDF5 file - - Args: - writer: HDF5 writer - """ - writer.write_object_list(self.__history_sessions, self.H5_PREFIX) - - def deserialize_from_hdf5( - self, reader: NativeH5Reader, reset_all: bool = False - ) -> None: - """Deserialize whole panel from a HDF5 file - - Args: - reader: HDF5 reader - reset_all: Unused (kept for compatibility with panel API) - """ - if self.H5_PREFIX not in reader.h5: - self.__history_sessions = [] - self.__session_increment = 0 - self.tree.populate_tree(self.__history_sessions) - self.__update_actions_state() - return - self.__history_sessions: list[HistorySession] = ( - reader.read_object_list(self.H5_PREFIX, HistorySession) or [] - ) - if self.__history_sessions: - self.__session_increment = self.__history_sessions[-1].number - # Rebuild the bijective mapping from the loaded actions. Legacy - # (v1) actions have empty ``output_uuids`` and contribute nothing - # to the index — the heuristic fallback handles them. - self._action_output_uuids = {} - self._output_to_action = {} - for session in self.__history_sessions: - for action in session.actions: - if action.output_uuids: - self._action_output_uuids[action.uuid] = list( - action.output_uuids - ) - for out_uuid in action.output_uuids: - self._output_to_action[out_uuid] = action.uuid - self.tree.populate_tree(self.__history_sessions) - self.refresh_compatibility_items() - self.__update_actions_state() - - def __len__(self) -> int: - """Return number of objects""" - return sum(len(session.actions) for session in self.__history_sessions) - - def __getitem__(self, nb: int) -> HistoryAction: - """Return object from its number (1 to N)""" - for session in self.__history_sessions: - if nb <= len(session.actions): - return session.actions[nb - 1] - nb -= len(session.actions) - raise IndexError("Index out of range") - - def __iter__(self) -> Generator[HistoryAction, None, None]: - """Iterate over objects""" - for session in self.__history_sessions: - yield from session.actions - - def create_new_session(self) -> None: - """Create a new history list""" - self.__session_increment += 1 - session = HistorySession(number=self.__session_increment) - self.__history_sessions.append(session) - self.tree.populate_tree(self.__history_sessions) - self.refresh_compatibility_items() - - def start_new_session_after_workspace_reset(self) -> None: - """Start a new history session after a workspace reset, when useful.""" - if self.__history_sessions and self.__history_sessions[-1].actions: - self.create_new_session() - - def add_compute_entry( - self, - action_title: str, - panel_str: str, - func_name: str, - pattern: str, - save_state: bool = True, - output_uuids: list[str] | None = None, - plugin_origin: dict[str, Any] | None = None, - **kwargs: Any, - ) -> HistoryAction | None: - """Record a *compute* action in the current history session. - - Args: - action_title: Title shown in the history tree. - panel_str: ``"signal"`` or ``"image"``. - func_name: Sigima feature name (resolvable via - :meth:`BaseProcessor.get_feature`). - pattern: One of ``"1_to_1"``, ``"1_to_0"``, ``"n_to_1"``, ``"2_to_1"``, - ``"1_to_n"``, ``"multiple_1_to_1"`` (the latter is recorded for - traceability but not replayable). - save_state: If True, capture the workspace state for replay. - output_uuids: Optional list of UUIDs of the data objects produced by - this action. When known at call time, prefer passing it here so the - bijective mapping is initialised in one step. Most callers do not - know the outputs yet and instead wrap the compute call with - :meth:`capture_outputs` (or call :meth:`register_action_outputs` - explicitly afterwards) using the returned action. - plugin_origin: Optional plugin origin descriptor (see - :func:`datalab.gui.processor.base._detect_plugin_origin`). ``None`` - for built-in Sigima/DataLab features. - **kwargs: Extra primitive kwargs (``param``, ``obj2_uuids``, - ``obj2_name``, ``pairwise``, ``params`` (list of DataSet), - ``func_names`` (list of str), ...). ``DataSet`` instances are - serialised as JSON. - - Returns: - The created :class:`HistoryAction`, or ``None`` if recording is - disabled (record mode off or replay in progress). - """ - if not self.__record_mode or self.__replaying: - return None - state = WorkspaceState() - if save_state: - state.save(self.mainwindow) - # Deep-copy kwargs so each action owns independent parameter - # instances. Without this, consecutive applications of the same - # function (e.g. two gaussian_filter calls with different sigma) - # would share a single DataSet object and editing one action's - # parameters would silently mutate the other. - action = HistoryAction( - title=action_title, - kind=HistoryAction.KIND_COMPUTE, - panel_str=panel_str, - func_name=func_name, - pattern=pattern, - kwargs=deepcopy(kwargs), - state=state, - plugin_origin=plugin_origin, - ) - self.add_object(action) - if output_uuids is not None: - self.register_action_outputs(action, output_uuids) - return action - - def add_compute_entry_from_pp( - self, - action_title: str, - pp: Any, # ProcessingParameters (avoid circular import) - panel_str: str, - save_state: bool = True, - output_uuids: list[str] | None = None, - plugin_origin: dict[str, Any] | None = None, - **extras: Any, - ) -> HistoryAction | None: - """Record a *compute* action derived from a ``ProcessingParameters``. - - Bridges the dash-form pattern used in object metadata - (``"1-to-1"`` …) with the underscore form expected by - :class:`HistoryAction` (``"1_to_1"`` …) so that both sides share - a single identity (``func_name`` / ``pattern`` / ``param``). - - Args: - action_title: Title shown in the history tree. - pp: :class:`~datalab.gui.processor.base.ProcessingParameters` - instance describing the operation. - panel_str: ``"signal"`` or ``"image"``. - save_state: If True, capture the workspace state for replay. - output_uuids: Optional list of UUIDs of the data objects produced - by this action (see :meth:`add_compute_entry`). - plugin_origin: Optional plugin origin descriptor (see - :meth:`add_compute_entry`). - **extras: Additional history-only kwargs (``obj2_uuids``, - ``obj2_name``, ``pairwise``, ``params``, ``func_names``…). - - Returns: - The created :class:`HistoryAction`, or ``None`` if recording is - disabled. - """ - hist_pattern = pp.pattern.replace("-", "_") - kwargs: dict[str, Any] = {} - if pp.param is not None and "param" not in extras and "params" not in extras: - kwargs["param"] = pp.param - kwargs.update(extras) - return self.add_compute_entry( - action_title, - panel_str=panel_str, - func_name=pp.func_name, - pattern=hist_pattern, - save_state=save_state, - output_uuids=output_uuids, - plugin_origin=plugin_origin, - **kwargs, - ) - - def register_action_outputs( - self, action: HistoryAction, output_uuids: list[str] - ) -> None: - """Register the data objects produced by ``action``. - - Maintains the bijective ``action → outputs`` and ``output → action`` - mappings. May be called multiple times for a given action (later calls - replace earlier ones, e.g. after a cascade recompute). - - Args: - action: The history action that produced the outputs. - output_uuids: UUIDs of the produced data objects (empty for - ``1_to_0`` analysis patterns and for UI actions that did not - create new objects). - """ - # Drop previous outputs for this action from the reverse index. - previous = self._action_output_uuids.get(action.uuid, []) - for prev_uuid in previous: - if self._output_to_action.get(prev_uuid) == action.uuid: - self._output_to_action.pop(prev_uuid, None) - new_outputs = list(output_uuids) - # Ownership transfer: if an output_uuid already belongs to a - # *different* action, remove it from that action's output list so the - # forward mapping stays consistent. The HistoryAction object's - # ``output_uuids`` attribute is NOT updated here because traversing all - # sessions to locate the object would be expensive; the panel-level - # dicts are the source of truth. - for out_uuid in new_outputs: - old_action_uuid = self._output_to_action.get(out_uuid) - if old_action_uuid is not None and old_action_uuid != action.uuid: - old_list = self._action_output_uuids.get(old_action_uuid) - if old_list is not None: - try: - old_list.remove(out_uuid) - except ValueError: - pass - if not old_list: - del self._action_output_uuids[old_action_uuid] - _logger.debug( - "Output %s transferred from action %s to %s", - out_uuid, - old_action_uuid, - action.uuid, - ) - action.output_uuids = list(new_outputs) - self._action_output_uuids[action.uuid] = new_outputs - for out_uuid in new_outputs: - self._output_to_action[out_uuid] = action.uuid - - @contextmanager - def capture_outputs( - self, action: HistoryAction | None - ) -> Generator[None, None, None]: - """Context manager: snapshot panel object IDs and record diffs as outputs. - - Use around any compute call when the produced UUIDs are not known - upfront. On exit, every newly-added object (signal or image) is - registered as an output of ``action`` via - :meth:`register_action_outputs`. No-op when ``action`` is ``None`` - (recording disabled). - - Args: - action: The history action being processed, or ``None``. - """ - if action is None: - yield - return - panels = (self.mainwindow.signalpanel, self.mainwindow.imagepanel) - before = { - p.PANEL_STR_ID: set(p.objmodel.get_object_ids()) for p in panels - } - try: - yield - finally: - new_uuids: list[str] = [] - for p in panels: - before_p = before[p.PANEL_STR_ID] - for uid in p.objmodel.get_object_ids(): - if uid not in before_p: - new_uuids.append(uid) - self.register_action_outputs(action, new_uuids) - - def _prune_output_mapping(self) -> None: - """Drop entries of :attr:`_output_to_action` whose object no longer exists. - - Connected to each data panel's ``SIG_OBJECT_REMOVED`` so that the - reverse index stays consistent with the live workspace. The forward - ``_action_output_uuids`` mapping is intentionally left intact: it - records the *historical* outputs of each action (useful for replay - and cascade introspection even after an output was deleted). - """ - if not self._output_to_action: - return - alive: set[str] = set() - for panel in (self.mainwindow.signalpanel, self.mainwindow.imagepanel): - alive.update(panel.objmodel.get_object_ids()) - stale = [u for u in self._output_to_action if u not in alive] - for u in stale: - self._output_to_action.pop(u, None) - - def _reconnect_chain_after_removal(self, panel: BaseDataPanel) -> None: - """Reconnect the processing chain after object(s) were deleted from a - data panel, like removing a link from a linked list. - - Each deleted object that was an intermediate processing result has its - downstream consumers reconnected to its own source (the first source for - multi-source operations) and recomputed in cascade, so the chain keeps - producing consistent results. Cases that cannot be reconnected (the - deleted object has no valid source) are reported in a single warning but - the deletion is always kept. - - Connected to each data panel's ``SIG_OBJECT_REMOVED`` (bound to the - panel via ``functools.partial``). Runs before :meth:`_prune_output_mapping` - so the bijective output map is still available. - """ - pstr = panel.PANEL_STR_ID - previous = self.__obj_ids_snapshot.get(pstr, set()) - current = set(panel.objmodel.get_object_ids()) - removed = previous - current - if not removed or self.__reconnecting: - return - self.__reconnecting = True - try: - warnings: list[str] = [] - roots_to_recompute: list[HistoryAction] = [] - for x_uuid in removed: - self.__reconnect_single_removed( - panel, x_uuid, warnings, roots_to_recompute - ) - for action in roots_to_recompute: - self._recompute_action_in_place(action) - self.recompute_cascade(action) - if warnings: - QW.QMessageBox.warning( - self.mainwindow, - _("Delete"), - _( - "Some operations could not be reconnected after " - "deletion:" - ) - + "\n\n• " - + "\n• ".join(warnings), - ) - self.tree.populate_tree(self.__history_sessions) - self.refresh_compatibility_items() - self.__update_actions_state() - finally: - self.__reconnecting = False - self.__refresh_obj_ids_snapshot() - - def __reconnect_single_removed( - self, - panel: BaseDataPanel, - x_uuid: str, - warnings: list[str], - roots_to_recompute: list[HistoryAction], - ) -> None: - """Reconnect consumers of a single deleted object ``x_uuid``. - - Appends a localized message to ``warnings`` when reconnection is not - possible, and appends each consumer's producing action to - ``roots_to_recompute`` so the caller can recompute the cascade. - """ - from datalab.gui.processor.base import ( # pylint: disable=import-outside-toplevel - ProcessingParameters, - extract_processing_parameters, - insert_processing_parameters, - ) - - pstr = panel.PANEL_STR_ID - # 1. Producing action of the deleted object (to learn its source). - action_a = None - action_a_uuid = self._output_to_action.get(x_uuid) - if action_a_uuid is not None: - for session in self.__history_sessions: - for a in session.actions: - if a.uuid == action_a_uuid: - action_a = a - break - if action_a is not None: - break - # 2. Consumers: objects whose processing references x_uuid as a source. - consumers: list[tuple[Any, Any]] = [] - for obj in panel.objmodel: - pp = extract_processing_parameters(obj) - if pp is None: - continue - if pp.source_uuid == x_uuid or ( - pp.source_uuids and x_uuid in pp.source_uuids - ): - consumers.append((obj, pp)) - if not consumers: - # Leaf deletion: nothing downstream to reconnect. - return - # 3. Source S of the deleted object (first source for multi-source ops). - s_uuid: str | None = None - if action_a is not None: - sel = action_a.state.selection.get(pstr, []) - if sel: - s_uuid = sel[0] - alive_ids = set(panel.objmodel.get_object_ids()) - if s_uuid is None or s_uuid not in alive_ids: - label = ( - action_a.title - or action_a.func_name - if action_a is not None - else x_uuid - ) - warnings.append( - _( - "“%s” has dependent operations but no valid source to " - "reconnect to — downstream results are left unchanged." - ) - % label - ) - return - # 4. Reconnect each consumer: replace x_uuid -> s_uuid in its pp and in - # its producing action's recorded inputs, then queue it for recompute. - for obj, pp in consumers: - new_source_uuid = ( - s_uuid if pp.source_uuid == x_uuid else pp.source_uuid - ) - new_source_uuids = pp.source_uuids - if pp.source_uuids and x_uuid in pp.source_uuids: - new_source_uuids = [ - s_uuid if u == x_uuid else u for u in pp.source_uuids - ] - insert_processing_parameters( - obj, - ProcessingParameters( - func_name=pp.func_name, - pattern=pp.pattern, - param=pp.param, - source_uuid=new_source_uuid, - source_uuids=new_source_uuids, - ), - ) - if pp.func_name: - action_b = self.find_action_for_output( - get_uuid(obj), pp.func_name - ) - if action_b is not None: - self.__rewrite_action_source( - action_b, pstr, x_uuid, s_uuid - ) - if action_b not in roots_to_recompute: - roots_to_recompute.append(action_b) - # 5. Drop the deleted node's action if all its outputs are gone. - if action_a is not None: - outs = self._action_output_uuids.get(action_a.uuid, []) - if not any(o in alive_ids for o in outs): - self.__remove_single_action(action_a) - - def __rewrite_action_source( - self, - action: HistoryAction, - pstr: str, - old_uuid: str, - new_uuid: str, - ) -> None: - """Replace ``old_uuid`` with ``new_uuid`` in an action's recorded inputs. - - Updates both the captured selection and the ``obj2_uuids`` kwarg (for - 2_to_1 actions) so future replays/recomputes use the new source. - """ - sel = action.state.selection.get(pstr) - if sel: - action.state.selection[pstr] = [ - new_uuid if u == old_uuid else u for u in sel - ] - obj2 = action.kwargs.get("obj2_uuids") - if isinstance(obj2, str): - if obj2 == old_uuid: - action.kwargs["obj2_uuids"] = new_uuid - elif obj2: - action.kwargs["obj2_uuids"] = [ - new_uuid if u == old_uuid else u for u in obj2 - ] - - def __remove_single_action(self, action: HistoryAction) -> None: - """Remove a single action from its session (splice, not truncate). - - Also drops the action's entries from the bijective output maps, and - removes the parent session if it becomes empty. - """ - for session in self.__history_sessions: - if action in session.actions: - session.actions.remove(action) - outs = self._action_output_uuids.pop(action.uuid, []) - for out_uuid in outs: - if self._output_to_action.get(out_uuid) == action.uuid: - self._output_to_action.pop(out_uuid, None) - if not session.actions: - self.__history_sessions.remove(session) - break - - def add_ui_entry( - self, - action_title: str, - target: str, - method_name: str, - save_state: bool = True, - **kwargs: Any, - ) -> None: - """Record a *UI* action in the current history session. - - Args: - action_title: Title shown in the history tree. - target: One of ``"mainwindow"``, ``"signalpanel"``, ``"imagepanel"``, - ``"historypanel"`` -- attribute path on the main window. - method_name: Method name to call on ``target`` at replay time. - save_state: If True, capture the workspace state for replay. - **kwargs: Method keyword arguments. ``DataSet`` instances are - serialised as JSON; other values must be HDF5-friendly primitives. - """ - if not self.__record_mode or self.__replaying: - return - state = WorkspaceState() - if save_state: - state.save(self.mainwindow) - # Deep-copy kwargs to ensure independent parameter ownership - # (same rationale as in add_compute_entry). - action = HistoryAction( - title=action_title, - kind=HistoryAction.KIND_UI, - target=target, - method_name=method_name, - kwargs=deepcopy(kwargs), - state=state, - ) - self.add_object(action) - - def add_entry( - self, - action_title: str, - save_state: bool, - func: Callable, - **kwargs, - ) -> None: - """Legacy entry-point kept as a compatibility shim. - - Most call sites have been migrated to :meth:`add_compute_entry` or - :meth:`add_ui_entry`. The remaining paths -- and the - :func:`add_to_history` decorator -- still call ``add_entry`` with a - bound method; we infer the ``(target, method_name)`` from the bound - ``func.__self__`` and route to :meth:`add_ui_entry`. - """ - if not self.__record_mode or self.__replaying: - return - target = None - if hasattr(func, "__self__"): - target = _resolve_self_target(func.__self__) - if target is None: - # Cannot route safely -- skip rather than pickle a Callable. - return - self.add_ui_entry( - action_title, - target=target, - method_name=func.__name__, - save_state=save_state, - **kwargs, - ) - - # ------ AbstractPanel interface --------------------------------------------------- - def create_object(self) -> HistoryAction: - """Create and return object""" - return HistoryAction() - - def add_object(self, obj: HistoryAction) -> None: - """Add object to panel""" - if not self.__history_sessions: - self.create_new_session() - self.__history_sessions[-1].add_action(obj) - self.tree.add_action_to_tree(obj) - self.tree.rearrange_tree() - self.refresh_compatibility_items() - self.__update_actions_state() - - def remove_all_objects(self): - """Remove all objects""" - super().remove_all_objects() - self._action_output_uuids.clear() - self._output_to_action.clear() - self.__update_actions_state() diff --git a/datalab/gui/panel/history/__init__.py b/datalab/gui/panel/history/__init__.py new file mode 100644 index 000000000..4cce7abf8 --- /dev/null +++ b/datalab/gui/panel/history/__init__.py @@ -0,0 +1,21 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +"""History panel subpackage — re-exports public history symbols.""" + +from datalab.gui.panel.history.panel import HistoryPanel +from datalab.history import HistoryAction, HistorySession, WorkspaceState +from datalab.history.core import ( + HISTORY_ACTION_SCHEMA_VERSION, + HISTORY_SCHEMA_VERSION, +) +from datalab.widgets.historytree import HistoryTree + +__all__ = [ + "HISTORY_ACTION_SCHEMA_VERSION", + "HISTORY_SCHEMA_VERSION", + "HistoryAction", + "HistoryPanel", + "HistorySession", + "HistoryTree", + "WorkspaceState", +] diff --git a/datalab/gui/panel/history/chain.py b/datalab/gui/panel/history/chain.py new file mode 100644 index 000000000..48e3a935c --- /dev/null +++ b/datalab/gui/panel/history/chain.py @@ -0,0 +1,375 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +"""Action↔output chain helpers for the History panel.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from qtpy import QtWidgets as QW + +from datalab.config import _ +from datalab.env import execenv +from datalab.gui.processor.base import ( + ProcessingParameters, + extract_processing_parameters, + insert_processing_parameters, +) +from datalab.history import HistoryAction, HistorySession +from datalab.objectmodel import get_uuid + +if TYPE_CHECKING: + from datalab.gui.panel.base import BaseDataPanel + from datalab.gui.panel.history.panel import HistoryPanel + +_logger = logging.getLogger(__name__) + + +def find_parent_session( + panel: HistoryPanel, action: HistoryAction +) -> HistorySession | None: + """Return the session that contains ``action``, or None.""" + for session in panel._history_sessions: + if action in session.actions: + return session + return None + + +def resolve_panel_for_action( + panel: HistoryPanel, action: HistoryAction +) -> BaseDataPanel | None: + """Return the data panel targeted by ``action``, or ``None``.""" + if action.kind != HistoryAction.KIND_COMPUTE: + return None + if action.panel_str == "signal": + return panel.mainwindow.signalpanel + if action.panel_str == "image": + return panel.mainwindow.imagepanel + return None + + +def find_output_object_uuid( + panel: HistoryPanel, panel_data: BaseDataPanel, action: HistoryAction +) -> str | None: + """Find the UUID of the output object produced by ``action`` in ``panel_data``. + + Primary path: consult the bijective ``action_output_uuids`` mapping. + Fallback path: legacy heuristic on ``processing_parameters`` metadata. + """ + registered = panel._action_output_uuids.get(action.uuid) + if registered: + existing_ids = set(panel_data.objmodel.get_object_ids()) + for out_uuid in registered: + if out_uuid in existing_ids: + return out_uuid + if action.func_name is None: + return None + recorded_uuids = set(action.state.selection.get(panel_data.PANEL_STR_ID, [])) + if not recorded_uuids: + return None + for obj in panel_data.objmodel: + pp = extract_processing_parameters(obj) + if pp is None or pp.func_name != action.func_name: + continue + if pp.source_uuid is not None and pp.source_uuid in recorded_uuids: + return get_uuid(obj) + if pp.source_uuids is not None and recorded_uuids.intersection(pp.source_uuids): + return get_uuid(obj) + return None + + +def find_action_for_output( + panel: HistoryPanel, output_uuid: str, func_name: str +) -> HistoryAction | None: + """Find the :class:`HistoryAction` that produced ``output_uuid``.""" + if not panel._history_sessions: + return None + action_uuid = panel._output_to_action.get(output_uuid) + if action_uuid is not None: + for session in panel._history_sessions: + for action in session.actions: + if action.uuid == action_uuid: + if action.func_name == func_name: + return action + return None + panel_data: BaseDataPanel | None = None + output_obj = None + for p in (panel.mainwindow.signalpanel, panel.mainwindow.imagepanel): + if p.objmodel.has_uuid(output_uuid): + output_obj = p.objmodel[output_uuid] + panel_data = p + break + if panel_data is None or output_obj is None: + return None + pp = extract_processing_parameters(output_obj) + if pp is None or pp.func_name != func_name: + return None + target_source_uuid = pp.source_uuid + if target_source_uuid is None: + return None + for current_session in reversed(panel._history_sessions): + for action in reversed(current_session.actions): + if action.kind != HistoryAction.KIND_COMPUTE: + continue + if action.func_name != func_name: + continue + if action.panel_str != panel_data.PANEL_STR_ID: + continue + captured = action.state.selection.get(panel_data.PANEL_STR_ID, []) + if captured and captured[0] == target_source_uuid: + return action + return None + + +def get_session_of(panel: HistoryPanel, action: HistoryAction) -> HistorySession | None: + """Return the session that contains ``action``, or None.""" + for session in panel._history_sessions: + if action in session.actions: + return session + return None + + +def action_output_uuid(panel: HistoryPanel, action: HistoryAction) -> str | None: + """Return the UUID of the object produced by ``action``, or ``None``.""" + panel_data = resolve_panel_for_action(panel, action) + if panel_data is None: + return None + return find_output_object_uuid(panel, panel_data, action) + + +def action_consumes_any(action: HistoryAction, uuids: set[str]) -> bool: + """Return True if ``action``'s input UUIDs intersect ``uuids``.""" + if action.kind != HistoryAction.KIND_COMPUTE: + return False + pstr = action.panel_str or "" + captured: set[str] = set(action.state.selection.get(pstr, [])) + obj2 = action.kwargs.get("obj2_uuids") + if obj2: + if isinstance(obj2, str): + captured.add(obj2) + else: + captured.update(obj2) + return bool(captured & uuids) + + +def collect_downstream_uuids(panel: HistoryPanel, action: HistoryAction) -> set[str]: + """Return the transitive closure of output UUIDs descending from ``action``.""" + if not panel._history_sessions: + return set() + current = get_session_of(panel, action) + if current is None: + return set() + root_out = action_output_uuid(panel, action) + if root_out is None: + return set() + closure: set[str] = {root_out} + idx = current.actions.index(action) + for downstream in current.actions[idx + 1 :]: + if downstream.kind != HistoryAction.KIND_COMPUTE: + continue + if not action_consumes_any(downstream, closure): + continue + out_uuid = action_output_uuid(panel, downstream) + if out_uuid is not None: + closure.add(out_uuid) + closure.discard(root_out) + return closure + + +def get_downstream_actions( + panel: HistoryPanel, action: HistoryAction +) -> list[HistoryAction]: + """Return the actions of the current session that depend on ``action``.""" + if not panel._history_sessions: + return [] + current = get_session_of(panel, action) + if current is None: + return [] + root_out = action_output_uuid(panel, action) + if root_out is None: + return [] + closure: set[str] = {root_out} + downstream: list[HistoryAction] = [] + idx = current.actions.index(action) + for candidate in current.actions[idx + 1 :]: + if candidate.kind != HistoryAction.KIND_COMPUTE: + continue + if not action_consumes_any(candidate, closure): + continue + downstream.append(candidate) + out_uuid = action_output_uuid(panel, candidate) + if out_uuid is not None: + closure.add(out_uuid) + return downstream + + +def resolve_target_outputs( + panel: HistoryPanel, panel_data: BaseDataPanel, action: HistoryAction +) -> tuple[list[str], list[str]]: + """Return ``(existing, missing)`` UUIDs registered for ``action``.""" + registered = list(panel._action_output_uuids.get(action.uuid, [])) + existing_ids = set(panel_data.objmodel.get_object_ids()) + existing: list[str] = [u for u in registered if u in existing_ids] + missing: list[str] = [u for u in registered if u not in existing_ids] + return existing, missing + + +def existing_input_uuids(panel_data: BaseDataPanel, action: HistoryAction) -> list[str]: + """Return recorded input UUIDs that still exist in ``panel_data``.""" + recorded = action.state.selection.get(panel_data.PANEL_STR_ID, []) + return [uuid for uuid in recorded if panel_data.objmodel.has_uuid(uuid)] + + +def prune_output_mapping(panel: HistoryPanel) -> None: + """Drop entries of :attr:`_output_to_action` whose object no longer exists.""" + if not panel._output_to_action: + return + alive: set[str] = set() + for pdata in (panel.mainwindow.signalpanel, panel.mainwindow.imagepanel): + alive.update(pdata.objmodel.get_object_ids()) + stale = [u for u in panel._output_to_action if u not in alive] + for u in stale: + panel._output_to_action.pop(u, None) + + +def rewrite_action_source( + action: HistoryAction, + pstr: str, + old_uuid: str, + new_uuid: str, +) -> None: + """Replace ``old_uuid`` with ``new_uuid`` in an action's recorded inputs.""" + sel = action.state.selection.get(pstr) + if sel: + action.state.selection[pstr] = [new_uuid if u == old_uuid else u for u in sel] + obj2 = action.kwargs.get("obj2_uuids") + if isinstance(obj2, str): + if obj2 == old_uuid: + action.kwargs["obj2_uuids"] = new_uuid + elif obj2: + action.kwargs["obj2_uuids"] = [new_uuid if u == old_uuid else u for u in obj2] + + +def remove_single_action(panel: HistoryPanel, action: HistoryAction) -> None: + """Remove a single action from its session (splice, not truncate).""" + for session in panel._history_sessions: + if action in session.actions: + session.actions.remove(action) + outs = panel._action_output_uuids.pop(action.uuid, []) + for out_uuid in outs: + if panel._output_to_action.get(out_uuid) == action.uuid: + panel._output_to_action.pop(out_uuid, None) + if not session.actions: + panel._history_sessions.remove(session) + break + + +def reconnect_single_removed( + panel: HistoryPanel, + panel_data: BaseDataPanel, + x_uuid: str, + warnings: list[str], + roots_to_recompute: list[HistoryAction], +) -> None: + """Reconnect consumers of a single deleted object ``x_uuid``.""" + pstr = panel_data.PANEL_STR_ID + action_a = None + action_a_uuid = panel._output_to_action.get(x_uuid) + if action_a_uuid is not None: + for session in panel._history_sessions: + for a in session.actions: + if a.uuid == action_a_uuid: + action_a = a + break + if action_a is not None: + break + consumers: list[tuple[Any, Any]] = [] + for obj in panel_data.objmodel: + pp = extract_processing_parameters(obj) + if pp is None: + continue + if pp.source_uuid == x_uuid or (pp.source_uuids and x_uuid in pp.source_uuids): + consumers.append((obj, pp)) + if not consumers: + return + s_uuid: str | None = None + if action_a is not None: + sel = action_a.state.selection.get(pstr, []) + if sel: + s_uuid = sel[0] + alive_ids = set(panel_data.objmodel.get_object_ids()) + if s_uuid is None or s_uuid not in alive_ids: + label = action_a.title or action_a.func_name if action_a is not None else x_uuid + warnings.append( + _( + "“%s” has dependent operations but no valid source to " + "reconnect to — downstream results are left unchanged." + ) + % label + ) + return + for obj, pp in consumers: + new_source_uuid = s_uuid if pp.source_uuid == x_uuid else pp.source_uuid + new_source_uuids = pp.source_uuids + if pp.source_uuids and x_uuid in pp.source_uuids: + new_source_uuids = [s_uuid if u == x_uuid else u for u in pp.source_uuids] + insert_processing_parameters( + obj, + ProcessingParameters( + func_name=pp.func_name, + pattern=pp.pattern, + param=pp.param, + source_uuid=new_source_uuid, + source_uuids=new_source_uuids, + ), + ) + if pp.func_name: + action_b = find_action_for_output(panel, get_uuid(obj), pp.func_name) + if action_b is not None: + rewrite_action_source(action_b, pstr, x_uuid, s_uuid) + if action_b not in roots_to_recompute: + roots_to_recompute.append(action_b) + if action_a is not None: + outs = panel._action_output_uuids.get(action_a.uuid, []) + if not any(o in alive_ids for o in outs): + remove_single_action(panel, action_a) + + +def reconnect_chain_after_removal( + panel: HistoryPanel, panel_data: BaseDataPanel +) -> None: + """Reconnect the processing chain after object(s) were deleted from a data panel.""" + from datalab.gui.panel.history import recompute as hrec + + pstr = panel_data.PANEL_STR_ID + previous = panel._obj_ids_snapshot.get(pstr, set()) + current = set(panel_data.objmodel.get_object_ids()) + removed = previous - current + if not removed or panel._reconnecting: + return + panel._reconnecting = True + try: + warnings: list[str] = [] + roots_to_recompute: list[HistoryAction] = [] + for x_uuid in removed: + reconnect_single_removed( + panel, panel_data, x_uuid, warnings, roots_to_recompute + ) + for action in roots_to_recompute: + hrec.recompute_action_in_place(panel, action) + hrec.recompute_cascade(panel, action) + if warnings and not execenv.unattended: + QW.QMessageBox.warning( + panel.mainwindow, + _("Delete"), + _("Some operations could not be reconnected after deletion:") + + "\n\n• " + + "\n• ".join(warnings), + ) + panel.tree.populate_tree(panel._history_sessions) + panel.refresh_compatibility_items() + panel.update_actions_state() + finally: + panel._reconnecting = False + panel.refresh_obj_ids_snapshot() diff --git a/datalab/gui/panel/history/interactive_replay.py b/datalab/gui/panel/history/interactive_replay.py new file mode 100644 index 000000000..2514981d9 --- /dev/null +++ b/datalab/gui/panel/history/interactive_replay.py @@ -0,0 +1,195 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +"""Interactive (dialog-driven) replay helpers for the History panel.""" + +from __future__ import annotations + +import copy +import logging +from typing import TYPE_CHECKING + +import guidata.dataset as gds +from guidata.dataset.qtwidgets import DataSetEditDialog, DataSetGroupEditDialog +from qtpy import QtWidgets as QW + +from datalab.config import _ +from datalab.env import execenv +from datalab.gui.panel.history import recompute as hrec +from datalab.history import HistoryAction, HistorySession + +if TYPE_CHECKING: + from datalab.gui.panel.history.panel import HistoryPanel + +_logger = logging.getLogger(__name__) + + +def replay_restore_actions( + panel: HistoryPanel, replay: bool = True, restore_selection: bool = True +) -> None: + """Replay and/or restore selection for the selected actions.""" + panel.refresh_compatibility_items() + selected = panel.tree.get_selected_actions_or_sessions(panel._history_sessions) + if not selected: + if not panel._history_sessions: + return + selected = [panel._history_sessions[-1]] + for session_or_action in selected: + if isinstance(session_or_action, HistoryAction) and session_or_action.is_stale: + hrec.recompute_cascade(panel, session_or_action) + continue + if not session_or_action.is_current_state_compatible( + panel.mainwindow, restore_selection=restore_selection + ): + if not execenv.unattended: + QW.QMessageBox.critical( + panel.mainwindow, + _("Error"), + _("The current workspace state is not compatible with the action."), + ) + return + if replay: + if panel._edit_mode and isinstance(session_or_action, HistoryAction): + edit_mode_replay(panel, session_or_action) + elif panel._edit_mode and isinstance(session_or_action, HistorySession): + view_only_session_replay(panel, session_or_action, restore_selection) + else: + with panel.replaying(), panel.output_suppressed(): + session_or_action.replay( + panel.mainwindow, + restore_selection=restore_selection, + edit=panel._edit_mode, + ) + elif restore_selection: + if panel._edit_mode or panel.has_any_pending_edits(): + restore_action_params(panel, session_or_action) + else: + session_or_action.restore(panel.mainwindow) + + +def prompt_edit_action_params( + panel: HistoryPanel, action: HistoryAction +) -> bool | None: + """Open the parameter dialog for *action* according to its pattern.""" + pattern = action.pattern + if pattern in {"1_to_1", "1_to_0", "n_to_1", "2_to_1"}: + param = action.kwargs.get("param") + if param is None: + return None + edited = copy.deepcopy(param) + if not edited.edit(parent=panel.mainwindow): + return False + action.snapshot_kwargs() + action.kwargs["param"] = edited + return True + if pattern == "1_to_n": + params = action.kwargs.get("params") or [] + if not params: + return None + edited_params = [copy.deepcopy(p) for p in params] + group = gds.DataSetGroup(edited_params, title=_("Parameters")) + if not group.edit(parent=panel.mainwindow): + return False + action.snapshot_kwargs() + action.kwargs["params"] = edited_params + return True + return None + + +def edit_mode_replay(panel: HistoryPanel, action: HistoryAction) -> None: + """Replay a single action in edit mode: open param dialog, update kwargs.""" + if action.kind != HistoryAction.KIND_COMPUTE or action.pattern is None: + with panel.replaying(), panel.output_suppressed(): + action.replay(panel.mainwindow, restore_selection=True, edit=True) + return + + chain: list[HistoryAction] = [action] + panel.get_downstream_actions(action) + edited_actions: list[HistoryAction] = [] + for a in chain: + result = prompt_edit_action_params(panel, a) + if result is False: + for done in edited_actions: + done.restore_kwargs() + panel.tree.refresh_action_item(done) + return + if result is True: + edited_actions.append(a) + + for a in edited_actions: + panel.tree.refresh_action_item(a) + + downstream = chain[1:] + hrec.recompute_action_in_place(panel, action) + hrec.recompute_cascade(panel, action, descendants=downstream) + + for a in chain: + panel.tree.refresh_action_item(a) + QW.QApplication.processEvents() + + +def show_readonly_param_dialog( + panel: HistoryPanel, dataset: gds.DataSet | gds.DataSetGroup +) -> None: + """Show a parameter dialog identical to the edit dialog but read-only.""" + if isinstance(dataset, gds.DataSetGroup): + dialog = DataSetGroupEditDialog(dataset, parent=panel.mainwindow) + else: + dialog = DataSetEditDialog(dataset, parent=panel.mainwindow) + for edl in dialog.edit_layout: + for widget in edl.widgets: + if widget.group is not None: + widget.group.setEnabled(False) + if widget.label is not None: + widget.label.setEnabled(False) + dialog.exec() + + +def view_only_session_replay( + panel: HistoryPanel, + session: HistorySession, + restore_selection: bool, +) -> None: + """Replay a session in edit mode with read-only parameter dialogs.""" + for action in session.actions: + if action.kind != HistoryAction.KIND_COMPUTE: + continue + pattern = action.pattern + panel.select_action_in_tree(action) + QW.QApplication.processEvents() + if pattern in {"1_to_1", "1_to_0", "n_to_1", "2_to_1"}: + param = action.kwargs.get("param") + if param is not None: + show_readonly_param_dialog(panel, copy.deepcopy(param)) + elif pattern == "1_to_n": + params = action.kwargs.get("params") or [] + if params: + group = gds.DataSetGroup( + [copy.deepcopy(p) for p in params], + title=_("Parameters"), + ) + show_readonly_param_dialog(panel, group) + + with panel.replaying(), panel.output_suppressed(): + session.replay( + panel.mainwindow, + restore_selection=restore_selection, + edit=False, + ) + + +def restore_action_params( + panel: HistoryPanel, item: HistoryAction | HistorySession +) -> None: + """Restore original kwargs from snapshot and recompute in-place.""" + actions: list[HistoryAction] + if isinstance(item, HistorySession): + actions = [a for a in item.actions if a.kind == HistoryAction.KIND_COMPUTE] + else: + actions = [item] + for action in actions: + if not action.has_pending_edits: + continue + action.restore_kwargs() + panel.tree.refresh_action_item(action) + hrec.recompute_action_in_place(panel, action) + hrec.recompute_cascade(panel, action) + panel.update_actions_state() diff --git a/datalab/gui/panel/history/panel.py b/datalab/gui/panel/history/panel.py new file mode 100644 index 000000000..c060e361d --- /dev/null +++ b/datalab/gui/panel/history/panel.py @@ -0,0 +1,902 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +""" +.. History panel (see parent package :mod:`datalab.gui.panel`) +""" + +from __future__ import annotations + +import functools +import logging +from contextlib import contextmanager +from typing import TYPE_CHECKING, Any, Callable, Generator + +from guidata.configtools import get_icon +from guidata.qthelpers import add_actions, create_action +from guidata.widgets.dockable import DockableWidgetMixin +from qtpy import QtCore as QC +from qtpy import QtGui as QG +from qtpy import QtWidgets as QW + +from datalab.config import Conf, _ +from datalab.env import execenv +from datalab.gui import historysession_ops as hsess +from datalab.gui import historytools_ops as htools +from datalab.gui.panel.base import AbstractPanel +from datalab.gui.panel.history import chain as hchain +from datalab.gui.panel.history import interactive_replay as hreplay +from datalab.gui.panel.history import recompute as hrec +from datalab.h5 import history as hio +from datalab.history import HistoryAction, HistorySession +from datalab.widgets.historytree import HistoryTree +from datalab.widgets.workspacestate_widget import WorkspaceStateWidget + +if TYPE_CHECKING: + from datalab.gui.main import DLMainWindow + from datalab.gui.panel.base import BaseDataPanel + from datalab.h5.native import NativeH5Reader, NativeH5Writer + +_logger = logging.getLogger(__name__) + + +class HistoryPanel(AbstractPanel, DockableWidgetMixin): + """History panel""" + + LOCATION = QC.Qt.RightDockWidgetArea + PANEL_STR = _("History panel") + + H5_PREFIX = "DataLab_His" + + SIG_OBJECT_MODIFIED = QC.Signal() + + FILE_FILTERS = f"{_('History files')} (*.dlhist)" + + def __init__(self, parent: DLMainWindow) -> None: + super().__init__(parent) + self.setWindowTitle(self.PANEL_STR) + self.setWindowIcon(get_icon("history.svg")) + self.setOrientation(QC.Qt.Vertical) + + self._record_mode = False + self._edit_mode = False + self._replaying = False + self._output_suppressed = False + self._syncing = False + self._cascade_in_progress = False + self._delete_action: QW.QAction | None = None + self._duplicate_action: QW.QAction | None = None + self._step_prev_action: QW.QAction | None = None + self._step_next_action: QW.QAction | None = None + self._restore_selection_action: QW.QAction | None = None + self._edit_action: QW.QAction | None = None + self._record_action: QW.QAction | None = None + self._menu_actions: list[QW.QAction] = self.create_menu_actions() + + self.mainwindow = parent + self.tree = HistoryTree(self) + self.tree.customContextMenuRequested.connect(self.show_context_menu) + self.tree.itemDoubleClicked.connect(self.replay_restore_actions) + self.tree.itemSelectionChanged.connect(self.sync_panel_selection) + self.tree.itemSelectionChanged.connect(self.update_actions_state) + self.tree.itemSelectionChanged.connect(self.update_state_widget) + + self._state_widget = WorkspaceStateWidget(self) + + toolbar = QW.QToolBar(self) + add_actions(toolbar, self._menu_actions) + widget = QW.QWidget(self) + layout = QW.QVBoxLayout() + layout.addWidget(toolbar) + layout.addWidget(self.tree) + layout.addWidget(self._state_widget) + layout.setContentsMargins(0, 0, 0, 0) + widget.setLayout(layout) + + self.addWidget(widget) + + self._history_sessions: list[HistorySession] = [] + self._session_increment = 0 + self._action_output_uuids: dict[str, list[str]] = {} + self._output_to_action: dict[str, str] = {} + self._cascade_warnings: list[str] = [] + self._broken_actions: set[str] = set() + self._reconnecting = False + self._obj_ids_snapshot: dict[str, set[str]] = {} + for panel in (self.mainwindow.signalpanel, self.mainwindow.imagepanel): + panel.SIG_OBJECT_ADDED.connect(self.refresh_compatibility_items) + panel.SIG_OBJECT_ADDED.connect(self.refresh_obj_ids_snapshot) + panel.SIG_OBJECT_REMOVED.connect(self.refresh_compatibility_items) + panel.SIG_OBJECT_REMOVED.connect( + functools.partial(self.reconnect_chain_after_removal, panel) + ) + panel.SIG_OBJECT_REMOVED.connect(self.prune_output_mapping) + panel.SIG_OBJECT_MODIFIED.connect(self.refresh_compatibility_items) + self.refresh_obj_ids_snapshot() + self.update_actions_state() + self.refresh_compatibility_items() + if not execenv.unattended and Conf.proc.history_auto_record.get(True): + self._record_action.setChecked(True) + self.create_new_session() + + def refresh_obj_ids_snapshot(self) -> None: + """Cache the current object ids of both data panels.""" + self._obj_ids_snapshot = { + self.mainwindow.signalpanel.PANEL_STR_ID: set( + self.mainwindow.signalpanel.objmodel.get_object_ids() + ), + self.mainwindow.imagepanel.PANEL_STR_ID: set( + self.mainwindow.imagepanel.objmodel.get_object_ids() + ), + } + + def update_actions_state(self) -> None: + """Update the enabled state of menu actions depending on history content.""" + has_history = len(self) > 0 + for action in (self._delete_action, self._duplicate_action): + if action is not None: + action.setEnabled(has_history) + if self._step_prev_action is not None: + self._step_prev_action.setEnabled(self.can_step_prev()) + if self._step_next_action is not None: + self._step_next_action.setEnabled(self.can_step_next()) + if self._restore_selection_action is not None: + self._restore_selection_action.setEnabled( + self._edit_mode or self.has_any_pending_edits() + ) + + __update_actions_state = update_actions_state + + @property + def history_sessions(self) -> list[HistorySession]: + """Return mutable history sessions storage.""" + return self._history_sessions + + @history_sessions.setter + def history_sessions(self, sessions: list[HistorySession]) -> None: + """Replace history sessions storage.""" + self._history_sessions = sessions + + @property + def session_increment(self) -> int: + """Return the current session counter.""" + return self._session_increment + + @session_increment.setter + def session_increment(self, value: int) -> None: + """Set the current session counter.""" + self._session_increment = value + + @property + def record_mode_enabled(self) -> bool: + """Return True when record mode is enabled.""" + return self._record_mode + + def has_any_pending_edits(self) -> bool: + """Return True if any action across all sessions has a pending Edit + mode snapshot (i.e. uncommitted edits that Restore can revert).""" + return any( + action.has_pending_edits + for session in self._history_sessions + for action in session.actions + ) + + def update_state_widget(self) -> None: + """Update the workspace state widget from the currently selected action.""" + action = self.current_action() + if action is not None: + self._state_widget.update_from_state(action.state) + else: + self._state_widget.update_from_state(None) + + def create_menu_actions(self) -> list[QW.QAction]: + """Create menu actions for the history panel.""" + edit_action = create_action( + self, + _("Edit mode"), + toggled=self.toggle_edit_mode, + icon=get_icon("edit_mode.svg"), + ) + edit_action.setChecked(self._edit_mode) + self._edit_action = edit_action + record_action = create_action( + self, + _("Record mode"), + toggled=self.toggle_record_mode, + icon=get_icon("record.svg"), + ) + record_action.setChecked(self._record_mode) + self._record_action = record_action + new_session_action = create_action( + self, + _("New session"), + self.create_new_session, + icon=get_icon("libre-gui-add.svg"), + tip=_("Start a new history session"), + ) + open_action = create_action( + self, + _("Open history file..."), + triggered=lambda checked=False: self.open_dlhist_file(), + icon=get_icon("fileopen_h5.svg"), + tip=_("Open history from a standalone .dlhist file"), + ) + save_action = create_action( + self, + _("Save history file..."), + triggered=lambda checked=False: self.save_to_dlhist_file(), + icon=get_icon("filesave_h5.svg"), + tip=_("Save history to a standalone .dlhist file"), + ) + self._delete_action = create_action( + self, + _("Delete"), + self.delete_selected, + icon=get_icon("delete.svg"), + ) + self._duplicate_action = create_action( + self, + _("Duplicate"), + self.duplicate_selected_entries, + icon=get_icon("duplicate.svg"), + tip=_("Duplicate selected history action/session"), + ) + self._step_prev_action = create_action( + self, + _("Previous step"), + triggered=self.step_prev, + icon=get_icon("libre-gui-arrow-left.svg"), + tip=_("Select the previous action in the current session"), + shortcut=QG.QKeySequence("Ctrl+Left"), + ) + self._step_next_action = create_action( + self, + _("Next step"), + triggered=self.step_next, + icon=get_icon("libre-gui-arrow-right.svg"), + tip=_("Select the next action in the current session"), + shortcut=QG.QKeySequence("Ctrl+Right"), + ) + generate_macro_action = create_action( + self, + _("Generate macro"), + self.generate_macro, + icon=get_icon("console.svg"), + tip=_("Generate a Python macro script from history"), + ) + remove_incompatible_action = create_action( + self, + _("Remove incompatible"), + self.remove_incompatible_actions, + icon=get_icon("edit/delete_all.svg"), + tip=_("Remove actions incompatible with the current workspace"), + ) + self._restore_selection_action = create_action( + self, + _("Restore parameters"), + lambda: self.replay_restore_actions(restore_selection=True, replay=False), + icon=get_icon("restore_selection.svg"), + tip=_("Restore original parameters (discard edit-mode changes)"), + ) + return [ + record_action, + new_session_action, + None, + open_action, + save_action, + None, + self._step_prev_action, + self._step_next_action, + None, + create_action( + self, + _("Replay"), + lambda: self.replay_restore_actions(restore_selection=False), + icon=get_icon("replay.svg"), + ), + self._restore_selection_action, + edit_action, + None, + self._duplicate_action, + generate_macro_action, + None, + remove_incompatible_action, + self._delete_action, + ] + + def toggle_edit_mode(self, checked: bool) -> None: + """Toggle edit mode. + + Toggling Edit mode off is a definitive commit: all parameter + changes performed during the session become permanent. + """ + if not checked and self.has_any_pending_edits(): + reply = ( + QW.QMessageBox.Yes + if execenv.unattended + else QW.QMessageBox.question( + self.mainwindow, + _("Commit edit mode changes?"), + _( + "You are about to exit Edit mode.\n\n" + "All parameter changes made during this session will be " + "permanently kept.\n" + "This action cannot be undone — Restore will no longer " + "be available.\n\n" + "Do you want to continue?" + ), + QW.QMessageBox.Yes | QW.QMessageBox.No, + QW.QMessageBox.No, + ) + ) + if reply != QW.QMessageBox.Yes: + if self._edit_action is not None: + self._edit_action.blockSignals(True) + self._edit_action.setChecked(True) + self._edit_action.blockSignals(False) + return + self._edit_mode = checked + if not checked: + for session in self._history_sessions: + for action in session.actions: + action.discard_snapshot() + self.update_actions_state() + + def toggle_record_mode(self, checked: bool) -> None: + """Toggle record mode.""" + self._record_mode = checked + + def is_edit_mode(self) -> bool: + """Return True when the History panel is in edit mode.""" + return self._edit_mode + + @contextmanager + def replaying(self) -> Generator[None, None, None]: + """Context manager suppressing history capture during its scope.""" + previous = self._replaying + self._replaying = True + try: + yield + finally: + self._replaying = previous + + def is_replaying(self) -> bool: + """Return True when an external replay/recompute is in progress.""" + return self._replaying + + @contextmanager + def output_suppressed(self) -> Generator[None, None, None]: + """Context manager suppressing compute outputs during its scope.""" + previous = self._output_suppressed + self._output_suppressed = True + try: + yield + finally: + self._output_suppressed = previous + + def is_output_suppressed(self) -> bool: + """Return True when compute outputs must not be added to panels.""" + return self._output_suppressed + + def show_context_menu(self, pos: QC.QPoint) -> None: + """Show the context menu.""" + self.refresh_compatibility_items() + menu = QW.QMenu() + add_actions(menu, self._menu_actions) + menu.exec_(self.tree.mapToGlobal(pos)) + + def get_action_from_uuid(self, uuid: str) -> HistoryAction: + """Get the action from its UUID.""" + for session in self._history_sessions: + for action in session.actions: + if action.uuid == uuid: + return action + raise ValueError("Action not found") + + # ------------------------------------------------------------------ + # Interactive replay delegations + # ------------------------------------------------------------------ + + def replay_restore_actions( + self, replay: bool = True, restore_selection: bool = True + ) -> None: + """Replay and/or restore selection for the selected actions.""" + return hreplay.replay_restore_actions(self, replay, restore_selection) + + def prompt_edit_action_params(self, action: HistoryAction) -> bool | None: + """Open the parameter dialog for *action* according to its pattern.""" + return hreplay.prompt_edit_action_params(self, action) + + def edit_mode_replay(self, action: HistoryAction) -> None: + """Replay a single action in edit mode.""" + return hreplay.edit_mode_replay(self, action) + + def show_readonly_param_dialog(self, dataset: Any) -> None: + """Show a parameter dialog identical to the edit dialog but read-only.""" + return hreplay.show_readonly_param_dialog(self, dataset) + + def view_only_session_replay( + self, session: HistorySession, restore_selection: bool + ) -> None: + """Replay a session in edit mode with read-only parameter dialogs.""" + return hreplay.view_only_session_replay(self, session, restore_selection) + + def restore_action_params(self, item: HistoryAction | HistorySession) -> None: + """Restore original kwargs from snapshot and recompute in-place.""" + return hreplay.restore_action_params(self, item) + + # ------------------------------------------------------------------ + # Chain delegations + # ------------------------------------------------------------------ + + def find_parent_session(self, action: HistoryAction) -> HistorySession | None: + """Return the session that contains ``action``, or None.""" + return hchain.find_parent_session(self, action) + + def resolve_panel_for_action(self, action: HistoryAction) -> BaseDataPanel | None: + """Return the data panel targeted by ``action``, or ``None``.""" + return hchain.resolve_panel_for_action(self, action) + + def find_output_object_uuid( + self, panel: BaseDataPanel, action: HistoryAction + ) -> str | None: + """Find the UUID of the output object produced by ``action``.""" + return hchain.find_output_object_uuid(self, panel, action) + + def find_action_for_output( + self, output_uuid: str, func_name: str + ) -> HistoryAction | None: + """Find the action that produced ``output_uuid``.""" + return hchain.find_action_for_output(self, output_uuid, func_name) + + def get_session_of(self, action: HistoryAction) -> HistorySession | None: + """Return the session that contains ``action``, or None.""" + return hchain.get_session_of(self, action) + + def action_output_uuid(self, action: HistoryAction) -> str | None: + """Return the UUID of the object produced by ``action``, or ``None``.""" + return hchain.action_output_uuid(self, action) + + _action_output_uuid = action_output_uuid + + def action_consumes_any(self, action: HistoryAction, uuids: set[str]) -> bool: + """Return True if ``action``'s input UUIDs intersect ``uuids``.""" + return hchain.action_consumes_any(action, uuids) + + def collect_downstream_uuids(self, action: HistoryAction) -> set[str]: + """Return the transitive closure of output UUIDs descending from ``action``.""" + return hchain.collect_downstream_uuids(self, action) + + def get_downstream_actions(self, action: HistoryAction) -> list[HistoryAction]: + """Return the actions of the current session that depend on ``action``.""" + return hchain.get_downstream_actions(self, action) + + def resolve_target_outputs( + self, panel: BaseDataPanel, action: HistoryAction + ) -> tuple[list[str], list[str]]: + """Return ``(existing, missing)`` UUIDs registered for ``action``.""" + return hchain.resolve_target_outputs(self, panel, action) + + def existing_input_uuids( + self, panel: BaseDataPanel, action: HistoryAction + ) -> list[str]: + """Return recorded input UUIDs that still exist in ``panel``.""" + return hchain.existing_input_uuids(panel, action) + + def prune_output_mapping(self) -> None: + """Drop entries of :attr:`_output_to_action` whose object no longer exists.""" + return hchain.prune_output_mapping(self) + + def rewrite_action_source( + self, + action: HistoryAction, + pstr: str, + old_uuid: str, + new_uuid: str, + ) -> None: + """Replace ``old_uuid`` with ``new_uuid`` in an action's recorded inputs.""" + return hchain.rewrite_action_source(action, pstr, old_uuid, new_uuid) + + def remove_single_action(self, action: HistoryAction) -> None: + """Remove a single action from its session (splice, not truncate).""" + return hchain.remove_single_action(self, action) + + def reconnect_single_removed( + self, + panel: BaseDataPanel, + x_uuid: str, + warnings: list[str], + roots_to_recompute: list[HistoryAction], + ) -> None: + """Reconnect consumers of a single deleted object ``x_uuid``.""" + return hchain.reconnect_single_removed( + self, panel, x_uuid, warnings, roots_to_recompute + ) + + def reconnect_chain_after_removal(self, panel: BaseDataPanel) -> None: + """Reconnect the processing chain after object(s) were deleted.""" + return hchain.reconnect_chain_after_removal(self, panel) + + # ------------------------------------------------------------------ + # Recompute delegations + # ------------------------------------------------------------------ + + def refresh_action(self, action: HistoryAction) -> None: + """Refresh the tree display for ``action`` after its kwargs were mutated.""" + return hrec.refresh_action(self, action) + + def update_obj_in_place(self, target_obj: Any, new_obj: Any) -> None: + """Copy data + title + metadata from ``new_obj`` onto ``target_obj``.""" + return hrec.update_obj_in_place(target_obj, new_obj) + + def refresh_target(self, panel: BaseDataPanel, output_uuid: str) -> None: + """Refresh tree item + plot for ``output_uuid`` in ``panel``.""" + return hrec.refresh_target(panel, output_uuid) + + def record_missing_outputs(self, action: HistoryAction, missing: list[str]) -> None: + """Log + queue a user-facing warning for deleted output objects.""" + return hrec.record_missing_outputs(self, action, missing) + + def recompute_action_in_place(self, action: HistoryAction) -> None: + """Re-run ``action`` on the existing output object(s) (same UUIDs).""" + return hrec.recompute_action_in_place(self, action) + + def handle_missing_feature(self, action: HistoryAction, exc: Any) -> None: + """Flag ``action`` as broken (missing plugin) and queue a user warning.""" + return hrec.handle_missing_feature(self, action, exc) + + def recompute_1_to_1_in_place(self, action: HistoryAction) -> None: + """Recompute a single 1-to-1 action in place.""" + return hrec.recompute_1_to_1_in_place(self, action) + + def recompute_1_to_n_in_place(self, action: HistoryAction) -> None: + """Recompute a 1-to-n action in place.""" + return hrec.recompute_1_to_n_in_place(self, action) + + def recompute_n_to_1_in_place(self, action: HistoryAction) -> None: + """Recompute an n-to-1 action in place.""" + return hrec.recompute_n_to_1_in_place(self, action) + + def recompute_2_to_1_in_place(self, action: HistoryAction) -> None: + """Recompute a 2-to-1 action in place.""" + return hrec.recompute_2_to_1_in_place(self, action) + + def recompute_1_to_0_in_place(self, action: HistoryAction) -> None: + """Recompute a 1-to-0 analysis on each source object in place.""" + return hrec.recompute_1_to_0_in_place(self, action) + + def recompute_cascade( + self, + root_action: HistoryAction, + descendants: list[HistoryAction] | None = None, + ) -> None: + """Recompute ``root_action``'s descendants in the current session.""" + return hrec.recompute_cascade(self, root_action, descendants) + + def flush_cascade_warnings(self) -> None: + """Show + clear accumulated cascade warnings (no-op when empty).""" + return hrec.flush_cascade_warnings(self) + + # ------------------------------------------------------------------ + # Sync History tree selection → Signal/Image panel + # ------------------------------------------------------------------ + + def sync_panel_selection(self) -> None: + """Sync data panel selection from the currently selected tree item.""" + if self._replaying or self._syncing: + return + item = self.tree.currentItem() + if item is None or not item.isSelected(): + return + if item.parent() is None: + index = self.tree.indexOfTopLevelItem(item) + if index < 0 or index >= len(self._history_sessions): + return + session = self._history_sessions[index] + action = next( + (a for a in session.actions if a.kind == HistoryAction.KIND_COMPUTE), + None, + ) + if action is None: + return + else: + uuid = item.data(0, QC.Qt.UserRole) + try: + action = self.tree.get_action_from_uuid(uuid, self._history_sessions) + except ValueError: + return + + panel = self.resolve_panel_for_action(action) + if panel is None: + return + + target_uuids: list[str] = [] + output_uuid = self.find_output_object_uuid(panel, action) + if output_uuid is not None: + target_uuids = [output_uuid] + else: + target_uuids = self.existing_input_uuids(panel, action) + + if not target_uuids: + return + + self._syncing = True + try: + with QC.QSignalBlocker(panel.objview): + panel.objview.select_objects(target_uuids) + self.mainwindow.set_current_panel(panel) + finally: + self._syncing = False + + # ------------------------------------------------------------------ + # Step-by-step navigation + # ------------------------------------------------------------------ + + def current_action(self) -> HistoryAction | None: + """Return the action currently selected in the tree, or ``None``.""" + item = self.tree.currentItem() + if item is None or item.parent() is None: + return None + uuid = item.data(0, QC.Qt.UserRole) + try: + return self.tree.get_action_from_uuid(uuid, self._history_sessions) + except ValueError: + return None + + def current_session(self) -> HistorySession | None: + """Return the session relevant for step navigation.""" + item = self.tree.currentItem() + if item is not None: + if item.parent() is None: + index = self.tree.indexOfTopLevelItem(item) + if 0 <= index < len(self._history_sessions): + return self._history_sessions[index] + else: + action = self.current_action() + if action is not None: + return self.find_parent_session(action) + if self._history_sessions: + return self._history_sessions[-1] + return None + + def can_step_prev(self) -> bool: + """Return True if a previous action exists in the current session.""" + session = self.current_session() + if session is None or not session.actions: + return False + action = self.current_action() + if action is None or action not in session.actions: + return False + return session.actions.index(action) > 0 + + def can_step_next(self) -> bool: + """Return True if a next action exists in the current session.""" + session = self.current_session() + if session is None or not session.actions: + return False + action = self.current_action() + if action is None or action not in session.actions: + return True + return session.actions.index(action) < len(session.actions) - 1 + + def select_action_in_tree(self, action: HistoryAction) -> None: + """Select ``action`` in the tree (triggers ``sync_panel_selection``).""" + for i in range(self.tree.topLevelItemCount()): + sess_item = self.tree.topLevelItem(i) + for j in range(sess_item.childCount()): + child = sess_item.child(j) + if child.data(0, QC.Qt.UserRole) == action.uuid: + self.tree.clearSelection() + self.tree.setCurrentItem(child) + child.setSelected(True) + return + + def step_prev(self) -> None: + """Select the previous action in the current session.""" + if not self.can_step_prev(): + return + session = self.current_session() + action = self.current_action() + idx = session.actions.index(action) + self.select_action_in_tree(session.actions[idx - 1]) + self.update_actions_state() + + _step_prev = step_prev + + def step_next(self) -> None: + """Select the next action in the current session.""" + if not self.can_step_next(): + return + session = self.current_session() + action = self.current_action() + if action is None or action not in session.actions: + target = session.actions[0] + else: + target = session.actions[session.actions.index(action) + 1] + self.select_action_in_tree(target) + self.update_actions_state() + + _step_next = step_next + + # ------------------------------------------------------------------ + # History tools delegations + # ------------------------------------------------------------------ + + def duplicate_selected_entries(self) -> None: + """Duplicate selected entries.""" + return htools.duplicate_selected_entries(self) + + def generate_macro(self) -> None: + """Generate a Python macro script from history.""" + return htools.generate_macro(self) + + def select_sessions(self, sessions: list[HistorySession]) -> None: + """Select top-level tree items matching ``sessions``.""" + self.tree.clearSelection() + for session in sessions: + index = self._history_sessions.index(session) + item = self.tree.topLevelItem(index) + item.setSelected(True) + self.tree.setCurrentItem(item) + + def delete_selected(self) -> None: + """Delete the currently selected entries.""" + return htools.delete_selected(self) + + def remove_incompatible_actions(self) -> None: + """Remove actions incompatible with the current workspace.""" + return htools.remove_incompatible_actions(self) + + # ------------------------------------------------------------------ + # HDF5 / .dlhist I/O delegations + # ------------------------------------------------------------------ + + def save_to_dlhist_file(self, filename: str | None = None) -> bool: + """Save history to a standalone .dlhist file.""" + return hio.save_to_dlhist_file(self, filename) + + def open_dlhist_file(self, filename: str | None = None) -> bool: + """Open history from a standalone .dlhist file.""" + return hio.open_dlhist_file(self, filename) + + def import_dlhist_into_new_session(self, reader: NativeH5Reader) -> None: + """Import a .dlhist into a new session.""" + return hio.import_dlhist_into_new_session(self, reader) + + def refresh_compatibility_items(self, *args: Any) -> None: + """Refresh compatibility icons in the history tree.""" + return hio.refresh_compatibility_items(self, *args) + + def serialize_to_hdf5(self, writer: NativeH5Writer) -> None: + """Serialize the history to HDF5.""" + return hio.serialize_to_hdf5(self, writer) + + def deserialize_from_hdf5( + self, reader: NativeH5Reader, reset_all: bool = False + ) -> None: + """Deserialize the history from HDF5.""" + return hio.deserialize_from_hdf5(self, reader, reset_all) + + def __len__(self) -> int: + """Return number of objects.""" + return sum(len(session.actions) for session in self._history_sessions) + + def __getitem__(self, nb: int) -> HistoryAction: + """Return object from its number (1 to N).""" + for session in self._history_sessions: + if nb <= len(session.actions): + return session.actions[nb - 1] + nb -= len(session.actions) + raise IndexError("Index out of range") + + def __iter__(self) -> Generator[HistoryAction, None, None]: + """Iterate over objects.""" + for session in self._history_sessions: + yield from session.actions + + # ------------------------------------------------------------------ + # Session operations delegations + # ------------------------------------------------------------------ + + def create_new_session(self) -> None: + """Create a new history session.""" + return hsess.create_new_session(self) + + def start_new_session_after_workspace_reset(self) -> None: + """Start a new history session after a workspace reset.""" + return hsess.start_new_session_after_workspace_reset(self) + + def add_compute_entry( + self, + action_title: str, + panel_str: str, + func_name: str, + pattern: str, + save_state: bool = True, + output_uuids: list[str] | None = None, + plugin_origin: dict[str, Any] | None = None, + **kwargs: Any, + ) -> HistoryAction | None: + """Add a compute entry to the history.""" + return hsess.add_compute_entry( + self, + action_title, + panel_str, + func_name, + pattern, + save_state, + output_uuids, + plugin_origin, + **kwargs, + ) + + def add_compute_entry_from_pp( + self, + action_title: str, + pp: Any, + panel_str: str, + save_state: bool = True, + output_uuids: list[str] | None = None, + plugin_origin: dict[str, Any] | None = None, + **extras: Any, + ) -> HistoryAction | None: + """Add a compute entry built from a :class:`ProcessingParameters`.""" + return hsess.add_compute_entry_from_pp( + self, + action_title, + pp, + panel_str, + save_state, + output_uuids, + plugin_origin, + **extras, + ) + + def register_action_outputs( + self, action: HistoryAction, output_uuids: list[str] + ) -> None: + """Register the output UUIDs produced by ``action``.""" + return hsess.register_action_outputs(self, action, output_uuids) + + def capture_outputs( + self, action: HistoryAction | None + ) -> Generator[None, None, None]: + """Context manager capturing outputs produced by ``action``.""" + return hsess.capture_outputs(self, action) + + def add_ui_entry( + self, + action_title: str, + target: str, + method_name: str, + save_state: bool = True, + **kwargs: Any, + ) -> None: + """Add a UI entry to the history.""" + return hsess.add_ui_entry( + self, action_title, target, method_name, save_state, **kwargs + ) + + def add_entry( + self, + action_title: str, + save_state: bool, + func: Callable, + **kwargs: Any, + ) -> None: + """Add a generic entry to the history.""" + return hsess.add_entry(self, action_title, save_state, func, **kwargs) + + # ------ AbstractPanel interface --------------------------------------------------- + def create_object(self) -> HistoryAction: + """Create and return object.""" + return HistoryAction() + + def add_object(self, obj: HistoryAction) -> None: + """Add an object to the history.""" + return hsess.add_object(self, obj) + + def remove_all_objects(self) -> None: + """Remove all objects.""" + super().remove_all_objects() + self._action_output_uuids.clear() + self._output_to_action.clear() diff --git a/datalab/gui/panel/history/recompute.py b/datalab/gui/panel/history/recompute.py new file mode 100644 index 000000000..308d34e58 --- /dev/null +++ b/datalab/gui/panel/history/recompute.py @@ -0,0 +1,495 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +"""In-place recompute helpers for the History panel cascade.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from coverage.debug import pp +from qtpy import QtWidgets as QW +from sigima.objects import ImageObj, SignalObj + +from datalab.config import _ +from datalab.env import execenv +from datalab.gui.processor.base import ( + FeatureNotFoundError, + ProcessingParameters, + extract_processing_parameters, + insert_processing_parameters, +) +from datalab.history import HistoryAction +from datalab.objectmodel import get_uuid + +if TYPE_CHECKING: + from datalab.gui.panel.base import BaseDataPanel + from datalab.gui.panel.history.panel import HistoryPanel + +_logger = logging.getLogger(__name__) + + +def refresh_action(panel: HistoryPanel, action: HistoryAction) -> None: + """Refresh the tree display for ``action`` after its kwargs were mutated. + + Used by :meth:`ObjectProp.apply_processing_parameters` to update the + Description column when the user edits a ``param`` from the Processing + tab of the Signal/Image panel. + """ + panel.tree.refresh_action_item(action) + + +def update_obj_in_place( + target_obj: SignalObj | ImageObj, + new_obj: SignalObj | ImageObj, +) -> None: + """Copy data + title + metadata from ``new_obj`` onto ``target_obj``. + + Preserves the target's identity (UUID, panel position, references) + while reflecting all user-visible changes produced by a recompute. + """ + target_obj.title = new_obj.title + if isinstance(target_obj, SignalObj): + target_obj.xydata = new_obj.xydata + else: + target_obj.data = new_obj.data + target_obj.invalidate_maskdata_cache() + try: + saved_uuid = target_obj.metadata.get("__uuid") + saved_number = target_obj.metadata.get("__number") + target_obj.metadata.clear() + target_obj.metadata.update(new_obj.metadata) + if saved_uuid is not None: + target_obj.metadata["__uuid"] = saved_uuid + if saved_number is not None: + target_obj.metadata["__number"] = saved_number + except AttributeError: + pass + + +def refresh_target(panel_data: BaseDataPanel, output_uuid: str) -> None: + """Refresh tree item + plot for ``output_uuid`` in ``panel_data``. + + Also updates the Properties panel when the refreshed object is + currently selected, marks the object as freshly processed so the + Processing tab is shown, and emits ``SIG_OBJECT_MODIFIED``. + """ + panel_data.objview.update_item(output_uuid) + panel_data.refresh_plot(output_uuid, update_items=True, force=True) + obj = ( + panel_data.objmodel[output_uuid] + if panel_data.objmodel.has_uuid(output_uuid) + else None + ) + if obj is not None: + if obj is panel_data.objview.get_current_object(): + panel_data.objprop.update_properties_from(obj, force_tab="processing") + else: + panel_data.objprop.mark_as_freshly_processed(obj) + panel_data.SIG_OBJECT_MODIFIED.emit() + + +def record_missing_outputs( + panel: HistoryPanel, action: HistoryAction, missing: list[str] +) -> None: + """Log + queue a user-facing warning for deleted output objects.""" + if not missing: + return + name = action.func_name or action.title or action.uuid + _logger.warning( + "Cascade recompute: %d output(s) missing for action %s (%s).", + len(missing), + action.uuid, + name, + ) + panel._cascade_warnings.append( + _( + "Action %s has been edited but its target output object(s) " + "no longer exist — skipping." + ) + % name + ) + + +def recompute_action_in_place(panel: HistoryPanel, action: HistoryAction) -> None: + """Re-run ``action`` on the existing output object(s) (same UUIDs).""" + if action.kind != HistoryAction.KIND_COMPUTE: + return + method = { + "1_to_1": recompute_1_to_1_in_place, + "1_to_n": recompute_1_to_n_in_place, + "n_to_1": recompute_n_to_1_in_place, + "2_to_1": recompute_2_to_1_in_place, + "1_to_0": recompute_1_to_0_in_place, + }.get(action.pattern or "") + if method is None: + _logger.warning( + "Cascade recompute: unsupported pattern %r for action %s.", + action.pattern, + action.uuid, + ) + panel._cascade_warnings.append( + _("Action %s uses pattern %r which is not recomputable yet.") + % (action.func_name or action.uuid, action.pattern) + ) + return + try: + method(panel, action) + except FeatureNotFoundError as exc: + handle_missing_feature(panel, action, exc) + except (RuntimeError, ValueError, AttributeError, KeyError, TypeError) as exc: + _logger.exception( + "Cascade recompute failed for action %s (%s): %s", + action.uuid, + action.func_name, + exc, + ) + panel._cascade_warnings.append( + _("Recompute failed for action %s: %s") + % (action.func_name or action.uuid, exc) + ) + + +def handle_missing_feature( + panel: HistoryPanel, action: HistoryAction, exc: FeatureNotFoundError +) -> None: + """Flag ``action`` as broken (missing plugin) and queue a user warning.""" + action.is_stale = True + panel._broken_actions.add(action.uuid) + plugin_origin = action.plugin_origin or exc.plugin_origin or {} + directory = (plugin_origin.get("directory") if plugin_origin else None) or "?" + param = action.kwargs.get("param") + paramclass = exc.paramclass_name or ( + type(param).__name__ if param is not None else "—" + ) + func_name = action.func_name or exc.func_name or action.uuid + location = f"{directory}/plugins:{func_name}" + _logger.warning( + "Cascade recompute: plugin missing for action %s (%s) — %s.", + action.uuid, + func_name, + location, + ) + panel._cascade_warnings.append( + _( + "Action %(name)s skipped: plugin '%(loc)s' is missing.\n" + "Required parameter class: %(param)s\n" + "Reinstall the plugin to re-enable this action." + ) + % {"name": func_name, "loc": location, "param": paramclass} + ) + + +def recompute_1_to_1_in_place(panel: HistoryPanel, action: HistoryAction) -> None: + """Recompute a single 1-to-1 action in place.""" + panel_data = panel.resolve_panel_for_action(action) + if panel_data is None: + return + existing, missing = panel.resolve_target_outputs(panel_data, action) + if not existing and not missing: + legacy = panel.find_output_object_uuid(panel_data, action) + if legacy is not None: + existing = [legacy] + record_missing_outputs(panel, action, missing) + if not existing: + return + output_uuid = existing[0] + if not panel_data.objmodel.has_uuid(output_uuid): + return + output_obj = panel_data.objmodel[output_uuid] + pp = extract_processing_parameters(output_obj) + if pp is None or pp.source_uuid is None: + return + if not panel_data.objmodel.has_uuid(pp.source_uuid): + panel._cascade_warnings.append( + _("Action %s: source object was deleted — skipping.") + % (action.func_name or action.uuid) + ) + return + source_obj = panel_data.objmodel[pp.source_uuid] + param = action.kwargs.get("param") + new_obj = panel_data.processor.recompute_1_to_1( + action.func_name, + source_obj, + param, + plugin_origin=action.plugin_origin, + ) + if new_obj is None: + return + update_obj_in_place(output_obj, new_obj) + insert_processing_parameters( + output_obj, + ProcessingParameters( + func_name=pp.func_name, + pattern=pp.pattern, + param=param if param is not None else pp.param, + source_uuid=pp.source_uuid, + ), + ) + panel_data.processor.auto_recompute_analysis(output_obj) + refresh_target(panel_data, output_uuid) + + +def recompute_1_to_n_in_place(panel: HistoryPanel, action: HistoryAction) -> None: + """Recompute a 1-to-n action in place: replace each of the N outputs.""" + panel_data = panel.resolve_panel_for_action(action) + if panel_data is None: + return + existing, missing = panel.resolve_target_outputs(panel_data, action) + record_missing_outputs(panel, action, missing) + if not existing: + return + if not panel_data.objmodel.has_uuid(existing[0]): + return + first_obj = panel_data.objmodel[existing[0]] + pp = extract_processing_parameters(first_obj) + if pp is None or pp.source_uuid is None: + return + if not panel_data.objmodel.has_uuid(pp.source_uuid): + panel._cascade_warnings.append( + _("Action %s: source object was deleted — skipping.") + % (action.func_name or action.uuid) + ) + return + source_obj = panel_data.objmodel[pp.source_uuid] + params = action.kwargs.get("params") or [] + if not params: + return + new_objs = panel_data.processor.recompute_1_to_n( + action.func_name, + source_obj, + params, + plugin_origin=action.plugin_origin, + ) + if not new_objs: + return + n = min(len(existing), len(new_objs)) + for idx in range(n): + out_uuid = existing[idx] + if not panel_data.objmodel.has_uuid(out_uuid): + continue + out_obj = panel_data.objmodel[out_uuid] + new_obj = new_objs[idx] + update_obj_in_place(out_obj, new_obj) + new_param = params[idx] if idx < len(params) else None + insert_processing_parameters( + out_obj, + ProcessingParameters( + func_name=action.func_name, + pattern="1-to-n", + param=new_param, + source_uuid=pp.source_uuid, + ), + ) + panel_data.processor.auto_recompute_analysis(out_obj) + refresh_target(panel_data, out_uuid) + if len(new_objs) != len(existing): + _logger.warning( + "1-to-n cardinality changed for action %s: %d outputs, %d existing.", + action.uuid, + len(new_objs), + len(existing), + ) + + +def recompute_n_to_1_in_place(panel: HistoryPanel, action: HistoryAction) -> None: + """Recompute an n-to-1 action in place.""" + panel_data = panel.resolve_panel_for_action(action) + if panel_data is None: + return + existing, missing = panel.resolve_target_outputs(panel_data, action) + record_missing_outputs(panel, action, missing) + if not existing: + return + output_uuid = existing[0] + if not panel_data.objmodel.has_uuid(output_uuid): + return + output_obj = panel_data.objmodel[output_uuid] + pp = extract_processing_parameters(output_obj) + source_uuids: list[str] = [] + if pp is not None and pp.source_uuids: + source_uuids = list(pp.source_uuids) + else: + source_uuids = list(action.state.selection.get(panel_data.PANEL_STR_ID, [])) + src_objs: list[SignalObj | ImageObj] = [] + for uuid in source_uuids: + if panel_data.objmodel.has_uuid(uuid): + src_objs.append(panel_data.objmodel[uuid]) + if not src_objs: + panel._cascade_warnings.append( + _("Action %s: all source objects were deleted — skipping.") + % (action.func_name or action.uuid) + ) + return + param = action.kwargs.get("param") + new_obj = panel_data.processor.recompute_n_to_1( + action.func_name, + src_objs, + param, + plugin_origin=action.plugin_origin, + ) + if new_obj is None: + return + update_obj_in_place(output_obj, new_obj) + insert_processing_parameters( + output_obj, + ProcessingParameters( + func_name=action.func_name, + pattern="n-to-1", + param=param, + source_uuids=[get_uuid(o) for o in src_objs], + ), + ) + panel_data.processor.auto_recompute_analysis(output_obj) + refresh_target(panel_data, output_uuid) + + +def recompute_2_to_1_in_place(panel: HistoryPanel, action: HistoryAction) -> None: + """Recompute a 2-to-1 action in place (single or pairwise).""" + panel_data = panel.resolve_panel_for_action(action) + if panel_data is None: + return + existing, missing = panel.resolve_target_outputs(panel_data, action) + record_missing_outputs(panel, action, missing) + if not existing: + return + param = action.kwargs.get("param") + obj2_uuids = action.kwargs.get("obj2_uuids") or [] + if isinstance(obj2_uuids, str): + obj2_uuids = [obj2_uuids] + pairwise = bool(action.kwargs.get("pairwise")) + recorded_inputs = list(action.state.selection.get(panel_data.PANEL_STR_ID, [])) + for idx, out_uuid in enumerate(existing): + if not panel_data.objmodel.has_uuid(out_uuid): + continue + output_obj = panel_data.objmodel[out_uuid] + pp = extract_processing_parameters(output_obj) + src_uuids = ( + list(pp.source_uuids) + if pp is not None and pp.source_uuids + else ( + recorded_inputs[idx : idx + 1] + obj2_uuids[idx : idx + 1] + if pairwise + else recorded_inputs[idx : idx + 1] + obj2_uuids[:1] + ) + ) + if len(src_uuids) < 2: + panel._cascade_warnings.append( + _("Action %s: missing source(s) for output #%d — skipping.") + % (action.func_name or action.uuid, idx + 1) + ) + continue + if not ( + panel_data.objmodel.has_uuid(src_uuids[0]) + and panel_data.objmodel.has_uuid(src_uuids[1]) + ): + panel._cascade_warnings.append( + _("Action %s: source object(s) were deleted — skipping.") + % (action.func_name or action.uuid) + ) + continue + obj1 = panel_data.objmodel[src_uuids[0]] + obj2 = panel_data.objmodel[src_uuids[1]] + new_obj = panel_data.processor.recompute_2_to_1( + action.func_name, + obj1, + obj2, + param, + plugin_origin=action.plugin_origin, + ) + if new_obj is None: + continue + update_obj_in_place(output_obj, new_obj) + insert_processing_parameters( + output_obj, + ProcessingParameters( + func_name=action.func_name, + pattern="2-to-1", + param=param, + source_uuids=[get_uuid(obj1), get_uuid(obj2)], + ), + ) + panel_data.processor.auto_recompute_analysis(output_obj) + refresh_target(panel_data, out_uuid) + + +def recompute_1_to_0_in_place(panel: HistoryPanel, action: HistoryAction) -> None: + """Recompute a 1-to-0 analysis on each source object in place.""" + panel_data = panel.resolve_panel_for_action(action) + if panel_data is None: + return + sources = list(action.state.selection.get(panel_data.PANEL_STR_ID, [])) + if not sources: + return + param = action.kwargs.get("param") + missing: list[str] = [] + for uuid in sources: + if not panel_data.objmodel.has_uuid(uuid): + missing.append(uuid) + continue + src_obj = panel_data.objmodel[uuid] + panel_data.processor.recompute_1_to_0( + action.func_name, + src_obj, + param, + plugin_origin=action.plugin_origin, + ) + refresh_target(panel_data, uuid) + if missing: + panel._cascade_warnings.append( + _("Action %s: %d analysed object(s) were deleted — skipping.") + % (action.func_name or action.uuid, len(missing)) + ) + + +def recompute_cascade( + panel: HistoryPanel, + root_action: HistoryAction, + descendants: list[HistoryAction] | None = None, +) -> None: + """Recompute ``root_action``'s descendants in the current session in place.""" + if descendants is None: + descendants = panel.get_downstream_actions(root_action) + if root_action.is_stale: + descendants = [root_action] + descendants + if getattr(panel, "_cascade_in_progress", False): + flush_cascade_warnings(panel) + return + if not descendants: + flush_cascade_warnings(panel) + return + panel._broken_actions.clear() + panel._cascade_in_progress = True + try: + for action in descendants: + action.is_stale = True + panel.tree.refresh_action_item(action) + QW.QApplication.processEvents() + for action in descendants: + try: + recompute_action_in_place(panel, action) + finally: + if action.uuid not in panel._broken_actions: + action.is_stale = False + panel.tree.refresh_action_item(action) + QW.QApplication.processEvents() + finally: + for action in descendants: + if action.is_stale and action.uuid not in panel._broken_actions: + action.is_stale = False + panel.tree.refresh_action_item(action) + panel._cascade_in_progress = False + flush_cascade_warnings(panel) + + +def flush_cascade_warnings(panel: HistoryPanel) -> None: + """Show + clear accumulated cascade warnings (no-op when empty).""" + if panel._cascade_warnings and not execenv.unattended: + QW.QMessageBox.warning( + panel.mainwindow, + _("Cascade recompute"), + _("Some downstream actions could not be recomputed:") + + "\n\n• " + + "\n• ".join(panel._cascade_warnings), + ) + panel._cascade_warnings = [] diff --git a/datalab/h5/history.py b/datalab/h5/history.py new file mode 100644 index 000000000..55060006c --- /dev/null +++ b/datalab/h5/history.py @@ -0,0 +1,263 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +"""History panel HDF5 import/export and persistence helpers.""" + +from __future__ import annotations + +import os.path as osp +from typing import TYPE_CHECKING, Any +from uuid import uuid4 + +from qtpy.compat import getopenfilename, getsavefilename + +from datalab.config import Conf, _ +from datalab.gui.processor.base import ( + PROCESSING_PARAMETERS_OPTION, + ProcessingParameters, +) +from datalab.h5.native import NativeH5Reader, NativeH5Writer +from datalab.history import HistorySession +from datalab.objectmodel import get_uuid +from datalab.utils.qthelpers import qt_try_loadsave_file, save_restore_stds + +if TYPE_CHECKING: + from datalab.gui.panel.history import HistoryPanel + + +def save_to_dlhist_file(panel: HistoryPanel, filename: str | None = None) -> bool: + """Save the History Panel content to a standalone ``.dlhist`` file. + + Args: + filename: History filename. If None, a file dialog is opened. + + Returns: + True if the history was saved, False if the operation was canceled. + """ + if filename is None: + basedir = Conf.main.base_dir.get() + with save_restore_stds(): + filename, _filt = getsavefilename( + panel, _("Save history file"), basedir, panel.FILE_FILTERS + ) + if not filename: + return False + if osp.splitext(filename)[1] == "": + filename += ".dlhist" + with qt_try_loadsave_file(panel.parentWidget(), filename, "save"): + Conf.main.base_dir.set(filename) + with NativeH5Writer(filename) as writer: + # Make the .dlhist file panel-contained: store the signal and + # image panel objects (all of them) alongside the history, so + # that reopening restores both the data objects and the history + # that references them. Each section is read back by its own + # H5_PREFIX key, so the write order is not significant. + panel.mainwindow.signalpanel.serialize_to_hdf5(writer) + panel.mainwindow.imagepanel.serialize_to_hdf5(writer) + panel.serialize_to_hdf5(writer) + return True + + +def open_dlhist_file(panel: HistoryPanel, filename: str | None = None) -> bool: + """Open a standalone ``.dlhist`` file into the History Panel. + + Args: + filename: History filename. If None, a file dialog is opened. + + Returns: + True if the history was loaded, False if the operation was canceled. + """ + if filename is None: + basedir = Conf.main.base_dir.get() + with save_restore_stds(): + filename, _filt = getopenfilename( + panel, _("Open history file"), basedir, panel.FILE_FILTERS + ) + if not filename: + return False + with qt_try_loadsave_file(panel.parentWidget(), filename, "load"): + Conf.main.base_dir.set(filename) + with NativeH5Reader(filename) as reader: + # A panel-contained .dlhist file stores the signal and image + # panel objects in addition to the history sessions. The way + # they are restored depends on whether the workspace is already + # in use (data objects OR history): a pristine workspace is + # loaded directly while preserving UUIDs, otherwise the file + # is imported as new groups/sessions. + workspace_in_use = ( + panel.mainwindow.signalpanel.objmodel.get_object_ids() + or panel.mainwindow.imagepanel.objmodel.get_object_ids() + or bool(panel.history_sessions) + ) + if workspace_in_use: + # Workspace not empty: import the objects into new groups + # with fresh UUIDs and append the history as new sessions + # whose references are remapped to the imported objects. + panel.import_dlhist_into_new_session(reader) + else: + # Workspace empty: load directly, preserving original UUIDs + # (reset_all=True) so that history references stay valid. + panel.mainwindow.signalpanel.deserialize_from_hdf5( + reader, reset_all=True + ) + panel.mainwindow.imagepanel.deserialize_from_hdf5( + reader, reset_all=True + ) + panel.deserialize_from_hdf5(reader) + return True + + +def import_dlhist_into_new_session(panel: HistoryPanel, reader: NativeH5Reader) -> None: + """Import a ``.dlhist`` file into new groups and new history sessions. + + Used when the workspace already contains objects: the file's signal and + image objects are imported into fresh groups with regenerated UUIDs, and + the history sessions are appended as new independent sessions whose action + references are remapped to the freshly imported objects. + + Args: + reader: HDF5 reader positioned on a ``.dlhist`` file. + """ + panel_map = { + "signal": panel.mainwindow.signalpanel, + "image": panel.mainwindow.imagepanel, + } + uuid_remap: dict[str, dict[str, str]] = {} + imported_by_pstr: dict[str, list] = {} + # 1. Import objects from each panel (each panel is read by its own + # H5_PREFIX key). Read each object preserving its original UUID to + # capture the old->new mapping, then assign a fresh UUID so that the + # imported objects keep an independent identity. + for pstr, data_panel in panel_map.items(): + uuid_remap[pstr] = {} + imported: list = [] + imported_by_pstr[pstr] = imported + if data_panel.H5_PREFIX not in reader.h5: + continue + with reader.group(data_panel.H5_PREFIX): + for name in reader.h5.get(data_panel.H5_PREFIX, []): + with reader.group(name): + group = data_panel.add_group("") + with reader.group("title"): + group.title = reader.read_str() + for obj_name in reader.h5.get(f"{data_panel.H5_PREFIX}/{name}", []): + obj = data_panel.deserialize_object_from_hdf5( + reader, obj_name, reset_all=True + ) + old_uuid = get_uuid(obj) + new_uuid = str(uuid4()) + # SignalObj/ImageObj store UUID via metadata option + try: + obj.set_metadata_option("uuid", new_uuid) + except AttributeError: + obj.uuid = new_uuid + uuid_remap[pstr][old_uuid] = new_uuid + data_panel.add_object(obj, get_uuid(group), set_current=False) + imported.append(obj) + data_panel.selection_changed() + # 2. Remap source UUIDs in imported objects' processing_parameters so + # that reprocessing in the Processing tab uses the imported sources, + # not the originals (same logic as duplicate_selected_entries). + for pstr, objs in imported_by_pstr.items(): + pmap = uuid_remap.get(pstr, {}) + if not pmap: + continue + for obj in objs: + try: + pp_dict = obj.get_metadata_option(PROCESSING_PARAMETERS_OPTION) + except (AttributeError, ValueError): + continue + if not pp_dict: + continue + try: + pp = ProcessingParameters.from_dict(pp_dict) + except (TypeError, ValueError, AttributeError): + continue + changed = False + if pp.source_uuid is not None and pp.source_uuid in pmap: + pp.source_uuid = pmap[pp.source_uuid] + changed = True + if pp.source_uuids is not None: + new_src = [pmap.get(u, u) for u in pp.source_uuids] + if new_src != pp.source_uuids: + pp.source_uuids = new_src + changed = True + if changed: + try: + obj.set_metadata_option(PROCESSING_PARAMETERS_OPTION, pp.to_dict()) + except (AttributeError, ValueError): + pass + # 3. Import history sessions as new independent sessions whose captured + # UUIDs are remapped to the imported objects. + if panel.H5_PREFIX not in reader.h5: + return + sessions = reader.read_object_list(panel.H5_PREFIX, HistorySession) or [] + imported_suffix = _("Imported") + new_sessions: list[HistorySession] = [] + for session in sessions: + panel.session_increment += 1 + title = f"{session.title} {imported_suffix}" + new_session = session.copy_with_uuid_remap(title=title, uuid_remap=uuid_remap) + new_session.number = panel.session_increment + new_sessions.append(new_session) + # Register output mappings for imported actions so that + # _resolve_target_outputs / get_downstream_actions work. + for action in new_session.actions: + if action.output_uuids: + panel._action_output_uuids[action.uuid] = list(action.output_uuids) + for out_uuid in action.output_uuids: + panel._output_to_action[out_uuid] = action.uuid + panel.history_sessions.extend(new_sessions) + panel.tree.populate_tree(panel.history_sessions) + panel.refresh_compatibility_items() + panel.update_actions_state() + + +def refresh_compatibility_items(panel: HistoryPanel, *args: Any) -> None: + """Refresh action item compatibility markers in the tree.""" + del args + panel.tree.update_compatibility_states(panel.history_sessions, panel.mainwindow) + + +def serialize_to_hdf5(panel: HistoryPanel, writer: NativeH5Writer) -> None: + """Serialize whole panel to a HDF5 file + + Args: + writer: HDF5 writer + """ + writer.write_object_list(panel.history_sessions, panel.H5_PREFIX) + + +def deserialize_from_hdf5( + panel: HistoryPanel, reader: NativeH5Reader, reset_all: bool = False +) -> None: + """Deserialize whole panel from a HDF5 file + + Args: + reader: HDF5 reader + reset_all: Unused (kept for compatibility with panel API) + """ + if panel.H5_PREFIX not in reader.h5: + panel.history_sessions = [] + panel.session_increment = 0 + panel.tree.populate_tree(panel.history_sessions) + panel.update_actions_state() + return + panel.history_sessions = ( + reader.read_object_list(panel.H5_PREFIX, HistorySession) or [] + ) + if panel.history_sessions: + panel.session_increment = panel.history_sessions[-1].number + # Rebuild the bijective mapping from the loaded actions. Legacy + # (v1) actions have empty ``output_uuids`` and contribute nothing + # to the index — the heuristic fallback handles them. + panel._action_output_uuids = {} + panel._output_to_action = {} + for session in panel.history_sessions: + for action in session.actions: + if action.output_uuids: + panel._action_output_uuids[action.uuid] = list(action.output_uuids) + for out_uuid in action.output_uuids: + panel._output_to_action[out_uuid] = action.uuid + panel.tree.populate_tree(panel.history_sessions) + panel.refresh_compatibility_items() + panel.update_actions_state() diff --git a/datalab/history/__init__.py b/datalab/history/__init__.py new file mode 100644 index 000000000..2ecb73c20 --- /dev/null +++ b/datalab/history/__init__.py @@ -0,0 +1,23 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +"""DataLab history model package (pure data model, no Qt widgets).""" + +from datalab.history.action import HistoryAction +from datalab.history.core import ( + HISTORY_ACTION_SCHEMA_VERSION, + HISTORY_SCHEMA_VERSION, + add_to_history, + get_datetime_str, +) +from datalab.history.session import HistorySession +from datalab.history.workspace_state import WorkspaceState + +__all__ = [ + "HISTORY_ACTION_SCHEMA_VERSION", + "HISTORY_SCHEMA_VERSION", + "HistoryAction", + "HistorySession", + "WorkspaceState", + "add_to_history", + "get_datetime_str", +] diff --git a/datalab/history/action.py b/datalab/history/action.py new file mode 100644 index 000000000..4d1907976 --- /dev/null +++ b/datalab/history/action.py @@ -0,0 +1,781 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +"""HistoryAction model: serialisable description of one recorded operation.""" + +from __future__ import annotations + +import html +import inspect +import json +import logging +import os +from typing import TYPE_CHECKING, Any, Callable, Generator +from uuid import uuid4 + +import sigima.proc.image +import sigima.proc.signal +from guidata.dataset.datatypes import DataSet +from sigima.objects import ( # noqa: F401 (kept for downstream consumers) + ImageObj, + SignalObj, +) + +from datalab.config import _ +from datalab.gui import ObjItf +from datalab.history.core import ( + HISTORY_ACTION_SCHEMA_VERSION, + HISTORY_SCHEMA_VERSION, + _copy_history_value, + _decode_kwargs, + _encode_kwargs, + get_datetime_str, +) +from datalab.history.workspace_state import WorkspaceState +from datalab.objectmodel import get_uuid + +if TYPE_CHECKING: + from datalab.gui.main import DLMainWindow + from datalab.h5.native import NativeH5Reader, NativeH5Writer + +_logger = logging.getLogger(__name__) + + +class HistoryAction(ObjItf): + """Object representing an action in the history panel. + + An action is a serialisable description of either a *compute* call (resolved + via the panel processor's feature registry) or a *UI* call (resolved as a + method on a known target -- ``mainwindow``/``signalpanel``/``imagepanel``). + + No Python ``Callable`` is ever pickled: a compute action is identified by + ``(panel_str, func_name, pattern)`` and a UI action by ``(target, + method_name)``. ``DataSet`` payloads inside ``kwargs`` are serialised with + :func:`guidata.dataset.conv.dataset_to_json`. + """ + + KIND_COMPUTE = "compute" + KIND_UI = "ui" + + FUNC_EDIT_MODE = "edit" # Name of the function parameter to enable edit mode + # Methods that create new data objects. During non-persistent (output-suppressed) + # replay, these UI actions are skipped so the panel object count stays stable. + UI_CREATION_METHODS: frozenset[str] = frozenset({"new_object"}) + + def __init__( + self, + title: str = "", + kind: str = KIND_UI, + # --- compute-only -------------------------------------------------- + panel_str: str | None = None, + func_name: str | None = None, + pattern: str | None = None, + # --- ui-only ------------------------------------------------------- + target: str | None = None, + method_name: str | None = None, + # --- common -------------------------------------------------------- + kwargs: dict[str, Any] | None = None, + state: WorkspaceState | None = None, + plugin_origin: dict[str, Any] | None = None, + ) -> None: + super().__init__() + self.__title = title or "" + self.kind = kind + # Compute kind: + self.panel_str = panel_str + self.func_name = func_name + self.pattern = pattern + # UI kind: + self.target = target + self.method_name = method_name + # Common: + self.kwargs: dict[str, Any] = ( + {} if kwargs is None else {k: v for k, v in kwargs.items() if v is not None} + ) + self.state = WorkspaceState() if state is None else state + self.dtstr: str = get_datetime_str() + self.uuid: str = str(uuid4()) + self.schema_version: int = HISTORY_ACTION_SCHEMA_VERSION + # UUIDs of the data objects produced by this action (bijective mapping + # maintained by :class:`HistoryPanel`). Populated post-compute via + # :meth:`HistoryPanel.register_action_outputs`. Empty for ``1_to_0`` + # patterns, for UI actions, and for sessions loaded without output info + # loaded from disk (the heuristic fallback then takes over). + self.output_uuids: list[str] = [] + # Plugin origin descriptor for compute actions (None for built-in + # Sigima/DataLab features). Populated at registration time by + # :meth:`BaseProcessor.add_feature` and propagated through + # ``add_compute_entry_from_pp``. See + # :func:`datalab.gui.processor.base._detect_plugin_origin` for shape. + # Persisted as a JSON string in HDF5. + self.plugin_origin: dict[str, Any] | None = plugin_origin + # Transient flag (NOT serialized): set during a cascade recompute to + # display a "stale" visual marker in the tree. Cleared once the + # action has been recomputed. + self.is_stale: bool = False + # Snapshot of original kwargs before edit-mode modification. + # Set lazily when the first edit-mode change touches this action. + # Persisted to HDF5 so that the Restore + # action still works after a save/reload cycle while Edit mode is + # active. Cleared by ``discard_snapshot`` (definitive commit when + # toggling Edit mode off) or ``restore_kwargs`` (Restore button). + self._saved_kwargs: dict[str, Any] | None = None + + def snapshot_kwargs(self) -> None: + """Save a copy of the current kwargs as the pre-edit baseline. + + No-op if a snapshot already exists (preserves the original baseline + across multiple edit-mode replays). + """ + if self._saved_kwargs is None: + self._saved_kwargs = { + key: _copy_history_value(value) for key, value in self.kwargs.items() + } + + def restore_kwargs(self) -> None: + """Restore kwargs from the saved snapshot and clear the snapshot.""" + if self._saved_kwargs is not None: + self.kwargs = self._saved_kwargs + self._saved_kwargs = None + + def discard_snapshot(self) -> None: + """Discard the saved snapshot (accept current kwargs as definitive).""" + self._saved_kwargs = None + + @property + def has_pending_edits(self) -> bool: + """Return True if this action has unsaved edit-mode changes.""" + return self._saved_kwargs is not None + + def regenerate_uuid(self): + """Regenerate UUID after loading from a file (no-op: per-action UUID).""" + + def copy(self, title_suffix: str | None = None) -> HistoryAction: + """Return an independent copy of this history action.""" + state = self.state.copy() + title = self.title + if title_suffix: + title = f"{title} {title_suffix}" + new_action = HistoryAction( + title=title, + kind=self.kind, + panel_str=self.panel_str, + func_name=self.func_name, + pattern=self.pattern, + target=self.target, + method_name=self.method_name, + kwargs={ + key: _copy_history_value(value) for key, value in self.kwargs.items() + }, + state=state, + ) + new_action.output_uuids = list(self.output_uuids) + # Note: _saved_kwargs is intentionally NOT propagated to the copy. + # Copying an action acts as an implicit commit (no pending edits). + return new_action + + def copy_with_uuid_remap( + self, uuid_remap: dict[str, dict[str, str]] + ) -> HistoryAction: + """Return a copy of this action with all captured UUIDs rewritten. + + Args: + uuid_remap: Per-panel mapping ``{panel_str: {old_uuid: new_uuid}}`` + used to translate captured UUIDs to the cloned objects created by + the Duplicate operation. + + Returns: + A new independent :class:`HistoryAction` with remapped UUIDs. + """ + new_action = self.copy() + # Rewrite state.selection + for pstr, uuids in new_action.state.selection.items(): + pmap = uuid_remap.get(pstr, {}) + new_action.state.selection[pstr] = [pmap.get(u, u) for u in uuids] + # Rewrite state.object_metadata keys + for pstr, metadata in new_action.state.object_metadata.items(): + pmap = uuid_remap.get(pstr, {}) + new_action.state.object_metadata[pstr] = { + pmap.get(uuid, uuid): val for uuid, val in metadata.items() + } + # Rewrite obj2_uuids in kwargs + obj2 = new_action.kwargs.get("obj2_uuids") + if obj2: + if isinstance(obj2, str): + obj2 = [obj2] + pstr = new_action.panel_str or "" + pmap = uuid_remap.get(pstr, {}) + rewritten = [pmap.get(u, u) for u in obj2] + new_action.kwargs["obj2_uuids"] = ( + rewritten[0] if len(rewritten) == 1 else rewritten + ) + # Rewrite output_uuids — they reference the target panel. + if new_action.output_uuids: + pstr = new_action.panel_str or "" + pmap = uuid_remap.get(pstr, {}) + new_action.output_uuids = [pmap.get(u, u) for u in new_action.output_uuids] + return new_action + + @property + def title(self) -> str: + """Return object title""" + return self.__title + + # ------------------------------------------------------------------ + # Description rendering (used by the tree view) + # ------------------------------------------------------------------ + + def __iter_param_kwargs(self) -> Generator[Any, None, None]: + """Yield kwargs values whose name ends with ``param`` (typically DataSets).""" + for kwname, value in self.kwargs.items(): + if kwname.endswith("param") and value is not None: + yield value + + @property + def description(self) -> str: + """Return object description (string representing function parameters)""" + desc = "" + no_parameters = True + for param in self.__iter_param_kwargs(): + if desc: + desc += os.linesep + desc += str(param) + no_parameters = False + if desc or no_parameters: + if desc: + return desc + # Fall back to a textual hint of the resolved callable + return self.__fallback_doc() + + def __fallback_doc(self) -> str: + """Return a single-line docstring for the underlying call, if available.""" + try: + func = self._resolve_callable() + except ( + ImportError, + ModuleNotFoundError, + AttributeError, + TypeError, + ValueError, + ): + return "" + doc = getattr(func, "__doc__", None) or "" + return doc.splitlines()[0] if doc else "" + + @property + def description_summary(self) -> str: + """Return a short, single-line summary of the description (collapsed view). + + For DataSet parameters, uses the dataset title followed by a compact + representation of its public fields ("name=value, ..."). Falls back to + the first non-empty line of the full description when no DataSet is + present. + """ + summaries: list[str] = [] + for param in self.__iter_param_kwargs(): + if isinstance(param, DataSet): + title = param.get_title() or "" + # Collect "name=value" for each non-private item of the DataSet. + pairs: list[str] = [] + for item in param.get_items(): + name = item.get_name() + if name.startswith("_"): + continue + try: + value = item.get_value(param) + except (AttributeError, KeyError, TypeError, ValueError): + continue + # Format floats compactly, leave other reprs as-is + if isinstance(value, float): + value_str = f"{value:g}" + else: + value_str = str(value) + pairs.append(f"{name}={value_str}") + if pairs: + summaries.append( + f"{title}: {', '.join(pairs)}" if title else ", ".join(pairs) + ) + elif title: + summaries.append(title) + if summaries: + return " | ".join(summaries) + for line in self.description.splitlines(): + stripped = line.strip() + if stripped: + return stripped + return "" + + @property + def description_html(self) -> str: + """Return rich-text (HTML) description used for the expanded view.""" + # Normal path + parts: list[str] = [] + no_parameters = True + for param in self.__iter_param_kwargs(): + no_parameters = False + if isinstance(param, DataSet): + parts.append(param.to_html()) + else: + parts.append(html.escape(str(param)).replace("\n", "
")) + if parts: + return "
".join(parts) + if no_parameters: + text = self.description + if not text: + return "" + return html.escape(text).replace("\n", "
") + return "" + + def to_macro_code( + self, + step_index: int, + input_var: str, + imports: set[str], + obj2_var: str | None = None, + ) -> tuple[list[str], str | None]: + """Return Python source lines for this action as a standalone sigima call. + + Args: + step_index: Step number for variable naming. + input_var: Name of the input variable from the previous step. + imports: Mutable set of import statements accumulated by the caller. + obj2_var: Resolved variable name for the second operand (2-to-1 + pattern). When ``None``, the second operand is left as a + placeholder. + + Returns: + Tuple of (code_lines, output_var_name). ``output_var_name`` is + ``None`` for UI-kind actions (no data output). + """ + if self.kind != self.KIND_COMPUTE: + return [f"# (UI) {self.title} [skipped]"], None + + lines: list[str] = [] + output_var = f"result_{step_index}" + + # Determine the sigima module alias + if self.panel_str == "signal": + mod_alias = "sips" + imports.add("import sigima.proc.signal as sips") + elif self.panel_str == "image": + mod_alias = "sipi" + imports.add("import sigima.proc.image as sipi") + else: + lines.append(f"# {self.title} [unknown panel: {self.panel_str}]") + return lines, None + + lines.append(f"# Step {step_index}: {self.title}") + + param = self.kwargs.get("param") + param_var: str | None = None + + if param is not None and isinstance(param, DataSet): + param_var = f"param_{step_index}" + param_class = type(param).__qualname__ + param_module = type(param).__module__ + imports.add(f"from {param_module} import {param_class}") + lines.append(f"{param_var} = {param_class}()") + # Reconstruct each attribute + for item in param._items: # noqa: SLF001 + attr_name = item._name # noqa: SLF001 + value = getattr(param, attr_name, None) + if value is not None: + lines.append(f"{param_var}.{attr_name} = {value!r}") + + # Build the function call + func_call = f"{mod_alias}.{self.func_name}" + if self.pattern in ("1_to_1", "1_to_0"): + if param_var: + lines.append(f"{output_var} = {func_call}({input_var}, {param_var})") + else: + lines.append(f"{output_var} = {func_call}({input_var})") + elif self.pattern == "n_to_1": + if param_var: + lines.append(f"{output_var} = {func_call}([{input_var}], {param_var})") + else: + lines.append(f"{output_var} = {func_call}([{input_var}])") + elif self.pattern == "2_to_1": + second = obj2_var or "... # TODO: provide second operand" + if param_var: + lines.append( + f"{output_var} = {func_call}({input_var}, {second}, {param_var})" + ) + else: + lines.append(f"{output_var} = {func_call}({input_var}, {second})") + elif self.pattern == "1_to_n": + lines.append(f"{output_var} = {func_call}({input_var})") + else: + lines.append(f"# Unknown pattern {self.pattern!r}") + return lines, None + + return lines, output_var + + # ------------------------------------------------------------------ + # Workspace-state delegation + # ------------------------------------------------------------------ + + def is_current_state_compatible( + self, mainwindow: DLMainWindow, restore_selection: bool + ) -> bool: + """Check if the current workspace state is compatible with the saved state.""" + return self.state.is_current_state_compatible(mainwindow, restore_selection) + + def restore(self, mainwindow: DLMainWindow) -> None: + """Restore the associated workspace state.""" + self.state.restore(mainwindow) + + # ------------------------------------------------------------------ + # Replay + # ------------------------------------------------------------------ + + def _resolve_target(self, mainwindow: DLMainWindow) -> Any: + """Resolve the target object (UI kind) from the mainwindow.""" + attr = self.target or "mainwindow" + if attr == "mainwindow": + return mainwindow + return getattr(mainwindow, attr) + + def _resolve_panel(self, mainwindow: DLMainWindow): + """Resolve the data panel for a compute action.""" + if self.panel_str == "signal": + return mainwindow.signalpanel + if self.panel_str == "image": + return mainwindow.imagepanel + raise ValueError( + f"Unknown panel_str {self.panel_str!r} for compute history action" + ) + + def _resolve_callable(self) -> Callable | None: + """Best-effort lookup of the underlying callable, for description only.""" + if self.kind == self.KIND_COMPUTE and self.func_name: + for module in (sigima.proc.signal, sigima.proc.image): + func = getattr(module, self.func_name, None) + if callable(func): + return func + return None + + def _resolve_obj_by_uuid(self, mainwindow: DLMainWindow, uuid: str) -> Any | None: + """Look up an object by UUID across both data panels.""" + for panel in (mainwindow.signalpanel, mainwindow.imagepanel): + try: + return panel.objmodel[uuid] + except KeyError: + continue + return None + + def replay( + self, + mainwindow: DLMainWindow, + restore_selection: bool, + edit: bool, + uuid_remap: dict[str, dict[str, str]] | None = None, + ) -> None: + """Replay the action. + + Args: + mainwindow: DataLab's main window + restore_selection: True to restore the workspace selection before replaying + a UI-kind action. Ignored for compute-kind actions: their semantics + depends on which objects are selected (e.g. ``n_to_1`` aggregators + such as ``average`` require their captured multi-object selection), + so the captured selection is always restored before running the + computation. + edit: if True, always open the dialog boxes to edit parameters; if False, + use the parameters captured when the action was recorded + uuid_remap: optional per-panel mapping ``{panel_str: {old_uuid: new_uuid}}`` + used during full-session replay to translate captured UUIDs to the + freshly-created ones. Defaults to an empty (identity) mapping. + """ + if uuid_remap is None: + uuid_remap = {} + # Suppress history capture during replay to avoid recording + # synthetic entries when the processor re-executes features. + # The context manager is reentrant, so nesting with + # HistoryPanel.replay_restore_actions() is safe. + hpanel = getattr(mainwindow, "historypanel", None) + if hpanel is not None: + ctx = hpanel.replaying() + else: + from contextlib import nullcontext + + ctx = nullcontext() + with ctx: + self._replay_inner(mainwindow, restore_selection, edit, uuid_remap) + + def _replay_inner( + self, + mainwindow: DLMainWindow, + restore_selection: bool, + edit: bool, + uuid_remap: dict[str, dict[str, str]], + ) -> None: + """Inner replay logic, always called under the replaying guard.""" + if self.kind == self.KIND_COMPUTE: + # Compute actions are selection-driven: restore the captured + # selection (translated through ``uuid_remap`` for session + # replays) whenever it is still resolvable so chained replays + # (especially ``n_to_1`` / ``2_to_1`` / ``1_to_n`` patterns) + # operate on the original input objects rather than on whatever + # the previous action left selected. When the captured UUIDs no + # longer exist (e.g. heuristic remap missed an object), fall + # back to the current selection -- replay may still fail + # downstream, but with the native processor error rather than + # an opaque ``WorkspaceState`` incompatibility. + translated = self._translate_state(uuid_remap) + if translated.is_current_state_compatible(mainwindow, False): + translated.restore(mainwindow) + self._replay_compute(mainwindow, edit, uuid_remap) + else: + if restore_selection: + self.state.restore(mainwindow) + self._replay_ui(mainwindow, edit) + + def _translate_state(self, uuid_remap: dict[str, dict[str, str]]) -> WorkspaceState: + """Return a copy of ``self.state`` whose captured UUIDs have been + translated through ``uuid_remap`` (identity when no mapping).""" + if not uuid_remap: + return self.state + translated = WorkspaceState() + for panel_str, uuids in self.state.selection.items(): + panel_map = uuid_remap.get(panel_str, {}) + translated.selection[panel_str] = [panel_map.get(u, u) for u in uuids] + translated.states = dict(self.state.states) + translated.titles = dict(self.state.titles) + for panel_str, metadata in self.state.object_metadata.items(): + panel_map = uuid_remap.get(panel_str, {}) + translated.object_metadata[panel_str] = { + panel_map.get(uuid, uuid): dict(signature) + for uuid, signature in metadata.items() + } + return translated + + def _replay_compute( + self, + mainwindow: DLMainWindow, + edit: bool, + uuid_remap: dict[str, dict[str, str]] | None = None, + ) -> None: + """Replay a compute-kind action via ``processor.run_feature``.""" + if self.pattern == "multiple_1_to_1": + raise NotImplementedError( + _("Replaying compound 'multiple_1_to_1' actions is not supported yet.") + ) + panel = self._resolve_panel(mainwindow) + processor = panel.processor + feature = processor.get_feature(self.func_name) + run_kwargs: dict[str, Any] = {self.FUNC_EDIT_MODE: edit} + + param = self.kwargs.get("param") + if self.pattern in {"1_to_1", "1_to_0", "n_to_1"}: + if param is not None: + run_kwargs["param"] = param + if self.pattern == "n_to_1" and "pairwise" in self.kwargs: + run_kwargs["pairwise"] = self.kwargs["pairwise"] + elif self.pattern == "2_to_1": + uuids = self.kwargs.get("obj2_uuids") or [] + if isinstance(uuids, str): + uuids = [uuids] + # Translate captured UUIDs through ``uuid_remap`` (session replay). + # ``uuid_remap`` keys are ``panel.PANEL_STR_ID`` (matches + # ``WorkspaceState.selection`` keys and + # ``HistoryAction.panel_str``). + panel_map = (uuid_remap or {}).get(panel.PANEL_STR_ID, {}) + uuids = [panel_map.get(u, u) for u in uuids] + objs2 = [ + obj + for obj in (self._resolve_obj_by_uuid(mainwindow, u) for u in uuids) + if obj is not None + ] + if not objs2: + raise ValueError( + _("Cannot replay 2-to-1 action: source object(s) missing.") + ) + run_kwargs["obj2"] = objs2[0] if len(objs2) == 1 else objs2 + if param is not None: + run_kwargs["param"] = param + if "pairwise" in self.kwargs: + run_kwargs["pairwise"] = self.kwargs["pairwise"] + elif self.pattern == "1_to_n": + params = self.kwargs.get("params") or [] + run_kwargs["params"] = params + else: + raise ValueError(f"Unknown compute pattern: {self.pattern!r}") + processor.run_feature(feature, **run_kwargs) + + def _replay_ui(self, mainwindow: DLMainWindow, edit: bool) -> None: + """Replay a UI-kind action by calling ``target.method_name(**kwargs)``.""" + hpanel = mainwindow.historypanel + if ( + hpanel is not None + and hpanel.is_output_suppressed() + and self.method_name in self.UI_CREATION_METHODS + ): + return # Skip creation UI during non-persistent replay + target = self._resolve_target(mainwindow) + # Safety guard for destructive UI actions: if the action would delete + # objects but the captured selection no longer resolves to existing + # UUIDs in the target panel, skip the call rather than delete whatever + # is currently selected (which would silently destroy unrelated data). + DESTRUCTIVE_METHODS = {"remove_object", "remove_group", "delete_all_objects"} + if self.method_name in DESTRUCTIVE_METHODS: + if target is None: + _logger.warning( + "Skipping destructive replay '%s': target '%s' not found", + self.method_name, + self.target, + ) + return + panel_str = getattr(target, "PANEL_STR_ID", None) + if panel_str and self.state and self.state.selection.get(panel_str): + existing_uuids = { + get_uuid(o) + for o in getattr(target, "objmodel", []) + if o is not None + } + captured = set(self.state.selection.get(panel_str, [])) + if not (captured & existing_uuids): + _logger.warning( + "Skipping destructive replay '%s': none of the captured " + "UUIDs %s exist in panel '%s' anymore", + self.method_name, + list(captured), + panel_str, + ) + return + method = getattr(target, self.method_name) + call_kwargs = dict(self.kwargs) + # Inject edit mode if the method supports it + try: + sig = inspect.signature(method) + if self.FUNC_EDIT_MODE in sig.parameters: + call_kwargs[self.FUNC_EDIT_MODE] = edit + except (TypeError, ValueError): + pass + method(**call_kwargs) + + # ------------------------------------------------------------------ + # Serialisation -- no Callable is ever pickled + # ------------------------------------------------------------------ + + def serialize(self, writer: NativeH5Writer) -> None: + """Serialize this action.""" + with writer.group("schema_version"): + writer.write(self.schema_version) + with writer.group("kind"): + writer.write(self.kind) + with writer.group("title"): + writer.write(self.__title) + if self.panel_str is not None: + with writer.group("panel_str"): + writer.write(self.panel_str) + if self.func_name is not None: + with writer.group("func_name"): + writer.write(self.func_name) + if self.pattern is not None: + with writer.group("pattern"): + writer.write(self.pattern) + if self.target is not None: + with writer.group("target"): + writer.write(self.target) + if self.method_name is not None: + with writer.group("method_name"): + writer.write(self.method_name) + encoded = _encode_kwargs(self.kwargs) + if encoded: + with writer.group("kwargs"): + writer.write_dict(encoded) + # ``saved_kwargs``: persisted Edit mode snapshot so the Restore button + # keeps working after save/reload. Group omitted when there are no + # pending edits. + if self._saved_kwargs is not None: + encoded_saved = _encode_kwargs(self._saved_kwargs) + # Write the group unconditionally (even when empty) so that the + # round-trip preserves the distinction between None (no pending + # edits) and {} (degenerate empty snapshot, keeps has_pending_edits). + with writer.group("saved_kwargs"): + writer.write_dict(encoded_saved) + # Only emit ``output_uuids`` when non-empty (empty lists skipped to + # avoid h5py edge cases with empty arrays). + if self.output_uuids: + with writer.group("output_uuids"): + writer.write(list(self.output_uuids)) + # ``plugin_origin``: stored as a JSON string so the HDF5 schema stays + # trivially round-trippable. Skipped when None. + if self.plugin_origin is not None: + with writer.group("plugin_origin"): + writer.write(json.dumps(self.plugin_origin)) + with writer.group("state"): + self.state.serialize(writer) + with writer.group("dtstr"): + writer.write(self.dtstr) + + def deserialize(self, reader: NativeH5Reader) -> None: + """Deserialize this action.""" + self.schema_version = reader.read( + "schema_version", default=HISTORY_SCHEMA_VERSION + ) + with reader.group("kind"): + self.kind = reader.read_any() + with reader.group("title"): + self.__title = reader.read_any() + # Optional descriptors are written conditionally; check existence in + # the underlying HDF5 group before reading to avoid leaking ``__seq`` + # frames on the option stack via guidata's read_any fallback path. + current = reader.h5 + for option in reader.option: + current = current.require_group(option) + for attr in ("panel_str", "func_name", "pattern", "target", "method_name"): + if attr in current.attrs or attr in current: + with reader.group(attr): + setattr(self, attr, reader.read_any()) + else: + setattr(self, attr, None) + if "kwargs" in current.attrs or "kwargs" in current: + with reader.group("kwargs"): + raw = reader.read_dict() + self.kwargs = _decode_kwargs(raw) + else: + self.kwargs = {} + # ``saved_kwargs`` group is present only when an Edit mode snapshot + # exists; otherwise leave it as ``None``. + if "saved_kwargs" in current.attrs or "saved_kwargs" in current: + with reader.group("saved_kwargs"): + raw_saved = reader.read_dict() + self._saved_kwargs = _decode_kwargs(raw_saved) + else: + self._saved_kwargs = None + # ``output_uuids`` is present only when the action produced outputs; + # otherwise leave it empty and consumers fall back to the heuristic + # matcher. + if "output_uuids" in current.attrs or "output_uuids" in current: + with reader.group("output_uuids"): + raw_outputs = reader.read_any() + if raw_outputs is None: + self.output_uuids = [] + else: + self.output_uuids = [str(u) for u in raw_outputs] + else: + self.output_uuids = [] + # ``plugin_origin`` is present only for plugin-originated compute + # actions; otherwise leave it as ``None`` (a replay of a missing plugin + # function then surfaces a generic ``FeatureNotFoundError``). + if "plugin_origin" in current.attrs or "plugin_origin" in current: + with reader.group("plugin_origin"): + raw_origin = reader.read_any() + if raw_origin in (None, ""): + self.plugin_origin = None + else: + try: + self.plugin_origin = json.loads(raw_origin) + except (TypeError, ValueError): + _logger.warning( + "Failed to decode plugin_origin for action %s; " + "falling back to None.", + self.uuid, + ) + self.plugin_origin = None + else: + self.plugin_origin = None + with reader.group("state"): + self.state.deserialize(reader) + with reader.group("dtstr"): + self.dtstr = reader.read_any() diff --git a/datalab/history/core.py b/datalab/history/core.py new file mode 100644 index 000000000..9ac7b3fcb --- /dev/null +++ b/datalab/history/core.py @@ -0,0 +1,235 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +"""History core utilities: schema constants, JSON codec, ``@add_to_history`` decorator.""" + +from __future__ import annotations + +import functools +import importlib +import json +import logging +import warnings +from copy import deepcopy +from typing import TYPE_CHECKING, Any + +import numpy as np +from guidata.dataset.conv import dataset_to_json, json_to_dataset +from guidata.dataset.datatypes import DataSet +from qtpy import QtCore as QC +from sigima.objects.base import BaseROI + +from datalab.config import _ + +if TYPE_CHECKING: + from datalab.gui.panel.base import BaseDataPanel + from datalab.gui.panel.history import HistoryPanel + from datalab.gui.processor.base import BaseProcessor + +_logger = logging.getLogger(__name__) +_TRUSTED_ROI_MODULE_PREFIX = "sigima." + +# Schema versions for persisted history sessions/actions. Both start at 1. +# Bump the relevant constant (and add the corresponding optional field +# handling in serialize/deserialize) when the on-disk layout evolves. +HISTORY_SCHEMA_VERSION = 1 +HISTORY_ACTION_SCHEMA_VERSION = 1 +# Keys used in the kwargs dict to mark DataSet payloads, so that the +# serialization layer can round-trip them as JSON strings instead of pickling +# arbitrary Python objects. +_DATASET_MARKER = "__dataset_json__" +_DATASET_LIST_MARKER = "__dataset_list_json__" +_ROI_MARKER = "__roi_json__" + + +def get_datetime_str() -> str: + """Return current date and time as a string""" + return QC.QDateTime.currentDateTime().toString("yyyy-MM-dd hh:mm:ss") + + +def _numpy_to_json_safe(obj: Any) -> Any: + """Recursively convert numpy arrays to lists for JSON serialization.""" + if isinstance(obj, np.ndarray): + return obj.tolist() + if isinstance(obj, dict): + return {k: _numpy_to_json_safe(v) for k, v in obj.items()} + if isinstance(obj, list): + return [_numpy_to_json_safe(i) for i in obj] + return obj + + +def _encode_roi(roi: Any) -> str: + """Encode a sigima ROI object to a JSON string via ``to_dict()``.""" + if not isinstance(roi, BaseROI): + raise TypeError(f"Expected BaseROI instance, got {type(roi)!r}") + roi_dict = _numpy_to_json_safe(roi.to_dict()) + # Store the concrete class so we can reconstruct on decode. + payload = { + "module": type(roi).__module__, + "class": type(roi).__qualname__, + "data": roi_dict, + } + return json.dumps(payload) + + +def _decode_roi(encoded: str) -> Any: + """Decode a JSON string back to a sigima ROI object. + + Only classes from trusted ``sigima.`` modules that are actual + :class:`sigima.objects.base.BaseROI` subclasses are allowed. + + Raises: + ValueError: If the module is not a trusted sigima module or the + resolved class is not a BaseROI subclass. + """ + payload = json.loads(encoded) + module_name = payload["module"] + class_name = payload["class"] + + if not module_name.startswith(_TRUSTED_ROI_MODULE_PREFIX): + raise ValueError( + f"Untrusted ROI module {module_name!r}: " + f"only modules under {_TRUSTED_ROI_MODULE_PREFIX!r} are allowed" + ) + + mod = importlib.import_module(module_name) + cls = getattr(mod, class_name) + + if not (isinstance(cls, type) and issubclass(cls, BaseROI)): + raise ValueError(f"{module_name}.{class_name} is not a BaseROI subclass") + + return cls.from_dict(payload["data"]) + + +def _encode_kwargs(kwargs: dict[str, Any]) -> dict[str, Any]: + """Encode kwargs for HDF5 storage: replace ``DataSet``, ``list[DataSet]``, + and sigima ROI values with marker dicts holding their JSON representation. + + All other values must already be HDF5-friendly primitives (str, int, float, + bool, list/tuple of the same). + + Args: + kwargs: Raw kwargs dict (may contain ``DataSet`` or ROI instances). + + Returns: + A new dict with special values wrapped in marker dicts. + """ + encoded: dict[str, Any] = {} + for key, value in kwargs.items(): + if value is None: + continue + if isinstance(value, DataSet): + encoded[key] = {_DATASET_MARKER: dataset_to_json(value)} + elif isinstance(value, BaseROI): + encoded[key] = {_ROI_MARKER: _encode_roi(value)} + elif ( + isinstance(value, list) + and value + and all(isinstance(item, DataSet) for item in value) + ): + encoded[key] = { + _DATASET_LIST_MARKER: [dataset_to_json(item) for item in value] + } + else: + encoded[key] = value + return encoded + + +def _decode_kwargs(kwargs: dict[str, Any]) -> dict[str, Any]: + """Inverse of :func:`_encode_kwargs`.""" + decoded: dict[str, Any] = {} + for key, value in kwargs.items(): + if isinstance(value, dict) and _DATASET_MARKER in value: + try: + decoded[key] = json_to_dataset(value[_DATASET_MARKER]) + except (TypeError, ValueError, KeyError): + warnings.warn( + _("Failed to deserialize history DataSet kwarg %r.") % key + ) + decoded[key] = None + elif isinstance(value, dict) and _ROI_MARKER in value: + try: + decoded[key] = _decode_roi(value[_ROI_MARKER]) + except Exception as exc: + raise ValueError( + f"Failed to deserialize history ROI kwarg {key!r}: {exc}" + ) from exc + elif isinstance(value, dict) and _DATASET_LIST_MARKER in value: + try: + decoded[key] = [ + json_to_dataset(item) for item in value[_DATASET_LIST_MARKER] + ] + except (TypeError, ValueError, KeyError): + warnings.warn( + _("Failed to deserialize history DataSet-list kwarg %r.") % key + ) + decoded[key] = [] + else: + decoded[key] = value + return decoded + + +def _copy_history_value(value: Any) -> Any: + """Return an independent copy of a history-serializable value.""" + if callable(value): + raise TypeError("History duplication does not support callable kwargs") + if isinstance(value, DataSet): + return json_to_dataset(dataset_to_json(value)) + if isinstance(value, BaseROI): + return _decode_roi(_encode_roi(value)) + if isinstance(value, dict): + return {key: _copy_history_value(item) for key, item in value.items()} + if isinstance(value, list): + return [_copy_history_value(item) for item in value] + if isinstance(value, tuple): + return tuple(_copy_history_value(item) for item in value) + return deepcopy(value) + + +def add_to_history(kwargs_names: list[str] | None = None, title: str | None = None): + """Method decorator to add the method call to the history panel as a UI entry. + + Args: + kwargs_names: List of keyword arguments to add to the history action. + Defaults to None. + title: Title of the history action. Defaults to None. + """ + if kwargs_names is None: + kwargs_names = [] + + def add_to_history_decorator(func): + """Decorator function""" + + @functools.wraps(func) + def method_wrapper(*args, **kwargs): + """Decorator wrapper function""" + self: BaseDataPanel | BaseProcessor = args[0] + history: HistoryPanel = self.mainwindow.historypanel + histkwargs = {k: kwargs[k] for k in kwargs_names if k in kwargs} + target = _resolve_self_target(self) + if target is not None: + history.add_ui_entry( + kwargs.get("title", title) or func.__name__, + target=target, + method_name=func.__name__, + save_state=kwargs.get("save_state", True), + **histkwargs, + ) + return func(*args, **kwargs) + + return method_wrapper + + return add_to_history_decorator + + +def _resolve_self_target(self_obj: Any) -> str | None: + """Resolve a 'self' instance to a string target understood by replay. + + Used by the legacy ``@add_to_history`` decorator. Returns None when no + safe routing is possible (in which case the entry is skipped). + """ + panel_str = getattr(self_obj, "PANEL_STR_ID", None) + if panel_str == "signal": + return "signalpanel" + if panel_str == "image": + return "imagepanel" + return None diff --git a/datalab/history/session.py b/datalab/history/session.py new file mode 100644 index 000000000..24a78a2f7 --- /dev/null +++ b/datalab/history/session.py @@ -0,0 +1,392 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +"""HistorySession: ordered list of HistoryAction with replay logic.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from datalab.config import _ +from datalab.history.action import HistoryAction +from datalab.history.core import HISTORY_SCHEMA_VERSION, get_datetime_str + +if TYPE_CHECKING: + from datalab.gui.main import DLMainWindow + from datalab.h5.native import NativeH5Reader, NativeH5Writer + + +class HistorySession: + """Object representing a history session, i.e. a list of actions. + + A history session is a list of actions that can be replayed in the same order + as they were added to the history session. The history session can be saved to + a file and loaded from a file. + + Args: + title: Title of the history session + number: Number of the history session + """ + + def __init__(self, title: str = "", number: int = 0) -> None: + """Create a new history session""" + prefix = _("Session") + self.title = title if title else f"{prefix} {number:03d}" + self.number = number + self.dtstr: str = get_datetime_str() + self.actions: list[HistoryAction] = [] + self.schema_version: int = HISTORY_SCHEMA_VERSION + + def add_action(self, action: HistoryAction) -> None: + """Add an action to the history session + + Args: + action: Action to add + """ + self.actions.append(action) + + def copy( + self, title: str | None = None, action_title_suffix: str | None = None + ) -> HistorySession: + """Return an independent copy of this history session.""" + session = HistorySession(title=title or self.title, number=self.number) + session.actions = [ + action.copy(title_suffix=action_title_suffix) for action in self.actions + ] + return session + + def copy_with_uuid_remap( + self, title: str, uuid_remap: dict[str, dict[str, str]] + ) -> HistorySession: + """Return a copy of this session with all UUIDs rewritten via ``uuid_remap``. + + Used by the Duplicate operation to build an independent session whose + captured object references point to the cloned data objects. + + Args: + title: Title for the new session. + uuid_remap: Per-panel mapping ``{panel_str: {old_uuid: new_uuid}}``. + + Returns: + A new :class:`HistorySession` with all captured UUIDs remapped. + """ + session = HistorySession(title=title, number=self.number) + session.actions = [ + action.copy_with_uuid_remap(uuid_remap) for action in self.actions + ] + return session + + def is_current_state_compatible( + self, mainwindow: DLMainWindow, restore_selection: bool + ) -> bool: + """Check if the current workspace state is compatible with the saved state + + Args: + mainwindow: DataLab's main window + restore_selection: True to restore the selection before checking the state + + Returns: + bool: True if the current workspace state is compatible with the saved state + """ + if self.actions: + return self.actions[0].is_current_state_compatible( + mainwindow, restore_selection + ) + return True + + def restore(self, mainwindow: DLMainWindow) -> None: + """Restore the state of the workspace associated to the first action of session + + Args: + mainwindow: DataLab's main window + """ + if self.actions: + self.actions[0].restore(mainwindow) + + def replay( + self, mainwindow: DLMainWindow, restore_selection: bool, edit: bool + ) -> None: + """Replay the history session + + Args: + mainwindow: DataLab's main window + restore_selection: True to restore the workspace selection before replaying + edit: if True, always open the dialog boxes to edit parameters, if False, + use the parameters passed when creating the action + """ + # Per-panel ``{old_uuid: new_uuid}`` mapping, populated as UI actions + # create new objects. Used by compute actions to translate their + # captured selection (and ``obj2_uuids``) into the freshly-created + # UUIDs of the current replay, so chained ``n_to_1`` / ``2_to_1`` / + # ``1_to_n`` actions operate on the correct inputs. Keys are + # ``panel.PANEL_STR_ID`` (matches ``WorkspaceState.selection`` keys). + panels = (mainwindow.signalpanel, mainwindow.imagepanel) + uuid_remap: dict[str, dict[str, str]] = {p.PANEL_STR_ID: {} for p in panels} + # FIFO of newly-created UUIDs not yet claimed by a remap entry -- + # required because most creation UI actions (e.g. ``new_signal``) + # are recorded with ``save_state=False`` (empty captured selection), + # so we cannot pair captured-vs-new UUIDs by position at UI time. + # Subsequent compute actions claim from this queue on demand. + unclaimed: dict[str, list[str]] = {p.PANEL_STR_ID: [] for p in panels} + + def _claim_unmapped( + pstr: str, + old_uuids: list[str], + action: HistoryAction, + ) -> None: + """Claim unclaimed new UUIDs for *old_uuids* not yet in uuid_remap. + + Uses title matching (scanning the full unclaimed queue) followed by + panel-order index alignment to deterministically pair old UUIDs + to the correct new UUIDs, regardless of creation order. + """ + # Collect unmapped UUIDs (deduplicated, preserving first-seen order). + all_unmapped: list[str] = [] + seen: set[str] = set() + for u in old_uuids: + if u not in seen and u not in uuid_remap.get(pstr, {}): + all_unmapped.append(u) + seen.add(u) + if not all_unmapped: + return + # Re-sort by recorded panel position when available. + panel_order = list(action.state.object_metadata.get(pstr, {}).keys()) + if panel_order and all(u in panel_order for u in all_unmapped): + all_unmapped.sort(key=panel_order.index) + queue = unclaimed.get(pstr) or [] + if not queue: + return + # Build old UUID → title from captured state and object_metadata. + sel_uuids = action.state.selection.get(pstr, []) + sel_titles = action.state.titles.get(pstr, []) + old_titles: dict[str, str] = {} + for _u, _t in zip(sel_uuids, sel_titles): + if _u in seen: + old_titles[_u] = _t + obj_meta = action.state.object_metadata.get(pstr, {}) + for _u in all_unmapped: + if _u not in old_titles and _u in obj_meta: + meta = obj_meta[_u] + if isinstance(meta, dict) and "title" in meta: + old_titles[_u] = meta["title"] + # Build new UUID → title from the live panel (full queue). + new_titles: dict[str, str] = {} + panel_obj = None + for p in panels: + if p.PANEL_STR_ID == pstr: + panel_obj = p + break + if panel_obj is not None: + for nu in queue: + try: + new_titles[nu] = panel_obj.objmodel[nu].title + except KeyError: + pass + # Phase 1: title matching against the FULL queue. + assigned_old: set[str] = set() + assigned_new: set[str] = set() + for ou in all_unmapped: + if ou not in old_titles: + continue + title = old_titles[ou] + candidates = [ + nu + for nu in queue + if nu not in assigned_new and new_titles.get(nu) == title + ] + if len(candidates) == 1: + uuid_remap.setdefault(pstr, {})[ou] = candidates[0] + assigned_old.add(ou) + assigned_new.add(candidates[0]) + # Phase 2: positional fallback using panel-order alignment. + # Two modes depending on whether the remaining queue covers all + # free recorded panel slots: + # + # A) Absolute index alignment (len(rem_queue) == len(free_indices)): + # Each free panel_order index maps 1-to-1 to a queue slot. + # This ensures e.g. the second-created object maps to the + # second queue entry even when only a subset of old UUIDs + # needs claiming. + # + # B) Relative order fallback (queue is a strict subset): + # The queue only contains later compute-created objects while + # earlier full-panel entries are absent. Absolute alignment + # would leave non-first old UUIDs unmapped. Instead, zip + # rem_old (already sorted by panel order) with rem_queue + # sequentially. + rem_old = [u for u in all_unmapped if u not in assigned_old] + if rem_old and panel_order: + rem_queue = [u for u in queue if u not in assigned_new] + # Find which panel_order indices are "free" (unclaimed). + free_indices: list[int] = [] + for idx, po_uuid in enumerate(panel_order): + if po_uuid not in uuid_remap.get(pstr, {}): + if po_uuid not in assigned_old: + free_indices.append(idx) + if len(rem_queue) == len(free_indices): + # Mode A: absolute index alignment. + idx_to_new: dict[int, str] = {} + for qi, fi in enumerate(free_indices): + if qi < len(rem_queue): + idx_to_new[fi] = rem_queue[qi] + for ou in rem_old: + if ou in panel_order: + idx = panel_order.index(ou) + if idx in idx_to_new: + nu = idx_to_new[idx] + uuid_remap.setdefault(pstr, {})[ou] = nu + assigned_new.add(nu) + else: + # Mode B: relative order fallback. + for ou, nu in zip(rem_old, rem_queue): + uuid_remap.setdefault(pstr, {})[ou] = nu + assigned_new.add(nu) + elif rem_old: + # No panel_order available: sequential fallback. + rem_queue = [u for u in queue if u not in assigned_new] + for ou, nu in zip(rem_old, rem_queue): + uuid_remap.setdefault(pstr, {})[ou] = nu + assigned_new.add(nu) + # Remove all assigned new UUIDs from the unclaimed queue. + if assigned_new: + unclaimed[pstr] = [u for u in queue if u not in assigned_new] + + for action in self.actions[:]: + before = {p.PANEL_STR_ID: set(p.objmodel.get_object_ids()) for p in panels} + if action.kind == HistoryAction.KIND_COMPUTE: + # Lazy-resolve any captured UUIDs missing from the remap by + # claiming from ``unclaimed`` (deterministic: title + panel-order). + pstr = action.panel_str or "" + captured = action.state.selection.get(pstr, []) + if action.pattern == "2_to_1": + # For 2_to_1: collect ALL unmapped old UUIDs from both + # captured selection and obj2_uuids in one batch so + # operand order is preserved by the helper. + obj2 = action.kwargs.get("obj2_uuids") or [] + if isinstance(obj2, str): + obj2 = [obj2] + _claim_unmapped(pstr, list(obj2) + list(captured), action) + else: + # For all other compute patterns (1_to_1, n_to_1, etc.): + # use the same deterministic helper. + _claim_unmapped(pstr, list(captured), action) + action.replay( + mainwindow, + restore_selection=restore_selection, + edit=edit, + uuid_remap=uuid_remap, + ) + # Post-action bookkeeping: track new/removed UUIDs for *every* + # action kind so that later actions consuming compute-created + # outputs can resolve them through ``uuid_remap`` / ``unclaimed``. + for panel in panels: + pstr = panel.PANEL_STR_ID + current_ids = set(panel.objmodel.get_object_ids()) + new_uuids = [ + u for u in panel.objmodel.get_object_ids() if u not in before[pstr] + ] + # Drop vanished UUIDs from the unclaimed queue and the + # reverse remap entries (e.g. ``Remove selected objects``): + # this keeps the FIFO claim in sync with the live panel + # contents during chained creation/removal replays. + removed_uuids = before[pstr] - current_ids + if removed_uuids: + unclaimed[pstr] = [ + u for u in unclaimed.get(pstr, []) if u not in removed_uuids + ] + panel_map = uuid_remap.get(pstr, {}) + for old_key in [ + k for k, v in panel_map.items() if v in removed_uuids + ]: + panel_map.pop(old_key, None) + if not new_uuids: + continue + if action.kind == HistoryAction.KIND_UI: + captured = action.state.selection.get(pstr, []) + if captured: + # Captured post-action selection available: pair + # captured UUIDs with new UUIDs by position. + for old_uuid, new_uuid in zip(captured, new_uuids): + uuid_remap.setdefault(pstr, {})[old_uuid] = new_uuid + # Any extra newly-created UUIDs go to the queue. + unclaimed.setdefault(pstr, []).extend( + new_uuids[len(captured) :] + ) + else: + # No captured selection (typical of ``new_signal``): + # queue all new UUIDs for lazy claiming. + unclaimed.setdefault(pstr, []).extend(new_uuids) + else: + # Compute actions: queue all newly-created UUIDs so + # later actions can lazily claim them. Do NOT map + # captured input UUIDs to output UUIDs — compute + # inputs and outputs are semantically different. + unclaimed.setdefault(pstr, []).extend(new_uuids) + + # Visually close the replay: select the output of the last compute + # action so the user sees the final result highlighted in the panel. + # Without this, the very last action's output is never selected + # (intermediate actions are implicitly "closed" by the next + # iteration's input restore). + if self.actions: + last = self.actions[-1] + if last.kind == HistoryAction.KIND_COMPUTE: + hpanel = getattr(mainwindow, "historypanel", None) + if hpanel is not None: + output_uuid = hpanel._action_output_uuid(last) + if output_uuid: + panel_str = last.panel_str or "" + panel_map = uuid_remap.get(panel_str, {}) + mapped_uuid = panel_map.get(output_uuid, output_uuid) + for panel in panels: + if panel.PANEL_STR_ID == panel_str: + try: + panel.objview.select_objects([mapped_uuid]) + except KeyError: + pass + break + + def serialize(self, writer: NativeH5Writer) -> None: + """Serialize this history session + + Args: + writer: Writer + """ + with writer.group("schema_version"): + writer.write(self.schema_version) + with writer.group("title"): + writer.write(self.title) + with writer.group("number"): + writer.write(self.number) + with writer.group("dtstr"): + writer.write(self.dtstr) + writer.write_object_list(self.actions, "actions") + + def deserialize(self, reader: NativeH5Reader) -> None: + """Deserialize this history session + + Args: + reader: Reader + """ + self.schema_version = reader.read( + "schema_version", default=HISTORY_SCHEMA_VERSION + ) + with reader.group("title"): + self.title = reader.read_any() + with reader.group("number"): + self.number = reader.read_any() + with reader.group("dtstr"): + self.dtstr = reader.read_any() + self.actions = reader.read_object_list("actions", HistoryAction) + + def remove_action(self, action: HistoryAction) -> None: + """Remove an action from the history session + + This implies removing all subsequent actions. If action is not found, this + fails silently. + + Args: + action: Action to remove + """ + if action in self.actions: + index = self.actions.index(action) + self.actions = self.actions[:index] diff --git a/datalab/history/workspace_state.py b/datalab/history/workspace_state.py new file mode 100644 index 000000000..a665d6a8c --- /dev/null +++ b/datalab/history/workspace_state.py @@ -0,0 +1,249 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +"""Workspace state snapshot captured at history action time.""" + +from __future__ import annotations + +from copy import deepcopy +from typing import TYPE_CHECKING, Any + +from datalab.objectmodel import get_uuid + +if TYPE_CHECKING: + from datalab.gui.main import DLMainWindow + from datalab.h5.native import NativeH5Reader, NativeH5Writer + + +class WorkspaceState: + """Object representing the workspace state at a given time. + + The workspace state stores the per-panel selection of objects by **UUID** + (robust against reordering, renaming or interleaved insertions). For + informative display, it also retains the data shape and title of each + selected object at the time of capture. + """ + + def __init__(self) -> None: + """Create a new workspace state""" + # The selection is stored as a dictionary where the key is the panel name + # and the value is the list of UUIDs of selected objects. + self.selection: dict[str, list[str]] = {} + # The states are stored as a dictionary where the key is the panel name + # and the value is the list of states (str) of the objects in the panel. The + # state is a string containing the object data shape (kept for informative + # display only -- not used for selection matching anymore). + self.states: dict[str, list[str]] = {} + # The titles are stored as a dictionary where the key is the panel name and the + # value is the list of titles of the objects in the panel. The title is only + # informative and is not used to determine if two objects have the same state. + self.titles: dict[str, list[str]] = {} + # Structured data signatures of selected objects, keyed by panel name and UUID. + # This is the current schema used for compatibility checks. Missing metadata + # means a pre-Gate-2 history and falls back to UUID-existence validation. + self.object_metadata: dict[str, dict[str, dict[str, Any]]] = {} + + def copy(self) -> WorkspaceState: + """Return an independent copy of this workspace state.""" + state = WorkspaceState() + state.selection = deepcopy(self.selection) + state.states = deepcopy(self.states) + state.titles = deepcopy(self.titles) + state.object_metadata = deepcopy(self.object_metadata) + return state + + def serialize(self, writer: NativeH5Writer) -> None: + """Serialize this workspace state + + Args: + writer: Writer + """ + with writer.group("selection"): + writer.write_dict(self.selection) + with writer.group("states"): + writer.write_dict(self.states) + with writer.group("titles"): + writer.write_dict(self.titles) + with writer.group("object_metadata"): + writer.write_dict(self.object_metadata) + + def deserialize(self, reader: NativeH5Reader) -> None: + """Deserialize this workspace state + + Args: + reader: Reader + """ + with reader.group("selection"): + self.selection = reader.read_dict() + with reader.group("states"): + self.states = reader.read_dict() + with reader.group("titles"): + self.titles = reader.read_dict() + current = reader.h5 + for option in reader.option: + current = current[option] + if "object_metadata" in current.attrs or "object_metadata" in current: + with reader.group("object_metadata"): + self.object_metadata = reader.read_dict() + else: + self.object_metadata = {} + # Normalize legacy translated keys to stable panel identifiers. + self.selection = self._normalize_panel_keys(self.selection) + self.states = self._normalize_panel_keys(self.states) + self.titles = self._normalize_panel_keys(self.titles) + self.object_metadata = self._normalize_panel_keys(self.object_metadata) + + def get_current_selection(self, mainwindow: DLMainWindow) -> dict[str, list[str]]: + """Get the current selection in the workspace, keyed by panel name and + valued by the list of selected object UUIDs. + + Args: + mainwindow: DataLab's main window + + Returns: + Current selection in the workspace, by panel name → list of UUIDs. + """ + selection: dict[str, list[str]] = {} + for panel in (mainwindow.signalpanel, mainwindow.imagepanel): + selection[panel.PANEL_STR_ID] = [ + get_uuid(obj) + for obj in panel.objview.get_sel_objects(include_groups=True) + ] + return selection + + @staticmethod + def get_object_metadata(obj: Any) -> dict[str, Any]: + """Return a stable data signature for an object.""" + data = getattr(obj, "data", None) + shape = getattr(data, "shape", None) + if shape is None: + return {} + shape = [int(size) for size in shape] + ndim = getattr(data, "ndim", len(shape)) + return {"shape": shape, "ndim": int(ndim)} + + @staticmethod + def _normalize_object_metadata(metadata: dict[str, Any]) -> dict[str, Any]: + """Normalize object metadata loaded from HDF5 for comparison.""" + shape = metadata.get("shape") + if shape is None: + return {} + shape = [int(size) for size in shape] + ndim = metadata.get("ndim", len(shape)) + return {"shape": shape, "ndim": int(ndim)} + + # Mapping from legacy translated panel keys to stable identifiers. + # Covers the English translations; other locales are handled by the + # catch-all ``"signal"``/``"image"`` substring heuristic below. + _LEGACY_PANEL_KEY_MAP: dict[str, str] = { + "Signal Panel": "signal", + "Image Panel": "image", + } + + @classmethod + def _normalize_panel_key(cls, key: str) -> str: + """Map a potentially translated panel key to its stable identifier.""" + if key in ("signal", "image"): + return key + mapped = cls._LEGACY_PANEL_KEY_MAP.get(key) + if mapped is not None: + return mapped + # Heuristic for non-English translations: look for the stable ID + # substring in the key (e.g. "Panneau signal" → "signal"). + lowered = key.lower() + for stable_id in ("signal", "image"): + if stable_id in lowered: + return stable_id + return key + + @classmethod + def _normalize_panel_keys(cls, d: dict) -> dict: + """Return *d* with all top-level keys normalized to stable panel IDs.""" + return {cls._normalize_panel_key(k): v for k, v in d.items()} + + def save(self, mainwindow: DLMainWindow) -> None: + """Save the current workspace state + + Args: + mainwindow: DataLab's main window + """ + self.selection = self.get_current_selection(mainwindow) + self.object_metadata = {} + for panel in (mainwindow.signalpanel, mainwindow.imagepanel): + sel_uuids = self.selection[panel.PANEL_STR_ID] + self.states[panel.PANEL_STR_ID] = [ + str(obj.data.shape) + for obj in panel.objmodel + if get_uuid(obj) in sel_uuids + ] + self.titles[panel.PANEL_STR_ID] = [ + obj.title for obj in panel.objmodel if get_uuid(obj) in sel_uuids + ] + # Store metadata for ALL panel objects (not just selected) so that + # the dict key order captures the full panel ordering. During + # session replay the key order lets us sort old UUIDs by their + # original panel position, which prevents non-commutative 2_to_1 + # operand swaps in the positional-fallback code path. + # ``is_current_state_compatible`` only checks *selected* UUIDs, so + # the extra entries are harmless for compatibility validation. + self.object_metadata[panel.PANEL_STR_ID] = { + get_uuid(obj): self.get_object_metadata(obj) for obj in panel.objmodel + } + + def is_current_state_compatible( # pylint: disable=unused-argument + self, mainwindow: DLMainWindow, restore_selection: bool + ) -> bool: + """Check if the current workspace state is compatible with the saved state. + + Compatibility means that **every** UUID recorded in the saved selection + still exists in the corresponding panel. When structured object metadata + is available (current schema), each selected object's data shape and + dimensions must also match the saved signature. Histories without this + metadata fall back to legacy UUID-existence validation. + + Args: + mainwindow: DataLab's main window + restore_selection: Unused (kept for API symmetry). With UUID-based + identity, the compatibility check no longer depends on the current + selection -- it only depends on object existence. + + Returns: + True if every saved UUID still exists in its panel and saved + metadata, when available, still matches. + """ + if not self.selection: + return True + for panel in (mainwindow.signalpanel, mainwindow.imagepanel): + saved_uuids = self.selection.get(panel.PANEL_STR_ID, []) + existing_uuids = set(panel.objmodel.get_object_ids()) + saved_metadata = self.object_metadata.get(panel.PANEL_STR_ID, {}) + for uuid in saved_uuids: + if uuid not in existing_uuids: + return False + if uuid in saved_metadata: + current = self.get_object_metadata(panel.objmodel[uuid]) + current = self._normalize_object_metadata(current) + saved = self._normalize_object_metadata(saved_metadata[uuid]) + if saved and current != saved: + return False + return True + + def restore(self, mainwindow: DLMainWindow) -> None: + """Restore the workspace state by selecting the recorded UUIDs. + + Args: + mainwindow: DataLab's main window + + Raises: + ValueError: If at least one of the saved UUIDs no longer exists in + its panel. + """ + if not self.selection: + return + if not self.is_current_state_compatible(mainwindow, False): + raise ValueError( + "Current workspace state is not compatible with saved state" + ) + for panel in (mainwindow.signalpanel, mainwindow.imagepanel): + uuids = self.selection.get(panel.PANEL_STR_ID, []) + if uuids: + panel.objview.select_objects(uuids) diff --git a/datalab/locale/fr/LC_MESSAGES/datalab.po b/datalab/locale/fr/LC_MESSAGES/datalab.po index 4b4b85108..fafd660ca 100644 --- a/datalab/locale/fr/LC_MESSAGES/datalab.po +++ b/datalab/locale/fr/LC_MESSAGES/datalab.po @@ -1,6 +1,6 @@ -# French translations for datalab. +# French translations for DataLab. # Copyright (C) 2026 DataLab Platform Developers -# This file is distributed under the same license as the datalab project. +# This file is distributed under the same license as the DataLab project. # msgid "" msgstr "" @@ -949,6 +949,38 @@ msgstr "Troncature de données uint32 en int32." msgid "No supported data available in HDF5 file(s)." msgstr "Aucune donnée prise en charge dans le(s) fichier(s) HDF5." +msgid "Generate macro" +msgstr "Générer une macro" + +msgid "No compute actions to export." +msgstr "Aucune action de calcul à exporter." + +#, python-format +msgid "Macro script copied to clipboard (%d actions)." +msgstr "Script macro copié dans le presse-papiers (%d actions)." + +msgid "" +"Do you really want to delete the selected items?\n" +"\n" +"Note: deleting an action also removes all subsequent actions in the same session." +msgstr "" +"Voulez-vous vraiment supprimer les éléments sélectionnés ?\n" +"\n" +"Note : la suppression d'une action supprime également toutes les actions suivantes de la même session." + +msgid "Do you really want to delete the selected items?" +msgstr "Voulez-vous vraiment supprimer les éléments sélectionnés ?" + +msgid "Remove incompatible" +msgstr "Supprimer les incompatibles" + +msgid "All actions are compatible with the current workspace." +msgstr "Toutes les actions sont compatibles avec l'espace de travail courant." + +#, python-format +msgid "%d incompatible action(s) will be removed. Continue?" +msgstr "%d action(s) incompatible(s) vont être supprimée(s). Continuer ?" + msgid "Macro simple example" msgstr "Exemple simple de macro" @@ -1656,54 +1688,17 @@ msgid "The label has been added as an annotation. You can edit or remove it usin msgstr "L'étiquette a été ajoutée comme annotation. Vous pouvez la modifier ou la supprimer en utilisant la fenêtre d'édition des annotations.
Ignorer ce message empêchera son affichage ultérieur." #, python-format -msgid "Failed to deserialize history DataSet kwarg %r." -msgstr "Échec de la désérialisation de l'argument DataSet de l'historique %r." - -#, python-format -msgid "Failed to deserialize history DataSet-list kwarg %r." -msgstr "Échec de la désérialisation de l'argument DataSet-list de l'historique %r." - -msgid "Replaying compound 'multiple_1_to_1' actions is not supported yet." -msgstr "La relecture des actions composées 'multiple_1_to_1' n'est pas encore prise en charge." - -msgid "Cannot replay 2-to-1 action: source object(s) missing." -msgstr "Impossible de rejouer une action 2-à-1 : objet(s) source manquant(s)." - -msgid "Session" -msgstr "Session" - -msgid "Show details" -msgstr "Afficher les détails" - -msgid "Hide details" -msgstr "Masquer les détails" - -msgid "Date and time" -msgstr "Date et heure" - -msgid "Description" -msgstr "Description" - -msgid "Title" -msgstr "Titre" - -msgid "Action is compatible with the current workspace state." -msgstr "L'action est compatible avec l'état actuel de l'espace de travail." - -msgid "Action is not compatible with the current workspace state." -msgstr "L'action n'est pas compatible avec l'état actuel de l'espace de travail." - -msgid "Signal" -msgstr "Signal" +msgid "“%s” has dependent operations but no valid source to reconnect to — downstream results are left unchanged." +msgstr "« %s » possède des opérations dépendantes mais aucune source valide à laquelle se reconnecter — les résultats en aval restent inchangés." -msgid "Shape" -msgstr "Forme" +msgid "Some operations could not be reconnected after deletion:" +msgstr "Certaines opérations n'ont pas pu être reconnectées après la suppression :" -msgid "Image" -msgstr "Image" +msgid "The current workspace state is not compatible with the action." +msgstr "L'état actuel de l'espace de travail n'est pas compatible avec l'action." -msgid "Dimensions" -msgstr "Dimensions" +msgid "Parameters" +msgstr "Paramètres" msgid "History panel" msgstr "Panneau d'historique" @@ -1750,15 +1745,9 @@ msgstr "Étape suivante" msgid "Select the next action in the current session" msgstr "Sélectionner l'action suivante dans la session courante" -msgid "Generate macro" -msgstr "Générer une macro" - msgid "Generate a Python macro script from history" msgstr "Générer un script macro Python à partir de l'historique" -msgid "Remove incompatible" -msgstr "Supprimer les incompatibles" - msgid "Remove actions incompatible with the current workspace" msgstr "Supprimer les actions incompatibles avec l'espace de travail courant" @@ -1789,12 +1778,6 @@ msgstr "" "\n" "Souhaitez-vous continuer ?" -msgid "The current workspace state is not compatible with the action." -msgstr "L'état actuel de l'espace de travail n'est pas compatible avec l'action." - -msgid "Parameters" -msgstr "Paramètres" - #, python-format msgid "Action %s has been edited but its target output object(s) no longer exist — skipping." msgstr "L'action %s a été modifiée mais son ou ses objets de sortie cibles n'existent plus — ignorée." @@ -1843,48 +1826,6 @@ msgstr "Recalcul en cascade" msgid "Some downstream actions could not be recomputed:" msgstr "Certaines actions en aval n'ont pas pu être recalculées :" -msgid "No compute actions to export." -msgstr "Aucune action de calcul à exporter." - -#, python-format -msgid "Macro script copied to clipboard (%d actions)." -msgstr "Script macro copié dans le presse-papiers (%d actions)." - -msgid "" -"Do you really want to delete the selected items?\n" -"\n" -"Note: deleting an action also removes all subsequent actions in the same session." -msgstr "" -"Voulez-vous vraiment supprimer les éléments sélectionnés ?\n" -"\n" -"Note : la suppression d'une action supprime également toutes les actions suivantes de la même session." - -msgid "Do you really want to delete the selected items?" -msgstr "Voulez-vous vraiment supprimer les éléments sélectionnés ?" - -msgid "All actions are compatible with the current workspace." -msgstr "Toutes les actions sont compatibles avec l'espace de travail courant." - -#, python-format -msgid "%d incompatible action(s) will be removed. Continue?" -msgstr "%d action(s) incompatible(s) vont être supprimée(s). Continuer ?" - -msgid "Save history file" -msgstr "Enregistrer un fichier d'historique" - -msgid "Open history file" -msgstr "Ouvrir un fichier d'historique" - -msgid "Imported" -msgstr "Importé" - -msgid "Some operations could not be reconnected after deletion:" -msgstr "Certaines opérations n'ont pas pu être reconnectées après la suppression :" - -#, python-format -msgid "“%s” has dependent operations but no valid source to reconnect to — downstream results are left unchanged." -msgstr "« %s » possède des opérations dépendantes mais aucune source valide à laquelle se reconnecter — les résultats en aval restent inchangés." - msgid "New image" msgstr "Nouvelle image" @@ -3377,6 +3318,32 @@ msgstr "C'est la fin de la visite guidée !" msgid "You can show the tour again, or close this dialog box." msgstr "Vous pouvez afficher la visite guidée à nouveau, ou fermer cette boîte de dialogue." +msgid "Save history file" +msgstr "Enregistrer un fichier d'historique" + +msgid "Open history file" +msgstr "Ouvrir un fichier d'historique" + +msgid "Imported" +msgstr "Importé" + +msgid "Replaying compound 'multiple_1_to_1' actions is not supported yet." +msgstr "La relecture des actions composées 'multiple_1_to_1' n'est pas encore prise en charge." + +msgid "Cannot replay 2-to-1 action: source object(s) missing." +msgstr "Impossible de rejouer une action 2-à-1 : objet(s) source manquant(s)." + +#, python-format +msgid "Failed to deserialize history DataSet kwarg %r." +msgstr "Échec de la désérialisation de l'argument DataSet de l'historique %r." + +#, python-format +msgid "Failed to deserialize history DataSet-list kwarg %r." +msgstr "Échec de la désérialisation de l'argument DataSet-list de l'historique %r." + +msgid "Session" +msgstr "Session" + msgid "Registered plugins:" msgstr "Plugins enregistrés :" @@ -3767,6 +3734,9 @@ msgstr "Afficher le tableau" msgid "Path" msgstr "Chemin" +msgid "Description" +msgstr "Description" + msgid "Textual preview" msgstr "Aperçu textuel" @@ -3785,6 +3755,24 @@ msgstr "Afficher uniquement les données prises en charge" msgid "Show values" msgstr "Afficher les valeurs" +msgid "Show details" +msgstr "Afficher les détails" + +msgid "Hide details" +msgstr "Masquer les détails" + +msgid "Date and time" +msgstr "Date et heure" + +msgid "Title" +msgstr "Titre" + +msgid "Action is compatible with the current workspace state." +msgstr "L'action est compatible avec l'état actuel de l'espace de travail." + +msgid "Action is not compatible with the current workspace state." +msgstr "L'action n'est pas compatible avec l'état actuel de l'espace de travail." + msgid "Image background selection" msgstr "Sélection de l'arrière-plan de l'image" @@ -4141,6 +4129,18 @@ msgstr "Merci de sélectionner le fichier à importer." msgid "Example Wizard" msgstr "Assistant exemple" +msgid "Signal" +msgstr "Signal" + +msgid "Shape" +msgstr "Forme" + +msgid "Image" +msgstr "Image" + +msgid "Dimensions" +msgstr "Dimensions" + msgid "Minimum value" msgstr "Valeur minimum" diff --git a/datalab/tests/features/common/auto_analysis_recompute_unit_test.py b/datalab/tests/features/common/auto_analysis_recompute_unit_test.py index 326cf66a8..7bf374acd 100644 --- a/datalab/tests/features/common/auto_analysis_recompute_unit_test.py +++ b/datalab/tests/features/common/auto_analysis_recompute_unit_test.py @@ -199,6 +199,10 @@ def test_analysis_recompute_after_recompute_1_to_1(): editor = panel.objprop.processing_param_editor editor.dataset.angle = 90.0 # Change from 45° to 90° + # In-place recompute + automatic analysis recompute only happens when + # the History panel is in edit mode (otherwise a new object is created). + win.historypanel.toggle_edit_mode(True) + # Apply the modified parameters (this triggers recompute_1_to_1) report = panel.objprop.apply_processing_parameters(interactive=False) diff --git a/datalab/tests/features/common/history_app_test.py b/datalab/tests/features/common/history_app_test.py index bf50d08e3..211b1282c 100644 --- a/datalab/tests/features/common/history_app_test.py +++ b/datalab/tests/features/common/history_app_test.py @@ -18,11 +18,13 @@ # pylint: disable=invalid-name # Allows short reference names like x, y, ... # guitest: show +import sigima.objects import sigima.params import sigima.proc.signal as sips from qtpy import QtCore as QC from sigima.tests.data import create_paracetamol_signal +from datalab.config import _ from datalab.env import execenv from datalab.gui.panel.history import HistoryAction, HistorySession, WorkspaceState from datalab.objectmodel import get_uuid @@ -34,6 +36,11 @@ def _entry_titles(history) -> list[str]: return [action.title for action in history] +def _session_action_counts(history) -> list[int]: + """Return the number of recorded actions in each history session.""" + return [len(session.actions) for session in history.history_sessions] + + def test_history_app(): """Run history application test scenario""" with datalab_test_app_context() as win: @@ -54,10 +61,11 @@ def test_history_app(): "Record mode is disabled: no entry should have been recorded" ) - # Reset workspace before starting the recorded scenario - # (this also clears the history panel) + # Reset workspace before starting the recorded scenario. + # No history exists yet, so this must not create an empty session. win.reset_all() assert len(history) == 0 + assert _session_action_counts(history) == [] # --- Enable record mode and start recording ---------------------------- history.toggle_record_mode(True) @@ -73,7 +81,7 @@ def test_history_app(): assert len(history) == 1 creation_entry = history[1] assert isinstance(creation_entry, HistoryAction) - assert creation_entry.title == "New signal" + assert creation_entry.title == _("New signal") assert creation_entry.state.selection == {} assert creation_entry.state.states == {} @@ -85,8 +93,8 @@ def test_history_app(): deriv_entry = history[2] assert deriv_entry.title # title must be non-empty # Workspace state must remember the single-object selection (by UUID) - assert deriv_entry.state.selection.get(panel.PANEL_STR) == [obj1_uuid] - assert len(deriv_entry.state.states.get(panel.PANEL_STR, [])) == 1 + assert deriv_entry.state.selection.get(panel.PANEL_STR_ID) == [obj1_uuid] + assert len(deriv_entry.state.states.get(panel.PANEL_STR_ID, [])) == 1 # 3) 1-to-1 with parameters (compute_1_to_1 + DataSet param) norm_param = sigima.params.NormalizeParam.create(method="maximum") @@ -103,7 +111,7 @@ def test_history_app(): panel.processor.run_feature(sips.fwhm, sigima.params.FWHMParam()) assert len(history) == 4 fwhm_entry = history[4] - assert fwhm_entry.state.selection.get(panel.PANEL_STR) == [obj1_uuid] + assert fwhm_entry.state.selection.get(panel.PANEL_STR_ID) == [obj1_uuid] # 5) n-to-1 aggregation (compute_n_to_1) on signals #1 and #2 obj2 = panel.objmodel.get_object_from_number(2) @@ -112,7 +120,7 @@ def test_history_app(): panel.processor.run_feature(sips.average) assert len(history) == 5 avg_entry = history[5] - assert sorted(avg_entry.state.selection.get(panel.PANEL_STR, [])) == sorted( + assert sorted(avg_entry.state.selection.get(panel.PANEL_STR_ID, [])) == sorted( [obj1_uuid, obj2_uuid] ) @@ -121,12 +129,12 @@ def test_history_app(): panel.processor.run_feature(sips.difference, obj2) assert len(history) == 6 diff_entry = history[6] - assert diff_entry.state.selection.get(panel.PANEL_STR) == [obj1_uuid] + assert diff_entry.state.selection.get(panel.PANEL_STR_ID) == [obj1_uuid] # --- Iteration / indexing API ------------------------------------------ all_titles = _entry_titles(history) assert len(all_titles) == 6 - assert all_titles[0] == "New signal" + assert all_titles[0] == _("New signal") # Indexing is 1-based; iteration order matches index order assert history[1] is creation_entry assert history[6] is diff_entry @@ -166,13 +174,12 @@ def test_history_app(): assert isinstance(action.dtstr, str) and action.dtstr # --- Delete cascade within a session ----------------------------------- - # ``delete_actions`` itself opens a confirmation dialog; we exercise the + # ``delete_selected`` itself opens a confirmation dialog; we exercise the # underlying ``HistorySession.remove_action`` path used by it. target = new_session_entry target_session: HistorySession | None = None - # pylint: disable=protected-access - for session in history._HistoryPanel__history_sessions: # noqa: SLF001 + for session in history.history_sessions: if target in session.actions: target_session = session break diff --git a/datalab/tests/features/common/history_contract_unit_test.py b/datalab/tests/features/common/history_contract_unit_test.py deleted file mode 100644 index d0731ccb6..000000000 --- a/datalab/tests/features/common/history_contract_unit_test.py +++ /dev/null @@ -1,214 +0,0 @@ -# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. - -""" -History panel contract tests - -Validates the core history-panel contract: - -1. ``compute_1_to_1`` produces a coherent ``ProcessingParameters`` *and* - history entry (same ``func_name``, same source UUID). -2. ``HistoryAction.replay`` finds its target by UUID even after the panel - has been reordered by inserting/deleting siblings. -3. ``recompute_processing`` does not add a history entry (anti-loop guard). -4. ``HistoryAction`` round-trips through HDF5 without pickling any - ``Callable`` and remains replayable after deserialisation. -5. A UI action (rename via ``set_current_object_title``) is captured and - replayable. -""" - -# pylint: disable=invalid-name - -# guitest: skip - -import os -import tempfile - -import sigima.params -import sigima.proc.signal as sips -from sigima.tests.data import create_paracetamol_signal - -from datalab.gui.panel.history import ( - HistoryAction, - HistorySession, - WorkspaceState, -) -from datalab.gui.processor.base import extract_processing_parameters -from datalab.h5.native import NativeH5Reader, NativeH5Writer -from datalab.objectmodel import get_uuid -from datalab.tests import datalab_test_app_context - - -def test_compute_1_to_1_history_matches_processing_parameters(): - """History entry and ProcessingParameters share func_name + source UUID.""" - with datalab_test_app_context() as win: - history = win.historypanel - history.toggle_record_mode(True) - panel = win.signalpanel - panel.add_object(create_paracetamol_signal()) - src_uuid = get_uuid(panel.objmodel.get_object_from_number(1)) - - panel.objview.select_objects([1]) - panel.processor.run_feature(sips.derivative) - - # Latest object holds ProcessingParameters. - result_obj = panel.objmodel.get_object_from_number(2) - pp = extract_processing_parameters(result_obj) - assert pp is not None - assert pp.func_name == "derivative" - assert pp.source_uuid == src_uuid - - # Latest history entry mirrors the same identity. - entry = history[len(history)] - assert entry.kind == HistoryAction.KIND_COMPUTE - assert entry.func_name == "derivative" - assert entry.pattern == "1_to_1" - assert entry.state.selection.get(panel.PANEL_STR) == [src_uuid] - - -def test_replay_finds_target_by_uuid_after_reorder(): - """Replay locates source object by UUID even after reordering.""" - with datalab_test_app_context() as win: - history = win.historypanel - history.toggle_record_mode(True) - panel = win.signalpanel - - panel.add_object(create_paracetamol_signal()) - target_uuid = get_uuid(panel.objmodel.get_object_from_number(1)) - - panel.objview.select_objects([1]) - panel.processor.run_feature(sips.derivative) - deriv_entry = history[len(history)] - - # Reorder the panel: insert a new sibling before the original source. - panel.add_object(create_paracetamol_signal()) - # Sanity: the original object now lives at a different index but its - # UUID is unchanged. - assert get_uuid(panel.objmodel[target_uuid]) == target_uuid - - n_before = len(panel.objmodel) - deriv_entry.replay(win, restore_selection=True, edit=False) - assert len(panel.objmodel) == n_before + 1 - - # The replayed result must be derived from the same source UUID. - new_obj = panel.objmodel.get_object_from_number(len(panel.objmodel)) - new_pp = extract_processing_parameters(new_obj) - assert new_pp is not None - assert new_pp.source_uuid == target_uuid - - -def test_recompute_processing_does_not_add_history_entry(): - """``recompute_processing`` is silent on the history panel (anti-loop).""" - with datalab_test_app_context() as win: - history = win.historypanel - history.toggle_record_mode(True) - panel = win.signalpanel - panel.add_object(create_paracetamol_signal()) - - panel.objview.select_objects([1]) - panel.processor.run_feature(sips.derivative) - n_before = len(history) - - # Select the derived object and recompute it: no new entry expected. - derived = panel.objmodel.get_object_from_number(2) - panel.objview.set_current_object(derived) - panel.recompute_processing() - - assert len(history) == n_before, ( - "recompute_processing must not register a history entry" - ) - - -def test_history_action_hdf5_roundtrip_without_pickle(): - """Serialise+deserialise a HistoryAction; replay still works.""" - with datalab_test_app_context() as win: - history = win.historypanel - history.toggle_record_mode(True) - panel = win.signalpanel - panel.add_object(create_paracetamol_signal()) - src_uuid = get_uuid(panel.objmodel.get_object_from_number(1)) - - norm_param = sigima.params.NormalizeParam.create(method="maximum") - panel.objview.select_objects([1]) - panel.processor.run_feature(sips.normalize, norm_param) - original = history[len(history)] - - # Wrap into a session so we exercise the full HDF5 path used by - # the panel (write_object_list / read_object_list). - session = HistorySession(number=1) - session.actions.append(original) - - with tempfile.TemporaryDirectory() as tmpdir: - path = os.path.join(tmpdir, "history.dlhist") - with NativeH5Writer(path) as writer: - writer.write_object_list([session], "history_session") - with NativeH5Reader(path) as reader: - restored_sessions = reader.read_object_list( - "history_session", HistorySession - ) - - assert len(restored_sessions) == 1 - restored = restored_sessions[0].actions[0] - - # No pickled Callable: the rebuilt HistoryAction has no ``func`` attr. - assert not hasattr(restored, "func") - assert restored.kind == HistoryAction.KIND_COMPUTE - assert restored.func_name == "normalize" - assert restored.pattern == "1_to_1" - assert restored.panel_str == panel.PANEL_STR_ID - # DataSet kwarg survived the JSON round-trip. - restored_param = restored.kwargs.get("param") - assert restored_param is not None - assert type(restored_param).__name__ == type(norm_param).__name__ - - # Replay the restored entry against the live workspace. - n_before = len(panel.objmodel) - restored.replay(win, restore_selection=True, edit=False) - assert len(panel.objmodel) == n_before + 1 - new_obj = panel.objmodel.get_object_from_number(len(panel.objmodel)) - new_pp = extract_processing_parameters(new_obj) - assert new_pp is not None - assert new_pp.source_uuid == src_uuid - assert new_pp.func_name == "normalize" - - -def test_ui_action_rename_capture_and_replay(): - """A UI rename is captured and can be replayed.""" - with datalab_test_app_context() as win: - history = win.historypanel - history.toggle_record_mode(True) - panel = win.signalpanel - panel.add_object(create_paracetamol_signal()) - - obj = panel.objmodel.get_object_from_number(1) - original_title = obj.title - - new_title = "renamed-by-test" - panel.objview.set_current_object(obj) - panel.set_current_object_title(new_title) - assert obj.title == new_title - - rename_entry = history[len(history)] - assert rename_entry.kind == HistoryAction.KIND_UI - assert rename_entry.target == "signalpanel" - assert rename_entry.method_name == "set_current_object_title" - assert rename_entry.kwargs.get("title") == new_title - - # Mutate the title to something else, then replay: the entry must - # restore the recorded value. - panel.set_current_object_title("transient-title") - assert obj.title == "transient-title" - - rename_entry.replay(win, restore_selection=False, edit=False) - assert obj.title == new_title - assert obj.title != original_title - - # WorkspaceState type check. - assert isinstance(rename_entry.state, WorkspaceState) - - -if __name__ == "__main__": - test_compute_1_to_1_history_matches_processing_parameters() - test_replay_finds_target_by_uuid_after_reorder() - test_recompute_processing_does_not_add_history_entry() - test_history_action_hdf5_roundtrip_without_pickle() - test_ui_action_rename_capture_and_replay() diff --git a/datalab/tests/features/common/history_replay_app_test.py b/datalab/tests/features/common/history_replay_app_test.py deleted file mode 100644 index 6b349d43b..000000000 --- a/datalab/tests/features/common/history_replay_app_test.py +++ /dev/null @@ -1,448 +0,0 @@ -# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. - -""" -History panel replay & persistence tests - -Covers replay/restore edge cases and HDF5 persistence paths: - -1. Workspace ``.h5`` round-trip embeds the history panel and survives - save → reset → reload (``MainWindow.save_h5_workspace`` / - ``load_h5_workspace``). -2. Standalone ``HistoryPanel.serialize_to_hdf5`` / - ``deserialize_from_hdf5`` round-trip on a populated panel (mirrors - the ``.dlhist`` save/open menu paths). -3. ``1_to_n`` replay (extract ROI) reproduces the same number of - children and same source UUID. -4. ``multiple_1_to_1`` replay surfaces a clear ``NotImplementedError``. -5. Replay of a ``2_to_1`` action whose secondary source UUID has been - deleted raises ``ValueError`` instead of silently failing. -6. Public panel API ``replay_restore_actions(replay=True)`` produces - a new object when a tree item is selected. -7. Public panel API ``replay_restore_actions(replay=False)`` only - restores the workspace selection (no new object). -8. ``n_to_1`` replay forces the captured selection so a chained - ``[New, ..., New, average]`` session does not aggregate over a - stale single-object selection. -9. ``n_to_1`` replay falls back to the current selection when the - captured UUIDs no longer exist (typical of a full-session replay - in a fresh workspace), so the failure surfaces as the native - processor error rather than an opaque ``WorkspaceState`` - incompatibility. -10. Full ``HistorySession`` replay on an empty workspace correctly - re-runs ``[New, New, New, average]`` end-to-end: the per-session - UUID remap translates the captured ``n_to_1`` selection to the - freshly-created signals. -11. Full ``HistorySession`` replay with an intermediate - ``[New, New, New, Remove, average]`` correctly drops the - vanished UUID from the remap queue so ``average`` aggregates - over the right two surviving signals. -""" - -# pylint: disable=invalid-name - -# guitest: skip - -import os - -import pytest -import sigima.objects -import sigima.params -import sigima.proc.signal as sips -from qtpy import QtCore as QC -from sigima.objects.signal.creation import NewSignalParam -from sigima.tests import helpers -from sigima.tests.data import create_paracetamol_signal - -from datalab.gui.panel.history import HistoryAction, WorkspaceState -from datalab.h5.native import NativeH5Reader, NativeH5Writer -from datalab.objectmodel import get_uuid -from datalab.tests import datalab_test_app_context - - -def _select_tree_item_for(history, entry: HistoryAction) -> None: - """Select the tree item matching ``entry`` in the history tree.""" - tree = history.tree - for i in range(tree.topLevelItemCount()): - sess_item = tree.topLevelItem(i) - for j in range(sess_item.childCount()): - child = sess_item.child(j) - if child.data(0, QC.Qt.UserRole) == entry.uuid: - tree.clearSelection() - tree.setCurrentItem(child) - child.setSelected(True) - return - raise AssertionError(f"No tree item found for entry {entry.uuid}") - - -# --- 1) Workspace .h5 round-trip ------------------------------------------ - - -def test_workspace_h5_roundtrip_with_history(): - """Saving + reloading the workspace ``.h5`` preserves the history panel.""" - with datalab_test_app_context() as win: - history = win.historypanel - history.toggle_record_mode(True) - panel = win.signalpanel - panel.add_object(create_paracetamol_signal()) - - norm_param = sigima.params.NormalizeParam.create(method="maximum") - panel.objview.select_objects([1]) - panel.processor.run_feature(sips.normalize, norm_param) - - recorded_titles = [a.title for a in history] - recorded_func_names = [a.func_name for a in history] - recorded_kinds = [a.kind for a in history] - n_recorded = len(history) - assert n_recorded >= 1 - - with helpers.WorkdirRestoringTempDir() as tmpdir: - path = os.path.join(tmpdir, "workspace.h5") - win.save_h5_workspace(path) - - # Wipe everything (objects + history) then reload. - win.reset_all() - assert len(panel.objmodel) == 0 - - win.load_h5_workspace([path], reset_all=True) - - # Note: ``save_h5_workspace`` itself records a UI history entry - # ("Save to HDF5 file") *before* writing — that entry is part of - # what gets saved. The reloaded history must therefore contain - # at least the originally-recorded actions. - reloaded = win.historypanel - reloaded_titles = [a.title for a in reloaded] - for title in recorded_titles: - assert title in reloaded_titles, ( - f"Title {title!r} missing from reloaded history" - ) - for func_name in recorded_func_names: - if func_name is not None: - assert func_name in [a.func_name for a in reloaded] - for kind in recorded_kinds: - assert kind in [a.kind for a in reloaded] - - -# --- 2) Standalone .dlhist round-trip on full panel ----------------------- - - -def test_history_panel_dlhist_roundtrip(): - """``HistoryPanel.serialize_to_hdf5`` / ``deserialize_from_hdf5`` round-trip.""" - with datalab_test_app_context() as win: - history = win.historypanel - history.toggle_record_mode(True) - panel = win.signalpanel - panel.add_object(create_paracetamol_signal()) - panel.objview.select_objects([1]) - panel.processor.run_feature(sips.derivative) - panel.objview.select_objects([1]) - panel.processor.run_feature( - sips.normalize, sigima.params.NormalizeParam.create(method="maximum") - ) - - original_titles = [a.title for a in history] - original_func_names = [a.func_name for a in history] - assert len(original_titles) >= 2 - - with helpers.WorkdirRestoringTempDir() as tmpdir: - path = os.path.join(tmpdir, "history_panel.dlhist") - with NativeH5Writer(path) as writer: - history.serialize_to_hdf5(writer) - with NativeH5Reader(path) as reader: - history.deserialize_from_hdf5(reader) - - assert [a.title for a in history] == original_titles - assert [a.func_name for a in history] == original_func_names - - -# --- 3) 1-to-n replay (extract ROI) --------------------------------------- - - -def test_replay_1_to_n_extract_roi(): - """``extract_roi`` (1-to-n) is recorded and replayable.""" - with datalab_test_app_context() as win: - history = win.historypanel - history.toggle_record_mode(True) - panel = win.signalpanel - - sig = create_paracetamol_signal() - sig.roi = sigima.objects.create_signal_roi([[26, 41], [125, 146]], indices=True) - panel.add_object(sig) - src_uuid = get_uuid(panel.objmodel.get_object_from_number(1)) - - n_objects_before = len(panel.objmodel) - panel.objview.select_objects([1]) - panel.processor.run_feature("extract_roi", params=sig.roi.to_params(sig)) - n_added_first = len(panel.objmodel) - n_objects_before - assert n_added_first >= 1 - - entry = history[len(history)] - assert entry.kind == HistoryAction.KIND_COMPUTE - assert entry.pattern == "1_to_n" - assert entry.func_name == "extract_roi" - assert entry.state.selection.get(panel.PANEL_STR) == [src_uuid] - - # Replay against the original source. - n_before_replay = len(panel.objmodel) - entry.replay(win, restore_selection=True, edit=False) - n_added_replay = len(panel.objmodel) - n_before_replay - assert n_added_replay == n_added_first, ( - "Replay must produce the same number of children as the original" - ) - - -# --- 4) multiple_1_to_1 NotImplementedError -------------------------------- - - -def test_multiple_1_to_1_replay_raises_not_implemented(): - """``multiple_1_to_1`` replay raises a clear ``NotImplementedError``.""" - with datalab_test_app_context() as win: - action = HistoryAction( - title="dummy multiple_1_to_1", - kind=HistoryAction.KIND_COMPUTE, - panel_str="signal", - func_name="some_compound_op", - pattern="multiple_1_to_1", - state=WorkspaceState(), - ) - with pytest.raises(NotImplementedError): - action.replay(win, restore_selection=False, edit=False) - - -# --- 5) 2-to-1 replay with vanished secondary source ----------------------- - - -def test_replay_2_to_1_with_vanished_obj2_raises(): - """Replaying a 2-to-1 action whose obj2 was deleted raises ``ValueError``.""" - with datalab_test_app_context() as win: - history = win.historypanel - history.toggle_record_mode(True) - panel = win.signalpanel - - panel.add_object(create_paracetamol_signal()) - panel.add_object(create_paracetamol_signal()) - obj2 = panel.objmodel.get_object_from_number(2) - - panel.objview.select_objects([1]) - panel.processor.run_feature(sips.difference, obj2) - diff_entry = history[len(history)] - assert diff_entry.pattern == "2_to_1" - - # Delete obj2 so its UUID is no longer resolvable. - panel.objview.set_current_object(obj2) - panel.remove_object(force=True) - - with pytest.raises(ValueError): - # restore_selection=False so we hit the obj2 lookup path, - # not the WorkspaceState compatibility check. - diff_entry.replay(win, restore_selection=False, edit=False) - - -# --- 6) Public API replay_restore_actions(replay=True) --------------------- - - -def test_replay_via_panel_api_creates_new_object(): - """``HistoryPanel.replay_restore_actions`` replays the selected entry.""" - with datalab_test_app_context() as win: - history = win.historypanel - history.toggle_record_mode(True) - panel = win.signalpanel - panel.add_object(create_paracetamol_signal()) - panel.objview.select_objects([1]) - panel.processor.run_feature(sips.derivative) - deriv_entry = history[len(history)] - - _select_tree_item_for(history, deriv_entry) - - n_before = len(panel.objmodel) - history.replay_restore_actions(replay=True, restore_selection=True) - assert len(panel.objmodel) == n_before + 1 - - -# --- 7) Public API replay_restore_actions(replay=False) ------------------- - - -def test_restore_selection_only_via_panel_api(): - """``replay_restore_actions(replay=False)`` only restores selection.""" - with datalab_test_app_context() as win: - history = win.historypanel - history.toggle_record_mode(True) - panel = win.signalpanel - panel.add_object(create_paracetamol_signal()) - src_uuid = get_uuid(panel.objmodel.get_object_from_number(1)) - - panel.objview.select_objects([1]) - panel.processor.run_feature(sips.derivative) - deriv_entry = history[len(history)] - - # Move selection elsewhere (the freshly-added derivative). - derived = panel.objmodel.get_object_from_number(2) - panel.objview.set_current_object(derived) - derived_uuid = get_uuid(derived) - assert panel.objview.get_sel_object_uuids() == [derived_uuid] - - _select_tree_item_for(history, deriv_entry) - - n_before = len(panel.objmodel) - history.replay_restore_actions(replay=False, restore_selection=True) - # No new object created. - assert len(panel.objmodel) == n_before - # Selection restored to the originally-recorded source. - assert panel.objview.get_sel_object_uuids() == [src_uuid] - - -# --- 8) n_to_1 replay ignores restore_selection=False -------------------- - - -def test_replay_n_to_1_forces_captured_selection(): - """``n_to_1`` replay must restore the captured multi-object selection - even when ``restore_selection=False`` (otherwise an aggregator such as - ``average`` would be applied to the single object the previous action - left selected and fail with ``src_list must be a list of at least 2 - objects``).""" - with datalab_test_app_context() as win: - history = win.historypanel - history.toggle_record_mode(True) - panel = win.signalpanel - - panel.add_object(create_paracetamol_signal()) - panel.add_object(create_paracetamol_signal()) - panel.add_object(create_paracetamol_signal()) - - # Average over the three signals (n_to_1 aggregator). - panel.objview.select_objects([1, 2, 3]) - panel.processor.run_feature(sips.average) - avg_entry = history[len(history)] - assert avg_entry.pattern == "n_to_1" - - # Drift selection to a single object (mimics the state left by a - # preceding "New signal" UI action in a chained session replay). - panel.objview.select_objects([1]) - assert len(panel.objview.get_sel_object_uuids()) == 1 - - n_before = len(panel.objmodel) - # restore_selection=False on purpose: compute actions must still - # restore their captured selection internally. - avg_entry.replay(win, restore_selection=False, edit=False) - assert len(panel.objmodel) == n_before + 1 - - -# --- 9) n_to_1 replay tolerates vanished captured UUIDs ------------------- - - -def test_replay_n_to_1_falls_back_when_captured_uuids_gone(): - """When the captured selection refers to UUIDs that no longer exist - (full-session replay creates fresh objects with new UUIDs), the - compute replay must fall back to the current selection rather than - raising the opaque ``WorkspaceState`` incompatibility error.""" - with datalab_test_app_context() as win: - history = win.historypanel - history.toggle_record_mode(True) - panel = win.signalpanel - - panel.add_object(create_paracetamol_signal()) - panel.add_object(create_paracetamol_signal()) - panel.add_object(create_paracetamol_signal()) - panel.objview.select_objects([1, 2, 3]) - panel.processor.run_feature(sips.average) - avg_entry = history[len(history)] - assert avg_entry.pattern == "n_to_1" - - # Wipe the workspace and recreate three signals: same shape, - # different UUIDs (mimics the state mid-way through a full - # session replay). The captured selection is now stale. - win.reset_all() - panel.add_object(create_paracetamol_signal()) - panel.add_object(create_paracetamol_signal()) - panel.add_object(create_paracetamol_signal()) - panel.objview.select_objects([1, 2, 3]) - assert not avg_entry.state.is_current_state_compatible(win, False) - - n_before = len(panel.objmodel) - # Must not raise ValueError("... not compatible with saved state"); - # falls back to the current selection (the 3 freshly-added signals). - avg_entry.replay(win, restore_selection=False, edit=False) - assert len(panel.objmodel) == n_before + 1 - - -# --- 10) Full session replay with UUID remap ------------------------------ - - -def test_full_session_replay_remaps_uuids_for_n_to_1(): - """A full ``HistorySession.replay`` on an empty workspace correctly - re-runs ``[New signal, New signal, New signal, average]``: the - per-session UUID remap translates ``average``'s captured selection - to the freshly-created signals, instead of aggregating over the - single object the last ``New signal`` left selected.""" - with datalab_test_app_context() as win: - history = win.historypanel - history.toggle_record_mode(True) - panel = win.signalpanel - - # Record [New, New, New, average]. - for _i in range(3): - panel.new_object(param=NewSignalParam(), edit=False) - n_after_creations = len(panel.objmodel) - assert n_after_creations == 3 - panel.objview.select_objects([1, 2, 3]) - panel.processor.run_feature(sips.average) - assert len(panel.objmodel) == 4 - - # pylint: disable=protected-access - session = history._HistoryPanel__history_sessions[-1] # noqa: SLF001 - - # Reset to an empty workspace, then replay the whole session. - win.reset_all() - assert len(panel.objmodel) == 0 - session.replay(win, restore_selection=False, edit=False) - # 3 fresh signals + 1 average = 4 objects. - assert len(panel.objmodel) == 4 - - -# --- 11) Full session replay with intermediate removal -------------------- - - -def test_full_session_replay_with_intermediate_removal(): - """A full ``HistorySession.replay`` with ``[New, New, New, Remove, - average]`` correctly drops the removed UUID from the unclaimed - queue and the reverse remap, so ``average`` aggregates over the - two surviving signals (and not, e.g., over a stale single-object - selection or a removed one).""" - with datalab_test_app_context() as win: - history = win.historypanel - history.toggle_record_mode(True) - panel = win.signalpanel - - # Record [New, New, New, Remove #3, average of remaining 2]. - for _i in range(3): - panel.new_object(param=NewSignalParam(), edit=False) - assert len(panel.objmodel) == 3 - panel.objview.select_objects([3]) - panel.remove_object(force=True) - assert len(panel.objmodel) == 2 - panel.objview.select_objects([1, 2]) - panel.processor.run_feature(sips.average) - assert len(panel.objmodel) == 3 - - # pylint: disable=protected-access - session = history._HistoryPanel__history_sessions[-1] # noqa: SLF001 - - # Reset to an empty workspace, then replay the whole session. - win.reset_all() - assert len(panel.objmodel) == 0 - session.replay(win, restore_selection=False, edit=False) - # 3 created − 1 removed + 1 average = 3 objects. - assert len(panel.objmodel) == 3 - - -if __name__ == "__main__": - test_workspace_h5_roundtrip_with_history() - test_history_panel_dlhist_roundtrip() - test_replay_1_to_n_extract_roi() - test_multiple_1_to_1_replay_raises_not_implemented() - test_replay_2_to_1_with_vanished_obj2_raises() - test_replay_via_panel_api_creates_new_object() - test_restore_selection_only_via_panel_api() - test_replay_n_to_1_forces_captured_selection() - test_replay_n_to_1_falls_back_when_captured_uuids_gone() - test_full_session_replay_remaps_uuids_for_n_to_1() - test_full_session_replay_with_intermediate_removal() diff --git a/datalab/tests/features/common/history_test.py b/datalab/tests/features/common/history_test.py new file mode 100644 index 000000000..6d03bf29a --- /dev/null +++ b/datalab/tests/features/common/history_test.py @@ -0,0 +1,1508 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +""" +History panel — grouped exhaustive tests. + +Each ``test_history_*`` function bundles several closely-related scenarios +that previously lived in dedicated tests across: + +* ``history_contract_unit_test.py`` (schema, compatibility, capture/replay + of UI actions, ROI clipboard, HDF5 round-trips, etc.) +* ``history_replay_app_test.py`` (replay patterns, session replay, + duplication, stepping, tree selection, cascade, dlhist persistence, + chain reconnection) +* ``history_app_test.py::test_history_reset_starts_new_session`` + +Each scenario is delimited by a ``# --- scenario:---`` comment. +Scenarios sharing GUI state run inside a single ``datalab_test_app_context`` +block; truly independent pure-Python scenarios (HDF5 round-trip, +``NotImplementedError`` smoke) run outside or in a nested block. + +Two GUI smoke tests are intentionally kept in their own modules: +``history_app_test.py::test_history_app`` and +``history_panel_app_test.py::test_history_panel``. +""" + +# guitest: skip + +import os +import shutil +import tempfile + +import numpy as np +import pytest +import sigima.objects +import sigima.params +import sigima.proc.signal as sips +from qtpy import QtCore as QC +from sigima.objects import create_signal_roi +from sigima.objects.base import BaseROI +from sigima.objects.signal.creation import NewSignalParam +from sigima.tests import helpers +from sigima.tests.data import create_paracetamol_signal + +from datalab.config import _ +from datalab.gui.panel.base import AddMetadataParam, BaseDataPanel +from datalab.gui.panel.history import ( + HISTORY_ACTION_SCHEMA_VERSION, + HISTORY_SCHEMA_VERSION, + HistoryAction, + HistorySession, + HistoryTree, + WorkspaceState, +) +from datalab.gui.processor.base import extract_processing_parameters +from datalab.h5.native import NativeH5Reader, NativeH5Writer +from datalab.objectmodel import get_uuid +from datalab.tests import datalab_test_app_context + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _delete_hdf5_items_by_name(group, item_name: str) -> None: + """Delete HDF5 attributes/groups named ``item_name`` recursively.""" + if item_name in group.attrs: + del group.attrs[item_name] + if not hasattr(group, "keys"): + return + for key in list(group.keys()): + if key == item_name: + del group[key] + else: + _delete_hdf5_items_by_name(group[key], item_name) + + +def _create_serializable_history_session() -> HistorySession: + """Create a history session requiring no application startup.""" + state = WorkspaceState() + state.selection = {"signal": ["source-uuid"]} + state.states = {"signal": ["(10,)"]} + state.titles = {"signal": ["source"]} + state.object_metadata = {"signal": {"source-uuid": {"shape": [10], "ndim": 1}}} + action = HistoryAction( + title="Rename", + kind=HistoryAction.KIND_UI, + target="signalpanel", + method_name="set_current_object_title", + kwargs={"title": "renamed"}, + state=state, + ) + session = HistorySession(number=1) + session.add_action(action) + return session + + +def _session_action_counts(history) -> list[int]: + """Return the number of recorded actions in each history session.""" + return [len(session.actions) for session in history.history_sessions] + + +def _get_tree_item_for(history, entry: HistoryAction): + """Return the tree item matching ``entry`` in the history tree.""" + tree = history.tree + for i in range(tree.topLevelItemCount()): + sess_item = tree.topLevelItem(i) + for j in range(sess_item.childCount()): + child = sess_item.child(j) + if child.data(0, QC.Qt.UserRole) == entry.uuid: + return child + raise AssertionError(f"No tree item found for entry {entry.uuid}") + + +def _select_tree_item_for(history, entry: HistoryAction) -> None: + """Select the tree item matching ``entry`` in the history tree.""" + child = _get_tree_item_for(history, entry) + history.tree.clearSelection() + history.tree.setCurrentItem(child) + child.setSelected(True) + + +def _select_tree_session(history, session) -> None: + """Select the tree item matching ``session`` in the history tree.""" + sessions = history.history_sessions + index = sessions.index(session) + item = history.tree.topLevelItem(index) + history.tree.clearSelection() + history.tree.setCurrentItem(item) + item.setSelected(True) + + +def _record_three_action_session(win): + """Helper: record [add_signal + normalize + derivative] in one session.""" + history = win.historypanel + history.toggle_record_mode(True) + panel = win.signalpanel + panel.add_object(create_paracetamol_signal()) + panel.objview.select_objects([1]) + panel.processor.run_feature( + sips.normalize, sigima.params.NormalizeParam.create(method="maximum") + ) + panel.objview.select_objects([2]) + panel.processor.run_feature(sips.derivative) + return panel, history + + +def _build_cascade_chain(panel, history): + """Build chain s001 -> gaussian -> s002 -> derivative -> s003 -> mavg -> s004. + + Returns ``(action_A, action_B, action_C, output_B, output_C)``. + """ + panel.add_object(create_paracetamol_signal()) + panel.objview.select_objects([1]) + panel.processor.run_feature( + sips.gaussian_filter, sigima.params.GaussianParam.create(sigma=1.5) + ) + action_A = history[len(history)] + + panel.objview.select_objects([2]) + panel.processor.run_feature(sips.derivative) + action_B = history[len(history)] + output_B = panel.objmodel.get_object_from_number(3) + + panel.objview.select_objects([3]) + mavg = sigima.params.MovingAverageParam() + mavg.n = 3 + panel.processor.run_feature(sips.moving_average, mavg) + action_C = history[len(history)] + output_C = panel.objmodel.get_object_from_number(4) + return action_A, action_B, action_C, output_B, output_C + + +# --------------------------------------------------------------------------- +# 1) Schema + HDF5 round-trips +# --------------------------------------------------------------------------- + + +def test_history_schema_and_hdf5_roundtrip(): + """Schema persistence + HDF5 round-trip variants (.dlhist and .h5).""" + # --- scenario: schema_version is persisted --- + session = _create_serializable_history_session() + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "history_schema.dlhist") + with NativeH5Writer(path) as writer: + writer.write_object_list([session], "history_session") + with NativeH5Reader(path) as reader: + restored_sessions = reader.read_object_list( + "history_session", HistorySession + ) + assert len(restored_sessions) == 1 + restored = restored_sessions[0] + assert restored.schema_version == HISTORY_SCHEMA_VERSION + assert restored.actions[0].schema_version == HISTORY_ACTION_SCHEMA_VERSION + assert restored.actions[0].kwargs == {"title": "renamed"} + assert restored.actions[0].state.object_metadata == { + "signal": {"source-uuid": {"shape": [10], "ndim": 1}} + } + + # --- scenario: missing schema_version defaults to current --- + session = _create_serializable_history_session() + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "history_schema_missing.dlhist") + with NativeH5Writer(path) as writer: + writer.write_object_list([session], "history_session") + _delete_hdf5_items_by_name(writer.h5, "schema_version") + with NativeH5Reader(path) as reader: + restored_sessions = reader.read_object_list( + "history_session", HistorySession + ) + restored_action = restored_sessions[0].actions[0] + assert restored_sessions[0].schema_version == HISTORY_SCHEMA_VERSION + assert restored_action.schema_version == HISTORY_SCHEMA_VERSION + assert restored_action.title == "Rename" + + # --- scenario: ROI kwargs survive HDF5 round-trip --- + roi = create_signal_roi([[26, 41]], indices=True) + state = WorkspaceState() + state.selection = {"signal": ["dst-uuid"]} + state.states = {"signal": ["(100,)"]} + state.titles = {"signal": ["dst"]} + state.object_metadata = {"signal": {"dst-uuid": {"shape": [100], "ndim": 1}}} + action = HistoryAction( + title="Paste ROI", + kind=HistoryAction.KIND_UI, + target="signalpanel", + method_name="paste_roi", + kwargs={"roi_data": roi}, + state=state, + ) + session = HistorySession(number=1) + session.add_action(action) + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "roi_roundtrip.dlhist") + with NativeH5Writer(path) as writer: + writer.write_object_list([session], "history_session") + with NativeH5Reader(path) as reader: + restored_sessions = reader.read_object_list( + "history_session", HistorySession + ) + restored_roi = restored_sessions[0].actions[0].kwargs.get("roi_data") + assert restored_roi is not None + assert isinstance(restored_roi, BaseROI) + assert restored_roi.get_single_roi(0).coords.tolist() == [26, 41] + + # --- scenario: legacy translated panel keys are normalized --- + state = WorkspaceState() + state.selection = {"Signal Panel": ["uuid-1"]} + state.states = {"Signal Panel": ["(10,)"]} + state.titles = {"Signal Panel": ["obj1"]} + state.object_metadata = {"Signal Panel": {"uuid-1": {"shape": [10], "ndim": 1}}} + legacy_action = HistoryAction( + title="Legacy", + kind=HistoryAction.KIND_UI, + target="signalpanel", + method_name="set_current_object_title", + kwargs={"title": "renamed"}, + state=state, + ) + legacy_session = HistorySession(number=1) + legacy_session.add_action(legacy_action) + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "legacy_keys.dlhist") + with NativeH5Writer(path) as writer: + writer.write_object_list([legacy_session], "history_session") + with NativeH5Reader(path) as reader: + restored_sessions = reader.read_object_list( + "history_session", HistorySession + ) + rstate = restored_sessions[0].actions[0].state + assert "signal" in rstate.selection and "Signal Panel" not in rstate.selection + assert rstate.selection["signal"] == ["uuid-1"] + assert rstate.states["signal"] == ["(10,)"] + assert rstate.titles["signal"] == ["obj1"] + assert rstate.object_metadata["signal"] == {"uuid-1": {"shape": [10], "ndim": 1}} + + # GUI-bound HDF5 scenarios run inside one app context. + with datalab_test_app_context() as win: + history = win.historypanel + panel = win.signalpanel + + # --- scenario: deserialize from .h5 without history group does not raise --- + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "no_history.h5") + with NativeH5Writer(path) as writer: + panel.serialize_to_hdf5(writer) + with NativeH5Reader(path) as reader: + history.deserialize_from_hdf5(reader) + assert len(history) == 0 + + # --- scenario: HistoryAction HDF5 round-trip without pickle, then replay --- + history.toggle_record_mode(True) + panel.add_object(create_paracetamol_signal()) + src_uuid = get_uuid(panel.objmodel.get_object_from_number(1)) + norm_param = sigima.params.NormalizeParam.create(method="maximum") + panel.objview.select_objects([1]) + panel.processor.run_feature(sips.normalize, norm_param) + original = history[len(history)] + ser_session = HistorySession(number=1) + ser_session.actions.append(original) + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "history.dlhist") + with NativeH5Writer(path) as writer: + writer.write_object_list([ser_session], "history_session") + with NativeH5Reader(path) as reader: + restored_sessions = reader.read_object_list( + "history_session", HistorySession + ) + restored = restored_sessions[0].actions[0] + assert not hasattr(restored, "func") + assert restored.kind == HistoryAction.KIND_COMPUTE + assert restored.func_name == "normalize" + assert restored.pattern == "1_to_1" + assert restored.panel_str == panel.PANEL_STR_ID + restored_param = restored.kwargs.get("param") + assert restored_param is not None + assert type(restored_param).__name__ == type(norm_param).__name__ + n_before = len(panel.objmodel) + restored.replay(win, restore_selection=True, edit=False) + assert len(panel.objmodel) == n_before + 1 + new_obj = panel.objmodel.get_object_from_number(len(panel.objmodel)) + new_pp = extract_processing_parameters(new_obj) + assert new_pp is not None + assert new_pp.source_uuid == src_uuid + assert new_pp.func_name == "normalize" + + # --- scenario: workspace .h5 round-trip embeds history --- + recorded_titles = [a.title for a in history] + recorded_func_names = [a.func_name for a in history] + recorded_kinds = [a.kind for a in history] + assert len(history) >= 1 + with helpers.WorkdirRestoringTempDir() as tmpdir: + path = os.path.join(tmpdir, "workspace.h5") + win.save_h5_workspace(path) + win.reset_all() + assert len(panel.objmodel) == 0 + win.load_h5_workspace([path], reset_all=True) + reloaded = win.historypanel + reloaded_titles = [a.title for a in reloaded] + for title in recorded_titles: + assert title in reloaded_titles + for func_name in recorded_func_names: + if func_name is not None: + assert func_name in [a.func_name for a in reloaded] + for kind in recorded_kinds: + assert kind in [a.kind for a in reloaded] + + # --- scenario: standalone .dlhist round-trip (import path on non-empty WS) --- + win.reset_all() + history = win.historypanel + panel = win.signalpanel + history.toggle_record_mode(True) + panel.add_object(create_paracetamol_signal()) + panel.objview.select_objects([1]) + panel.processor.run_feature(sips.derivative) + panel.objview.select_objects([1]) + panel.processor.run_feature( + sips.normalize, sigima.params.NormalizeParam.create(method="maximum") + ) + original_titles = [a.title for a in history] + original_func_names = [a.func_name for a in history] + assert len(original_titles) >= 2 + n_actions_before = len(history) + n_signals_before = len(panel.objmodel) + n_sessions_before = len(history.history_sessions) + with helpers.WorkdirRestoringTempDir() as tmpdir: + path = os.path.join(tmpdir, "history_panel.dlhist") + assert history.save_to_dlhist_file(path) + assert history.open_dlhist_file(path) + reloaded_titles = [a.title for a in history] + reloaded_func_names = [a.func_name for a in history] + for title in original_titles: + assert title in reloaded_titles + for func_name in original_func_names: + if func_name is not None: + assert func_name in reloaded_func_names + assert len(history.history_sessions) > n_sessions_before + assert len(panel.objmodel) > n_signals_before + assert len(history) > n_actions_before + + # --- scenario: .dlhist self-contained — direct-load into fresh empty workspace --- + tmpdir = tempfile.mkdtemp() + try: + path = os.path.join(tmpdir, "test.dlhist") + with datalab_test_app_context() as win: + history = win.historypanel + history.toggle_record_mode(True) + panel = win.signalpanel + panel.add_object(create_paracetamol_signal()) + panel.objview.select_objects([1]) + panel.processor.run_feature(sips.derivative) + original_titles = [a.title for a in history] + original_func_names = [a.func_name for a in history] + assert len(original_titles) >= 1 + assert history.save_to_dlhist_file(path) + with datalab_test_app_context() as win2: + history2 = win2.historypanel + panel2 = win2.signalpanel + assert len(panel2.objmodel) == 0 + assert len(history2.history_sessions) == 0 + assert history2.open_dlhist_file(path) + reloaded_titles = [a.title for a in history2] + reloaded_func_names = [a.func_name for a in history2] + assert reloaded_titles == original_titles + for func_name in original_func_names: + if func_name is not None: + assert func_name in reloaded_func_names + assert len(panel2.objmodel) >= 1 + finally: + shutil.rmtree(tmpdir, ignore_errors=True) + + +# --------------------------------------------------------------------------- +# 2) HistoryAction / WorkspaceState compatibility +# --------------------------------------------------------------------------- + + +def test_history_action_compatibility(): + """``HistoryAction`` compatibility (UUID/shape/legacy fallback + tree marker).""" + # --- scenario: incompatible when selected UUID disappears --- + with datalab_test_app_context() as win: + panel = win.signalpanel + panel.add_object(create_paracetamol_signal()) + obj = panel.objmodel.get_object_from_number(1) + panel.objview.set_current_object(obj) + state = WorkspaceState() + state.save(win) + action = HistoryAction(title="Dummy", state=state) + assert action.is_current_state_compatible(win, restore_selection=False) + panel.remove_object(force=True) + assert not action.is_current_state_compatible(win, restore_selection=False) + + # --- scenario: incompatible when selected object shape changes --- + win.reset_all() + panel.add_object(create_paracetamol_signal()) + obj = panel.objmodel.get_object_from_number(1) + panel.objview.set_current_object(obj) + state = WorkspaceState() + state.save(win) + action = HistoryAction(title="Dummy", state=state) + assert action.is_current_state_compatible(win, restore_selection=False) + obj.set_xydata(obj.x[:-1], obj.y[:-1]) + assert not action.is_current_state_compatible(win, restore_selection=False) + + # --- scenario: histories without object_metadata fall back to UUID check --- + win.reset_all() + panel.add_object(create_paracetamol_signal()) + obj = panel.objmodel.get_object_from_number(1) + panel.objview.set_current_object(obj) + state = WorkspaceState() + state.save(win) + action = HistoryAction(title="Dummy", state=state) + session = HistorySession(number=1) + session.add_action(action) + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "history_without_gate2_metadata.dlhist") + with NativeH5Writer(path) as writer: + writer.write_object_list([session], "history_session") + _delete_hdf5_items_by_name(writer.h5, "object_metadata") + with NativeH5Reader(path) as reader: + restored_sessions = reader.read_object_list( + "history_session", HistorySession + ) + restored_action = restored_sessions[0].actions[0] + assert restored_action.state.object_metadata == {} + assert restored_action.is_current_state_compatible(win, restore_selection=False) + + # --- scenario: tree marks incompatible action after source deletion --- + win.reset_all() + history = win.historypanel + history.toggle_record_mode(True) + panel.add_object(create_paracetamol_signal()) + panel.objview.select_objects([1]) + panel.processor.run_feature(sips.derivative) + deriv_entry = history[len(history)] + item = _get_tree_item_for(history, deriv_entry) + assert item.data(0, HistoryTree.COMPATIBILITY_ROLE) is True + history.toggle_record_mode(False) + panel.objview.select_objects([1]) + panel.remove_object(force=True) + history.refresh_compatibility_items() + item = _get_tree_item_for(history, deriv_entry) + assert item.data(0, HistoryTree.COMPATIBILITY_ROLE) is False + assert item.foreground(0).color().isValid() + + +# --------------------------------------------------------------------------- +# 3) Recording: compute + UI actions +# --------------------------------------------------------------------------- + + +def test_history_recording_compute_and_ui(monkeypatch): + """Recording and replay of compute + UI actions (capture fidelity).""" + with datalab_test_app_context() as win: + history = win.historypanel + history.toggle_record_mode(True) + panel = win.signalpanel + panel.add_object(create_paracetamol_signal()) + src_uuid = get_uuid(panel.objmodel.get_object_from_number(1)) + + # --- scenario: compute_1_to_1 history matches ProcessingParameters --- + panel.objview.select_objects([1]) + panel.processor.run_feature(sips.derivative) + result_obj = panel.objmodel.get_object_from_number(2) + pp = extract_processing_parameters(result_obj) + assert pp is not None + assert pp.func_name == "derivative" + assert pp.source_uuid == src_uuid + entry = history[len(history)] + assert entry.kind == HistoryAction.KIND_COMPUTE + assert entry.func_name == "derivative" + assert entry.pattern == "1_to_1" + assert entry.state.selection.get(panel.PANEL_STR_ID) == [src_uuid] + + # --- scenario: recompute_processing does not add a history entry --- + n_before = len(history) + derived = panel.objmodel.get_object_from_number(2) + panel.objview.set_current_object(derived) + panel.recompute_processing() + assert len(history) == n_before + + # --- scenario: replay finds target by UUID after panel reorder --- + deriv_entry = history[len(history)] + panel.add_object(create_paracetamol_signal()) + assert get_uuid(panel.objmodel[src_uuid]) == src_uuid + n_before_replay = len(panel.objmodel) + deriv_entry.replay(win, restore_selection=True, edit=False) + assert len(panel.objmodel) == n_before_replay + 1 + new_obj = panel.objmodel.get_object_from_number(len(panel.objmodel)) + new_pp = extract_processing_parameters(new_obj) + assert new_pp is not None + assert new_pp.source_uuid == src_uuid + + # --- scenario: UI rename capture + replay --- + with datalab_test_app_context() as win: + history = win.historypanel + history.toggle_record_mode(True) + panel = win.signalpanel + panel.add_object(create_paracetamol_signal()) + obj = panel.objmodel.get_object_from_number(1) + original_title = obj.title + new_title = "renamed-by-test" + panel.objview.set_current_object(obj) + panel.set_current_object_title(new_title) + assert obj.title == new_title + rename_entry = history[len(history)] + assert rename_entry.kind == HistoryAction.KIND_UI + assert rename_entry.target == "signalpanel" + assert rename_entry.method_name == "set_current_object_title" + assert rename_entry.kwargs.get("title") == new_title + panel.set_current_object_title("transient-title") + assert obj.title == "transient-title" + rename_entry.replay(win, restore_selection=False, edit=False) + assert obj.title == new_title and obj.title != original_title + assert isinstance(rename_entry.state, WorkspaceState) + + # --- scenario: add_metadata capture --- + obj_uuid = get_uuid(obj) + panel.objview.select_objects([1]) + param = AddMetadataParam([obj]) + param.metadata_key = "history_gate6" + param.value_pattern = "value_{index}" + param.conversion = "string" + panel.add_metadata(param) + entry = history[len(history)] + assert entry.kind == HistoryAction.KIND_UI + assert entry.target == "signalpanel" + assert entry.method_name == "add_metadata" + captured = entry.kwargs.get("param") + assert captured is not None and captured is not param + assert captured.metadata_key == param.metadata_key + assert captured.value_pattern == param.value_pattern + assert captured.conversion == param.conversion + assert entry.state.selection.get(panel.PANEL_STR_ID) == [obj_uuid] + + # --- scenario: add_metadata replay --- + with datalab_test_app_context() as win: + history = win.historypanel + history.toggle_record_mode(True) + panel = win.signalpanel + panel.add_object(create_paracetamol_signal()) + obj = panel.objmodel.get_object_from_number(1) + panel.objview.select_objects([1]) + param = AddMetadataParam([obj]) + param.metadata_key = "replay_test_key" + param.value_pattern = "replay_value_{index}" + param.conversion = "string" + panel.add_metadata(param) + assert obj.metadata.get("replay_test_key") == "replay_value_1" + entry = history[len(history)] + del obj.metadata["replay_test_key"] + assert "replay_test_key" not in obj.metadata + entry.replay(win, restore_selection=False, edit=False) + assert obj.metadata.get("replay_test_key") == "replay_value_1" + + # --- scenario: ROI copy/paste capture + deterministic replay --- + with datalab_test_app_context() as win: + history = win.historypanel + history.toggle_record_mode(True) + panel = win.signalpanel + src = create_paracetamol_signal() + src.roi = create_signal_roi([[26, 41]], indices=True) + panel.add_object(src) + dst = create_paracetamol_signal() + panel.add_object(dst) + src_uuid = get_uuid(src) + dst_uuid = get_uuid(dst) + panel.objview.set_current_item_id(src_uuid) + panel.copy_roi() + copy_entry = history[len(history)] + panel.objview.set_current_item_id(dst_uuid) + panel.paste_roi() + paste_entry = history[len(history)] + assert copy_entry.kind == HistoryAction.KIND_UI + assert copy_entry.target == "signalpanel" + assert copy_entry.method_name == "copy_roi" + assert "roi_data" in copy_entry.kwargs + assert copy_entry.state.selection.get(panel.PANEL_STR_ID) == [src_uuid] + assert paste_entry.kind == HistoryAction.KIND_UI + assert paste_entry.target == "signalpanel" + assert paste_entry.method_name == "paste_roi" + assert "roi_data" in paste_entry.kwargs + assert paste_entry.state.selection.get(panel.PANEL_STR_ID) == [dst_uuid] + # Deterministic replay: change source ROI then replay paste. + dst_obj = panel.objmodel.get_object_from_number(2) + assert dst_obj.roi is not None + src.roi = create_signal_roi([[100, 200]], indices=True) + dst_obj.roi = None + paste_entry.replay(win, restore_selection=False, edit=False) + assert dst_obj.roi is not None + assert dst_obj.roi.get_single_roi(0).coords.tolist() == [26, 41] + + # --- scenario: save_to_directory capture + replay --- + saved_paths: list[str] = [] + + def fake_save_to_file(_self, _obj, filename): + saved_paths.append(filename) + + monkeypatch.setattr( + BaseDataPanel, "_BaseDataPanel__save_to_file", fake_save_to_file + ) + with datalab_test_app_context() as win: + with helpers.WorkdirRestoringTempDir() as tmpdir: + history = win.historypanel + history.toggle_record_mode(True) + panel = win.signalpanel + panel.add_object(create_paracetamol_signal()) + obj = panel.objmodel.get_object_from_number(1) + obj_uuid = get_uuid(obj) + panel.objview.select_objects([1]) + param = sigima.params.SaveToDirectoryParam.create( + directory=tmpdir, + basename="history_gate6_{index}", + extension=".csv", + overwrite=True, + ) + panel.save_to_directory(param) + entry = history[len(history)] + assert entry.kind == HistoryAction.KIND_UI + assert entry.target == "signalpanel" + assert entry.method_name == "save_to_directory" + captured = entry.kwargs.get("param") + assert captured is not None and captured is not param + assert captured.directory == param.directory + assert captured.basename == param.basename + assert captured.extension == param.extension + assert captured.overwrite == param.overwrite + assert entry.state.selection.get(panel.PANEL_STR_ID) == [obj_uuid] + assert saved_paths == [os.path.join(tmpdir, "history_gate6_1.csv")] + # Replay: must call save again with same parameters. + n_before = len(saved_paths) + entry.replay(win, restore_selection=False, edit=False) + assert len(saved_paths) == n_before + 1 + assert saved_paths[-1] == saved_paths[-2] + + +# --------------------------------------------------------------------------- +# 4) Replay patterns (1_to_n, n_to_1, 2_to_1, multiple_1_to_1, normal) +# --------------------------------------------------------------------------- + + +def test_history_replay_patterns(monkeypatch): + """Replay behaviour for each compute pattern (persistent + non-persistent).""" + # --- scenario: multiple_1_to_1 replay raises NotImplementedError --- + with datalab_test_app_context() as win: + action = HistoryAction( + title="dummy multiple_1_to_1", + kind=HistoryAction.KIND_COMPUTE, + panel_str="signal", + func_name="some_compound_op", + pattern="multiple_1_to_1", + state=WorkspaceState(), + ) + with pytest.raises(NotImplementedError): + action.replay(win, restore_selection=False, edit=False) + + # --- scenario: normal processing outside replay still adds objects --- + panel = win.signalpanel + panel.add_object(create_paracetamol_signal()) + panel.objview.select_objects([1]) + n_before = len(panel.objmodel) + panel.processor.run_feature(sips.derivative) + assert len(panel.objmodel) == n_before + 1 + + # --- scenario: 1_to_n extract_roi replay (persistent direct replay) --- + with datalab_test_app_context() as win: + history = win.historypanel + history.toggle_record_mode(True) + panel = win.signalpanel + sig = create_paracetamol_signal() + sig.roi = sigima.objects.create_signal_roi([[26, 41], [125, 146]], indices=True) + panel.add_object(sig) + src_uuid = get_uuid(panel.objmodel.get_object_from_number(1)) + n_objects_before = len(panel.objmodel) + panel.objview.select_objects([1]) + panel.processor.run_feature("extract_roi", params=sig.roi.to_params(sig)) + n_added_first = len(panel.objmodel) - n_objects_before + assert n_added_first >= 1 + entry = history[len(history)] + assert entry.kind == HistoryAction.KIND_COMPUTE + assert entry.pattern == "1_to_n" + assert entry.func_name == "extract_roi" + assert entry.state.selection.get(panel.PANEL_STR_ID) == [src_uuid] + n_before_replay = len(panel.objmodel) + entry.replay(win, restore_selection=True, edit=False) + assert len(panel.objmodel) - n_before_replay == n_added_first + + # --- scenario: 1_to_n via panel API does NOT add output (non-persistent) --- + _select_tree_item_for(history, entry) + n_before = len(panel.objmodel) + n_hist_before = len(history) + history.replay_restore_actions(replay=True, restore_selection=True) + assert len(panel.objmodel) == n_before + assert len(history) == n_hist_before + + # --- scenario: n_to_1 forces captured selection --- + with datalab_test_app_context() as win: + history = win.historypanel + history.toggle_record_mode(True) + panel = win.signalpanel + panel.add_object(create_paracetamol_signal()) + panel.add_object(create_paracetamol_signal()) + panel.add_object(create_paracetamol_signal()) + panel.objview.select_objects([1, 2, 3]) + panel.processor.run_feature(sips.average) + avg_entry = history[len(history)] + assert avg_entry.pattern == "n_to_1" + panel.objview.select_objects([1]) + assert len(panel.objview.get_sel_object_uuids()) == 1 + n_before = len(panel.objmodel) + avg_entry.replay(win, restore_selection=False, edit=False) + assert len(panel.objmodel) == n_before + 1 + + # --- scenario: n_to_1 via panel API does NOT add output --- + _select_tree_item_for(history, avg_entry) + n_before = len(panel.objmodel) + n_hist_before = len(history) + history.replay_restore_actions(replay=True, restore_selection=True) + assert len(panel.objmodel) == n_before + assert len(history) == n_hist_before + + # --- scenario: n_to_1 falls back when captured UUIDs are gone --- + win.reset_all() + panel.add_object(create_paracetamol_signal()) + panel.add_object(create_paracetamol_signal()) + panel.add_object(create_paracetamol_signal()) + panel.objview.select_objects([1, 2, 3]) + assert not avg_entry.state.is_current_state_compatible(win, False) + n_before = len(panel.objmodel) + avg_entry.replay(win, restore_selection=False, edit=False) + assert len(panel.objmodel) == n_before + 1 + + # --- scenario: n_to_1 passes recorded pairwise flag --- + with datalab_test_app_context() as win: + panel = win.signalpanel + captured: dict = {} + + def capture_compute_n_to_1(*_args, **kwargs): + captured.update(kwargs) + + monkeypatch.setattr(panel.processor, "compute_n_to_1", capture_compute_n_to_1) + action = HistoryAction( + title="average pairwise", + kind=HistoryAction.KIND_COMPUTE, + panel_str=panel.PANEL_STR_ID, + func_name="average", + pattern="n_to_1", + kwargs={"pairwise": True}, + state=WorkspaceState(), + ) + action._replay_compute(win, edit=False) # noqa: SLF001 + assert captured["pairwise"] is True + + # --- scenario: 2_to_1 with vanished obj2 raises ValueError --- + with datalab_test_app_context() as win: + history = win.historypanel + history.toggle_record_mode(True) + panel = win.signalpanel + panel.add_object(create_paracetamol_signal()) + panel.add_object(create_paracetamol_signal()) + obj2 = panel.objmodel.get_object_from_number(2) + panel.objview.select_objects([1]) + panel.processor.run_feature(sips.difference, obj2) + diff_entry = history[len(history)] + assert diff_entry.pattern == "2_to_1" + panel.objview.set_current_object(obj2) + panel.remove_object(force=True) + with pytest.raises(ValueError): + diff_entry.replay(win, restore_selection=False, edit=False) + + # --- scenario: 2_to_1 replay translates obj2 UUIDs and passes pairwise --- + with datalab_test_app_context() as win: + panel = win.signalpanel + panel.add_object(create_paracetamol_signal()) + obj2 = panel.objmodel.get_object_from_number(1) + obj2_uuid = get_uuid(obj2) + captured = {} + + def capture_compute_2_to_1(obj2_arg, *_args, **kwargs): + captured["obj2"] = obj2_arg + captured.update(kwargs) + + monkeypatch.setattr(panel.processor, "compute_2_to_1", capture_compute_2_to_1) + action = HistoryAction( + title="difference pairwise", + kind=HistoryAction.KIND_COMPUTE, + panel_str=panel.PANEL_STR_ID, + func_name="difference", + pattern="2_to_1", + kwargs={"obj2_uuids": ["recorded-obj2"], "pairwise": True}, + state=WorkspaceState(), + ) + action._replay_compute( # noqa: SLF001 + win, + edit=False, + uuid_remap={panel.PANEL_STR_ID: {"recorded-obj2": obj2_uuid}}, + ) + assert captured["obj2"] is obj2 + assert captured["pairwise"] is True + + # --- scenario: replay_restore_actions(replay=True) on 1_to_1 does NOT add output --- + with datalab_test_app_context() as win: + history = win.historypanel + history.toggle_record_mode(True) + panel = win.signalpanel + panel.add_object(create_paracetamol_signal()) + panel.objview.select_objects([1]) + panel.processor.run_feature(sips.derivative) + deriv_entry = history[len(history)] + _select_tree_item_for(history, deriv_entry) + n_signal_before = len(panel.objmodel) + n_history_before = len(history) + history.replay_restore_actions(replay=True, restore_selection=True) + assert len(panel.objmodel) == n_signal_before + assert len(history) == n_history_before + + +# --------------------------------------------------------------------------- +# 5) Session-level replay (+ reset-starts-new-session) +# --------------------------------------------------------------------------- + + +def test_history_session_replay(): + """Full ``HistorySession.replay`` behaviour + reset/session boundaries.""" + # --- scenario: reset_all starts a new session and preserves history --- + with datalab_test_app_context() as win: + history = win.historypanel + panel = win.signalpanel + win.reset_all() + assert len(history) == 0 + assert _session_action_counts(history) == [] + history.toggle_record_mode(True) + panel.new_object(param=sigima.objects.GaussParam(), edit=False) + assert len(history) == 1 + assert _session_action_counts(history) == [1] + first_title = history[1].title + history.toggle_record_mode(False) + win.reset_all() + assert len(history) == 1 + assert _session_action_counts(history) == [1, 0] + assert history[1].title == first_title + history.toggle_record_mode(True) + panel.new_object(param=sigima.objects.LorentzParam(), edit=False) + assert len(history) == 2 + assert _session_action_counts(history) == [1, 1] + assert history[1].title == first_title + assert history[2].title == _("New signal") + + # --- scenario: full session replay on existing data --- + with datalab_test_app_context() as win: + history = win.historypanel + history.toggle_record_mode(True) + panel = win.signalpanel + panel.add_object(create_paracetamol_signal()) + panel.add_object(create_paracetamol_signal()) + panel.add_object(create_paracetamol_signal()) + assert len(panel.objmodel) == 3 + panel.objview.select_objects([1, 2, 3]) + panel.processor.run_feature(sips.average) + assert len(panel.objmodel) == 4 + session = history.history_sessions[-1] + n_before = len(panel.objmodel) + session.replay(win, restore_selection=False, edit=False) + assert len(panel.objmodel) == n_before + 1 + + # --- scenario: chained compute session replay (output queue remap) --- + with datalab_test_app_context() as win: + history = win.historypanel + history.toggle_record_mode(True) + panel = win.signalpanel + panel.add_object(create_paracetamol_signal()) + panel.add_object(create_paracetamol_signal()) + panel.objview.select_objects([1]) + panel.processor.run_feature(sips.derivative) + panel.objview.select_objects([2]) + panel.processor.run_feature(sips.derivative) + panel.objview.select_objects([3, 4]) + panel.processor.run_feature(sips.average) + assert len(panel.objmodel) == 5 + session = history.history_sessions[-1] + n_before = len(panel.objmodel) + session.replay(win, restore_selection=False, edit=False) + assert len(panel.objmodel) == n_before + 3 + + # --- scenario: direct HistoryAction.replay() does NOT record new entries --- + with datalab_test_app_context() as win: + history = win.historypanel + panel = win.signalpanel + history.toggle_record_mode(True) + panel.new_object(param=NewSignalParam(), edit=False) + panel.objview.select_objects([1]) + panel.processor.run_feature(sips.derivative) + assert len(panel.objmodel) == 2 + session = history.history_sessions[-1] + compute_action = [ + a for a in session.actions if a.kind == HistoryAction.KIND_COMPUTE + ][0] + n_before = sum(len(s.actions) for s in history.history_sessions) + panel.objview.select_objects([1]) + compute_action.replay(win, restore_selection=True, edit=False) + assert sum(len(s.actions) for s in history.history_sessions) == n_before + + # --- scenario: direct HistorySession.replay() does NOT record entries --- + with datalab_test_app_context() as win: + history = win.historypanel + panel = win.signalpanel + history.toggle_record_mode(True) + panel.add_object(create_paracetamol_signal()) + panel.add_object(create_paracetamol_signal()) + panel.objview.select_objects([1, 2]) + panel.processor.run_feature(sips.average) + assert len(panel.objmodel) == 3 + session = history.history_sessions[-1] + n_before = sum(len(s.actions) for s in history.history_sessions) + panel.objview.select_objects([1, 2]) + session.replay(win, restore_selection=False, edit=False) + assert sum(len(s.actions) for s in history.history_sessions) == n_before + + # --- scenario: panel API session replay skips UI-creation, no output --- + with datalab_test_app_context() as win: + history = win.historypanel + history.toggle_record_mode(True) + panel = win.signalpanel + panel.new_object(param=NewSignalParam(), edit=False) + panel.objview.select_objects([1]) + panel.processor.run_feature(sips.derivative) + session = history.history_sessions[-1] + _select_tree_session(history, session) + n_signal_before = len(panel.objmodel) + n_history_before = len(history) + history.replay_restore_actions(replay=True, restore_selection=True) + assert len(panel.objmodel) == n_signal_before + assert len(history) == n_history_before + + # --- scenario: replay whole session when no tree selection --- + with datalab_test_app_context() as win: + history = win.historypanel + history.toggle_record_mode(True) + panel = win.signalpanel + panel.add_object(create_paracetamol_signal()) + panel.objview.select_objects([1]) + panel.processor.run_feature(sips.derivative) + history.tree.clearSelection() + n_before = len(panel.objmodel) + n_history_before = len(history) + history.replay_restore_actions(replay=True, restore_selection=False) + assert len(panel.objmodel) == n_before + assert len(history) == n_history_before + + # --- scenario: 2_to_1 preserves operand order (primary = #2) --- + with datalab_test_app_context() as win: + history = win.historypanel + history.toggle_record_mode(True) + panel = win.signalpanel + panel.add_object(create_paracetamol_signal()) + panel.add_object(create_paracetamol_signal()) + obj2_for_diff = panel.objmodel.get_object_from_number(1) + panel.objview.select_objects([2]) + panel.processor.run_feature(sips.difference, obj2_for_diff) + assert len(panel.objmodel) == 3 + original_title = panel.objmodel.get_object_from_number(3).title + assert "s002" in original_title and "s001" in original_title + assert original_title.index("s002") < original_title.index("s001") + diff_entry = history[len(history)] + _select_tree_item_for(history, diff_entry) + n_before = len(panel.objmodel) + history.replay_restore_actions(replay=True, restore_selection=True) + assert len(panel.objmodel) == n_before + + # --- scenario: 2_to_1 with primary = #1, obj2 = #2 --- + with datalab_test_app_context() as win: + history = win.historypanel + history.toggle_record_mode(True) + panel = win.signalpanel + panel.add_object(create_paracetamol_signal()) + panel.add_object(create_paracetamol_signal()) + obj2_for_diff = panel.objmodel.get_object_from_number(2) + panel.objview.select_objects([1]) + panel.processor.run_feature(sips.difference, obj2_for_diff) + assert len(panel.objmodel) == 3 + original_title = panel.objmodel.get_object_from_number(3).title + assert "s001" in original_title and "s002" in original_title + assert original_title.index("s001") < original_title.index("s002") + diff_entry = history[len(history)] + _select_tree_item_for(history, diff_entry) + n_before = len(panel.objmodel) + history.replay_restore_actions(replay=True, restore_selection=True) + assert len(panel.objmodel) == n_before + + # --- scenario: 1_to_1 on second signal (derivative on #2) --- + with datalab_test_app_context() as win: + history = win.historypanel + history.toggle_record_mode(True) + panel = win.signalpanel + panel.add_object(create_paracetamol_signal()) + panel.add_object(create_paracetamol_signal()) + panel.objview.select_objects([2]) + panel.processor.run_feature(sips.derivative) + assert len(panel.objmodel) == 3 + original_title = panel.objmodel.get_object_from_number(3).title + assert "s002" in original_title + deriv_entry = history[len(history)] + _select_tree_item_for(history, deriv_entry) + n_before = len(panel.objmodel) + history.replay_restore_actions(replay=True, restore_selection=True) + assert len(panel.objmodel) == n_before + + +# --------------------------------------------------------------------------- +# 6) Duplication +# --------------------------------------------------------------------------- + + +def test_history_duplication(): + """Duplication of actions/sessions + replay of duplicates + ordering.""" + # --- scenario: duplicating an action creates an independent copy --- + with datalab_test_app_context() as win: + history = win.historypanel + history.toggle_record_mode(True) + panel = win.signalpanel + panel.add_object(create_paracetamol_signal()) + param = sigima.params.MovingAverageParam() + param.n = 3 + panel.objview.select_objects([1]) + panel.processor.run_feature(sips.moving_average, param) + original = history[len(history)] + _select_tree_item_for(history, original) + n_before = len(panel.objmodel) + history.duplicate_selected_entries() + sessions = history.history_sessions + duplicate_session = sessions[-1] + duplicate = duplicate_session.actions[0] + assert duplicate_session.title.endswith(_("Copy")) + assert duplicate is not original + assert duplicate.uuid != original.uuid + assert duplicate.kwargs["param"] is not original.kwargs["param"] + duplicate.kwargs["param"].n = 7 + assert original.kwargs["param"].n == 3 + assert duplicate.kwargs["param"].n == 7 + assert len(panel.objmodel) > n_before + + # --- scenario: duplicating a session copies all actions independently --- + with datalab_test_app_context() as win: + history = win.historypanel + history.toggle_record_mode(True) + panel = win.signalpanel + panel.add_object(create_paracetamol_signal()) + param = sigima.params.MovingAverageParam() + param.n = 3 + panel.objview.select_objects([1]) + panel.processor.run_feature(sips.moving_average, param) + original_session = history.history_sessions[-1] + _select_tree_session(history, original_session) + history.duplicate_selected_entries() + sessions = history.history_sessions + duplicate_session = sessions[-1] + assert duplicate_session is not original_session + assert len(duplicate_session.actions) == len(original_session.actions) + assert duplicate_session.title.endswith(_("Copy")) + orig_a = original_session.actions[-1] + dup_a = duplicate_session.actions[-1] + assert dup_a is not orig_a + assert dup_a.kwargs["param"] is not orig_a.kwargs["param"] + + # --- scenario: duplicate clones data AND remaps UUIDs --- + with datalab_test_app_context() as win: + history = win.historypanel + history.toggle_record_mode(True) + panel = win.signalpanel + panel.add_object(create_paracetamol_signal()) + panel.objview.select_objects([1]) + panel.processor.run_feature(sips.derivative) + original_session = history.history_sessions[-1] + _select_tree_session(history, original_session) + n_obj_before = len(panel.objmodel) + n_sessions_before = len(history.history_sessions) + history.duplicate_selected_entries() + sessions = history.history_sessions + assert len(sessions) == n_sessions_before + 1 + dup_session = sessions[-1] + assert dup_session is not original_session + assert dup_session.title.endswith(_("Copy")) + assert len(panel.objmodel) > n_obj_before + for orig_action, dup_action in zip( + original_session.actions, dup_session.actions + ): + for pstr in orig_action.state.selection: + orig_uuids = set(orig_action.state.selection.get(pstr, [])) + dup_uuids = set(dup_action.state.selection.get(pstr, [])) + if orig_uuids and dup_uuids: + assert orig_uuids.isdisjoint(dup_uuids) + + # --- scenario: replay of duplicated session does NOT add output --- + _select_tree_session(history, dup_session) + n_before = len(panel.objmodel) + n_history_before = len(history) + history.replay_restore_actions(replay=True, restore_selection=False) + assert len(panel.objmodel) == n_before + assert len(history) == n_history_before + + # --- scenario: duplicated session preserves topological object order --- + with datalab_test_app_context() as win: + history = win.historypanel + history.toggle_record_mode(True) + panel = win.signalpanel + _build_cascade_chain(panel, history) + # Extra step so the moving_average output is captured in metadata. + panel.objview.select_objects([4]) + panel.processor.run_feature(sips.derivative) + original_ids = panel.objmodel.get_object_ids() + original_titles = [panel.objmodel[uid].title for uid in original_ids] + assert len(original_titles) >= 3 + sessions = history.history_sessions + _select_tree_session(history, sessions[0]) + history.duplicate_selected_entries() + groups = panel.objmodel.get_groups() + assert len(groups) >= 2 + dup_group_id = get_uuid(groups[-1]) + dup_ids = [ + uid + for uid in panel.objmodel.get_object_ids() + if panel.objmodel.get_object_group_id(panel.objmodel[uid]) == dup_group_id + ] + dup_titles = [panel.objmodel[uid].title for uid in dup_ids] + + def _suffix(title: str) -> str: + parts = title.split("|", 1) + return parts[1].strip() if len(parts) > 1 else title.strip() + + orig_suffixes = [_suffix(t) for t in original_titles] + dup_suffixes = [_suffix(t) for t in dup_titles] + clonable = orig_suffixes[: len(dup_suffixes)] + assert clonable == dup_suffixes + + +# --------------------------------------------------------------------------- +# 7) Stepping + selection sync +# --------------------------------------------------------------------------- + + +def test_history_stepping_and_selection_sync(): + """Step-prev / step-next navigation + tree-to-panel selection sync.""" + # --- scenario: step_next walks forward through current session --- + with datalab_test_app_context() as win: + _panel, history = _record_three_action_session(win) + sessions = history.history_sessions + actions = sessions[-1].actions + assert len(actions) >= 2 + history.tree.clearSelection() + for action in actions: + history.step_next() + current = history.tree.currentItem() + assert current is not None + assert current.data(0, QC.Qt.UserRole) == action.uuid + # End -> no-op. + last_uuid = history.tree.currentItem().data(0, QC.Qt.UserRole) + history.step_next() + assert history.tree.currentItem().data(0, QC.Qt.UserRole) == last_uuid + + # --- scenario: step_prev walks backward through current session --- + _select_tree_item_for(history, actions[-1]) + for expected in reversed(actions[:-1]): + history.step_prev() + current = history.tree.currentItem() + assert current.data(0, QC.Qt.UserRole) == expected.uuid + first_uuid = history.tree.currentItem().data(0, QC.Qt.UserRole) + history.step_prev() + assert history.tree.currentItem().data(0, QC.Qt.UserRole) == first_uuid + + # --- scenario: step button enabled state reflects position --- + with datalab_test_app_context() as win: + history = win.historypanel + prev_btn = history._step_prev_action # noqa: SLF001 + next_btn = history._step_next_action # noqa: SLF001 + history.update_actions_state() + assert not prev_btn.isEnabled() + assert not next_btn.isEnabled() + _panel, history = _record_three_action_session(win) + sessions = history.history_sessions + actions = sessions[-1].actions + prev_btn = history._step_prev_action # noqa: SLF001 + next_btn = history._step_next_action # noqa: SLF001 + _select_tree_item_for(history, actions[0]) + assert not prev_btn.isEnabled() + assert next_btn.isEnabled() + if len(actions) >= 3: + _select_tree_item_for(history, actions[1]) + assert prev_btn.isEnabled() + assert next_btn.isEnabled() + _select_tree_item_for(history, actions[-1]) + assert prev_btn.isEnabled() + assert not next_btn.isEnabled() + + # --- scenario: selecting a compute action selects its output --- + with datalab_test_app_context() as win: + history = win.historypanel + history.toggle_record_mode(True) + panel = win.signalpanel + panel.add_object(create_paracetamol_signal()) + src_uuid = get_uuid(panel.objmodel.get_object_from_number(1)) + panel.objview.select_objects([1]) + panel.processor.run_feature( + sips.normalize, sigima.params.NormalizeParam.create(method="maximum") + ) + out_uuid = get_uuid(panel.objmodel.get_object_from_number(2)) + norm_entry = history[len(history)] + assert norm_entry.func_name == "normalize" + assert src_uuid in norm_entry.state.selection.get("signal", []) + panel.objview.select_objects([src_uuid]) + _select_tree_item_for(history, norm_entry) + assert panel.objview.get_sel_object_uuids() == [out_uuid] + + # --- scenario: deleted output -> selection falls back to input --- + panel.objview.select_objects([2]) + panel.remove_object(force=True) + assert len(panel.objmodel) == 1 + panel.objview.select_groups([1]) + _select_tree_item_for(history, norm_entry) + assert panel.objview.get_sel_object_uuids() == [src_uuid] + + +# --------------------------------------------------------------------------- +# 8) Processing-tab edit propagation + restore-selection-only +# --------------------------------------------------------------------------- + + +def test_history_edit_in_tree(): + """Processing-tab parameter edits propagate into the matching action.""" + # --- scenario: edit updates current session action and refreshes html --- + with datalab_test_app_context() as win: + history = win.historypanel + history.toggle_record_mode(True) + history.toggle_edit_mode(True) + panel = win.signalpanel + panel.add_object(create_paracetamol_signal()) + param = sigima.params.GaussianParam.create(sigma=1.5) + panel.objview.select_objects([1]) + panel.processor.run_feature(sips.gaussian_filter, param) + result_obj = panel.objmodel.get_object_from_number(2) + action = history[len(history)] + assert action.func_name == "gaussian_filter" + assert action.kwargs["param"].sigma == 1.5 + html_before = action.description_html + panel.objview.select_objects([2]) + assert panel.objprop.setup_processing_tab(result_obj, reset_params=False) + editor = panel.objprop.processing_param_editor + assert editor is not None + editor.dataset.sigma = 3.5 + report = panel.objprop.apply_processing_parameters( + result_obj, interactive=False + ) + assert report.success + assert action.kwargs["param"].sigma == 3.5 + html_after = action.description_html + assert html_before != html_after + assert "3.5" in html_after + win.historypanel.refresh_action(action) + editor.dataset.sigma = 7.0 + assert action.kwargs["param"].sigma == 3.5 + + # --- scenario: edit does NOT touch a past session --- + with datalab_test_app_context() as win: + history = win.historypanel + history.toggle_record_mode(True) + history.toggle_edit_mode(True) + panel = win.signalpanel + panel.add_object(create_paracetamol_signal()) + panel.objview.select_objects([1]) + panel.processor.run_feature( + sips.gaussian_filter, sigima.params.GaussianParam.create(sigma=1.0) + ) + past_action = history[len(history)] + assert past_action.kwargs["param"].sigma == 1.0 + win.reset_all() + panel.add_object(create_paracetamol_signal()) + panel.objview.select_objects([1]) + panel.processor.run_feature( + sips.gaussian_filter, sigima.params.GaussianParam.create(sigma=2.0) + ) + new_obj = panel.objmodel.get_object_from_number(2) + new_action = history[len(history)] + assert new_action is not past_action + found = history.find_action_for_output(get_uuid(new_obj), "gaussian_filter") + assert found is new_action and found is not past_action + panel.objview.select_objects([2]) + assert panel.objprop.setup_processing_tab(new_obj, reset_params=False) + editor = panel.objprop.processing_param_editor + assert editor is not None + editor.dataset.sigma = 4.0 + report = panel.objprop.apply_processing_parameters(new_obj, interactive=False) + assert report.success + assert new_action.kwargs["param"].sigma == 4.0 + assert past_action.kwargs["param"].sigma == 1.0 + + # --- scenario: restore selection only (replay=False) --- + with datalab_test_app_context() as win: + history = win.historypanel + history.toggle_record_mode(True) + panel = win.signalpanel + panel.add_object(create_paracetamol_signal()) + src_uuid = get_uuid(panel.objmodel.get_object_from_number(1)) + panel.objview.select_objects([1]) + panel.processor.run_feature(sips.derivative) + deriv_entry = history[len(history)] + derived = panel.objmodel.get_object_from_number(2) + panel.objview.set_current_object(derived) + derived_uuid = get_uuid(derived) + assert panel.objview.get_sel_object_uuids() == [derived_uuid] + _select_tree_item_for(history, deriv_entry) + n_before = len(panel.objmodel) + history.replay_restore_actions(replay=False, restore_selection=True) + assert len(panel.objmodel) == n_before + assert panel.objview.get_sel_object_uuids() == [src_uuid] + + +# --------------------------------------------------------------------------- +# 9) Cascade recompute +# --------------------------------------------------------------------------- + + +def test_history_cascade_recompute(): + """Downstream detection + cascade recompute + duplicated-session cascade.""" + # --- scenario: downstream detection --- + with datalab_test_app_context() as win: + history = win.historypanel + history.toggle_record_mode(True) + panel = win.signalpanel + action_A, action_B, action_C, _ob, _oc = _build_cascade_chain(panel, history) + downstream = history.get_downstream_actions(action_A) + assert downstream == [action_B, action_C] + assert history.get_downstream_actions(action_C) == [] + assert history.get_downstream_actions(action_B) == [action_C] + + # --- scenario: cascade recompute updates downstream outputs in place --- + with datalab_test_app_context() as win: + history = win.historypanel + history.toggle_record_mode(True) + history.toggle_edit_mode(True) + panel = win.signalpanel + action_A, action_B, action_C, output_B, output_C = _build_cascade_chain( + panel, history + ) + uuid_B = get_uuid(output_B) + uuid_C = get_uuid(output_C) + data_B_before = output_B.xydata.copy() + data_C_before = output_C.xydata.copy() + n_objects_before = len(panel.objmodel) + result_obj_A = panel.objmodel.get_object_from_number(2) + panel.objview.select_objects([2]) + assert panel.objprop.setup_processing_tab(result_obj_A, reset_params=False) + editor = panel.objprop.processing_param_editor + assert editor is not None + editor.dataset.sigma = 6.0 + report = panel.objprop.apply_processing_parameters( + result_obj_A, interactive=False + ) + assert report.success + assert action_A.kwargs["param"].sigma == 6.0 + assert len(panel.objmodel) == n_objects_before + assert get_uuid(panel.objmodel[uuid_B]) == uuid_B + assert get_uuid(panel.objmodel[uuid_C]) == uuid_C + assert not np.array_equal(panel.objmodel[uuid_B].xydata, data_B_before) + assert not np.array_equal(panel.objmodel[uuid_C].xydata, data_C_before) + for a in (action_A, action_B, action_C): + assert a.is_stale is False + + # --- scenario: play on stale action triggers cascade --- + with datalab_test_app_context() as win: + history = win.historypanel + history.toggle_record_mode(True) + panel = win.signalpanel + action_A, action_B, _aC, output_B, _oC = _build_cascade_chain(panel, history) + uuid_B = get_uuid(output_B) + output_B.xydata = output_B.xydata * 0.0 + tampered = output_B.xydata.copy() + action_A.is_stale = True + _select_tree_item_for(history, action_A) + history.replay_restore_actions(replay=True, restore_selection=False) + assert action_A.is_stale is False + assert action_B.is_stale is False + assert not np.array_equal(panel.objmodel[uuid_B].xydata, tampered) + + # --- scenario: cascade in a duplicated session that is NOT [-1] --- + with datalab_test_app_context() as win: + history = win.historypanel + history.toggle_record_mode(True) + history.toggle_edit_mode(True) + panel = win.signalpanel + action_A, _aB, _aC, _oB, output_C = _build_cascade_chain(panel, history) + uuid_C_orig = get_uuid(output_C) + data_C_orig = output_C.xydata.copy() + panel.objview.select_objects([4]) + panel.processor.run_feature(sips.derivative) + sessions = history.history_sessions + s1 = sessions[0] + history.create_new_session() + panel.add_object(create_paracetamol_signal()) + panel.objview.select_objects([6]) + panel.processor.run_feature(sips.derivative) + assert len(sessions) == 2 + _select_tree_session(history, s1) + history.duplicate_selected_entries() + assert len(sessions) == 3 + dup_session = sessions[1] + assert sessions[-1] is not dup_session + dup_action_A = next( + a for a in dup_session.actions if a.func_name == action_A.func_name + ) + dup_action_C = next( + a for a in dup_session.actions if a.func_name == "moving_average" + ) + dup_output_C_uuid = history._action_output_uuid(dup_action_C) # noqa: SLF001 + assert dup_output_C_uuid is not None + data_dup_C_before = panel.objmodel[dup_output_C_uuid].xydata.copy() + dup_result_obj_A_uuid = history._action_output_uuid( # noqa: SLF001 + dup_action_A + ) + assert dup_result_obj_A_uuid is not None + dup_result_obj_A = panel.objmodel[dup_result_obj_A_uuid] + panel.objview.select_objects([dup_result_obj_A_uuid]) + assert panel.objprop.setup_processing_tab(dup_result_obj_A, reset_params=False) + editor = panel.objprop.processing_param_editor + assert editor is not None + editor.dataset.sigma = 10.0 + report = panel.objprop.apply_processing_parameters( + dup_result_obj_A, interactive=False + ) + assert report.success + assert not np.array_equal( + panel.objmodel[dup_output_C_uuid].xydata, data_dup_C_before + ) + assert np.array_equal(panel.objmodel[uuid_C_orig].xydata, data_C_orig) + + +# --------------------------------------------------------------------------- +# 10) Chain reconnect after object deletion +# --------------------------------------------------------------------------- + + +def test_history_chain_reconnect(): + """Deleting an intermediate result rewires downstream actions to its source.""" + with datalab_test_app_context() as win: + history = win.historypanel + history.toggle_record_mode(True) + panel = win.signalpanel + panel.add_object(create_paracetamol_signal()) + src_uuid = get_uuid(panel.objmodel.get_object_from_number(1)) + + panel.objview.select_objects([1]) + panel.processor.run_feature( + sips.gaussian_filter, sigima.params.GaussianParam.create(sigma=1.5) + ) + s002_uuid = get_uuid(panel.objmodel.get_object_from_number(2)) + action_gaussian = history[len(history)] + assert action_gaussian.func_name == "gaussian_filter" + + panel.objview.select_objects([2]) + panel.processor.run_feature(sips.derivative) + action_deriv = history[len(history)] + assert action_deriv.func_name == "derivative" + assert s002_uuid in action_deriv.state.selection.get("signal", []) + + panel.objview.select_objects([2]) + panel.remove_object(force=True) + assert len(panel.objmodel) == 2 + + reconnected_uuids = action_deriv.state.selection.get("signal", []) + assert s002_uuid not in reconnected_uuids + assert src_uuid in reconnected_uuids diff --git a/datalab/tests/features/common/interactive_processing_test.py b/datalab/tests/features/common/interactive_processing_test.py index 9ea1351c3..705abec1f 100644 --- a/datalab/tests/features/common/interactive_processing_test.py +++ b/datalab/tests/features/common/interactive_processing_test.py @@ -164,6 +164,10 @@ def test_recompute(): filtered_sig = panel.objview.get_current_object() original_data = filtered_sig.y.copy() + # In-place recompute requires History panel edit mode (otherwise a + # new object is created instead of mutating the existing one). + win.historypanel.toggle_edit_mode(True) + # Recompute with different input signal data constant = 1.23098765 signal.y += constant @@ -402,6 +406,10 @@ def test_apply_processing_parameters_signal(): # Change constant from 5.0 to 15.0 editor.dataset.value = v1 = 15.0 + # In-place update requires History panel edit mode (otherwise a new + # object is created instead of mutating the existing one). + win.historypanel.toggle_edit_mode(True) + # Apply the new processing parameters report = objprop.apply_processing_parameters() @@ -463,6 +471,10 @@ def test_apply_processing_parameters_image(): # Change constant from 7.0 to 20.0 editor.dataset.value = v1 = 20.0 + # In-place update requires History panel edit mode (otherwise a new + # object is created instead of mutating the existing one). + win.historypanel.toggle_edit_mode(True) + # Apply the new processing parameters report = objprop.apply_processing_parameters() @@ -658,6 +670,10 @@ def test_cross_panel_image_to_signal(): editor.dataset.x0 = 40 editor.dataset.y0 = 40 + # In-place update + in-place recompute require History panel edit + # mode (otherwise new objects are created instead of mutating). + win.historypanel.toggle_edit_mode(True) + # Apply the new processing parameters report = signal_panel.objprop.apply_processing_parameters() @@ -1065,6 +1081,10 @@ def test_roi_mask_invalidation_on_processing_change(): editor.dataset.sx = 4 editor.dataset.sy = 4 + # In-place update requires History panel edit mode (otherwise a new + # object is created instead of mutating the existing one). + win.historypanel.toggle_edit_mode(True) + # Apply the new processing parameters report = objprop.apply_processing_parameters(binned) assert report.success diff --git a/datalab/widgets/historydescription.py b/datalab/widgets/historydescription.py new file mode 100644 index 000000000..8fdbd22de --- /dev/null +++ b/datalab/widgets/historydescription.py @@ -0,0 +1,90 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +"""Collapsible description widget used by the History panel.""" + +from __future__ import annotations + +import html + +from qtpy import QtCore as QC +from qtpy import QtWidgets as QW + +from datalab.config import _ + + +class CollapsibleDescriptionWidget(QW.QWidget): + """Compact, expandable cell widget for the history Description column. + + Shows a single-line summary by default; a chevron toggle reveals the full + HTML description (mirroring the *Properties* tab rendering). + """ + + toggled = QC.Signal(bool) + + def __init__( + self, + summary: str, + html_text: str, + expanded: bool = False, + parent: QW.QWidget | None = None, + ) -> None: + super().__init__(parent) + self._summary = summary + self._html = html_text + self._expanded = expanded + + self._toggle = QW.QToolButton(self) + self._toggle.setAutoRaise(True) + self._toggle.setCheckable(True) + self._toggle.setFocusPolicy(QC.Qt.NoFocus) + self._toggle.setArrowType(QC.Qt.RightArrow) + self._toggle.setToolTip(_("Show details")) + + self._label = QW.QLabel(self) + self._label.setTextFormat(QC.Qt.RichText) + self._label.setWordWrap(True) + self._label.setTextInteractionFlags(QC.Qt.TextSelectableByMouse) + self._label.setAlignment(QC.Qt.AlignTop | QC.Qt.AlignLeft) + + layout = QW.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(2) + layout.addWidget(self._toggle, 0, QC.Qt.AlignTop) + layout.addWidget(self._label, 1) + + # Hide the toggle when there is nothing more to show than the summary. + if not self._html or self.html_matches_summary(): + self._toggle.setVisible(False) + + self._toggle.toggled.connect(self.on_toggled) + self.refresh_widget() + + def html_matches_summary(self) -> bool: + """Return True when the HTML rendering would not add information.""" + return self._html.strip() == html.escape(self._summary).strip() + + def on_toggled(self, checked: bool) -> None: + self._expanded = checked + self.refresh_widget() + self.toggled.emit(checked) + + def refresh_widget(self) -> None: + if self._expanded: + self._toggle.setArrowType(QC.Qt.DownArrow) + self._toggle.setToolTip(_("Hide details")) + self._label.setText(self._html or html.escape(self._summary)) + else: + self._toggle.setArrowType(QC.Qt.RightArrow) + self._toggle.setToolTip(_("Show details")) + self._label.setText(html.escape(self._summary)) + self.updateGeometry() + + def is_expanded(self) -> bool: + """Return current expanded state.""" + return self._expanded + + def set_expanded(self, expanded: bool) -> None: + """Programmatically set the expanded state.""" + if expanded == self._expanded: + return + self._toggle.setChecked(expanded) diff --git a/datalab/widgets/historytree.py b/datalab/widgets/historytree.py new file mode 100644 index 000000000..6591285ea --- /dev/null +++ b/datalab/widgets/historytree.py @@ -0,0 +1,249 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +"""History tree widget used by the History panel.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from guidata.configtools import get_icon +from qtpy import QtCore as QC +from qtpy import QtGui as QG +from qtpy import QtWidgets as QW + +from datalab.config import _ +from datalab.history import HistoryAction, HistorySession +from datalab.widgets.historydescription import CollapsibleDescriptionWidget + +if TYPE_CHECKING: + from datalab.gui.main import DLMainWindow + + +class HistoryTree(QW.QTreeWidget): + """Tree widget for the history panel""" + + DESCRIPTION_COLUMN = 2 + COMPATIBILITY_ROLE = QC.Qt.UserRole + 1 + + def __init__(self, parent: QW.QWidget) -> None: + """Create a new history tree widget""" + super().__init__(parent) + self.setHeaderLabels([_("Title"), _("Date and time"), _("Description")]) + self.setContextMenuPolicy(QC.Qt.CustomContextMenu) + self.setSelectionMode(QW.QAbstractItemView.ContiguousSelection) + self.setUniformRowHeights(False) + header = self.header() + header.setSectionResizeMode(self.DESCRIPTION_COLUMN, QW.QHeaderView.Stretch) + # Per-action expanded state, preserved across repopulate (delete/replay). + self.__expanded_state: dict[str, bool] = {} + + def on_description_toggled(self, uuid: str, expanded: bool) -> None: + """Remember the expanded state of a description cell.""" + self.__expanded_state[uuid] = expanded + # Force the tree to recompute row heights now that the label content + # has changed. + self.scheduleDelayedItemsLayout() + + def install_description_widget( + self, item: QW.QTreeWidgetItem, action: HistoryAction + ) -> None: + """Attach the collapsible description widget to ``item`` (column 2). + + The item must already be inserted in the tree before calling this. + """ + expanded = self.__expanded_state.get(action.uuid, False) + widget = CollapsibleDescriptionWidget( + action.description_summary, + action.description_html, + expanded=expanded, + parent=self, + ) + widget.toggled.connect( + lambda checked, uuid=action.uuid: self.on_description_toggled(uuid, checked) + ) + # Clear any text the item may carry for that column to avoid double + # rendering behind the widget. + item.setText(self.DESCRIPTION_COLUMN, "") + self.setItemWidget(item, self.DESCRIPTION_COLUMN, widget) + + @classmethod + def action_to_tree_item(cls, action: HistoryAction) -> QW.QTreeWidgetItem: + """Convert an action to a tree item + + Args: + action: Action to convert + + Returns: + QW.QTreeWidgetItem: Tree item + """ + # Description column is left empty: a CollapsibleDescriptionWidget is + # installed by ``HistoryTree`` once the item is inserted in the tree. + item = QW.QTreeWidgetItem([action.title, action.dtstr, ""]) + item.setData(0, QC.Qt.UserRole, action.uuid) + item.setData(0, cls.COMPATIBILITY_ROLE, True) + return item + + def update_compatibility_states( + self, history_sessions: list[HistorySession], mainwindow: DLMainWindow + ) -> None: + """Update action item visual state from workspace compatibility.""" + default_brush = QG.QBrush() + disabled_brush = QG.QBrush( + self.palette().color(QG.QPalette.Disabled, QG.QPalette.Text) + ) + compatible_tip = _("Action is compatible with the current workspace state.") + incompatible_tip = _( + "Action is not compatible with the current workspace state." + ) + for i in range(self.topLevelItemCount()): + session_item = self.topLevelItem(i) + for j in range(session_item.childCount()): + child = session_item.child(j) + uuid = child.data(0, QC.Qt.UserRole) + action = self.get_action_from_uuid(uuid, history_sessions) + compatible = action.is_current_state_compatible( + mainwindow, restore_selection=True + ) + child.setData(0, self.COMPATIBILITY_ROLE, compatible) + brush = default_brush if compatible else disabled_brush + icon = get_icon("apply.svg") if compatible else get_icon("delete.svg") + child.setIcon(0, icon) + for col in range(self.columnCount()): + child.setForeground(col, brush) + child.setToolTip( + col, compatible_tip if compatible else incompatible_tip + ) + + def forget_orphan_expanded_states( + self, history_sessions: list[HistorySession] + ) -> None: + """Drop expanded-state entries for actions that no longer exist.""" + live_uuids = { + action.uuid for session in history_sessions for action in session.actions + } + self.__expanded_state = { + uuid: state + for uuid, state in self.__expanded_state.items() + if uuid in live_uuids + } + + def populate_tree(self, history_sessions: list[HistorySession]) -> None: + """Populate the history tree widget + + Args: + history_sessions: List of history sessions + """ + self.forget_orphan_expanded_states(history_sessions) + self.clear() + for session in history_sessions: + ritem = QW.QTreeWidgetItem([session.title, session.dtstr]) + ritem.setData(0, self.COMPATIBILITY_ROLE, True) + self.addTopLevelItem(ritem) + for action in session.actions: + child = self.action_to_tree_item(action) + ritem.addChild(child) + self.install_description_widget(child, action) + self.expandAll() + for col in (0, 1): + self.resizeColumnToContents(col) + + def rearrange_tree(self) -> None: + """Rearrange the history tree widget""" + self.expandAll() + for col in (0, 1): + self.resizeColumnToContents(col) + + def add_action_to_tree(self, action: HistoryAction) -> None: + """Add an action to the history tree widget + + Args: + action: Action to add + """ + item = self.action_to_tree_item(action) + ritem = self.topLevelItem(self.topLevelItemCount() - 1) + ritem.addChild(item) + self.install_description_widget(item, action) + + def refresh_action_item(self, action: HistoryAction) -> None: + """Refresh the tree item corresponding to ``action``. + + Re-installs the description widget so it reflects the current + ``action.kwargs`` (e.g. after the user edited a ``param`` via the + Processing tab of the Signal/Image panel). Also applies a light + orange background when ``action.is_stale`` is True, to signal that + the action is currently being recomputed in a cascade. + """ + target_uuid = action.uuid + stale_brush = QG.QBrush(QG.QColor(255, 220, 150)) # light orange + normal_brush = QG.QBrush() + iterator = QW.QTreeWidgetItemIterator(self) + while iterator.value(): + item = iterator.value() + if item.data(0, QC.Qt.UserRole) == target_uuid: + # Remove and re-install the collapsible description widget so + # it reflects the mutated ``action.kwargs``. + self.removeItemWidget(item, self.DESCRIPTION_COLUMN) + self.install_description_widget(item, action) + brush = stale_brush if action.is_stale else normal_brush + for col in range(self.columnCount()): + item.setBackground(col, brush) + self.scheduleDelayedItemsLayout() + return + iterator += 1 + + def get_action_from_uuid( + self, uuid: str, history_sessions: list[HistorySession] + ) -> HistoryAction: + """Get the action from its UUID + + Args: + uuid: Action UUID + history_sessions: List of history sessions + + Returns: + HistoryAction: Action + """ + for session in history_sessions: + for action in session.actions: + if action.uuid == uuid: + return action + raise ValueError("Action not found") + + def get_selected_actions_or_sessions( + self, history_sessions: list[HistorySession] + ) -> list[HistoryAction | HistorySession]: + """Get the selected actions or sessions + + Args: + history_sessions: List of history sessions + + Returns: + list[HistoryAction | HistorySession]: List of selected actions or sessions + """ + selected: list[HistoryAction | HistorySession] = [] + for item in self.selectedItems(): + if item.parent() is None: + index = self.indexOfTopLevelItem(item) + selected.append(history_sessions[index]) + else: + uuid = item.data(0, QC.Qt.UserRole) + selected.append(self.get_action_from_uuid(uuid, history_sessions)) + return selected + + def get_selected_actions( + self, history_sessions: list[HistorySession] + ) -> list[HistoryAction]: + """Get the selected actions + + Args: + history_sessions: List of history sessions + + Returns: + list[HistoryAction]: List of selected actions + """ + selected: list[HistoryAction] = [] + for item in self.selectedItems(): + if item.parent() is not None: + uuid = item.data(0, QC.Qt.UserRole) + selected.append(self.get_action_from_uuid(uuid, history_sessions)) + return selected diff --git a/datalab/widgets/workspacestate_widget.py b/datalab/widgets/workspacestate_widget.py new file mode 100644 index 000000000..50b81c9e8 --- /dev/null +++ b/datalab/widgets/workspacestate_widget.py @@ -0,0 +1,82 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +"""Workspace state display widget used by the History panel.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from qtpy import QtCore as QC +from qtpy import QtWidgets as QW + +from datalab.config import _ + +if TYPE_CHECKING: + from datalab.history import WorkspaceState + + +class WorkspaceStateWidget(QW.QWidget): + """Side-by-side tables showing the workspace state captured by a history action. + + Left table: signals (title + data shape). + Right table: images (title + data shape/dimensions). + """ + + def __init__(self, parent: QW.QWidget | None = None) -> None: + super().__init__(parent) + self._signal_table = QW.QTableWidget(0, 2, self) + self._signal_table.setHorizontalHeaderLabels([_("Signal"), _("Shape")]) + self._signal_table.horizontalHeader().setStretchLastSection(True) + self._signal_table.setEditTriggers(QW.QAbstractItemView.NoEditTriggers) + self._signal_table.setSelectionMode(QW.QAbstractItemView.NoSelection) + self._signal_table.verticalHeader().hide() + + self._image_table = QW.QTableWidget(0, 2, self) + self._image_table.setHorizontalHeaderLabels([_("Image"), _("Dimensions")]) + self._image_table.horizontalHeader().setStretchLastSection(True) + self._image_table.setEditTriggers(QW.QAbstractItemView.NoEditTriggers) + self._image_table.setSelectionMode(QW.QAbstractItemView.NoSelection) + self._image_table.verticalHeader().hide() + + splitter = QW.QSplitter(QC.Qt.Horizontal, self) + splitter.addWidget(self._signal_table) + splitter.addWidget(self._image_table) + layout = QW.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(splitter) + + def update_from_state(self, state: WorkspaceState | None) -> None: + """Populate tables from a WorkspaceState.""" + self._signal_table.setRowCount(0) + self._image_table.setRowCount(0) + if state is None: + return + self.populate_table(self._signal_table, state, "signal") + self.populate_table(self._image_table, state, "image") + + @staticmethod + def populate_table( + table: QW.QTableWidget, state: WorkspaceState, panel_key: str + ) -> None: + """Fill a table from the state for a given panel key.""" + titles = state.titles.get(panel_key, []) + shapes = state.states.get(panel_key, []) + metadata = state.object_metadata.get(panel_key, {}) + uuids = state.selection.get(panel_key, []) + # Use metadata keyed by UUID when available + rows: list[tuple[str, str]] = [] + for i, uuid in enumerate(uuids): + title = titles[i] if i < len(titles) else uuid[:8] + meta = metadata.get(uuid, {}) + shape = meta.get("shape") + if shape is not None: + shape_str = " × ".join(str(s) for s in shape) + elif i < len(shapes): + shape_str = shapes[i] + else: + shape_str = "—" + rows.append((title, shape_str)) + table.setRowCount(len(rows)) + for row_idx, (title, shape_str) in enumerate(rows): + table.setItem(row_idx, 0, QW.QTableWidgetItem(title)) + table.setItem(row_idx, 1, QW.QTableWidgetItem(shape_str)) From 5cc3091950ce26fe6986bff927661213bb4e1974 Mon Sep 17 00:00:00 2001 From: Thomas MALLET Date: Wed, 24 Jun 2026 16:26:31 +0200 Subject: [PATCH 22/24] run ruff linter and fix warnings --- datalab/gui/panel/base.py | 4 +--- datalab/gui/panel/history/recompute.py | 1 - datalab/gui/processor/base.py | 10 +++------- datalab/history/core.py | 4 +++- datalab/tests/features/common/history_test.py | 2 +- 5 files changed, 8 insertions(+), 13 deletions(-) diff --git a/datalab/gui/panel/base.py b/datalab/gui/panel/base.py index 810d75d2e..46e46de97 100644 --- a/datalab/gui/panel/base.py +++ b/datalab/gui/panel/base.py @@ -213,9 +213,7 @@ def __init__(self, panel: BaseDataPanel, objclass: SignalObj | ImageObj) -> None self.__auto_recompute_enabled: bool = False self.__auto_recompute_timer = QC.QTimer(self) self.__auto_recompute_timer.setSingleShot(True) - self.__auto_recompute_timer.timeout.connect( - self.__auto_recompute_trigger - ) + self.__auto_recompute_timer.timeout.connect(self.__auto_recompute_trigger) # Properties tab self.properties = gdq.DataSetEditGroupBox("", objclass) diff --git a/datalab/gui/panel/history/recompute.py b/datalab/gui/panel/history/recompute.py index 308d34e58..8016f5d1e 100644 --- a/datalab/gui/panel/history/recompute.py +++ b/datalab/gui/panel/history/recompute.py @@ -7,7 +7,6 @@ import logging from typing import TYPE_CHECKING -from coverage.debug import pp from qtpy import QtWidgets as QW from sigima.objects import ImageObj, SignalObj diff --git a/datalab/gui/processor/base.py b/datalab/gui/processor/base.py index 2cae348f4..dec384ffb 100644 --- a/datalab/gui/processor/base.py +++ b/datalab/gui/processor/base.py @@ -1,4 +1,4 @@ -# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """ .. Base processor object (see parent package :mod:`datalab.gui.processor`) @@ -1510,9 +1510,7 @@ def recompute_1_to_0( ) historypanel = self.mainwindow.historypanel with historypanel.replaying(), Conf.proc.show_result_dialog.temp(False): - self.compute_1_to_0( - feature.function, param, edit=False, target_objs=[obj] - ) + self.compute_1_to_0(feature.function, param, edit=False, target_objs=[obj]) def _compute_1_to_1_subroutine( self, funcs: list[Callable], params: list, title: str @@ -1743,9 +1741,7 @@ def compute_multiple_1_to_1( panel_str=self.panel.PANEL_STR_ID, func_names=[f.__name__ for f in funcs], params=params if any(p is not None for p in params) else None, - plugin_origin=( - self._get_plugin_origin_for(funcs[0]) if funcs else None - ), + plugin_origin=(self._get_plugin_origin_for(funcs[0]) if funcs else None), ) with self.mainwindow.historypanel.capture_outputs(action): self._compute_1_to_1_subroutine(funcs, params, title) diff --git a/datalab/history/core.py b/datalab/history/core.py index 9ac7b3fcb..07ec11a4d 100644 --- a/datalab/history/core.py +++ b/datalab/history/core.py @@ -1,6 +1,8 @@ # Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. -"""History core utilities: schema constants, JSON codec, ``@add_to_history`` decorator.""" +""" +History core utilities: schema constants, JSON codec, ``@add_to_history`` decorator. +""" from __future__ import annotations diff --git a/datalab/tests/features/common/history_test.py b/datalab/tests/features/common/history_test.py index 6d03bf29a..be259cc4b 100644 --- a/datalab/tests/features/common/history_test.py +++ b/datalab/tests/features/common/history_test.py @@ -837,7 +837,7 @@ def capture_compute_2_to_1(obj2_arg, *_args, **kwargs): assert captured["obj2"] is obj2 assert captured["pairwise"] is True - # --- scenario: replay_restore_actions(replay=True) on 1_to_1 does NOT add output --- + # scenario: replay_restore_actions(replay=True) on 1_to_1 does NOT add output with datalab_test_app_context() as win: history = win.historypanel history.toggle_record_mode(True) From 8b88420b439cc369bb05f8c31a1f5bda98e2e589 Mon Sep 17 00:00:00 2001 From: Thomas MALLET Date: Wed, 24 Jun 2026 16:40:49 +0200 Subject: [PATCH 23/24] fix translations after merge --- datalab/locale/fr/LC_MESSAGES/datalab.po | 129 ++--- .../features/common/historypanel.po | 531 ++++-------------- .../LC_MESSAGES/release_notes/release_1.03.po | 6 - 3 files changed, 135 insertions(+), 531 deletions(-) diff --git a/datalab/locale/fr/LC_MESSAGES/datalab.po b/datalab/locale/fr/LC_MESSAGES/datalab.po index 92d14a7e6..223581775 100644 --- a/datalab/locale/fr/LC_MESSAGES/datalab.po +++ b/datalab/locale/fr/LC_MESSAGES/datalab.po @@ -1,6 +1,6 @@ -# French translations for DataLab. +# French translations for datalab. # Copyright (C) 2026 DataLab Platform Developers -# This file is distributed under the same license as the DataLab project. +# This file is distributed under the same license as the datalab project. # msgid "" msgstr "" @@ -940,6 +940,15 @@ msgstr "Afficher le panneau de contraste" msgid "Show or hide contrast adjustment panel" msgstr "Afficher ou cacher le panneau de réglage du contraste" +msgid "Command palette" +msgstr "Palette de commandes" + +msgid "Type to search for a command…" +msgstr "Tapez pour rechercher une commande…" + +msgid "Search a command…" +msgstr "Rechercher une commande…" + msgid "Process signal" msgstr "Traiter le signal" @@ -1044,7 +1053,7 @@ msgstr "Ignorer ce message empêchera son affichage ultérieur." msgid "Plugins" msgstr "Plugins" -msgid "Third-party plugins are disabled. Enable them in the Settings dialog to use this feature." +msgid "Third-party plugins are disabled. Enable them again from the plugin configuration dialog to use this feature." msgstr "Les plugins tiers sont désactivés. Activez-les dans la boîte de dialogue des préférences pour utiliser cette fonctionnalité." #, python-format @@ -1075,18 +1084,9 @@ msgstr "Préférences..." msgid "Open settings dialog" msgstr "Ouvrir la boîte de dialogue des préférences" -msgid "Command palette" -msgstr "Palette de commandes" - msgid "Command palette..." msgstr "Palette de commandes..." -msgid "Search a command…" -msgstr "Rechercher une commande…" - -msgid "Type to search for a command…" -msgstr "Tapez pour rechercher une commande…" - msgid "Search and run any command by its menu path" msgstr "Rechercher et exécuter n'importe quelle commande par son chemin de menu" @@ -1398,9 +1398,6 @@ msgstr "Paramètres pour la fonction `%s`" msgid "Created" msgstr "Créé" -msgid "Created" -msgstr "Créé" - msgid "Original object" msgstr "Objet original" @@ -1872,12 +1869,6 @@ msgstr "Fichiers Python" msgid "Clear console" msgstr "Effacer la console" -msgid "Python files" -msgstr "Fichiers Python" - -msgid "Clear console" -msgstr "Effacer la console" - msgid "-***- Macro Console -***-" msgstr "-***- Console des macros -***-" @@ -1993,30 +1984,30 @@ msgstr "Modifier le répertoire" msgid "Remove directory" msgstr "Supprimer le répertoire" +msgid "from" +msgstr "depuis" + msgid "Plugin Configuration" msgstr "Configuration des plugins" msgid "Enable/disable plugins" msgstr "Activer/désactiver les plugins" -msgid "Plugin search paths" -msgstr "Chemins de recherche des plugins" +msgid "Plugin settings" +msgstr "Paramètres des plugins" msgid "Apply and reload plugins" msgstr "Appliquer et recharger les plugins" +msgid "Third-party plugins are globally disabled." +msgstr "Les plugins tiers sont désactivés globalement." + msgid "Changes will be applied after clicking OK and reloading plugins." msgstr "Les modifications seront appliquées après avoir cliqué sur OK et rechargé les plugins." msgid "The following directories are scanned at startup for plugins:" msgstr "Les répertoires suivants sont analysés au démarrage pour détecter les plugins :" -msgid "from" -msgstr "depuis" - -msgid "from" -msgstr "depuis" - msgid "Default plugin directories" msgstr "Répertoires de plugins par défaut" @@ -2036,6 +2027,15 @@ msgstr "Aucun répertoire de plugins supplémentaire n'est configuré." msgid "Directories provided via the %senvironment variable (multiple paths separated by '%s') also appear above as read-only entries. Changes take effect at DataLab startup." msgstr "Les répertoires fournis via la variable d'environnement%s(plusieurs chemins séparés par '%s') apparaissent également ci-dessus en lecture seule. Les modifications prennent effet au démarrage de DataLab." +msgid "Compatibility warnings" +msgstr "Avertissements de compatibilité" + +msgid "Hide warnings for incompatible DataLab v0.20 plugins" +msgstr "Masquer les avertissements pour les plugins DataLab v0.20 incompatibles" + +msgid "If enabled, DataLab will not warn you about v0.20 plugins that are no longer compatible with v1.0." +msgstr "Si activé, DataLab ne vous avertira pas des plugins v0.20 qui ne sont plus compatibles avec v1.0." + msgid "Select plugin directory" msgstr "Sélectionner un répertoire de plugins" @@ -2057,32 +2057,17 @@ msgstr "Plugins désactivés" msgid "Plugins with errors" msgstr "Plugins en erreur" -msgid "Reload Plugins" -msgstr "Recharger les plugins" - -msgid "Plugin configuration has been saved. Do you want to reload plugins now to apply changes?" -msgstr "La configuration des plugins a été enregistrée. Voulez-vous recharger les plugins maintenant pour appliquer les modifications ?" - -msgid "Plugin settings" -msgstr "Paramètres des plugins" - -msgid "Third-party plugins are globally disabled." -msgstr "Les plugins tiers sont désactivés globalement." - -msgid "Compatibility warnings" -msgstr "Avertissements de compatibilité" - -msgid "Hide warnings for incompatible DataLab v0.20 plugins" -msgstr "Masquer les avertissements pour les plugins DataLab v0.20 incompatibles" - msgid "Disable plugins globally" msgstr "Désactiver globalement les plugins" msgid "Enable plugins globally" msgstr "Activer globalement les plugins" -msgid "Enable them again from the plugin configuration dialog to use this feature." -msgstr "Réactivez-les depuis la boîte de dialogue de configuration des plugins pour utiliser cette fonctionnalité." +msgid "Reload Plugins" +msgstr "Recharger les plugins" + +msgid "Plugin configuration has been saved. Do you want to reload plugins now to apply changes?" +msgstr "La configuration des plugins a été enregistrée. Voulez-vous recharger les plugins maintenant pour appliquer les modifications ?" msgid "Failed to deserialize processing parameters from JSON." msgstr "Échec de la désérialisation des paramètres de traitement depuis le format JSON." @@ -2941,21 +2926,6 @@ msgstr "Mo" msgid "Memory threshold below which a warning is displayed before loading any new data" msgstr "Seuil de mémoire en dessous duquel un avertissement est affiché avant de charger de nouvelles données" -msgid "Third-party plugins" -msgstr "Plugins tiers" - -msgid "Enable or disable third-party plugins immediately. Changes are applied without restarting DataLab" -msgstr "Activer ou désactiver immédiatement les plugins tiers. Les changements sont appliqués sans redémarrer DataLab" - -msgid "Ignore compatibility issues warning" -msgstr "Ignorer l'avertissement de compatibilité" - -msgid "DataLab v0.20 plugins" -msgstr "Plugins DataLab v0.20" - -msgid "If enabled, DataLab will not warn you about v0.20 plugins that are no longer compatible with v1.0." -msgstr "Si activé, DataLab ne vous avertira pas des plugins v0.20 qui ne sont plus compatibles avec v1.0." - msgid "Settings for internal console, used for debugging or advanced users" msgstr "Réglages de la console interne, utilisée pour le débogage ou les utilisateurs avancés" @@ -3523,9 +3493,6 @@ msgstr "Créer une image avec un anneau" msgid "Create image with a grid of gaussian spots" msgstr "Créer une image avec une grille de spots gaussiens" -msgid "New signal" -msgstr "Nouveau signal" - msgid "Host application" msgstr "Application hôte" @@ -3877,30 +3844,12 @@ msgstr "Afficher les détails" msgid "Hide details" msgstr "Masquer les détails" -msgid "Date and time" -msgstr "Date et heure" - msgid "Title" msgstr "Titre" -msgid "Action is compatible with the current workspace state." -msgstr "L'action est compatible avec l'état actuel de l'espace de travail." - -msgid "Action is not compatible with the current workspace state." -msgstr "L'action n'est pas compatible avec l'état actuel de l'espace de travail." - -msgid "Show details" -msgstr "Afficher les détails" - -msgid "Hide details" -msgstr "Masquer les détails" - msgid "Date and time" msgstr "Date et heure" -msgid "Title" -msgstr "Titre" - msgid "Action is compatible with the current workspace state." msgstr "L'action est compatible avec l'état actuel de l'espace de travail." @@ -3949,6 +3898,9 @@ msgstr "±{n} points" msgid "±{n} rows × ±{n} columns" msgstr "±{n} lignes × ±{n} colonnes" +msgid "This image uses an integer data type, so it cannot contain NaN or infinite values. Replace special values is therefore not applicable." +msgstr "Cette image utilise un type de données entier, elle ne peut donc pas contenir de valeurs NaN ou infinies. Le remplacement des valeurs spéciales n'est donc pas applicable." + msgid "Signal baseline selection" msgstr "Sélection de la ligne de base du signal" @@ -3976,9 +3928,6 @@ msgstr "Détection de pics 1D" msgid "Peaks:" msgstr "Pics :" -msgid "Peaks:" -msgstr "Pics :" - msgid "Internal console" msgstr "Console interne" @@ -4293,9 +4242,6 @@ msgstr "Image" msgid "Dimensions" msgstr "Dimensions" -msgid "This image uses an integer data type, so it cannot contain NaN or infinite values. Replace special values is therefore not applicable." -msgstr "Cette image utilise un type de données entier, elle ne peut donc pas contenir de valeurs NaN ou infinies. Le remplacement des valeurs spéciales n'est donc pas applicable." - msgid "Minimum value" msgstr "Valeur minimum" @@ -4344,4 +4290,3 @@ msgstr "Aucun contour n'a été trouvé pour la plage de niveaux sélectionnée. msgid "Show contour plot..." msgstr "Afficher le tracé de contours..." - diff --git a/doc/locale/fr/LC_MESSAGES/features/common/historypanel.po b/doc/locale/fr/LC_MESSAGES/features/common/historypanel.po index 8183e3c0c..daf1be0d6 100644 --- a/doc/locale/fr/LC_MESSAGES/features/common/historypanel.po +++ b/doc/locale/fr/LC_MESSAGES/features/common/historypanel.po @@ -5,32 +5,17 @@ # msgid "" msgstr "" -"Project-Id-Version: DataLab \n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-06-02 15:49+0200\n" -"PO-Revision-Date: 2026-05-16 11:35+0200\n" -"Last-Translator: DataLab Platform Developers\n" "Language: fr\n" -"Language-Team: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.18.0\n" -msgid "" -"History Panel in DataLab, the open-source scientific data analysis and " -"visualization platform" -msgstr "" -"Panneau historique de DataLab, la plateforme open-source d'analyse et de " -"visualisation de données scientifiques" +msgid "History Panel in DataLab, the open-source scientific data analysis and visualization platform" +msgstr "Panneau historique de DataLab, la plateforme open-source d'analyse et de visualisation de données scientifiques" -msgid "" -"DataLab, history, record, replay, session, scientific, data, analysis, " -"visualization, platform" -msgstr "" -"DataLab, historique, enregistrement, rejeu, session, scientifique, " -"données, analyse, visualisation, plateforme" +msgid "DataLab, history, record, replay, session, scientific, data, analysis, visualization, platform" +msgstr "DataLab, historique, enregistrement, rejeu, session, scientifique, données, analyse, visualisation, plateforme" msgid "History Panel" msgstr "Panneau historique" @@ -38,74 +23,32 @@ msgstr "Panneau historique" msgid "Overview" msgstr "Vue d'ensemble" -msgid "" -"The \"History Panel\" records the sequence of actions performed by the " -"user on signals and images, organized into **sessions**. Each session is " -"a chronological list of either:" -msgstr "" -"Le « Panneau historique » enregistre la séquence des actions effectuées " -"par l'utilisateur sur les signaux et les images, organisée en " -"**sessions**. Chaque session est une liste chronologique constituée soit " -":" +msgid "The \"History Panel\" records the sequence of actions performed by the user on signals and images, organized into **sessions**. Each session is a chronological list of either:" +msgstr "Le « Panneau historique » enregistre la séquence des actions effectuées par l'utilisateur sur les signaux et les images, organisée en **sessions**. Chaque session est une liste chronologique constituée soit :" -msgid "" -"**UI actions** (creating a new signal, removing selected objects, saving " -"the workspace to HDF5, ...), or" -msgstr "" -"d'**actions de l'interface** (création d'un nouveau signal, suppression " -"des objets sélectionnés, enregistrement de l'espace de travail au format " -"HDF5, ...), soit" +msgid "**UI actions** (creating a new signal, removing selected objects, saving the workspace to HDF5, ...), or" +msgstr "d'**actions de l'interface** (création d'un nouveau signal, suppression des objets sélectionnés, enregistrement de l'espace de travail au format HDF5, ...), soit" -msgid "" -"**computations** (FFT, average, Gaussian fit, ...) dispatched through the" -" Sigima processor." -msgstr "" -"de **calculs** (FFT, moyenne, ajustement gaussien, ...) exécutés via le " -"processeur Sigima." +msgid "**computations** (FFT, average, Gaussian fit, ...) dispatched through the Sigima processor." +msgstr "de **calculs** (FFT, moyenne, ajustement gaussien, ...) exécutés via le processeur Sigima." msgid "A recorded session can be:" msgstr "Une session enregistrée peut être :" -msgid "" -"**Replayed** in validation mode, without adding new signal/image outputs " -"to the workspace;" -msgstr "" -"**rejouée** en mode validation, sans ajouter de nouvelles sorties " -"signal/image à l'espace de travail ;" +msgid "**Replayed** in validation mode, without adding new signal/image outputs to the workspace;" +msgstr "**rejouée** en mode validation, sans ajouter de nouvelles sorties signal/image à l'espace de travail ;" -msgid "" -"**Duplicated and applied**, to create an explicit comparison branch with " -"new outputs in the signal/image panels;" -msgstr "" -"**dupliquée et appliquée**, afin de créer une branche de comparaison " -"explicite avec de nouvelles sorties dans les panneaux signal/image ;" +msgid "**Duplicated and applied**, to create an explicit comparison branch with new outputs in the signal/image panels;" +msgstr "**dupliquée et appliquée**, afin de créer une branche de comparaison explicite avec de nouvelles sorties dans les panneaux signal/image ;" -msgid "" -"**Restored to a given selection state** without re-executing anything, to" -" quickly jump back to a previous working context;" -msgstr "" -"**restaurée dans un état de sélection donné** sans rien réexécuter, afin " -"de revenir rapidement à un contexte de travail antérieur ;" +msgid "**Restored to a given selection state** without re-executing anything, to quickly jump back to a previous working context;" +msgstr "**restaurée dans un état de sélection donné** sans rien réexécuter, afin de revenir rapidement à un contexte de travail antérieur ;" -msgid "" -"**Saved to a standalone history file** (``.dlhist``) or **embedded in the" -" workspace** when saving to HDF5, so that the full processing chain " -"travels with the data." -msgstr "" -"**sauvegardée dans un fichier d'historique autonome** (``.dlhist``) ou " -"**intégrée à l'espace de travail** lors de l'enregistrement au format " -"HDF5, de sorte que toute la chaîne de traitement accompagne les données." +msgid "**Saved to a standalone history file** (``.dlhist``) or **embedded in the workspace** when saving to HDF5, so that the full processing chain travels with the data." +msgstr "**sauvegardée dans un fichier d'historique autonome** (``.dlhist``) ou **intégrée à l'espace de travail** lors de l'enregistrement au format HDF5, de sorte que toute la chaîne de traitement accompagne les données." -msgid "" -"The History Panel after recording a representative session: create three " -"signals (Voigt, Lorentzian, Lorentzian), remove one of them, create a " -"Gaussian signal, compute the average, add Gaussian noise to the result " -"and run a Gaussian fit." -msgstr "" -"Le Panneau historique après l'enregistrement d'une session représentative" -" : création de trois signaux (Voigt, lorentzien, lorentzien), suppression" -" de l'un d'eux, création d'un signal gaussien, calcul de la moyenne, " -"ajout d'un bruit gaussien au résultat et ajustement par une gaussienne." +msgid "The History Panel after recording a representative session: create three signals (Voigt, Lorentzian, Lorentzian), remove one of them, create a Gaussian signal, compute the average, add Gaussian noise to the result and run a Gaussian fit." +msgstr "Le Panneau historique après l'enregistrement d'une session représentative : création de trois signaux (Voigt, lorentzien, lorentzien), suppression de l'un d'eux, création d'un signal gaussien, calcul de la moyenne, ajout d'un bruit gaussien au résultat et ajustement par une gaussienne." msgid "Toolbar" msgstr "Barre d'outils" @@ -113,276 +56,131 @@ msgstr "Barre d'outils" msgid "The toolbar at the top of the panel exposes the following actions:" msgstr "La barre d'outils en haut du panneau expose les actions suivantes :" -msgid "" -"|record| **Record mode**: toggle the recording of new actions. When off, " -"no new entry is added to the history (existing sessions are preserved)." -msgstr "" -"|record| **Mode enregistrement** : active ou désactive l'enregistrement " -"des nouvelles actions. Lorsqu'il est désactivé, aucune nouvelle entrée " -"n'est ajoutée à l'historique (les sessions existantes sont conservées)." +msgid "|record| **Record mode**: toggle the recording of new actions. When off, no new entry is added to the history (existing sessions are preserved)." +msgstr "|record| **Mode enregistrement** : active ou désactive l'enregistrement des nouvelles actions. Lorsqu'il est désactivé, aucune nouvelle entrée n'est ajoutée à l'historique (les sessions existantes sont conservées)." msgid "record" msgstr "record" -msgid "" -"|open_history| **Open history file**: load recorded sessions from a " -"standalone ``.dlhist`` file." -msgstr "" -"|open_history| **Ouvrir un fichier d'historique** : charge des sessions " -"enregistrées depuis un fichier ``.dlhist`` autonome." +msgid "|open_history| **Open history file**: load recorded sessions from a standalone ``.dlhist`` file." +msgstr "|open_history| **Ouvrir un fichier d'historique** : charge des sessions enregistrées depuis un fichier ``.dlhist`` autonome." msgid "open_history" msgstr "open_history" -msgid "" -"|save_history| **Save history file**: save the current recorded sessions " -"to a standalone ``.dlhist`` file." -msgstr "" -"|save_history| **Enregistrer le fichier d'historique** : enregistre les " -"sessions actuellement enregistrées dans un fichier ``.dlhist`` autonome." +msgid "|save_history| **Save history file**: save the current recorded sessions to a standalone ``.dlhist`` file." +msgstr "|save_history| **Enregistrer le fichier d'historique** : enregistre les sessions actuellement enregistrées dans un fichier ``.dlhist`` autonome." msgid "save_history" msgstr "save_history" -msgid "" -"|replay| **Replay**: validate/replay the selected action (or the whole " -"session if a session row is selected) without changing the current " -"workspace selection beforehand and without adding new outputs to the " -"signal/image panels." -msgstr "" -"|replay| **Rejouer** : valide/rejoue l'action sélectionnée (ou la session" -" entière si une ligne de session est sélectionnée) sans modifier au " -"préalable la sélection courante de l'espace de travail et sans ajouter de" -" nouvelles sorties aux panneaux signal/image." +msgid "|replay| **Replay**: validate/replay the selected action (or the whole session if a session row is selected) without changing the current workspace selection beforehand and without adding new outputs to the signal/image panels." +msgstr "|replay| **Rejouer** : valide/rejoue l'action sélectionnée (ou la session entière si une ligne de session est sélectionnée) sans modifier au préalable la sélection courante de l'espace de travail et sans ajouter de nouvelles sorties aux panneaux signal/image." msgid "replay" msgstr "replay" -msgid "" -"|restore_selection| **Restore selection**: only re-select the objects " -"that were selected when the action was originally executed; no " -"computation is re-run." -msgstr "" -"|restore_selection| **Restaurer la sélection** : ré-sélectionne " -"uniquement les objets qui étaient sélectionnés lors de l'exécution " -"initiale de l'action ; aucun calcul n'est ré-exécuté." +msgid "|restore_selection| **Restore selection**: only re-select the objects that were selected when the action was originally executed; no computation is re-run." +msgstr "|restore_selection| **Restaurer la sélection** : ré-sélectionne uniquement les objets qui étaient sélectionnés lors de l'exécution initiale de l'action ; aucun calcul n'est ré-exécuté." msgid "restore_selection" msgstr "restore_selection" -msgid "" -"|edit_mode| **Edit mode**: when on, replaying a computation opens the " -"parameters dialog so the user can tweak the parameters before re-running." -msgstr "" -"|edit_mode| **Mode édition** : lorsqu'il est activé, rejouer un calcul " -"ouvre la boîte de dialogue des paramètres afin que l'utilisateur puisse " -"les ajuster avant de relancer l'exécution." +msgid "|edit_mode| **Edit mode**: when on, replaying a computation opens the parameters dialog so the user can tweak the parameters before re-running. When replaying a *whole session*, the parameter dialogs open in a **read-only** mode — all fields are shown with their recorded values but cannot be edited." +msgstr "|edit_mode| **Mode édition** : lorsqu'il est activé, le rejeu d'un calcul ouvre la boîte de dialogue des paramètres afin que l'utilisateur puisse ajuster les paramètres avant de relancer. Lors du rejeu d'une *session entière*, les boîtes de dialogue des paramètres s'ouvrent en mode **lecture seule** — tous les champs affichent les valeurs enregistrées mais ne peuvent pas être édités." msgid "edit_mode" msgstr "edit_mode" -msgid "" -"|duplicate| **Duplicate**: copy the selected action or session into a new" -" history session. The copied parameters are independent from the original" -" record." -msgstr "" -"|duplicate| **Dupliquer** : copie l'action ou la session sélectionnée " -"dans une nouvelle session d'historique. Les paramètres copiés sont " -"indépendants de l'enregistrement d'origine." +msgid "|duplicate| **Duplicate**: copy the selected action or session into a new history session. The copied parameters are independent from the original record." +msgstr "|duplicate| **Dupliquer** : copie l'action ou la session sélectionnée dans une nouvelle session d'historique. Les paramètres copiés sont indépendants de l'enregistrement d'origine." msgid "duplicate" msgstr "duplicate" -msgid "" -"|generate_macro| **Generate macro**: generate a Python macro script from " -"the selected actions (or all actions if nothing is selected). The " -"generated script is copied to the clipboard." -msgstr "" -"|generate_macro| **Générer une macro** : génère un script macro Python à " -"partir des actions sélectionnées (ou de toutes les actions si rien n'est " -"sélectionné). Le script généré est copié dans le presse-papiers." +msgid "|generate_macro| **Generate macro**: generate a Python macro script from the selected actions (or all actions if nothing is selected). The generated script is copied to the clipboard." +msgstr "|generate_macro| **Générer une macro** : génère un script macro Python à partir des actions sélectionnées (ou de toutes les actions si rien n'est sélectionné). Le script généré est copié dans le presse-papiers." msgid "generate_macro" msgstr "generate_macro" -msgid "" -"|remove_incompatible| **Remove incompatible**: remove all actions whose " -"workspace state is no longer compatible with the current workspace. A " -"confirmation dialog shows how many actions will be removed." -msgstr "" -"|remove_incompatible| **Supprimer les incompatibles** : supprime toutes " -"les actions dont l'état de l'espace de travail n'est plus compatible avec " -"l'espace de travail actuel. Une boîte de dialogue de confirmation indique " -"combien d'actions seront supprimées." +msgid "|remove_incompatible| **Remove incompatible**: remove all actions whose workspace state is no longer compatible with the current workspace. A confirmation dialog shows how many actions will be removed." +msgstr "|remove_incompatible| **Supprimer les incompatibles** : supprime toutes les actions dont l'état de l'espace de travail n'est plus compatible avec l'espace de travail actuel. Une boîte de dialogue de confirmation indique combien d'actions seront supprimées." msgid "remove_incompatible" msgstr "remove_incompatible" -msgid "" -"|delete| **Delete**: remove the selected actions or sessions from the " -"history." -msgstr "" -"|delete| **Supprimer** : retire de l'historique les actions ou sessions " -"sélectionnées." +msgid "|delete| **Delete**: remove the selected actions or sessions from the history." +msgstr "|delete| **Supprimer** : retire de l'historique les actions ou sessions sélectionnées." msgid "delete" msgstr "delete" -msgid "" -"|step_prev| **Previous step**: select the preceding action in the current" -" session (keyboard shortcut: :kbd:`Ctrl+Left`)." -msgstr "" -"|step_prev| **Étape précédente** : sélectionne l'action précédente dans " -"la session courante (raccourci clavier : :kbd:`Ctrl+Gauche`)." +msgid "|step_prev| **Previous step**: select the preceding action in the current session (keyboard shortcut: :kbd:`Ctrl+Left`)." +msgstr "|step_prev| **Étape précédente** : sélectionne l'action précédente dans la session courante (raccourci clavier : :kbd:`Ctrl+Gauche`)." msgid "step_prev" msgstr "step_prev" -msgid "" -"|step_next| **Next step**: select the following action in the current " -"session (keyboard shortcut: :kbd:`Ctrl+Right`)." -msgstr "" -"|step_next| **Étape suivante** : sélectionne l'action suivante dans la " -"session courante (raccourci clavier : :kbd:`Ctrl+Droite`)." +msgid "|step_next| **Next step**: select the following action in the current session (keyboard shortcut: :kbd:`Ctrl+Right`)." +msgstr "|step_next| **Étape suivante** : sélectionne l'action suivante dans la session courante (raccourci clavier : :kbd:`Ctrl+Droite`)." msgid "step_next" msgstr "step_next" msgid "Double-clicking on an action row in the tree is equivalent to **Replay**." -msgstr "" -"Un double-clic sur la ligne d'une action dans l'arborescence équivaut à " -"**Rejouer**." +msgstr "Un double-clic sur la ligne d'une action dans l'arborescence équivaut à **Rejouer**." msgid "Tree view" msgstr "Arborescence" msgid "The tree view organizes recorded actions into expandable sessions:" -msgstr "" -"L'arborescence organise les actions enregistrées dans des sessions " -"dépliables :" +msgstr "L'arborescence organise les actions enregistrées dans des sessions dépliables :" -msgid "" -"Each top-level row is a **session**, automatically created when recording" -" is enabled and a new application context is started." -msgstr "" -"Chaque ligne de premier niveau est une **session**, créée automatiquement" -" lorsque l'enregistrement est activé et qu'un nouveau contexte " -"d'application est démarré." +msgid "Each top-level row is a **session**, automatically created when recording is enabled and a new application context is started." +msgstr "Chaque ligne de premier niveau est une **session**, créée automatiquement lorsque l'enregistrement est activé et qu'un nouveau contexte d'application est démarré." -msgid "" -"Each child row is an **action**, with its title, date/time and a " -"description summarising the parameters (for computations) or the call " -"(for UI actions)." -msgstr "" -"Chaque ligne enfant est une **action**, accompagnée de son titre, de sa " -"date et de son heure, ainsi que d'une description résumant les paramètres" -" (pour les calculs) ou l'appel (pour les actions de l'interface)." +msgid "Each child row is an **action**, with its title, date/time and a description summarising the parameters (for computations) or the call (for UI actions)." +msgstr "Chaque ligne enfant est une **action**, accompagnée de son titre, de sa date et de son heure, ainsi que d'une description résumant les paramètres (pour les calculs) ou l'appel (pour les actions de l'interface)." -msgid "" -"The selection of one or several rows drives which actions are targeted by" -" the toolbar buttons." -msgstr "" -"La sélection d'une ou de plusieurs lignes détermine les actions ciblées " -"par les boutons de la barre d'outils." +msgid "The selection of one or several rows drives which actions are targeted by the toolbar buttons." +msgstr "La sélection d'une ou de plusieurs lignes détermine les actions ciblées par les boutons de la barre d'outils." -msgid "" -"Actions that are not compatible with the current workspace state (for " -"example because a referenced object identifier no longer exists, or " -"because its data shape changed) are shown with a disabled foreground and " -"an explanatory tooltip. They cannot be replayed until the workspace " -"matches the recorded state again." -msgstr "" -"Les actions qui ne sont pas compatibles avec l'état courant de l'espace " -"de travail (par exemple parce qu'un identifiant d'objet référencé " -"n'existe plus, ou parce que la forme de ses données a changé) sont " -"affichées avec un texte désactivé et une infobulle explicative. Elles ne " -"peuvent pas être rejouées tant que l'espace de travail ne correspond pas " -"de nouveau à l'état enregistré." +msgid "Actions that are not compatible with the current workspace state (for example because a referenced object identifier no longer exists, or because its data shape changed) are shown with a disabled foreground and an explanatory tooltip. They cannot be replayed until the workspace matches the recorded state again." +msgstr "Les actions qui ne sont pas compatibles avec l'état courant de l'espace de travail (par exemple parce qu'un identifiant d'objet référencé n'existe plus, ou parce que la forme de ses données a changé) sont affichées avec un texte désactivé et une infobulle explicative. Elles ne peuvent pas être rejouées tant que l'espace de travail ne correspond pas de nouveau à l'état enregistré." msgid "Workspace state display" msgstr "Affichage de l'état de l'espace de travail" -msgid "" -"Below the action tree, a split-view widget shows the **workspace state** " -"captured at the time of the selected action:" -msgstr "" -"Sous l'arborescence des actions, un widget en vue divisée affiche " -"l'**état de l'espace de travail** tel qu'il était au moment de l'action " -"sélectionnée :" +msgid "Below the action tree, a split-view widget shows the **workspace state** captured at the time of the selected action:" +msgstr "Sous l'arborescence des actions, un widget en vue divisée affiche l'**état de l'espace de travail** tel qu'il était au moment de l'action sélectionnée :" -msgid "" -"**Left table**: lists the signals that were selected, with their data " -"shape." -msgstr "" -"**Tableau de gauche** : liste les signaux qui étaient sélectionnés, avec " -"leur forme de données." +msgid "**Left table**: lists the signals that were selected, with their data shape." +msgstr "**Tableau de gauche** : liste les signaux qui étaient sélectionnés, avec leur forme de données." -msgid "" -"**Right table**: lists the images that were selected, with their " -"dimensions." -msgstr "" -"**Tableau de droite** : liste les images qui étaient sélectionnées, avec " -"leurs dimensions." +msgid "**Right table**: lists the images that were selected, with their dimensions." +msgstr "**Tableau de droite** : liste les images qui étaient sélectionnées, avec leurs dimensions." -msgid "" -"This information helps the user understand the context in which each " -"action was originally executed and diagnose compatibility issues when " -"replaying sessions on a different workspace." -msgstr "" -"Ces informations aident l'utilisateur à comprendre le contexte dans " -"lequel chaque action a été exécutée à l'origine et à diagnostiquer les " -"problèmes de compatibilité lors du rejeu de sessions sur un espace de " -"travail différent." +msgid "This information helps the user understand the context in which each action was originally executed and diagnose compatibility issues when replaying sessions on a different workspace." +msgstr "Ces informations aident l'utilisateur à comprendre le contexte dans lequel chaque action a été exécutée à l'origine et à diagnostiquer les problèmes de compatibilité lors du rejeu de sessions sur un espace de travail différent." msgid "Session replay across workspaces" msgstr "Rejeu d'une session entre espaces de travail" -msgid "" -"A full session can be replayed on a workspace that no longer contains the" -" objects originally referenced by the recorded actions -- typically after" -" loading a saved session into a fresh workspace. In that case, the panel " -"**remaps the recorded object identifiers** to the newly-created ones on " -"the fly:" -msgstr "" -"Une session complète peut être rejouée sur un espace de travail qui ne " -"contient plus les objets référencés à l'origine par les actions " -"enregistrées -- typiquement après le chargement d'une session sauvegardée" -" dans un espace de travail vierge. Dans ce cas, le panneau **réassocie à " -"la volée les identifiants d'objets enregistrés** aux nouveaux " -"identifiants créés :" +msgid "A full session can be replayed on a workspace that no longer contains the objects originally referenced by the recorded actions -- typically after loading a saved session into a fresh workspace. In that case, the panel **remaps the recorded object identifiers** to the newly-created ones on the fly:" +msgstr "Une session complète peut être rejouée sur un espace de travail qui ne contient plus les objets référencés à l'origine par les actions enregistrées -- typiquement après le chargement d'une session sauvegardée dans un espace de travail vierge. Dans ce cas, le panneau **réassocie à la volée les identifiants d'objets enregistrés** aux nouveaux identifiants créés :" -msgid "" -"UI actions creating new objects (e.g. *New signal*) enqueue the freshly " -"created identifiers;" -msgstr "" -"les actions de l'interface qui créent de nouveaux objets (par exemple " -"*Nouveau signal*) empilent les identifiants fraîchement créés ;" +msgid "UI actions creating new objects (e.g. *New signal*) enqueue the freshly created identifiers;" +msgstr "les actions de l'interface qui créent de nouveaux objets (par exemple *Nouveau signal*) empilent les identifiants fraîchement créés ;" -msgid "" -"subsequent computations claim the identifiers they need from that queue, " -"in the same order as the original recording;" -msgstr "" -"les calculs ultérieurs récupèrent dans cette file les identifiants dont " -"ils ont besoin, dans l'ordre de l'enregistrement initial ;" +msgid "subsequent computations claim the identifiers they need from that queue, in the same order as the original recording;" +msgstr "les calculs ultérieurs récupèrent dans cette file les identifiants dont ils ont besoin, dans l'ordre de l'enregistrement initial ;" -msgid "" -"UI actions removing objects keep the queue in sync with the live " -"workspace contents, so chained creation/removal sequences replay " -"correctly." -msgstr "" -"les actions de l'interface qui suppriment des objets maintiennent la file" -" synchronisée avec le contenu réel de l'espace de travail, de sorte que " -"les séquences enchaînées de création et de suppression se rejouent " -"correctement." +msgid "UI actions removing objects keep the queue in sync with the live workspace contents, so chained creation/removal sequences replay correctly." +msgstr "les actions de l'interface qui suppriment des objets maintiennent la file synchronisée avec le contenu réel de l'espace de travail, de sorte que les séquences enchaînées de création et de suppression se rejouent correctement." -msgid "" -"This makes it possible, for instance, to record a full processing chain " -"on one dataset, save it, then re-apply the exact same chain on a " -"different but structurally identical input." -msgstr "" -"Cela permet par exemple d'enregistrer une chaîne de traitement complète " -"sur un jeu de données, de la sauvegarder, puis de la ré-appliquer telle " -"quelle à une entrée différente mais structurellement identique." +msgid "This makes it possible, for instance, to record a full processing chain on one dataset, save it, then re-apply the exact same chain on a different but structurally identical input." +msgstr "Cela permet par exemple d'enregistrer une chaîne de traitement complète sur un jeu de données, de la sauvegarder, puis de la ré-appliquer telle quelle à une entrée différente mais structurellement identique." msgid "Persistence" msgstr "Persistance" @@ -390,177 +188,44 @@ msgstr "Persistance" msgid "The history can be persisted in two complementary ways:" msgstr "L'historique peut être persisté de deux manières complémentaires :" -msgid "" -"**Embedded in the workspace**: when the workspace is saved to HDF5 " -"(``File > Save to HDF5 file``), the History Panel content is " -"automatically saved alongside the signals and images. Reloading the " -"workspace restores the recorded sessions." -msgstr "" -"**Intégré à l'espace de travail** : lorsque l'espace de travail est " -"enregistré au format HDF5 (``Fichier > Enregistrer dans un fichier " -"HDF5``), le contenu du Panneau historique est automatiquement sauvegardé " -"aux côtés des signaux et des images. Le rechargement de l'espace de " -"travail restaure les sessions enregistrées." +msgid "**Embedded in the workspace**: when the workspace is saved to HDF5 (``File > Save to HDF5 file``), the History Panel content is automatically saved alongside the signals and images. Reloading the workspace restores the recorded sessions." +msgstr "**Intégré à l'espace de travail** : lorsque l'espace de travail est enregistré au format HDF5 (``Fichier > Enregistrer dans un fichier HDF5``), le contenu du Panneau historique est automatiquement sauvegardé aux côtés des signaux et des images. Le rechargement de l'espace de travail restaure les sessions enregistrées." -msgid "" -"**Standalone history file**: the panel can also be serialised to a " -"dedicated ``.dlhist`` file, which is convenient to share or version a " -"processing chain independently of the data it was applied to." -msgstr "" -"**Fichier d'historique autonome** : le panneau peut également être " -"sérialisé dans un fichier ``.dlhist`` dédié, ce qui est pratique pour " -"partager ou versionner une chaîne de traitement indépendamment des " -"données auxquelles elle a été appliquée." +msgid "**Standalone history file** (``.dlhist``): the file embeds both the recorded sessions **and** all signal/image objects referenced by those sessions. This makes the file fully self-contained:" +msgstr "**Fichier d'historique autonome** (``.dlhist``) : le fichier embarque à la fois les sessions enregistrées **et** tous les objets signal/image référencés par ces sessions. Cela rend le fichier entièrement autonome :" -msgid "" -"Replaying a session that depends on external files (e.g. opening a " -"dataset from disk) will only succeed if those files are still available " -"at the same locations as when the session was recorded." -msgstr "" -"Le rejeu d'une session qui dépend de fichiers externes (par exemple " -"l'ouverture d'un jeu de données depuis le disque) ne réussira que si ces " -"fichiers sont toujours disponibles aux mêmes emplacements qu'au moment de" -" l'enregistrement de la session." +msgid "Opening a ``.dlhist`` into an **empty workspace** loads sessions and objects directly, restoring the workspace to its recorded state." +msgstr "Ouvrir un fichier ``.dlhist`` dans un **espace de travail vide** charge les sessions et les objets directement, restaurant l'espace de travail dans son état enregistré." -msgid "Auto-recompute" -msgstr "Recalcul automatique" +msgid "Opening a ``.dlhist`` into a **non-empty workspace** creates new signal/image groups for the imported objects (with remapped identifiers to avoid collisions) and appends new history sessions that reference those fresh identifiers." +msgstr "Ouvrir un fichier ``.dlhist`` dans un **espace de travail non vide** crée de nouveaux groupes signal/image pour les objets importés (avec des identifiants remappés pour éviter les collisions) et ajoute de nouvelles sessions d'historique référençant ces nouveaux identifiants." -msgid "" -"When a result object is selected in the signal/image panel and it has " -"processing parameters (i.e. was produced by a 1-to-1 computation), a " -"**Processing** tab appears in the Properties panel. Checking **Auto-" -"recompute on edit** in that tab will re-run the computation automatically" -" 300 ms after any parameter modification." -msgstr "" -"Lorsqu'un objet résultat est sélectionné dans le panneau signal/image et " -"qu'il possède des paramètres de traitement (c'est-à-dire qu'il a été " -"produit par un calcul 1-à-1), un onglet **Traitement** apparaît dans le " -"panneau Propriétés. Cocher **Recalcul automatique lors de l'édition** " -"dans cet onglet relancera automatiquement le calcul 300 ms après toute " -"modification d'un paramètre." - -msgid "" -"When replaying a *whole session*, the parameter dialogs open in a " -"**read-only** mode — all fields are shown with their recorded values but " -"cannot be edited." -msgstr "" -"Lors du rejeu d'une *session entière*, les boîtes de dialogue des " -"paramètres s'ouvrent en mode **lecture seule** — tous les champs " -"affichent les valeurs enregistrées mais ne peuvent pas être édités." +msgid "Replaying a session that depends on external files (e.g. opening a dataset from disk) will only succeed if those files are still available at the same locations as when the session was recorded." +msgstr "Le rejeu d'une session qui dépend de fichiers externes (par exemple l'ouverture d'un jeu de données depuis le disque) ne réussira que si ces fichiers sont toujours disponibles aux mêmes emplacements qu'au moment de l'enregistrement de la session." msgid "Chain reconnection on deletion" msgstr "Reconnexion de la chaîne lors d'une suppression" -msgid "" -"When a result object is deleted from the **signal or image panel** (not " -"from the History Panel tree), and that object was produced by a recorded " -"processing step, the History Panel automatically reconnects the processing " -"chain:" -msgstr "" -"Lorsqu'un objet résultat est supprimé depuis le **panneau signal ou image** " -"(et non depuis l'arborescence du panneau historique), et que cet objet a " -"été produit par une étape de traitement enregistrée, le Panneau historique " -"reconnecte automatiquement la chaîne de traitement :" +msgid "When a result object is deleted from the **signal or image panel** (not from the History Panel tree), and that object was produced by a recorded processing step, the History Panel automatically reconnects the processing chain:" +msgstr "Lorsqu'un objet résultat est supprimé depuis le **panneau signal ou image** (et non depuis l'arborescence du panneau historique), et que cet objet a été produit par une étape de traitement enregistrée, le Panneau historique reconnecte automatiquement la chaîne de traitement :" -msgid "" -"All downstream steps that consumed the deleted object are rewired to use " -"the source of the deleted step as their new input." -msgstr "" -"Toutes les étapes aval qui consommaient l'objet supprimé sont recâblées " -"pour utiliser la source de l'étape supprimée comme nouvelle entrée." - -msgid "" -"For ``2_to_1`` operations (e.g. *difference*), the first source is used " -"for reconnection." -msgstr "" -"Pour les opérations ``2_to_1`` (par exemple *différence*), la première " -"source est utilisée pour la reconnexion." - -msgid "" -"If no valid source can be determined (e.g. the source itself was already " -"deleted), a warning is displayed listing the unreconnectable operations, " -"but the deletion is allowed to proceed." -msgstr "" -"Si aucune source valide ne peut être déterminée (par exemple la source " -"elle-même a déjà été supprimée), un avertissement est affiché listant les " -"opérations non reconnectables, mais la suppression est néanmoins autorisée." +msgid "All downstream steps that consumed the deleted object are rewired to use the source of the deleted step as their new input." +msgstr "Toutes les étapes aval qui consommaient l'objet supprimé sont recâblées pour utiliser la source de l'étape supprimée comme nouvelle entrée." -msgid "" -"This behaviour mirrors removing a link from a chain: the adjacent links " -"reconnect to preserve the processing flow." -msgstr "" -"Ce comportement reproduit la suppression d'un maillon d'une chaîne : les " -"maillons adjacents se reconnectent pour préserver le flux de traitement." +msgid "For ``2_to_1`` operations (e.g. *difference*), the first source is used for reconnection." +msgstr "Pour les opérations ``2_to_1`` (par exemple *différence*), la première source est utilisée pour la reconnexion." -msgid "" -"Reconnection is only triggered by deletions initiated from the " -"signal/image panels. Deleting an action directly from the History Panel " -"tree removes it and all subsequent actions in that session." -msgstr "" -"La reconnexion n'est déclenchée que par les suppressions initiées depuis " -"les panneaux signal/image. Supprimer une action directement depuis " -"l'arborescence du Panneau historique la retire ainsi que toutes les " -"actions suivantes de cette session." +msgid "If no valid source can be determined (e.g. the source itself was already deleted), a warning is displayed listing the unreconnectable operations, but the deletion is allowed to proceed." +msgstr "Si aucune source valide ne peut être déterminée (par exemple la source elle-même a déjà été supprimée), un avertissement est affiché listant les opérations non reconnectables, mais la suppression est néanmoins autorisée." -msgid "" -"**Standalone history file** (``.dlhist``): the file embeds both the " -"recorded sessions **and** all signal/image objects referenced by those " -"sessions. This makes the file fully self-contained:" -msgstr "" -"**Fichier d'historique autonome** (``.dlhist``) : le fichier embarque à " -"la fois les sessions enregistrées **et** tous les objets signal/image " -"référencés par ces sessions. Cela rend le fichier entièrement autonome :" +msgid "This behaviour mirrors removing a link from a chain: the adjacent links reconnect to preserve the processing flow." +msgstr "Ce comportement reproduit la suppression d'un maillon d'une chaîne : les maillons adjacents se reconnectent pour préserver le flux de traitement." -msgid "" -"Opening a ``.dlhist`` into an **empty workspace** loads sessions and " -"objects directly, restoring the workspace to its recorded state." -msgstr "" -"Ouvrir un fichier ``.dlhist`` dans un **espace de travail vide** charge " -"les sessions et les objets directement, restaurant l'espace de travail " -"dans son état enregistré." +msgid "Reconnection is only triggered by deletions initiated from the signal/image panels. Deleting an action directly from the History Panel tree removes it and all subsequent actions in that session." +msgstr "La reconnexion n'est déclenchée que par les suppressions initiées depuis les panneaux signal/image. Supprimer une action directement depuis l'arborescence du Panneau historique la retire ainsi que toutes les actions suivantes de cette session." -msgid "" -"Opening a ``.dlhist`` into a **non-empty workspace** creates new " -"signal/image groups for the imported objects (with remapped identifiers " -"to avoid collisions) and appends new history sessions that reference " -"those fresh identifiers." -msgstr "" -"Ouvrir un fichier ``.dlhist`` dans un **espace de travail non vide** crée " -"de nouveaux groupes signal/image pour les objets importés (avec des " -"identifiants remappés pour éviter les collisions) et ajoute de nouvelles " -"sessions d'historique référençant ces nouveaux identifiants." - -#~ msgid "" -#~ "|restore_and_replay| **Restore selection and " -#~ "replay**: combine the two previous " -#~ "actions -- restore the selection first," -#~ " then replay." -#~ msgstr "" -#~ "|restore_and_replay| **Restaurer la sélection " -#~ "et rejouer** : combine les deux " -#~ "actions précédentes -- restaure d'abord " -#~ "la sélection, puis rejoue." - -#~ msgid "restore_and_replay" -#~ msgstr "restore_and_replay" - -#~ msgid "" -#~ "|replay| **Apply duplicate**: replay the " -#~ "selected duplicate persistently, adding " -#~ "comparison outputs to the signal/image " -#~ "panels. Outputs without an existing " -#~ "destination group are placed in a " -#~ "``Replay duplicate`` group, and their " -#~ "titles are prefixed with ``Replay " -#~ "duplicate -``." -#~ msgstr "" -#~ "|replay| **Appliquer le duplicata** : " -#~ "rejoue le duplicata sélectionné de façon" -#~ " persistante, en ajoutant des sorties " -#~ "de comparaison aux panneaux signal/image. " -#~ "Les sorties sans groupe de destination" -#~ " existant sont placées dans un groupe" -#~ " ``Rejeu du duplicata``, et leurs " -#~ "titres sont préfixés par ``Rejeu du " -#~ "duplicata -``." +msgid "Auto-recompute" +msgstr "Recalcul automatique" +msgid "When a result object is selected in the signal/image panel and it has processing parameters (i.e. was produced by a 1-to-1 computation), a **Processing** tab appears in the Properties panel. Checking **Auto-recompute on edit** in that tab will re-run the computation automatically 300 ms after any parameter modification." +msgstr "Lorsqu'un objet résultat est sélectionné dans le panneau signal/image et qu'il possède des paramètres de traitement (c'est-à-dire qu'il a été produit par un calcul 1-à-1), un onglet **Traitement** apparaît dans le panneau Propriétés. Cocher **Recalcul automatique lors de l'édition** dans cet onglet relancera automatiquement le calcul 300 ms après toute modification d'un paramètre." diff --git a/doc/locale/fr/LC_MESSAGES/release_notes/release_1.03.po b/doc/locale/fr/LC_MESSAGES/release_notes/release_1.03.po index d261ac539..96083084f 100644 --- a/doc/locale/fr/LC_MESSAGES/release_notes/release_1.03.po +++ b/doc/locale/fr/LC_MESSAGES/release_notes/release_1.03.po @@ -5,17 +5,11 @@ # msgid "" msgstr "" -"Project-Id-Version: PROJECT VERSION\n" -"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-05-28 17:03+0200\n" -"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: FULL NAME \n" "Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.18.0\n" msgid "Version 1.3" msgstr "Version 1.3" From c8359efbcb381cbcc419db7590388ef6b6c97b33 Mon Sep 17 00:00:00 2001 From: Duy Anh Philippe PHAM Date: Thu, 25 Jun 2026 14:01:54 +0200 Subject: [PATCH 24/24] style: run ruff and fix pylint warnings on history panel Follow-up to a review noting that ruff/pylint had not been run on the History panel refactor after it was split into submodules. Cross-module access to underscore-prefixed members triggered W0212, plus a batch of other lint warnings that were addressed without disabling any checks. --- datalab/gui/historysession_ops.py | 16 +-- datalab/gui/historytools_ops.py | 8 +- datalab/gui/main.py | 1 + datalab/gui/panel/history/chain.py | 76 +++++------ .../gui/panel/history/interactive_replay.py | 39 +++--- datalab/gui/panel/history/panel.py | 108 +++++++--------- datalab/gui/panel/history/recompute.py | 44 ++++--- datalab/h5/history.py | 15 +-- datalab/history/action.py | 25 ++-- datalab/history/session.py | 53 +++++--- datalab/tests/features/common/history_test.py | 118 +++++++++--------- datalab/widgets/historydescription.py | 2 + 12 files changed, 254 insertions(+), 251 deletions(-) diff --git a/datalab/gui/historysession_ops.py b/datalab/gui/historysession_ops.py index 67102cdf6..1d31be238 100644 --- a/datalab/gui/historysession_ops.py +++ b/datalab/gui/historysession_ops.py @@ -166,10 +166,10 @@ def register_action_outputs( create new objects). """ # Drop previous outputs for this action from the reverse index. - previous = panel._action_output_uuids.get(action.uuid, []) + previous = panel.action_output_uuids.get(action.uuid, []) for prev_uuid in previous: - if panel._output_to_action.get(prev_uuid) == action.uuid: - panel._output_to_action.pop(prev_uuid, None) + if panel.output_to_action.get(prev_uuid) == action.uuid: + panel.output_to_action.pop(prev_uuid, None) new_outputs = list(output_uuids) # Ownership transfer: if an output_uuid already belongs to a # *different* action, remove it from that action's output list so the @@ -178,16 +178,16 @@ def register_action_outputs( # sessions to locate the object would be expensive; the panel-level # dicts are the source of truth. for out_uuid in new_outputs: - old_action_uuid = panel._output_to_action.get(out_uuid) + old_action_uuid = panel.output_to_action.get(out_uuid) if old_action_uuid is not None and old_action_uuid != action.uuid: - old_list = panel._action_output_uuids.get(old_action_uuid) + old_list = panel.action_output_uuids.get(old_action_uuid) if old_list is not None: try: old_list.remove(out_uuid) except ValueError: pass if not old_list: - del panel._action_output_uuids[old_action_uuid] + del panel.action_output_uuids[old_action_uuid] _logger.debug( "Output %s transferred from action %s to %s", out_uuid, @@ -195,9 +195,9 @@ def register_action_outputs( action.uuid, ) action.output_uuids = list(new_outputs) - panel._action_output_uuids[action.uuid] = new_outputs + panel.action_output_uuids[action.uuid] = new_outputs for out_uuid in new_outputs: - panel._output_to_action[out_uuid] = action.uuid + panel.output_to_action[out_uuid] = action.uuid @contextmanager diff --git a/datalab/gui/historytools_ops.py b/datalab/gui/historytools_ops.py index 3e326df92..e4d29c8bb 100644 --- a/datalab/gui/historytools_ops.py +++ b/datalab/gui/historytools_ops.py @@ -154,13 +154,13 @@ def duplicate_selected_entries(panel: HistoryPanel) -> None: new_sessions.append(new_session) # Register output mappings for cloned actions so that - # _resolve_target_outputs / get_downstream_actions work on - # the duplicated session (same logic as read_h5_data). + # resolve_target_outputs / get_downstream_actions work on + # the duplicated session (same logic as deserialize_from_hdf5). for action in new_session.actions: if action.output_uuids: - panel._action_output_uuids[action.uuid] = list(action.output_uuids) + panel.action_output_uuids[action.uuid] = list(action.output_uuids) for out_uuid in action.output_uuids: - panel._output_to_action[out_uuid] = action.uuid + panel.output_to_action[out_uuid] = action.uuid # Insert each duplicated session immediately after its original. offset = 0 diff --git a/datalab/gui/main.py b/datalab/gui/main.py index 9d99592ba..cc98e81da 100644 --- a/datalab/gui/main.py +++ b/datalab/gui/main.py @@ -198,6 +198,7 @@ def __init__(self, console=None, hide_on_close=False): # pylint: disable=too-ma self.saveh5_action: QW.QAction | None = None self.browseh5_action: QW.QAction | None = None self.settings_action: QW.QAction | None = None + self.command_palette_action: QW.QAction | None = None self.quit_action: QW.QAction | None = None self.autorefresh_action: QW.QAction | None = None self.showfirstonly_action: QW.QAction | None = None diff --git a/datalab/gui/panel/history/chain.py b/datalab/gui/panel/history/chain.py index 48e3a935c..67d0906d4 100644 --- a/datalab/gui/panel/history/chain.py +++ b/datalab/gui/panel/history/chain.py @@ -11,6 +11,7 @@ from datalab.config import _ from datalab.env import execenv +from datalab.gui.panel.history import recompute as hrec from datalab.gui.processor.base import ( ProcessingParameters, extract_processing_parameters, @@ -30,7 +31,7 @@ def find_parent_session( panel: HistoryPanel, action: HistoryAction ) -> HistorySession | None: """Return the session that contains ``action``, or None.""" - for session in panel._history_sessions: + for session in panel.history_sessions: if action in session.actions: return session return None @@ -57,7 +58,7 @@ def find_output_object_uuid( Primary path: consult the bijective ``action_output_uuids`` mapping. Fallback path: legacy heuristic on ``processing_parameters`` metadata. """ - registered = panel._action_output_uuids.get(action.uuid) + registered = panel.action_output_uuids.get(action.uuid) if registered: existing_ids = set(panel_data.objmodel.get_object_ids()) for out_uuid in registered: @@ -83,16 +84,21 @@ def find_action_for_output( panel: HistoryPanel, output_uuid: str, func_name: str ) -> HistoryAction | None: """Find the :class:`HistoryAction` that produced ``output_uuid``.""" - if not panel._history_sessions: + if not panel.history_sessions: return None - action_uuid = panel._output_to_action.get(output_uuid) + action_uuid = panel.output_to_action.get(output_uuid) if action_uuid is not None: - for session in panel._history_sessions: - for action in session.actions: - if action.uuid == action_uuid: - if action.func_name == func_name: - return action - return None + mapped = next( + ( + action + for session in panel.history_sessions + for action in session.actions + if action.uuid == action_uuid + ), + None, + ) + if mapped is not None: + return mapped if mapped.func_name == func_name else None panel_data: BaseDataPanel | None = None output_obj = None for p in (panel.mainwindow.signalpanel, panel.mainwindow.imagepanel): @@ -103,12 +109,10 @@ def find_action_for_output( if panel_data is None or output_obj is None: return None pp = extract_processing_parameters(output_obj) - if pp is None or pp.func_name != func_name: + if pp is None or pp.func_name != func_name or pp.source_uuid is None: return None target_source_uuid = pp.source_uuid - if target_source_uuid is None: - return None - for current_session in reversed(panel._history_sessions): + for current_session in reversed(panel.history_sessions): for action in reversed(current_session.actions): if action.kind != HistoryAction.KIND_COMPUTE: continue @@ -124,7 +128,7 @@ def find_action_for_output( def get_session_of(panel: HistoryPanel, action: HistoryAction) -> HistorySession | None: """Return the session that contains ``action``, or None.""" - for session in panel._history_sessions: + for session in panel.history_sessions: if action in session.actions: return session return None @@ -155,7 +159,7 @@ def action_consumes_any(action: HistoryAction, uuids: set[str]) -> bool: def collect_downstream_uuids(panel: HistoryPanel, action: HistoryAction) -> set[str]: """Return the transitive closure of output UUIDs descending from ``action``.""" - if not panel._history_sessions: + if not panel.history_sessions: return set() current = get_session_of(panel, action) if current is None: @@ -181,7 +185,7 @@ def get_downstream_actions( panel: HistoryPanel, action: HistoryAction ) -> list[HistoryAction]: """Return the actions of the current session that depend on ``action``.""" - if not panel._history_sessions: + if not panel.history_sessions: return [] current = get_session_of(panel, action) if current is None: @@ -208,7 +212,7 @@ def resolve_target_outputs( panel: HistoryPanel, panel_data: BaseDataPanel, action: HistoryAction ) -> tuple[list[str], list[str]]: """Return ``(existing, missing)`` UUIDs registered for ``action``.""" - registered = list(panel._action_output_uuids.get(action.uuid, [])) + registered = list(panel.action_output_uuids.get(action.uuid, [])) existing_ids = set(panel_data.objmodel.get_object_ids()) existing: list[str] = [u for u in registered if u in existing_ids] missing: list[str] = [u for u in registered if u not in existing_ids] @@ -222,15 +226,15 @@ def existing_input_uuids(panel_data: BaseDataPanel, action: HistoryAction) -> li def prune_output_mapping(panel: HistoryPanel) -> None: - """Drop entries of :attr:`_output_to_action` whose object no longer exists.""" - if not panel._output_to_action: + """Drop entries of :attr:`output_to_action` whose object no longer exists.""" + if not panel.output_to_action: return alive: set[str] = set() for pdata in (panel.mainwindow.signalpanel, panel.mainwindow.imagepanel): alive.update(pdata.objmodel.get_object_ids()) - stale = [u for u in panel._output_to_action if u not in alive] + stale = [u for u in panel.output_to_action if u not in alive] for u in stale: - panel._output_to_action.pop(u, None) + panel.output_to_action.pop(u, None) def rewrite_action_source( @@ -253,15 +257,15 @@ def rewrite_action_source( def remove_single_action(panel: HistoryPanel, action: HistoryAction) -> None: """Remove a single action from its session (splice, not truncate).""" - for session in panel._history_sessions: + for session in panel.history_sessions: if action in session.actions: session.actions.remove(action) - outs = panel._action_output_uuids.pop(action.uuid, []) + outs = panel.action_output_uuids.pop(action.uuid, []) for out_uuid in outs: - if panel._output_to_action.get(out_uuid) == action.uuid: - panel._output_to_action.pop(out_uuid, None) + if panel.output_to_action.get(out_uuid) == action.uuid: + panel.output_to_action.pop(out_uuid, None) if not session.actions: - panel._history_sessions.remove(session) + panel.history_sessions.remove(session) break @@ -275,9 +279,9 @@ def reconnect_single_removed( """Reconnect consumers of a single deleted object ``x_uuid``.""" pstr = panel_data.PANEL_STR_ID action_a = None - action_a_uuid = panel._output_to_action.get(x_uuid) + action_a_uuid = panel.output_to_action.get(x_uuid) if action_a_uuid is not None: - for session in panel._history_sessions: + for session in panel.history_sessions: for a in session.actions: if a.uuid == action_a_uuid: action_a = a @@ -331,7 +335,7 @@ def reconnect_single_removed( if action_b not in roots_to_recompute: roots_to_recompute.append(action_b) if action_a is not None: - outs = panel._action_output_uuids.get(action_a.uuid, []) + outs = panel.action_output_uuids.get(action_a.uuid, []) if not any(o in alive_ids for o in outs): remove_single_action(panel, action_a) @@ -340,15 +344,13 @@ def reconnect_chain_after_removal( panel: HistoryPanel, panel_data: BaseDataPanel ) -> None: """Reconnect the processing chain after object(s) were deleted from a data panel.""" - from datalab.gui.panel.history import recompute as hrec - pstr = panel_data.PANEL_STR_ID - previous = panel._obj_ids_snapshot.get(pstr, set()) + previous = panel.obj_ids_snapshot.get(pstr, set()) current = set(panel_data.objmodel.get_object_ids()) removed = previous - current - if not removed or panel._reconnecting: + if not removed or panel.reconnecting: return - panel._reconnecting = True + panel.reconnecting = True try: warnings: list[str] = [] roots_to_recompute: list[HistoryAction] = [] @@ -367,9 +369,9 @@ def reconnect_chain_after_removal( + "\n\n• " + "\n• ".join(warnings), ) - panel.tree.populate_tree(panel._history_sessions) + panel.tree.populate_tree(panel.history_sessions) panel.refresh_compatibility_items() panel.update_actions_state() finally: - panel._reconnecting = False + panel.reconnecting = False panel.refresh_obj_ids_snapshot() diff --git a/datalab/gui/panel/history/interactive_replay.py b/datalab/gui/panel/history/interactive_replay.py index 2514981d9..07fa37356 100644 --- a/datalab/gui/panel/history/interactive_replay.py +++ b/datalab/gui/panel/history/interactive_replay.py @@ -28,11 +28,11 @@ def replay_restore_actions( ) -> None: """Replay and/or restore selection for the selected actions.""" panel.refresh_compatibility_items() - selected = panel.tree.get_selected_actions_or_sessions(panel._history_sessions) + selected = panel.tree.get_selected_actions_or_sessions(panel.history_sessions) if not selected: - if not panel._history_sessions: + if not panel.history_sessions: return - selected = [panel._history_sessions[-1]] + selected = [panel.history_sessions[-1]] for session_or_action in selected: if isinstance(session_or_action, HistoryAction) and session_or_action.is_stale: hrec.recompute_cascade(panel, session_or_action) @@ -48,19 +48,19 @@ def replay_restore_actions( ) return if replay: - if panel._edit_mode and isinstance(session_or_action, HistoryAction): + if panel.edit_mode and isinstance(session_or_action, HistoryAction): edit_mode_replay(panel, session_or_action) - elif panel._edit_mode and isinstance(session_or_action, HistorySession): + elif panel.edit_mode and isinstance(session_or_action, HistorySession): view_only_session_replay(panel, session_or_action, restore_selection) else: with panel.replaying(), panel.output_suppressed(): session_or_action.replay( panel.mainwindow, restore_selection=restore_selection, - edit=panel._edit_mode, + edit=panel.edit_mode, ) elif restore_selection: - if panel._edit_mode or panel.has_any_pending_edits(): + if panel.edit_mode or panel.has_any_pending_edits(): restore_action_params(panel, session_or_action) else: session_or_action.restore(panel.mainwindow) @@ -76,23 +76,22 @@ def prompt_edit_action_params( if param is None: return None edited = copy.deepcopy(param) - if not edited.edit(parent=panel.mainwindow): - return False - action.snapshot_kwargs() - action.kwargs["param"] = edited - return True - if pattern == "1_to_n": + dialog_target: gds.DataSet | gds.DataSetGroup = edited + new_kwargs = {"param": edited} + elif pattern == "1_to_n": params = action.kwargs.get("params") or [] if not params: return None edited_params = [copy.deepcopy(p) for p in params] - group = gds.DataSetGroup(edited_params, title=_("Parameters")) - if not group.edit(parent=panel.mainwindow): - return False - action.snapshot_kwargs() - action.kwargs["params"] = edited_params - return True - return None + dialog_target = gds.DataSetGroup(edited_params, title=_("Parameters")) + new_kwargs = {"params": edited_params} + else: + return None + if not dialog_target.edit(parent=panel.mainwindow): + return False + action.snapshot_kwargs() + action.kwargs.update(new_kwargs) + return True def edit_mode_replay(panel: HistoryPanel, action: HistoryAction) -> None: diff --git a/datalab/gui/panel/history/panel.py b/datalab/gui/panel/history/panel.py index c060e361d..e4c74b722 100644 --- a/datalab/gui/panel/history/panel.py +++ b/datalab/gui/panel/history/panel.py @@ -58,15 +58,15 @@ def __init__(self, parent: DLMainWindow) -> None: self.setOrientation(QC.Qt.Vertical) self._record_mode = False - self._edit_mode = False + self.edit_mode = False self._replaying = False self._output_suppressed = False self._syncing = False - self._cascade_in_progress = False + self.cascade_in_progress = False self._delete_action: QW.QAction | None = None self._duplicate_action: QW.QAction | None = None - self._step_prev_action: QW.QAction | None = None - self._step_next_action: QW.QAction | None = None + self.step_prev_action: QW.QAction | None = None + self.step_next_action: QW.QAction | None = None self._restore_selection_action: QW.QAction | None = None self._edit_action: QW.QAction | None = None self._record_action: QW.QAction | None = None @@ -94,14 +94,14 @@ def __init__(self, parent: DLMainWindow) -> None: self.addWidget(widget) - self._history_sessions: list[HistorySession] = [] + self.history_sessions: list[HistorySession] = [] self._session_increment = 0 - self._action_output_uuids: dict[str, list[str]] = {} - self._output_to_action: dict[str, str] = {} - self._cascade_warnings: list[str] = [] - self._broken_actions: set[str] = set() - self._reconnecting = False - self._obj_ids_snapshot: dict[str, set[str]] = {} + self.action_output_uuids: dict[str, list[str]] = {} + self.output_to_action: dict[str, str] = {} + self.cascade_warnings: list[str] = [] + self.broken_actions: set[str] = set() + self.reconnecting = False + self.obj_ids_snapshot: dict[str, set[str]] = {} for panel in (self.mainwindow.signalpanel, self.mainwindow.imagepanel): panel.SIG_OBJECT_ADDED.connect(self.refresh_compatibility_items) panel.SIG_OBJECT_ADDED.connect(self.refresh_obj_ids_snapshot) @@ -120,7 +120,7 @@ def __init__(self, parent: DLMainWindow) -> None: def refresh_obj_ids_snapshot(self) -> None: """Cache the current object ids of both data panels.""" - self._obj_ids_snapshot = { + self.obj_ids_snapshot = { self.mainwindow.signalpanel.PANEL_STR_ID: set( self.mainwindow.signalpanel.objmodel.get_object_ids() ), @@ -135,27 +135,15 @@ def update_actions_state(self) -> None: for action in (self._delete_action, self._duplicate_action): if action is not None: action.setEnabled(has_history) - if self._step_prev_action is not None: - self._step_prev_action.setEnabled(self.can_step_prev()) - if self._step_next_action is not None: - self._step_next_action.setEnabled(self.can_step_next()) + if self.step_prev_action is not None: + self.step_prev_action.setEnabled(self.can_step_prev()) + if self.step_next_action is not None: + self.step_next_action.setEnabled(self.can_step_next()) if self._restore_selection_action is not None: self._restore_selection_action.setEnabled( - self._edit_mode or self.has_any_pending_edits() + self.edit_mode or self.has_any_pending_edits() ) - __update_actions_state = update_actions_state - - @property - def history_sessions(self) -> list[HistorySession]: - """Return mutable history sessions storage.""" - return self._history_sessions - - @history_sessions.setter - def history_sessions(self, sessions: list[HistorySession]) -> None: - """Replace history sessions storage.""" - self._history_sessions = sessions - @property def session_increment(self) -> int: """Return the current session counter.""" @@ -176,7 +164,7 @@ def has_any_pending_edits(self) -> bool: mode snapshot (i.e. uncommitted edits that Restore can revert).""" return any( action.has_pending_edits - for session in self._history_sessions + for session in self.history_sessions for action in session.actions ) @@ -196,7 +184,7 @@ def create_menu_actions(self) -> list[QW.QAction]: toggled=self.toggle_edit_mode, icon=get_icon("edit_mode.svg"), ) - edit_action.setChecked(self._edit_mode) + edit_action.setChecked(self.edit_mode) self._edit_action = edit_action record_action = create_action( self, @@ -240,7 +228,7 @@ def create_menu_actions(self) -> list[QW.QAction]: icon=get_icon("duplicate.svg"), tip=_("Duplicate selected history action/session"), ) - self._step_prev_action = create_action( + self.step_prev_action = create_action( self, _("Previous step"), triggered=self.step_prev, @@ -248,7 +236,7 @@ def create_menu_actions(self) -> list[QW.QAction]: tip=_("Select the previous action in the current session"), shortcut=QG.QKeySequence("Ctrl+Left"), ) - self._step_next_action = create_action( + self.step_next_action = create_action( self, _("Next step"), triggered=self.step_next, @@ -284,8 +272,8 @@ def create_menu_actions(self) -> list[QW.QAction]: open_action, save_action, None, - self._step_prev_action, - self._step_next_action, + self.step_prev_action, + self.step_next_action, None, create_action( self, @@ -334,9 +322,9 @@ def toggle_edit_mode(self, checked: bool) -> None: self._edit_action.setChecked(True) self._edit_action.blockSignals(False) return - self._edit_mode = checked + self.edit_mode = checked if not checked: - for session in self._history_sessions: + for session in self.history_sessions: for action in session.actions: action.discard_snapshot() self.update_actions_state() @@ -347,7 +335,7 @@ def toggle_record_mode(self, checked: bool) -> None: def is_edit_mode(self) -> bool: """Return True when the History panel is in edit mode.""" - return self._edit_mode + return self.edit_mode @contextmanager def replaying(self) -> Generator[None, None, None]: @@ -386,7 +374,7 @@ def show_context_menu(self, pos: QC.QPoint) -> None: def get_action_from_uuid(self, uuid: str) -> HistoryAction: """Get the action from its UUID.""" - for session in self._history_sessions: + for session in self.history_sessions: for action in session.actions: if action.uuid == uuid: return action @@ -456,8 +444,6 @@ def action_output_uuid(self, action: HistoryAction) -> str | None: """Return the UUID of the object produced by ``action``, or ``None``.""" return hchain.action_output_uuid(self, action) - _action_output_uuid = action_output_uuid - def action_consumes_any(self, action: HistoryAction, uuids: set[str]) -> bool: """Return True if ``action``'s input UUIDs intersect ``uuids``.""" return hchain.action_consumes_any(action, uuids) @@ -483,7 +469,7 @@ def existing_input_uuids( return hchain.existing_input_uuids(panel, action) def prune_output_mapping(self) -> None: - """Drop entries of :attr:`_output_to_action` whose object no longer exists.""" + """Drop entries of :attr:`output_to_action` whose object no longer exists.""" return hchain.prune_output_mapping(self) def rewrite_action_source( @@ -589,21 +575,21 @@ def sync_panel_selection(self) -> None: return if item.parent() is None: index = self.tree.indexOfTopLevelItem(item) - if index < 0 or index >= len(self._history_sessions): + if index < 0 or index >= len(self.history_sessions): return - session = self._history_sessions[index] + session = self.history_sessions[index] action = next( (a for a in session.actions if a.kind == HistoryAction.KIND_COMPUTE), None, ) - if action is None: - return else: uuid = item.data(0, QC.Qt.UserRole) try: - action = self.tree.get_action_from_uuid(uuid, self._history_sessions) + action = self.tree.get_action_from_uuid(uuid, self.history_sessions) except ValueError: - return + action = None + if action is None: + return panel = self.resolve_panel_for_action(action) if panel is None: @@ -638,7 +624,7 @@ def current_action(self) -> HistoryAction | None: return None uuid = item.data(0, QC.Qt.UserRole) try: - return self.tree.get_action_from_uuid(uuid, self._history_sessions) + return self.tree.get_action_from_uuid(uuid, self.history_sessions) except ValueError: return None @@ -648,14 +634,14 @@ def current_session(self) -> HistorySession | None: if item is not None: if item.parent() is None: index = self.tree.indexOfTopLevelItem(item) - if 0 <= index < len(self._history_sessions): - return self._history_sessions[index] + if 0 <= index < len(self.history_sessions): + return self.history_sessions[index] else: action = self.current_action() if action is not None: return self.find_parent_session(action) - if self._history_sessions: - return self._history_sessions[-1] + if self.history_sessions: + return self.history_sessions[-1] return None def can_step_prev(self) -> bool: @@ -700,8 +686,6 @@ def step_prev(self) -> None: self.select_action_in_tree(session.actions[idx - 1]) self.update_actions_state() - _step_prev = step_prev - def step_next(self) -> None: """Select the next action in the current session.""" if not self.can_step_next(): @@ -715,8 +699,6 @@ def step_next(self) -> None: self.select_action_in_tree(target) self.update_actions_state() - _step_next = step_next - # ------------------------------------------------------------------ # History tools delegations # ------------------------------------------------------------------ @@ -733,7 +715,7 @@ def select_sessions(self, sessions: list[HistorySession]) -> None: """Select top-level tree items matching ``sessions``.""" self.tree.clearSelection() for session in sessions: - index = self._history_sessions.index(session) + index = self.history_sessions.index(session) item = self.tree.topLevelItem(index) item.setSelected(True) self.tree.setCurrentItem(item) @@ -778,11 +760,11 @@ def deserialize_from_hdf5( def __len__(self) -> int: """Return number of objects.""" - return sum(len(session.actions) for session in self._history_sessions) + return sum(len(session.actions) for session in self.history_sessions) def __getitem__(self, nb: int) -> HistoryAction: """Return object from its number (1 to N).""" - for session in self._history_sessions: + for session in self.history_sessions: if nb <= len(session.actions): return session.actions[nb - 1] nb -= len(session.actions) @@ -790,7 +772,7 @@ def __getitem__(self, nb: int) -> HistoryAction: def __iter__(self) -> Generator[HistoryAction, None, None]: """Iterate over objects.""" - for session in self._history_sessions: + for session in self.history_sessions: yield from session.actions # ------------------------------------------------------------------ @@ -898,5 +880,5 @@ def add_object(self, obj: HistoryAction) -> None: def remove_all_objects(self) -> None: """Remove all objects.""" super().remove_all_objects() - self._action_output_uuids.clear() - self._output_to_action.clear() + self.action_output_uuids.clear() + self.output_to_action.clear() diff --git a/datalab/gui/panel/history/recompute.py b/datalab/gui/panel/history/recompute.py index 8016f5d1e..1505d6cbe 100644 --- a/datalab/gui/panel/history/recompute.py +++ b/datalab/gui/panel/history/recompute.py @@ -101,7 +101,7 @@ def record_missing_outputs( action.uuid, name, ) - panel._cascade_warnings.append( + panel.cascade_warnings.append( _( "Action %s has been edited but its target output object(s) " "no longer exist — skipping." @@ -127,7 +127,7 @@ def recompute_action_in_place(panel: HistoryPanel, action: HistoryAction) -> Non action.pattern, action.uuid, ) - panel._cascade_warnings.append( + panel.cascade_warnings.append( _("Action %s uses pattern %r which is not recomputable yet.") % (action.func_name or action.uuid, action.pattern) ) @@ -143,7 +143,7 @@ def recompute_action_in_place(panel: HistoryPanel, action: HistoryAction) -> Non action.func_name, exc, ) - panel._cascade_warnings.append( + panel.cascade_warnings.append( _("Recompute failed for action %s: %s") % (action.func_name or action.uuid, exc) ) @@ -154,7 +154,7 @@ def handle_missing_feature( ) -> None: """Flag ``action`` as broken (missing plugin) and queue a user warning.""" action.is_stale = True - panel._broken_actions.add(action.uuid) + panel.broken_actions.add(action.uuid) plugin_origin = action.plugin_origin or exc.plugin_origin or {} directory = (plugin_origin.get("directory") if plugin_origin else None) or "?" param = action.kwargs.get("param") @@ -169,7 +169,7 @@ def handle_missing_feature( func_name, location, ) - panel._cascade_warnings.append( + panel.cascade_warnings.append( _( "Action %(name)s skipped: plugin '%(loc)s' is missing.\n" "Required parameter class: %(param)s\n" @@ -200,7 +200,7 @@ def recompute_1_to_1_in_place(panel: HistoryPanel, action: HistoryAction) -> Non if pp is None or pp.source_uuid is None: return if not panel_data.objmodel.has_uuid(pp.source_uuid): - panel._cascade_warnings.append( + panel.cascade_warnings.append( _("Action %s: source object was deleted — skipping.") % (action.func_name or action.uuid) ) @@ -236,16 +236,14 @@ def recompute_1_to_n_in_place(panel: HistoryPanel, action: HistoryAction) -> Non return existing, missing = panel.resolve_target_outputs(panel_data, action) record_missing_outputs(panel, action, missing) - if not existing: - return - if not panel_data.objmodel.has_uuid(existing[0]): + if not existing or not panel_data.objmodel.has_uuid(existing[0]): return first_obj = panel_data.objmodel[existing[0]] pp = extract_processing_parameters(first_obj) if pp is None or pp.source_uuid is None: return if not panel_data.objmodel.has_uuid(pp.source_uuid): - panel._cascade_warnings.append( + panel.cascade_warnings.append( _("Action %s: source object was deleted — skipping.") % (action.func_name or action.uuid) ) @@ -315,7 +313,7 @@ def recompute_n_to_1_in_place(panel: HistoryPanel, action: HistoryAction) -> Non if panel_data.objmodel.has_uuid(uuid): src_objs.append(panel_data.objmodel[uuid]) if not src_objs: - panel._cascade_warnings.append( + panel.cascade_warnings.append( _("Action %s: all source objects were deleted — skipping.") % (action.func_name or action.uuid) ) @@ -373,7 +371,7 @@ def recompute_2_to_1_in_place(panel: HistoryPanel, action: HistoryAction) -> Non ) ) if len(src_uuids) < 2: - panel._cascade_warnings.append( + panel.cascade_warnings.append( _("Action %s: missing source(s) for output #%d — skipping.") % (action.func_name or action.uuid, idx + 1) ) @@ -382,7 +380,7 @@ def recompute_2_to_1_in_place(panel: HistoryPanel, action: HistoryAction) -> Non panel_data.objmodel.has_uuid(src_uuids[0]) and panel_data.objmodel.has_uuid(src_uuids[1]) ): - panel._cascade_warnings.append( + panel.cascade_warnings.append( _("Action %s: source object(s) were deleted — skipping.") % (action.func_name or action.uuid) ) @@ -435,7 +433,7 @@ def recompute_1_to_0_in_place(panel: HistoryPanel, action: HistoryAction) -> Non ) refresh_target(panel_data, uuid) if missing: - panel._cascade_warnings.append( + panel.cascade_warnings.append( _("Action %s: %d analysed object(s) were deleted — skipping.") % (action.func_name or action.uuid, len(missing)) ) @@ -451,14 +449,14 @@ def recompute_cascade( descendants = panel.get_downstream_actions(root_action) if root_action.is_stale: descendants = [root_action] + descendants - if getattr(panel, "_cascade_in_progress", False): + if getattr(panel, "cascade_in_progress", False): flush_cascade_warnings(panel) return if not descendants: flush_cascade_warnings(panel) return - panel._broken_actions.clear() - panel._cascade_in_progress = True + panel.broken_actions.clear() + panel.cascade_in_progress = True try: for action in descendants: action.is_stale = True @@ -468,27 +466,27 @@ def recompute_cascade( try: recompute_action_in_place(panel, action) finally: - if action.uuid not in panel._broken_actions: + if action.uuid not in panel.broken_actions: action.is_stale = False panel.tree.refresh_action_item(action) QW.QApplication.processEvents() finally: for action in descendants: - if action.is_stale and action.uuid not in panel._broken_actions: + if action.is_stale and action.uuid not in panel.broken_actions: action.is_stale = False panel.tree.refresh_action_item(action) - panel._cascade_in_progress = False + panel.cascade_in_progress = False flush_cascade_warnings(panel) def flush_cascade_warnings(panel: HistoryPanel) -> None: """Show + clear accumulated cascade warnings (no-op when empty).""" - if panel._cascade_warnings and not execenv.unattended: + if panel.cascade_warnings and not execenv.unattended: QW.QMessageBox.warning( panel.mainwindow, _("Cascade recompute"), _("Some downstream actions could not be recomputed:") + "\n\n• " - + "\n• ".join(panel._cascade_warnings), + + "\n• ".join(panel.cascade_warnings), ) - panel._cascade_warnings = [] + panel.cascade_warnings = [] diff --git a/datalab/h5/history.py b/datalab/h5/history.py index 55060006c..0b5b39c15 100644 --- a/datalab/h5/history.py +++ b/datalab/h5/history.py @@ -200,12 +200,12 @@ def import_dlhist_into_new_session(panel: HistoryPanel, reader: NativeH5Reader) new_session.number = panel.session_increment new_sessions.append(new_session) # Register output mappings for imported actions so that - # _resolve_target_outputs / get_downstream_actions work. + # resolve_target_outputs / get_downstream_actions work. for action in new_session.actions: if action.output_uuids: - panel._action_output_uuids[action.uuid] = list(action.output_uuids) + panel.action_output_uuids[action.uuid] = list(action.output_uuids) for out_uuid in action.output_uuids: - panel._output_to_action[out_uuid] = action.uuid + panel.output_to_action[out_uuid] = action.uuid panel.history_sessions.extend(new_sessions) panel.tree.populate_tree(panel.history_sessions) panel.refresh_compatibility_items() @@ -236,6 +236,7 @@ def deserialize_from_hdf5( reader: HDF5 reader reset_all: Unused (kept for compatibility with panel API) """ + del reset_all # required by the polymorphic panel API; unused here if panel.H5_PREFIX not in reader.h5: panel.history_sessions = [] panel.session_increment = 0 @@ -250,14 +251,14 @@ def deserialize_from_hdf5( # Rebuild the bijective mapping from the loaded actions. Legacy # (v1) actions have empty ``output_uuids`` and contribute nothing # to the index — the heuristic fallback handles them. - panel._action_output_uuids = {} - panel._output_to_action = {} + panel.action_output_uuids = {} + panel.output_to_action = {} for session in panel.history_sessions: for action in session.actions: if action.output_uuids: - panel._action_output_uuids[action.uuid] = list(action.output_uuids) + panel.action_output_uuids[action.uuid] = list(action.output_uuids) for out_uuid in action.output_uuids: - panel._output_to_action[out_uuid] = action.uuid + panel.output_to_action[out_uuid] = action.uuid panel.tree.populate_tree(panel.history_sessions) panel.refresh_compatibility_items() panel.update_actions_state() diff --git a/datalab/history/action.py b/datalab/history/action.py index 4d1907976..f415367fc 100644 --- a/datalab/history/action.py +++ b/datalab/history/action.py @@ -9,16 +9,13 @@ import json import logging import os +from contextlib import nullcontext from typing import TYPE_CHECKING, Any, Callable, Generator from uuid import uuid4 import sigima.proc.image import sigima.proc.signal from guidata.dataset.datatypes import DataSet -from sigima.objects import ( # noqa: F401 (kept for downstream consumers) - ImageObj, - SignalObj, -) from datalab.config import _ from datalab.gui import ObjItf @@ -60,6 +57,11 @@ class HistoryAction(ObjItf): # Methods that create new data objects. During non-persistent (output-suppressed) # replay, these UI actions are skipped so the panel object count stays stable. UI_CREATION_METHODS: frozenset[str] = frozenset({"new_object"}) + # UI methods that destroy data objects. Replaying these requires that the + # captured selection still resolves to existing objects (see ``_replay_ui``). + DESTRUCTIVE_METHODS: frozenset[str] = frozenset( + {"remove_object", "remove_group", "delete_all_objects"} + ) def __init__( self, @@ -375,8 +377,8 @@ def to_macro_code( imports.add(f"from {param_module} import {param_class}") lines.append(f"{param_var} = {param_class}()") # Reconstruct each attribute - for item in param._items: # noqa: SLF001 - attr_name = item._name # noqa: SLF001 + for item in param.get_items(): + attr_name = item.get_name() value = getattr(param, attr_name, None) if value is not None: lines.append(f"{param_var}.{attr_name} = {value!r}") @@ -495,8 +497,6 @@ def replay( if hpanel is not None: ctx = hpanel.replaying() else: - from contextlib import nullcontext - ctx = nullcontext() with ctx: self._replay_inner(mainwindow, restore_selection, edit, uuid_remap) @@ -523,7 +523,7 @@ def _replay_inner( translated = self._translate_state(uuid_remap) if translated.is_current_state_compatible(mainwindow, False): translated.restore(mainwindow) - self._replay_compute(mainwindow, edit, uuid_remap) + self.replay_compute(mainwindow, edit, uuid_remap) else: if restore_selection: self.state.restore(mainwindow) @@ -548,7 +548,7 @@ def _translate_state(self, uuid_remap: dict[str, dict[str, str]]) -> WorkspaceSt } return translated - def _replay_compute( + def replay_compute( self, mainwindow: DLMainWindow, edit: bool, @@ -615,8 +615,7 @@ def _replay_ui(self, mainwindow: DLMainWindow, edit: bool) -> None: # objects but the captured selection no longer resolves to existing # UUIDs in the target panel, skip the call rather than delete whatever # is currently selected (which would silently destroy unrelated data). - DESTRUCTIVE_METHODS = {"remove_object", "remove_group", "delete_all_objects"} - if self.method_name in DESTRUCTIVE_METHODS: + if self.method_name in self.DESTRUCTIVE_METHODS: if target is None: _logger.warning( "Skipping destructive replay '%s': target '%s' not found", @@ -632,7 +631,7 @@ def _replay_ui(self, mainwindow: DLMainWindow, edit: bool) -> None: if o is not None } captured = set(self.state.selection.get(panel_str, [])) - if not (captured & existing_uuids): + if not captured & existing_uuids: _logger.warning( "Skipping destructive replay '%s': none of the captured " "UUIDs %s exist in panel '%s' anymore", diff --git a/datalab/history/session.py b/datalab/history/session.py index 24a78a2f7..7566ed9e6 100644 --- a/datalab/history/session.py +++ b/datalab/history/session.py @@ -328,22 +328,7 @@ def _claim_unmapped( # (intermediate actions are implicitly "closed" by the next # iteration's input restore). if self.actions: - last = self.actions[-1] - if last.kind == HistoryAction.KIND_COMPUTE: - hpanel = getattr(mainwindow, "historypanel", None) - if hpanel is not None: - output_uuid = hpanel._action_output_uuid(last) - if output_uuid: - panel_str = last.panel_str or "" - panel_map = uuid_remap.get(panel_str, {}) - mapped_uuid = panel_map.get(output_uuid, output_uuid) - for panel in panels: - if panel.PANEL_STR_ID == panel_str: - try: - panel.objview.select_objects([mapped_uuid]) - except KeyError: - pass - break + select_last_compute_output(mainwindow, panels, uuid_remap, self.actions[-1]) def serialize(self, writer: NativeH5Writer) -> None: """Serialize this history session @@ -390,3 +375,39 @@ def remove_action(self, action: HistoryAction) -> None: if action in self.actions: index = self.actions.index(action) self.actions = self.actions[:index] + + +def select_last_compute_output( + mainwindow: DLMainWindow, + panels: tuple, + uuid_remap: dict[str, dict[str, str]], + last_action: HistoryAction, +) -> None: + """Select the output of the last compute action after a session replay. + + Visually closes the replay by highlighting the final result in its panel. + No-op when the last action is not a compute action or its output is gone. + + Args: + mainwindow: DataLab's main window + panels: signal and image panels + uuid_remap: per-panel ``{old_uuid: new_uuid}`` mapping built during replay + last_action: last action of the replayed session + """ + if last_action.kind != HistoryAction.KIND_COMPUTE: + return + hpanel = getattr(mainwindow, "historypanel", None) + if hpanel is None: + return + output_uuid = hpanel.action_output_uuid(last_action) + if not output_uuid: + return + panel_str = last_action.panel_str or "" + mapped_uuid = uuid_remap.get(panel_str, {}).get(output_uuid, output_uuid) + target_panel = next((p for p in panels if p.PANEL_STR_ID == panel_str), None) + if target_panel is None: + return + try: + target_panel.objview.select_objects([mapped_uuid]) + except KeyError: + pass diff --git a/datalab/tests/features/common/history_test.py b/datalab/tests/features/common/history_test.py index be259cc4b..9e6f51af4 100644 --- a/datalab/tests/features/common/history_test.py +++ b/datalab/tests/features/common/history_test.py @@ -147,27 +147,27 @@ def _record_three_action_session(win): def _build_cascade_chain(panel, history): """Build chain s001 -> gaussian -> s002 -> derivative -> s003 -> mavg -> s004. - Returns ``(action_A, action_B, action_C, output_B, output_C)``. + Returns ``(action_a, action_b, action_c, output_b, output_c)``. """ panel.add_object(create_paracetamol_signal()) panel.objview.select_objects([1]) panel.processor.run_feature( sips.gaussian_filter, sigima.params.GaussianParam.create(sigma=1.5) ) - action_A = history[len(history)] + action_a = history[len(history)] panel.objview.select_objects([2]) panel.processor.run_feature(sips.derivative) - action_B = history[len(history)] - output_B = panel.objmodel.get_object_from_number(3) + action_b = history[len(history)] + output_b = panel.objmodel.get_object_from_number(3) panel.objview.select_objects([3]) mavg = sigima.params.MovingAverageParam() mavg.n = 3 panel.processor.run_feature(sips.moving_average, mavg) - action_C = history[len(history)] - output_C = panel.objmodel.get_object_from_number(4) - return action_A, action_B, action_C, output_B, output_C + action_c = history[len(history)] + output_c = panel.objmodel.get_object_from_number(4) + return action_a, action_b, action_c, output_b, output_c # --------------------------------------------------------------------------- @@ -787,7 +787,7 @@ def capture_compute_n_to_1(*_args, **kwargs): kwargs={"pairwise": True}, state=WorkspaceState(), ) - action._replay_compute(win, edit=False) # noqa: SLF001 + action.replay_compute(win, edit=False) assert captured["pairwise"] is True # --- scenario: 2_to_1 with vanished obj2 raises ValueError --- @@ -829,7 +829,7 @@ def capture_compute_2_to_1(obj2_arg, *_args, **kwargs): kwargs={"obj2_uuids": ["recorded-obj2"], "pairwise": True}, state=WorkspaceState(), ) - action._replay_compute( # noqa: SLF001 + action.replay_compute( win, edit=False, uuid_remap={panel.PANEL_STR_ID: {"recorded-obj2": obj2_uuid}}, @@ -1209,16 +1209,16 @@ def test_history_stepping_and_selection_sync(): # --- scenario: step button enabled state reflects position --- with datalab_test_app_context() as win: history = win.historypanel - prev_btn = history._step_prev_action # noqa: SLF001 - next_btn = history._step_next_action # noqa: SLF001 + prev_btn = history.step_prev_action + next_btn = history.step_next_action history.update_actions_state() assert not prev_btn.isEnabled() assert not next_btn.isEnabled() _panel, history = _record_three_action_session(win) sessions = history.history_sessions actions = sessions[-1].actions - prev_btn = history._step_prev_action # noqa: SLF001 - next_btn = history._step_next_action # noqa: SLF001 + prev_btn = history.step_prev_action + next_btn = history.step_next_action _select_tree_item_for(history, actions[0]) assert not prev_btn.isEnabled() assert next_btn.isEnabled() @@ -1364,11 +1364,11 @@ def test_history_cascade_recompute(): history = win.historypanel history.toggle_record_mode(True) panel = win.signalpanel - action_A, action_B, action_C, _ob, _oc = _build_cascade_chain(panel, history) - downstream = history.get_downstream_actions(action_A) - assert downstream == [action_B, action_C] - assert history.get_downstream_actions(action_C) == [] - assert history.get_downstream_actions(action_B) == [action_C] + action_a, action_b, action_c, _ob, _oc = _build_cascade_chain(panel, history) + downstream = history.get_downstream_actions(action_a) + assert downstream == [action_b, action_c] + assert not history.get_downstream_actions(action_c) + assert history.get_downstream_actions(action_b) == [action_c] # --- scenario: cascade recompute updates downstream outputs in place --- with datalab_test_app_context() as win: @@ -1376,31 +1376,31 @@ def test_history_cascade_recompute(): history.toggle_record_mode(True) history.toggle_edit_mode(True) panel = win.signalpanel - action_A, action_B, action_C, output_B, output_C = _build_cascade_chain( + action_a, action_b, action_c, output_b, output_c = _build_cascade_chain( panel, history ) - uuid_B = get_uuid(output_B) - uuid_C = get_uuid(output_C) - data_B_before = output_B.xydata.copy() - data_C_before = output_C.xydata.copy() + uuid_b = get_uuid(output_b) + uuid_c = get_uuid(output_c) + data_b_before = output_b.xydata.copy() + data_c_before = output_c.xydata.copy() n_objects_before = len(panel.objmodel) - result_obj_A = panel.objmodel.get_object_from_number(2) + result_obj_a = panel.objmodel.get_object_from_number(2) panel.objview.select_objects([2]) - assert panel.objprop.setup_processing_tab(result_obj_A, reset_params=False) + assert panel.objprop.setup_processing_tab(result_obj_a, reset_params=False) editor = panel.objprop.processing_param_editor assert editor is not None editor.dataset.sigma = 6.0 report = panel.objprop.apply_processing_parameters( - result_obj_A, interactive=False + result_obj_a, interactive=False ) assert report.success - assert action_A.kwargs["param"].sigma == 6.0 + assert action_a.kwargs["param"].sigma == 6.0 assert len(panel.objmodel) == n_objects_before - assert get_uuid(panel.objmodel[uuid_B]) == uuid_B - assert get_uuid(panel.objmodel[uuid_C]) == uuid_C - assert not np.array_equal(panel.objmodel[uuid_B].xydata, data_B_before) - assert not np.array_equal(panel.objmodel[uuid_C].xydata, data_C_before) - for a in (action_A, action_B, action_C): + assert get_uuid(panel.objmodel[uuid_b]) == uuid_b + assert get_uuid(panel.objmodel[uuid_c]) == uuid_c + assert not np.array_equal(panel.objmodel[uuid_b].xydata, data_b_before) + assert not np.array_equal(panel.objmodel[uuid_c].xydata, data_c_before) + for a in (action_a, action_b, action_c): assert a.is_stale is False # --- scenario: play on stale action triggers cascade --- @@ -1408,16 +1408,16 @@ def test_history_cascade_recompute(): history = win.historypanel history.toggle_record_mode(True) panel = win.signalpanel - action_A, action_B, _aC, output_B, _oC = _build_cascade_chain(panel, history) - uuid_B = get_uuid(output_B) - output_B.xydata = output_B.xydata * 0.0 - tampered = output_B.xydata.copy() - action_A.is_stale = True - _select_tree_item_for(history, action_A) + action_a, action_b, _ac, output_b, _oc = _build_cascade_chain(panel, history) + uuid_b = get_uuid(output_b) + output_b.xydata = output_b.xydata * 0.0 + tampered = output_b.xydata.copy() + action_a.is_stale = True + _select_tree_item_for(history, action_a) history.replay_restore_actions(replay=True, restore_selection=False) - assert action_A.is_stale is False - assert action_B.is_stale is False - assert not np.array_equal(panel.objmodel[uuid_B].xydata, tampered) + assert action_a.is_stale is False + assert action_b.is_stale is False + assert not np.array_equal(panel.objmodel[uuid_b].xydata, tampered) # --- scenario: cascade in a duplicated session that is NOT [-1] --- with datalab_test_app_context() as win: @@ -1425,9 +1425,9 @@ def test_history_cascade_recompute(): history.toggle_record_mode(True) history.toggle_edit_mode(True) panel = win.signalpanel - action_A, _aB, _aC, _oB, output_C = _build_cascade_chain(panel, history) - uuid_C_orig = get_uuid(output_C) - data_C_orig = output_C.xydata.copy() + action_a, _ab, _ac, _ob, output_c = _build_cascade_chain(panel, history) + uuid_c_orig = get_uuid(output_c) + data_c_orig = output_c.xydata.copy() panel.objview.select_objects([4]) panel.processor.run_feature(sips.derivative) sessions = history.history_sessions @@ -1442,33 +1442,31 @@ def test_history_cascade_recompute(): assert len(sessions) == 3 dup_session = sessions[1] assert sessions[-1] is not dup_session - dup_action_A = next( - a for a in dup_session.actions if a.func_name == action_A.func_name + dup_action_a = next( + a for a in dup_session.actions if a.func_name == action_a.func_name ) - dup_action_C = next( + dup_action_c = next( a for a in dup_session.actions if a.func_name == "moving_average" ) - dup_output_C_uuid = history._action_output_uuid(dup_action_C) # noqa: SLF001 - assert dup_output_C_uuid is not None - data_dup_C_before = panel.objmodel[dup_output_C_uuid].xydata.copy() - dup_result_obj_A_uuid = history._action_output_uuid( # noqa: SLF001 - dup_action_A - ) - assert dup_result_obj_A_uuid is not None - dup_result_obj_A = panel.objmodel[dup_result_obj_A_uuid] - panel.objview.select_objects([dup_result_obj_A_uuid]) - assert panel.objprop.setup_processing_tab(dup_result_obj_A, reset_params=False) + dup_output_c_uuid = history.action_output_uuid(dup_action_c) + assert dup_output_c_uuid is not None + data_dup_c_before = panel.objmodel[dup_output_c_uuid].xydata.copy() + dup_result_obj_a_uuid = history.action_output_uuid(dup_action_a) + assert dup_result_obj_a_uuid is not None + dup_result_obj_a = panel.objmodel[dup_result_obj_a_uuid] + panel.objview.select_objects([dup_result_obj_a_uuid]) + assert panel.objprop.setup_processing_tab(dup_result_obj_a, reset_params=False) editor = panel.objprop.processing_param_editor assert editor is not None editor.dataset.sigma = 10.0 report = panel.objprop.apply_processing_parameters( - dup_result_obj_A, interactive=False + dup_result_obj_a, interactive=False ) assert report.success assert not np.array_equal( - panel.objmodel[dup_output_C_uuid].xydata, data_dup_C_before + panel.objmodel[dup_output_c_uuid].xydata, data_dup_c_before ) - assert np.array_equal(panel.objmodel[uuid_C_orig].xydata, data_C_orig) + assert np.array_equal(panel.objmodel[uuid_c_orig].xydata, data_c_orig) # --------------------------------------------------------------------------- diff --git a/datalab/widgets/historydescription.py b/datalab/widgets/historydescription.py index 8fdbd22de..e2ca7945a 100644 --- a/datalab/widgets/historydescription.py +++ b/datalab/widgets/historydescription.py @@ -64,11 +64,13 @@ def html_matches_summary(self) -> bool: return self._html.strip() == html.escape(self._summary).strip() def on_toggled(self, checked: bool) -> None: + """Handle the expand/collapse toggle being toggled.""" self._expanded = checked self.refresh_widget() self.toggled.emit(checked) def refresh_widget(self) -> None: + """Refresh the widget contents to match the current expanded state.""" if self._expanded: self._toggle.setArrowType(QC.Qt.DownArrow) self._toggle.setToolTip(_("Hide details"))