PNG  IHDRX cHRMz&u0`:pQ<bKGD pHYsodtIME MeqIDATxw]Wug^Qd˶ 6`!N:!@xI~)%7%@Bh&`lnjVF29gΨ4E$|>cɚ{gk= %,a KX%,a KX%,a KX%,a KX%,a KX%,a KX%, b` ǟzeאfp]<!SJmɤY޲ڿ,%c ~ع9VH.!Ͳz&QynֺTkRR.BLHi٪:l;@(!MԴ=žI,:o&N'Kù\vRmJ雵֫AWic H@" !: Cé||]k-Ha oݜ:y F())u]aG7*JV@J415p=sZH!=!DRʯvɱh~V\}v/GKY$n]"X"}t@ xS76^[bw4dsce)2dU0 CkMa-U5tvLƀ~mlMwfGE/-]7XAƟ`׮g ewxwC4\[~7@O-Q( a*XGƒ{ ՟}$_y3tĐƤatgvێi|K=uVyrŲlLӪuܿzwk$m87k( `múcE)"@rK( z4$D; 2kW=Xb$V[Ru819קR~qloѱDyįݎ*mxw]y5e4K@ЃI0A D@"BDk_)N\8͜9dz"fK0zɿvM /.:2O{ Nb=M=7>??Zuo32 DLD@D| &+֎C #B8ַ`bOb $D#ͮҪtx]%`ES`Ru[=¾!@Od37LJ0!OIR4m]GZRJu$‡c=%~s@6SKy?CeIh:[vR@Lh | (BhAMy=݃  G"'wzn޺~8ԽSh ~T*A:xR[ܹ?X[uKL_=fDȊ؂p0}7=D$Ekq!/t.*2ʼnDbŞ}DijYaȲ(""6HA;:LzxQ‘(SQQ}*PL*fc\s `/d'QXW, e`#kPGZuŞuO{{wm[&NBTiiI0bukcA9<4@SӊH*؎4U/'2U5.(9JuDfrޱtycU%j(:RUbArLֺN)udA':uGQN"-"Is.*+k@ `Ojs@yU/ H:l;@yyTn}_yw!VkRJ4P)~y#)r,D =ě"Q]ci'%HI4ZL0"MJy 8A{ aN<8D"1#IJi >XjX֔#@>-{vN!8tRݻ^)N_╗FJEk]CT՟ YP:_|H1@ CBk]yKYp|og?*dGvzنzӴzjֺNkC~AbZƷ`.H)=!QͷVTT(| u78y֮}|[8-Vjp%2JPk[}ԉaH8Wpqhwr:vWª<}l77_~{s۴V+RCģ%WRZ\AqHifɤL36: #F:p]Bq/z{0CU6ݳEv_^k7'>sq*+kH%a`0ԣisqにtү04gVgW΂iJiS'3w.w}l6MC2uԯ|>JF5`fV5m`Y**Db1FKNttu]4ccsQNnex/87+}xaUW9y>ͯ骵G{䩓Գ3+vU}~jJ.NFRD7<aJDB1#ҳgSb,+CS?/ VG J?|?,2#M9}B)MiE+G`-wo߫V`fio(}S^4e~V4bHOYb"b#E)dda:'?}׮4繏`{7Z"uny-?ǹ;0MKx{:_pÚmFמ:F " .LFQLG)Q8qN q¯¯3wOvxDb\. BKD9_NN &L:4D{mm o^tֽ:q!ƥ}K+<"m78N< ywsard5+вz~mnG)=}lYݧNj'QJS{S :UYS-952?&O-:W}(!6Mk4+>A>j+i|<<|;ر^߉=HE|V#F)Emm#}/"y GII웻Jі94+v뾧xu~5C95~ūH>c@덉pʃ1/4-A2G%7>m;–Y,cyyaln" ?ƻ!ʪ<{~h~i y.zZB̃/,雋SiC/JFMmBH&&FAbϓO^tubbb_hZ{_QZ-sύodFgO(6]TJA˯#`۶ɟ( %$&+V'~hiYy>922 Wp74Zkq+Ovn錄c>8~GqܲcWꂎz@"1A.}T)uiW4="jJ2W7mU/N0gcqܗOO}?9/wìXžΏ0 >֩(V^Rh32!Hj5`;O28؇2#ݕf3 ?sJd8NJ@7O0 b־?lldщ̡&|9C.8RTWwxWy46ah嘦mh٤&l zCy!PY?: CJyв]dm4ǜҐR޻RլhX{FƯanшQI@x' ao(kUUuxW_Ñ줮[w8 FRJ(8˼)_mQ _!RJhm=!cVmm ?sFOnll6Qk}alY}; "baӌ~M0w,Ggw2W:G/k2%R,_=u`WU R.9T"v,<\Ik޽/2110Ӿxc0gyC&Ny޽JҢrV6N ``یeA16"J³+Rj*;BϜkZPJaÍ<Jyw:NP8/D$ 011z֊Ⱳ3ι֘k1V_"h!JPIΣ'ɜ* aEAd:ݺ>y<}Lp&PlRfTb1]o .2EW\ͮ]38؋rTJsǏP@芎sF\> P^+dYJLbJ C-xϐn> ι$nj,;Ǖa FU *择|h ~izť3ᤓ`K'-f tL7JK+vf2)V'-sFuB4i+m+@My=O҈0"|Yxoj,3]:cо3 $#uŘ%Y"y죯LebqtҢVzq¼X)~>4L׶m~[1_k?kxֺQ`\ |ٛY4Ѯr!)N9{56(iNq}O()Em]=F&u?$HypWUeB\k]JɩSع9 Zqg4ZĊo oMcjZBU]B\TUd34ݝ~:7ڶSUsB0Z3srx 7`:5xcx !qZA!;%͚7&P H<WL!džOb5kF)xor^aujƍ7 Ǡ8/p^(L>ὴ-B,{ۇWzֺ^k]3\EE@7>lYBȝR.oHnXO/}sB|.i@ɥDB4tcm,@ӣgdtJ!lH$_vN166L__'Z)y&kH;:,Y7=J 9cG) V\hjiE;gya~%ks_nC~Er er)muuMg2;֫R)Md) ,¶ 2-wr#F7<-BBn~_(o=KO㭇[Xv eN_SMgSҐ BS헃D%g_N:/pe -wkG*9yYSZS.9cREL !k}<4_Xs#FmҶ:7R$i,fi!~' # !6/S6y@kZkZcX)%5V4P]VGYq%H1!;e1MV<!ϐHO021Dp= HMs~~a)ަu7G^];git!Frl]H/L$=AeUvZE4P\.,xi {-~p?2b#amXAHq)MWǾI_r`S Hz&|{ +ʖ_= (YS(_g0a03M`I&'9vl?MM+m~}*xT۲(fY*V4x@29s{DaY"toGNTO+xCAO~4Ϳ;p`Ѫ:>Ҵ7K 3}+0 387x\)a"/E>qpWB=1 ¨"MP(\xp߫́A3+J] n[ʼnӼaTbZUWb={~2ooKױӰp(CS\S筐R*JغV&&"FA}J>G֐p1ٸbk7 ŘH$JoN <8s^yk_[;gy-;߉DV{c B yce% aJhDȶ 2IdйIB/^n0tNtџdcKj4϶v~- CBcgqx9= PJ) dMsjpYB] GD4RDWX +h{y`,3ꊕ$`zj*N^TP4L:Iz9~6s) Ga:?y*J~?OrMwP\](21sZUD ?ܟQ5Q%ggW6QdO+\@ ̪X'GxN @'4=ˋ+*VwN ne_|(/BDfj5(Dq<*tNt1х!MV.C0 32b#?n0pzj#!38}޴o1KovCJ`8ŗ_"]] rDUy޲@ Ȗ-;xџ'^Y`zEd?0„ DAL18IS]VGq\4o !swV7ˣι%4FѮ~}6)OgS[~Q vcYbL!wG3 7띸*E Pql8=jT\꘿I(z<[6OrR8ºC~ډ]=rNl[g|v TMTղb-o}OrP^Q]<98S¤!k)G(Vkwyqyr޽Nv`N/e p/~NAOk \I:G6]4+K;j$R:Mi #*[AȚT,ʰ,;N{HZTGMoּy) ]%dHء9Պ䠬|<45,\=[bƟ8QXeB3- &dҩ^{>/86bXmZ]]yޚN[(WAHL$YAgDKp=5GHjU&99v簪C0vygln*P)9^͞}lMuiH!̍#DoRBn9l@ xA/_v=ȺT{7Yt2N"4!YN`ae >Q<XMydEB`VU}u]嫇.%e^ánE87Mu\t`cP=AD/G)sI"@MP;)]%fH9'FNsj1pVhY&9=0pfuJ&gޤx+k:!r˭wkl03׼Ku C &ѓYt{.O.zҏ z}/tf_wEp2gvX)GN#I ݭ߽v/ .& и(ZF{e"=V!{zW`, ]+LGz"(UJp|j( #V4, 8B 0 9OkRrlɱl94)'VH9=9W|>PS['G(*I1==C<5"Pg+x'K5EMd؞Af8lG ?D FtoB[je?{k3zQ vZ;%Ɠ,]E>KZ+T/ EJxOZ1i #T<@ I}q9/t'zi(EMqw`mYkU6;[t4DPeckeM;H}_g pMww}k6#H㶏+b8雡Sxp)&C $@'b,fPߑt$RbJ'vznuS ~8='72_`{q纶|Q)Xk}cPz9p7O:'|G~8wx(a 0QCko|0ASD>Ip=4Q, d|F8RcU"/KM opKle M3#i0c%<7׿p&pZq[TR"BpqauIp$ 8~Ĩ!8Սx\ւdT>>Z40ks7 z2IQ}ItԀ<-%S⍤};zIb$I 5K}Q͙D8UguWE$Jh )cu4N tZl+[]M4k8֦Zeq֮M7uIqG 1==tLtR,ƜSrHYt&QP윯Lg' I,3@P'}'R˪e/%-Auv·ñ\> vDJzlӾNv5:|K/Jb6KI9)Zh*ZAi`?S {aiVDԲuy5W7pWeQJk֤#5&V<̺@/GH?^τZL|IJNvI:'P=Ϛt"¨=cud S Q.Ki0 !cJy;LJR;G{BJy޺[^8fK6)=yʊ+(k|&xQ2`L?Ȓ2@Mf 0C`6-%pKpm')c$׻K5[J*U[/#hH!6acB JA _|uMvDyk y)6OPYjœ50VT K}cǻP[ $:]4MEA.y)|B)cf-A?(e|lɉ#P9V)[9t.EiQPDѠ3ϴ;E:+Օ t ȥ~|_N2,ZJLt4! %ա]u {+=p.GhNcŞQI?Nd'yeh n7zi1DB)1S | S#ًZs2|Ɛy$F SxeX{7Vl.Src3E℃Q>b6G ўYCmtկ~=K0f(=LrAS GN'ɹ9<\!a`)֕y[uՍ[09` 9 +57ts6}b4{oqd+J5fa/,97J#6yν99mRWxJyѡyu_TJc`~W>l^q#Ts#2"nD1%fS)FU w{ܯ R{ ˎ󅃏џDsZSQS;LV;7 Od1&1n$ N /.q3~eNɪ]E#oM~}v֯FڦwyZ=<<>Xo稯lfMFV6p02|*=tV!c~]fa5Y^Q_WN|Vs 0ҘދU97OI'N2'8N֭fgg-}V%y]U4 峧p*91#9U kCac_AFңĪy뚇Y_AiuYyTTYЗ-(!JFLt›17uTozc. S;7A&&<ԋ5y;Ro+:' *eYJkWR[@F %SHWP 72k4 qLd'J "zB6{AC0ƁA6U.'F3:Ȅ(9ΜL;D]m8ڥ9}dU "v!;*13Rg^fJyShyy5auA?ɩGHRjo^]׽S)Fm\toy 4WQS@mE#%5ʈfFYDX ~D5Ϡ9tE9So_aU4?Ѽm%&c{n>.KW1Tlb}:j uGi(JgcYj0qn+>) %\!4{LaJso d||u//P_y7iRJ߬nHOy) l+@$($VFIQ9%EeKʈU. ia&FY̒mZ=)+qqoQn >L!qCiDB;Y<%} OgBxB!ØuG)WG9y(Ą{_yesuZmZZey'Wg#C~1Cev@0D $a@˲(.._GimA:uyw֬%;@!JkQVM_Ow:P.s\)ot- ˹"`B,e CRtaEUP<0'}r3[>?G8xU~Nqu;Wm8\RIkբ^5@k+5(By'L&'gBJ3ݶ!/㮻w҅ yqPWUg<e"Qy*167΃sJ\oz]T*UQ<\FԎ`HaNmڜ6DysCask8wP8y9``GJ9lF\G g's Nn͵MLN֪u$| /|7=]O)6s !ĴAKh]q_ap $HH'\1jB^s\|- W1:=6lJBqjY^LsPk""`]w)󭃈,(HC ?䔨Y$Sʣ{4Z+0NvQkhol6C.婧/u]FwiVjZka&%6\F*Ny#8O,22+|Db~d ~Çwc N:FuuCe&oZ(l;@ee-+Wn`44AMK➝2BRՈt7g*1gph9N) *"TF*R(#'88pm=}X]u[i7bEc|\~EMn}P瘊J)K.0i1M6=7'_\kaZ(Th{K*GJyytw"IO-PWJk)..axӝ47"89Cc7ĐBiZx 7m!fy|ϿF9CbȩV 9V-՛^pV̌ɄS#Bv4-@]Vxt-Z, &ֺ*diؠ2^VXbs֔Ìl.jQ]Y[47gj=幽ex)A0ip׳ W2[ᎇhuE^~q흙L} #-b۸oFJ_QP3r6jr+"nfzRJTUqoaۍ /$d8Mx'ݓ= OՃ| )$2mcM*cЙj}f };n YG w0Ia!1Q.oYfr]DyISaP}"dIӗթO67jqR ҊƐƈaɤGG|h;t]䗖oSv|iZqX)oalv;۩meEJ\!8=$4QU4Xo&VEĊ YS^E#d,yX_> ۘ-e\ "Wa6uLĜZi`aD9.% w~mB(02G[6y.773a7 /=o7D)$Z 66 $bY^\CuP. (x'"J60׿Y:Oi;F{w佩b+\Yi`TDWa~|VH)8q/=9!g߆2Y)?ND)%?Ǐ`k/sn:;O299yB=a[Ng 3˲N}vLNy;*?x?~L&=xyӴ~}q{qE*IQ^^ͧvü{Huu=R|>JyUlZV, B~/YF!Y\u_ݼF{_C)LD]m {H 0ihhadd nUkf3oٺCvE\)QJi+֥@tDJkB$1!Đr0XQ|q?d2) Ӣ_}qv-< FŊ߫%roppVBwü~JidY4:}L6M7f٬F "?71<2#?Jyy4뷢<_a7_=Q E=S1И/9{+93֮E{ǂw{))?maÆm(uLE#lïZ  ~d];+]h j?!|$F}*"4(v'8s<ŏUkm7^7no1w2ؗ}TrͿEk>p'8OB7d7R(A 9.*Mi^ͳ; eeUwS+C)uO@ =Sy]` }l8^ZzRXj[^iUɺ$tj))<sbDJfg=Pk_{xaKo1:-uyG0M ԃ\0Lvuy'ȱc2Ji AdyVgVh!{]/&}}ċJ#%d !+87<;qN޼Nفl|1N:8ya  8}k¾+-$4FiZYÔXk*I&'@iI99)HSh4+2G:tGhS^繿 Kتm0 вDk}֚+QT4;sC}rՅE,8CX-e~>G&'9xpW,%Fh,Ry56Y–hW-(v_,? ; qrBk4-V7HQ;ˇ^Gv1JVV%,ik;D_W!))+BoS4QsTM;gt+ndS-~:11Sgv!0qRVh!"Ȋ(̦Yl.]PQWgٳE'`%W1{ndΗBk|Ž7ʒR~,lnoa&:ü$ 3<a[CBݮwt"o\ePJ=Hz"_c^Z.#ˆ*x z̝grY]tdkP*:97YľXyBkD4N.C_[;F9`8& !AMO c `@BA& Ost\-\NX+Xp < !bj3C&QL+*&kAQ=04}cC!9~820G'PC9xa!w&bo_1 Sw"ܱ V )Yl3+ס2KoXOx]"`^WOy :3GO0g;%Yv㐫(R/r (s } u B &FeYZh0y> =2<Ϟc/ -u= c&׭,.0"g"7 6T!vl#sc>{u/Oh Bᾈ)۴74]x7 gMӒ"d]U)}" v4co[ ɡs 5Gg=XR14?5A}D "b{0$L .\4y{_fe:kVS\\O]c^W52LSBDM! C3Dhr̦RtArx4&agaN3Cf<Ԉp4~ B'"1@.b_/xQ} _߃҉/gٓ2Qkqp0շpZ2fԫYz< 4L.Cyυι1t@鎫Fe sYfsF}^ V}N<_`p)alٶ "(XEAVZ<)2},:Ir*#m_YӼ R%a||EƼIJ,,+f"96r/}0jE/)s)cjW#w'Sʯ5<66lj$a~3Kʛy 2:cZ:Yh))+a߭K::N,Q F'qB]={.]h85C9cr=}*rk?vwV렵ٸW Rs%}rNAkDv|uFLBkWY YkX מ|)1!$#3%y?pF<@<Rr0}: }\J [5FRxY<9"SQdE(Q*Qʻ)q1E0B_O24[U'],lOb ]~WjHޏTQ5Syu wq)xnw8~)c 쫬gٲߠ H% k5dƝk> kEj,0% b"vi2Wس_CuK)K{n|>t{P1򨾜j>'kEkƗBg*H%'_aY6Bn!TL&ɌOb{c`'d^{t\i^[uɐ[}q0lM˕G:‚4kb祔c^:?bpg… +37stH:0}en6x˟%/<]BL&* 5&fK9Mq)/iyqtA%kUe[ڛKN]Ě^,"`/ s[EQQm?|XJ߅92m]G.E΃ח U*Cn.j_)Tѧj̿30ڇ!A0=͜ar I3$C^-9#|pk!)?7.x9 @OO;WƝZBFU keZ75F6Tc6"ZȚs2y/1 ʵ:u4xa`C>6Rb/Yм)^=+~uRd`/|_8xbB0?Ft||Z\##|K 0>>zxv8۴吅q 8ĥ)"6>~\8:qM}#͚'ĉ#p\׶ l#bA?)|g g9|8jP(cr,BwV (WliVxxᡁ@0Okn;ɥh$_ckCgriv}>=wGzβ KkBɛ[˪ !J)h&k2%07δt}!d<9;I&0wV/ v 0<H}L&8ob%Hi|޶o&h1L|u֦y~󛱢8fٲUsւ)0oiFx2}X[zVYr_;N(w]_4B@OanC?gĦx>мgx>ΛToZoOMp>40>V Oy V9iq!4 LN,ˢu{jsz]|"R޻&'ƚ{53ўFu(<٪9:΋]B;)B>1::8;~)Yt|0(pw2N%&X,URBK)3\zz&}ax4;ǟ(tLNg{N|Ǽ\G#C9g$^\}p?556]/RP.90 k,U8/u776s ʪ_01چ|\N 0VV*3H鴃J7iI!wG_^ypl}r*jɤSR 5QN@ iZ#1ٰy;_\3\BQQ x:WJv츟ٯ$"@6 S#qe딇(/P( Dy~TOϻ<4:-+F`0||;Xl-"uw$Цi󼕝mKʩorz"mϺ$F:~E'ҐvD\y?Rr8_He@ e~O,T.(ފR*cY^m|cVR[8 JҡSm!ΆԨb)RHG{?MpqrmN>߶Y)\p,d#xۆWY*,l6]v0h15M˙MS8+EdI='LBJIH7_9{Caз*Lq,dt >+~ّeʏ?xԕ4bBAŚjﵫ!'\Ը$WNvKO}ӽmSşذqsOy?\[,d@'73'j%kOe`1.g2"e =YIzS2|zŐƄa\U,dP;jhhhaxǶ?КZ՚.q SE+XrbOu%\GتX(H,N^~]JyEZQKceTQ]VGYqnah;y$cQahT&QPZ*iZ8UQQM.qo/T\7X"u?Mttl2Xq(IoW{R^ ux*SYJ! 4S.Jy~ BROS[V|žKNɛP(L6V^|cR7i7nZW1Fd@ Ara{詑|(T*dN]Ko?s=@ |_EvF]׍kR)eBJc" MUUbY6`~V޴dJKß&~'d3i WWWWWW
Current Directory: /usr/share/doc/subversion-1.7.14/hook-scripts/mailer
Viewing File: /usr/share/doc/subversion-1.7.14/hook-scripts/mailer/mailer.py
#!/usr/bin/env python # -*- coding: utf-8 -*- # # # Licensed to the Apache Software Foundation (ASF) under one # or more contributor license agreements. See the NOTICE file # distributed with this work for additional information # regarding copyright ownership. The ASF licenses this file # to you under the Apache License, Version 2.0 (the # "License"); you may not use this file except in compliance # with the License. You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, # software distributed under the License is distributed on an # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. # # # mailer.py: send email describing a commit # # $HeadURL: http://svn.apache.org/repos/asf/subversion/branches/1.7.x/tools/hook-scripts/mailer/mailer.py $ # $LastChangedDate: 2010-12-30 20:46:50 +0000 (Thu, 30 Dec 2010) $ # $LastChangedBy: hwright $ # $LastChangedRevision: 1053998 $ # # USAGE: mailer.py commit REPOS REVISION [CONFIG-FILE] # mailer.py propchange REPOS REVISION AUTHOR REVPROPNAME [CONFIG-FILE] # mailer.py propchange2 REPOS REVISION AUTHOR REVPROPNAME ACTION \ # [CONFIG-FILE] # mailer.py lock REPOS AUTHOR [CONFIG-FILE] # mailer.py unlock REPOS AUTHOR [CONFIG-FILE] # # Using CONFIG-FILE, deliver an email describing the changes between # REV and REV-1 for the repository REPOS. # # ACTION was added as a fifth argument to the post-revprop-change hook # in Subversion 1.2.0. Its value is one of 'A', 'M' or 'D' to indicate # if the property was added, modified or deleted, respectively. # # See _MIN_SVN_VERSION below for which version of Subversion's Python # bindings are required by this version of mailer.py. import os import sys try: # Python >=3.0 import configparser from urllib.parse import quote as urllib_parse_quote except ImportError: # Python <3.0 import ConfigParser as configparser from urllib import quote as urllib_parse_quote import time import subprocess if sys.version_info[0] >= 3: # Python >=3.0 from io import StringIO else: # Python <3.0 from cStringIO import StringIO import smtplib import re import tempfile # Minimal version of Subversion's bindings required _MIN_SVN_VERSION = [1, 5, 0] # Import the Subversion Python bindings, making sure they meet our # minimum version requirements. try: import svn.fs import svn.delta import svn.repos import svn.core except ImportError: sys.stderr.write( "You need version %s or better of the Subversion Python bindings.\n" \ % ".".join([str(x) for x in _MIN_SVN_VERSION])) sys.exit(1) if _MIN_SVN_VERSION > [svn.core.SVN_VER_MAJOR, svn.core.SVN_VER_MINOR, svn.core.SVN_VER_PATCH]: sys.stderr.write( "You need version %s or better of the Subversion Python bindings.\n" \ % ".".join([str(x) for x in _MIN_SVN_VERSION])) sys.exit(1) SEPARATOR = '=' * 78 def main(pool, cmd, config_fname, repos_dir, cmd_args): ### TODO: Sanity check the incoming args if cmd == 'commit': revision = int(cmd_args[0]) repos = Repository(repos_dir, revision, pool) cfg = Config(config_fname, repos, {'author': repos.author, 'repos_basename': os.path.basename(repos.repos_dir) }) messenger = Commit(pool, cfg, repos) elif cmd == 'propchange' or cmd == 'propchange2': revision = int(cmd_args[0]) author = cmd_args[1] propname = cmd_args[2] action = (cmd == 'propchange2' and cmd_args[3] or 'A') repos = Repository(repos_dir, revision, pool) # Override the repos revision author with the author of the propchange repos.author = author cfg = Config(config_fname, repos, {'author': author, 'repos_basename': os.path.basename(repos.repos_dir) }) messenger = PropChange(pool, cfg, repos, author, propname, action) elif cmd == 'lock' or cmd == 'unlock': author = cmd_args[0] repos = Repository(repos_dir, 0, pool) ### any old revision will do # Override the repos revision author with the author of the lock/unlock repos.author = author cfg = Config(config_fname, repos, {'author': author, 'repos_basename': os.path.basename(repos.repos_dir) }) messenger = Lock(pool, cfg, repos, author, cmd == 'lock') else: raise UnknownSubcommand(cmd) messenger.generate() def remove_leading_slashes(path): while path and path[0] == '/': path = path[1:] return path class OutputBase: "Abstract base class to formalize the interface of output methods" def __init__(self, cfg, repos, prefix_param): self.cfg = cfg self.repos = repos self.prefix_param = prefix_param self._CHUNKSIZE = 128 * 1024 # This is a public member variable. This must be assigned a suitable # piece of descriptive text before make_subject() is called. self.subject = "" def make_subject(self, group, params): prefix = self.cfg.get(self.prefix_param, group, params) if prefix: subject = prefix + ' ' + self.subject else: subject = self.subject try: truncate_subject = int( self.cfg.get('truncate_subject', group, params)) except ValueError: truncate_subject = 0 if truncate_subject and len(subject) > truncate_subject: subject = subject[:(truncate_subject - 3)] + "..." return subject def start(self, group, params): """Override this method. Begin writing an output representation. GROUP is the name of the configuration file group which is causing this output to be produced. PARAMS is a dictionary of any named subexpressions of regular expressions defined in the configuration file, plus the key 'author' contains the author of the action being reported.""" raise NotImplementedError def finish(self): """Override this method. Flush any cached information and finish writing the output representation.""" raise NotImplementedError def write(self, output): """Override this method. Append the literal text string OUTPUT to the output representation.""" raise NotImplementedError def run(self, cmd): """Override this method, if the default implementation is not sufficient. Execute CMD, writing the stdout produced to the output representation.""" # By default we choose to incorporate child stderr into the output pipe_ob = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, close_fds=sys.platform != "win32") buf = pipe_ob.stdout.read(self._CHUNKSIZE) while buf: self.write(buf) buf = pipe_ob.stdout.read(self._CHUNKSIZE) # wait on the child so we don't end up with a billion zombies pipe_ob.wait() class MailedOutput(OutputBase): def __init__(self, cfg, repos, prefix_param): OutputBase.__init__(self, cfg, repos, prefix_param) def start(self, group, params): # whitespace (or another character) separated list of addresses # which must be split into a clean list to_addr_in = self.cfg.get('to_addr', group, params) # if list of addresses starts with '[.]' # use the character between the square brackets as split char # else use whitespaces if len(to_addr_in) >= 3 and to_addr_in[0] == '[' \ and to_addr_in[2] == ']': self.to_addrs = \ [_f for _f in to_addr_in[3:].split(to_addr_in[1]) if _f] else: self.to_addrs = [_f for _f in to_addr_in.split() if _f] self.from_addr = self.cfg.get('from_addr', group, params) \ or self.repos.author or 'no_author' # if the from_addr (also) starts with '[.]' (may happen if one # map is used for both to_addr and from_addr) remove '[.]' if len(self.from_addr) >= 3 and self.from_addr[0] == '[' \ and self.from_addr[2] == ']': self.from_addr = self.from_addr[3:] self.reply_to = self.cfg.get('reply_to', group, params) # if the reply_to (also) starts with '[.]' (may happen if one # map is used for both to_addr and reply_to) remove '[.]' if len(self.reply_to) >= 3 and self.reply_to[0] == '[' \ and self.reply_to[2] == ']': self.reply_to = self.reply_to[3:] def mail_headers(self, group, params): subject = self.make_subject(group, params) try: subject.encode('ascii') except UnicodeError: from email.Header import Header subject = Header(subject, 'utf-8').encode() hdrs = 'From: %s\n' \ 'To: %s\n' \ 'Subject: %s\n' \ 'MIME-Version: 1.0\n' \ 'Content-Type: text/plain; charset=UTF-8\n' \ 'Content-Transfer-Encoding: 8bit\n' \ 'X-Svn-Commit-Project: %s\n' \ 'X-Svn-Commit-Author: %s\n' \ 'X-Svn-Commit-Revision: %d\n' \ 'X-Svn-Commit-Repository: %s\n' \ % (self.from_addr, ', '.join(self.to_addrs), subject, group, self.repos.author or 'no_author', self.repos.rev, os.path.basename(self.repos.repos_dir)) if self.reply_to: hdrs = '%sReply-To: %s\n' % (hdrs, self.reply_to) return hdrs + '\n' class SMTPOutput(MailedOutput): "Deliver a mail message to an MTA using SMTP." def start(self, group, params): MailedOutput.start(self, group, params) self.buffer = StringIO() self.write = self.buffer.write self.write(self.mail_headers(group, params)) def finish(self): server = smtplib.SMTP(self.cfg.general.smtp_hostname) if self.cfg.is_set('general.smtp_username'): server.login(self.cfg.general.smtp_username, self.cfg.general.smtp_password) server.sendmail(self.from_addr, self.to_addrs, self.buffer.getvalue()) server.quit() class StandardOutput(OutputBase): "Print the commit message to stdout." def __init__(self, cfg, repos, prefix_param): OutputBase.__init__(self, cfg, repos, prefix_param) self.write = sys.stdout.write def start(self, group, params): self.write("Group: " + (group or "defaults") + "\n") self.write("Subject: " + self.make_subject(group, params) + "\n\n") def finish(self): pass class PipeOutput(MailedOutput): "Deliver a mail message to an MTA via a pipe." def __init__(self, cfg, repos, prefix_param): MailedOutput.__init__(self, cfg, repos, prefix_param) # figure out the command for delivery self.cmd = cfg.general.mail_command.split() def start(self, group, params): MailedOutput.start(self, group, params) ### gotta fix this. this is pretty specific to sendmail and qmail's ### mailwrapper program. should be able to use option param substitution cmd = self.cmd + [ '-f', self.from_addr ] + self.to_addrs # construct the pipe for talking to the mailer self.pipe = subprocess.Popen(cmd, stdin=subprocess.PIPE, close_fds=sys.platform != "win32") self.write = self.pipe.stdin.write # start writing out the mail message self.write(self.mail_headers(group, params)) def finish(self): # signal that we're done sending content self.pipe.stdin.close() # wait to avoid zombies self.pipe.wait() class Messenger: def __init__(self, pool, cfg, repos, prefix_param): self.pool = pool self.cfg = cfg self.repos = repos if cfg.is_set('general.mail_command'): cls = PipeOutput elif cfg.is_set('general.smtp_hostname'): cls = SMTPOutput else: cls = StandardOutput self.output = cls(cfg, repos, prefix_param) class Commit(Messenger): def __init__(self, pool, cfg, repos): Messenger.__init__(self, pool, cfg, repos, 'commit_subject_prefix') # get all the changes and sort by path editor = svn.repos.ChangeCollector(repos.fs_ptr, repos.root_this, \ self.pool) e_ptr, e_baton = svn.delta.make_editor(editor, self.pool) svn.repos.replay(repos.root_this, e_ptr, e_baton, self.pool) self.changelist = sorted(editor.get_changes().items()) log = repos.get_rev_prop(svn.core.SVN_PROP_REVISION_LOG) or '' # collect the set of groups and the unique sets of params for the options self.groups = { } for path, change in self.changelist: for (group, params) in self.cfg.which_groups(path, log): # turn the params into a hashable object and stash it away param_list = sorted(params.items()) # collect the set of paths belonging to this group if (group, tuple(param_list)) in self.groups: old_param, paths = self.groups[group, tuple(param_list)] else: paths = { } paths[path] = None self.groups[group, tuple(param_list)] = (params, paths) # figure out the changed directories dirs = { } for path, change in self.changelist: if change.item_kind == svn.core.svn_node_dir: dirs[path] = None else: idx = path.rfind('/') if idx == -1: dirs[''] = None else: dirs[path[:idx]] = None dirlist = list(dirs.keys()) commondir, dirlist = get_commondir(dirlist) # compose the basic subject line. later, we can prefix it. dirlist.sort() dirlist = ' '.join(dirlist) if commondir: self.output.subject = 'r%d - in %s: %s' % (repos.rev, commondir, dirlist) else: self.output.subject = 'r%d - %s' % (repos.rev, dirlist) def generate(self): "Generate email for the various groups and option-params." ### the groups need to be further compressed. if the headers and ### body are the same across groups, then we can have multiple To: ### addresses. SMTPOutput holds the entire message body in memory, ### so if the body doesn't change, then it can be sent N times ### rather than rebuilding it each time. subpool = svn.core.svn_pool_create(self.pool) # build a renderer, tied to our output stream renderer = TextCommitRenderer(self.output) for (group, param_tuple), (params, paths) in self.groups.items(): self.output.start(group, params) # generate the content for this group and set of params generate_content(renderer, self.cfg, self.repos, self.changelist, group, params, paths, subpool) self.output.finish() svn.core.svn_pool_clear(subpool) svn.core.svn_pool_destroy(subpool) class PropChange(Messenger): def __init__(self, pool, cfg, repos, author, propname, action): Messenger.__init__(self, pool, cfg, repos, 'propchange_subject_prefix') self.author = author self.propname = propname self.action = action # collect the set of groups and the unique sets of params for the options self.groups = { } for (group, params) in self.cfg.which_groups('', None): # turn the params into a hashable object and stash it away param_list = sorted(params.items()) self.groups[group, tuple(param_list)] = params self.output.subject = 'r%d - %s' % (repos.rev, propname) def generate(self): actions = { 'A': 'added', 'M': 'modified', 'D': 'deleted' } for (group, param_tuple), params in self.groups.items(): self.output.start(group, params) self.output.write('Author: %s\n' 'Revision: %s\n' 'Property Name: %s\n' 'Action: %s\n' '\n' % (self.author, self.repos.rev, self.propname, actions.get(self.action, 'Unknown (\'%s\')' \ % self.action))) if self.action == 'A' or self.action not in actions: self.output.write('Property value:\n') propvalue = self.repos.get_rev_prop(self.propname) self.output.write(propvalue) elif self.action == 'M': self.output.write('Property diff:\n') tempfile1 = tempfile.NamedTemporaryFile() tempfile1.write(sys.stdin.read()) tempfile1.flush() tempfile2 = tempfile.NamedTemporaryFile() tempfile2.write(self.repos.get_rev_prop(self.propname)) tempfile2.flush() self.output.run(self.cfg.get_diff_cmd(group, { 'label_from' : 'old property value', 'label_to' : 'new property value', 'from' : tempfile1.name, 'to' : tempfile2.name, })) self.output.finish() def get_commondir(dirlist): """Figure out the common portion/parent (commondir) of all the paths in DIRLIST and return a tuple consisting of commondir, dirlist. If a commondir is found, the dirlist returned is rooted in that commondir. If no commondir is found, dirlist is returned unchanged, and commondir is the empty string.""" if len(dirlist) < 2 or '/' in dirlist: commondir = '' newdirs = dirlist else: common = dirlist[0].split('/') for j in range(1, len(dirlist)): d = dirlist[j] parts = d.split('/') for i in range(len(common)): if i == len(parts) or common[i] != parts[i]: del common[i:] break commondir = '/'.join(common) if commondir: # strip the common portion from each directory l = len(commondir) + 1 newdirs = [ ] for d in dirlist: if d == commondir: newdirs.append('.') else: newdirs.append(d[l:]) else: # nothing in common, so reset the list of directories newdirs = dirlist return commondir, newdirs class Lock(Messenger): def __init__(self, pool, cfg, repos, author, do_lock): self.author = author self.do_lock = do_lock Messenger.__init__(self, pool, cfg, repos, (do_lock and 'lock_subject_prefix' or 'unlock_subject_prefix')) # read all the locked paths from STDIN and strip off the trailing newlines self.dirlist = [x.rstrip() for x in sys.stdin.readlines()] # collect the set of groups and the unique sets of params for the options self.groups = { } for path in self.dirlist: for (group, params) in self.cfg.which_groups(path, None): # turn the params into a hashable object and stash it away param_list = sorted(params.items()) # collect the set of paths belonging to this group if (group, tuple(param_list)) in self.groups: old_param, paths = self.groups[group, tuple(param_list)] else: paths = { } paths[path] = None self.groups[group, tuple(param_list)] = (params, paths) commondir, dirlist = get_commondir(self.dirlist) # compose the basic subject line. later, we can prefix it. dirlist.sort() dirlist = ' '.join(dirlist) if commondir: self.output.subject = '%s: %s' % (commondir, dirlist) else: self.output.subject = '%s' % (dirlist) # The lock comment is the same for all paths, so we can just pull # the comment for the first path in the dirlist and cache it. self.lock = svn.fs.svn_fs_get_lock(self.repos.fs_ptr, self.dirlist[0], self.pool) def generate(self): for (group, param_tuple), (params, paths) in self.groups.items(): self.output.start(group, params) self.output.write('Author: %s\n' '%s paths:\n' % (self.author, self.do_lock and 'Locked' or 'Unlocked')) self.dirlist.sort() for dir in self.dirlist: self.output.write(' %s\n\n' % dir) if self.do_lock: self.output.write('Comment:\n%s\n' % (self.lock.comment or '')) self.output.finish() class DiffSelections: def __init__(self, cfg, group, params): self.add = False self.copy = False self.delete = False self.modify = False gen_diffs = cfg.get('generate_diffs', group, params) ### Do a little dance for deprecated options. Note that even if you ### don't have an option anywhere in your configuration file, it ### still gets returned as non-None. if len(gen_diffs): list = gen_diffs.split(" ") for item in list: if item == 'add': self.add = True if item == 'copy': self.copy = True if item == 'delete': self.delete = True if item == 'modify': self.modify = True else: self.add = True self.copy = True self.delete = True self.modify = True ### These options are deprecated suppress = cfg.get('suppress_deletes', group, params) if suppress == 'yes': self.delete = False suppress = cfg.get('suppress_adds', group, params) if suppress == 'yes': self.add = False class DiffURLSelections: def __init__(self, cfg, group, params): self.cfg = cfg self.group = group self.params = params def _get_url(self, action, repos_rev, change): # The parameters for the URLs generation need to be placed in the # parameters for the configuration module, otherwise we may get # KeyError exceptions. params = self.params.copy() params['path'] = change.path and urllib_parse_quote(change.path) or None params['base_path'] = change.base_path and urllib_parse_quote(change.base_path) \ or None params['rev'] = repos_rev params['base_rev'] = change.base_rev return self.cfg.get("diff_%s_url" % action, self.group, params) def get_add_url(self, repos_rev, change): return self._get_url('add', repos_rev, change) def get_copy_url(self, repos_rev, change): return self._get_url('copy', repos_rev, change) def get_delete_url(self, repos_rev, change): return self._get_url('delete', repos_rev, change) def get_modify_url(self, repos_rev, change): return self._get_url('modify', repos_rev, change) def generate_content(renderer, cfg, repos, changelist, group, params, paths, pool): svndate = repos.get_rev_prop(svn.core.SVN_PROP_REVISION_DATE) ### pick a different date format? date = time.ctime(svn.core.secs_from_timestr(svndate, pool)) diffsels = DiffSelections(cfg, group, params) diffurls = DiffURLSelections(cfg, group, params) show_nonmatching_paths = cfg.get('show_nonmatching_paths', group, params) \ or 'yes' params_with_rev = params.copy() params_with_rev['rev'] = repos.rev commit_url = cfg.get('commit_url', group, params_with_rev) # figure out the lists of changes outside the selected path-space other_added_data = other_replaced_data = other_deleted_data = \ other_modified_data = [ ] if len(paths) != len(changelist) and show_nonmatching_paths != 'no': other_added_data = generate_list('A', changelist, paths, False) other_replaced_data = generate_list('R', changelist, paths, False) other_deleted_data = generate_list('D', changelist, paths, False) other_modified_data = generate_list('M', changelist, paths, False) if len(paths) != len(changelist) and show_nonmatching_paths == 'yes': other_diffs = DiffGenerator(changelist, paths, False, cfg, repos, date, group, params, diffsels, diffurls, pool) else: other_diffs = None data = _data( author=repos.author, date=date, rev=repos.rev, log=repos.get_rev_prop(svn.core.SVN_PROP_REVISION_LOG) or '', commit_url=commit_url, added_data=generate_list('A', changelist, paths, True), replaced_data=generate_list('R', changelist, paths, True), deleted_data=generate_list('D', changelist, paths, True), modified_data=generate_list('M', changelist, paths, True), show_nonmatching_paths=show_nonmatching_paths, other_added_data=other_added_data, other_replaced_data=other_replaced_data, other_deleted_data=other_deleted_data, other_modified_data=other_modified_data, diffs=DiffGenerator(changelist, paths, True, cfg, repos, date, group, params, diffsels, diffurls, pool), other_diffs=other_diffs, ) renderer.render(data) def generate_list(changekind, changelist, paths, in_paths): if changekind == 'A': selection = lambda change: change.action == svn.repos.CHANGE_ACTION_ADD elif changekind == 'R': selection = lambda change: change.action == svn.repos.CHANGE_ACTION_REPLACE elif changekind == 'D': selection = lambda change: change.action == svn.repos.CHANGE_ACTION_DELETE elif changekind == 'M': selection = lambda change: change.action == svn.repos.CHANGE_ACTION_MODIFY items = [ ] for path, change in changelist: if selection(change) and (path in paths) == in_paths: item = _data( path=path, is_dir=change.item_kind == svn.core.svn_node_dir, props_changed=change.prop_changes, text_changed=change.text_changed, copied=(change.action == svn.repos.CHANGE_ACTION_ADD \ or change.action == svn.repos.CHANGE_ACTION_REPLACE) \ and change.base_path, base_path=remove_leading_slashes(change.base_path), base_rev=change.base_rev, ) items.append(item) return items class DiffGenerator: "This is a generator-like object returning DiffContent objects." def __init__(self, changelist, paths, in_paths, cfg, repos, date, group, params, diffsels, diffurls, pool): self.changelist = changelist self.paths = paths self.in_paths = in_paths self.cfg = cfg self.repos = repos self.date = date self.group = group self.params = params self.diffsels = diffsels self.diffurls = diffurls self.pool = pool self.diff = self.diff_url = None self.idx = 0 def __nonzero__(self): # we always have some items return True def __getitem__(self, idx): while True: if self.idx == len(self.changelist): raise IndexError path, change = self.changelist[self.idx] self.idx = self.idx + 1 diff = diff_url = None kind = None label1 = None label2 = None src_fname = None dst_fname = None binary = None singular = None content = None # just skip directories. they have no diffs. if change.item_kind == svn.core.svn_node_dir: continue # is this change in (or out of) the set of matched paths? if (path in self.paths) != self.in_paths: continue if change.base_rev != -1: svndate = self.repos.get_rev_prop(svn.core.SVN_PROP_REVISION_DATE, change.base_rev) ### pick a different date format? base_date = time.ctime(svn.core.secs_from_timestr(svndate, self.pool)) else: base_date = '' # figure out if/how to generate a diff base_path = remove_leading_slashes(change.base_path) if change.action == svn.repos.CHANGE_ACTION_DELETE: # it was delete. kind = 'D' # get the diff url, if any is specified diff_url = self.diffurls.get_delete_url(self.repos.rev, change) # show the diff? if self.diffsels.delete: diff = svn.fs.FileDiff(self.repos.get_root(change.base_rev), base_path, None, None, self.pool) label1 = '%s\t%s\t(r%s)' % (base_path, self.date, change.base_rev) label2 = '/dev/null\t00:00:00 1970\t(deleted)' singular = True elif change.action == svn.repos.CHANGE_ACTION_ADD \ or change.action == svn.repos.CHANGE_ACTION_REPLACE: if base_path and (change.base_rev != -1): # any diff of interest? if change.text_changed: # this file was copied and modified. kind = 'W' # get the diff url, if any is specified diff_url = self.diffurls.get_copy_url(self.repos.rev, change) # show the diff? if self.diffsels.modify: diff = svn.fs.FileDiff(self.repos.get_root(change.base_rev), base_path, self.repos.root_this, change.path, self.pool) label1 = '%s\t%s\t(r%s, copy source)' \ % (base_path, base_date, change.base_rev) label2 = '%s\t%s\t(r%s)' \ % (change.path, self.date, self.repos.rev) singular = False else: # this file was copied. kind = 'C' if self.diffsels.copy: diff = svn.fs.FileDiff(None, None, self.repos.root_this, change.path, self.pool) label1 = '/dev/null\t00:00:00 1970\t' \ '(empty, because file is newly added)' label2 = '%s\t%s\t(r%s, copy of r%s, %s)' \ % (change.path, self.date, self.repos.rev, \ change.base_rev, base_path) singular = False else: # the file was added. kind = 'A' # get the diff url, if any is specified diff_url = self.diffurls.get_add_url(self.repos.rev, change) # show the diff? if self.diffsels.add: diff = svn.fs.FileDiff(None, None, self.repos.root_this, change.path, self.pool) label1 = '/dev/null\t00:00:00 1970\t' \ '(empty, because file is newly added)' label2 = '%s\t%s\t(r%s)' \ % (change.path, self.date, self.repos.rev) singular = True elif not change.text_changed: # the text didn't change, so nothing to show. continue else: # a simple modification. kind = 'M' # get the diff url, if any is specified diff_url = self.diffurls.get_modify_url(self.repos.rev, change) # show the diff? if self.diffsels.modify: diff = svn.fs.FileDiff(self.repos.get_root(change.base_rev), base_path, self.repos.root_this, change.path, self.pool) label1 = '%s\t%s\t(r%s)' \ % (base_path, base_date, change.base_rev) label2 = '%s\t%s\t(r%s)' \ % (change.path, self.date, self.repos.rev) singular = False if diff: binary = diff.either_binary() if binary: content = src_fname = dst_fname = None else: src_fname, dst_fname = diff.get_files() try: content = DiffContent(self.cfg.get_diff_cmd(self.group, { 'label_from' : label1, 'label_to' : label2, 'from' : src_fname, 'to' : dst_fname, })) except OSError: # diff command does not exist, try difflib.unified_diff() content = DifflibDiffContent(label1, label2, src_fname, dst_fname) # return a data item for this diff return _data( path=change.path, base_path=base_path, base_rev=change.base_rev, diff=diff, diff_url=diff_url, kind=kind, label_from=label1, label_to=label2, from_fname=src_fname, to_fname=dst_fname, binary=binary, singular=singular, content=content, ) def _classify_diff_line(line, seen_change): # classify the type of line. first = line[:1] ltype = '' if first == '@': seen_change = True ltype = 'H' elif first == '-': if seen_change: ltype = 'D' else: ltype = 'F' elif first == '+': if seen_change: ltype = 'A' else: ltype = 'T' elif first == ' ': ltype = 'C' else: ltype = 'U' if line[-2] == '\r': line=line[0:-2] + '\n' # remove carriage return return line, ltype, seen_change class DiffContent: "This is a generator-like object returning annotated lines of a diff." def __init__(self, cmd): self.seen_change = False # By default we choose to incorporate child stderr into the output self.pipe = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, close_fds=sys.platform != "win32") def __nonzero__(self): # we always have some items return True def __getitem__(self, idx): if self.pipe is None: raise IndexError line = self.pipe.stdout.readline() if not line: # wait on the child so we don't end up with a billion zombies self.pipe.wait() self.pipe = None raise IndexError line, ltype, self.seen_change = _classify_diff_line(line, self.seen_change) return _data( raw=line, text=line[1:-1], # remove indicator and newline type=ltype, ) class DifflibDiffContent(): "This is a generator-like object returning annotated lines of a diff." def __init__(self, label_from, label_to, from_file, to_file): import difflib self.seen_change = False fromlines = open(from_file, 'U').readlines() tolines = open(to_file, 'U').readlines() self.diff = difflib.unified_diff(fromlines, tolines, label_from, label_to) def __nonzero__(self): # we always have some items return True def __getitem__(self, idx): try: line = self.diff.next() except StopIteration: raise IndexError line, ltype, self.seen_change = _classify_diff_line(line, self.seen_change) return _data( raw=line, text=line[1:-1], # remove indicator and newline type=ltype, ) class TextCommitRenderer: "This class will render the commit mail in plain text." def __init__(self, output): self.output = output def render(self, data): "Render the commit defined by 'data'." w = self.output.write w('Author: %s\nDate: %s\nNew Revision: %s\n' % (data.author, data.date, data.rev)) if data.commit_url: w('URL: %s\n\n' % data.commit_url) else: w('\n') w('Log:\n%s\n\n' % data.log.strip()) # print summary sections self._render_list('Added', data.added_data) self._render_list('Replaced', data.replaced_data) self._render_list('Deleted', data.deleted_data) self._render_list('Modified', data.modified_data) if data.other_added_data or data.other_replaced_data \ or data.other_deleted_data or data.other_modified_data: if data.show_nonmatching_paths: w('\nChanges in other areas also in this revision:\n') self._render_list('Added', data.other_added_data) self._render_list('Replaced', data.other_replaced_data) self._render_list('Deleted', data.other_deleted_data) self._render_list('Modified', data.other_modified_data) else: w('and changes in other areas\n') self._render_diffs(data.diffs, '') if data.other_diffs: self._render_diffs(data.other_diffs, '\nDiffs of changes in other areas also' ' in this revision:\n') def _render_list(self, header, data_list): if not data_list: return w = self.output.write w(header + ':\n') for d in data_list: if d.is_dir: is_dir = '/' else: is_dir = '' if d.props_changed: if d.text_changed: props = ' (contents, props changed)' else: props = ' (props changed)' else: props = '' w(' %s%s%s\n' % (d.path, is_dir, props)) if d.copied: if is_dir: text = '' elif d.text_changed: text = ', changed' else: text = ' unchanged' w(' - copied%s from r%d, %s%s\n' % (text, d.base_rev, d.base_path, is_dir)) def _render_diffs(self, diffs, section_header): """Render diffs. Write the SECTION_HEADER if there are actually any diffs to render.""" if not diffs: return w = self.output.write section_header_printed = False for diff in diffs: if not diff.diff and not diff.diff_url: continue if not section_header_printed: w(section_header) section_header_printed = True if diff.kind == 'D': w('\nDeleted: %s\n' % diff.base_path) elif diff.kind == 'A': w('\nAdded: %s\n' % diff.path) elif diff.kind == 'C': w('\nCopied: %s (from r%d, %s)\n' % (diff.path, diff.base_rev, diff.base_path)) elif diff.kind == 'W': w('\nCopied and modified: %s (from r%d, %s)\n' % (diff.path, diff.base_rev, diff.base_path)) else: # kind == 'M' w('\nModified: %s\n' % diff.path) if diff.diff_url: w('URL: %s\n' % diff.diff_url) if not diff.diff: continue w(SEPARATOR + '\n') if diff.binary: if diff.singular: w('Binary file. No diff available.\n') else: w('Binary file (source and/or target). No diff available.\n') continue for line in diff.content: w(line.raw) class Repository: "Hold roots and other information about the repository." def __init__(self, repos_dir, rev, pool): self.repos_dir = repos_dir self.rev = rev self.pool = pool self.repos_ptr = svn.repos.open(repos_dir, pool) self.fs_ptr = svn.repos.fs(self.repos_ptr) self.roots = { } self.root_this = self.get_root(rev) self.author = self.get_rev_prop(svn.core.SVN_PROP_REVISION_AUTHOR) def get_rev_prop(self, propname, rev = None): if not rev: rev = self.rev return svn.fs.revision_prop(self.fs_ptr, rev, propname, self.pool) def get_root(self, rev): try: return self.roots[rev] except KeyError: pass root = self.roots[rev] = svn.fs.revision_root(self.fs_ptr, rev, self.pool) return root class Config: # The predefined configuration sections. These are omitted from the # set of groups. _predefined = ('general', 'defaults', 'maps') def __init__(self, fname, repos, global_params): cp = configparser.ConfigParser() cp.read(fname) # record the (non-default) groups that we find self._groups = [ ] for section in cp.sections(): if not hasattr(self, section): section_ob = _sub_section() setattr(self, section, section_ob) if section not in self._predefined: self._groups.append(section) else: section_ob = getattr(self, section) for option in cp.options(section): # get the raw value -- we use the same format for *our* interpolation value = cp.get(section, option, raw=1) setattr(section_ob, option, value) # be compatible with old format config files if hasattr(self.general, 'diff') and not hasattr(self.defaults, 'diff'): self.defaults.diff = self.general.diff if not hasattr(self, 'maps'): self.maps = _sub_section() # these params are always available, although they may be overridden self._global_params = global_params.copy() # prepare maps. this may remove sections from consideration as a group. self._prep_maps() # process all the group sections. self._prep_groups(repos) def is_set(self, option): """Return None if the option is not set; otherwise, its value is returned. The option is specified as a dotted symbol, such as 'general.mail_command' """ ob = self for part in option.split('.'): if not hasattr(ob, part): return None ob = getattr(ob, part) return ob def get(self, option, group, params): "Get a config value with appropriate substitutions and value mapping." # find the right value value = None if group: sub = getattr(self, group) value = getattr(sub, option, None) if value is None: value = getattr(self.defaults, option, '') # parameterize it if params is not None: value = value % params # apply any mapper mapper = getattr(self.maps, option, None) if mapper is not None: value = mapper(value) # Apply any parameters that may now be available for # substitution that were not before the mapping. if value is not None and params is not None: value = value % params return value def get_diff_cmd(self, group, args): "Get a diff command as a list of argv elements." ### do some better splitting to enable quoting of spaces diff_cmd = self.get('diff', group, None).split() cmd = [ ] for part in diff_cmd: cmd.append(part % args) return cmd def _prep_maps(self): "Rewrite the [maps] options into callables that look up values." mapsections = [] for optname, mapvalue in vars(self.maps).items(): if mapvalue[:1] == '[': # a section is acting as a mapping sectname = mapvalue[1:-1] if not hasattr(self, sectname): raise UnknownMappingSection(sectname) # construct a lambda to look up the given value as an option name, # and return the option's value. if the option is not present, # then just return the value unchanged. setattr(self.maps, optname, lambda value, sect=getattr(self, sectname): getattr(sect, value.lower(), value)) # mark for removal when all optnames are done if sectname not in mapsections: mapsections.append(sectname) # elif test for other mapper types. possible examples: # dbm:filename.db # file:two-column-file.txt # ldap:some-query-spec # just craft a mapper function and insert it appropriately else: raise UnknownMappingSpec(mapvalue) # remove each mapping section from consideration as a group for sectname in mapsections: self._groups.remove(sectname) def _prep_groups(self, repos): self._group_re = [ ] repos_dir = os.path.abspath(repos.repos_dir) # compute the default repository-based parameters. start with some # basic parameters, then bring in the regex-based params. self._default_params = self._global_params try: match = re.match(self.defaults.for_repos, repos_dir) if match: self._default_params = self._default_params.copy() self._default_params.update(match.groupdict()) except AttributeError: # there is no self.defaults.for_repos pass # select the groups that apply to this repository for group in self._groups: sub = getattr(self, group) params = self._default_params if hasattr(sub, 'for_repos'): match = re.match(sub.for_repos, repos_dir) if not match: continue params = params.copy() params.update(match.groupdict()) # if a matching rule hasn't been given, then use the empty string # as it will match all paths for_paths = getattr(sub, 'for_paths', '') exclude_paths = getattr(sub, 'exclude_paths', None) if exclude_paths: exclude_paths_re = re.compile(exclude_paths) else: exclude_paths_re = None # check search_logmsg re search_logmsg = getattr(sub, 'search_logmsg', None) if search_logmsg is not None: search_logmsg_re = re.compile(search_logmsg) else: search_logmsg_re = None self._group_re.append((group, re.compile(for_paths), exclude_paths_re, params, search_logmsg_re)) # after all the groups are done, add in the default group try: self._group_re.append((None, re.compile(self.defaults.for_paths), None, self._default_params, None)) except AttributeError: # there is no self.defaults.for_paths pass def which_groups(self, path, logmsg): "Return the path's associated groups." groups = [] for group, pattern, exclude_pattern, repos_params, search_logmsg_re in self._group_re: match = pattern.match(path) if match: if exclude_pattern and exclude_pattern.match(path): continue params = repos_params.copy() params.update(match.groupdict()) if search_logmsg_re is None: groups.append((group, params)) else: if logmsg is None: logmsg = '' for match in search_logmsg_re.finditer(logmsg): # Add captured variables to (a copy of) params msg_params = params.copy() msg_params.update(match.groupdict()) groups.append((group, msg_params)) if not groups: groups.append((None, self._default_params)) return groups class _sub_section: pass class _data: "Helper class to define an attribute-based hunk o' data." def __init__(self, **kw): vars(self).update(kw) class MissingConfig(Exception): pass class UnknownMappingSection(Exception): pass class UnknownMappingSpec(Exception): pass class UnknownSubcommand(Exception): pass if __name__ == '__main__': def usage(): scriptname = os.path.basename(sys.argv[0]) sys.stderr.write( """USAGE: %s commit REPOS REVISION [CONFIG-FILE] %s propchange REPOS REVISION AUTHOR REVPROPNAME [CONFIG-FILE] %s propchange2 REPOS REVISION AUTHOR REVPROPNAME ACTION [CONFIG-FILE] %s lock REPOS AUTHOR [CONFIG-FILE] %s unlock REPOS AUTHOR [CONFIG-FILE] If no CONFIG-FILE is provided, the script will first search for a mailer.conf file in REPOS/conf/. Failing that, it will search the directory in which the script itself resides. ACTION was added as a fifth argument to the post-revprop-change hook in Subversion 1.2.0. Its value is one of 'A', 'M' or 'D' to indicate if the property was added, modified or deleted, respectively. """ % (scriptname, scriptname, scriptname, scriptname, scriptname)) sys.exit(1) # Command list: subcommand -> number of arguments expected (not including # the repository directory and config-file) cmd_list = {'commit' : 1, 'propchange' : 3, 'propchange2': 4, 'lock' : 1, 'unlock' : 1, } config_fname = None argc = len(sys.argv) if argc < 3: usage() cmd = sys.argv[1] repos_dir = svn.core.svn_path_canonicalize(sys.argv[2]) try: expected_args = cmd_list[cmd] except KeyError: usage() if argc < (expected_args + 3): usage() elif argc > expected_args + 4: usage() elif argc == (expected_args + 4): config_fname = sys.argv[expected_args + 3] # Settle on a config file location, and open it. if config_fname is None: # Default to REPOS-DIR/conf/mailer.conf. config_fname = os.path.join(repos_dir, 'conf', 'mailer.conf') if not os.path.exists(config_fname): # Okay. Look for 'mailer.conf' as a sibling of this script. config_fname = os.path.join(os.path.dirname(sys.argv[0]), 'mailer.conf') if not os.path.exists(config_fname): raise MissingConfig(config_fname) svn.core.run_app(main, cmd, config_fname, repos_dir, sys.argv[3:3+expected_args]) # ------------------------------------------------------------------------ # TODO # # * add configuration options # - each group defines delivery info: # o whether to set Reply-To and/or Mail-Followup-To # (btw: it is legal do set Reply-To since this is the originator of the # mail; i.e. different from MLMs that munge it) # - each group defines content construction: # o max size of diff before trimming # o max size of entire commit message before truncation # - per-repository configuration # o extra config living in repos # o optional, non-mail log file # o look up authors (username -> email; for the From: header) in a # file(s) or DBM # * get rid of global functions that should properly be class methods