statbtrfs 0.30__tar.gz → 0.32__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: statbtrfs
3
- Version: 0.30
3
+ Version: 0.32
4
4
  Summary: Query Btrfs filesystem status
5
5
  Home-page: https://github.com/yufei-pan/statbtrfs
6
6
  Author: Yufei Pan
@@ -45,3 +45,9 @@ optional arguments:
45
45
 
46
46
  Install:
47
47
  sudo pip install statbtrfs
48
+
49
+ Development:
50
+ pip install -r requirements-dev.txt
51
+ pytest # unit tests (offline)
52
+ pytest --cov=statbtrfs --cov-fail-under=85
53
+ pytest -m live # optional status-only smoke on a btrfs host
@@ -15,4 +15,10 @@ optional arguments:
15
15
 
16
16
 
17
17
  Install:
18
- sudo pip install statbtrfs
18
+ sudo pip install statbtrfs
19
+
20
+ Development:
21
+ pip install -r requirements-dev.txt
22
+ pytest # unit tests (offline)
23
+ pytest --cov=statbtrfs --cov-fail-under=85
24
+ pytest -m live # optional status-only smoke on a btrfs host
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: statbtrfs
3
- Version: 0.30
3
+ Version: 0.32
4
4
  Summary: Query Btrfs filesystem status
5
5
  Home-page: https://github.com/yufei-pan/statbtrfs
6
6
  Author: Yufei Pan
@@ -45,3 +45,9 @@ optional arguments:
45
45
 
46
46
  Install:
47
47
  sudo pip install statbtrfs
48
+
49
+ Development:
50
+ pip install -r requirements-dev.txt
51
+ pytest # unit tests (offline)
52
+ pytest --cov=statbtrfs --cov-fail-under=85
53
+ pytest -m live # optional status-only smoke on a btrfs host
@@ -0,0 +1,19 @@
1
+ README.txt
2
+ setup.py
3
+ statbtrfs.py
4
+ statbtrfs.egg-info/PKG-INFO
5
+ statbtrfs.egg-info/SOURCES.txt
6
+ statbtrfs.egg-info/dependency_links.txt
7
+ statbtrfs.egg-info/entry_points.txt
8
+ statbtrfs.egg-info/requires.txt
9
+ statbtrfs.egg-info/top_level.txt
10
+ tests/test_cli.py
11
+ tests/test_color.py
12
+ tests/test_filter_recent.py
13
+ tests/test_import_seams.py
14
+ tests/test_live.py
15
+ tests/test_mounts.py
16
+ tests/test_parse_scrub.py
17
+ tests/test_parse_show.py
18
+ tests/test_parse_stats.py
19
+ tests/test_scrub_orchestration.py
@@ -8,92 +8,101 @@ import fnmatch
8
8
  from datetime import datetime
9
9
  try:
10
10
  import multiCMD # type: ignore
11
- assert tuple(int(part) for part in multiCMD.version.split('.')[:2]) >= (1, 47)
12
- except (ImportError, AssertionError, ValueError):
11
+ if tuple(int(x) for x in multiCMD.version.split('.')) < (1, 51):
12
+ raise ImportError('multiCMD too old')
13
+ except (ImportError, AttributeError, ValueError):
13
14
  import sys,types,base64,lzma # noqa: E401
14
15
  multiCMD = types.ModuleType("multiCMD")
15
16
  sys.modules["multiCMD"] = multiCMD
