pax_global_header00006660000000000000000000000064142674350310014517gustar00rootroot0000000000000052 comment=4e76dc54028a88099c72ec3a925b5f059f442d05 FVS-0.3.4/000077500000000000000000000000001426743503100121615ustar00rootroot00000000000000FVS-0.3.4/.gitignore000066400000000000000000000001121426743503100141430ustar00rootroot00000000000000test/ *__pycache__* *.pyc /env /venv /test* /.idea /build /dist *.egg-infoFVS-0.3.4/LICENSE000066400000000000000000000020551426743503100131700ustar00rootroot00000000000000MIT License Copyright (c) 2022 Mirko Brombin Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.FVS-0.3.4/README.md000066400000000000000000000065661426743503100134550ustar00rootroot00000000000000# FVS File Versioning System with hash comparison and data storage to create unlinked states that can be deleted [![CodeFactor](https://www.codefactor.io/repository/github/mirkobrombin/fvs/badge)](https://www.codefactor.io/repository/github/mirkobrombin/fvs) [![PyPI version](https://badge.fury.io/py/FVS.svg)](https://badge.fury.io/py/FVS) > ⚠️ This is currently a Beta. ### Why FVS? The main reason for this project is for the purpose of personal knowledge and understanding of the versioning system. The second reason is to make a simple and easy-to-implement versioning system for [Bottles](https://github.com/bottlesdevs/Bottles). There are plenty of other versioning systems out there, but all of these provide features that I wouldn't need in my projects. The purpose of FVS is to always remain as clear and simple as possible, providing only the functionality of organizing file versions into states, ie recovery points that take advantage of deduplication to minimize space consumption. ### Dependencies FVS only need the `orjson` python package. ### Concept With the following images, we can see the basic concept of FVS and how it works. In the following examples we will investigate only the first file cell, the others follow the same concept and should be easy to understand. ![](https://github.com/mirkobrombin/FVS/raw/main/data/cnpt_1.png) As you can see, the first file was added, removed and re-added but FVS always kept only one copy of that file as it was always the same version. ![](https://github.com/mirkobrombin/FVS/raw/main/data/cnpt_2.png) The example above shows a different timeline. In State #4 a new file has been added in the same place as the one in State #1 but since it is a different file, FVS is keeping two files in its storage. Since the second version of the file is used only by State #4, if we were to restore one of the previous States, FVS will permanently delete that file as it is not necessary for the other States, this is because FVS per concept does not allow travel to the future, so all the States after the restored one are deleted. ### Install ```bash python setup.py install # --user for user-local install ``` ### CLI usage ```bash > mkdir repo ; cd repo > fvs init # with custom path: fvs init --path # with compression turned on: fvs init --use-compression Initialized FVS repository in /your/location/repo > touch hello.txt > fvs commit -m "First state" # -i= -i= to ignore files Committing... Committed state 0 > echo "Hello world!" >> hello.txt > fvs commit -m "Second state" Committing... Committed state 1 > fvs states - 0 First state - ➔ 1 Second state > fvs restore -s 0 Restored state 0 > fvs active Active state is 0 ``` ### Lib usage ```python from fvs.repo import FVSRepo # create a new repo or point to an existing one repo = FVSRepo("just/one/path") # add some new files with open("test/hello.txt", "w") as f: f.write("Hello world!") with open("test/ciao.txt", "w") as f: f.write("Ciao!") # commit the changes repo.commit("My first state!") # add some more files with open("test/test.txt", "w") as f: f.write("Hello world again!") with open("test/test.ignore", "w") as f: f.write("This time nobody will see this!") # commit the changes ignoring files with .ignore extension repo.commit("My second state!", ignore=["*.ignore"]) # restore the state 1 repo.restore_state(1) ``` FVS-0.3.4/data/000077500000000000000000000000001426743503100130725ustar00rootroot00000000000000FVS-0.3.4/data/cnpt_1.png000066400000000000000000001501311426743503100147650ustar00rootroot00000000000000PNG  IHDRy + IDATx^}} $$ c `Ml1d0`L0d1r8%Nnޭ =awgvkt{73;󺪻^vl6Kr   @Uh'˗     !b   @FGA@A@A@     @@zAAA@A@A@.6    $! hyA@A@A@A@؀    'A@A@A@!b   @FGA@A@A@     @@zAAA@A@A@.6    $! hyA@A@A@A@؀    'A@A@A@!b   @FGA@A@A@     @@zAAA@A@A@.6    $! hyA@A@A@A@؀    'A@A@A@!b   @FGA@A@A@ꆠgYn׮r # v& C@|&^rv"*k f PA:i~ U犝vŏLӉ@9}A{:mBZ n])^$vVm.o hյ@AڲyA@ROO7A7;QꧭMA/9.@k?)A@&ZVУ ou^jd5BlZZ[(KښLZZy;S^Gw_'q_ $RZɴ,7QeICd[)JP6J-(ۚQ?gwPF-CiMh.=GUiˀF )ofٖjdZ7~[[ZvR~S}ehHe,׮]KWVlʾ3D_=o馴ۗo8H,/%7A@(+%nud7gEA[3[ga_㼖L+ut Pebn{Z:dϔ%P5/>K=GBϴ({3 jߩmy%< ` вb͹\KL|*y^W;c4 }S,}dhnn+W: w":{A#FHR˳ $&|^[H i:FA֋HRΙ̷z_p8qvZ'z:6[$ ӊW>HFt)位oԭў6&#!!ዀJh=5VIԏ|C1WʹI[,os eFl2ڸAHpPu$&g{-sA@(#C[l=裑/yӺ pġ}߶ꗝ뽍8xp7ȑ#aq駟VGy$3$\ǫ.rz+NpnWx9~6ʹNֿ8U-%$/ZF ˗Pgռ8s.?l~GA;}y;/7xk"?A~8MmذA)S&l:~mELrKjllT5zh8q"=C*ǃFAre=G] vC ?<r!#fI?)}__]ݠCj$;SdxwuѶnK{[o˕X=GV6tćO>T*mٳ\sd(|͕%s*-B!intW=5@`=,|4} ? ?]wݥJr=x/(9N%K,[nEVRV[mEcƌ!$`k֬c=V)^XmsJϤ̂nu'GZ݋'$AFpWKKK6L`޽;}ߠ_jgT@i>@Zp!a<Pk2`-(Xe\?obJ;ց@{ꩧM7DU$vuW5V`ěo$9眣|ש(aǺ%8#ƀo J*7Slf0ъ~(q}eq3Awϩ g}6zKMAR }8=a;/plc]N:$}\E ַ|}/TA 6RL_ ABJQtWԩSE$ / }%-t& AϠ\68}}Gi+_ŽhC *;G$BAn+>l 1$i,Q$4 \y Fgԭ[7k>:to$4H~>'REB- x/,f[]':IKSD_c214]tQ}0$3H|{%͈6SE"K}GH.^fQH@G\ '-3[.@=!:΃+^->^9o_>j  P00 H<*(N'c,Dh( Sƀ{/"P7sm:MUtRv P+GA_ P{)jl:GfhLP!B#0R !al[qr[AcsؚAqN/#Ggt!Ts`u,4:PmJNazDbww"Xy dl߃DƎ;hEA<H@݁J(r#gZ0۔zݒSCNj+@7 HRSP]#)}3BvNpaQ$ {ۆR D=Qp 1σ>[_T3`\NH‡A)po(8LAE:zDuTv5[P:q Ag†øJ$-C&AGvV2wJSD0c̝C@E^=~ƪSp,T "AB}9!$q`X$-Kw>*Aq31L=3g^@#JN/_b~s7[ >H:tOP!AL0pbm * 3^I6BB?ă`q;ˋu76U_9䭷jatѼxs3y0,Eu\5J  @!lD'X jDTA * (PݸqN :CRH$||C'8)1*||HCs"#][OyŸLXL=R$DX5Fa1ElF` 8 5W$p0!z ?ĸ`8 栣 n=m:GHcH R9z]üyEOg<݌STt/8e8x? ]EX@U b7n.'Lz 0%+W zVk-<ϗcsO[>N/ :@ۛ!(CFKܡZ"r;x*),܃@Pn`be@C)>|f}N6W#AGF߾}ե:&VsH>$b%=}kb 98'ov:@vN KM1U Dr&Aǘ z9%xIA :O@́/6#) @X𘄘cT_I^z_3g^@#Zβs"b9+a΢ĉUs~4&A|q*XM&\Aplp(B,8vPbK4!s:tYQVi.Tf1 zS]m;Bom wmιI8:=(ۙ^t ă|$CabRpH"A7A po@ '^%g;||"~O۔}TBHLWfsƁD N ^ SZ|F! x#χ[`}FC-n#eŸߍw &^&AlqdމC} 058 ?bvś#zU#x&)~!!H-A߲gߋͯ➡ss(G?ۿ8t4Jdг/9o* T# %!PQVt!\[eg\qN6V/{4r{P?r;QZ$Ppq ˼ S H 𚃎-qGi.O@ 5 (9^czt0w̉żcT`ux< <3/ViʹR[[i׏?R { wC tBб3HTDb($0.@iF,G" P&@-Ěn D Fx̡9>w(Nsq}w.eF[83H(c;|W:+&A= ƹNaC6 a:o$@Fԧoz):蠃T9LnxQOϠcTA .RMs~s{ܕMt̜7`t?:Tp,FA-ǡb>8ޮ?C ' Z@AZ:0HC*X+ub4^gs.o?|XAwĉc'JA¡C) V@qp 1m"7Ɓg}!E X X` A\⌠ʃ9ij#i yQ3.VO(;aIO=tsޭA rf&a.4 TD!/p$9AT@q_,$of.D-7~ E b=,'e̗OÇ x.lc0Ly«g$tCB z9_y{P6U<9N%*Rd$rQŅ F,HNڱO{L݃ @|yxVGK.}YxWyn 5<~]KM [`p1a@ "![}b%m`/ XQ+>L"ӌ7|@6@(w;0ȁhC-Uq.T",5j ,j|Pυ|V]xVh4'/}ۜzp;CNm@T4Ha5Xc>łچAك}rJ`vL(党_ (dz `G@8# IDATP-r:@1o" d&\WȆ|/0 :>3KAǜsV~M7Fyn 2l^ *$TQ>>62tDWSPq! HyD$ȰB:9˜:EaLb;m?0D Z<+@$*hпc; O__ yɜ\Ề)SulXbat$9{\dA%^Cl[hۭ w,\`;>g'W$k+Q1)C|$,H:U0n6q ž2Bc#{ Ԫ{ @j Kٶ:Ԛ=!>YdPIOο3Os roml~ Z6~;KMBju>iĉm z3g !11 :"'nIǴ!dA. $=1V5@ũ϶XzzHsj1)P)\"N^wC09qA%˩w1 :AA[9gמ   Z'y7ǮeUA_FS#jMlM9g;^vVKo̴V]:-/!d9.&AjUm<&@Ozِ )&ѣsnjq9+l ? dnp8SsQ9QvND)Ӫ r \r3[@Ns9C/̹Sk>R;@pd瘛 -Pl&P%ÐA %//z}\9h2vg @Żx_QBD/dc}$Z2Wj_N)[|M;s"9x>Jc>WS3yԭLh377׹VMɰ Umɺrׁ}c}9/?TkA@@j :ֵ݇6GZT΁g.}$F\ oSfƪʶ2iw"mkמ:y:tY!<'Ϭ[>J+Qs<@>}hw훙!í4&A~ϖ*x^zرc*} ttiԸ~.9`5B eZXݽYjJPu2Y79c.]AϭtLW6q(e ޞ?o\LGR Ub1A2<Ny)3Ћ:p-ut+;DP @*0fRkK/ ˗J3E֩5wU?64ˠAE/f]\ D$<|O=/uV/n~כe1b/t0UNA3q23? ?YA$NAN+܆u1#`Xnv6rT =  O9k}3W߬+Lu"¿B ɇ&~3z =V?FפK @!:1}=t"|[: | gs]ҵN ۓh9-cg^ғANs% IWbK?G'~}NH5o5K8tNF řcMߚ/pKV\ۑ'AgdbF8t zq9IOI $vrB$%zl 2'>YY?$5u0UA +V1=z CW.@uH8=g\+ahuEkq|ٙI66ŜSgcTrpJj_9IuRܜ.$ zY)$&neN㈔۴#@HA׉ؙ%N4G'Of s%LaG}K'fYVӄ)ѓNj)q~L 4(ZxUؐs?0MA$O .&L9J SI;5z37E쌃,4W)af]%wJHI)UUbV]cdLqdymJ@t&ĸ}֯j^2wSqRg$="ղ3FiFR9ͭ)$S^ye^6VZGb-+  P~RMMF *aNb2X+ nvd{bg x' 7{%QuߑdVsˁI7qSV{  P~RI93<R3ԃ=L@[DyImIN[P%<rnpurnOtEg+r*AH-Aw"nrn6ϗDW0^֍eN69BLs8Uժ򽂀 Ttm0 a-ץ7d8YzlA8(\J&^㇌!l3y*A@@ w7(4|=U=[LHrT$N\7  '5Au@ Kn`^bgn?y# >Sy@9}o,J2]J[jxQ7[L&`Kή]ڵy3A@ꆠKS    IF@z[GMA@A@An^7M-/*   d'uA@A@A@!u򢂀   IF@z[GMA@A@An^7M-/*   d'uA@A@A@!u򢂀   IF@z[GMA@A@An^7M-/*   d'uA@A@A@!u򢂀   IF@z[GMA@A@An^7M-/*   d'uA@A@A@!u򢂀   IF@z[GMA@A@AnI>ydz'hƍԫW/߿? :FU n:z 6|pӧ_G}TۮJl' kmm(up^f =4uT|ڵaÆ=ٳgTN_ ,/^[^{EC SK׎ >Ih?ԭ[ISG Af.7ocntATSM˴A^iΜ9_Wt^2A@:_/[;Azf #" ~ίvgm.<9Ar8_? *g=ONdxt $PY[XjyJ;Ph"U/\v '(>駟;\WU%׃Ϝ9,YBvX\pqL8Z sUkz/df4s3.cpԩS?ϳ߫}ycƌQjsUJ3+WT}N=Tݻw}oj+:c=Tn̍Gi<=UjD%䯽BY~hvPe|A_O1ѣP݃*>?2v"ߙ|ėW N qf~&( d|>q 3C/@?}B^USO}Ԏ=37cǎ%U׮]xPqcz']σ*ExPWVZu;lc w:{8?I$@bA2jNË_o> ~',Opfpr" ]?_XkU;LSݻwerE! >ɝ|rJ`?Q_]zpx  GuZb>`ޅSl :JDׁoqBAyR1{(Ür>27_ x n7,]D ~_sB9X D|>r<2wpl/FdA Pf+ `dt|뭷t4a7)_jc4я~CPj[A'?7GXTLԶ#<&,iBb R {W:>^yՁA 9_hJ^+,@RNA/Æ SkIG鬳R r;OF5k֨v,ܡC5;6A=7  /Sc/[\wuQJfmVq?CBN|6枣l>00Xt>nEq çoo3t?QГ u<~ ~ӟQ}CzT _7j;E>a C4| Be~DSSS0O:uݚJ|_zIjFÁ)b 1 O>rKK/MAȓV$u΁9 AGQ6q6M@-1#E℠W" ~ Bz C=4 r /t&{Ģ/rrZE@|'! 204kNίOj0\[};)j]y(I׃TNM?7;ذ¨Q ɓ>0?\ePjGqb6ÉBiůNap#,SO*rpٗǘ۾wBpW^]5v~w]!>s <8u`B](%5AǍG7p^؇o߾cABb`^bp:zCC? -N :˞={œ&MRTsuxN:$ %~WtAK/[L-lL? Do}z]D\!x# Bؗ{9Xv̘1Ç\s }#nYx1mP+'7H=AG466Ю];z {cn9pM}Y5(b0+o5:Ag Vϻ;̑7ϿsҰ^.2׌ ~MjiXFo{FnF|z9r ͗:M7d4+Lud42 Y$+6RO?Eu c4]yӟ3,e,r1wWD7OB;VXw"Xͩ~as@"Ngu@-3v!Ӈ߁@6,-=6ߒE@@(_ A`|ri/GeTakW֚b\k#j h>)!/zdFWeP۱2Z~ќqndٍ7ިcO? [=t嗫US:TYU*$ ~ XM5z袒4(WPɟy800xτ@|1m4v| pbUX$-|{lM' Piį5@nsH JrNė2zt[yGEiFFA>)(A\+   $!IhyA@A@A@G@zݛ    'A@A@A@!uo   @VgA@A@A@{׽    I@@zZAAA@A@A^&    $!IhyA@A@A@G@zݛ    'A@A@A@!uo   @VgA@A@A@{׽    I@@zZAAA@A@A^&    $!IhyA@A@A@G@zݛ    'A@A@A@!uo   @Yf3kܠ' uˉEPJbϹHxrs('ISgvsFIp)%M Ji¤'2j{K ɫcI !&캄d%OʥǨ]>;^n9RI{N &~kӂ؞s .%N{Aw\%=xur0{' |8ppR{F%)N:d;9}Ws6}^ۮnN}t9#=e3Rv\ksӆMG6\)ϲA)sROh6OVNae)ͨs3,e2g)]U({LF'`-\}s2r[})M m6QDJ&dr6dy־;/-O|7nCDvcT߽v҅ @;v,K;kz֭q)=mO9gݜ|;2>RC~v`D]z[V6nHk֬q, "4.0m{Էo_GW;?ڥurjzMZ;Ov$O_!mR}f6>A^]ޝ~8eذ3yڵq6McveC{n>J0bn]bz*cwqgש{ j|3:|&/v@ vOaN&:\%. 'D@Ąg-{hܰ] /&4B/},x8Tj6PҦf;|uB9 j[];t FC;&,^=+V6Z@iu^`s{AÆ ݞ\z'eI w?P|_G3'*At }nhN\Cz͞-/_N 4G6 cC n,lv6Epl3<@Moe*˓iiHU^m`gvٲeƮ55NiK=ea3v8Ʀ}7|N+@1]Id_Z]U,| sHCL.Kؘ焉͸)'qU\xQǏm1.Ac ϏDAf{.$,09gءCvmW^ A=sSKHP5َgtݶد03I;sXeޥYP7pX \p"V)+#e{hAQ9m#l\EݨiK:06a @OD;.YQQr'9b$ J݈~N:ѨQ9b6ߣ/ܞW|l 899ϓȶImЭs~dl2"L֞&1bD,cv Aj]NK)+YD~YrIz6Եtt]KeYxq |+1ׯmֱs$ %f)lNlIjcT[f弈f3ԥ;]7%|CJs{'eiJJHu)x~62"֎ ߏ/NMr^" 򏢶=v`C2 :2+WZ?MF:֋cĀi08DXTO9/RP.e m>XUVC#j}=*+z&l=)n ]$zsi .݀KX{˨}gYp>eڴҝ+r^ķ>c},8J0*BCŏsY?x'C)=Emg! t7HZ6>M_g\gĢ;{3u?~hglH:i޺8T{ιr 1&L,l:Y_0tBЌۀP΀mПVmD5_>FkV˪J+8Tswx:us^}mdCJl0 Iw޹@YFNb^p~<ߧPWqιEˉd|qe…V%fWξ-H;vl,̘mz?k+D),uE\8?ciYAj97sx(>i;=6U&'z P͘$\㱐:n)^ezm٣$U9gc-sTyL(؝wkwyhP00iLma9$ms'mA  p |m7{^jڸnTyN{Q>GgnCq!qy˜1c d˳:2kITd^ctyJs%F>.H2myh}$*.K{oH/OB/f3Թ!Z46a8d:ԕKf5Ve;F80-K[=9gwRAAgi߾'U9WϕRv]ꝟ-XL,U2=?8>O=9/wP]m)gcvbNoդ*<N1 `nꛓo*RR-(‚Ϟu lHI\d;L@ef,$I<c4= zs޲m䫯V]3lDxg >N{Ds{CQc-^l%HB'(=x1ʹMW'+I|uVFs?3vK&|҂'U9g A8KbTt=`u(IWι'ӷ^o_Ax1,A ݖq%[ʢ[ b_1'׮uU$cVt:<+ȸWsSK'z^_#&IWY>ω- :sRANi/=9'0) 4s0oCӠI&t J|܆wSГ|c{SEГю}N[tz:6tؐ 8gp$e AOre_~1uj7<:q6lcQ(כ}xeV8ٳn+ͫ^5_'\9]]A>~ >u9)IWEgWS-0dYJ]$_^O67SSԶ,1)isVzc"iQsA6/%c^ʊIVl7-U.;:᠗Xs~>K/Y[G}[63{."N zJsuIn#|\ri{f\tIzYDl\d ]v-"l{COq,qSeLn;6&ezw=A银cx:7:s{ڔsVKܢY'cNe)nIӤ3Ѝ?3gx )<-l v*A"6|[ydWěAĽMAOrHN6CPzt>-Xu"aKЀ'iKلS-v>ms)(iSywIS/֞mrQ8Sg\bQ{#67ȀIНNI[9ϋ'6^tPmj̕1_?_G={@W^$cݾ%A.9zcNhzXkڔsVji4ԆM9_1䙒l>9kV0AOr6zHBgН tѲpK&Aj:AOrNN[ިЃjm0>\lfml"MFStS*qqJ8lp2٩" 4@_=7.L9=/QCAMj)qċn]ɴ-Cn :Q=Z}u6土AYIwSCA3j% _ R>cW g>vk=9Knv&1EҺ~6W7'*>:ߵ}-L@^A箟/ Ҟ)O$rZaK$t&qt&8I'N;1˞s}\'9 V2+ePytʢ˒-O-Aw"^`%`e :`$ڧ M)-ǫ9<=s0nݢt%>dZ'JW#yl"0F!MHP6$l`aoD|4/ |c/ƞsO)&ۊ\HwΑϑ6Rv2s/ws'jjR@:M_AIl(MɷM_}YĝnIw2Gu_%l6}_p7EɮBVڿ\2߿]s*$5OJkݙ I ;:]g䭳zċD(?HM{?QE/ gT{l2 z+Imt̩o6CA~<t9IRywzuO= Ac6}[9ȸyOU\\LП璃yR옌H9?d?k#)_rDƋH}A-NL=\ϱ͚[Rml yt#%zy?TG6土}N}{ϴQou&4UX(d1W2xsgFȄ}کyM&ě.FГ% >b]vl_&T9g%]L}… r'\Jbi>n67W\=rx#vhѢ\W~ISƸ$k͡2'9+]GQmos,qBž>y3i]kxs:c]2ҢҲmz7ÎE/5x%lHb9{CA2dHdӟ}ժU] !ݷp_sЃTΙȘ8tztTukwOdBkiԫMt7D&MAA@餠WJ Bԥ#GF/^:+Jis^<=KJAez6fucXHod`+9?jq˞i͊| UkpTC9gEhIR<s.O&*^ AOxяq';=r+Wj( z۽Lҿ{AO؜s<,3 8CAOrϋ놽<49[-9f}O}C4~}ʖ˩.٨Aor@oXYgBϟjC6!E665*T&6NpY-]x zX{J$2boZAmzzs^L4A/&A9X)JEН|wԱ HСC#۞嗞 z(Pб՚A/3K/"+^=Ls`wH%`rÌS A@KbgF9|v/ ^uBмi $E98l"MAA@+8$0%c=Xp9m\7e!dn*\~b#iV7D|z*xA"r"~3B9)dܩo˞KJLr]C1'艈C(,Dy;ڦ/Aib%8$JŸ"PЅ+/ЋKs󕂬Fiae%+{;$  IL(nS >9笜}_Z;jzR望mg~!6i :J7= d뮻F/N)iXTsC3^%s-QE D̀*MɷM>S >"qwwމg}c\lK˹~Wkwy>6:n$)p b8E/dEWXᘸ:FVJzM{+woVXVLƅ"c&A㌓h-VqwHX }O;ӟb λf7Ov/iy&I Nh.L8msЭ%>n9~X9sRnD4v A?*:iNrϻ{Nн9id)˞ z}K˜sVy5vPw'W5^/zt'KLaÆE=Y/_sTj8h ! Y`97 IL~򝃞D圓]u?ZdyW59mzRsN$o\ٳgp7G0vbea`5᫵=*q :l"遀&L??D9q*:/}G~֞JܮIg4tx1yA-֨v|/[(9^m~?:2tqYozA}sƇ"c&Afh]j}81]^Lh{9+0Sէ@$U9 ei t 23AA<REss9 A5kVV 9PwmP}\|T?կe4 E "A{FN;Aw T1.{."+!avS9o>:'g(mdUs}zxы>0}#"۞.XOAv_l8;w?2 aw"K0?L:kd0# kZ  IDAT+lÇ &t <=M9xw==w)mhDoo70`S{igpf_A/ D|$KUnn]f9.rIQ~;Ag?{ғ?~*W$MK- Jzr*()/_~0}ȽIR^b8E/Qvm={=u8{{=Ki_BJ]Tӊ.$1L&1PdTЯzBnw}z s 6tG72ѬR 1P6 ޏctSЫ C]Ͻ64rVp>[+Y$@new{ύEA9sfWll$=Ql"Mm'@*LGO;A9=9JE\\Bߒ/ߏ/ ?^/HKqSہg3 z zE8E/Iy%@wy!SA_t#.aSնn;Q+M d|w}Yfv9~g+cwA9/)jN|+2Ahv^A9iQr6q\*˄̠~ƌ9[V$jnߏ `=l+@|SS,uxܞ$$pMTE\B&jK.}21&N9-d=Jg$~P⻍Jt(?7tK52I ;:]gNb8E/nd㋠}&lRXX%*߰c هdzýL{y]r9? EL)ǷAOr^SЃnȄY켂[q[(N_n 9ﺞAm;b๡l"MATЫ=X~~y싨*%Jz\}:<ڇH8?O&&rj9d[$4E3gO>ÞnAl)$W\?eOӚ%A/T %[9$j 'W$^,rGEƘr8(#Gl{{b5SA/'ˠ A/39@P좿A%B^zs+샞&\Stǘw#/|#yQ_ q ymkq_Dёϫ{krtff8 k_Zd{^1"Zc9ɝs9ao?\kO>=Q+ @hD +ݏ t傒l:- 7Oc'=jYeOӪƻsӥsGRL ͤ/I9#^"Վ/PAн(q_d\ =r=C^=srEJ Omw5;eN:}}ݾkC54kT:Ф)S1P2AO܃ J’1=lօ9)PyNgΏO6SA *^ߏE4I&JAWhʵd;r;v z{ mJ?qs$^|ߥC9]1^,r6=|E|2cdhM7QFEN :zt9^  T\9笜s%c&AgкV,S`gX9{}I滴NȊ].M9iS: ԩS(%bns h.瞑y٬i#t3yaVBalC?4bi&{Z$.9ݦL=ېm?[_e%}]kn$gv5AԮnyE&Iqf 5!cb|}_x1$/z)STuuԾ\}ѢE%3iw{8{9o.}]x?+l!望J1[_ώ-M[%M9R]NLh<"~ns-ss1[_&)^[ZZ:XF$^y٬h꜂sZ;97W5 GS3P0#8kim |v1zҕs.wʴi0lcq&| =wt)?7 uP zYŐdW1ELċ~R r1c"۞bmŮF*X.ܜ̂K5[E||0>=rFvfKCw{쇑  Ehr aK]&t4 ==/ymX31WB9wSy.8g}VՄMԀ*MɷMQG0u){w }z}ɾ!Atr$g7-a͍+M. =bX-/,_m!|%x˙+bi&qeV.=fΌg{Y˞KۊBPRߩ+^I?P~96^rGEН|2 c " ,p-qTzv^fbnR4O?YJR2Y KJCuu+jl Auy4ss am"MAqƊ<R}msvkVdlic6%[}6:<6Ģ#xJ&ۦZ7C M9w)A礐qoӞ c4vGe:GFIwm[|?和%S_[25# %qċbTdRoCE?87=~_suq^bWбN㊓–~`;^ 0S; QyKL~ChmB¯\Z"΄2q'zAOrm&t +ՉcA@A_6c>iҤ&l bi~!9iSι*Aj6X PU5Q*zx12I/zt'(q$ނur%i%;]ϸA;Ge^o|w{)QɘI/jT(Oj|&#.~˴shIsG46]^үlz $KS%DΕ{'Q96KA81xKMɷKO~BUؤ- g~Bmxs[X{d˞BƝs$SAo|V-xzJs% zx1$َFWZ/zt'r2"%öqRD=^jAwT1_ً3[YX$N<[}>!>~6M+%s AoK!6=d]9#7}[(%sY9G6W@eqQl"}Kj2o3Pm+Ͽ%?;]ym4>I6 荍d*Q{W?FOν͛\[yZ%*cCM;g0(znY.wX:nfBpuBsӴySnkU)Q<`[Nn&ě]M栗sk0[2VDЧAV#h-WREd,;+VY8@V-5s>f-myNhnrRK(B^3ApW>Ԝ/yBM9eIؐ#GTZ֬q%IVنA+A+e%}]•s4tx1yA7}2id'E̙'UByEzU9 /)aNr8=yN~1M\z["E9cq7oAԾ}{_01U7dE,o0SAoZ0Dsg D7?` Nmڳǡ{Ī66iՇiiSM9WWmcϝ:uګėMgo^"|g}N[ c}GпUh)FE&_)^oIԆAĜOHGw0N~wo9a WR9"cWӼ , W\vTnsxP3-}7mU)R. @jo=h zf@L!c;Ll{aEE#'\Imp9oij0ڼT1Si |VjN54gBfCLbtͧ'9$q[gS8};Z&0:i%hIL&AG D,޶y${^!Բ~ Z<{ɯvl^{{8?~{X8n `._|*PS'R]okx}٧{%ߜ4ݽ,֔X9}osiehڴiq%`[{y{'u-Tg$L9֯c*SqD5f]o O $aMA7 yg'U)żU=Zޙ;u1qRv‡詹w&-+ ݬtܶ=7?@ͱT9SlBe94g4덁* e:q 8W0hI:G^v\dRM'z̴_'G СCjzhY״nJRzg=up= }O *{køȸiK6ѥK1b lBO聄%!P/|yҌϛXIGCiй疔$I&?JЁՂ 0Us[I[{Fd=; nch΢u3uR7KHv4,E)ij*E#[%#K`dM)Q2H&тJ ܊k^s﹞==Yr{swRG7sTXz2OxEb IcAj/y&%X ꓋'m[/g gxҋX߆32}kf͚R/EĹsn!u]학[e> 6` Mw[-i$ib߷Ͻ5\qw z}^Zy5Xʦ[[ɝ'̿}sJV:^fxe6HKKť8'9wuyߖyaP-a>4?V$V^fNxm9,?d!;B8CZ&?]=0Aai.5m"؋T&ni r3HH& &%A%hP{F=Eȥg 3(ްk2Lٳg/v`_NڞJ{}yaxbڂq~`A_^$,sx.W t ixV:e\xٰ7E1&z7&,cΫh+b4g>/hIjM{v(R7w_qEO0/ZəFfP[^x❋V{ӛYCzEacr~ۦ7gΜsv 'R-qɮ{kͻ?wq{;z !SE]ؗIK-^Xz rIlȎ*2XkQě^&}v18NsQ[A3 ܋X5{ʬGAFƒfO2\vpO*i.*"NyUyJՉq{l2(;MlE~~R+]g9.5M 3VMyuigT{.yT=g9=+_e\t[VCܯ|?nbβ&=5T$5kt*oEjeI5ݰ5 l=%г%o,øk!$;/3Ǵ Y9*{ǩAs55}U~jى.YIN=Mr3`O|XWfbsh2=ţDi@/jvķd'aƊQ% ?9=XEC8J蠌 IDAToou7H)zyYq%LH6eL5O;&w=>o~]=aM7eDUS]_]/yp`Uݠ1Ez"8/#Ї,ˮAZAd /li@e/yTDetXwi@/zOjEމ,"74\ :&)|Ea"'hh7&D[ 4ozE\UٯG-|47eh얫y)jپoCBqOyxXaTk,99u3p1dTA*0#po淈2"}fB[EcwauΪs@[wzVMnu4=Hg Q .yElcCd_ԙX8 j Öo3!+Gq} 39L]oz.tl{ַ֞B;QY>o}Zh>ɊˏNϛzAFⰈyHښ.g{G>9lL^ەb铃ƩTX5HO g;F}]T1?}fk=i!YZX y]jysQxذ|vb+Д3o+b(ELmh<γ8 cQv'M6?6TUH~"ĸc\o-IyAm:ƻm棼Roy6MuA7-ɜ=HQ뺙^M zFOfX^y~M}XRf7Z4O[̵evR). EVvl*gN'섞ȋ&gO&?hM-Hy4 DEZ˔4,ZOuȷAeBY&n~Vsv2(AAڞG-A&ƸlDLWw}z9%8&O*:gZ,]$>~kI[_)a)Zwk3Aji@%ҋ +k f!$0D[&qH I]a"6>Sݮ(lUgiy\\ͳ4AE *<lijNeu w.oM:d<wћ/߰t՞8y~>hΛvrM7j3eFMZ'%_2`'98w'gB[v1}q)ävzv@ aF]f@/Z,T#?ZM!/ gj!8MQEȴ5=ի&qjσJv9hkaZv]XL_ԗE8Q\ 똳EkA&wyA0隮ʾ:e~3$Fs'q׎Ee,3i򚺵=/-fjaee7|Tv=bq4 U:=!eXOцvꚳ¥-)UAeTyIT-c̥Re]wF'He9c8Je)%aQ\We)r5Ȗ2u֣/o\S&Ǔ2^kʔ5K޿u꫇*̞={E,S/e$c$,sO򖹦̻ה)sk&y{ʔ5S0_S:kIQWrLL+sM]8= 鞃:(>ꦮRf^$`0$=Da$'tuIj&V[mpWW"ЋՆK'ʼni{ٶ7SsLeL>>oڞ\xu^c&q@yRm 0gΜp5&XA@H\Dtz-\ ({Xq[W%fC 5g" UH[ݪOKNέ7s tz;Z\DL$P V^U]^suzy6 TM@^5a/[G3 LJ@@xZAED+LJ4ziT^(8VKCm!_wN-Q (][Q梛Af˶TX@EMA"@V}ZvpnmgvP+Rͥ&$"bBp&P7bV8-l @\D۷Ij | &:WH`R tm$ ."ZQMfR (KB TF2>Xm6 t{ujM@ ڌz0$]z7[."l zҴsk;\]ގj.%0!6 4czEWmak&@W|%"^޾MUPWMK`4ьBP+'m;'Vpъj2(M@^J2έlpGH{ݫSKo ~׿ofԃ&=zлٲ-paS@(лU[Yo vTs) I +έn [3&$P/mj>_ 8f]>i> VT@i ҨPpn eۀ=B#`^Z~P-}386E7 A׃͖m$E@ޭ4$z3 @WK LHEĄM %@ohŘ^pnUu[ؚ (579_'z o@UMu4# IێI\D̤JPFskeh}<$.luoA`.I@nlK%\D$- nէi'v֛n-\J`B."&mh(zC+lskL@@: KED}&@ϗhέy&%@WOvO "d&%P4*/@e[+C%!q6`c@دWK έͨsMzwe[* , "¦ nPw>-M; 8u;(hRp1!8o@C (Z1fW[{Uf tzMI^."$P5zՄ}Fpn+$0)}Ҷ}h&3)Qy*#ZZ,˶{G~ݽ:D&@w[fpnmF=nЃ-RI`6 t[iiIfA@@oGK5 yJ@Њ1["ګ궰5P+knrNpQ/o& (Ы&%0shF^!I (蓶@+hE5I &@/ %P` xH\ 8#$=թ%7z7sk3\tt=lٖJ )H[ݪOKNέ7s tz;Z\DL$P V^U]^suzy6 TM@^5a/[G3 LJ@@xZAED+LJ4ziT^(8VKCm!_wN-Q (][Q梛Af˶TX@EMA"@V}ZvpnmgvP+Rͥ&$"bBp&P7bV8-l @\D۷Ij | &:WH`R tm$ ."ZQMfR (KB TF2>Xm6 t{ujM@ ڌz0$]z7[."l zҴsk;\]ގj.%0!6 4czEWmak&@W|%"^޾MUPWMK`4ьBP+'m;'Vpъj2(M@^J2έlpGH{ݫSKo ~׿ofԃ&=zлٲ-paS@(лU[Yo vTs) I +έn [3&$P/mj>_ 8f]>i> VT@i ҨPpn eۀ=B#`^Z~b-©fϞo^3HuЃ*$  H@$ 6Ԓy$  H@$ Pw-$  H@$   d%  H@$  H蝯b ( H@$  H@m @oC-G H@$  H@<zJ@$  H@@(PKQ$  H@:O@*$  H@$ o;r!ᨣ lA]/7OOae)=??Ùg]v/?wgwᵯ}mx7]O~2vi[VxԣU]w5⊁k'I{ovm#w<{ZF~3gΜe9/_W~A{]$  H@G@^Pgs W\qEdM _Bx_/~ k tPꪫ³ZÑGE^Zk;MozS,LJ>8Xn~ӟEiO{Z={v?Q?VZ) 7uS=.6_|qx _8 x$<GqDKL$  H@#@/a vXW^6x[^peK-TG?Qyc9 XoCc7yq~} |'=)r-a5׌)'F$  H1蹪k;~D/6W=_E^d{gxcãuomo{[x1CQ t1z7+/Q\s==#,o'xbl;+B(U[!ѿuccA>a=?/ym{~ž㭈~s .D_=1ꆐwɤt]wž@<z)_PE(ӟP}cQYg \wuQPBHEgzyb:a'"O}S o9yN/~EvBO?/R~q~6.#"eO(^ e{0A#o}-h# |"O~r44 ,毿(`",Y#:{1$bo{6@9X#x q'^򒗄W^9 / i:|<+A``1}Kmwզn'FW"7@u'ǽ߇vX;0> ~ 1;O|aЯ:cg#9ςq #3X8(2}'Ǽ`pC8.ϱ c y`311f0\~qL!s]v%QF& H@$u f°(ĝ8 r^}'ÓOyS#(` :,4 U$:-oyK\/ o(Գ Ajv"O1c̾yyzskv$x~2?EypHwSD#O^{:D7n-f'?Q4QsR:S=^ENh#zDT`k8D_}cTC<"W_}u]W]u 7zw%o5 qmX; E!|q?+G}thF"~8MD01 GaD~v:,9UzU,u0J`X;1`$#_0JR/){0p$  H@@ ( jx@'3rf=zcюxDPi*J t{$NG貘Mӻ'ao넧&4lxSn2b1>[Fv /I'1.o!{h@@S6DN8ᄅdO~:By;Jn2DCCKt"\#lg k͓pEbØNA\Dt v j21Ppx<$Q'Ї> 0gI>e0`Kcp!}1?@F& Ilcx~0zB?``TN5I@$ P0=ŝ5^AGs={+"E6^xưyD:–E2'dzgqO8,b4JN8F$_|pr_7 OCG?:$7pΎhJ#0'=I5D@"̟X&K;#w}_>:oi&G6o=#Qڞ1i@'*~r-c87cشz$ 5ǰȸMx󿥿N0C?o.1Q[g:k0V?) !IJA$k@^xG$  ɡH|{<Ϭ~B^Y#Ӹ="DuTȉ B)d"Di/=f?IX^<zy #Nтp)6'5-y'{ sGC 086O.I ĆQ-;jmPēK8E N[C#hIm<sYRښz\C4JVcD`ؖf{4Rщc IDAT)y̋l.[䙱>sCˆ8$>Xg@`y: H@$v d%"+0gt;:^"O-Bom Ϳv\N'!f4!wqG< OyQKO!߈d'ӈG@GlߒXTR{W9b0$Г=B[ղ!)$:c#B tD1gR}+!qx 7ChC3 A{)}qvG%=$<쟦}iD89C tm3Xߴ,.xD:+·wq>@|"gHp8 '1a8G?%fI:wg3ePֽR$  6B+LW{\NH{گpoe~8YWs aNqF4 >񈳨eq_ϫBāY/7&9;Q@g?:'A1Qt2k~`F`T`:Oycq`O=&|$x8̊['D?Hʏ <v Z65 =|RB\:#ǛvD~0pCD:" h癌 u6"7'D4흯( 1hn Dp^C̏}✉awa l|w7.=ㆸ,[AAP^L8 K'Ab7fs|bMØx\fMq^?(q+6n.[Y,dO7>c43_@h: <4I@3OmYA1Y DdEjsm @[NO5}Y[meӗ 6rv|i (ǵ$t&g [u(NH_N!ʄm$\ Vu3,gjM^ 6?U3@m赡EI3G8,8[6:e t$aKU;۟mQΝa9&_HᙜvȰ-ZlἉ3'9Ӳ #Xʙ/73eFB 4>#JHۻ83Y2Y0l-c [8O"ʽ\pA[{gƖ1=p65/ffXf:@#C9+,j DRX8Mp&s\8$]HpN;-.T+◳*8d/sdm'}"={ ,x +`KIyX|gNXeQAS6hp?vb>-9QɁk,YܯQppFz_f9C__&1.PA`S_9|G8р` Ϝ'&CH/n!\1·cܣL1_'A"06o9_˘:.^My[Is3y29Wg$=yΑbl&O_~P:)}.P (e=+geŸ@F3FPtH&|b$  H@B }moꫯ&Y8#! J>ߙMٰt/{7MDbb@&/B/8E̟p QJIS&dbJ aQ$mQZC&FٯM@{>h:W i!sD~Mfˆc.Q,詏a$D@H8d}KEX7Lc I  ES #)%JL} C$E3c+`*õ(л^ÖO$A㊅iT{Żga0't=xc @輏5^x) $Fbx~c6|R /OtO;!'&H!މN a : |x 1B,!xxǓVw B#1Ah?:$г!D1Zq g'on WmPp?8 t?93>QJG'Eh)02Hڠgs~O FqNqOcm9܋^>!!'{'6lH^ͳS*;UF$㊅!vޥ ;$;,OlWD8!Y^ljl8i >y/: #x nO͝;7E|My$߄=yggo:"'Ȥ F MϧA=Hi<ἈXk!x3D$Nf`Jg?3 Ab; [QR;j'^nƤ|l/a84$ I:Q:Dq\+JEk4ydcev-V"=$XoI?2a`:=SZM ;^Y:rb'|0\& b6G()."E'ƴ'=o"as@ZBބ"xYyf@(1" AHlx%:>I:奌D ѳǢ~C5 1P2NNg:{10Vpky9휯9~RN 'WH]: t%ă1-N@^*8}ces,i"15.,B'SOcJesXƱz1 0ԑE$`O/aUMZ*3%$,`{ gnO'˗Dc>u}13#웓Y"{dN[&Ͼo uigM)y, t)yK؏!yلuB>Py<_h# k*~a<`?@'_,KsP }"G iM sE/"Ewp_cw*d}O8F/ XI1gK g/ t;9~s l:Cy儶s1уκ!5rDJw5k7 (7n tʇqc"iq VA*:C`e2_o2Ё|11!%:İfّӤ鳚D##*>W$  z;p!,Qc_섓b" NM'(({abu<:8P2>G'@a;6&"9 %X9 0'i~aT 5RMX谪a‚OXqB+!{p wu)gAxxWJ\IXy@/ƂmD'SR:~衇B G7bo䕽t7W2~)Ţ! `ɥMVhӴ= =&a)HG_"̌;:5,E{g?t ~>&{F{D_ӑ:rk'yg+ 8ë%q0 _02OKEYZ$  H@PزP7)ɞ):{:X"Y#m=!!\{oHkca8`1ik`I c z.}<9B!88)K "rH"qO!铈v)r <-0J|<<a y8"*8AA$ʳ!Ď}~Ba<|$bT~ov%u)O&DH%OgnaC@s >HX!c_Ҏ0Na:I("f %jF['2>!)Q27~KH,D !xII¥3ؗ3038k#px11яDxRZy$  HBzQ;Bt/x.hgae/8 Y<ȧCi t'95n'D=X; !g}v@g 'Ӳpw^3 {P0H!zIy<Fڥ<'aZ黭4<!xx8`@G#0"nѢ2=D2F*a&"v>sNT&zY@%"# t?EwߊDMyN[F#~ QO.'{@xD{ؒ[04`ؗNO2 ЉN? NF_aH`<#/;f$  H@>={)O34%d=% p 뮻.۴/B8–w4m2ƣgC$JoI;¾gY$A{XHq b&=H[I!%`@=ԓ_0!01xOOчO/q5kVI#|:g3Q8!:r"eRDʎq>觿D?Cܓ|J=۷!0D`C6j"= iҌ[DΤOFq/p,BM$  H@'@/`:̃==z:7IhG=oik_e"=x&ox-NJr\J"mل=[!q$ F<O8%NI o gB(Olio1z1a°wC%&"a0 %pl9lߠ"U$/ͬ}I' <pN 5'BS9 C%NK>UNoMk# H@J@^Ps|CPG){>}w6y5!E== o%΋g:X`# MiP8GtpިTFCO9H#8>ʋ6%2ލgo3fm0Es^reyΙl;F(:؛T!AHȗ=Hc oϺz,$  H@3+!!g*wPUBVwy/3Xֳ^9@(ix|GF✼#؟-go9xKMo!qtL={NX/̧2|y$fx1FWo Wq>$Ęyzo)KC2NiGfn:6o'Jx^s;C8ܑ}M} 7l9Lq2iG(ұ$ H@$0} xE'!9Js-ڼSy8:uDN_RγEa[_~D))}:a$ SPݗ>nJ3$&#QpRJ :M '!zO8(ӆ*:q~5}qd@Z3g3rI$  H`C"9X*:j`w}_Ux. A%ϐ OO{gXܓ? u -b`@I%O>0h2mvɗGeN9ݹ>j|6 y3ii<# Dy$  Ho}q+ H@$  H@$@od) H@$  H@zjJ@$  H@@# (Y-fJ$  H@F@޷$  H@$H FV$  H@$ P-$  H@$  4b$  H@$  Ho}q+ H@$  H@$@od) H@$  H@zjJ@$  H@@# (Y-fJ$  H@F@޷$  H@$H FV$  H@$ P-$  H@$  4b$  H@$  Ho}q+ H@$  H@$@od) H@J`ܹ lIj q??'? O|ænG> þf͚V[ՂٗD@^h_# H@@=?[o5z0;3*}{_8#<au׍n?Fm}coyc*/M7r?1$qơ5Zr/5m9%  H@=!p.(s93RQ~1/Ű>? t7]@[[^ H@@"G?QxS6`?>\uUaw >`xVZi'k7~ӟZ+e/ +B|pxk_͛C^{;Ŀ~~/| ;.kDocB}9| /r>|[ |;ٳ ::katK_ҰKl/<\|a嗏<|$wk/YgȂk);?(}n`e<mB$ V{³6< ^{mXmբUzU~cꫯ/n- o1{v׏sN;O?=~I'a7Bk_Zk=} IDAT'>s(W\qxW_ɇ_1aa5׌ keʇW%/yIxԣE—=.ijG}]w rJ׿u뮻><}Q/R5 zW:|U7ԓY}[ @H@$*Jw\xDp}3H;םx??\veE/zQxs=I!Gp.ѓM:vFG?^nלua|{W*зngN?Owa\vm׿=p 7>1cpm݆3z[jˬ}>W[cE tۀ$  H@"@8gxGx{=) tNJw<)3ΈfPfmb;tuE6ץ{>OtkD9Gp!@Bw;̓GW2| nؕ@YonzPP=   H@x~߄C=4|Ɂx)脝9goyJMGr(_gu{] Wx9>xox%#מ)x9X{l8#c?7sluZ^'m% H@T qq4l36>sy{^ ƻVCyHC oX(9y?ַu_qgs{I?%ړ=-zɇ%6N?I^T-Z~*J@jϜ;wno&a*|ӽ??>&\%C{~}7,cfzf7D5& _BO8 . }/!+"x 't|P$W3L<wb#5{ٻp瓟T:1ogB9WU dzaNb'?Dp@y9=Ò}}su5!qk!NWYeUɬLx8 '2lXkɷ& H`Qk[#|'~SBx&]r%ex]vٸWC8 1!!q>gΜA瀶fc'aGܒu ';1;O0~0~2S鄾cDHws; )z}vاsk>קV?seՃ>&{vaf_LQ yNx$/G @a`o lSC دk xyu58$9[n jGL-\yP9>6Niڳ >BϿ1V8Ў2,|OƢ}nLrXWZi3$, cc!,{ '^B^{;{wV[ h_}ahb>9yw>S$Й:|c5Y[/8~䑉CrwX 'Đ}z f }ʍ~ݧnY%  H@h'zXk'&]X,Ʊ a=\7`MgI`tIsyn^!^ͱjZ7Mke tBSG9K;{9SNP,B# Gj@o@\ۯmo_$ ~PAcoy[!4W<褢O Ox)!"N9swJMGr8cEaW$  ,9wqG;7Yg)g瞻NJ7{jv"ajEV7͋C*s*;pAI>vgy>s!K~>7lR%uU_/J~]u$  H<z!9DH΁g dr>v!D7X&oUR@gO:h%H>;lvttN{}iE^tars:=J9l0aI^Sz دoU>Q$  L={_|ڎe]6U4#xq9s ts>)RNV'}ߎǧ'GT$t{, ,L<4ڞFx1Pg聢f@~ د糲_o3^) H@f:op-UW]unR9'#N)+r|-t7GCGGo:PT>>M7t&ڹ@<~m+H@$ &P7v̛$  H@$  7UmA%  H@$  HM& H@$  H@!@MU[P H@$  H@h2zkǼI@$  H@@o({ST$  H@L@1o$  H@$ T$  H@$ &P7v̛$  H@$  7UmA%  H@$  HM& H@$  H@!@MU[P H@$  H@h2zkǼI@$  H@@o({ST$  H@L@1o$  H@$ T$  H@$ &P7v̛$  H@$  7UmA%  H@$  HM& H@$  H@!@MU[P H@$  H@h2zkǼI@$  H@@o({ST$  H@L@1o$  H@$ T$  H@$ &P7v̛$  H@$  7UmA%  H@$  HM& H@$  H@!@MU[P H@$  H@h2zkǼI@$  H@@o({ST$  H@L@1o$  H@$ T$  H@$ &P7v̛$  H@$  7UmA%  H@$  HM& H@$  H@!@MU[P H@$  H@h2zkǼI@$  H@@o({ST$  H@L@1o$  H@$ T$  H@$ &'iYIENDB`FVS-0.3.4/data/cnpt_2.png000066400000000000000000001257231426743503100147770ustar00rootroot00000000000000PNG  IHDRC$ IDATx^}Eި)C( W/b[@T+ AbC(`"(B  !Ϝ3g~ev|<~e晷=;Gx"@ D"@ MED"@ D"@$"@ D"@  @ D"@ D"@H)D"@ D"@<@݃A` D"@ DS D"@ Dx &"@ D"@ A "@ D"@tM D"@ D"@N D"@ D"$ @ D"@ D2@ D"@ DH=6"@ D"@ :e"@ D"@{0l D"@ Dt D"@ D A`"@ D"@ $"@ D"@  @ D"@ D"@H)D"@ D"@<@݃A` D"@ DS D"@ Dx &"@ D"@ A "@ D"@tM D"@ D"@N D"@ D"$ @ D"@ D2@ D"@ DH=6"@ D"@ :e"@ D"@{0l D"@ Dt D"@ DhI>m4~'7n-RƎ+ᄏL4o?O{=ZꫯO`:th^-C{zzK_G?s;(W]uuQ-5/N>?}COS^n/\̙9餓nh)ؙ @NK.?_nVYvmb-/><r:]~r-iӦ@o|/#@LasϩB j@{ǎ/l}Zwq_*v@X7~Kkϼ;/q70cX꿃L?7,'xbC=$oxbӟT/׈=P5pCnٱ#~e@to{7~̊뮻b DP'd!yO=TݟIЩoih @Uz.L^` c=O~RU^<>z;k<b/ŋcr긞|I9묳je}N y+GHs6u:t#A.;eܦu1 toU{ sS{'?I_u2Hk|kͻIГ'B(=As8p`]AW6Oo~Soɪڀ/*NXBu{viV[P뮫nܸq<#8B8ʍ CYW:#7Yֿ6ȮP'ޠXÇ3|݃Y>׿Ҟf ?l5{gmH܇Y3y{ P'IYľB x^x67UKtڱ[@pA:y9oۊ/<$-AO8[qЅ#|7v+AQju!k7ָ'! ӟ"}Mua ֔˜j߰1uגLx:?9A0kнmu9]4wpdnFdA 0͖pA:]'B~'SL;EyO,-AбIc*zWm u;>un021]D]vmrךcR2cP_|yTu7?q#Uwe@Hcu74u~zDxb$x".&LP{IW~䳟HL9|wNf5k֨`ji+NlB=h  q/ScYfb(ַ߳lH^G6rҗYsL׳>+pavG]_?Dž yy\XAA-:^x|_ f 0m%u9.t=^C=*V(jlr"@LX/| I˩ >e .րc;*t܃flFnP#?pB]$!9/lDq%v}}#Q;:c*>}/tՔmz}%H^DP26EUtl.#VZU>ӧ[qʃu]'QPl4\8MAoV"!35Jx饗G Ҧ#В]~zr`Myas8BaZyLq\.m#Ƒ&q$MA6 !pMl64m  $0?&D|.rН>WXmu2Z'Q mKK^zD%3G]}n.85"qņc&MTIЧM֦ 1q}a;.P p# z{*mz|M;}.~^1?yF!@]Ne1c_7!FW'PnOP'8tΓ]v%}`R=ȇ g}|;QNj`*8!3f"iӦMzSL3w=wwvvg /~8їI,GQO:UM-P<6;ST{6?A z[ltz|uPtR%ym;n#ɿ'@4uel;ydEԡo|c^e…2rmu2p'njOGdlhѢX:::;Yx\å_r]w)g7Ϙ?Ñd瞫ڻ{Y#oW-Q֤a'}g?r!8<X1 @z-jiZF/xF=z^Dt_s5NCS`jt2JI\۫Wn?ӊ U t0EUo~W:>` \;7a;vX"ؼ-hٻga6q: 23c~ 2} l/d)Is-(k,zMAhP2fhר 5l\/k)tΔYT_iZ;!d.5zDIqgW_}Zk>cuv=_vME}wWgTH\uUuG ۯnJ*Wy-Lp8k. ; Sp;^pbWX$-8h4z2\ɂu9.cIu]'7|:E_ w NE\NItW)}Yh >K D"@ DH}"@ D"@@ D"@ > @( D"@ D""@ D"@tFm D"@ DGE"@ D"@0 l D"@ D=$m/"@ D"@|@݇Q` D"@ D Ao{ D"@ D"$>@ D"@ m zۋ D"@ D Aa"@ D"@h{H^ D"@ D 6"@ D"@@#@"@ D"@ DH}"@ D"@@ D"@ > @( D"@ D"Ab;:: bM 1eᔽਆ'˨YcF-QA#F SHlI*@qY{Yqh[Ը8F6q赶e"w/>Gx|O㙷6<$ v\&^wUp[_7#2yӘ1ԵIIڼkܿ72g8a=7FW #\šKaT-FxOWnJ 8毓v:b+ڈLYwyrku=cv9S~>uKw7 Q?U^N 2P *w/jPmI%S_q9xNaܿc\2mr=KՍA$ucZm{mLퟫ$[!X~{ĝίD, Iw…cht[o0]󝝝mu$WS$~NFMMn1sSܨdo#lp{.XT}[*uNUx>u򾎎2j_I 1_fMn>&7d-AɤI ="qzEIږmF);sn1keGVMJ4I#Bjɀ*A{wˇw@ fWnZ*?s;9x7m$/Hv$UzɼMt >k..Ao_%/!L&^t_b6vB'*a@t9|̱u2a'pځc,X` +0O9蠃Bqy^Zymg,'R!pUx 2|ܩlb65`ɒ%$K@6BFɹ8wAN`6 {JIycµʈIpPb=NM`s`\@9V%M$&ыrw.f ZvԈ]q4]3{8֓zh&5{KrV7pdjC$1;޴Tr;9ƋDm/)91( j"f˖-sɴ!CȾ[NbG8cǎ>s o,%A׊YR*f}INE7Ȍ5SDC')lQ]Oo> z;y_9_pAƀG8+a ~ۓY&F,$67:`^Lmc=cRy{duJz`V{FW+Q2egŋkKp T2tI/a;<뮻V[mU”Wo >,F)ʹ]I2Wra~`FJI |m6}u.LL+udGFNtVK7DI_*z^z5d{d/dcd67^2|dO%|KR"-|NduY)-Aǔ;~]$`!S󬜛wZbQ׃ٟ׺Be=s>wAӷHd=]iCAsS&?Id"V:j4$I幙ƨ8lgqCڕod`U\~wUԽ%VlΡĿƋA8a˞7q>Qd<Æ =ܳ(BSɈq n- jǸqdmǘ &>97z{6ڰ&q4k\eΤGuoswMQ 6,Is਱%4Z. m3>ݕsm:ڰ~)Iy+e1Hz[9l#2dSeШj6 DA &l\`, ه,@&L}ٟteauHD%=Foi+w&=PY5va҂hZyjW</Nd{Z9I!eׅJ҄NY z5a%Sįt 7uNޕc*av*%!ibm^ۥ1H:]$W=4-=fʲ&"0G6zoSͣS(-+:Y z/<<il)YSkd >_+es$"m̉[ޚdiS=Sʰ dOJw<*{0OʞnGU]Ht#lYT;@q yV"覑wMJ}QcNF~߿O04օ^9ICk᠃(R!^9܏seT=IBf>BЃȹOs'Hs]9 } H!vFГ&l\8ÆI p%zSo'!yTH{w_1/klBУl\ZPD'v!,dbv彲Ր)WqcC'ʐx&j%Ǎ]9׾c T5k@a 2uߐYAzkȕFtrZYD) ,?bqvt-GP9ֽw)SxXff+z$ϕsQ֠u?`OI 8a scze"ȠX-}H꼛}vhwP=)$ 6$\x&Yd__EcD&.: ˃'ۦ%q>۔X^ʹ>cȾ2`|o=̇Ǒ$W}sɾ$s&L=J_d/N' y&nVЃdO˝Ua.0,IiޓfPA9rdhQQәMъd A_^9Rל? n]S(B z+5ɄL r2Q{{ysU)ŔQ)Yl\kV=-qiCf2vA^sMTDM?mn6u4 <<6+Q<tlsmdDCew$ɜ'/ z.wUdZe)$XoYsզϺҍǾ/=CeE]k WDo iG-+ܠh-rimK1&.<[j)-[\W;dyM_7糲{]1\x4F#;1eIg?H;# /N&Ⱥjs mUgq`1ͬӹ7]Aե,D+kF7̠ JX͖gE?8%tLQgUXT+BڙLY\gǘj.uHȃHR^ K%a +z:>븹t=#lH޼A_]mh &^د<6 _:S^lюaܧ*;bCd/A?Mkm%jr ^&FUob26An5Nt}!l6BRMu!?,)Ÿ&$.ol&v`ndZ:cKWMXhsu w̩rVFV¦ꘊT*$6렓;A7%@mmt{8zsl-x*纂?c|}tQj7AXvwLRV&UA7m,:G9_WצWrvnsV.Cdiω'f* pA?,ٴR$i*AI|~CNʳ/d>/y#+5okrj]H\͡Og,=kNU֦7sy5* ,o1+E zTR8mn- z^>e6Aۂנ;;4pƜ.oɁw)k= "ߜ.#j@ans+:1gV"ͫ̂Pk{ՔWAЫk.ϐ~#Oȴe"Bk@E&ʤ6A3k]ZW%&)r {tsl[>%}[k=_snaEo\3ur쓤G-9'Brt {5s[t|){fٳ++Vie7?7cvmGA {W=AW[r[;jg*rm@Ʋwg*e+e[9[}]欭Tl9qr{tJ(SWAWBIy֌l]g>iҤTIЗϿ\6{.x#Fl~9mT zF1Llo(nd`KW^ʹnw=S_sn۹͙o䉉iܹsS/ypuEVieLm&=kתeNгȳd= $nݲzU{WyeύA}?/~S]uBw7vܗ2`'+Et,d=3}>E8?vHs .0_5Go{-cu \5TY׵:x zh^s+8$4Unztvk774/H+e :%vq7=k;I2?3A_>˲qݳ#Gr~c΅Ng9fL7DtA?TZ$yns{>?;AwgxP4/yߥzѕ2MoDFt/vde<[s+za2`S(LKQ>*QA/7ja8nŷ$:ۗʹ&dmt0e]uMQ#llW9}fg ]UPm"a\ۜJ&9sD;J>DuS]&QA/a\W;ǜ#28d"(4cJpڤMtA?V6r+dGuK#4YK?sedˏ3W1N. Y{"$YbavƼs'NŷhA0aBfٳ+K. 75n8N➧U x -]|K=+I\W{N*2fOq挓A/A\Wұ}HepMB5Wu2ؔ2Q >Ϟ=FгǑ8 Szz*5ډ2&A/uA/A\pZ(SG(qzX+rT>8SR] w(R0ynmKJq-SV-~ύku2sG:ߔ$R:hGUΫ8&W*Em, "%KB7hÖ_Î; zťw_,r sTHKlTrUiAϵ5wu\ϪV֠{ܞqvJڔ)#pf=|R+UjnvU9 ?޷#vJ<_=5#/,2Q&C?Iv\E޳O~ ,=3.!hx\v.SƋQ}̙E"}C"-#F}'ŋp)v :vrY)^zܳ2^|R'`ymWBUϵ4ɶoLYu5 Of_&/Y^㊘"Kǥ2q=$MƃڂCI7yd^nV65kUߍ隡RR=0(9P5TtȄNyPrG&?TݖgɸmyɳK5RIFr+O?QWUܰikK z%+z"P7LNo*GZ[|s'XTA!rN^097W  n \{\Դd̮_Ge}@[ܬ"{R9וACI n=ATf~&9v3acx βq~.UҦLA+A>z衩 䵵 z*o$$^HI;A$MH׽.LIm?ʹONУ d= YǥZsnȠo\3urD3.$BV9*&=ٖ2UQA8qbf3 zp>~]W`rlW צ=vvqʈG 諟i`*z O%e 37n!諧Z^s^$\wA.2Q# ȠoxRٸ2MGT]^^9k6-w$e3Bz{YGJÉcuYZ?{_wkL‹ MП;NmWc"sa2hG3RndV܌qڤI2˞]A_pad"/s(5 _̪V57-'N8 IDAT&|0&i(5e=mZG_@6y& ku弶} s!=\YdL݇xb5eӧBfdM4f}>r z)5婜k{1 c k.jR\q: zjA/Sȳ=}ڴi zYweV e5=|ݔնe}gjBߕ󊽓 f&Ii}is]ѕ]V9כ*x*5:tE*yH%?I.BA {)-C#3w9?=G%`Å+0ktdB9J~3& U]cPʹ2Q sV97Kϓ ֠\WCv(8R(!em[9xٴrr*z 3fNs#ȼK!im\h}d+q:E39ל]uV`fɠ woT5IrZ*ˠȝd|WA3˞_QA_`AdIYHhۮJ ~+?mL;>-kqwZ2fOqd]y^9A&7d*kbDʇw(UҦLA|A+e"MozSfy^ נGytyX\ r!9וs-+ STǬ5KkC'@Dƥ(+Rn6fLMrnEͽϙeM/|kuUo{#Ѝ{4 ;^Wh$3VT6?IT2sua]d2qt8JA ٲau^ʹ" Oj.4FAOBlTgڨ"zy-ײj kuU\so-\݌T5߻\[QUgWGW#"N6d%L;UAg4ą E_5yv܆) O%ks#ȶkm"<\dȵst8 Ak]T37=R,sE0!Zg":@f}r!AoAݜ֠nVj=su]s\>Q^)]c+iq[~w*92c哥td6IlK" ?őY Y6e&Ou:f-lv+z I =fmѯeH+e{]"jw^Bw\c\A:YoJvzP=k?z>7/vkГkђsV9JZ?7HYyLqwNxT9*e msaOE&{ƍ6ߝed?qEnjS KR9׸EЧL;A[x;e=vq7v.q01=hvN8Ih~1J;#נ[{klt<E\=N.u]tG*线F+SƋQK6lc&Դ~eqDT[SFC-"qpI$~xicRt~ܛ*ǡ%ؽ<Av]mOv8?63т bzɓ )fVҝHvu4\GaA|P+.Q<֡[ǎiQlgeD97ۃ;[&`lnt l]\tta zm_]ǐiV΁q6nݺuVp = W&'9FELs{#ȼ˸ ޾pUa7#3N@=I"2MdるޞhZjFЁʕ+gl<]"N-ZdE>c;Z âdN .ǘjqw4+LsW(M@dtP1>y0Bj7rZi9`ֵ~<+;ɷ'UFW@AF%hݵVR=.yy u2Qz\#NLS9}@?X&)ôsf:KAn=P2dHdIa#h5r.!F#R8[ŋWCtpى!y=Xp)a6Laʸ9VғLw~nW_-[qMNJ73B Ad>20F@a FLOg]!SC鮴.WjgCo.G}2uAd$rEWmLf"6w{h+^}6ưGKH0]o=0͸`*e'^$dX%)GQr :|֫[Bkۘl2jD@Sߧkl; v([oAQ~cj7eӺ+$iYSwWWtS˟܊|B~3W䵣*]z#sU[Q5x t6N;(RjVm6u_|QU;q3e"Ngd", sM"7nڶqE#(]^xm2׿ ˗+ W+Wz*5JnEzjIk]wðje=T_| Wo%M=:ǘ:}$:ٸf${A }Yfff*/<(>0m+.HXxVHt3.QK\X}hyz-,W[1[4XS>Gaafaa+ 3qlٶz(2cʲ򬝾ml'i|3!ƙ35so32(k$$.(H7M.D[B {䟂:bf_lyi[my2z%A1] ra1`JZh9kAODkc줙gL;r铇HtMHYPƦ_c;h R$Rj*TXvtofjb7~G[&L#[ Lg)vIk}3 NbLgf'!]u۔413aD/8đ/[$S>TD"q*F ikIe~J4ӿb վL?cDYǘT'}LIb"vT+UX0oS[=QA[3.&GLȹ J<؉ [F qQuq vۗ[1 Gj:J0ܸ&\9( i93x ܛIl;oN= Fa e>("o.[^M6yԌ8'ϭپ:7;g*Q(9^23!nȹ N~qVAä+%A7LUM^+sTjEY-3AԆe8r,y"vVk"Aci'$. /7+ Gq E-؉V&8e/9.5AڬGUϛEȂ&"gȅ; J^؊-/fQq%4Ѱ6> 3fe r A2l1s۶L)a6.@L88Q^U2u.+(@mHac>anuP)䳃u> oWy흚ntbgWÒAvʧP5q1Vcb<+.Stm6#x[Qlw3 "A-ȩ% h1 naaʄ [Ee@WǓ >kgqm7y TM"I9H&A5sL~;.&8"Hc_y<=ys_qzE\d%ݟD'[ݿ%PThsE=+ݞ$pZl5UŽ ΃  S(eH1hMk"{Դ ԷMꚉlMP"N}{8] w{\‚8;6.[%Q6.,hjF^>q69UVj|v}n%oDZ!QKyS?_ьK%Aˌ8c {W) TSOWV k+JJ>0 |2AE+UMLM6mEpl\bzl\P'͖[?o#AۧJ۾+MNpgFt,.mLWQ'qG3q}ͤaymM(R/]zna÷w( ؂'f#,a4.F7r@YF<ώ%*EK:j:f 邟N36'+> 6H=gI 7 W_}gs .H{+ALA}wQoQ8aD& ?86[:88I=tl\+PD_EVp7˺;N8AFYX׌Y|'yl{ ۱IiLbCQ:Yt ]--TwQYǏz|26$ Z// -IhQoq!atIpHփ")q}YF9o R]7ݦy=mi;^l8:o~%.g[)A]# *0a}vE'Y yRsޗuEsP)wUp)J&|븶demFzꩦt݆,)/\,1]}M"]'7/kmo"i-Eue]K\"& w, C/`E`p8=<7[Y) IDAT480ycA|tm2vcƌ~!]2JӡܘI0Eff٢1΂I}ĥ:ْ=LƥhqQbq8Ȍ>zL2e܊&> .E5q)ПFF\dWs42%qZa zN_qɣ| n'njO=z<3M Flw[G "K8P]VCF);eA7HЫ#Bh=D ȷ_ Ae$"PA~@E=_ۈg0pl@"q"39׵=$$m`޵$7=>l] A'A/ԲD  [@  A/ ml[ 7;HI fh =L@TN"I" : :54 Zzxٹ6D ]Y+!$$%[6#+Iʀ zFml'giF @N97@`4a"P$ʗϦ@HIЩD`ε!$m8z=H A'AjFCK/;׆ᠳ^#@?q%Db&w8c;@ A/(l;6HI!gh =?L AXR"A" : :U4 Zzxٹ6D ]Y+!$$%[6#+Iʀ zFml'giF @N97@`4a"P$ʗϦ@HIЩD`ε!$m8z=H A'AjFCK/;׆ᠳ^#@?q%Db&w8c;@ A/(l;6HI!gh =?L AXR"A" : :U4 Zzxٹ6D ]Y+!$$%[6#+Iʀ zFml'giF @N97@`4a"P$ʗϦ@HIЩD`ε!v\r%2jԨ6=LC~ֿ1aʍ zǏ'D"@ DAE D"@ Dr#@^c D"@ DE Aod7 D"@ D{z"@ D"@hH[d "@ D"@(7$?"@ D"@ZHv"@ D"@ʍ zǏ'D"@ DAE D"@ Dr#@^c D"@ DE Aod7 D"@ D{z"@ D"@hH[d "@ D"@(7$?"@ D"@ZHv"@ D"@ʍ zǏ'D"@ DAq ɗeꪫdɁO͞=[n'>!t|s嶞,ӟdذa~V|I9蠃dE1#'pwkFnv,lG>|֬Y#~gҼϔѣ[V [9st2 Ex@ϕ+W ' cƌk;N}Cwcwi_9/)MPc6)馛~_˨QgG}T>я^NoB,ƍs=Wo??Ǐ/}K/~1ѻvgիcezT[blrg3hK`9}eWz\ԵxbY`ᄅ?i&-o|#S%tB瞓 &7MeOxVG#t`v"_YUwe?VTkTξ.Do+{Jկvn,1k. Y7|,]TŭK,wQ}bnM^}U1bDFL4I uD/K&S!~_9# .Յ*;uTy^x$X@)wttx [o5 ( zb" / wygj?*'nݺw̚5Kvyg;wlv>Ԑ"pW\GHjl6$@IXt&m˨i}& и[q\t\ ͓'n zj y݂SI:(?bGLg}VU`y׻HБT]{TQm K)T}T.b@0dH.Pd~aC}o ^<|vuu)L0]Omݶv/f+`μ+hVі72d9ԷÇUoF_*6lPOӵovcN:$9XA/ĬRCfկ~UN;4Tя~g6@CW1vLc'tU?L_wf̘ d.RҤ/<WDOr~oCdt˼dO~b 7l f}6˖-Z;8X*a꥗^R3aW۰'h3 O!Rk&t1^*o,C}N T~QtVö!~MH l"a 9@uwjFڅB `?$81FavxG;#2 C!S wx@fuK_?o|C6a A}"/PXa &Hhce<00[n"]HH5Aܠ Klht r)78(8A8I8>8#mwh7b%ڌw!FB_;~D645|4ֵ"W$ T$Q }A`_0^!7x6#F|ۅ!G;n14+ vۗvG"@__s&)Ha}8.yx${BuC#S$!5AG,{ 7(? ΈqMۂ q;ލxۅx1 C>!6Dt#b## }D\Ž>AOa~=GU,E;0 zw[NJ0h; Y;OPC2 …j\fg(BqGtoUA A1J !PFWR߃N^;rAI F@ax`!8H=P\]G E\z,1a¸"å,p z+pV =p4 BN3u/q$@AAPņCCӁ0~ $S!*q!h3D ASVz2{8M$pa6 t ̼hat`g0_"GK2p )"&$.$&1.:X}3AC=ȂHW\tgmQ"+_yG FڼDnoY'HPioAS>> 6? .M'H]68 l&~ #?.HaV .V|1څ$͈10D~#)0 1<ڏEt$0 "C1& hMG=%D IyYmu}=оz;g"Ɇ=A";P10 CP)Mo`A+:;ct2 B :T0#A=:tQ;*4G([&#a :}K.b\a0Kq@^$#EеcZؠx t]yDYߠ+)A()dP]zc2ADda>H#F A H<=!bW>lePJdۑD̙3B묳RJ ;8g}@՛ 4p {i0b@@ H :*VT G@1SW_$A 3d"޹tU$_p@W tIBL} A0CuS*L;'*cp w8fM8)h Ȁ@@Co]EP/-A`ELf`d>L o`||,~ɅC|?quzD{FSN#4p]MCG2L'̴ǷN9LGp!pӳʐӛ"釄@OB`6 f AD&v vp~Ba{`5Ea &򡽚T wq/A^hu}c&.~5=B[#"o,*?|qwww&nCp gY<}!aq'WyLOz&=:'{U_W{os3.SWD~0^rm@>3j kԗ<[C\0aoMW7k&[< <עn9u <R&:q,DLJ`QY#>khcG3G3G^}41d @g<B m yxYWcʅQ l8+ 6㋱okw|̗ż;9O်4Đsb+~VصVW?Bx`TZneqo@ϧYhc.zz iVȤdA۰\c33Y#l*>{ IDAT4@" ,`7y_ K!  Vk,Y#|Xd5mw8@^sM&7 t<,c4(ǁ=”҃޵'zk•"V&kk=\<<3#"ƳCqF)3(U!"#U@M8oN4%s8(¿ZlGcd^dស='sd]6E2g䒬'"Ssc& t *,YT{%k-4{FLqDγy6 ҌwO"IS*Q^NOgw6YW!\Ez'2\{Jf@aaQNhX= tUeqaYN/a0.XYвV8"!<(y39,,$G40@:lb/jy@r,x_~1"j t~GXNL/o]``i?2Ƕt) tf(sB1v@g<"xd~Dhf}ĺ[#k<<z5 s=#/]יּ$xe1h$  H@@ (MI@ݖh mm$  H@@Pw*n/& te {]$  4?z59ؗ^-{rTjH@$  ta .yV]$  H@zK[" H@$  H@] wU$  H@$ C@}ҖH@$  H@@&@ocZk6nm6hР8㣏>[/^xx7cM6|W_}^zilf1S4Ok%\9|u5:;7|s/qC.7s=c'kxR H@$ !@~s^Zb;?5wx㍘wyhgϞ+}o6^xXr%k_~|0_~cl]tQl~=?[c5LƏ 7ܰz1vX<buH}tP1sv5<$  H@@PW|.쒼ڹt@GDꫯ.ė_~9R /0/Rͻ#~-v c1F܁ NEF mO?t2uTQwY+ H@:wߍm&ӷxA'?k&{kbk9'|{l+1\sE>}>駟{W\qE1&Oz+cM7?z꩸w|N.m>#o:+_{kV.)kVM|XYf%]BG"fuw}c9H__sI]O?}2G91ǎ;R:묓gyx11X~>EY$+GIchw*X|v-mm#L[\ajVa?|218_1$0xcLdMy=9sy祶0L8Q91*QޱV@$  t# Bg~1`P!o1 g=-X,ֈ & ѣG|)\7fa+!r͢aB;HK }K/c5VZ̏>)lq?#P GlIOXD3ʂ@\#s;ƒzGxlƉ{gX`dtIS=Yft=@؟|ɱ{˜r)Gc7tSb{.&dԞ)2rypF'|2A/Fl@(>CWMaeFئF$ c39 0!Zj$ ?^@1쪫JS-bNCz{Nt csB{yM4Q5(Dp,!#>A% t~iIe.0żA^ 橼k=sN:qihcHT@qNkKx$  H@@Uǃ^-Ghc'pB&=C)YGE,38c+~":/E9^h'_,z~k!$CW~U;{nZ诰 ~x ?c%-\2HOC9;gc\,8=@" . y9q(ĻJ+sY#KM(y C 7DhD ]0sg&5"{Q>"XZ!OQT2@c u]7O9 R(й_Disf QO/0+/-[lE24`{3.[D0Eh`hc<20$0Qzjil$  H@d ::x)J<"\,K[/m7 a˵ MeMi@BsQdz>dȐ~OhqbiMcCK(  lSyIGtx_4z!mӺd %  H@@PWየJx+{٣׬@'Lp6,ˌ+V͗l@'ix$};餓qNˌ`ƃVYgo9xU&Zz:m&bO8{ 饞N:3<#x2k t<b"YyC$ilcXY*ٶZ}F7'~%(νG:ʰ/?}Dz)sm%6O<1E.ұ" H@$X *<ËNA豿sDcIFkE1X+0vIB8 -YY)d.zɼ@ 4?#+9}\H5hРv t~8 Qsk tH@1YsPP/Cʳ5;H^k\W$qr :9'  z?$ڋ﹏ Ixeb~ [Gt5y{ F;?sI-αiW`HBK$  H@@c ([&T13󈺀79e*dg,jᧄBS%FԏmsmIW |zkuGpC^}`>e> #pss|k0Qz^4 y$:Ξ_$  Lm[%  H@$  Hi (Лk$  H@$  Lm[%  H@$  Hi (Лk$  H@$  Lm[%  H@$  Hi (Лk$  H@$  Lm[%  H@$  Hi (Лk$  H@]СC㏏^zŪZC K/6[,/GϞ=cqƉ+|ӧO7x]('x]` @J@$5|g{E=FI⋘|cO+/ H@@5GqDllo%ӤN{w?ƭJ8㌱dM#M74~b>3믟'{ .l|W{,O?>tUqꩧkFSO>$n0j t۷rKKt+>zu4hP<1D%q1ƈc=6}{ wज़k }ZjG4b<2o}-7|f uK'@}l %  H@ݎ\0> /4Lna?~ qvi+>( _=p",Ku'1CK,Ip F~N;؟dI1=\:~u%ư0 3cϧ6C٫J+Ÿ㎛UW]34S gn_|q￟\믿N"~F~jmQ n7b:AƎzw8?ӖK'@}l %  H@ݎ9;so‚v&ĝ.u_j'W\1^x9{7Ol}&㎋C=4y'p5'dsc^k=xps>[kvk`gyfƅZ+n=ꫯsϝK<ۮ{=ntx38vguV! (лB H@@#@8 ,Ln>$Ot.:q.컾馛Rb4H,L eυ5\3#)/b`s\ ᧄRrj$qxDP)d:R[/;X|wzADڞxlx+c-;/vaﵒPm$  H裏.(JR3 t񜳷<#nɦ>묳&V[sy-L:n_r%Y)3^ y]O9唴7EVRWqX2wq-"@/WZ H@@ @7D5 `}exds=I:4GJq<㄂~饗&.:#J_x)Q!$b#5{ٻN^z~ $ӧO3!${Sh1u'@{IŞ֊[ NoqkMsR޽(rruzz'@|^Q$ ${?Nf\x)B׿BB;"?ByJgϞ)l%-'ǣ㱜w~[@o}x7j% D  H@$@b_|O>y~q'W_~% 7x#ZhtjSO= /ptAѷoOc)p|M;˸:z^@]c+u$  H@%@I'v[qǿ|[ ^IDATu1h?xc9Cc9&]7ߌ/<8s1;U?쳱z% 4#v}خGK@$ PWwC=8XcXr%S=?dT}c*п꫸[ދ=z+>zƯ ~8&hă:1P.osϝB }'maێ<$  H@hz"bs?0xbiI^ 70 ?i6裏~Sxy ." xg8Ӓ!w9wt^΅_~w^{%?$$sž;\u]{lfat<S!U_iRg}xגGm6.?sBW?h wK€{߯@o#vlW$  H( x?a~H:efow\K-T<+x@C|͓'a\rIkɓ=&9!>kA돇ߚ@_guOv^s5&ęgk׺_}o)q nVZa(0ĽWc۱혐$  H@N@^!X`ocvK^o<ѹTdJs<Ĺ ho t|'N"weI*oI~WFlIJuS\Ǟ{B\s^;,_|kzA 8n$  H@@ (ЫD}qE{V ƫM& ;s΅[:IoV霹6ι ܯJ?N/5^w l7J#Գ={sZkn?g$"ԟz[Iy\GplNױݑw疀$  H@'@oN&\s͕:Ζ_.b)웽9ȓL [}~}Oop5!);';Y)J񠳷7>?';<@{'{[+ FXmva]$  <z!B_u7s=7?Yv>$I#!b|Ȑ!8qSO]S{ysgyꔻ;eaO< GTK{<Թwx{4ڞ Fx1Psj}g99G>$  H# (Ыewߍ)%[>ANv)b:^^#g-V 8 H@$;M$  H@JC@^$  H@$;M$  H@JC@^$  H@$;M$  H@JC@^$  H@$;M$  H@JC@^$  H@$;M$  H@JC@^$  H@$;M$  H@JC@^$  H@$;M$  H@JC@^$  H@$;M$  H@JC@^$  H@$;M$  H@JC@^$  H@$;M$  H@JC@^$  H@$;M$  H@JC@^$  H@$;M$  H@JC@^$  H@$;M$  H@JC@^$  H@$;M$  H@JC@^$  H@$;M$  H@JC@^$  H@$;M$  H@JC@^$  H@$;M$  H@JC@^$  H@$;M$  H@JC@^$  H@$;M$  H@JC@^$  H@$;M$  H@JC@^$  H@$;M$  H@JC=Co_6IENDB`FVS-0.3.4/fvs/000077500000000000000000000000001426743503100127575ustar00rootroot00000000000000FVS-0.3.4/fvs/__init__.py000066400000000000000000000000001426743503100150560ustar00rootroot00000000000000FVS-0.3.4/fvs/cli.py000066400000000000000000000103321426743503100140770ustar00rootroot00000000000000import os import sys import argparse import datetime import contextlib from fvs.repo import FVSRepo from fvs.exceptions import FVSNothingToCommit, FVSEmptyCommitMessage, FVSStateNotFound, FVSNothingToRestore version = 'FVS 0.3.4' def fvs_cli(): parser = argparse.ArgumentParser(description='FVS') parser.add_argument('-v', '--version', action='version', version=version) subparsers = parser.add_subparsers(dest='command', help='sub-command help') init_parser = subparsers.add_parser("init", help="Create a new FVS repository") init_parser.add_argument('-i', '--ignore', help='patterns to ignore', action='append', default=[], required=False) init_parser.add_argument('-p', '--path', help='path to the repository', default=os.getcwd()) init_parser.add_argument('-c', '--use-compression', help='use compression', action='store_true', default=False) commit_parser = subparsers.add_parser("commit", help="Commit changes to the repository") commit_parser.add_argument('-i', '--ignore', help='patterns to ignore', action='append', default=[], required=False) commit_parser.add_argument('-m', '--message', help='commit message', nargs='+', required=True) states_parser = subparsers.add_parser("states", help="List all states in the repository") restore_parser = subparsers.add_parser("restore", help="Restore a state from the repository") restore_parser.add_argument('-i', '--ignore', help='patterns to ignore', action='append', default=[], required=False) restore_parser.add_argument('-s', '--state-id', help='state id', required=True) args = parser.parse_args() if args.command == 'init': repo = FVSRepo(args.path, args.use_compression) with contextlib.suppress(FVSNothingToCommit): repo.commit("Init", args.ignore) sys.stdout.write("Initialized FVS repository in {}\n".format(args.path)) sys.exit(0) elif args.command == 'commit': repo = FVSRepo(os.getcwd()) message = ' '.join(args.message) try: sys.stdout.write("Committing...\n") res = repo.commit(message, args.ignore) sep = "-" * 10 sys.stdout.write("\nCommitted state {}\nMessage: {}\nDate: {}\n{}\nAdded files: {}\nRemoved files: {}\nModified files: {}\nIntact files: {}\n".format( res['state_id'], res['message'], datetime.datetime.fromtimestamp(res['timestamp']).strftime('%Y-%m-%d %H:%M:%S'), sep, res["added"], res["removed"], res["modified"], res["intact"] )) sys.exit(0) except FVSNothingToCommit: sys.stderr.write("Nothing to commit\n") sys.exit(1) except FVSEmptyCommitMessage: sys.stderr.write("Empty commit message\n") sys.exit(1) elif args.command == 'states': repo = FVSRepo(os.getcwd()) if len(repo.states) == 0: sys.stdout.write("No states\n") sys.exit(0) for k, v in repo.states.items(): sym = "\033[32m➔" if k == repo.active_state_id else "-" sys.stdout.write( "{} ({}): {}\n\t{}\n\n\033[0m".format( sym, k, v["message"], datetime.datetime.fromtimestamp(v["timestamp"])) ) sys.exit(0) elif args.command == 'restore': repo = FVSRepo(os.getcwd()) try: repo.restore_state(args.state_id, args.ignore) sys.stdout.write("Restored state\n") sys.exit(0) except FVSStateNotFound: sys.stderr.write("State {} not found\n".format(args.state_id)) sys.exit(1) except FVSNothingToRestore: sys.stderr.write("Nothing to restore from state {}\n".format(args.state_id)) sys.exit(1) elif args.command == 'active': repo = FVSRepo(os.getcwd()) if repo.active_state_id in [-1, None]: sys.stdout.write("No active state\n") sys.exit(0) sys.stdout.write("Active state is {}\n".format(repo.active_state_id)) sys.exit(0) else: parser.print_help() sys.exit(1) if __name__ == '__main__': fvs_cli() sys.exit(0) FVS-0.3.4/fvs/data.py000066400000000000000000000170251426743503100142470ustar00rootroot00000000000000import os import orjson import logging from typing import Union from fvs.exceptions import FVSDataHasNoState, VFSTransactionAlreadyStarted logger = logging.getLogger("fvs.data") # noinspection PyTypeChecker class FVSData: __data_conf: dict = None __data_conf_path: str = None __data_int_paths: list = [ "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "-" ] __state: 'FVSState' = None __transaction: list = None __transaction_type: int = None # 0: add, 1: remove def __init__(self, repo: 'FVSRepo', state: 'FVSState' = None): """ Initialize the FVSData. """ self.__data_path = os.path.join(repo.repo_path, ".fvs/data") self.__state = state self.__update_fvs_path() self.__load_config() def __update_fvs_path(self): """ Update the structure of path data/ in the FVS repository. This also performs some checks to ensure that the data/ directory is valid, fixing it if necessary. """ self.__data_conf_path = os.path.join(self.__data_path, "data.json") """ This check if the data/ directory exists. If not, create it. """ if not os.path.exists(self.__data_path): os.makedirs(os.path.join(self.__data_path, dir)) """ This check if internal paths (__data_int_paths) exist. If not, create them. """ for int_path in self.__data_int_paths: if not os.path.exists(os.path.join(self.__data_path, int_path)): os.makedirs(os.path.join(self.__data_path, int_path)) """ Check if the data.json file exists. If not, create it and write the default configuration. """ if not os.path.exists(self.__data_conf_path): with open(self.__data_conf_path, "wb") as f: self.__data_conf = {} f.write(orjson.dumps(self.__data_conf, f, option=orjson.OPT_NON_STR_KEYS,)) def __load_config(self): """ Load the data configuration from the data/data.json file. """ with open(self.__data_conf_path, "r") as f: self.__data_conf = orjson.loads(f.read()) def __save_config(self): """ Save the data configuration to the data/data.json file. """ with open(self.__data_conf_path, "wb") as f: f.write(orjson.dumps(self.__data_conf, f, option=orjson.OPT_NON_STR_KEYS,)) def complete_transaction(self): """ Complete the transaction duplicating the files in the proper internal data path. It also saves the configuration to the data/data.json file. """ if self.__transaction is None: return # it's safe to ignore this call, the state is probably only removing files for file in self.__transaction: _int_path = self.get_int_path(file.file_name) if self.__transaction_type == 0: file.copy_to(_int_path) elif self.__transaction_type == 1: file.remove(_int_path) self.__save_config() def get_int_path(self, file_name: str) -> str: """ This simple method determines the internal path of a file based on the first letter of the file name. Every file starting with a special character will be placed in the "-" directory. """ first_letter = file_name[0].lower() int_path = "-" if first_letter in self.__data_int_paths: int_path = first_letter return os.path.join(self.__data_path, int_path) def __set_transaction_type(self, type_id: int): if self.__transaction is None: self.__transaction = [] if self.__transaction_type is None: self.__transaction_type = type_id elif self.__transaction_type != type_id: raise VFSTransactionAlreadyStarted() def add_file(self, file: 'FVSFile'): """ This method add a file to the catalog and append it to the transaction list. Files already in the catalog will be updated listing the new state for the deduplication. A FVSFile object is needed for the 'file' parameter. ... Raises: FVSDataHasNoState: if the state is not set. """ if not self.__state: raise FVSDataHasNoState() self.__set_transaction_type(0) if file.sha1 not in self.__data_conf.keys(): logger.debug(f"Adding file {file.file_name} to data catalog.") self.__data_conf[file.sha1] = { "file_name": file.file_name, "sha1": file.sha1, "states": {str(self.__state.state_id): 1} } self.__transaction.append(file) elif str(self.__state.state_id) not in self.__data_conf[file.sha1]["states"]: logger.debug(f"Adding state {self.__state.state_id} to file {file.file_name} in data catalog.") self.__data_conf[file.sha1]["states"][str(self.__state.state_id)] = 1 self.__transaction.append(file) else: logger.debug(f"File {file.file_name} already in data catalog.") self.__data_conf[file.sha1]["states"][str(self.__state.state_id)] += 1 def delete_file(self, file: 'FVSFile', state_id: int = None): """ This method delete a state for a file in the catalog, it will remove the file entry if the state is the last one. If state_id is not set, the one defined in the FVSFile object will be used, assuming it was the intended state. ... Raises: FVSDataHasNoState: if the state is not set. """ if state_id is None and not self.__state: raise FVSDataHasNoState() if state_id is None: state_id = self.__state.state_id self.__set_transaction_type(1) if file.sha1 in self.__data_conf.keys(): if str(state_id) in self.__data_conf[file.sha1]["states"]: logger.debug(f"Unlinking state {state_id} from file {file.file_name} in data catalog.") self.__data_conf[file.sha1]["states"][str(state_id)] -= 1 if self.__data_conf[file.sha1]["states"][str(state_id)] == 0: logger.debug( f"{file.file_name} reached 0 for state {state_id}. Removing state reference.") del self.__data_conf[file.sha1]["states"][str(state_id)] self.__transaction.append(file) if len(self.__data_conf[file.sha1]["states"]) == 0: logger.debug(f"{file.file_name} reached 0 for all states. Removing from data catalog.") del self.__data_conf[file.sha1] self.__transaction.append(file) else: logger.debug(f"File {file.file_name} has no state {self.__state.state_id} referenced. Ignoring.") else: logger.debug(f"File {file.file_name} is not in data catalog. Ignoring.") def get_file_location(self, sha1: str) -> Union[str, None]: """ This method returns the location of a file in the data catalog. """ if sha1 in self.__data_conf.keys(): file_name = self.__data_conf[sha1]["file_name"] return self.get_int_path(file_name) else: logging.debug(f"File {sha1} is not in data catalog.") return None FVS-0.3.4/fvs/exceptions.py000066400000000000000000000102211426743503100155060ustar00rootroot00000000000000class FVSException(Exception): """ Base class for all FVS exceptions. """ def __init__(self, message: str = "An unknown error occurred."): super().__init__(message) class FVSStateNotFound(FVSException): """ Exception raised when a state is not found. """ def __init__(self, state_id: int): super().__init__("No state found for ID: {}".format(state_id)) class FVSEmptyStateIndex(FVSException): """ Exception raised when the state index is empty. """ def __init__(self, state_id: int): super().__init__("Index is empty for state ID: {}".format(state_id)) class FVSMissingStateIndex(FVSException): """ Exception raised when a state index is not found. """ def __init__(self, state_id: int): super().__init__("State index not found for state with ID: {}".format(state_id)) class FVSFileNotFound(FVSException): """ Exception raised when a file is not found. """ def __init__(self): super().__init__("File not found in the state.") class FVSFileAlreadyExists(FVSException): """ Exception raised when a file already exists. """ def __init__(self): super().__init__("File already exists.") class FVSNothingToCommit(FVSException): """ Exception raised when there is nothing to commit. """ def __init__(self): super().__init__("Nothing to commit.") class FVSEmptyCommitMessage(FVSException): """ Exception raised when the commit message is empty. """ def __init__(self): super().__init__("Commit message is empty.") class FVSCallerWrongClass(FVSException): """ Exception raised when the caller is not the expected class. """ def __init__(self, cls: str = "FVSRepo"): super().__init__("Caller is not the expected class: {}".format(cls)) class FVSWrongUnstagedDict(FVSException): """ Exception raised when the unstaged_files dict is not correct. """ def __init__(self): super().__init__("The unstaged_files dict is not correct (following\ keys are required: added, modified, removed, intact).") class FVSUnsupportedKey(FVSException): """ Exception raised when the has_relative_path method was requested with a wrong key. """ def __init__(self, supported_keys: list): super().__init__("The has_relative_path method was requested with a \ wrong key, the following are corrected: {}.".format(supported_keys)) class FVSCommittingToExistingState(FVSException): """ Exception raised when committing to an existing state. """ def __init__(self): super().__init__("You were trying to commit to an existing state. \ This is not allowed and states are not meant to be altered \ once the first commit has been made.") class FVSDataHasNoState(FVSException): """ Exception raised when the state data has no state. """ def __init__(self): super().__init__("FVSData was initialized without a FVSState, \ but a FVSState was expected to perform transactions.") class VFSTransactionAlreadyStarted(FVSException): """ Exception raised when a transaction is already started. """ def __init__(self): super().__init__("A different kind of transaction is already started.") class FVSWrongSortBy(FVSException): """ Exception raised when a sort_by parameter is not correct. """ def __init__(self, allowed_sort_by: list): super().__init__("The sort_by parameter is not correct. \ It should be one of the following: {}".format(allowed_sort_by)) class FVSNothingToRestore(FVSException): """ Exception raised when there is nothing to restore. """ def __init__(self): super().__init__("Nothing to restore.") class FVSStateZeroNotDeletable(FVSException): """ Exception raised when a state with ID 0 is not deletable. """ def __init__(self): super().__init__("State with ID 0 is not deletable.") class FVSStateAlreadyExists(FVSException): """ Exception raised when a state already exists. """ def __init__(self, state_id: int): super().__init__("State already exists with ID: {}".format(state_id)) FVS-0.3.4/fvs/file.py000066400000000000000000000137451426743503100142620ustar00rootroot00000000000000import os import shutil import tarfile import logging logger = logging.getLogger("fvs.file") # noinspection DuplicatedCode class FVSFile: def __init__(self, repo: 'FVSRepo', file_name: str, sha1: str, relative_paths: list): self.__repo = repo self.__file_name = file_name self.__sha1 = sha1 self.__relative_paths = relative_paths def is_equal(self, file: 'FVSFile') -> bool: return self.__sha1 == file.get_sha1() def as_dict(self) -> dict: return { "file_name": self.__file_name, "sha1": self.__sha1, "relative_paths": self.__relative_paths } def copy_to(self, dest: str, use_sha1_as_name: bool = True): """ This method opy the file to the given destination. Despite it looks flexible, it is meant to be used only by FVSData to copy files to the appropriate data location, for this reason use_sha1_as_name is set to True by default (data files must be stored with their sha1 hash as name to avoid name collisions). This method use copy2 to copy the file, so it will preserve the file metadata. """ if self.__repo.has_compression: self.__compress_copy_to(dest, use_sha1_as_name) return if use_sha1_as_name: _dest = os.path.join(dest, self.__sha1) _name = self.__sha1 else: _dest = os.path.join(dest, self.__file_name) _name = self.__file_name """ There never should be a file with the same name and FVSData should already check for duplicates. Anyway, we will check for it here just in case. """ if os.path.islink(_dest) or os.path.exists(_dest): logger.debug(f"File {self.__sha1} already exists in {dest}.") return """ We will move only the first relative path as the file is supposed to be the same in all relative paths. """ logger.debug(f"Copying file {_name} to {dest}") shutil.copy2( os.path.join(self.__repo.repo_path, self.__relative_paths[0]), _dest, follow_symlinks=False ) def remove(self, path: str, use_sha1_as_name: bool = True): """ This method will remove the file from the internal data directory. """ if use_sha1_as_name: file_path = os.path.join(path, self.__sha1) else: file_path = os.path.join(path, self.__file_name) if os.path.exists(file_path): logger.debug(f"removing file {self.__file_name} from {path}") os.remove(file_path) else: logger.debug(f"file {self.__file_name} does not exist, data catalog may be corrupted.") def restore(self, internal_path: str): """ This method will restore the file, copying from the internal data directory to the repo, renaming it to the original name. """ if self.__repo.has_compression: self.__compress_restore(internal_path) return file_path = os.path.join(internal_path, self.__sha1) if not os.path.exists(file_path): logger.debug(f"file {self.__file_name} does not exist, data catalog may be corrupted.") return for relative_path in self.__relative_paths: dir_name = os.path.dirname(os.path.join(self.__repo.repo_path, relative_path)) logger.debug(f"restoring file {self.__file_name}") os.makedirs(dir_name, exist_ok=True) shutil.copy2( file_path, os.path.join(self.__repo.repo_path, relative_path), follow_symlinks=False ) def __compress_copy_to(self, dest: str, use_sha1_as_name: bool = True): """ This method will copy the file to the given destination, compressing it. """ if use_sha1_as_name: _dest = os.path.join(dest, self.__sha1) _name = self.__sha1 else: _dest = os.path.join(dest, self.__file_name) _name = self.__file_name """ There never should be a file with the same name and FVSData should already check for duplicates. Anyway, we will check for it here just in case. """ if os.path.islink(_dest) or os.path.exists(_dest): logger.debug(f"File {self.__sha1} already exists in {dest}.") return """ We will move only the first relative path as the file is supposed to be the same in all relative paths. """ logger.debug(f"Compressing file {_name} to {dest}") with tarfile.open(_dest, "w:gz") as tar: tar.add( os.path.join(self.__repo.repo_path, self.__relative_paths[0]), arcname=_name ) def __compress_restore(self, internal_path: str): """ This method will restore the file, decompressing it and copying it to the repo. """ file_path = os.path.join(internal_path, self.__sha1) if not os.path.exists(file_path): logger.debug(f"file {self.__file_name} does not exist, data catalog may be corrupted.") return for relative_path in self.__relative_paths: full_rel_path = os.path.join(self.__repo.repo_path, relative_path) dir_name = os.path.dirname(full_rel_path) logger.debug(f"restoring file {self.__file_name}") os.makedirs(dir_name, exist_ok=True) with tarfile.open(file_path, "r:gz") as tar: tar.extractall(dir_name) os.rename( os.path.join(dir_name, self.__sha1), full_rel_path ) @property def file_name(self) -> str: return self.__file_name @property def sha1(self) -> str: return self.__sha1 @property def relative_paths(self) -> list: return self.__relative_paths FVS-0.3.4/fvs/pattern.py000066400000000000000000000011421426743503100150040ustar00rootroot00000000000000import fnmatch import logging logger = logging.getLogger("fvs.pattern") class FVSPattern: @staticmethod def match(patterns: list, file_name: str) -> bool: """ This method will check if the file_name matches the pattern. It is currently just a wrapper around fnmatch.fnmatch. Here just for better code readability and future improvements. """ for pattern in patterns: if fnmatch.fnmatch(file_name, pattern): logger.debug(f"One pattern match: {file_name} matches {pattern}") return True return False FVS-0.3.4/fvs/repo.py000066400000000000000000000423501426743503100143020ustar00rootroot00000000000000import os import time import orjson import shutil import logging from fvs.exceptions import FVSNothingToCommit, FVSEmptyCommitMessage, FVSStateNotFound, FVSMissingStateIndex, \ FVSNothingToRestore, FVSStateZeroNotDeletable, FVSEmptyStateIndex, FVSStateAlreadyExists from fvs.pattern import FVSPattern from fvs.state import FVSState from fvs.file import FVSFile from fvs.data import FVSData from fvs.utils import FVSUtils logger = logging.getLogger("fvs.repo") class FVSRepo: __repo_conf: dict = None __has_no_states: bool = False __use_compression = False __active_state: 'FVSState' = None def __init__(self, repo_path: str, use_compression: bool = False, no_init: bool = False): """ Initialize the FVSRepo. """ self.__repo_path = os.path.abspath(repo_path) self.__states_path = os.path.join(self.__repo_path, ".fvs/states") self.__use_compression = use_compression if not no_init: self.__update_fvs_path() self.__load_config() def __update_fvs_path(self): """ Update the path of the .fvs directory. This directory is not meant to be edited outside/manually. Here we just create it if it doesn't exist and try to fix it if wrong. """ dirs = [ ".fvs", ".fvs/states", ".fvs/data", ] repo_conf = os.path.join(self.__repo_path, ".fvs/repo.json") updated = False for _dir in dirs: if not os.path.exists(os.path.join(self.__repo_path, _dir)): os.makedirs(os.path.join(self.__repo_path, _dir)) updated = True if not os.path.exists(repo_conf): with open(repo_conf, "wb") as f: self.__repo_conf = {"id": -1, "states": {}, "compression": self.__use_compression} f.write(orjson.dumps(self.__repo_conf, f, option=orjson.OPT_NON_STR_KEYS,)) updated = True if updated: logger.debug(f"FVS path updated for repository {self.__repo_path}") def __load_config(self): """ Load the repository configuration. """ repo_conf = os.path.join(self.__repo_path, ".fvs/repo.json") with open(repo_conf, "r") as f: self.__repo_conf = orjson.loads(f.read()) """ JSON store int key as strings, so we need to convert them back to int. """ self.__states = {int(key): value for key, value in self.__repo_conf["states"].items()} if int(self.__repo_conf["id"]) >= 0: self.__active_state = FVSState(self, int(self.__repo_conf["id"])) else: self.__has_no_states = True self.__use_compression = self.__repo_conf["compression"] def get_unstaged_files(self, ignore: list = None, purpose: int = 0) -> dict: """ Get the unstaged files. ... Purpose values: 0: Committing a new state. 1: Restoring a state (will return original sha1 for modified files) """ unstaged_files = { "count": 0, "added": [], "removed": [], "modified": [], "intact": [] } active_state_files = {} if ignore is None: ignore = [] """ The following new variable is used to store all relative paths handled in the following loop. We will use them to list removed files. """ unstaged_relative_paths = [] """ Create a copy of the active state files so we can remove every handled entry and easily figure out what was deleted. """ if not self.__has_no_states: active_state_files = self.__active_state.files.copy() else: active_state_files = {"added": {}, "removed": {}, "modified": {}, "intact": {}} def del_active_state_file_key(sha1: str): active_state_files["added"].pop(sha1, None) active_state_files["modified"].pop(sha1, None) active_state_files["intact"].pop(sha1, None) for root, _, files in os.walk(self.__repo_path): """ Here we are excluding the .fvs/ directory from the unstaged files because we don't want to invoke the monster of loops. """ if ".fvs" in root.split(os.sep): continue """ Here we loop through the files and determinate which ones are added, removed, modified or intact, comparing with prior state or simply adding all of them if this is the first state. """ for file in files: _full_path = os.path.join(root, file) _relative_path = self.__get_relative_path(os.path.join(root, file)) """ Here we loop through the ignore pattern and remove the files that match any of them. Check if performed on the relative path. """ if FVSPattern.match(ignore, _relative_path): continue """ Here we calculate the sha1 of the file. It will return None if the file is not accessible or doesn't exist. """ _sha1 = FVSUtils.get_sha1_hash(_full_path) if _sha1 is None: continue _entry = { "file_name": file, "sha1": _sha1, "relative_path": _relative_path } unstaged_relative_paths.append(_relative_path) """ If this is the first state, just add all files. """ if self.__has_no_states: unstaged_files["added"].append(_entry) unstaged_files["count"] += 1 continue """ Assuming this is not the first state, we need to check if the file is added, removed, modified or intact. """ if self.__active_state.has_file(_sha1, _relative_path): unstaged_files["intact"].append(_entry) elif orig := self.__active_state.has_relative_path(_relative_path): if purpose == 1: _sha1 = orig["sha1"] unstaged_files["modified"].append({ "file_name": file, "sha1": _sha1, "relative_path": _relative_path }) unstaged_files["count"] += 1 print(f"{_relative_path}is modified") else: unstaged_files["added"].append(_entry) unstaged_files["count"] += 1 unstaged_relative_paths.append(_relative_path) if not self.__has_no_states: for file in list(active_state_files["added"].values()) + \ list(active_state_files["modified"].values()) + \ list(active_state_files["intact"].values()): for relative_path in file["relative_paths"]: if relative_path not in unstaged_relative_paths: unstaged_files["removed"].append({ "file_name": file["file_name"], "sha1": file["sha1"], "relative_path": relative_path }) unstaged_files["count"] += 1 return unstaged_files def commit(self, message: str, ignore: list = None) -> dict: """ Commit the current state. This is a wrapper around the commit method of the FVSState class. A wrapper is used to store the state message and process staged files before committing. ... Raises: FVSEmptyCommitMessage: If the message is empty. FVSNothingToCommit: If there are no unstaged files. """ if message in [None, ""]: raise FVSEmptyCommitMessage() unstaged_files = self.get_unstaged_files(ignore) if unstaged_files["count"] == 0: raise FVSNothingToCommit() # Create a new state state = FVSState(self) state.commit(message, unstaged_files) self.__states[state.state_id] = { "message": message, "timestamp": time.time() } self.__active_state = state self.__update_repo() return { "state_id": state.state_id, "message": message, "timestamp": time.time(), "added": len(unstaged_files["added"]), "removed": len(unstaged_files["removed"]), "modified": len(unstaged_files["modified"]), "intact": len(unstaged_files["intact"]) } def delete_state(self, state_id: int, update_repo: bool = True): """ Delete a state and all its subsequent states. ... Raises: FVSStateZeroNotDeletable: If the state_id is 0. FVSStateNotFound: If the state doesn't exist. """ if int(state_id) == 0: raise FVSStateZeroNotDeletable() if int(state_id) not in self.__states: raise FVSStateNotFound(state_id) """ Traveling in the future is probably something we don't want to do. So we will break references for subsequent states too. """ for _state_id in [state_id] + self.__get_subsequent_state_ids(state_id): state = FVSState(self, _state_id) state.break_references() """ If the state is the active state, we need to set the active state to the previous one. """ if self.__active_state.state_id == _state_id: self.__active_state = FVSState(self, self.__get_prior_state_id(_state_id)) """ Delete the state from the states folder. It should be safer now as we already unreferenced the state from all its files. """ self.__delete_state_folder(state) del self.__states[_state_id] if update_repo: self.__update_repo() def delete_active_state(self): """ Delete the active state. """ self.delete_state(self.__active_state.state_id) def restore_state(self, state_id: int, ignore: list = None): """ Restore the state with the given id. This will remove all unstaged files and restore the given state, deleting any subsequent states. ... Raises: FVSStateNotFound: If the state doesn't exist. FVSNothingToRestore: If there are no unstaged files. """ if int(state_id) not in self.__states.keys(): raise FVSStateNotFound(state_id) self.__active_state = FVSState(self, state_id) subsequent_state_id = self.__get_subsequent_state_id(state_id) unstaged_files = self.get_unstaged_files(ignore, purpose=1) if unstaged_files["count"] == 0: raise FVSNothingToRestore() """ If the given state has subsequent states, we need to delete them. The following call will start breaking references for the first subsequent state, FVSData will take care of the rest, physically deleting the files when the reference count reaches 0 (no state references). """ if subsequent_state_id is not None: self.delete_state(subsequent_state_id, False) """ Here we restore the situation to the given state, removing all unstaged files. """ fvs_data = FVSData(self) for file in unstaged_files["added"]: _file_path = os.path.join(self.__repo_path, file["relative_path"]) if os.path.isdir(_file_path): shutil.rmtree(_file_path) else: os.remove(_file_path) for file in unstaged_files["modified"]: internal_path = fvs_data.get_int_path(file["file_name"]) FVSFile(self, file["file_name"], file["sha1"], [file["relative_path"]]).restore(internal_path) for file in unstaged_files["removed"]: internal_path = fvs_data.get_file_location(file["sha1"]) FVSFile(self, file["file_name"], file["sha1"], [file["relative_path"]]).restore(internal_path) self.__update_repo() @staticmethod def __delete_state_folder(state: FVSState): """ Delete the state folder with the given id. """ shutil.rmtree(state.state_path) def is_valid_state(self, state_id: int) -> bool: """ Check if the state with the given id is valid. ... Raises: FVSStateNotFound: If the state with the given id does not exist. FVSMissingStateIndex: If the state with the given id is missing FVSEmptyStateIndex: If the state with the given id is empty. """ index_path = os.path.join(self.get_state_path(state_id), "files.json") if not os.path.exists(self.get_state_path(state_id)): raise FVSStateNotFound(state_id) if not os.path.exists(index_path): raise FVSMissingStateIndex(state_id) with open(index_path, "r") as f: index = orjson.loads(f.read()) if not index: raise FVSEmptyStateIndex(state_id) return True def __get_prior_state_id(self, state_id: int) -> int: """ Get the id of the prior state. ... Raises: FVSStateNotFound: If the state with the given id does not exist. """ if int(state_id) not in self.__states.keys(): raise FVSStateNotFound(state_id) for key in self.__states.keys(): if key < state_id: return key return 0 def __get_subsequent_state_ids(self, state_id: int) -> list: """ Get the ids of the subsequent states. ... Raises: FVSStateNotFound: If the state with the given id does not exist. """ if int(state_id) not in self.__states.keys(): raise FVSStateNotFound(state_id) subsequent_states = [] for key in self.__states.keys(): if key > int(state_id): subsequent_states.append(key) return subsequent_states def __get_subsequent_state_id(self, state_id: int) -> int: """ Get the id of the subsequent state. ... Raises: FVSStateNotFound: If the state with the given id does not exist. """ if int(state_id) not in self.__states.keys(): raise FVSStateNotFound(state_id) for key in self.__states.keys(): if key > int(state_id): return key return 0 def __get_relative_path(self, path: str) -> str: """ Get the relative path of the given files. """ repo_root = os.path.dirname(self.__repo_path) return os.path.relpath(path, self.__repo_path) def get_state_path(self, state_id: int) -> str: """ Get the path of the state with the given id. """ return os.path.join(self.__states_path, str(state_id)) def __update_repo(self): """ Update the repository configuration. """ repo_conf = os.path.join(self.__repo_path, ".fvs/repo.json") with open(repo_conf, "wb") as f: self.__repo_conf["id"] = self.__active_state.state_id self.__repo_conf["states"] = self.__states f.write(orjson.dumps(self.__repo_conf, f, option=orjson.OPT_NON_STR_KEYS,)) if self.__has_no_states: self.__has_no_states = False def new_state_path_by_id(self, state_id: int) -> str: """ Get the path of the state with the given id. ... Raises: FVSStateAlreadyExists: If the state already exists. """ state_path = os.path.join(self.__states_path, str(state_id)) if os.path.exists(state_path): raise FVSStateAlreadyExists(state_id) os.makedirs(state_path) return state_path @property def repo_path(self) -> str: """ Get the repository path. """ return self.__repo_path @property def states_path(self) -> str: """ Get the path of the states. """ return self.__states_path @property def next_state_id(self) -> int: """ Get the next state id. """ if self.__has_no_states: return 0 return list(self.__states)[-1] + 1 @property def active_state_id(self) -> int: """ Get the active state. """ if self.__active_state is None: return None return self.__active_state.state_id @property def states(self) -> dict: """ Get the list of states. """ return self.__states @property def has_compression(self) -> bool: """ Get the compression status. """ return self.__use_compression @property def has_no_states(self) -> bool: """ Get the no states status. """ return self.__has_no_states FVS-0.3.4/fvs/state.py000066400000000000000000000213311426743503100144510ustar00rootroot00000000000000import os import orjson import logging from fvs.exceptions import FVSCallerWrongClass, FVSEmptyCommitMessage, FVSWrongUnstagedDict, \ FVSStateNotFound, FVSCommittingToExistingState, FVSUnsupportedKey from fvs.data import FVSData from fvs.file import FVSFile from fvs.utils import FVSUtils logger = logging.getLogger("fvs.state") class FVSState: __files: dict = None __state_id: int = None __state_path: str = None def __init__(self, repo: 'FVSRepo', state_id: int = None): self.__repo = repo self.__files = {"count": 0, "added": {}, "modified": {}, "removed": {}, "intact": {}} if state_id is not None: self.__load_state(state_id) @classmethod def load(cls, repo: 'FVSRepo', state_id: int): """ This constructor will return a new FVSState object for the given state_id. """ return cls(repo, state_id) def __load_state(self, state_id: int): """ This method will load a state from the repository. """ self.__state_id = state_id self.__state_path = os.path.join(self.__repo.states_path, str(state_id)) if not os.path.exists(self.__state_path): raise FVSStateNotFound(state_id) with open(os.path.join(self.__state_path, "files.json"), "r") as f: self.__files = orjson.loads(f.read()) def commit( self, message: str, unstaged_files: dict ): """ States are supposed to be committed only from FVSRepo and only on first initialization. So here we will check if the caller is the expected class. """ if FVSUtils.get_caller_class_name() != "FVSRepo": raise FVSCallerWrongClass("FVSRepo") """ For the same reason above, we will check if the state were already initialized to avoid unwanted commits. """ if self.__is_initialized(): raise FVSCommittingToExistingState() """ As a rule in FVS, the commit message should not be empty. We don't want untraceable commits. """ if message in [None, ""]: raise FVSEmptyCommitMessage() """ To avoid further investigation and multiple checks, we will check for the unstaged files dict structure. It must contain the following keys: added, modified, removed. """ if False in [ unstaged_files.get("count"), unstaged_files.get("added"), unstaged_files.get("modified"), unstaged_files.get("removed"), unstaged_files.get("intact") ]: raise FVSWrongUnstagedDict() """ Set the state id with the next state id available in the repository. """ self.__state_id = self.__repo.next_state_id self.__files["count"] = unstaged_files["count"] """ Instantiate the FVSData class and start collecting the files. """ fvs_data = FVSData(self.__repo, self) for _file in unstaged_files["added"]: fvs_data.add_file(FVSFile(self.__repo, _file["file_name"], _file["sha1"], [_file["relative_path"]])) if _file["sha1"] in self.__files["added"]: self.__files["added"][_file["sha1"]]["relative_paths"] += [_file["relative_path"]] else: self.__files["added"][_file["sha1"]] = { "file_name": _file["file_name"], "sha1": _file["sha1"], "relative_paths": [_file["relative_path"]], } for _file in unstaged_files["modified"]: fvs_data.add_file(FVSFile(self.__repo, _file["file_name"], _file["sha1"], [_file["relative_path"]])) if _file["sha1"] in self.__files["modified"]: self.__files["modified"][_file["sha1"]]["relative_paths"] += [_file["relative_path"]] else: self.__files["modified"][_file["sha1"]] = { "file_name": _file["file_name"], "sha1": _file["sha1"], "relative_paths": [_file["relative_path"]], } for _file in unstaged_files["removed"]: fvs_data.delete_file(FVSFile(self.__repo, _file["file_name"], _file["sha1"], [_file["relative_path"]])) if _file["sha1"] in self.__files["removed"]: self.__files["removed"][_file["sha1"]]["relative_paths"] += [_file["relative_path"]] else: self.__files["removed"][_file["sha1"]] = { "file_name": _file["file_name"], "sha1": _file["sha1"], "relative_paths": [_file["relative_path"]], } for _file in unstaged_files["intact"]: if _file["sha1"] in self.__files["intact"]: self.__files["intact"][_file["sha1"]]["relative_paths"] += [_file["relative_path"]] else: self.__files["intact"][_file["sha1"]] = { "file_name": _file["file_name"], "sha1": _file["sha1"], "relative_paths": [_file["relative_path"]], } fvs_data.complete_transaction() self.__save_state() def break_references(self): """ This method ask to FVSData to remove the reference to the state for all the files, it will also physical delete the file if it has no other referenced states. """ if FVSUtils.get_caller_class_name() != "FVSRepo": raise FVSCallerWrongClass("FVSRepo") fvs_data = FVSData(self.__repo, self) for _file in self.__files["added"].values(): fvs_data.delete_file(FVSFile(self.__repo, _file["file_name"], _file["sha1"], _file["relative_paths"])) for _file in self.__files["modified"].values(): fvs_data.delete_file(FVSFile(self.__repo, _file["file_name"], _file["sha1"], _file["relative_paths"])) fvs_data.complete_transaction() def has_file(self, sha1: str, relative_path: str) -> bool: """ This method will check if the state has the given file. """ if sha1 in self.__files["added"]: if relative_path in self.__files["added"][sha1]["relative_paths"]: return True if sha1 in self.__files["modified"]: if relative_path in self.__files["modified"][sha1]["relative_paths"]: return True if sha1 in self.__files["intact"]: if relative_path in self.__files["intact"][sha1]["relative_paths"]: return True return False def __save_state(self): """ This method will save the state to the repository. """ state_path = self.__repo.new_state_path_by_id(self.__state_id) with open(os.path.join(state_path, "files.json"), "wb") as f: f.write(orjson.dumps(self.__files, f, option=orjson.OPT_NON_STR_KEYS,)) def __is_initialized(self) -> bool: """ This method will check if the state is initialized. """ try: self.__repo.is_valid_state(self.__repo.next_state_id) except FVSStateNotFound: return False return True def has_relative_path(self, relative_path: str, key: str = "any") -> bool: """ This method will return the entry from the state files which corresponds to the given file name. The 'any' key will check in added, modified and intact files. """ supported_keys = ["any", "added", "modified", "intact"] if key not in supported_keys: raise FVSUnsupportedKey(supported_keys) if key == "any": for file in self.__files["added"].values(): if relative_path in file["relative_paths"]: return file for file in self.__files["modified"].values(): if relative_path in file["relative_paths"]: return file for file in self.__files["intact"].values(): if relative_path in file["relative_paths"]: return file else: for _file in self.__files[key].values(): if relative_path in _file["relative_paths"]: return _file return None @property def files(self) -> dict: """ This method will return the files in the state. """ return self.__files @property def state_id(self) -> int: """ This method will return the state id. """ return self.__state_id @property def state_path(self) -> str: """ This method will return the state path. """ return self.__state_path FVS-0.3.4/fvs/utils.py000066400000000000000000000020421426743503100144670ustar00rootroot00000000000000import os import inspect import hashlib from typing import Union class FVSUtils: @staticmethod def get_caller_class_name() -> str: """ Get the name of the caller class. """ stack = inspect.stack()[2] caller = stack[0].f_locals.get('self').__class__.__name__ return caller @staticmethod def get_sha1_hash(path: str, block_size: int = 2 ** 20) -> Union[str, None]: """ Get the sha1 hash of the given file. It will use name+content to avoid empty files. """ sha1_temp = hashlib.sha1() file_name = os.path.basename(path) try: with open(path, "rb") as f: while True: buffer = f.read(block_size) if not buffer: break sha1_temp.update(buffer) sha1_temp.update(file_name.encode()) return sha1_temp.hexdigest() except (FileNotFoundError, PermissionError, IsADirectoryError): return None FVS-0.3.4/setup.cfg000066400000000000000000000007121426743503100140020ustar00rootroot00000000000000[metadata] name = FVS version = 0.3.4 author = Mirko Brombin author_email = send@mirko.pm long_description = file: README.md long_description_content_type = text/markdown url = https://github.com/mirkobrombin/FVS project_urls = Bug Tracker = https://github.com/mirkobrombin/FVS/issues classifiers = Programming Language :: Python :: 3 License :: OSI Approved :: MIT License Operating System :: POSIX :: Linux [options] python_requires = >=3.9FVS-0.3.4/setup.py000066400000000000000000000010241426743503100136700ustar00rootroot00000000000000from setuptools import setup setup( name='FVS', version='0.3.4', packages=['fvs'], url='https://github.com/mirkobrombin/FVS', license='MIT', author='Mirko Brombin', author_email='send@mirko.pm', description='File Versioning System with hash comparison, deduplication and data storage to create unlinked ' 'states that can be deleted ', entry_points={ 'console_scripts': [ 'fvs=fvs.cli:fvs_cli' ] }, install_requires=[ 'orjson' ] )