From 7c366a9ca4345cd38a9280b315788ba2b51df433 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Miquel?= Date: Thu, 20 Aug 2026 11:39:14 +0200 Subject: [PATCH] gui --- copienator_gui/__init__.py | 5 + .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 252 bytes .../__pycache__/app.cpython-314.pyc | Bin 0 -> 55558 bytes .../__pycache__/runner.cpython-314.pyc | Bin 0 -> 7877 bytes .../__pycache__/state.cpython-314.pyc | Bin 0 -> 5886 bytes .../__pycache__/workflow.cpython-314.pyc | Bin 0 -> 20046 bytes copienator_gui/app.py | 660 ++++++++++++++++++ copienator_gui/runner.py | 113 +++ copienator_gui/state.py | 67 ++ copienator_gui/workflow.py | 485 +++++++++++++ gui.py | 32 + 11 files changed, 1362 insertions(+) create mode 100644 copienator_gui/__init__.py create mode 100644 copienator_gui/__pycache__/__init__.cpython-314.pyc create mode 100644 copienator_gui/__pycache__/app.cpython-314.pyc create mode 100644 copienator_gui/__pycache__/runner.cpython-314.pyc create mode 100644 copienator_gui/__pycache__/state.cpython-314.pyc create mode 100644 copienator_gui/__pycache__/workflow.cpython-314.pyc create mode 100644 copienator_gui/app.py create mode 100644 copienator_gui/runner.py create mode 100644 copienator_gui/state.py create mode 100644 copienator_gui/workflow.py create mode 100644 gui.py diff --git a/copienator_gui/__init__.py b/copienator_gui/__init__.py new file mode 100644 index 0000000..92a1179 --- /dev/null +++ b/copienator_gui/__init__.py @@ -0,0 +1,5 @@ +"""Interface graphique pour piloter le workflow Copienator.""" + +__all__ = ["__version__"] + +__version__ = "0.1.0" diff --git a/copienator_gui/__pycache__/__init__.cpython-314.pyc b/copienator_gui/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..95e00ea5d65623c0b872636664f9b2c230d094f4 GIT binary patch literal 252 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