16
17
  _SRC_B85=r'''
17
- rAT%)=o\O*k16n/!WXAE$ig8-O-i5'i*R6=FBnkYLdFUh,;[5]%l=/aQgL]knEMJj2Td\)1[`bH(.0)JfPPs3eIh*#BVY2@n9=XkOcI@DKhS;F=XYf@.
18
- bkU"!;JY/2uZq5d6W*qmS$*M-\,Pd5gM:^G;SrceSRe-B2<VH6Ya:5DN5q#E@aeCT\u:L=Ahc3b=*V%>Cctj(2ncD(q47UQ/T['Tu4`n!B,&"9c;Dp?X
19
- $Mt3`>UhCLNd2)!\2+QeY[<Ld@R^mOoJ9%I_T>5@D.V'T`X>3j7@/UqE90=Li(ZK@\[6PX_i!c%e(RYrSgB-#(M%JD!R`+"%C.LPs4f6,NOi[csmhntR
20
- -llggcNB6JO`h#?qZ+3g)3@PanuB_`LSZf3g\r4ETI3taQ\$1Ip\?+6MIf8q"3KC5_L=fF[F^DA2H;Nq0Z^0f-*o;;IDIus:3#%ku1GW$!@?oF"1^\,$
21
- r+@;;eq>,?5Bkj<\ZW8g/bT=1TIGTc+V"f<;/]*IQbUA:\*1aM!+dN@VE3/j',.P9fWaeWVn6N(pB"uW50>`_a2sT$A&-qe[rC-j=$%Bo&psb,[EUBLB
22
- "#L:oQiq"!ho19f]h:Ir3:`gSZHXH7Y"^+n231\615&fFFL<l"-VWt<1;ammd-XZLfk<l6hB,mO[6b;cWQ.bPD<#OS6q'@1?eOlh0)HH*]2AN]bqVFCQ
23
- usE7CA#Y^M(<I]X:6[MIDAjI5(TSY`rN*mNY4OKL!^X%.&)0ccTsOUk,C*FW+Fk/TCIMSs'i6DH2:?<@&Wm?p#=.BG8G'"M:=`0Lu!!b+lki\TOFt2T%
24
- DEdZ/(>WOLt$FDWS'UUedNe#<aQNWMOfZO&h\sZQ?DdmY=.BG3U[MXg_2KcY,kQLJI*Gl*8N3nk\?HI@J8RTY"qE=fcpG2m!'q7Y-8t<EN[iBohQ(Hd.
25
- ;&3`T`Q\IcI9ACM%;Uoj^o@K_2:+&>!AWadU&N["c'-WC.CD3Cn#G;Ot=Z+g0+\,`H=?$M-n:Tbc\Gr&U4pf13\;c*r>6@I\:/$4D9mfoiFb=YKF^a]@
26
- 1EFY_[KhKKd-\?r'7\<,8PZ@A=#o5L1gdVtQ=CRZ_dM/Z#j'PJbHs,.n+cS#=Np7b%EoY^nCFWlQB"J<ld:l34]`ed$-Zm:)NSY$B2ifEjSU=MZ;&V%5
27
- `W$Cu<GY!UmQ7!uG'k4>"#4-,&)25T/PEJ*_S,0)*4d2*p8Q.[OeLs*@r/)pL4EP#IXhCrrO.MUoIE!9_qEi@Z*/!_=/bdKXg#GYdU>VUB,QX_c(9CWo
28
- iusRYl+dm]Enn!f-Bf.E3@5=<G@"^IY*u]EfG=!E%-lQaqYDnT&?3iC%SgYmXoOK:7%dSl[j,V&?^,[r?!DUoN2j9]C@G'A^6Em2B!8>SE(uXg$)E5:i
29
- #4/KXSDq=8_4^Ymk3rSg;nG<(5])2k90.:4FRh?]nQ4I)0FUH+C.NIK5b3^D,l_r<e_!']",DT$T=[^aE;dM+ihF.-:!jf@8`cXLiV_!#6@3rlQ\hf8I
30
- sTc&=1*BSIIB.jRF>K;YO#4r-oPYq2t]9nVe_6$JGGoSii$[%otAkd%W!]R"0.&J['!;EmF`EKBL'"K83-Aue]KW>CoNQ*FA1&nnE)ISY32n9u"E7Tk^
31
- I.cDHoQ8>nL_`>E16jAotl6sdA%e((c"->b=lgT!(k4Vq;Ds7redC[tr$^RP!W[An<V.k*1<RG4VW3N1<[(B.bhU5:]b&AFjPDR<Yj=mEa3OC!1Nn_@\
32
- )!e"gf@ljZ_fu,(`C2>W>"Gg\P%*fH8:0/2W=GrMf!Rs'hM/iKU4%^<O<dQ@?,%Rjp)cYJ6]WH7\@/LV86Zea&gI*:_m#q9bq8FSH/<T_4l&hrB__F4U
33
- n;RATdu`;%ejZG#\qd<?*GNtilO.kJ;TFqrN31b(6eml`h\!ZM8qZ^@>^?@LTI-kN&gm:.jPHH)%kF?hbBM9ou.^i'Mc_3?@-F;=S8B*(c?uYaaddX[>
34
- K$G9M,7eS;foJeoIa9P>8XqqS[j#?d!8dRnP)lO!rF(4G,sXRBr@8kM-lcep'"IHcG5E7Nd*M054%?pY=BJ1=`Rk2mT'k[C8BA,]o-pYe6L'/CEc"TDO
35
- "'l+)0oHG*rYmG<83"UrXe(;HP7rITEkc"gSh(.e"0m,%peMO%!p*fd2OrGYo<^n6F`]kiU:W_4G=i$:s0s7Q[cgf->X-jRQD/BnXm!!:KZeLm`uDbct
36
- Yeiu[+9g71mg3qQY#c_;@\(<f[fqoBOKJM/=kJE-*D!PKVVt&fE"!4Y.B@'MmGQ7lEQD,G+o-,V-l)XL$(KFlF/pdWF"68oZ@HTec2:c@<?6?_^qH8*$
37
- )^,aL`FYs<:eq>>7Q?I1JJ+Qu/ktpI`BU^5Y5b3p9+.5jb2ef1ngl;>_b"NR`gH:f(\dan_otEO>l3Y8@)#'bfM`9\\b;IR->j3-F]=!&.aT\jSF$6Vp
38
- (;20f*T9eC\;8_']b7XX+(f.T/?^7pmsWLS)\`V0&lE'Y@0QL#Pcd*,kZ*$]4/ko"'piq>8n-`9!f7OS&DD$Vhi[*=a$>on0bK_nIlgl0Xgaq1F@)c=-
39
- UP*D;T]74/BP&\o,k(RH&_,IVdIUSIun)[\gZRoFFpthI=Tke3H`uG%c+C`MOo&9KO`m%Ci8:Q?"tioWf5/7<@URNj.;8^@E8mm%M_GbgPcsZ5)q#=h8
40
- Lkp?g/8jW;B;?%]+S1cSC4ge6kJgGf[pGZQ,t#:VQk=\G<NO>s8'+cK>3L]D?2.W\r&q6S<Io=X1c0)uEa"nM)=+@1dT4'qfamV\e7bJ.^?^fO`ZcMC*
41
- [,D.o4_(gW3PF<fIY>9]p>RKY_Y]$<)X]!h\RJ-l<\HPEo9cRl=U7O-Xms2`NZ`@cj!?--E^efQg82eF:40_TFK7W8Z@D)eqP`/VMcSBY0J2L30;@96k
42
- Qu8&cClKj3[8[VEZe3@T?UqsRWPX1MjQ?2MPt;;/BhUaVLLnHV^eXYk'.&83SWe/=;*82@W.3-=EmE8@36-'to`IMp-3-WXZO3Je>e7D2H(]A)m6$h])
43
- &RaU?^3a04Us]eX8a;9U\ac*Bg3)m3LO1mq2>:\G+>BYES*$RGGOjKi^^4LL2NP`A2Zt2%[:Q>Oo>eFm)@'p#/9]]bSqB"7.X\)9ArC<s&`;&NCX%C(B
44
- 'Xp+O3iq$k>CN[^3`"R[5;M64)NpPCtht15H4!,I\r.#tnesTb^t2W2ml"#N+uKNHghY*WJUEmI2iu+3_74#]l?N&."u?_Nk!b-`TmK<&gU*MW`e)L,"
45
- C<?$q,CYiLU3K!#O<$%*U<Oe<MQlTUZMhEV:cb.o'T*UD6$huoD1&s_2m!78L:k[Z#[oOLaG#d419j"%lXGs4]:ZGF';l8tgFNBRB:7\(fdSMAj((N]K
46
- ui(Rh^4@7iTSad@FE4%NtD0[=^29uOnM14T@TKKq+*e+^_7N+6G@+0<LT2[`jC;/Ig+:hkYC'8)li+Cu&Ni$]/*Z6FIqY"__h,FRZLBkL%9u;ZJdTN*b
47
- $3TVDAfX#)nC:gcAe:ojjoh)-Ch=)0#:13_VX4+ml<k-3/[o5T;2&M4e1?_;SsJbGrrMU`)kDo-3,Z(o]&/?ZnJ`GFT\0bJ5N8?tQNjE$83Tq=.3^QeR
48
- o)A8$eTTSoe*<7jF->T,(q6u3!q+lETSj4JgGgQ-WEUnS1"Nj03/g50ODjs't<%'[G$P2As4l@@Z/,[b3bUoOH^=/3b\VhO2,@$Y)kKJQuY,?f!ka=@6
49
- CCTq<I76S"+fkXDbgb_Qd&.K6W:p$:!Do!F\YQ87MI]Ds.5.oYD6D!;N,;*cRl7IUUAmeRJq<-!IH,@""^mgW94OY*le/!i?X,]sI,GAML?rE>qphfkO
50
- YJ,Vuj/]SUZd0FZ888Jq-m_gP;p0W^!*M1ku,dsnTf&mSmK,l++s+K<%22Su[q_jG%Y<NWi:a&pk($PdJQ6e;s+oQ4N`@S0M+Hk_0C:XR\fW]KmA4C_@
51
- kDn,[/?'bf1FXd(c,IJCdhBkj!J\FuR4`ZZ5h@f:A1^#aeC0@?bos]m=5S9IjAZd:1q`VkP%8orE?_8\<c$G:=?%^E3Q_LJ<QJTagUY^##`..).T]90C
52
- @Pa.O4b>!Z(pb!I,%o6.X:GGeX^^!j%&^.N\sGJ_"c6"c"$U33,j`dqg>]@doLqkfbC"m7F0k!kbC<un=W3h!\f#]oda9P\:W#e#Ka8?m_Cmkd"YWp.,
53
- &YQYP&NE@Bg>?LVt1@'K6_]%@2!'%H?kj-AK)#K`6fr_6&bVRGR*2l%oqcHmJlRFY)B;/C>.ZpC'Op3#-a8">PPJ()X1RMPe'E8%pT!$N(\SsrdP4]&S
54
- O5agH(I14'])T>f;q!io(_pF,RL53T9n1oQY.?PTXGCfbo1^3q;M;s+eege2QR!nuH<hTLZA-FH"%Wpl=uDEX2h>a(^Gd<pbDpX3N-2Dtr=;9:)ZR;V*
55
- US,]T&\-:6YoDF3$:dLD[bc>G3^l]]hLo,;OI_^*u3Z*Q8M3DcYQZL>8p[O1=6,K"4]Tu2Q\3F+W8G_<P4e)N4Qa^#njARj_jn\Yee>%J(]7%a]ZfIE8
56
- #aKsa)5#P60_dgR3g=AecSat#>Uc!kdNV]Ko2&f*c\!mi^co4Z8]4tUuQgEHZ80"1Nq/Y1M0FK@Co'=#%,Dt7)n-/ac,/&V#jLA9p,%;#Tj*+DS(Vh,B
57
- qns'j@X1!0XIH70-tXtGWL2g.Li@X4dtIHua7Mtn`>Z(3.H,^_:p>nHZW]T5R<<OBYVup?8'H.8+/2<;QRb0$<XEa8*[4,nJaM#X_ZJP:oZRlA#qHmEJ
58
- n];Phnlt<1l!hS&cSL5]$uki$+n)TeJ.MZ]UYT_d)$pfBM3[qg:7'R&U#M^`%)%FouJ+tC+a%Ro25'a\GY:Kp;2nR"F/Vjg+tLmiAD'Qo*b\N(7RH"8B
59
- u$Amm*k11AQaXE&/^_Z=@;34,?')Gcf)SG_8m'nI=E+He<DOa)QR2D8Bq+Tlr`7)2"e/^I-l)6DV"U_3L&(>$uHUbQe:Bm<WZp0Xt3;3CgoQ<WO&81.`
60
- buKZC$aUm\'aV<]:.2u_$t/#Q+Q\np/fCsm%Ur7T3c\\<AUJ\gD`]7a2_s.>X/+43M4L.IjUg!lBS4?ofL]"#Y.#QP8_#V]Z,1X5pKQG)b<ndqPP:VI%
61
- Bl0SF7=5*O'k=JB00oPA8d:>,m0?&/6dg2toLO+A=(&RFl2]Q%nr[D?No`XhANt@qZ_9cXW"Nt/TD9nr4Hu@cQi:V;:WI'-Igl?eL+/TNa\or-1^Ge*<
62
- Fm<4I`E3uPV^:IbPhZO27(#07YYbqGG5/t(3s/g3'5t7#!>ml]67$?6Z7U^OIf\iM^n(!4%'D7'+nLJL+E<40Ak4F@E7XY<$WF+u@MDP88U[`ek\l6D/
63
- >2\`_^AkeUc0)/hqUhY\OI)@H$h,m#h`i9-\ZH8nBJd8ln4qikio-Aj1['ng)TcgfDd/@L+aKr6s7aHAmVd?!GJ?"ReS.)9FjNS9(n)tC87!>+'Zj)I1
64
- rpo:Wc.ALMY1EHQDJbPLjb10nSi\V[K^NQX#g9QsW9Jn/GcnPF&Rid+IDOGBS28H^$Cr820MpaO2qGMDE'hVg5(l*#F`lfb&18(pn^A<NPB@3l@3gaJt
65
- 'bUEZE0g>t`YG#?e3,g+p^fp2k]!+\3:`$G2EFj>)YjE.RrDhs&UZN:pEW[6(Lp#5*sTjEX!Z3^4/!fU>AO6_G`nic-pF^hH0%1<M_-.+I6.Zhdj/tU[
66
- T8VgQ):E2heq7S+_5Y$RDs5aCM:$E.Bn9NTX']&&gGWit#qIljA7&m'?.r1UKVQDtc@[0i^Vnb[t$h4;je0U#-\:ch7IWj=s`]\]Y7j%PBAuP[d?b)a(
67
- 2r#$@c@9S^^SE[)+s)^H_D9f0W'S\E=_aWt5$#1'<fG$am[Z>@[W*=BKs&+&bddSF/l[uD+pWEU^n33CXJV#:*[AZt.-#tRe#Y.b^e\9=l_YV5W&l`\P
68
- tp.*d>2S:V6s3Wgji5Yp(tm%%(>ii61i%!Ue;tWs)QgD6&W.k%dUBK""`,sMgK$8pf6#I-FZ;2LhU29F%k\SgFEnmJk&H\'aN,i9u%+RZKt$eZ5_^p^7
69
- RCVlPB\nLTQT#%RdY)UgZUDja/))0S>Z(dl]JOTc+`kO!rLBi(.,)S]E&T<\kn%I;2HWC(MH5Se#9T!f`1..ig`r0kEtH#lChE6>"KY!,J6=<i+D.0TD
70
- p,>UNHe'?Qc2YdUS8`3Z"`^"E3.dsr_J3U[4&!LBAd)++(`^T0f065CN7e3C4-n7i9GMS'50jNOts+SP)'MHQoo$aT&9>,APsP$Jc\b6GQu+:d37%ch9
71
- LZ2=#P`;3U.rf,J$?RkTZ#MV$'f+\Y\Z*+t_Z:eS#$tlcg58]'D+`hP)6Fi*.8O-'A^FKlY41'_>SX9s7$<i"&?[kB#N"7Xfm5B>T?"</73aa]R,"l:e
72
- SBM65Ris]Q^HP7-.Qk$]/i4erX/EI8aBrrB?F/.(MJ[GBWr"U["EhW0pM,j=QXht+bUE+jJ_WN'Z-[%Cbei\T462gLKV=onOXZjVbkglRNbH<>iaEluZ
73
- E_j0=cKN)5_97+S2qRpI&aYJebDlJ``c.ubCUMnhSBu%:XYkrK(<p[I)uATcDl6CGAUKYUZ5%M(=3sV_ugb[_mss_SiHUY/:!i0d=A:s=(Xdt3eE$nYg
74
- kAJbZ7PA=)8,l4",!Z`Tf"6:7,q;%`4_:1lHL0?c$P()@4g"lZ,kRX/i\`.ecTO=];p)HGXR-Ak@S^3'kLX5[-HF"$%FO"'/Q:EV@5sGJre:O85d_6.O
75
- ]5EM%Xed4:&p3\F]2l)78&OaMpqQVA2h3c<XI*ZTjF@)CT7]-#3[rPtAE)a61-c5)81,"D^gUWNBOLO.'t8?`ZFN^!nri;-7iFb>cPZuWRJ#8<F-n8ZP
76
- s[HV)j=B?QCaFZj9KVbG^.#H[(%,VKqn5Dg\o#PohECC@$N#9!p"BYe`G3Dur<P^6.G)RT7hQQ#nAVE6#G+?&2"j$Cd*ga`X2h,`<mQ^X\r^GQ.oIeL^
77
- $n,cu,6o[tB3`/W_XYJb7)=EB"NY;q!0sekD*kLi:47s&I_G,'7XT7;oh(AC;N55[G>Z)K11VXZbED^I8_hU9,JP'(72Z^]R^'Yj9t0(uD&=@Ci^.t_,
78
- &GOJp?_jXX-Y8?'CX!S44oYNk$HX3#fe8?dKWc^G)"1P&r7+8K/=>-*-D*Q@9_MkZFOd&MnK9f9t@jQ_$bdrEB:o1-6^[l6K[HIoSQdX4Siug_g7h#@P
79
- \eXjn[JH=/:,naV:7"3//$DqOP)_55/Pb6(!.#&@/)s7J:UnnUWde51]s!j<qABZLs@(=\cb[Ne_s[oiO/E?HG$9kC54>DodIaCCqPoZkS%!\P"".WMI
80
- 7T6V/^3D%ogN'MZG<n<pB91mVi>KJfG<+TpnR_SdtZ6bbJ<M`.b%/jaEH3?NlL7m>khf6)`dNSE9W`iObDnO607E[8b'"UeNq,pZdo<6Vc*fBg&m5u[7
81
- %;m!99NiTErSinTc'6FUhGng7EAch0cF[c=qF^Bf3E6/*Gp2L]b.,>0JWL!u+rA*cYGc"!>pAeOe[bBdu4hoAR:R5Ht&s5%O!H33_:7YWge:*RqRNcqo
82
- -@tUfp.n0-HFMb0X$@l!/MBS29*r5RQse=&C#nD*nLqOmlV#rR8pg$o)4G!.R/V*(BS$,q;?l%HgW:n_/60c+Cu3hsd!R9]>pmM3kIl;e+jgt(6ZH+=9
83
- 6W*)`/#UQYD/B";cO8e8aO+]2^9t&Gf!3+h*hQ`*s?k"RG4tWQ;s.H\a#.68gH)T%&Mpq"=Iu+JVZo3;hsIkL$rKfC>ZQF,5)f[&MMr?G\5_a/QA)0=A
84
- r>G)@YG<"^3Y',R`%$6Iu9f1^bk55.F%Z#F,`(D\SNL(CCA0TE*?enc0Eih1Ug!?b\L3@$*m4^BRKbI&h0+'ucSb=O$kH,7/4E@7.S9)u;9dn^.2SR)/
85
- ?mfLZ3*W-cb-6?gpZGO%UPCX4'Q`dAj7THe`OMUdFj=cP69I05FMdkqrA1L]!-AUu;V\RE8`5C=]i\Ud$:p+VF_*83U:o.N20K*-\SaN>n>1KS$A]V1o
86
- "JC.iI._*ONrK`%tOJ87:6\sc'pi68t$5m,#*8)8$M<ts$IHT*B[oQr$2N91f8,MEF]L^2"Nh)*VLKRR&cKq\j,L5Dg=+C8EHRe[8C5Sng]:(83?=25l
87
- 5eeh/h7Buf0!EagVN5]YR"[%3i!CdKSiQk)?Y.Zi^QMUk]K9[?%ur?AnKQ5lV+j^JYrRn]INh5V8e%2c71[#Tq'((n#0S4@)Sm!o3@,">TfcmS")`O#_
88
- \!c#e"#ua%.=Q$M=o5!<Okg*H]Td)2qoTnNn:(O;'r*TTsYgFSbj>AkS;'IjB14D_ck=.h?0i/QXs'%5I'Zk:;4p;(m8/>+]#G$mgpKO[=r%Y@5A7PHL
89
- @Y&aFurl2"_d<X@`?6H27)0r4>MFDuPBa_Bg,"/qCLQCCu?RIfE]\ioI56eRIr-cDKVhG.Z;jjrd@A`LApWZ9^7!cu9!ZBc9Q0/X:#]Zs_E>Hau#6`0Y
90
- YN1T,,0D"MF*:(U`S\<N;7gK$l%&lq]o5glY&r-37nSAISQ>\h6]c1%SWEm;'-$bGQS!!8Cq5ik:rR,<1]hJ<WJ/hf>kU>ghu6Ku>9LUng5EU2a]&QiQ
91
- N*I0[aMNKA7KNHu-`DXr=/WgYKNF(5D"%3*ks4Wr!j-3[@s2"8PM/5?eUJt;PV&T:u![O[Y[A'!8[2O>]rF,1j/G=#f_XBB$@5g!/Nq"m8P@-Doa%WoE
92
- J%qnMk3G%Ki=s>n[1@,^@>j\8FEhN0`om\)D0qE:R`HqPJ4V2sGJQF,mfou/XJaY?rc[UV'\!tti]K/e%3;p7@SNFf[s'7\]4L4kbgc8r$2#b)fj<"rX
93
- 3o1Z^c;DqS!6qW$2SYp!!/0?QbEJ#:7ZhqZ,C;P!WW3#!!HG.'''
18
+ rAT%)=o\O*k16n/!WXAE$ig8-O-i5'i+r01HsH^aLdFUh,;[5]%l=/aQgL]knEMJj2Td\)1[`bH(.0)JfPPs3eIh)d^;Cl"iF+?;5$GY\@0&f>,"jBk/
19
+ YiH%6+MO0G/&_cVMbEIV%te4SsUIN@I9"V,DPig_^G):A?gs:G4WIo4U9V(ca0E&!d7=Dp]k!,Ha*Kah\qE4^ZV8^P$l@o]F\b'NLI.UoSLB2-BsAPK<
20
+ TC+)/F=9lUh$$,-L0U,\B$Beq)E4d*p3?al3[\ThsBL1lSo.bKBCPcZ<_?NmrNo)ZLj0,OEjn[/cE:Vptg0[Cq7Wfn#8`N8iGq@':[%E_d<[n<Da,s![
21
+ V)Tt+8:H>-U",1)'J#^*$DjC3hYHqso>I7'F)4%WckpDf5TQ[U>Pk,]P^C%M?]kDI/O16EW_.512+,N&D1#T:M<Me?X8f!KNEY"U?8EDPpeDJ;-.&W;?
22
+ -326YM;Q9M/&S.GMTP2c@aHA+L.,7!!]lK=G"2A9$Mn#i)90BAG\g`*=3<p@:IY(m8,"V^d3PJT@K]W@?A\!W6kH"hmEp.8DK*R_ohW,n+I7QR*Sfb+L
23
+ S5(D&1#C.%&r%+^HcL(J5*YnF0MA+4SHl8QPl4V@UCUFk<QC'@&7UcN*cu]g&F@0od<6jU``WZ,a:MLcSb8BA(%3QN`>>H+DC$,5K+8TK7<BO`@;aZ*[
24
+ ?6L7CjnGCER'7]CXE_\_"<1M:InKkKGE>AlM%"EQ5P_'Y,t[&FsU<a/WOBAb4mdU+&c"$n^gPqMbZN?PoQE'gc^fHX_(R#,:;:[WKZ%<P53`fY>X>%24
25
+ u4,L;!3R>]4+n:7,9,VpcVc8_qP0=RNh2o\J+1jDoXmGcRr2YE4QRW;i7m8kmseZsr-"N>-E^_rhp(WM)\dh[M+`mX;]sW!dtj6bQ"$]rTjs71\k6@ik
26
+ V'EX!9N?-j"Fa'NG#r8T-L9QP3mSDPMkp2BpSM4>U/:.(mHbA+H)IeP9a4lAQmNT,S>j+(U`+.%p]g[_gP<ol;a?brX'Uhf9"@l_OA@(>>DF$Qp)q5<J
27
+ L4L%tG>WsXg0hYG8ODMFCBedZHSjg'7/('>=6^F2t[dT*0@XGshE*Qb">Zn@MEqNEA,W)Jhq.=:\DFJ$5X.P(2c0oWAM)sCQX4a+ppLYRu*C$a?-`)"t
28
+ heej,p1^sE4khB@M%@6Ao`I\PF5E6X@Km;Q>J4%gQXdf8rVp>X@e]/E&?u,o@mq!8N=S*?/9=H2GO+)g5%?*oAkVp_ba3V$Wiqn2ck^*?Y`*W:,%tRoj
29
+ 2T"P*^jp:Z2%S2ocM95+GuT9D^C5?)k<)m?cFn4Xha^NL;Ym(2^uH%2R8e?,@_K%X6QSO.Kg@N[3B8(Xe_?SDDk><B5..I2r.b$"K!Ei'[A20o"QDaOW
30
+ 4W4fGRBSGM>M^CddKF#LT_$eYk?70p+rq)ems(WKCVmWG>VPoai[<?h@T#feB`?+WkMqh,E-oQ`Ir>fol>(qYcpedQ1[Ul:=%Yj`kpmB!]$^fZZ@J']<
31
+ ;4&ujr7fOlB@R@P>pf8]k7I-,i8gml7j.sEb4T=F3H/s9E47sPPU\o/JT\huWtebQVX9HYmD-eA)-IX>?OY)TpW2JL+S]`qtMP%MNM#fAG8*SmZd`:*e
32
+ ),dK9+bNAL.IEWbsbk&Qd*6)`['\Bgk5XMQje1oqh*2D\@!CXk8[!"/:<^F$.?"m<QVPQs+s)Zc$n6+;Zr3UBma$.`0@*^8/')/tS*[Z$tqC*pH0$;8S
33
+ ^gi9el!6%oqNHejC;*U)7))l1cOXd38%'Tp+WN=WI9QV'1@S;d'Y`(]S"iQ\.H4OVgFrjo_l0L=8"oA0-5'@$#Tjk2j@?IG/?GQB]rtATb#F'^>S+,eU
34
+ >EG^$+?01'Ntu#D`fM_,6=KQ&j9d>GI:j]U':WMe$<t%9H6LoqMJ^c@pb/(`DomO5Q#f8o@Z+/G2i)HnC[b2ElL)(=Yk/m__&QOhK_iE*%<b[XfaqBI!
35
+ o9&^DQ.nhOgI9`Y6V`D$Q<AjOEg=>M=TP44pk8R%\&J)Pc&23"I=.(k8g9931T.4DUP4h\2pOCG#WZV4)FNq5KZ\n+[eB;r?mU`\c;6(l!IQFeCJmc__
36
+ @Qr(';0WIY:j;u#<UUh&':1Y%PI'K)TD-2$3`0o?G7!deqi>OO-?X`,g?4DUY,o5;@;a&*\)p,Nk4OYHLR?m)`e/nPqS`Nbu;Tpd^Y"jErD10E5V:H*n
37
+ en;qR3^UkjT&)HY7H@[oe0@)=dg./)Q4>=g9c(,OjS>>ImG&Db%3:11Noe3[g%8p4(j)1Fnh',L*UZMaV'%Ea)Pd'*Bl/#KTMb)2p%gI@cFpR5MEslcf
38
+ \!#^uO3@eXeI/]0!+8;mY[c/4`]@8&7sH5fZ-,H@!78MQ7sa`l#m+&bHrj=.5^0Sc_hnQ.X6<S)]sl"W(Ji/a0ro#DBMdSaE9Uis#b.i]ITCL5#`oR7)
39
+ sU>*Ft]Q*QW/8%?mHW?OrYce_pSi#'*bDO<j=rH>dCMf[RPUgOZU(`DDu^+5,gh0qQp^uYKI0p9c;`Onho*d\CpR2dn3S"4]7KPREE5a<J&o(E"lUh&a
40
+ Jq%chOD1I_6"j>QNkWQYfPc:mVl-Kj/]^;-^O$kp`?1$g.MNRO!O>]Y-V4EYn>!/Ij4%i43\[Nd/NK65B\]Q+p?/%_u($_mdP5\I#Il35S5Z.*i="60i
41
+ Kg]Z1mc5)6AD3EDR->j8)N[qe4=eM?9C]%4W\0p3E95it<&#`)sk#HoZEHJIh!LecRIf9McY+^)m5#fI:0GqifK=PX3TjX9!#Z\-3>b[LP!+?)gJ>D2?
42
+ fN`u@pW2T5T*7]#7+B:*nk7DL/E6&XO_Nq;P>#&Hd6hcB"UZ9PN)t=sX%ZB$YI,@WO?o\B<?TR6;TL>R$g+@cPGjQmr199QeK4Kg]fS\YN!]-Si(BJk\
43
+ GanO#;c73L.XZA`_G4jNIo@<D4NVl<7O@&]EB?t<l)QjJ+L&!:VJ*-+Fe!].,$u7WkD4s4qOK;_BnumShGbhd!lFSj1CYc;M<iZVe,o^q@B`77p]aA*6
44
+ _M'5+tK,!]?!/s=inj$qj!Fpftfq!Nq?QZ\7sMrj67Wfr=Q?1pFP[r<Q%+u1-3bs!W*/$jp)Ep#3.Q=q+gQCmh9S?7(hp4JbbmH[brq4&^RpU^mT]gi<
45
+ '3bkdnnUNhZ<L\jo>p9H@F$=K!j1-2OK?;cKeL)sZc9d,a9%IBI1)EVkQsqos4"X>bh0KZS>=@G@pCa;*u6,p"maoU41F`;j?a(o\GQC`H/pfQ/2g0a7
46
+ SWU-ogXZLb+e$D7ToI8R^D7B9hT;:6Z[I_.CYlgXl#&@@t9Vr't)(7s13-'6ieK8CJDJ;LuWe`0cs,kp(n"&"pf=R0'q?pm&lbiRfu:$q=if#/YR0`?H
47
+ ]F7!;R@n>QAmFCopc3/6ES6CR(PYfM"I4VdVP@I.P7m"@_-O?qkCXM^p(dm#7h&H1J<f8l7a0fabMSfWd_T[uBYB0St_#!)KY;N<N,Kd03a?87=.guQG
48
+ 1TM6!9HN;u]0>F:]\%oH2082]@!k$ERMbu!Z&9bPF!H]Q8T(0G,sfi,.ORL66:"N-'CWYV$7+o=A+/'M1I-'XE9g0P\poHKSc.-0F0""Mphb7a0j9O>T
49
+ G=_,67d-Q"cGed2cnq%/uS7,iT2C^DJWCL&<9D&D.63ph.LMh/MKo)<1J_;T,/$sYGIFu7RrnIA&f5%*iR[iRops8>+IdJ&Wk);J"S2;fd`+_DJDWUg(
50
+ p3X,JdXLLF\SX(o$2\SS+nbh+P;W6-b-5N8O(11Jsg\b?Y3.OoBXdjZIZ$;b%84/'5jE#9nijE(`Q=0L9L`ViS7QT#3JjBTH%!/")19,pd%^kstW'dJI
51
+ $l^ILddi_JA"cCbRnr=nQ1[?q(KN7@-&ONu5VH'5Rp*Ql_(5</\Z*O_!H>`-4Q3b%)haV=:b.e]#cL?pT$#:Enf&ZoMrrSYYf^bu7uY:819Mt`gGWBB?
52
+ @&ZQK"dq(R_Ug1d!+YQkI>^0!\Og[gBM&\j`Qti/,_f"f+(V)i]B/<?W-]>9#'ff8@bB-,A;L)L>&9Tq;DEfXD_^_7I"aTNrQ<O3d5gr=?>dShPTt9TP
53
+ #gg#RD-GI\@Q#c-ELRE//Qo$'+^,c(2XG6]NNq2jCJoq2CLG3IJu!TiVQRBLpuKS.&V>6*AVm1:+=j5C]*5[b)Wm:*<6$WLjj^U&p8&@TQetCXW$+2_A
54
+ "SQrR8P=L+_/f'WZQICBNkQk9uM^Z\jZe/Rf.b$l"c"I>Wk\=X-PIt&Kc\2prh-Ce<bgk4us-Lc^&t#'U7bP%BETUNSPoH^1bhBEi3?)?%YL7PXDD'P>
55
+ ):@]ZqUcO!DNn*9&qNq!s>KcJlGppY.hUc87IM]cmJ!#2*6;9FJod*r1EnO<`l0:;L;7:)4XqHgf3q7mKuG_H..Ch_U(kV]<;<C5kg3JN2h*aN+qtfde
56
+ /`KSiTliQAMaK(AfLkA-n&I,T11VIWHe?r;'19LK7SrcKil-j>Z?pH&_H)Xo_C*SIXFS,`>'`8=W)d"Z_"b@VHTRu#]*W4aZJ;Q/R]2N9Ma#$8ttU;,^
57
+ @XNk460U_;E"3htH"26=SRFph9WCq?;jEm=^l?Fbi:=^.O@G)^W&*ME:jkQIJ*JnDE)9.;BVHX$:]Q]K3oMNTTYH!tf,i!"K+!<</H!p-G'5saD6M&b8
58
+ 1]^dhXZ>>mrf$G]E)D$]8LeUt02l83![>2Y+O7\68C`>5_Rc&3#-kA$n:R='],b>\@8:ns&+3X?Zr#Y1=5U`,c5I%5,)QLV4Wp:G5;jnimndrI<tIpJ6
59
+ $?biZk^Z:g>0+HK%#58D,?gQ88#uRY-iVs%]-m(6lgd0oFP[T-e:)#$-rRUIODoA!";!76[7M6jL(K*rA63^HXikX@Tj"?^4JU^%D'b>FV.u5.pEb`Z=
60
+ L)'(JXo>HE:M`2-qX9FA\44Kp"tD)(4"q8T>s37t7rITW_Y*-D<^<1g&qVUg%GBNs0gJaK4.^B0#04Cll'2C=Nl?H3ch!KBq3-'Dg'qL#]j(j_%6%c*\
61
+ g>Gfl!0=\MY=1\Z8NcJP>eYpEaA<gLcU(RL9$TKU)Z2?\B\L.jKNB3V0L,]2s8H/jce_@aBfc!@HVJtj-n#Pp2kl;oZrYG-b)Djn/H0?:UH<nM>U42-Y
62
+ qM3H(BPUsE<7/Nq`:JOG5o3j&I0o\+J)#ugSX))AU$q)THd<7L23&u2IH1TJ=QQ*4?B'8iO5otnQ`@0]?1Kf>`Z0jgR@Sm?ZMZ(+T2l:K$Fpbt_O^YSo
63
+ ^/G7BL#o4CC-I=bB&$FR<)Vc;b?qr^B[=,F2.HX%U5uWmR6S(pF7dN9]IZ7;R/7`J\M*G_l68BoL_N[I\-7]6dN0P_;H@,T'2hu''-<bAm_EO;5?`\ti
64
+ OXLS>]P;b["Ul2QgR(n_bXoGh?#;=0aCYO+7Mki-E7a'Uh@"5q[-TP=D#%C3d%)hAs0#;ZY-K*3Cdf':4o"R>J7ilWAgq4E\tasQ--8D"']pa_j,)PkM
65
+ bsm^=CfQ71AutU;t=GbV+)dl40bjFQWCB.*8G_,,@c0%KolE%NjI[o6=XEJI8ZE,7su!_o<.D2KP20*bJM03UHLGM]jk#j&I,Do[_)0i/Sk+?dTG6NCf
66
+ ^(R$Ci_8i[6\aPCf64<TGpi>a7Ib!6oeoFq6h>$aE[gQ24is"<M0ShoMUM)GVS#$\$15U4i0PRsD6dO0sU0.q)$bI1u,o6l&kPfW?"p>_SARL`\PR?1R
67
+ 5o@Pl"!NtJenU+98.k/r5U1UZ7M,/T3GSG90Nq.+AZtd>oDmre2bS&hnW''H9=OLGSb(DN+$_agg+BPAU+Z$P@Y#0u`b!`BqjK(c-M3'8B]mU*GZ^EY2
68
+ ,bV727dI"NRl[93o'S*8K@R>-,J.mL8K>t0:Fk5NBr"7)QVGM:p_L(N+TtR/^uHfqqZ'F+*u%Q1*8GT4oZ)SY79/=LSO]F1>f)t<Eu7g$`"(&1k5RZ?`
69
+ d,bF_s`rs`l<=NS.$Y!D@keL*pAHGM9Fsr48..sfP+2>;'1%:;77EEB+Rr'/]0SN_\4NZjP"l]a#>t"=Fq.?7BGEM.Uf*ZXA9<k)rlUg(6@'DdkDF3F1
70
+ +)qm(DCUmN1l25O[S]%:12CY3ER"afKn"(OGP[?lNiNVuH/O^`E9O0`N(t(mEK;giRBoa87G:/0L"P?MZoel.bRg*JfYY%)61]'^Xtc$k,X\L.#-=;_3
71
+ OMHk:(I1_&;K"bU_UYj.r:aV]=r(,"`V@/&%B]8^a6AN5kei_"p5T6kYURGOZ]s,j_J87k$HXY2\qhiK#F>WA-kp&tLioJM&;]KCLF48B)EI'+lpqd>/
72
+ nF_lifh'39C7qV=`g!k?i+/B.+g+@@%c%rfrAP;oGAnPl(`_*qaF9!fg<itd`&Xt;Q(S#:m>1W!OE>*OfN^fZ<KG"`c)u+LC/Gq^\K/@]^W\SRa4Y$Am
73
+ hMeg"0t=JoqpXRl`E`K[-kh@nOQEp_#pbbIYS#F\&GZ^ejs))]CAjm#5tOF,X5R5SIbL_A0gn,((P?D5@?npXKK[d2q;t1\EGD*g+\7B%Kdm9,<;`d',
74
+ ><$A3T@Q&U0^@&8(:X?U-#h%DCd4>a0Z[8`CBqk=9L>B_W+AkmfCM^RS?.A0UQ/;lTa]N>j;^n6N:Z\OO<"kHI8aeV\Y"HGZ;XE5Du`58VR"u!Q];XI`
75
+ "%2,:u%l`eVtimgc`j+A:D*YRl1JTt+I;EF[b"0_oH+rQ,gaI!WHg5J8?(_WkT7,V+I).+JP:>6u?_UDXrt-Ro`NO&X<,Q.bqd*tJ%kqIf?trIe.!^Y'
76
+ Bms+miQk<;!&"[mZj_V"c\->3QYd`N6^C0`FS5.8IZ;CbqVS*K8`_ioo!1CLRKdG2b"3*g!ML.H/;mpXO%S@IF^KE=J"L\DgVO)fdHk,cmSWL!1LnXuo
77
+ KaF.9D;Q%^XMP1:PGSOd-;g0]foW%+3/ttgCQ7c0&jDSs_6mt-9i1%&WpRr2K5q6%A$L]d.7Xr&.HDH,]rDMEVs0q9h$0NW.`JtJ8^k1<<Q]dd``d_F-
78
+ gP'Gf]t>8TWaX:RP?Xt1YTAfneY<D*5K8\<E*J>u5J^]fI/'cagZ)_Z&U^n(/pHePI*6DhOqMC!/D,lXR0ViO#k]2s,/9/?"9qe6fTQcJ_qLb5L`_e"f
79
+ J*%>/D)d*8*qaEEfcI//s\P"(OlN$a=;ntrQIS2KM$s.a=Dqfo;Fm;qpWGYVcKQlqVSlZ)8..<kcWQSb5bi^#Y4drENObuIf*+d2?1h.K%^10cukEoSV
80
+ F,Y@hFbHH$%O$JDU.;B&U)AIJ>NX8J7cl'qJ3\D`^*[Um5=(G^0Z>DkGlGo_Nn!&!;7'/H2r0Y)n)nJnMtHf0!P=hf@TE&m(Ip\>^sR;l#K5M^)iZAZ4
81
+ *k?AK[G#h'@L,?.hekCiJ#BO-[lZogLl`T$S^Dg$_-9XVM!pbPMANER2`9H$F-C_ZWi;)i"rIY\\7p@;GLF,Hg*Z]A=I]eF7_q=jV<G=Grl4)fo3=<uY
82
+ 4/#g=<ObXqc?D=>-,,9f/UkV6/h81L]:+8SFBsr\9U"#EM:oG]MUcm5kH[8IiS[i.#A[`?aA:0:>;#<pDK5)l^fWjY<$To:XMFt#Sei=Y?Kc"YdA[)56
83
+ gT=%0o(3^JrY0c1bQRqMY;Z)go$c^&VW6c\CNg#&XPrh(nB4u+ltW<J%X']qV]Fc`aU1tEc(V#-\V(CqS1b2GR<D=iZSO$d/'gJJ5kh?<4$.$`IbPe:R
84
+ 5==iH?U&__&Utl6cE9F5frdBXI#NHUW(JlH\Y$[\HaBe@t).qlC61b9c=8-T;H0&-.`==Jt,Dq_1FK^[j,Jdho@e;KuPm8oG%)X@)&\A.gX6l%:^4,kO
85
+ '#^"_CkcLUm6M&t2^`>\b6B*;gMY]<W<0Cq7111'+41eknqg,,F;B=)]8!@AaH_<r?4T>D;EbYm&FWQSC^q$iWNsBZG@a4hf1;8F_P3:)+&F&B^.EL;f
86
+ (AdhP2#WF)l`<#bE0\#&IJCU$1K_'K^^4L8Js!3nX?2#@F-eqL.XfJItb64ia\J-$"C;c%)71P0fJ4psK$Yiq]F3.:N;W<6@c/bZ?(-I?Q>]l,T[gRfh
87
+ .A0kNfUa.eGEULKtA[_gU#VT.l=]kgfo(uUkHW2^E=jkRS\LkO.+E`E3p&JaZ4e^7Q]c;bj\TiI0H\sETXLMu!_WF(>baf(ZHZ+?T%UL54W'h<[BpUD9
88
+ 9jIOLDC$]\k+(9r?cCS#lLXJ`?Mb*Dk3M\d%5-c[1e81Cl*[O&?p@?H6]1a^,,N"%J>DRQSd$Uj=mdq_Z$rd>hAnG2O9!6-A7f+M[j"s#&=#!CFbYtrM
89
+ 'mo-fGT7JTg>^8#(%>pM6^#DHnP]!YbT89j/m=!Z*Z_S]bZGelFlS*&O;RJ'E:.X3=4:E\@o5Kb@fCYN]h=b!t1Nk3rTV)QCpk/*]+.M(KAQM_*6.l\!
90
+ `ja9_eH()4*&(0G0oJ]*\1H/t*nLflr_Bqj#PLODig\;1^%i1BWMa<U_gQAr+hOP7kcC/MKg3NG]L974)8t,f<b)J"KV\i,c2[\C6BO'VL2@FI(+@]5'
91
+ S@/Mnef:&L'p9;3O\F0,u=(@(>apt#?($GBL!Ya:M%[$i*`NG#'!\),TR+Bh4YTl%h!#iMbIPho%7`Xn/jB@E&n?TYaS)K/G;VqrL^pekNYA'Ll4ZD3R
92
+ `ktS[c%r8d.8LGiFGX<i\JZf)7?<S7?@rZ]_%EVeC8nQFF`;E%[eu,<kT9SIV<'!]dFPVL$_EWD-mEA/m.soZ.g+);q%jDSu(QbElb&3K^^chec1$ieA
93
+ Htk:%*$g=ji0G$.dnNRE#UH:<DZu9f'a27Rmto0QF!b(WrRO=(q,,N5C=hZ@bgJF&>a5R@ldX/I<Pq155]n<FKQ=&&4@nZ*hqUCFJ`DT=RT.t`LdrO8X
94
+ sl'PrQb)rQW,QAmC[DmhX._[cOr1E0<VMP1KLip8Y.qWYMfY7-Sqm"o(>iTp#:lPT"Zc1Ud6S9Y/X.`bBTRZ/d4sNI*Wi@O4=U[W+$KE72+S_o@eY"P\
95
+ @^.Q2gDB<;!P`&U-_o?>lAQ)L.$D/o.`r3THQP2RqTPLe65RPtsF]c"[FtoZM%S"tC[)7tq8-T<l/9KH(g1%gM<\*cB*BgXL`?+/s]]#264p5^rQ:s)U
96
+ "qbj;Mie!Rq;`87n!Q-D0i?4h=q6[=_YlbC`NO]\luLcY%3:gV(2k45fipEmpsT0)R77.cfeXNp<,.TfG-3Q+a%6mXViRT7>ikIL-`fJ#*qb&u?_CAi$
97
+ YHIEt9+A$7XXi=`TDL><snVAZ-KZ->p#/";eho+]3.PE%[JRQLcaQ2)B1L?(fn1@_l8/h`#X/>kZaB(?+igB9!$g#U%q9)\jK0d%!\DPII1E+YB%fZ*l
98
+ _'&W*QPn=VO8T.q`Wd`C/1OStg5GPj5%\'hR5nmcnF##s#ZdZ5\!lnhnmLWPOYg/.XJ@G>--rf7BF)<tAAN9aiFcf^0/63F_6Pt;/i\4=A3kNg57HVuS
99
+ Bk?Ia#@D8h35UhPh20m7(MP*I@JEIp^9h!.t1]T_>K,&;>*sZ00DI;`;FHVIZMCG.f<Tu>f,C3E2M2(20'qMW>m[`/kX7<-\gtFs";4*E"7!u!HnI-I\
100
+ :CrXnN2=$WC7hT2VjPN>-/ncqs(e1C=OBQfuIiRIq]Od[O3nWm4X0`5UfH^2Mqe)%2DhPmmU+m@UW+I\"g?kuEGGG0?(R[c9?,o1=MKmu:'b';u%dnld
101
+ hC&:PC:E%[FANK#cH#@/_I>:!&/)=\B\c="2%19!$[Zl]el65mfBHS+OJM(bb*[5$&m0lZ\p2AiZ-`3@2IfRBSt^pl,N8\=/q[nfdI@HP).o0T2USWJo
102
+ #-H=)K\O@*IBaYQY*5)<QXXiqV0<g`TS`9^5\A`eL3h(@';$d4&U,IgcO]eX`!!/HMqptiKQG749Z,C;P!WW3#!!HG.'''
94
103
  exec(lzma.decompress(base64.a85decode(_SRC_B85)).decode("utf-8"), multiCMD.__dict__)
95
104
 
96
- version='0.30'
105
+ version='0.32'
97
106
  __version__ = version
98
107
 
99
108
  # Colors
@@ -102,22 +111,27 @@ GREEN = "\033[92m"
102
111
  YELLOW = "\033[93m"
103
112
  RESET = "\033[0m"
104
113
 
105
- USE_SUDO = True
106
- if os.geteuid() == 0:
107
- USE_SUDO = False
114
+ USE_SUDO = os.geteuid() != 0
115
+ _SUDO_CHECKED = False
108
116
 
109
- if USE_SUDO:
110
- #check if sudo is available
111
- if multiCMD.run_command(['which','sudo'],quiet=True,return_code_only=True):
117
+ def ensure_privileges():
118
+ """Fail fast if non-root and sudo is unavailable. Safe to call repeatedly."""
119
+ global _SUDO_CHECKED
120
+ if not USE_SUDO or _SUDO_CHECKED:
121
+ return
122
+ _SUDO_CHECKED = True
123
+ if multiCMD.run_command(['which', 'sudo'], quiet=True, return_code_only=True):
112
124
  print(f"{RED}sudo is required to run this script.{RESET}")
113
125
  sys.exit(1)
114
126
 
115
127
  def execute_command(command,wait=True):
128
+ ensure_privileges()
116
129
  if USE_SUDO:
117
130
  command = ['sudo'] + command
118
131
  return multiCMD.run_command(command,quiet=True,wait_for_return=wait)
119
132
 
120
133
  def execute_commands(commands,wait=True):
134
+ ensure_privileges()
121
135
  if USE_SUDO:
122
136
  commands = [['sudo']+command for command in commands]
123
137
  return multiCMD.run_commands(commands,quiet=True,wait_for_return=wait,max_threads=0)
@@ -154,13 +168,15 @@ def parse_show_info(info):
154
168
  return uuid, fs_used, device_path
155
169
 
156
170
  def parse_device_stats(stats):
171
+ # Multi-device filesystems print one block per device; sum each counter across devices.
157
172
  err_dict = {}
158
173
  for line in stats:
159
174
  parts = line.split()
160
- if len(parts) != 2:
175
+ if len(parts) != 2 or not parts[1].isdigit():
161
176
  continue
162
177
  key, val = parts
163
- err_dict[key.split(".")[-1]] = val
178
+ key = key.split(".")[-1]
179
+ err_dict[key] = err_dict.get(key, 0) + int(val)
164
180
  return err_dict
165
181
 
166
182
  def color_error_count(error_count):
@@ -320,7 +336,7 @@ def main():
320
336
 
321
337
 
322
338
  while True:
323
- header = ["Mount", "Path", "FS Used", "w_err", "r_err", "bit_rot", "gen_err",
339
+ header = ["Mount", "Path", "FS Used", "w_err", "r_err", "f_err", "bit_rot", "gen_err",
324
340
  "scrub_status", "scrub_time", "scrub_rate", "scrubbed_data", "error_summary"]
325
341
  table = []
326
342
  running_scrubs = []
@@ -356,6 +372,7 @@ def main():
356
372
  fs_used,
357
373
  color_error_count(err_dict.get('write_io_errs', 0)),
358
374
  color_error_count(err_dict.get('read_io_errs', 0)),
375
+ color_error_count(err_dict.get('flush_io_errs', 0)),
359
376
  color_error_count(err_dict.get('corruption_errs', 0)),
360
377
  color_error_count(err_dict.get('generation_errs', 0)),
361
378
  scrub_data["scrub_status"],
@@ -0,0 +1,112 @@
1
+ import pytest
2
+
3
+ import statbtrfs
4
+ from fakes import (
5
+ MOUNT_LINES_MULTI,
6
+ MOUNT_LINES_SINGLE,
7
+ SCRUB_FINISHED_OK,
8
+ SHOW_MULTI_DEVICE,
9
+ SHOW_SINGLE,
10
+ STATS_MULTI_DEVICE,
11
+ STATS_ZERO,
12
+ CommandMap,
13
+ install_command_map,
14
+ )
15
+
16
+
17
+ def _status_map_for_mounts(mount_lines, mounts_show_stats_scrub):
18
+ """Build CommandMap for mount + per-mount show/stats/scrub triples."""
19
+ mapping = {("mount", "-t", "btrfs"): mount_lines}
20
+ # execute_commands looks up each subcommand individually via _lookup
21
+ for mount, (show, stats, scrub) in mounts_show_stats_scrub.items():
22
+ mapping[("btrfs", "filesystem", "show", mount)] = show
23
+ mapping[("btrfs", "device", "stats", mount)] = stats
24
+ mapping[("btrfs", "scrub", "status", mount)] = scrub
25
+ return CommandMap(mapping=mapping)
26
+
27
+
28
+ def test_main_no_mounts_message(monkeypatch, capsys):
29
+ cm = CommandMap(default=[])
30
+ install_command_map(monkeypatch, cm, statbtrfs)
31
+ monkeypatch.setattr(statbtrfs.sys, "argv", ["statbtrfs"])
32
+ statbtrfs.main()
33
+ assert "No Btrfs mounts found." in capsys.readouterr().out
34
+
35
+
36
+ def test_main_status_prints_table_once(monkeypatch, capsys):
37
+ cm = _status_map_for_mounts(
38
+ MOUNT_LINES_SINGLE,
39
+ {"/mnt/a": (SHOW_SINGLE, STATS_ZERO, SCRUB_FINISHED_OK)},
40
+ )
41
+ install_command_map(monkeypatch, cm, statbtrfs)
42
+ monkeypatch.setattr(statbtrfs.multiCMD, "pretty_format_table", lambda rows, header=None: "TABLE")
43
+ monkeypatch.setattr(statbtrfs.sys, "argv", ["statbtrfs"])
44
+ sleeps = []
45
+ monkeypatch.setattr(statbtrfs.time, "sleep", lambda s: sleeps.append(s))
46
+ statbtrfs.main()
47
+ out = capsys.readouterr().out
48
+ assert "TABLE" in out
49
+ assert sleeps == [] # no -s => single pass, no sleep
50
+
51
+
52
+ def test_main_pattern_filters_mounts(monkeypatch, capsys):
53
+ cm = _status_map_for_mounts(
54
+ MOUNT_LINES_MULTI,
55
+ {
56
+ "/data": (SHOW_SINGLE, STATS_ZERO, SCRUB_FINISHED_OK),
57
+ },
58
+ )
59
+ install_command_map(monkeypatch, cm, statbtrfs)
60
+ monkeypatch.setattr(statbtrfs.multiCMD, "pretty_format_table", lambda rows, header=None: f"ROWS={len(rows)}")
61
+ monkeypatch.setattr(statbtrfs.sys, "argv", ["statbtrfs", "data"])
62
+ statbtrfs.main()
63
+ assert "ROWS=1" in capsys.readouterr().out
64
+
65
+
66
+ def test_main_table_shows_errors_from_every_device(monkeypatch, capsys):
67
+ cm = _status_map_for_mounts(
68
+ MOUNT_LINES_SINGLE,
69
+ {"/mnt/a": (SHOW_MULTI_DEVICE, STATS_MULTI_DEVICE, SCRUB_FINISHED_OK)},
70
+ )
71
+ install_command_map(monkeypatch, cm, statbtrfs)
72
+ captured = {}
73
+
74
+ def fake_table(rows, header=None):
75
+ captured["rows"], captured["header"] = rows, header
76
+ return "TABLE"
77
+
78
+ monkeypatch.setattr(statbtrfs.multiCMD, "pretty_format_table", fake_table)
79
+ monkeypatch.setattr(statbtrfs.sys, "argv", ["statbtrfs"])
80
+ statbtrfs.main()
81
+ (row,) = captured["rows"]
82
+ cells = dict(zip(captured["header"], row))
83
+ assert cells["w_err"] == statbtrfs.color_error_count(7)
84
+ assert cells["r_err"] == statbtrfs.color_error_count(1)
85
+ assert cells["f_err"] == statbtrfs.color_error_count(2)
86
+ assert cells["bit_rot"] == statbtrfs.color_error_count(42)
87
+ assert cells["gen_err"] == statbtrfs.color_error_count(0)
88
+
89
+
90
+ def test_main_version(monkeypatch):
91
+ monkeypatch.setattr(statbtrfs.sys, "argv", ["statbtrfs", "-V"])
92
+ with pytest.raises(SystemExit) as ei:
93
+ statbtrfs.main()
94
+ assert ei.value.code == 0
95
+
96
+
97
+ def test_main_exclude_recent_prints_skip(monkeypatch, capsys):
98
+ scrub_epoch = statbtrfs.parse_scrub_time_to_epoch("Thu May 2 09:56:55 2024")
99
+ monkeypatch.setattr(statbtrfs.time, "time", lambda: scrub_epoch + 24 * 3600)
100
+
101
+ mapping = {
102
+ ("mount", "-t", "btrfs"): MOUNT_LINES_SINGLE,
103
+ ("btrfs", "scrub", "status", "/mnt/a"): list(SCRUB_FINISHED_OK),
104
+ }
105
+ cm = CommandMap(mapping=mapping, default=[])
106
+ install_command_map(monkeypatch, cm, statbtrfs)
107
+ monkeypatch.setattr(statbtrfs.sys, "argv", ["statbtrfs", "-d", "7"])
108
+ statbtrfs.main()
109
+ out = capsys.readouterr().out
110
+ assert "Skipping mounts scrubbed within the last 7d" in out
111
+ assert "/mnt/a" in out
112
+ assert "No Btrfs mounts found." in out
@@ -0,0 +1,10 @@
1
+ import statbtrfs
2
+
3
+
4
+ def test_color_error_count_thresholds():
5
+ assert statbtrfs.GREEN in statbtrfs.color_error_count(0)
6
+ assert statbtrfs.YELLOW in statbtrfs.color_error_count(1)
7
+ assert statbtrfs.YELLOW in statbtrfs.color_error_count(9)
8
+ assert statbtrfs.RED in statbtrfs.color_error_count(10)
9
+ assert "0" in statbtrfs.color_error_count(0)
10
+ assert "12" in statbtrfs.color_error_count(12)
@@ -0,0 +1,53 @@
1
+ import time
2
+
3
+ import statbtrfs
4
+ from fakes import SCRUB_FINISHED_OK, SCRUB_NEVER, SCRUB_RUNNING, CommandMap, install_command_map
5
+
6
+
7
+ def test_filter_recent_days_nonpositive_noop(monkeypatch):
8
+ mounts = {"/mnt/a", "/mnt/b"}
9
+ kept, skipped = statbtrfs.filter_recently_scrubbed_mounts(mounts, 0)
10
+ assert kept == mounts
11
+ assert skipped == []
12
+ kept, skipped = statbtrfs.filter_recently_scrubbed_mounts(mounts, -1)
13
+ assert kept == mounts
14
+ assert skipped == []
15
+
16
+
17
+ def test_filter_recent_skips_finished_within_window(monkeypatch):
18
+ # SCRUB_FINISHED_OK time is May 2 2024 — freeze "now" to a few days later
19
+ scrub_epoch = statbtrfs.parse_scrub_time_to_epoch("Thu May 2 09:56:55 2024")
20
+ monkeypatch.setattr(statbtrfs.time, "time", lambda: scrub_epoch + 2 * 24 * 3600)
21
+
22
+ def fake_exec(command, wait=True):
23
+ return list(SCRUB_FINISHED_OK)
24
+
25
+ monkeypatch.setattr(statbtrfs, "execute_command", fake_exec)
26
+ kept, skipped = statbtrfs.filter_recently_scrubbed_mounts({"/mnt/a"}, 7)
27
+ assert kept == set()
28
+ assert skipped == ["/mnt/a"]
29
+
30
+
31
+ def test_filter_recent_keeps_old_finished(monkeypatch):
32
+ scrub_epoch = statbtrfs.parse_scrub_time_to_epoch("Thu May 2 09:56:55 2024")
33
+ monkeypatch.setattr(statbtrfs.time, "time", lambda: scrub_epoch + 30 * 24 * 3600)
34
+ monkeypatch.setattr(statbtrfs, "execute_command", lambda *a, **k: list(SCRUB_FINISHED_OK))
35
+ kept, skipped = statbtrfs.filter_recently_scrubbed_mounts({"/mnt/a"}, 7)
36
+ assert kept == {"/mnt/a"}
37
+ assert skipped == []
38
+
39
+
40
+ def test_filter_recent_keeps_never_and_running(monkeypatch):
41
+ responses = {
42
+ "/mnt/never": SCRUB_NEVER,
43
+ "/mnt/run": SCRUB_RUNNING,
44
+ }
45
+
46
+ def fake_exec(command, wait=True):
47
+ mount = command[-1]
48
+ return list(responses[mount])
49
+
50
+ monkeypatch.setattr(statbtrfs, "execute_command", fake_exec)
51
+ kept, skipped = statbtrfs.filter_recently_scrubbed_mounts({"/mnt/never", "/mnt/run"}, 7)
52
+ assert kept == {"/mnt/never", "/mnt/run"}
53
+ assert skipped == []
@@ -0,0 +1,36 @@
1
+ import pytest
2
+
3
+ import statbtrfs
4
+
5
+
6
+ def test_module_imports_without_exiting():
7
+ assert hasattr(statbtrfs, "version")
8
+ assert callable(statbtrfs.main)
9
+
10
+
11
+ def test_ensure_privileges_exits_when_sudo_missing(monkeypatch, capsys):
12
+ monkeypatch.setattr(statbtrfs, "USE_SUDO", True)
13
+ monkeypatch.setattr(statbtrfs, "_SUDO_CHECKED", False)
14
+
15
+ def fake_run(cmd, **kwargs):
16
+ # which sudo fails -> non-zero truthy return_code_only
17
+ return 1
18
+
19
+ monkeypatch.setattr(statbtrfs.multiCMD, "run_command", fake_run)
20
+ with pytest.raises(SystemExit) as ei:
21
+ statbtrfs.ensure_privileges()
22
+ assert ei.value.code == 1
23
+ assert "sudo is required" in capsys.readouterr().out
24
+
25
+
26
+ def test_ensure_privileges_noop_when_root(monkeypatch):
27
+ monkeypatch.setattr(statbtrfs, "USE_SUDO", False)
28
+ monkeypatch.setattr(statbtrfs, "_SUDO_CHECKED", False)
29
+ calls = []
30
+ monkeypatch.setattr(
31
+ statbtrfs.multiCMD,
32
+ "run_command",
33
+ lambda *a, **k: calls.append(1) or 0,
34
+ )
35
+ statbtrfs.ensure_privileges()
36
+ assert calls == []
@@ -0,0 +1,39 @@
1
+ import pytest
2
+
3
+ import statbtrfs
4
+ from conftest import skip_unless_live_btrfs
5
+
6
+
7
+ @pytest.mark.live
8
+ def test_live_status_table(monkeypatch, capsys):
9
+ skip_unless_live_btrfs()
10
+ monkeypatch.setattr(statbtrfs.sys, "argv", ["statbtrfs"])
11
+ # Safety: if something patched scrub in, fail hard
12
+ real_exec = statbtrfs.execute_command
13
+
14
+ def guard(command, wait=True):
15
+ if list(command)[:3] == ["btrfs", "scrub", "start"] or list(command)[:4] == [
16
+ "sudo",
17
+ "btrfs",
18
+ "scrub",
19
+ "start",
20
+ ]:
21
+ raise AssertionError("live tests must not scrub start")
22
+ return real_exec(command, wait=wait)
23
+
24
+ monkeypatch.setattr(statbtrfs, "execute_command", guard)
25
+ # execute_commands used for status; wrap similarly
26
+ real_execs = statbtrfs.execute_commands
27
+
28
+ def guard_many(commands, wait=True):
29
+ for command in commands:
30
+ if list(command)[:3] == ["btrfs", "scrub", "start"]:
31
+ raise AssertionError("live tests must not scrub start")
32
+ return real_execs(commands, wait=wait)
33
+
34
+ monkeypatch.setattr(statbtrfs, "execute_commands", guard_many)
35
+ statbtrfs.main()
36
+ out = capsys.readouterr().out
37
+ assert "Mount" in out or "scrub_status" in out or "TABLE" in out or "│" in out or "|" in out
38
+ # Header fields appear inside pretty_format_table output; require at least one known column name:
39
+ assert any(h in out for h in ("Mount", "scrub_status", "FS Used", "w_err"))
@@ -0,0 +1,27 @@
1
+ import statbtrfs
2
+ from fakes import MOUNT_LINES_MULTI, CommandMap, install_command_map
3
+
4
+
5
+ def test_find_btrfs_mounts_empty(monkeypatch):
6
+ cm = CommandMap(default=[])
7
+ install_command_map(monkeypatch, cm, statbtrfs)
8
+ assert statbtrfs.find_btrfs_mounts([]) == set()
9
+ assert statbtrfs.find_btrfs_mounts(None) == set()
10
+
11
+
12
+ def test_find_btrfs_mounts_none_output(monkeypatch):
13
+ monkeypatch.setattr(statbtrfs, "execute_command", lambda *a, **k: None)
14
+ assert statbtrfs.find_btrfs_mounts([]) == set()
15
+
16
+
17
+ def test_find_btrfs_mounts_all(monkeypatch):
18
+ cm = CommandMap({("mount", "-t", "btrfs"): MOUNT_LINES_MULTI})
19
+ install_command_map(monkeypatch, cm, statbtrfs)
20
+ assert statbtrfs.find_btrfs_mounts([]) == {"/mnt/a", "/mnt/b", "/data"}
21
+
22
+
23
+ def test_find_btrfs_mounts_pattern_filter(monkeypatch):
24
+ cm = CommandMap({("mount", "-t", "btrfs"): MOUNT_LINES_MULTI})
25
+ install_command_map(monkeypatch, cm, statbtrfs)
26
+ assert statbtrfs.find_btrfs_mounts(["mnt"]) == {"/mnt/a", "/mnt/b"}
27
+ assert statbtrfs.find_btrfs_mounts(["data"]) == {"/data"}
@@ -0,0 +1,56 @@
1
+ from datetime import datetime
2
+
3
+ import statbtrfs
4
+ from fakes import (
5
+ SCRUB_FINISHED_ERRORS,
6
+ SCRUB_FINISHED_OK,
7
+ SCRUB_NEVER,
8
+ SCRUB_RUNNING,
9
+ SCRUB_TIME_BASIC,
10
+ SCRUB_TIME_ISO,
11
+ )
12
+
13
+
14
+ def test_parse_scrub_never_defaults():
15
+ data = statbtrfs.parse_scrub_status(SCRUB_NEVER)
16
+ assert data["scrub_status"] == "Never"
17
+ assert data["scrub_time"] == "N/A"
18
+ assert data["scrub_rate"] == "N/A"
19
+ assert data["scrubbed_data"] == "N/A"
20
+ assert data["error_summary"] == "N/A"
21
+
22
+
23
+ def test_parse_scrub_empty_defaults():
24
+ data = statbtrfs.parse_scrub_status([])
25
+ assert data["scrub_status"] == "Never"
26
+
27
+
28
+ def test_parse_scrub_finished_ok_uses_total_to_scrub():
29
+ data = statbtrfs.parse_scrub_status(SCRUB_FINISHED_OK)
30
+ assert data["scrub_status"] == "finished"
31
+ assert data["scrub_time"] == "Thu May 2 09:56:55 2024"
32
+ assert data["scrub_rate"] == "17.00MiB/s"
33
+ assert data["scrubbed_data"] == "10.00GiB"
34
+ assert "OK" in data["error_summary"]
35
+ assert statbtrfs.GREEN in data["error_summary"]
36
+
37
+
38
+ def test_parse_scrub_running_uses_bytes_scrubbed():
39
+ data = statbtrfs.parse_scrub_status(SCRUB_RUNNING)
40
+ assert data["scrub_status"] == "running"
41
+ assert data["scrubbed_data"] == "40.00GiB"
42
+
43
+
44
+ def test_parse_scrub_finished_with_errors_is_red():
45
+ data = statbtrfs.parse_scrub_status(SCRUB_FINISHED_ERRORS)
46
+ assert "csum=3" in data["error_summary"]
47
+ assert statbtrfs.RED in data["error_summary"]
48
+
49
+
50
+ def test_parse_scrub_time_formats():
51
+ epoch = statbtrfs.parse_scrub_time_to_epoch(SCRUB_TIME_BASIC)
52
+ assert epoch == datetime.strptime(SCRUB_TIME_BASIC, "%a %b %d %H:%M:%S %Y").timestamp()
53
+ epoch_iso = statbtrfs.parse_scrub_time_to_epoch(SCRUB_TIME_ISO)
54
+ assert epoch_iso == datetime.strptime(SCRUB_TIME_ISO, "%Y-%m-%d %H:%M:%S").timestamp()
55
+ assert statbtrfs.parse_scrub_time_to_epoch("N/A") is None
56
+ assert statbtrfs.parse_scrub_time_to_epoch("not a time") is None
@@ -0,0 +1,25 @@
1
+ import statbtrfs
2
+ from fakes import SHOW_INCOMPLETE, SHOW_MULTI_DEVICE, SHOW_SINGLE
3
+
4
+
5
+ def test_parse_show_single_device():
6
+ uuid, fs_used, device_path = statbtrfs.parse_show_info(SHOW_SINGLE)
7
+ assert uuid == "11111111-2222-3333-4444-555555555555"
8
+ assert fs_used == "12.34GiB"
9
+ assert device_path == "/dev/sda1"
10
+
11
+
12
+ def test_parse_show_multi_device_paths_joined():
13
+ uuid, fs_used, device_path = statbtrfs.parse_show_info(SHOW_MULTI_DEVICE)
14
+ assert uuid == "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
15
+ assert fs_used == "1.00GiB"
16
+ assert device_path == "/dev/nvme0n1, /dev/nvme1n1"
17
+
18
+
19
+ def test_parse_show_incomplete_returns_nones(capsys):
20
+ uuid, fs_used, device_path = statbtrfs.parse_show_info(SHOW_INCOMPLETE)
21
+ assert uuid == "11111111-2222-3333-4444-555555555555"
22
+ assert fs_used == "1.00GiB"
23
+ assert device_path is None
24
+ out = capsys.readouterr().out
25
+ assert "Could not parse btrfs show info" in out
@@ -0,0 +1,42 @@
1
+ import statbtrfs
2
+ from fakes import STATS_MULTI_DEVICE, STATS_NONZERO, STATS_ZERO
3
+
4
+
5
+ def test_parse_device_stats_zero():
6
+ d = statbtrfs.parse_device_stats(STATS_ZERO)
7
+ assert d["write_io_errs"] == 0
8
+ assert d["read_io_errs"] == 0
9
+ assert d["flush_io_errs"] == 0
10
+ assert d["corruption_errs"] == 0
11
+ assert d["generation_errs"] == 0
12
+
13
+
14
+ def test_parse_device_stats_skips_malformed_and_reads_nonzero():
15
+ d = statbtrfs.parse_device_stats(STATS_NONZERO)
16
+ assert d["read_io_errs"] == 3
17
+ assert d["corruption_errs"] == 12
18
+ assert d["generation_errs"] == 1
19
+ assert "not-a-stat-line" not in d
20
+
21
+
22
+ def test_parse_device_stats_sums_across_devices():
23
+ d = statbtrfs.parse_device_stats(STATS_MULTI_DEVICE)
24
+ assert d == {
25
+ "write_io_errs": 7,
26
+ "read_io_errs": 1,
27
+ "flush_io_errs": 2,
28
+ "corruption_errs": 42,
29
+ "generation_errs": 0,
30
+ }
31
+
32
+
33
+ def test_parse_device_stats_skips_non_numeric_values():
34
+ d = statbtrfs.parse_device_stats([
35
+ "[/dev/sda1].write_io_errs n/a",
36
+ "[/dev/sdb1].write_io_errs 5",
37
+ ])
38
+ assert d == {"write_io_errs": 5}
39
+
40
+
41
+ def test_parse_device_stats_empty():
42
+ assert statbtrfs.parse_device_stats([]) == {}
@@ -0,0 +1,203 @@
1
+ import pytest
2
+
3
+ import statbtrfs
4
+ from fakes import (
5
+ SCRUB_FINISHED_OK,
6
+ SCRUB_NEVER,
7
+ SCRUB_RUNNING,
8
+ SHOW_SINGLE,
9
+ STATS_ZERO,
10
+ CommandMap,
11
+ install_command_map,
12
+ )
13
+
14
+
15
+ def test_issue_scrub_command_yields_cumulative_and_starts(monkeypatch):
16
+ cm = CommandMap(default=[])
17
+ install_command_map(monkeypatch, cm, statbtrfs)
18
+ mounts = ["/mnt/a", "/mnt/b"]
19
+ gen = statbtrfs.issue_scrub_command(mounts)
20
+ assert next(gen) == ["/mnt/a"]
21
+ assert next(gen) == ["/mnt/a", "/mnt/b"]
22
+ starts = [c for c in cm.calls if c[0] == "cmd" and c[1][:3] == ["btrfs", "scrub", "start"]]
23
+ assert [c[1][3] for c in starts] == ["/mnt/a", "/mnt/b"]
24
+ assert all(c[2] is False for c in starts) # wait=False
25
+
26
+
27
+ def test_main_scrub_first_wave_respects_max_count(monkeypatch, capsys):
28
+ mounts = ["/mnt/a", "/mnt/b", "/data"]
29
+ # mount output order is not set order; provide all three lines
30
+ mount_lines = [
31
+ "/dev/sda1 on /mnt/a type btrfs (rw)",
32
+ "/dev/sdb1 on /mnt/b type btrfs (rw)",
33
+ "/dev/sdc1 on /data type btrfs (rw)",
34
+ ]
35
+
36
+ # lockout=2 + interval=1 → ≥1 status poll with room under max before lockout elapses
37
+ lockout = 2
38
+ interval = 1
39
+ clock = {"now": 1_000_000.0}
40
+ mount_status = {} # only started mounts appear; unstarted → Never
41
+ started = []
42
+ start_clocks = []
43
+ sleep_snapshots = [] # (now, len(started)) taken at each sleep entry
44
+ state = {"status_in_round": 0, "round": 0}
45
+
46
+ def fake_time():
47
+ return clock["now"]
48
+
49
+ def fake_sleep(seconds):
50
+ sleep_snapshots.append((clock["now"], len(started)))
51
+ clock["now"] += seconds
52
+
53
+ def lookup(command, wait=True):
54
+ cmd = tuple(command)
55
+ if cmd[:3] == ("mount", "-t", "btrfs"):
56
+ return mount_lines
57
+ if cmd[:3] == ("btrfs", "scrub", "start"):
58
+ return []
59
+ if cmd[:3] == ("btrfs", "filesystem", "show"):
60
+ return list(SHOW_SINGLE)
61
+ if cmd[:3] == ("btrfs", "device", "stats"):
62
+ return list(STATS_ZERO)
63
+ if cmd[:3] == ("btrfs", "scrub", "status"):
64
+ mount = cmd[3]
65
+ # Start of each table round (one status call per mount)
66
+ if state["status_in_round"] == 0:
67
+ state["round"] += 1
68
+ if state["round"] == 1 and started:
69
+ # Free a slot under -m while one scrub still running (lockout must still block)
70
+ mount_status[started[0]] = "finished"
71
+ if len(started) == 3:
72
+ # After wave-2 start, finish everyone so the exit path can fire
73
+ for m in list(mount_status):
74
+ mount_status[m] = "finished"
75
+ state["status_in_round"] = (state["status_in_round"] + 1) % len(mounts)
76
+
77
+ status = mount_status.get(mount)
78
+ if status == "running":
79
+ return list(SCRUB_RUNNING)
80
+ if status == "finished":
81
+ return list(SCRUB_FINISHED_OK)
82
+ return list(SCRUB_NEVER)
83
+ return []
84
+
85
+ def execute_command(command, wait=True):
86
+ if tuple(command)[:3] == ("btrfs", "scrub", "start"):
87
+ mount = command[3]
88
+ started.append(mount)
89
+ start_clocks.append(clock["now"])
90
+ mount_status[mount] = "running"
91
+ return lookup(command, wait=wait)
92
+
93
+ def execute_commands(commands, wait=True):
94
+ return [lookup(c, wait=wait) for c in commands]
95
+
96
+ monkeypatch.setattr(statbtrfs, "execute_command", execute_command)
97
+ monkeypatch.setattr(statbtrfs, "execute_commands", execute_commands)
98
+ monkeypatch.setattr(statbtrfs.time, "time", fake_time)
99
+ monkeypatch.setattr(statbtrfs.time, "sleep", fake_sleep)
100
+ monkeypatch.setattr(statbtrfs.multiCMD, "pretty_format_table", lambda rows, header=None: "TABLE")
101
+ monkeypatch.setattr(
102
+ statbtrfs.sys,
103
+ "argv",
104
+ [
105
+ "statbtrfs",
106
+ "-s",
107
+ "-m",
108
+ "2",
109
+ "--scrub_command_lockout",
110
+ str(lockout),
111
+ "-i",
112
+ str(interval),
113
+ ],
114
+ )
115
+
116
+ # Force deterministic iteration order for the set of mounts by patching find_btrfs_mounts
117
+ monkeypatch.setattr(statbtrfs, "find_btrfs_mounts", lambda patterns: set(mounts))
118
+ # And make issue_scrub_command iterate a stable list:
119
+ real_issue = statbtrfs.issue_scrub_command
120
+
121
+ def stable_issue(ms):
122
+ return real_issue(sorted(ms))
123
+
124
+ monkeypatch.setattr(statbtrfs, "issue_scrub_command", stable_issue)
125
+
126
+ statbtrfs.main()
127
+ out = capsys.readouterr().out
128
+ assert "All Scrubs finished." in out
129
+
130
+ wave_start = start_clocks[0]
131
+ assert start_clocks[0] == start_clocks[1]
132
+ assert started[:2] == sorted(mounts)[:2]
133
+ assert started[2] == sorted(mounts)[2]
134
+
135
+ # First sleep is immediately after wave 1 — concurrency cap holds
136
+ assert sleep_snapshots[0] == (wave_start, 2)
137
+
138
+ # Mid-loop: room under max (one finished, one running) but lockout not elapsed → still 2 starts
139
+ pre_third_after_poll = [
140
+ (now, n) for now, n in sleep_snapshots[1:] if n == 2
141
+ ]
142
+ assert pre_third_after_poll, "expected ≥1 poll sleep with room under -m before wave 2"
143
+ assert all(n == 2 for _, n in pre_third_after_poll)
144
+ assert all(now - wave_start <= lockout for now, _ in pre_third_after_poll)
145
+
146
+ # Third start only after lockout gate (not merely "eventually 3")
147
+ assert len(started) == 3
148
+ assert start_clocks[2] - wave_start > lockout
149
+
150
+
151
+ def test_main_scrub_exclude_recent_never_starts_skipped(monkeypatch, capsys):
152
+ # /mnt/new finished recently -> skipped by -d 7
153
+ # /mnt/old never scrubbed -> kept and started
154
+ scrub_epoch = statbtrfs.parse_scrub_time_to_epoch("Thu May 2 09:56:55 2024")
155
+ clock = {"now": scrub_epoch + 24 * 3600}
156
+
157
+ monkeypatch.setattr(statbtrfs.time, "time", lambda: clock["now"])
158
+
159
+ def sleep_and_advance(s):
160
+ clock["now"] += max(s, 1)
161
+
162
+ monkeypatch.setattr(statbtrfs.time, "sleep", sleep_and_advance)
163
+
164
+ started = []
165
+
166
+ def execute_command(command, wait=True):
167
+ cmd = tuple(command)
168
+ if cmd[:3] == ("mount", "-t", "btrfs"):
169
+ return [
170
+ "/dev/sda1 on /mnt/old type btrfs (rw)",
171
+ "/dev/sdb1 on /mnt/new type btrfs (rw)",
172
+ ]
173
+ if cmd[:3] == ("btrfs", "scrub", "status"):
174
+ mount = cmd[3]
175
+ if mount == "/mnt/new":
176
+ return list(SCRUB_FINISHED_OK)
177
+ return list(SCRUB_NEVER)
178
+ if cmd[:3] == ("btrfs", "scrub", "start"):
179
+ started.append(cmd[3])
180
+ return []
181
+ if cmd[:3] == ("btrfs", "filesystem", "show"):
182
+ return list(SHOW_SINGLE)
183
+ if cmd[:3] == ("btrfs", "device", "stats"):
184
+ return list(STATS_ZERO)
185
+ return []
186
+
187
+ def execute_commands(commands, wait=True):
188
+ return [execute_command(c, wait=wait) for c in commands]
189
+
190
+ monkeypatch.setattr(statbtrfs, "execute_command", execute_command)
191
+ monkeypatch.setattr(statbtrfs, "execute_commands", execute_commands)
192
+ monkeypatch.setattr(statbtrfs.multiCMD, "pretty_format_table", lambda rows, header=None: "TABLE")
193
+ monkeypatch.setattr(
194
+ statbtrfs.sys,
195
+ "argv",
196
+ ["statbtrfs", "-s", "-d", "7", "-m", "8", "--scrub_command_lockout", "1", "-i", "1"],
197
+ )
198
+
199
+ # Import SCRUB_NEVER at module top with the other fakes.
200
+ statbtrfs.main()
201
+ assert started == ["/mnt/old"]
202
+ assert "/mnt/new" not in started
203
+ assert "Skipping mounts scrubbed within the last 7d" in capsys.readouterr().out
@@ -1,9 +0,0 @@
1
- README.txt
2
- setup.py
3
- statbtrfs.py
4
- statbtrfs.egg-info/PKG-INFO
5
- statbtrfs.egg-info/SOURCES.txt
6
- statbtrfs.egg-info/dependency_links.txt
7
- statbtrfs.egg-info/entry_points.txt
8
- statbtrfs.egg-info/requires.txt
9
- statbtrfs.egg-info/top_level.txt
File without changes
File without changes