W`Uj?183?)`}Np!*jIss=?pcBStpc5%^I$>gT zLN~9{Dg{$zillXGsOLeY-=gGG=>%`?Hu}wn3P2w$OJvzPQ@SFtCI146n$drd@c$Yp zj7e|GR_g@%|4w{8LsPZovnC50bl*|;DAQ0_ z^DxWcD45FnjChPI_`*qN@Iq5N89bbXZ{kyNU#6eH^$o2q1H=>(ubyEkB{ripT)Ts~ zj?&v6_H#K+iPOFG$FC#%N)+WuyMs&k5ymqN!X8m8Q_%CGq^h7V#j*H)VS>isbK*gd zCrGYI!F-YjDdoobA9){AXP;3?=h;Fy4xURVjh@s5l)$F6O}%fs1m+&HAy$$ObFWaB za>KFpsw-C1sv}QQ(^9X1DW^r2P>ylxZAcfG&>%e_>n8Vjm~jJ_e8KZV8zp3Xzba&~ zBOhc2vco$jI#8zeE0mhnYHX5MruI0i{{SyzWl5;IRI!%<-ftltCU&fcyKZzz&N|Tv zp1xr!{e64kbk~ggcMnRX8^zL%^W9SE-g~8cNz4rn{)<R2>wRFa*$jmp36`P^JQt+LEXvID(m}LoV)K;@t3Z=FpCOIcPl>1!C za@3^mV^e%7YpedMnOu`Sn~pi7In%2ISF->!jlw6FoANWs?G!6KB#R{8SMCkP)I{5@8lBPs67mZ$d<=zE2`n zdZiQOAo$J4_v@8_?RQHkWj$@b<11b`s@K8`DLv?gxW{A*6HCiN%1_xQ^19ro*-timq5{lrq&jMg=gC93U=_!#XRg zA^AO6v4ori3IawwBXMjktDE93MwHXY^?2V}ozir-#Tzxy?oUIHH!aWEr}s^1+V-C| z__-mH`m&%8g-XwzHg5YGrjTD!YloVZJbKOqm`e{G8i36Pi{cWeZ>v&*dLQIqzMX#B z%s25Fsu*6sRWf8V#S$5^x2feQF{q2gA={r0$5d*YcPJ^;`$}ydgdcz2rnn)Q$a%)r z=-{f_8h9qR^zBybR${6;h(Du0r*E(FT)hu!R6USFzYmRkCi*Z-G2aH1=Jf4X%TQvx zeJ}^^I8xYI1RC$n7zN`J=;DT2l{_opTlPAGf^S1K!?U0~NzhZ!OfVn%6i`zFqm))a z^Z}(L^}Z<{^~3voUzhq^i6!1c^+eBYhQrWq>Ht>w$$fzi#Z9zTrhpbbda0k2(M`;u zSd4811+ePTdE`8(v{b!o{84A>xr-RIDLtv&{aG4VRz6uR(6AybXX?2jdIp@#S%#CD zeY~c^c$Cj&666z%LJTPH_1qs+7>153?+3FK3InHnamwnqYrYWWC5IKi&<(r!HT1&of~H|4W$!U7UhPyME$zaA#Y2Wk-DA2M0q37fPRH;D0 zaQ~COG>I1AOVdBY?>M8^55P?w>aUElKYK8H#c@e$EmDpwuYoz(EL6rBPUcj~?pdB1 z!rtsbJ71)vU#Yj4d(up}6ap4gz-G8UM3iVCLaljJ&ZWIl&3x&geIry6AcDgAEI3sL zP7;+G^m>7P+d95nNwehZR;t65r~_xcJX0Mh{E|8b$v}3365~mmyG~_oS>5SK+kybYl%0dIw@@abNo}3pP0fH`BxrUP8ekS~X>mUxm0* zBvNlVtkCeNsw@aS;PwgN^}*M zN$0Lz@cM9e0#>;FaodJHOI@cBVFx2;eB6S_t`rCgbbUDEw1+quMY2Rtx=oo1AkZKd z7G5>%aZ_wEXl4hRrNuQfAzZqGBryw|Wt^R0J3g|I`N|b}hllv+SYwhHNFz(q$d==W zKlI?k*$xLpw+kL=4=?WF!~M@m{b$7fGZC0s>=|Jq64pa-c5q3kL>&SVD430Kif`Fu87*DHCr~j3 z1f|u8tlXmtJjVM|pf#jGtQl)Z{Cp9CAdGd}MQR6Do78RdL1}BhxV1mJ^(bot{B3Mu z!B>jP0TZNwC48GMZ{QLt#$|u}V7-6~=ZpBEH%&SXZs;}yLL_8WJbz&C6+UL5CN2CC zp2kvf^4&#fPyGk{fJcZw#;rs69Kz=_KOt(|~VWZ%D{YDOH?p>`oWMyNGFotAo7X))h#sho^U$C*HFK7>K~M|$-SbXiTc z)80E;ojB8mbmzwc4Y}&hH3oq+>Ol9&eFJpfNT)@ig2HoD2-~s}C`L0;ER}T}oFl~h z0PY>A--!GyIqZdc!yNE?M+7WLF=q5W6`qUe$QS63d!fcRjAu+`cN9BdOT#{yx(-mT zWPKVfb)^!%Wno9CFl88*Xm=$f<;8S*h@JM-kKZ^h<*gO-*51!sH`Tsq%O-v0-#&~@ z@%;64bO;Q{WkXmfc`r6^k(yh?<`${>5dOl=hpw9yE%6%BQ8VLt^V}QfqK>9;|IsPS zVqqC+tj#n)dHmGesf7*it=%oH-6yWy7b)Biy*z8-_jZ4Gcf{58o!#?kZx(;E_&d8r zS69^91;PBSL$4m1t$44rQ7T<0madyR#KiPua3A;oa7ufa2^ko_zHvHq>+-9YXA9;l zBjuYTPzdM9B41I3C&S`d^II8n8O%m?<6=&}l+!5YK(#%R(+oT9{9-A;R?M%RvNE0X zf^f;s1<&_<-|^i|i|pxpucYriTi;X{wdSK0(3SN8p z>dO&p*^Du2t$1LwU+elx*LBZL-wodkRQ15b=cY7D4O0j z!$-60!zWL{yp}q#PRyx`- z#cvePZ+d6<+q)USRk^A9uswC#4@ zpY%lQJFjyOib`*lzg9l$e5+`#XyLhNQOEVvMMwTk>kaFSiToAZHeR>hceE^K%ijqL z;#%eCo{{;QKYBh|vRy6(XY~NoeQ)>{&j0B1Km2@nxc2Pe>4Ta zOg{a!^qJ~68{TM$6gFPBKq-`&6a;2B%nzUu8BsLCktaDAPLX}QqbBNTruG-TRy4DD z{-tQqKD9b#pnlyNb!=bEEx5V;#`c-iXl^Y5PWLO_)1ld2cRTOz4@8|o$$43HUS2FM zn{hK2A9<5xLY!>!;e7RRdGF6>ZgvHm77; zCE8X^Uy9ZrjoJp;KwP^#b@_oK_h$Ml>9T4r)b<+Y8g8eD2aiV`CnU$P=onrsESYxy zZW=7*s~(yR*=MMd>>N~*?Y#cN?{L>maj^W2)<@7H$g6tTX*4*T*S>J=3sYZM0v+Mn zmZ+^&vUQ5K&P8Vd%=WA158Nh8@ysqNG&2jInF*y_1s`E8j-!~Ow%wAgMYOft4uty$ z!l&Hf^Alkkit-cjW1bJlsRSs&`1i&a#>KiFVg3~J%Wyi0xXcf{bLyQ_^QYzt@5&^m zKYol9OIIy&)rxBx@{n<#EC1Ec4yPC@x{bg3S*anv#rUhAl(sY0?@ojBbVKS7^PQ)g z%zstpJiT$LaR~AG;NU-*)=`LN3lmw0LB?tjh@E|^zR|y-8HBhaQVvKX%G!w4ud|P@w;h@IQgD83e_t;jv6x9=g?8(-3Muva0;`tf zpzze{*#hiK@{-f3`i@v~V>IGg1UNODaXk%InU^Ky01f;30{v`7SsJc2o1Pt|XT+_5 zA>KuCf<<{bQ7f}Dr|@8zzJRQypzvD^7H5zf`T}IZ=E7gt?wVXPr{R4zXc})UaEW-BBD9 z4;+&YoD>h7j2t)>w$(>%G9mbvs}P`{p$ijNwI?`vDu#ZAdNQB&$}bEiOH0ZlcAbHR z7yXj46KN75ft^3WBe!uS{2T@L;rwO8d9u{fVt2eCJ?$I?x+aX;GtCq|;1%GRRvt+~ z)dPI?JWIFZmCYDHZc6X6*-)~6sHhe2apUl%%4 z%k*`D9|Zpx#+~SMd4-ThBSf@psp?&_o^I0ONh29i^mKoV3L@~3^>m$Xv9V*O;76-} zxH|mY8L|7!&10_|i!^rpidmk1DW}@_T1K@I0;f)7k+GEFA=AWTsG3Rz(%9C<7Zfky z!7_#1q=qqo^HM7mJj9~n9*iZq#<0B+?1z+FCFWMaKu652i{v&;aS!adQKy?6DD4&- zcT0^O_ZmAQPIq|ZoHP;?M}pGG%i_q(;V*pg=a>MD`%@kp433tRhv;4{{Gw?rPJ6DL`+2VK7>FoEu2jaM5ZxvK+P2)Asf!rMkASqh{>Pg2eD=nO&0gX zMR=yTNI|yMA}B!TFhb0h;k5dUL#T_9<5(Jq?pVkhh~*{8?&Qa;YpBrxft`E5xBEN0 zrJcRv&fe(GzNr0(xU+9=)vOanvST;KzB+!-QH>|}?C|GwKb9gWD4ZZXO-J5=B(z@pJ3q^Eg3bQc%)(%HnCvNeS>% z&*(_Wr>boMc=Yn7FE0-%by}jgVWe;`dOlKks6Z)H)33F8H4ZtHI+gcIYdx5uu<@GU zT~yT?rY1ltV>&zt+6&N54hjk=ZCsJVBaBQ6&)x$_siRn+*B?rIG`>md>1$cL^!l5) z+NL z>e;eM2F+43TlOMS3YTz+rOJ{p;Z=IV_%dsqj6!5y^g_5zQZf3u zgp3GGc{JJE(lXZ$3|B$)fq78hyten+-l@Ga)l%g~{DmtwE^Lx^_J})sq@9EK3-25x zUaz1;DyS0+>ZF23u>kTLY3&|y?H<|fVWgl%D(DakI&QxdKGGj47?`p>Xxg?=`h%d< zbnsr&!Pm{xL(#(3VSXU)oK9vDZwb#r%UQ>_M)GIdiF6O!*Be~ghqe;rC7IUg2IkgYd z4e2%iWU!=bXc0P{1%jtlBf%@Cz{r43z#TEpWa%%(T@zOi?{J7R~+O*r#9i>ulX|FSXZ zLiVTf@4rW5qD)j7G*RI)eVN_VhM?&z5cy{V@=u7YG$0CjmM4=Ykcg)ah^{^{NK=|z zt@f%AM!eOTj5Kdy&z2lCZ!vWwhrTB$zHdsEQ!ATmoXpkIeh-5Vfmm0WauGbpJ0ZmA zs1D!dm1Zmxy8#yxN7%d)UOo65z3d^;jfkz19>R->EGLWDT#qx;92taA&vtB z-0(ETPAk9;X47Q;eq}!no}(~<nL5H3rN^@h-BWO*o3Q+8bYyf z#fhlxBvL+1F{SS}GSrjhIP~_L<>a8dRou`T+0Y)T=(uk?@XH7GqK^#5B+CELDxwtfHn&_t;|4iNYFZbfrTp5gm>*4A{>_g$-6&89nM3*t-GGiD@Y)3}R$ zlD^dl9VkV<7Utqdm(xOQ(h7qv?0fpiiPt9()qWRw6QWv^rHbS~RkJ-0>9&A{4{kD+ zYjH2$%Jj#7z*;$ks~q$&bR|bT8WtJTc$`Jn!8VOcQLXm`C!-xkJzP{hDac|-5=9Co zd?=(ER6)!Rm!RZ7g)}S37njU4xbTWW>s)-5OZ_USau~%Q0cl#qDw3cxe&&HjWrOwX z7L*zH2Dz)(s-CaUPXrqRA;weL zi0>iM^3mTZkN&3re~!Ljp`Kw}Vq(OFZ&ODA@l>x9&*RBTE?-p*7uJEAL6%*XEuYCbxgqI~bI_cp&g4h~7g6#oP2HFj6b_C#@Jf0|KlNsDSdjfi+=5Jp zDkRPEM%|5>6usGdJGvq6fV9~_GrZ2$h~7dPVu{2Yt$uPs>e?FkwTLo_HXz>-2^1Ef4vdQYkANrDiiU*a|Xp21LJ7)l5~K50!#jW9I7D@zL* zPgJ3_OuZ{@1jS9OGJu~+2u#4wtdZ&aP(s+DC%mYDYfRoWkfNutYBTWWW|Ok~D>syp z`wT#v!qFtv*GTpG%}=e*qV~r?!fH)h3!myY?=>?Xip4ZM6xSjAHXvy-TVaM0ybwEb z?fZY^3So1=;QIcbxI7cSpbKXZ30_xd97jCipeLbeGFQnMNaPIk4m4!TOBA*+nM4Hw z6Qs1FD1j8bWa$f&zkZ(Ho~37iD+6nd@Q3s)jecfPjNB|SwhJ@gE_(VLJ-AAjjr8+d zhy^T&)sm)=EX2(vnT*eaL!oF59AZ9tmQ8odlQ8O1g7TKGddShnQdJLf$WtRaYb58o zsB_&@lspP=9=dTzPA}_CM4aoP#1hxmXuof-c+>la_m55U?eBEG-Sr=PBJ1}_>pR5t z9n$*F==#owDdy}7=rg=-nr^?<^=jAGdm@GP_ni%oG7Wj_A3DwHMU2}c1%xQvEOJl~ z_~l30hQdnHB|yeilC%Dvvp(Fg>$d53+h2CS+kJai`0)Ag9$(b?{NI(h9$Jv>pFhel zIEs-Yy-4F>bIYHa7Fz#=i`w@8m)ydCM#A)>j{~dG)pyD(+X_>Dkm_i67=BQ=zCF|O z=Qbl<9oCLi)1Q~PI*g{jFj^2_!WlmE#PDn@yC2iYE0e)4GAG7m22(z6%O?XON+9d$ zkKt}-#mRsXcWP!*fDy3jZ4crUEcsL?SWl98>y(usZy+U*AtxpVJ@OBNqURl4kKF5s z6_lD}=UJMXbS44Rq%ltgX(Ic7quz9oDAmdO!JsGU<`X_e;w3#%@~xB@-vgkU4Z4PE zssXFUUx8ihwmOLTI+(HYoSvUW)r%x(ZX)pM+FuGm)om%pKRGB<34Ed$^gM4T z-dhIEeXiu(s;4MV;=8jlzv`u4+PV2asZUpCO^X=fLTLpm8XLVXyomx0h4~dLBqJp zYOGgE=mVRT9Mn^q`!*-PQ?*F+%u{%W!j!A2=Mo3(HJ8ACLqve36jiK+L#{c2o^DKO)Qp&w2lFQkU? zMTiws4CCLTq*U*Hv;~tClyaVAPOHGNhcQE+ZP{pXNxa3hOOzBovlf4bWuwKV@fP{r}|#wM?TeO00Z2oq#ojx*v1qv-~XPdpx*{zV!UqUE#39}+z;qt48IBnjPV({ z9IoheOC!;FQs7-Hlvknl`G87eDp60>&@rWu0h`kQ>k9J#c$hdJNUkQ*DMEWL&QvJ9pk&t;>5~b6gDvAMi}P{(Afaq5CLwh7mf;5`7?~<;%E9GfolSX(!$I}@S#8wI^%7Cc|Q(N zMVg}muzQSU7UbH3Yi}&=l3N&u<9(bsZ1&-NNp?&EHU?iob?gw?$;_JLH5Y3P6zxITC?d1Bk+lQOho>~(#4o_N}Mkf3?kJ^oMgy=*8oIPz^J85o0EN0vg zbMze_=setew72DGM@Ku~-Zp6o1xMFzQ(kl(Ztrb9+}725K%kkC9iTmF%BXe;w7qhoC2{kg*xhrSV*ySO|dISqkNLzBFm z9HT;MCebVoJLspADzefU3h_=7NOB*`z;>5#fjMpxzJmv{DG&#_>1A&K#!F6m#@47z zsGsg}kK>SXoRrEo?**F2m`WR+PO7dIN9mx_Y|6^^0}fCsB1$pSIF5kLaO05bi(VZ6 zjgyU`b2Uo8VktP2))WYx75XXl0Hro#ATG077#33F*&>>T z3E%`Qjah-vUIEGsK2IQ)P9>w(i?AUIxJT&}R6hx1EdH z&db{)*LGgrDOp$HFKk`K5>?)_Rfem!g?CIwZJ&!L+H-Bs6ih$Z=H+x()K*K+Nu#z- zwAV@YhI{q~dXi)u1B;l~cSt2o_ez=;ZFP&bj>W8;Yrd<#>9k0ei;hXC_#kV`&n+pL zTYq7(lxF-y#isb<@~nr(};QF>-EyZ9Gr=Gu(M z23#Ixpd2XUOPq4P#hP_*?fAnTk^C)lJ7zZBy!5q8b33N2;e0vU+6c z7P=O8-ge(jzx}x|KN#MA?7iG$Q{0F7g*TtS@%+t+8xxWII-GE^dMz66ti@4*i>o&z zqN-AIxo9nqSSuuJooKC-tPS_94U3KI<-;hY#(iSrzDQ$>)YvIDc19Yzr+UKh4sL58 zMVdWV_ej<<(OMRRsWg-3xrUY#&Zbe3+7+-a=bz*Ltq@NH)@onLhXC7vK2ef;DRE zeVD_J1dJaD!a6-+>9E~Uu=ST8)f%!3UoQ>YYd^xiH!EnVuJv05a|H{9OsTp>vw=s7 z*f{)g+Ig$s)qz%Y1)t;LlV_uzbK&zBBA&6Z<01|i%*=xENLAfbThvz0 z%3zy=RUNkjx4n0p!xg1%Xkq%}|BMo-g$YIzFO-m2Z7A{_q)iTb z9HWviF1?U95Ym!!YWZH&uUEE?Qi(pdj&*s8W?f#X=T}i(m&2_S>Dn!CkU=k>$|Ie~Le)rjwPq(sNEmEL}mj0okVHEUN6fUvmhh7_E>#r0|!FUE7F%0|BfADf`j z5HmI@M`tkJiE&Y+ohbY*U0U$Tvf&EjX%^|3hn~^GjqN1MX4Grw1#v1ckfyV$;u?`M z|H)=@;Fr4d_*Y0CD@OT&$h?mdwltNpmbk39}=XsWsogV4|tz9vvg>v$# zTA5eRms#F4%_jCWJV{|+wFxcInbB0u{(;0S^5l)yp6_6z^eMYJkYt&u?X>xO$gFJq zq6H+>ND8NS;W;gFG)kWU28dp*4kHi4GZI6;v7Y6oH&oiLtR^`Rf>lM3E8bn?>4hnfZ$qHE))@Q6g1r6e~7L z6??>rJ&}riH?po%OC#-mku&UA?%bS#;E2wb2ndD)}PlX!1BDR&)t*iV!B?|-84quqb`tW7pt z&(4W&A;MI_y8Rc1eA;@&Wp?93x-AT_8#z$NFPTiyG@=9&KQl7*5(-&qO3`shPK>p> z>BI>fPSJ?rQ{Xwv#5Uegesa(wbH_Fj!>HL9(r3?gSb84E&hfPArB{<4 zJD(sXx?~SqlnQ$D+@P6HA2e!3#;C`Z{5=L}<18`V*4f&{;dr|4()kQJ2$#1Gc&L7TmrGP zlPRY%gr86(%~j7HPo^|9!5HZP71B-@mCI&T5JxSv(S^kSGErr$w#t5gi1IYzyi{47 z$684Q6KCB)I0vzv+(;^hF_N|H_y}1X_ZUSivlCfLv>8ILLORe z{C#WwVqWFdjt?r=&Sys}n{OOn;BdxU5?_7nZVBV1k0!nNz>zE-Zn0$dg70olq~zeDqi`D3w`}@z57RC1S7Cy2z(a=t?nvx6>@#Wa zIqD@xljvxIYaObYq%`63P4g$`58clD!OP*JC*M0T6z(_`K6NHs=8ke_kxIjtADuoh zv+4@4ap%1#FTtIsS<1=LMEUdpyQ!7O>sC*%r(;Q0BBgy=;v3JYAcNn? zb_798lY^cCjB4e`cF&Tt;J4nX47`SqBkmnsQex){98n{2ME;ba#IB?sk;A^0K|PIB z!&}A)FL6|u3{v){$m;`&X?C_u(D#>(0s?M$@AA6nB&l&v$lJ^C8;3*u?`XWNm&W)X zoaZG6m&r1AH}WX!;LDVsiNvY5ArhA{?;ZSz6(y~M)w0=W z`;dk$gs*+!>K7z?g=nvc*sG?@Q2a{IO^6#SMH@`E;LYeEN325D)hvl4&rj3M_qkQS z{3y-fC?)l+TqfNszh^5CySDvl)xyZ1z(+&tf3f8-^{w2GnOXPiCHtL*cb(Rj4AZ;i zt`>{w`xXo0OBjpK7_N^IToXGhi|)Fa=&svH>rT$n9T(Z5qZ5H(MZn_4FuAsU{KbjO zY`4`*=c!}i5MpPfV(--z7!iDabt~7kH$-!nyqOzM`)Z z5NHc`jP{KM0Xn3lPa5#i=5M@E80cUZ)ITxqp?%q=$$}-t`Vw1*y$u;`c_NMG__!ShBnH%^gzJmU~rO z7Hvi0lI~xaQmV1#gUiEIb7r>@yg&KWlHFHD9d-0O^9D}Kt`Hp+;mXVR9g~l&NG)1{ zwKjvZgcbtb#s`jK$x#i50pXfGKQP^Hy~9b4u6vFyGQYuV8E^k&^ZqS{cehwu8cp9f zx?1W?->O;_cpN#aq&~R=D;A?qYl3)8Z~~ z0T#yp0}B&)tc!&@oH(%1teK?rvCzV>kkyieg{i`K0dFA?AH#B#!G^ekflMJG0L_8; zdOH+=g2gCwCIYU#0vU4%w{c;vJNL`@ro|vm7lSe|r;X4)?}T4?8sa=8#7Umh)cg!$ zEEfcxe?U#WICJZN!(I01T2GJj zalvAZ*#gjT$W%SKZ(|J*@Jn@8OJ%OzEx%DcCj%KhxL&|{cvk~6=36y9ji$KXlpoGhencdXN z<*E&a1PoD6uE;~_jn1crAzowQ@(G~9yaXCR9qF`H8f=1wNz;a=^;(cHX+WZyQqAG} z$WFu%hk~CWy6lj3=2ckHs$m_u56PYp2sTH#hW~GdJVZmTaL%cgUd+IBovD`ZPZ;%k z=#dpiT}x}w4HTI11HnvE&|eBFGwwNnBip=5Eb7$ki8O#QvaN$;4&whM)G0B`s=1zA zpGPjsh_ax{xTGwdIr&qUmEgfYgLdg~?4Pb(blfrsBB8U29(1-LEm~!+_#Y|ucXa8b z%l}Q6M|Al=>GFTzqR>tfWi8=|NnLE25442Iy6hvGLE|@&6Lb4GVXi95^|Dv8`(tus{LZnt!rQBvBV(;+TU{$EjW#8gP6+ zpkxJR( zlaG^#yAEx^=gBG;;Mg@EHeBsH%H&$2WFFvYOu5F}>;9 zrQf{t#*QgdIID@gOXR}8FdbPs?U7ck6IZR1RyB*Onj_AQQ|<6EoRfF`$m^!pTW3sP z@0@wzcMs0i{QI?$!flc4?eLck3Xy&_T~=kBG5x+(_Bch)NK$)!U%QhNLn|eH)L!$xK6T@yNjDvpjPR6MySIUwoLVD zf>0}&X*`V7v4SL7Ee`G-8Jh@r84&o90>}ad(_(YiM3sDGHsltmSlUU?Pr+gT*Wl?6h1KXy1t@{{**fcAi~oZW7C zce}ME&Gh|LBd!hQvY(m*^o4_T>7vUCy0GOEoBThZpZ_aeXt^VMhGM+H-_uXF#l-l7 z59#NBrOPq8xajhabYWe|7>9qLpY3!Z7D5)6w^6KxF7MNY5iJN^#R*SnjN-Q8;TLcuMf{C@7}^6uAUNh5_l{2t4-5NHK#YTE!|xTKW-vzO4bde+ zml3*n=+Zz#xREYI+Jy)7Y=C0qPg&y=i;eeXdPZ9Q?Bok!lw#x@L$(X}@06FR4*j?P zx23CTX(NiF?~U`~OgfP!&Bstbzz_;%QA?Ml6)9xz!iB32RUDKE>PtlYSac;>##Mhx ze}_;?NC+-Uw~|(GW; zrZ!zb4|9pxS6NaIn&ncWD6-@!D%x|VK;^aMprHhj8Mpu^T z3+V|+AMgc>K&i5#h`)TcH-O=67tK((_q;))4!LPM=j&X)C;r+ z=CX!VH2|a!6ZN(RexY~%3{kH=@QeLV&p%)Ez6|`iel#hDMekeTAwCLqt^fIZ>$>$H Jb)PzWvQK@uZCC&R literal 0 HcmV?d00001 diff --git a/copienator_gui/__pycache__/runner.cpython-314.pyc b/copienator_gui/__pycache__/runner.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2ae4ddc7dcfc320a3560ffce02906de3763974dc GIT binary patch literal 7877 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 CVkl_< literal 0 HcmV?d00001 diff --git a/copienator_gui/__pycache__/workflow.cpython-314.pyc b/copienator_gui/__pycache__/workflow.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5164e169fe131073baea2a95827448883357c97e GIT binary patch literal 20046 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 N* None: + super().__init__() + self.repository = repository.resolve() + self.steps = build_workflow(show_personal_steps) + self.step_by_id = {step.id: step for step in self.steps} + self.state_store = StateStore() + self.runner = ProcessRunner() + self.active_step_id: str | None = None + self.current_step: StepDefinition | None = None + self.arg_vars: dict[str, tk.Variable] = {} + self._rendering = False + + self.title("Copienator — assistant de correction") + self.geometry("1180x820") + self.minsize(900, 640) + self.protocol("WM_DELETE_WINDOW", self._on_close) + + self.evaluation_var = tk.StringVar() + self.api_key_var = tk.StringVar(value=os.environ.get("GEMINI_API_KEY", "")) + self.proxy_var = tk.StringVar(value=os.environ.get("HTTPS_PROXY", "")) + self.variant_var = tk.StringVar() + self.extra_var = tk.StringVar() + self.command_var = tk.StringVar(value="Sélectionnez une étape.") + self.info_var = tk.StringVar(value="Choisissez un dossier d’évaluation.") + + self._build_ui(show_personal_steps) + self.extra_var.trace_add("write", lambda *_args: self._update_command_preview()) + self.after(60, self._poll_runner) + + if initial_evaluation and initial_evaluation.is_dir(): + self.evaluation_var.set(str(initial_evaluation.resolve())) + self._load_evaluation() + else: + self._populate_tree() + + @property + def evaluation(self) -> Path | None: + value = self.evaluation_var.get().strip() + return Path(value).expanduser().resolve() if value else None + + def _build_ui(self, show_personal_steps: bool) -> None: + self.columnconfigure(0, weight=1) + self.rowconfigure(1, weight=3) + self.rowconfigure(2, weight=2) + + top = ttk.Frame(self, padding=(10, 8)) + top.grid(row=0, column=0, sticky="ew") + top.columnconfigure(1, weight=1) + ttk.Label(top, text="Évaluation").grid(row=0, column=0, sticky="w", padx=(0, 8)) + path_entry = ttk.Entry(top, textvariable=self.evaluation_var) + path_entry.grid(row=0, column=1, sticky="ew") + path_entry.bind("", lambda _event: self._load_evaluation()) + ttk.Button(top, text="Parcourir…", command=self._browse_evaluation).grid(row=0, column=2, padx=6) + ttk.Button(top, text="Charger", command=self._load_evaluation).grid(row=0, column=3) + + ttk.Label(top, text="Clé Gemini").grid(row=1, column=0, sticky="w", padx=(0, 8), pady=(7, 0)) + ttk.Entry(top, textvariable=self.api_key_var, show="•", width=32).grid( + row=1, column=1, sticky="w", pady=(7, 0) + ) + environment = ttk.Frame(top) + environment.grid(row=1, column=2, columnspan=2, sticky="e", pady=(7, 0)) + ttk.Label(environment, text="HTTPS_PROXY").pack(side="left", padx=(0, 6)) + ttk.Entry(environment, textvariable=self.proxy_var, width=28).pack(side="left") + profile = "standard + personnel" if show_personal_steps else "standard" + ttk.Label(environment, text=f"Profil : {profile}").pack(side="left", padx=(12, 0)) + + main_pane = ttk.Panedwindow(self, orient="horizontal") + main_pane.grid(row=1, column=0, sticky="nsew", padx=10) + + navigation = ttk.Frame(main_pane, padding=(0, 5, 6, 5)) + self.tree = ttk.Treeview(navigation, columns=("status",), show="tree headings", selectmode="browse") + self.tree.heading("#0", text="Étape") + self.tree.heading("status", text="État") + self.tree.column("#0", minwidth=230, width=280) + self.tree.column("status", minwidth=90, width=105, anchor="center") + tree_scroll = ttk.Scrollbar(navigation, orient="vertical", command=self.tree.yview) + self.tree.configure(yscrollcommand=tree_scroll.set) + self.tree.pack(side="left", fill="both", expand=True) + tree_scroll.pack(side="right", fill="y") + self.tree.bind("<>", self._on_tree_select) + main_pane.add(navigation, weight=1) + + self.detail = ttk.Frame(main_pane, padding=(12, 6, 4, 4)) + self.detail.columnconfigure(0, weight=1) + self.detail.rowconfigure(2, weight=1) + self.title_label = ttk.Label(self.detail, text="Sélectionnez une étape", font=("TkDefaultFont", 15, "bold")) + self.title_label.grid(row=0, column=0, sticky="w") + self.description_label = ttk.Label(self.detail, text="", wraplength=680, justify="left") + self.description_label.grid(row=1, column=0, sticky="ew", pady=(5, 8)) + self.form = ttk.Frame(self.detail) + self.form.grid(row=2, column=0, sticky="nsew") + self.form.columnconfigure(1, weight=1) + + command_box = ttk.LabelFrame(self.detail, text="Commande", padding=6) + command_box.grid(row=3, column=0, sticky="ew", pady=(8, 5)) + command_box.columnconfigure(0, weight=1) + ttk.Label(command_box, textvariable=self.command_var, wraplength=690, justify="left").grid( + row=0, column=0, sticky="ew" + ) + + buttons = ttk.Frame(self.detail) + buttons.grid(row=4, column=0, sticky="ew", pady=(5, 0)) + self.previous_button = ttk.Button(buttons, text="← Précédente", command=lambda: self._move_selection(-1)) + self.previous_button.pack(side="left") + self.next_button = ttk.Button(buttons, text="Suivante →", command=lambda: self._move_selection(1)) + self.next_button.pack(side="left", padx=6) + self.skip_button = ttk.Button(buttons, text="Marquer ignorée", command=self._skip_step) + self.skip_button.pack(side="right") + self.run_button = ttk.Button(buttons, text="Exécuter", command=self._run_current_step) + self.run_button.pack(side="right", padx=6) + main_pane.add(self.detail, weight=3) + + console_frame = ttk.LabelFrame(self, text="Sortie en temps réel", padding=(7, 5)) + console_frame.grid(row=2, column=0, sticky="nsew", padx=10, pady=(6, 8)) + console_frame.columnconfigure(0, weight=1) + console_frame.rowconfigure(0, weight=1) + self.console = tk.Text( + console_frame, + height=14, + wrap="word", + background="#171717", + foreground="#efefef", + insertbackground="white", + state="disabled", + ) + console_scroll = ttk.Scrollbar(console_frame, orient="vertical", command=self.console.yview) + self.console.configure(yscrollcommand=console_scroll.set) + self.console.grid(row=0, column=0, sticky="nsew") + console_scroll.grid(row=0, column=1, sticky="ns") + + console_actions = ttk.Frame(console_frame) + console_actions.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(5, 0)) + console_actions.columnconfigure(1, weight=1) + ttk.Label(console_actions, text="Réponse au script").grid(row=0, column=0, padx=(0, 6)) + self.stdin_var = tk.StringVar() + self.stdin_entry = ttk.Entry(console_actions, textvariable=self.stdin_var) + self.stdin_entry.grid(row=0, column=1, sticky="ew") + self.stdin_entry.bind("", lambda _event: self._send_input()) + self.send_button = ttk.Button(console_actions, text="Envoyer", command=self._send_input, state="disabled") + self.send_button.grid(row=0, column=2, padx=5) + self.interrupt_button = ttk.Button( + console_actions, text="Interrompre", command=self._interrupt, state="disabled" + ) + self.interrupt_button.grid(row=0, column=3, padx=5) + self.force_button = ttk.Button( + console_actions, text="Forcer l’arrêt", command=self._force_stop, state="disabled" + ) + self.force_button.grid(row=0, column=4) + ttk.Button(console_actions, text="Effacer la console", command=self._clear_console).grid( + row=0, column=5, padx=(8, 0) + ) + + status = ttk.Label(self, textvariable=self.info_var, anchor="w", relief="sunken", padding=(6, 3)) + status.grid(row=3, column=0, sticky="ew") + + def _browse_evaluation(self) -> None: + selected = filedialog.askdirectory(initialdir=self.evaluation_var.get() or self.repository) + if selected: + self.evaluation_var.set(selected) + self._load_evaluation() + + def _load_evaluation(self) -> None: + evaluation = self.evaluation + if not evaluation or not evaluation.is_dir(): + messagebox.showerror("Dossier invalide", "Choisissez un dossier d’évaluation existant.") + return + self._save_current_form() + self.state_store.load(evaluation) + for step in self.steps: + if self.state_store.step(step.id).get("status") == "running": + self.state_store.update_step(step.id, status="interrupted") + self._populate_tree() + missing = [name for name in ("enonce.pdf", "enonce.tex", "correction.tex") if not (evaluation / name).exists()] + names_found = (evaluation / "names").exists() or (self.repository / "names").exists() + details = [] + if missing: + details.append("manquants : " + ", ".join(missing)) + if not names_found: + details.append("fichier names introuvable") + if not self.api_key_var.get(): + details.append("clé Gemini non renseignée") + self.info_var.set( + f"Évaluation chargée : {evaluation}" + + (" — " + " ; ".join(details) if details else " — prérequis principaux détectés") + ) + first = self.steps[0].id if self.steps else None + if first: + self.tree.selection_set(first) + self.tree.see(first) + + def _populate_tree(self) -> None: + selected = self.current_step.id if self.current_step else None + self.tree.delete(*self.tree.get_children()) + section_items: dict[str, str] = {} + for step in self.steps: + if step.section not in section_items: + section_id = f"section:{len(section_items)}" + section_items[step.section] = section_id + self.tree.insert("", "end", iid=section_id, text=step.section, values=("",), open=True) + status = self._step_status(step) + suffix = " (facultative)" if step.optional else "" + self.tree.insert( + section_items[step.section], + "end", + iid=step.id, + text=step.title + suffix, + values=(STATUS_LABELS.get(status, status),), + ) + if selected and self.tree.exists(selected): + self.tree.selection_set(selected) + + def _step_status(self, step: StepDefinition) -> str: + if self.state_store.evaluation: + saved = self.state_store.step(step.id).get("status") + if saved: + return str(saved) + if self._artifacts_exist(step): + return "detected" + if step.id == "inputs" and not self._missing_requirements(step): + return "detected" + return "ready" + + def _artifacts_exist(self, step: StepDefinition) -> bool: + evaluation = self.evaluation + if not evaluation or not step.artifacts: + return False + for pattern in step.artifacts: + if any(char in pattern for char in "*?["): + if next(evaluation.glob(pattern), None) is not None: + return True + elif (evaluation / pattern).exists(): + return True + return False + + def _missing_requirements(self, step: StepDefinition) -> list[str]: + evaluation = self.evaluation + if not evaluation: + return list(step.requires) + missing = [] + for pattern in step.requires: + if any(char in pattern for char in "*?["): + exists = next(evaluation.glob(pattern), None) is not None + else: + exists = (evaluation / pattern).exists() + if not exists: + missing.append(pattern) + return missing + + def _on_tree_select(self, _event: tk.Event[Any] | None = None) -> None: + selection = self.tree.selection() + if not selection or selection[0].startswith("section:"): + return + step = self.step_by_id.get(selection[0]) + if not step: + return + if self.current_step and self.current_step.id != step.id: + self._save_current_form() + self.current_step = step + self._render_step() + + def _render_step(self) -> None: + step = self.current_step + if not step: + return + self._rendering = True + for child in self.form.winfo_children(): + child.destroy() + self.arg_vars.clear() + entry = self.state_store.step(step.id) if self.state_store.evaluation else {} + saved_variant = entry.get("variant", step.variants[0].id) + if saved_variant not in {variant.id for variant in step.variants}: + saved_variant = step.variants[0].id + self.variant_var.set(saved_variant) + self.extra_var.set(str(entry.get("extra", ""))) + self.title_label.configure(text=step.title) + missing = self._missing_requirements(step) + description = step.description + if missing: + description += "\nPrérequis non détectés : " + ", ".join(missing) + self.description_label.configure(text=description) + + row = 0 + if len(step.variants) > 1: + ttk.Label(self.form, text="Mode").grid(row=row, column=0, sticky="w", pady=4, padx=(0, 8)) + labels = [variant.label for variant in step.variants] + combo = ttk.Combobox(self.form, values=labels, state="readonly") + variant_index = [variant.id for variant in step.variants].index(saved_variant) + combo.current(variant_index) + combo.grid(row=row, column=1, sticky="ew", pady=4) + combo.bind("<>", lambda _event: self._select_variant(combo.current())) + row += 1 + + values = entry.get("values", {}) if isinstance(entry.get("values", {}), dict) else {} + variant = self._current_variant() + evaluation_arg = self._evaluation_arg() + for spec in step.arguments: + if spec.variants and variant.id not in spec.variants: + continue + value = values.get(spec.name, value_for_default(spec.default, evaluation_arg)) + ttk.Label(self.form, text=spec.label).grid(row=row, column=0, sticky="nw", pady=4, padx=(0, 8)) + if spec.kind == "bool": + variable: tk.Variable = tk.BooleanVar(value=bool(value)) + widget = ttk.Checkbutton(self.form, variable=variable) + widget.grid(row=row, column=1, sticky="w", pady=4) + elif spec.kind == "choice": + variable = tk.StringVar(value=str(value)) + widget = ttk.Combobox(self.form, textvariable=variable, values=spec.choices, state="readonly") + widget.grid(row=row, column=1, sticky="ew", pady=4) + else: + variable = tk.StringVar(value=str(value)) + field = ttk.Frame(self.form) + field.grid(row=row, column=1, sticky="ew", pady=4) + field.columnconfigure(0, weight=1) + widget = ttk.Entry(field, textvariable=variable) + widget.grid(row=0, column=0, sticky="ew") + if spec.kind == "path": + ttk.Button(field, text="Fichier…", command=lambda var=variable: self._browse_target_file(var)).grid( + row=0, column=1, padx=(5, 0) + ) + ttk.Button(field, text="Dossier…", command=lambda var=variable: self._browse_target_dir(var)).grid( + row=0, column=2, padx=(5, 0) + ) + self.arg_vars[spec.name] = variable + variable.trace_add("write", lambda *_args: self._update_command_preview()) + if spec.help: + ttk.Label(self.form, text=spec.help, foreground="#666666", wraplength=540).grid( + row=row + 1, column=1, sticky="w" + ) + row += 1 + row += 1 + + if not step.is_manual: + ttk.Label(self.form, text="Arguments supplémentaires").grid( + row=row, column=0, sticky="w", pady=(10, 4), padx=(0, 8) + ) + ttk.Entry(self.form, textvariable=self.extra_var).grid(row=row, column=1, sticky="ew", pady=(10, 4)) + + self.run_button.configure(text="Marquer terminée" if step.is_manual else "Exécuter") + self.skip_button.configure(state="normal" if step.optional else "disabled") + self._rendering = False + self._update_command_preview() + self._update_controls() + + def _select_variant(self, index: int) -> None: + if not self.current_step or index < 0: + return + self._save_current_form() + self.variant_var.set(self.current_step.variants[index].id) + if self.state_store.evaluation: + self.state_store.update_step(self.current_step.id, variant=self.variant_var.get()) + self._render_step() + + def _current_variant(self) -> CommandVariant: + assert self.current_step is not None + selected = self.variant_var.get() + return next((variant for variant in self.current_step.variants if variant.id == selected), self.current_step.variants[0]) + + def _values(self) -> dict[str, object]: + return {name: variable.get() for name, variable in self.arg_vars.items()} + + def _save_current_form(self) -> None: + if self._rendering or not self.current_step or not self.state_store.evaluation: + return + saved_values = self.state_store.step(self.current_step.id).get("values", {}) + merged_values = dict(saved_values) if isinstance(saved_values, dict) else {} + merged_values.update(self._values()) + self.state_store.update_step( + self.current_step.id, + variant=self.variant_var.get() or self.current_step.variants[0].id, + values=merged_values, + extra=self.extra_var.get(), + ) + + def _evaluation_arg(self) -> str: + evaluation = self.evaluation + return evaluation_argument(self.repository, evaluation) if evaluation else "<évaluation>" + + def _make_command(self) -> list[str]: + if not self.current_step: + return [] + return build_command( + self.repository, + self.current_step, + self._current_variant(), + self._values(), + self._evaluation_arg(), + self.extra_var.get(), + ) + + def _update_command_preview(self) -> None: + if self._rendering or not self.current_step: + return + if self.current_step.is_manual: + self.command_var.set("Étape manuelle — aucune commande ne sera exécutée.") + return + try: + self.command_var.set(command_display(self._make_command())) + except ValueError as exc: + self.command_var.set(f"Arguments invalides : {exc}") + + def _browse_target_file(self, variable: tk.Variable) -> None: + selected = filedialog.askopenfilename(initialdir=self.evaluation or self.repository) + if selected: + variable.set(selected) + + def _browse_target_dir(self, variable: tk.Variable) -> None: + selected = filedialog.askdirectory(initialdir=self.evaluation or self.repository) + if selected: + variable.set(selected) + + def _validate_arguments(self) -> bool: + if not self.current_step: + return False + variant = self._current_variant() + for spec in self.current_step.arguments: + if spec.variants and variant.id not in spec.variants: + continue + value = self.arg_vars.get(spec.name) + if spec.kind == "int" and value and str(value.get()).strip(): + try: + int(str(value.get())) + except ValueError: + messagebox.showerror("Argument invalide", f"« {spec.label} » doit être un entier.") + return False + try: + self._make_command() + except ValueError as exc: + messagebox.showerror("Arguments invalides", str(exc)) + return False + return True + + def _run_current_step(self) -> None: + step = self.current_step + evaluation = self.evaluation + if not step or not evaluation or not self.state_store.evaluation: + messagebox.showerror("Évaluation absente", "Chargez d’abord un dossier d’évaluation.") + return + if self.runner.running: + messagebox.showwarning("Traitement en cours", "Interrompez le traitement actuel avant d’en lancer un autre.") + return + if step.is_manual: + self._mark_step("success") + return + if not self._validate_arguments(): + return + missing = self._missing_requirements(step) + if missing and not messagebox.askyesno( + "Prérequis non détectés", + "Les éléments suivants n’ont pas été trouvés :\n\n" + + "\n".join(missing) + + "\n\nLancer tout de même la commande ?", + ): + return + variant = self._current_variant() + command = self._make_command() + if variant.dangerous and not messagebox.askyesno( + "Confirmation requise", + "Cette commande réinitialise ou supprime des résultats de correction. Continuer ?", + icon="warning", + ): + return + + self._save_current_form() + ordered_ids = [item.id for item in self.steps] + self.state_store.invalidate_after(ordered_ids, step.id) + self.state_store.update_step(step.id, status="running", command=command_display(command)) + self.active_step_id = step.id + timestamp = datetime.now().astimezone().strftime("%Y%m%d-%H%M%S") + safe_step = re.sub(r"[^A-Za-z0-9_.-]+", "_", step.id) + log_path = evaluation / ".copienator" / "logs" / f"{timestamp}-{safe_step}.log" + + environment = os.environ.copy() + environment["PYTHONUNBUFFERED"] = "1" + environment["PYTHONIOENCODING"] = "utf-8" + if self.api_key_var.get().strip(): + environment["GEMINI_API_KEY"] = self.api_key_var.get().strip() + if self.proxy_var.get().strip(): + environment["HTTPS_PROXY"] = self.proxy_var.get().strip() + + self._append_console(f"\n$ {command_display(command)}\n") + try: + self.runner.start(command, self.repository, environment, log_path) + except (OSError, RuntimeError) as exc: + self.state_store.update_step(step.id, status="failed") + self.state_store.add_history( + {"step": step.id, "command": command_display(command), "status": "failed", "error": str(exc)} + ) + self.active_step_id = None + self._append_console(f"Impossible de lancer la commande : {exc}\n") + messagebox.showerror("Échec du lancement", str(exc)) + self._populate_tree() + self._update_controls() + + def _mark_step(self, status: str) -> None: + if not self.current_step or not self.state_store.evaluation: + return + self._save_current_form() + self.state_store.invalidate_after([item.id for item in self.steps], self.current_step.id) + self.state_store.update_step(self.current_step.id, status=status) + self.state_store.add_history({"step": self.current_step.id, "status": status, "manual": True}) + self._populate_tree() + self.info_var.set(f"{self.current_step.title} : {STATUS_LABELS.get(status, status).lower()}.") + + def _skip_step(self) -> None: + if self.current_step and self.current_step.optional: + self._mark_step("skipped") + + def _poll_runner(self) -> None: + while True: + try: + event, payload = self.runner.events.get_nowait() + except queue.Empty: + break + if event == "output": + self._append_console(str(payload)) + elif event == "input_echo": + self._append_console(f"> {payload}") + elif event == "runner_error": + self._append_console(f"\nErreur du lanceur : {payload}\n") + elif event == "finished": + return_code, interrupted = payload + self._finish_process(int(return_code), bool(interrupted)) + self.after(60, self._poll_runner) + + def _finish_process(self, return_code: int, interrupted: bool) -> None: + step_id = self.active_step_id + if not step_id: + return + if interrupted: + status = "interrupted" + else: + status = "success" if return_code == 0 else "failed" + self.state_store.update_step(step_id, status=status, return_code=return_code) + self.state_store.add_history( + { + "step": step_id, + "command": self.state_store.step(step_id).get("command", ""), + "status": status, + "return_code": return_code, + } + ) + title = self.step_by_id[step_id].title + self._append_console(f"\n[Terminé — code {return_code} — {STATUS_LABELS[status]}]\n") + self.info_var.set(f"{title} : {STATUS_LABELS[status].lower()} (code {return_code}).") + self.active_step_id = None + self._populate_tree() + self._update_controls() + + def _send_input(self) -> None: + text = self.stdin_var.get() + try: + self.runner.send_input(text) + except RuntimeError as exc: + messagebox.showinfo("Aucune saisie attendue", str(exc)) + return + self.stdin_var.set("") + + def _interrupt(self) -> None: + if self.runner.running and messagebox.askyesno( + "Interrompre", "Envoyer une interruption au script en cours ?" + ): + try: + self.runner.interrupt() + self.info_var.set("Interruption demandée. Utilisez « Forcer l’arrêt » si le script ne répond pas.") + except OSError as exc: + messagebox.showerror("Interruption impossible", str(exc)) + + def _force_stop(self) -> None: + if self.runner.running and messagebox.askyesno( + "Forcer l’arrêt", "Forcer l’arrêt du script et de ses processus enfants ?", icon="warning" + ): + try: + self.runner.force_stop() + except OSError as exc: + messagebox.showerror("Arrêt impossible", str(exc)) + + def _append_console(self, text: str) -> None: + self.console.configure(state="normal") + self.console.insert("end", text) + self.console.see("end") + self.console.configure(state="disabled") + + def _clear_console(self) -> None: + self.console.configure(state="normal") + self.console.delete("1.0", "end") + self.console.configure(state="disabled") + + def _update_controls(self) -> None: + running = self.runner.running + self.run_button.configure(state="disabled" if running or not self.current_step else "normal") + self.interrupt_button.configure(state="normal" if running else "disabled") + self.force_button.configure(state="normal" if running else "disabled") + self.send_button.configure(state="normal" if running else "disabled") + self.stdin_entry.configure(state="normal" if running else "disabled") + + def _move_selection(self, delta: int) -> None: + if not self.current_step: + return + ids = [step.id for step in self.steps] + try: + index = ids.index(self.current_step.id) + except ValueError: + return + target = max(0, min(len(ids) - 1, index + delta)) + self.tree.selection_set(ids[target]) + self.tree.see(ids[target]) + + def _on_close(self) -> None: + if self.runner.running: + if not messagebox.askyesno( + "Traitement en cours", "Un script est encore actif. Le forcer à s’arrêter et fermer l’application ?" + ): + return + try: + self.runner.force_stop() + except OSError: + pass + self._save_current_form() + self.destroy() diff --git a/copienator_gui/runner.py b/copienator_gui/runner.py new file mode 100644 index 0000000..0b19e90 --- /dev/null +++ b/copienator_gui/runner.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import os +import queue +import signal +import subprocess +import threading +from pathlib import Path +from typing import Any + + +class ProcessRunner: + """Run one subprocess and expose thread-safe events for the Tk loop.""" + + def __init__(self) -> None: + self.events: queue.Queue[tuple[str, Any]] = queue.Queue() + self.process: subprocess.Popen[bytes] | None = None + self._interrupted = False + self._log_file = None + + @property + def running(self) -> bool: + return self.process is not None and self.process.poll() is None + + def start( + self, + command: list[str], + cwd: Path, + environment: dict[str, str], + log_path: Path, + ) -> None: + if self.running: + raise RuntimeError("Un processus est déjà en cours") + log_path.parent.mkdir(parents=True, exist_ok=True) + self._log_file = log_path.open("wb") + self._interrupted = False + + kwargs: dict[str, Any] = {} + if os.name == "nt": + kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP + else: + kwargs["start_new_session"] = True + + self.process = subprocess.Popen( + command, + cwd=cwd, + env=environment, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + bufsize=0, + **kwargs, + ) + thread = threading.Thread(target=self._read_process, daemon=True) + thread.start() + + def _read_process(self) -> None: + process = self.process + if process is None or process.stdout is None: + return + return_code = -1 + try: + while True: + chunk = process.stdout.read(4096) + if not chunk: + break + if self._log_file: + self._log_file.write(chunk) + self._log_file.flush() + self.events.put(("output", chunk.decode("utf-8", errors="replace"))) + return_code = process.wait() + except (OSError, ValueError) as exc: # pragma: no cover - defensive reporting + self.events.put(("runner_error", str(exc))) + return_code = process.wait() + finally: + if self._log_file: + self._log_file.close() + self._log_file = None + process.stdout.close() + if process.stdin: + process.stdin.close() + self.events.put(("finished", (return_code, self._interrupted))) + + def send_input(self, text: str) -> None: + if not self.running or not self.process or not self.process.stdin: + raise RuntimeError("Aucun processus n’attend de saisie") + data = (text + "\n").encode("utf-8") + self.process.stdin.write(data) + self.process.stdin.flush() + self.events.put(("input_echo", text + "\n")) + + def interrupt(self) -> None: + if not self.running or not self.process: + return + self._interrupted = True + if os.name == "nt": + self.process.send_signal(signal.CTRL_BREAK_EVENT) + else: + os.killpg(self.process.pid, signal.SIGINT) + + def force_stop(self) -> None: + if not self.running or not self.process: + return + self._interrupted = True + if os.name == "nt": + subprocess.run( + ["taskkill", "/PID", str(self.process.pid), "/T", "/F"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + else: + os.killpg(self.process.pid, signal.SIGKILL) diff --git a/copienator_gui/state.py b/copienator_gui/state.py new file mode 100644 index 0000000..b2f1b04 --- /dev/null +++ b/copienator_gui/state.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import json +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +STATE_FILENAME = ".copienator-gui.json" + + +class StateStore: + def __init__(self) -> None: + self.evaluation: Path | None = None + self.data: dict[str, Any] = self._empty_data() + + @staticmethod + def _empty_data() -> dict[str, Any]: + return {"version": 1, "steps": {}, "history": []} + + def load(self, evaluation: Path) -> None: + self.evaluation = evaluation + path = evaluation / STATE_FILENAME + try: + loaded = json.loads(path.read_text(encoding="utf-8")) + self.data = loaded if isinstance(loaded, dict) else self._empty_data() + except (OSError, json.JSONDecodeError): + self.data = self._empty_data() + self.data.setdefault("version", 1) + self.data.setdefault("steps", {}) + self.data.setdefault("history", []) + + def save(self) -> None: + if not self.evaluation: + return + path = self.evaluation / STATE_FILENAME + temporary = path.with_suffix(path.suffix + ".tmp") + temporary.write_text( + json.dumps(self.data, indent=2, ensure_ascii=False), encoding="utf-8" + ) + temporary.replace(path) + + def step(self, step_id: str) -> dict[str, Any]: + return self.data["steps"].setdefault(step_id, {}) + + def update_step(self, step_id: str, **changes: Any) -> None: + entry = self.step(step_id) + entry.update(changes) + entry["updated_at"] = datetime.now(timezone.utc).isoformat() + self.save() + + def add_history(self, entry: dict[str, Any]) -> None: + entry = dict(entry) + entry["timestamp"] = datetime.now(timezone.utc).isoformat() + self.data["history"].append(entry) + self.data["history"] = self.data["history"][-200:] + self.save() + + def invalidate_after(self, ordered_ids: list[str], current_id: str) -> None: + try: + index = ordered_ids.index(current_id) + except ValueError: + return + for step_id in ordered_ids[index + 1 :]: + entry = self.step(step_id) + if entry.get("status") in {"success", "detected", "skipped"}: + entry["status"] = "stale" + self.save() diff --git a/copienator_gui/workflow.py b/copienator_gui/workflow.py new file mode 100644 index 0000000..50d7e6e --- /dev/null +++ b/copienator_gui/workflow.py @@ -0,0 +1,485 @@ +from __future__ import annotations + +import os +import shlex +import sys +from collections.abc import Iterable +from dataclasses import dataclass +from pathlib import Path + +EVALUATION = "${evaluation}" + + +@dataclass(frozen=True) +class ArgumentSpec: + name: str + label: str + kind: str = "text" # text, int, bool, choice, path + flag: str | None = None + default: object = "" + choices: tuple[str, ...] = () + help: str = "" + positional: bool = False + variants: tuple[str, ...] = () + + +@dataclass(frozen=True) +class CommandVariant: + id: str + label: str + program: str | None + kind: str = "python" # python, shell, external, manual + fixed_args: tuple[str, ...] = () + dangerous: bool = False + + +@dataclass(frozen=True) +class StepDefinition: + id: str + section: str + title: str + description: str + variants: tuple[CommandVariant, ...] + arguments: tuple[ArgumentSpec, ...] = () + optional: bool = False + personal: bool = False + requires: tuple[str, ...] = () + artifacts: tuple[str, ...] = () + + @property + def is_manual(self) -> bool: + return all(variant.kind == "manual" for variant in self.variants) + + +def arg_target(help_text: str = "Dossier d’évaluation ou fichier à traiter") -> ArgumentSpec: + return ArgumentSpec( + "target", + "Cible", + kind="path", + default=EVALUATION, + positional=True, + help=help_text, + ) + + +def build_workflow(show_personal_steps: bool) -> list[StepDefinition]: + python = lambda ident, label, script, **kwargs: CommandVariant( + ident, label, script, "python", **kwargs + ) + manual = lambda ident, label="Étape manuelle": CommandVariant( + ident, label, None, "manual" + ) + + steps = [ + StepDefinition( + "inputs", + "Préparation", + "Vérifier les fichiers d’entrée", + "Le dossier doit contenir enonce.pdf, enonce.tex et correction.tex. " + "Le fichier names doit être présent dans l’évaluation ou à la racine du projet.", + (manual("check"),), + requires=("enonce.pdf", "enonce.tex", "correction.tex"), + ), + StepDefinition( + "statement", + "Prétraitement de l’énoncé", + "Analyser l’énoncé", + "Détecte les questions, leurs groupes, le corrigé et les indications de barème.", + ( + python("gemini", "Analyse avec Gemini", "gemini_for_enonce.py"), + python("personal", "Alternative enonce_info.py", "enonce_info.py"), + ), + arguments=( + arg_target("Dossier de l’évaluation"), + ArgumentSpec( + "restart", + "Ignorer le cache (--restart)", + kind="bool", + flag="--restart", + variants=("gemini",), + ), + ), + requires=("enonce.pdf", "enonce.tex", "correction.tex"), + artifacts=("labels", "Text", "Sol", "Persp"), + ), + StepDefinition( + "review_persp", + "Prétraitement de l’énoncé", + "Relire les barèmes dans Persp", + "Étape manuelle facultative : vérifier et modifier les instructions de correction.", + (manual("review"),), + optional=True, + requires=("Persp",), + ), + StepDefinition( + "rotate", + "Prétraitement des copies", + "Retourner toutes les copies", + "Rotation facultative de 180° lorsque les scans sont à l’envers.", + (CommandVariant("rotate", "Rotation", "rotate_all.sh", "shell"),), + arguments=(arg_target("Dossier de l’évaluation"),), + optional=True, + ), + StepDefinition( + "rename", + "Prétraitement des copies", + "Renommer les PDF en CopieXX.pdf", + "Renomme les PDF du dossier d’évaluation dans leur ordre courant.", + (CommandVariant("rename", "Renommage", "rename_to_copie.sh", "shell"),), + arguments=(arg_target("Dossier de l’évaluation"),), + ), + StepDefinition( + "page_splitter", + "Prétraitement des copies", + "Séparer et réordonner les pages", + "Ouvre l’outil interactif de découpage A3 vers A4. La cible peut être un dossier ou un PDF.", + (python("default", "Séparation des pages", "page_splitter.py"),), + arguments=(arg_target(),), + artifacts=("Copies", "Copies Originales"), + ), + StepDefinition( + "cutleft", + "Prétraitement des copies", + "Découper la marge des labels", + "Produit les images de la partie gauche des copies. Une copie précise peut être ciblée.", + (python("default", "Découpe", "cutleft.py"),), + arguments=( + arg_target(), + ArgumentSpec("fullpage", "Toujours utiliser la page entière", "bool", "--fullpage"), + ), + requires=("Copies",), + artifacts=("Cutleft",), + ), + StepDefinition( + "labels", + "Labels et regroupement", + "Détecter les labels avec Gemini", + "Identifie les labels dans les images produites par la découpe gauche.", + (python("default", "Détection des labels", "gemini_for_labels.py"),), + arguments=( + arg_target(), + ArgumentSpec("overwrite", "Régénérer les résultats", "bool", "--overwrite"), + ), + requires=("labels", "Copies", "Cutleft"), + artifacts=("Copies/*.json",), + ), + StepDefinition( + "plotting", + "Labels et regroupement", + "Vérifier visuellement les labels", + "Ouvre la fenêtre de vérification. La cible peut être toute l’évaluation ou une copie.", + (python("default", "Vérification", "plotting.py"),), + arguments=(arg_target(),), + requires=("labels", "Cutleft"), + artifacts=("Copies/Copie*.json",), + ), + StepDefinition( + "splitting", + "Labels et regroupement", + "Découper les réponses par question", + "Découpe les copies à partir des coordonnées de labels vérifiées.", + (python("default", "Découpage", "splitting_int.py"),), + arguments=(arg_target(),), + requires=("Copies",), + artifacts=("Copies/Copie*/*",), + ), + StepDefinition( + "grouping", + "Labels et regroupement", + "Regrouper les réponses", + "Regroupe les réponses portant le même label pour préparer les requêtes.", + (python("default", "Regroupement", "grouping.py"),), + arguments=(arg_target("Dossier de l’évaluation"),), + requires=("Copies",), + artifacts=("Par label",), + ), + StepDefinition( + "correction", + "Correction", + "Lancer ou intégrer la correction", + "Choisir une correction immédiate, batch, hybride, une recorrection, ou l’intégration d’un batch.", + ( + python("live", "Correction immédiate", "correction.py"), + python("batch", "Préparer toutes les requêtes batch", "correction.py", fixed_args=("--batch",)), + python("hybrid", "Batch à partir d’un label", "correction.py"), + python("refaire", "Recorrection depuis refaire.json", "correction.py", fixed_args=("--refaire",)), + python( + "integrate", + "Intégrer les résultats batch", + "correction.py", + fixed_args=("--deal-with-batched",), + ), + python("reset", "Réinitialiser les corrections", "correction.py", fixed_args=("--reset",), dangerous=True), + ), + arguments=( + arg_target("Évaluation ou image Group_X.jpg"), + ArgumentSpec("overwrite", "Écraser les corrections existantes", "bool", "--overwrite", variants=("live",)), + ArgumentSpec("limit", "Limite d’appels Pro", "int", "--limit", variants=("live",)), + ArgumentSpec("batch_from", "Premier label envoyé en batch", "text", "--batch-from", variants=("hybrid",)), + ), + requires=("Par label", "Persp", "labels"), + artifacts=("correction.json", "batch_requests_*.jsonl"), + ), + StepDefinition( + "submit_batches", + "Correction", + "Envoyer les batchs", + "Envoie à Gemini les fichiers JSONL produits par le mode batch.", + (python("default", "Envoi", "submit_batches.py"),), + arguments=(arg_target("Dossier de l’évaluation"),), + optional=True, + artifacts=("batch_jobs.json",), + ), + StepDefinition( + "batch_status", + "Correction", + "Consulter l’état des batchs", + "Affiche les jobs Gemini en cours. L’identifiant de téléchargement est facultatif.", + (python("default", "État des batchs", "batch_status.py"),), + arguments=(ArgumentSpec("download", "Télécharger le job", "text", "--download"),), + optional=True, + ), + StepDefinition( + "fetch_batches", + "Correction", + "Récupérer les résultats batch", + "Télécharge et rassemble les réponses des jobs terminés.", + (python("default", "Récupération", "fetch_batched_results.py"),), + arguments=(arg_target("Dossier de l’évaluation"),), + optional=True, + ), + StepDefinition( + "post_correction", + "Correction", + "Nettoyer la correction", + "Corrige certains problèmes d’encodage et prépare le texte pour LaTeX.", + (python("default", "Post-correction", "post-correction.py"),), + arguments=(arg_target("Dossier de l’évaluation"),), + requires=("correction.json",), + ), + StepDefinition( + "manual_resolution", + "Correction", + "Résoudre les conflits manuels", + "Étape conditionnelle, uniquement si manual_resolutions.txt contient des conflits à traiter.", + (python("default", "Résolution", "resolve_manual.py"),), + arguments=(arg_target("Dossier de l’évaluation"),), + optional=True, + requires=("manual_resolutions.txt", "correction.json"), + ), + StepDefinition( + "annotation", + "Génération des annotations", + "Générer les copies annotées", + "Les trois modes sont exclusifs pour un parcours donné.", + ( + python("simple", "Annotations simples (Anot)", "annotating.py"), + python("checks", "Annotations avec cases (Bnot)", "annotating_with_checks.py"), + python("grouped", "Annotations groupées (BGnot)", "annotating_by_label.py"), + ), + arguments=( + arg_target("Dossier de l’évaluation"), + ArgumentSpec("overwrite", "Écraser les sorties", "bool", "--overwrite"), + ArgumentSpec("refaire", "Mode refaire", "bool", "--refaire", variants=("checks",)), + ), + requires=("correction.json",), + artifacts=("Anot", "Bnot", "BGnot"), + ), + StepDefinition( + "export", + "Génération des annotations", + "Exporter vers la tablette", + "Exporte les groupes vers le dossier EXPORT_DIR défini dans config.py.", + (python("default", "Export", "export.py"),), + arguments=( + arg_target("Dossier de l’évaluation"), + ArgumentSpec("refaire", "Mode refaire", "bool", "--refaire"), + ), + optional=True, + ), + StepDefinition( + "tablet", + "Correction manuscrite", + "Annoter les PDF sur la tablette", + "Étape manuelle : enregistrer chaque résultat sous le nom Concat_annotated.pdf.", + (manual("tablet"),), + ), + StepDefinition( + "import", + "Correction manuscrite", + "Importer les annotations manuscrites", + "Copie les PDF présents dans IMPORT_DIR vers l’évaluation.", + (python("default", "Import", "import.py"),), + arguments=( + arg_target("Dossier de l’évaluation"), + ArgumentSpec("refaire", "Mode refaire", "bool", "--refaire"), + ), + ), + StepDefinition( + "read_annotations", + "Finalisation", + "Lire les annotations manuscrites", + "Le mode doit correspondre au mode choisi lors de la génération des annotations.", + ( + python("standard", "Lecture Bnot", "reading_annotations.py"), + python("grouped", "Lecture BGnot", "reading_grouped_annotations.py"), + ), + arguments=( + arg_target("Dossier de l’évaluation"), + ArgumentSpec("update_score", "Réappliquer les score.json", "bool", "--update-score"), + ArgumentSpec("refaire", "Mode refaire", "bool", "--refaire", variants=("grouped",)), + ), + ), + StepDefinition( + "giving_names", + "Finalisation", + "Attribuer les noms et préparer A Rendre", + "Crée le dossier A Rendre à partir du dossier d’annotations choisi.", + (python("default", "Attribution des noms", "giving_names.py"),), + arguments=( + arg_target("Dossier de l’évaluation"), + ArgumentSpec( + "annotation_dir", + "Dossier d’annotations", + "choice", + default="BGnot", + choices=("BGnot", "Bnot", "Anot"), + positional=True, + ), + ), + artifacts=("A Rendre",), + ), + StepDefinition( + "personal_create", + "Étapes personnelles", + "Créer l’interro dans gestion_classe", + "Exécute gestion_classe ne.", + (CommandVariant("default", "gestion_classe ne", "gestion_classe", "external", ("ne",)),), + personal=True, + ), + StepDefinition( + "personal_scale", + "Étapes personnelles", + "Renseigner le barème", + "Exécute gestion_classe we.", + (CommandVariant("default", "gestion_classe we", "gestion_classe", "external", ("we",)),), + personal=True, + ), + StepDefinition( + "update_ods", + "Étapes personnelles", + "Mettre à jour le fichier ODS", + "Transfère les scores, avec la possibilité de n’écrire que leur somme.", + (python("default", "Mise à jour ODS", "update_ods.py"),), + arguments=( + arg_target("Dossier de l’évaluation"), + ArgumentSpec("sum", "Écrire seulement la somme", "bool", "--sum"), + ), + personal=True, + ), + StepDefinition( + "personal_read", + "Étapes personnelles", + "Relire avec gestion_classe", + "Exécute gestion_classe re.", + (CommandVariant("default", "gestion_classe re", "gestion_classe", "external", ("re",)),), + personal=True, + ), + StepDefinition( + "personal_sent", + "Étapes personnelles", + "Marquer comme envoyé", + "Exécute gestion_classe wsent.", + (CommandVariant("default", "gestion_classe wsent", "gestion_classe", "external", ("wsent",)),), + personal=True, + ), + StepDefinition( + "final_score", + "Étapes personnelles", + "Ajouter le score final", + "Génère les fichiers de diffusion avec le score final.", + (python("default", "Score final", "add_final_score.py"),), + arguments=(arg_target("Dossier de l’évaluation"),), + personal=True, + ), + StepDefinition( + "personal_deploy", + "Étapes personnelles", + "Déployer et publier les copies", + "Étape manuelle : déployer miqmacs-copies-assets puis mettre à jour les copies depuis l’administration.", + (manual("deploy"),), + personal=True, + ), + StepDefinition( + "personal_print", + "Étapes personnelles", + "Imprimer une copie", + "Étape manuelle via le lecteur PDF.", + (manual("print"),), + personal=True, + optional=True, + ), + ] + return [step for step in steps if show_personal_steps or not step.personal] + + +def value_for_default(value: object, evaluation_arg: str) -> object: + return evaluation_arg if value == EVALUATION else value + + +def build_command( + repository: Path, + step: StepDefinition, + variant: CommandVariant, + values: dict[str, object], + evaluation_arg: str, + extra_arguments: str = "", +) -> list[str]: + if variant.kind == "manual" or not variant.program: + return [] + + program_path = repository / variant.program + if variant.kind == "python": + command = [sys.executable, "-u", str(program_path)] + elif variant.kind == "shell": + command = [str(program_path)] + else: + command = [variant.program] + + positionals: list[str] = [] + options: list[str] = [] + for spec in step.arguments: + if spec.variants and variant.id not in spec.variants: + continue + value = values.get(spec.name, value_for_default(spec.default, evaluation_arg)) + if spec.kind == "bool": + if bool(value) and spec.flag: + options.append(spec.flag) + continue + if value is None or str(value).strip() == "": + continue + rendered = str(value) + if spec.positional: + positionals.append(rendered) + elif spec.flag: + options.extend((spec.flag, rendered)) + + command.extend(positionals) + command.extend(variant.fixed_args) + command.extend(options) + if extra_arguments.strip(): + command.extend(shlex.split(extra_arguments, posix=os.name != "nt")) + return command + + +def command_display(command: Iterable[str]) -> str: + if os.name == "nt": + return " ".join(f'"{part}"' if " " in part else part for part in command) + return shlex.join(command) + + +def evaluation_argument(repository: Path, evaluation: Path) -> str: + try: + return str(evaluation.resolve().relative_to(repository.resolve())) + except ValueError: + return str(evaluation.resolve()) diff --git a/gui.py b/gui.py new file mode 100644 index 0000000..17cde64 --- /dev/null +++ b/gui.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import argparse +from pathlib import Path + +from copienator_gui.app import CopienatorApp + + +def personal_steps_enabled() -> bool: + try: + from config import SHOW_PERSONAL_STEPS + except (ImportError, AttributeError): + try: + from default_config import SHOW_PERSONAL_STEPS + except (ImportError, AttributeError): + return False + return bool(SHOW_PERSONAL_STEPS) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Interface graphique du workflow Copienator") + parser.add_argument("evaluation", nargs="?", type=Path, help="Dossier d’évaluation à ouvrir") + args = parser.parse_args() + + repository = Path(__file__).resolve().parent + evaluation = args.evaluation.resolve() if args.evaluation else None + app = CopienatorApp(repository, personal_steps_enabled(), evaluation) + app.mainloop() + + +if __name__ == "__main__": + main()