diff --git a/creds/google_converse_test_oauth_20241122.json b/creds/goog/app/google_converse_test_oauth_20241122.json similarity index 100% rename from creds/google_converse_test_oauth_20241122.json rename to creds/goog/app/google_converse_test_oauth_20241122.json diff --git a/creds/google_tcaoff_test_oauth_20241125.json b/creds/goog/app/google_tcaoff_test_oauth_20241125.json similarity index 100% rename from creds/google_tcaoff_test_oauth_20241125.json rename to creds/goog/app/google_tcaoff_test_oauth_20241125.json diff --git a/creds/goog/user/test_user_tokens_20241125.json b/creds/goog/user/test_user_tokens_20241125.json new file mode 100644 index 0000000..e69de29 diff --git a/data/images/cat_petting.png b/data/images/cat_petting.png new file mode 100644 index 0000000..88a2074 Binary files /dev/null and b/data/images/cat_petting.png differ diff --git a/data/pdf/sample_label.pdf b/data/pdf/sample_label.pdf new file mode 100644 index 0000000..5cc5181 --- /dev/null +++ b/data/pdf/sample_label.pdf @@ -0,0 +1,481 @@ +%PDF-1.4 +% ReportLab Generated PDF document http://www.reportlab.com +1 0 obj +<< +/F1 2 0 R /F2+0 9 0 R /F3+0 13 0 R /F4+0 17 0 R /F5+0 21 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/BitsPerComponent 8 /ColorSpace /DeviceRGB /Filter [ /ASCII85Decode /FlateDecode ] /Height 148 /Length 6086 /Subtype /Image + /Type /XObject /Width 246 +>> +stream +Gb!$@6#Hik#XWn<9=NZJ.W:oU9k67.!qZBh?hjK5Y=E!OPu!"m=,n8s'Whi5c20GXl_LVJ6q4l+\PsoS*QKsGbN,:Yr5t.sPbPSE]m9WiELi?EP/sR\31%bj[GuLDCmk3"P$^4iPZ"kH]Jo<:/?Y2B=qV_G#3SmPTQV=$N%L6C9(FK&N`(bcLA0j/S&KMJ<_FX3hV_Wg'@`'&RB^>o:J$#saAGRWg@3t3J$ZX[b%aLYe/OoNAV/WU4>XE>r[Gsm*'$V?=rZ=fpMUi!8QdX%N-^+tWD$,MNb?@taZh*IPp8dJ7;KefoD["o3[q*UO\p%9/sQ`Xa77C+1ARgn^N/B`V.a^jDr/63>UO*j,VnWocD<9^m5U[T>:0B7jB%?]V%g$@n'1_gMsI1?Q:+3U_7InK8^7ugK)eqWq]rL:cK;H?F7UYap6.ML+3s;OO2$*7'QqPCH3q.n>UtMUmOVk5CdgF])75,,=#Q>UgE:NoQP4Ab&VgIc\XJ9$uA^hRn?E\5(1]8S$I_@Fr\he/X(ZVngd4j;Ms%[pZDFD`H>'Z9-U.fi*7`Fe;iRWZ"o:J$#saAGRWg@3t3J$ZX[b%aLYe/OoNAV/WU4>XE>r[Gsm*'$V?=rZ=fpMUi!8QdX%N-^+tWD$,MNb?@taZh*IPp8dJ7;KefoD["o3[q*UO\p%9/sQ`Xa77C+1ARgn^N/B`V.a^jDr/63>UO*j,VnWocD<9^m5U[T>:0B7jB%?]V%g$@n'1_gMsI1?Q:+3U_7InK8^7ugK)eqWq]rL:cK;H?F7UYap6.ML+3s;OO2$*7'QqPCH3q.n>UtMUmOVk5CdgF])75,,=#Q>UgE:NoQP4Ab&VgIc\XJ9$uA^hRn?E\5(1]8S$I_@Fr\he/X(ZVngd4j;Ms%[pZDFD`H>'Z9-U.fi*7`Fe;iRWZ"o:J$#saAGRWg@3t3J$ZX[b%aLYe/OoNAV/WU4>XE>r[Gsm*'$V?=rZ=fpMUi!8QdX%N-^+tWD$,MNb?@taZh*IPp8dJ7;KefoD["o3[q*UO\p%9/sQ`Xa77C+1ARgn^N/B`V.a^jDr/63>UO*j,VnWocD<9^m5U[T>:0B7jB%?]V%g$@n'1_gMsI1?Q:+3U_7InK8^7ugK)eqWq]rL:cK;H?F7UYap6.ML+3s;OO2$*7'QqPCH3q.n>UtMUmOVk5CdgF])75,,=#Q>UgE:NoQP4Ab&VgIc\XJ9$uA^hRn?E\5(1]8S$I_@Fr\he/X(ZVngd4j;Ms%[pZDFD`H>'Z9-U.fi*7`Fe;iRWZ"o:J$#saAGRWg@3t3J$ZX[b%aLYe/OoNAV/WU4>XE>r[Gsm*'$V?=rZ=fpMUi!8QdX%N-^+tWD$,MNb?@taZh*IPp8dJ7;KefoD["o3[q*UO\p%9/sQ`Xa77C+1ARgn^N/B`V.a^jDr/63>UO*j,VnWocD<9^m5U[T>:0B7jB%?]V%g$@n'1_gMsI1?Q:+3U_7InK8^7ugK)eqWq]rL:cK;H?F7UYap6.ML+3s;OO2$*7'QqPCH3q.n>UtMUmOVk5CdgF])75,,=#Q>UgE:NoQP4Ab&VgIc\XJ9$uA^hRn?E\5(1]8S$I_@Fr\he/X(ZVngd4j;Ms%[pZDFD`H>'Z9-U.fi*7`Fe;iRWZ"o:J$#saAGRWg@3t3J$ZX[b%aLYe/OoNAV/WU4>XE>r[Gsm*'$V?=rZ=fpMUi!8QdX%N-^+tWD$,MNb?@taZh*IPp8dJ7;KefoD["o3[q*UO\p%9/sQ`Xa77C+1ARgn^N/B`V.a^jDr/63>UO*j,VnWocD<9^m5U[T>:0B7jB%?]V%g$@n'1_gMsI1?Q:+3U_7InK8^7ugK)eqWq]rL:cK;H?F7UYap6.ML+3s;OO2$*7'QqPCH3q.n>UtMUmOVk5CdgF])75,,=#Q>UgE:rWGIR+mVr~>endstream +endobj +4 0 obj +<< +/BitsPerComponent 8 /ColorSpace /DeviceRGB /Filter [ /ASCII85Decode /FlateDecode ] /Height 148 /Length 7199 /Subtype /Image + /Type /XObject /Width 324 +>> +stream +Gb!$?5u%DG%)_l?Vr\r;gXlg6Ns+.34u%b:O"n!^g%\!fiRqBl=[''PMf%5/'-1X^.T^O-45oIe+Igk.VD.HnL:/H9*o#<$V-D0pZ)UJ+;Al>:#Y>Wi^1PPW33jup`2G%BVL$&3).eR\Kk7P;,dO&'k^8Z>t.DKnn8,4q/0ENc`O>1(M2j>?Q)hBRST+6mOA5!R;jd:r6ed`_?R-J[X]:SH!unf'[QQh%;'r,[osV+=Xr[nT8UY%To,_CT40nC7"N.RRC9i4p,8]+Ca!WpjUf=MR2Klsf,=+5e:J>3R@gm3E2L^crGd^XIQ4Ifm@b^VO6qeuHj_JW3oNgE*!N.$IQ>HJ@'Y=-!L;UcpMeH&j\e*SL3VK&PBd2-E,IOc+)2itEGE;CYad)a!$DOW^.C-`/Td0o!+])o"ot4Bnj5b6KqGZ#+i8U8(CSjm$\m-X8eIAh/CXltk^d]rbD3%J7PY%iZ]l!+hX%.bX;P0>n]Z1lCNbnV;_=29CROfQ5aMkUTnVN8].@$sCNLb%]8hDTPoeK[@*M(-YqN1oX[`\.r5cdo,T@jbME&nI4&`"_>s`mAm>iti#VB5?%.q7oHP[V_($FHf@r>eP"@KW@UpCf6mm_S!o+g5^"I,/NbbXdUd_fZ;%?mI?$n1+AKh[uHqtogk0=l4[*`pD?9POc<]n(nRj0PN;lc\_+>R51*4SV/l7;YnO8]$*m-N@LD]J@q[s0nV[OfZaFPRHse08gU>-M3m+6tY22'[_UTks2Q9JG@GYK&D+?L5O^NFB<_\kjCuf'[p@q$GR?YkEgW!!uH6gQYonb-AH]4ZcaMokG%+!M)amt"B>\r.9TBI\e;Rq,.fEkW?"PKS70njn60shtSpdKenfig795-U0-D;4(a*G&+u=)T$>iTcOJS(&g_0^g`N[Nca+82*/1X!Z!p2KH'>H[MQ_9M4F3D.=qu4VD:hqSX*14iYilAE+I4L1b;PAGO#10",VX:NVgmWEd,Rm"DE0Jo">WR)6MV(tt!dr$&uHCos-W+5"ZR[dEUAN\W&KX1+(ZE:lb`:$NBjR.hFbgQ@J&,T*,T\r;gXlg6Ns+.34u%b:O"n!^g%\!fiRqBl=[''PMf%5/'-1X^.T^O-45oIe+Igk.VD.HnL:/H9*o#<$V-D0pZ)UJ+;Al>:#Y>Wi^1PPW33jup`2G%BVL$&3).eR\Kk7P;,dO&'k^8Z>t.DKnn8,4q/0ENc`O>1(M2j>?Q)hBRST+6mOA5!R;jd:r6ed`_?R-J[X]:SH!unf'[QQh%;'r,[osV+=Xr[nT8UY%To,_CT40nC7"N.RRC9i4p,8]+Ca!WpjUf=MR2Klsf,=+5e:J>3R@gm3E2L^crGd^XIQ4Ifm@b^VO6qeuHj_JW3oNgE*!N.$IQ>HJ@'Y=-!L;UcpMeH&j\e*SL3VK&PBd2-E,IOc+)2itEGE;CYad)a!$DOW^.C-`/Td0o!+])o"ot4Bnj5b6KqGZ#+i8U8(CSjm$\m-X8eIAh/CXltk^d]rbD3%J7PY%iZ]l!+hX%.bX;P0>n]Z1lCNbnV;_=29CROfQ5aMkUTnVN8].@$sCNLb%]8hDTPoeK[@*M(-YqN1oX[`\.r5cdo,T@jbME&nI4&`"_>s`mAm>iti#VB5?%.q7oHP[V_($FHf@r>eP"@KW@UpCf6mm_S!o+g5^"I,/NbbXdUd_fZ;%?mI?$n1+AKh[uHqtogk0=l4[*`pD?9POc<]n(nRj0PN;lc\_+>R51*4SV/l7;YnO8]$*m-N@LD]J@q[s0nV[OfZaFPRHse08gU>-M3m+6tY22'[_UTks2Q9JG@GYK&D+?L5O^NFB<_\kjCuf'[p@q$GR?YkEgW!!uH6gQYonb-AH]4ZcaMokG%+!M)amt"B>\r.9TBI\e;Rq,.fEkW?"PKS70njn60shtSpdKenfig795-U0-D;4(a*G&+u=)T$>iTcOJS(&g_0^g`N[Nca+82*/1X!Z!p2KH'>H[MQ_9M4F3D.=qu4VD:hqSX*14iYilAE+I4L1b;PAGO#10",VX:NVgmWEd,Rm"DE0Jo">WR)6MV(tt!dr$&uHCos-W+5"ZR[dEUAN\W&KX1+(ZE:lb`:$NBjR.hFbgQ@J&,T*,T\r;gXlg6Ns+.34u%b:O"n!^g%\!fiRqBl=[''PMf%5/'-1X^.T^O-45oIe+Igk.VD.HnL:/H9*o#<$V-D0pZ)UJ+;Al>:#Y>Wi^1PPW33jup`2G%BVL$&3).eR\Kk7P;,dO&'k^8Z>t.DKnn8,4q/0ENc`O>1(M2j>?Q)hBRST+6mOA5!R;jd:r6ed`_?R-J[X]:SH!unf'[QQh%;'r,[osV+=Xr[nT8UY%To,_CT40nC7"N.RRC9i4p,8]+Ca!WpjUf=MR2Klsf,=+5e:J>3R@gm3E2L^crGd^XIQ4Ifm@b^VO6qeuHj_JW3oNgE*!N.$IQ>HJ@'Y=-!L;UcpMeH&j\e*SL3VK&PBd2-E,IOc+)2itEGE;CYad)a!$DOW^.C-`/Td0o!+])o"ot4Bnj5b6KqGZ#+i8U8(CSjm$\m-X8eIAh/CXltk^d]rbD3%J7PY%iZ]l!+hX%.bX;P0>n]Z1lCNbnV;_=29CROfQ5aMkUTnVN8].@$sCNLb%]8hDTPoeK[@*M(-YqN1oX[`\.r5cdo,T@jbME&nI4&`"_>s`mAm>iti#VB5?%.q7oHP[V_($FHf@r>eP"@KW@UpCf6mm_S!o+g5^"I,/NbbXdUd_fZ;%?mI?$n1+AKh[uHqtogk0=l4[*`pD?9POc<]n(nRj0PN;lc\_+>R51*4SV/l7;YnO8]$*m-N@LD]J@q[s0nV[OfZaFPRHse08gU>-M3m+6tY22'[_UTks2Q9JG@GYK&D+?L5O^NFB<_\kjCuf'[p@q$GR?YkEgW!!uH6gQYonb-AH]4ZcaMokG%+!M)amt"B>\r.9TBI\e;Rq,.fEkW?"PKS70njn60shtSpdKenfig795-U0-D;4(a*G&+u=)T$>iTcOJS(&g_0^g`N[Nca+82*/1X!Z!p2KH'>H[MQ_9M4F3D.=qu4VD:hqSX*14iYilAE+I4L1b;PAGO#10",VX:NVgmWEd,Rm"DE0Jo">WR)6MV(tt!dr$&uHCos-W+5"ZR[dEUAN\W&KX1+(ZE:lb`:$NBjR.hFbgQ@J&,T*,T\r;gXlg6Ns+.34u%b:O"n!^g%\!fiRqBl=[''PMf%5/'-1X^.T^O-45oIe+Igk.VD.HnL:/H9*o#<$V-D0pZ)UJ+;Al>:#Y>Wi^1PPW33jup`2G%BVL$&3).eR\Kk7P;,dO&'k^8Z>t.DKnn8,4q/0ENc`O>1(M2j>?Q)hBRST+6mOA5!R;jd:r6ed`_?R-J[X]:SH!unf'[QQh%;'r,[osV+=Xr[nT8UY%To,_CT40nC7"N.RRC9i4p,8]+Ca!WpjUf=MR2Klsf,=+5e:J>3R@gm3E2L^crGd^XIQ4Ifm@b^VO6qeuHj_JW3oNgE*!N.$IQ>HJ@'Y=-!L;UcpMeH&j\e*SL3VK&PBd2-E,IOc+)2itEGE;CYad)a!$DOW^.C-`/Td0o!+])o"ot4Bnj5b6KqGZ#+i8U8(CSjm$\m-X8eIAh/CXltk^d]rbD3%J7PY%iZ]l!+hX%.bX;P0>n]Z1lCNbnV;_=29CROfQ5aMkUTnVN8].@$sCNLb%]8hDTPoeK[@*M(-YqN1oX[`\.r5cdo,T@jbME&nI4&`"_>s`mAm>iti#VB5?%.q7oHP[V_($FHf@r>eP"@KW@UpCf6mm_S!o+g5^"I,/NbbXdUd_fZ;%?mI?$n1+AKh[uHqtogk0=l4[*`pD?9POc<]n(nRj0PN;lc\_+>R51*4SV/l7;YnO8]$*m-N@LD]J@q[s0nV[OfZaFPRHse08gU>-M3m+6tY22'[_UTks2Q9JG@GYK&D+?L5O^NFB<_\kjCuf'[p@q$GR?YkEgW!!uH6gQYonb-AH]4ZcaMokG%+!M)amt"B>\r.9TBI\e;Rq,.fEkW?"PKS70njn60shtSpdKenfig795-U0-D;4(a*G&+u=)T$>iTcOJS(&g_0^g`N[Nca+82*/1X!Z!p2KH'>H[MQ_9M4F3D.=qu4VD:hqSX*14iYilAE+I4L1b;PAGO#10",VX:NVgmWEd,Rm"DE0Jo">WR)6MV(tt!dr$&uHCos-W+5"ZR[dEUAN\W&KX1+(ZE:lb`:$NBjR.hFbgQ@J&,T*,T\r;gXlg6Ns+.34u%b:O"n!^g%\!fiRqBl=[''PMf%5/'-1X^.T^O-45oIe+Igk.VD.HnL:/H9*o#<$V-D0pZ)UJ+;Al>:#Y>Wi^1PPW33jup`2G%BVL$&3).eR\Kk7P;,dO&'k^8Z>t.DKnn8,4q/0ENc`O>1(M2j>?Q)hBRST+6mOA5!R;jd:r6ed`_?R-J[X]:SH!unf'[QQh%;'r,[osV+=Xr[nT8UY%To,_CT40nC7"N.RRC9i4p,8]+Ca!WpjUf=MR2Klsf,=+5e:J>3R@gm3E2L^crGd^XIQ4Ifm@b^VO6qeuHj_JW3oNgE*!N.$IQ>HJ@'Y=-!L;UcpMeH&j\e*SL3VK&PBd2-E,IOc+)2itEGE;CYad)a!$DOW^.C-`/Td0o!+])o"ot4Bnj5b6KqGZ#+i8U8(CSjm$\m-X8eIAh/CXltk^d]rbD3%J7PY%iZ]l!+hX%.bX;P0>n]Z1lCNbnV;_=29CROfQ5aMkUTnVN8].@$sCNLb%]8hDTPoeK[@*M(-YqN1oX[`\.r5cdo,T@jbME&nI4&`"_>s`mAm>iti#VB5?%.q7oHP[V^LL?_[20;u~>endstream +endobj +5 0 obj +<< +/Contents 25 0 R /MediaBox [ 0 0 300.4724 413.8583 ] /Parent 24 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] /XObject << +/FormXob.3380198a8f991da010c93d0d1ea12014 4 0 R /FormXob.49022c30e1beb1617bd3612f012d5ce0 3 0 R +>> +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +6 0 obj +<< +/Filter [ /FlateDecode ] /Length 683 +>> +stream +xujQ{b.[J=딀944≠ +(Sw}~ݔVg65nu 7mTvPzg6zu<߼<x-[|:L/ݛav62m~PxժԻOc*dSt|/Mkִ7M4}YM_4}􇦯ti4K/4K/4K/4KMlsdӟ#Ȧ?G69ϑM?G69ϑMlsdӟc~_~_~_~_~_~_~_~_W~_W~_W~_W~_W~_W~_W~_W~_7 ~7 ~7 ~7 ~7 ~7 ~7 ~7 ~w~w~w~w~w~w~w~w~???????uCn +{hx\Qx\> +stream +xĽ |E8^U}#s昙L= !! "I#(rz!ނklPTE]fE @~?TWzޫa-G 1|?x[@9i9 M+*p $딖kDAY=9;k&@^<푋1"T5a|q +N i0}9 =!yS>S&B9 ½{nÜYo- vhӼ YԈЌyƦF~]İ?ෙ˃WFPO]sߴxxWetw$w;| `M'/h4yt#KЏ>q8 }vzΕ, %N2Wtwj^3(T^jyZԎlq{؂b-Pt=:޽+Fgv '֠y^> vq"]}@g؅q:ނ?a:w }gc8栛&=,PPz +Bgя /"1_1߳}-Xho23@D<OM),'&6ncj zDר]@!Np#d'19'nuu_A~$^71 EסЭX@3蟀It؀8W1*< /FwG^|g𗀝I w YC";2e'ֳ/qarr;{}DL@9VVz D;/jGo^9X,<0&p X1| _CA"Lri ؂6wUhd2C?0O10?I]ʮe7s\)75qw\'ǯD!_$(8pq$,z ^ |+pu:ہc*Y[YefS״"oykTzu33fP:TumAtd +5LmeZdچ1h-9m|Ua+Iиv f]uv2kvC䶺V|[] p/4gLw-N_;s2 :++ZѨڝCc'fz~"\p2]0m;luOqzk1=5Sa g@+QbZA GGh^AnfN}\^'5f^v|seNC$O2QE% mNR+{e=dVd!mHQ wֵPE`IWt*p3*9Ȁ4@@g ZAS׺HI4FYИZOk.XWɢj=k렆f5@!(JQ;uyELk֗OuuP*p]69pN 7\h[6|vmZ:m2#hCj@6|hZuy =Ԇ)\./QP8()pC)\G+()\֛ALp! +W:+ +JwտOA)<RxCk:(GQxݛc*(2>Vg80)BFQ]bOOYR*f*Ae%rᶮ Qa_88`nΊ&EF$õ Y m(H252\d¼~yӏXl7yŃGȩr`V;r-Kb|b˂[V<""[C/7)gX?&љqU$]ˢM-H }sw,戙Л#m389#Ѐ`bIlNIE ˰ 9dM0kN7 l8jBߖ9f2p|#7p>64vWNBۘ&/3[߆XHP =.akd,ن{pcZq]wV %Tf+,Qb_Jd J_f8 JVe)d+1ԇQ[e())'of/3{yf5)#ycCsyle6fAqr'nVд]9]iiB f#P)!8-ㆳùE'Ӝ6ss&)3=;_JOPlɬK+gL5He@ >ʪ 3߯9|44pFSo+ꣽ㣧?no7@qs}RaQaA/%fɾd +ї y假>^ Ӈ'4~LwPCÁ';|G/EU Mzq^9/Z~;j՞άqq.r'cAkpܘ> ?a/~MEU1FUKfk@-P>@dC^Gygh>~d8j:j=<(˂5_ԲUi|QsUZϘC L";!c11 cړHIU*m;<"d{2η]__^ /-Ǐor ׿Goi +ul>f}xIhfmzs߇Ƿ/dS} +z:堾/}4yеZ ?W[P2ZYD~\}۳*%I55K*=} iψWXUmq : +V5?m0 x:gmq03 /x_&Wg3<ݗt@gaz s Oa;NB_̾ L1 %d@DBiH+! 1H!IWߟCp;}\X-J7o:Vzc.w*cm"Xҽۆڿ"MO Y}6D]і<{TP:;CF 4d40|0 nݰʪwoͯsXz۔owV&qnc/}?DNpo =0fg闠%~f6fͽ5CCJ#e&^F4~Vhe0$IQXg&Ay!?H V^^Yv2Kֳ(USm4Lh0he1e% faP0(Bam0|E~m{떯kq?PU/prUvMw+Fu`xee6<@82Y(X=  x2M9bN.*Sְ(8 Pߠ }+Ka(hO |b&M%)Z0Bc02n$O)+RqOaY2q PIȆ!?}ةzTPh 'r@ithЌSQj%jBF'`9+oS\o7J fp\Z*lGϓ>5z@I`F,؋ N}{h1';EYI Oy^k Fl +j[\ap,)E-CtQM{-_lh4߂Zx_bĠ#A[׬o3DgGn>}VS5ũ62 j~G\SNF#xܠ/LթCRYa@\6 yqIfMr9ך>qYh'c=`!vaj':7 d1A#Q:`LGA3G2zau \ YYy9#xU ) n0*N9ͨ%xxLX<eS7m-NZ(garǐ]7'.i(^g)I@o ?aUiTsT*Z<{Q]21U{1y?| ʂ(\3M]-d~Nǁ:hGE=h2o6[(݆L0Ng,6t_'/vns'Ty (O՜ +fZ p +f_(َ/x.,IfI2 yec0s`Ne͋΋fHN M '<Кp$!!*3BajB 0 pYThTLigh""ȉȪՆaҜ& *ɪ_׭-}?ح|{KQ_> +77M:n{Y¡7O1M߳t^\/_0J,9 ufBxEm7$GXkZ}*  'O,T1{ਗ਼ktP&"LyUʒex%~sbQYJQk.dwF0f iچ'VSq l/y=zs/gp8h^-Expj*w5'OnV>7kFٷ~mٌY aIƏk}ZZf[`޵|'OᒒW5Y8vӆ-A "=nbi8R4R >?ELzUHMy_)3V%j&4y!E" x4),Զ%8AdB}1h;ի9ИT0o},E+9npV- +\(Q;['3%M 3,C`$f6% fyL}<4)" X3Tf n=:Kw (a#[g]F_VT/S Bϰ2TdLxWDaS}MC w[/`^<[P2~ +yk]X'EjB!),x_-e8݂ܵTlSA䷡^4.t(ޕD`w2풝I sĤ6sSAV Fu(Զ:F̓D#~ _.,|ZNtٖ:}L0q?~'WKϔb`C:P6dFʒMQMP%`IܼܼL߷~_ߤfb5%mwxle̽)g- X5ܘrYmv[}&-)1 'X!^L;p8knj)j 7Pήv;ͪ @kK=5.g*@`۬<5fLn7lVh2lYI6u۬Fi8Ow(FOfɈJ|qy.%cSܳGyͽ/=:a}Нs;S/~3^u\晉S0NGZ}nT >ۋ͋yA9xb +cpʾEQNAH!n;LΚNK"./W-vyη6Ul$Jd,p$ZLpqI`(AL9<8ؑ52 6Sbѻep/C~PSVRB +&$fCFֶ/ǴuU@^誁^9b0p`-mODVvN +}6My%ЏY5 Ц ETs3g LUV+aCi)Dc \}LG$#-(p#t:;;T+ vf1'P(G>5UÐ̆#*g)/Pxͤx`sEEieUhM%ʾAzl )|USw޴bz0n&hb1n)t`xMckǣw˯5GOl7ڰ&uS0Ne-Ex*w==OʌpTpM\8#5;ķIr(?~IQQMֳݹf?|nf`;F } +|\цFs7aA >mii){;]klb7bt$"*E'YvGv|Djx2j:b5YI*b΋W7eg6Xß筞ۍ*)u\ Y&7 IoC #h}<@yz;Ӈg˱*IW'9g/>HM9G34> i,+1iH"߂FL B-MUdle-8*Xy{Y )OuнtsYum!=l*ICuMW~$lJ8ҧEE^c6k+^$ TIx2LbI]Ý [4[@ +¥ j|S1z:2Uc#fzWyMyL;䪫ZSh?؁:B6x+?*k?u}6#}5x( zhPAphDhC{3jqo"JT* +?n9$s7C/sCo ȟBsVhl7wR접%,ZEI{4;@3RAʛ8w3?}6`%$i\hrD[]z@ ɁԖ_@V<0w9~Gn*JTDQV!".%LEBX"fFbtq Zi܂5nGsR,'ʒUK`r +NfkJ2̴ĴGm:hG Q2LFID^ IȈ!,#(0S٦u {qu(4v1RX~aP2jOK5Q>/c-PÜN/b:^}]1@t,- +_$ !4#2Ȁȋy8ɡHcB~u0RUՐx]ʡc5EЏ]W0j/}VùqcmV,)58"#._̛Yh*'5'ZDwI'tg;v5|cAX+ԳL‹\pyW'9+ +/uYt6 XLȉIp??r 7tǃ;GVS_ ;pl.{^1vӘoω<RnB(2JC47`ơT fޖbȗHhP.\H4i -ڨJm8YY8,?.wGd\2mPZSV+wɻ`.ݰ2XQz7R7711߯;z0cszUbGcKmH8I5ZRɾ>bw26m=o`=GFMݕd֞u4~4?aK~~mxo3Uh +9*}.aKyL-dJق+ XtRY%ߑw> +8@rж=h7$Lsnx8y{V< ~.^q(i V*gH0@Q$$ ԮJMllΠ>%(7r}`U0Ӹ^1+g\$ܢti5.!`O|1.UqMX h@8&3Gi(XRpj6

DLF5|}wrh@^nR]WUPSti>Î{mB K?gr/#r`MZH4 ٝi,gBKv|'agİ:O] +?c\E>_Y3-?jTN@>iI|`}}oEY{994%kλ]ǐoo [}̘ln%b Kiif9h~" ƽ&;^&Fk Q.TosB^vTquU5zjoA^ [P1ywLHR9/m07J WS~ Z={- ؁۾ď}+n^wÃg# /% 4xc.g䩮1t抺Nwnԍ3zM@\!K|]uߕ{~]wbv"^nl?bvWx`O}%O WEoěe8na{%-jKfl5۪š֡AΉ։ #˸/܆xa56!7\?I?O1|bG2b4V$0 ˵DL$.x7q%np?Ou8ևzBH"xsX*WDF9zOXzUA|CѵE6Вn:Ǯf6_ ˇHyjnLv,uȆn;e]h8I@1A Jja #"25HU,VHIf(4b.fn@JI:9 !ɠMšGHQՠh:. *{ :C @݅ci=01'_XԝcnddN֒iS>N$R}벚~ѵ fFR^?(t=YӐ1|xf] ?61X7brf&Й39lIjkxl2R"LBzVq(trd,&r–rwa,sQNgqYco $3r~y;V)6ƦGM1\=Z$5vH +mNXht8meוF(K x>x?iSCl+-[jRQ &G}m#<#윦<~ < -)- :TYc$-8y9ѬqE]Whp&UU +T+钑ۺ+0Q@d/YfTKF],KJ-lʟܿTٶ3wʧ;+Cr=X:(K0w^ cAWg-6NUϸ.L TiQ*a O,l]*=K9/%tścrY9oOX΍o:o^> yC ZՅ x`DDA Et| +:̂nM_4ƧU~#G>~=w!b +%k1i'zϰ8Vzy8&gXyȡ,괦YFwL[:̩}N 페J)P:Ҝzde}mO $7Oԛa8ƏaC[3ȋ/mr%Gnϖüp???G@jm^eĸYG2čO] t]g,uU@^Ъގ+ ^, !pi5@Rz$\z]e1~Ζ_|-:Ü~hQ _8_:` `_DB=6Q-3U+=1^|qv8pTEBYH"̧Ʌl@dc:yY~}Z~}^'ɧse,L` 3}/KvY#L(,iaVYs 56en%ƯǑCۨ=+-L%^&[e_t3ϓT:JNPيwģ-I ++$zW){' +7I}M>yV02T j9\/,lQUj&M֊:j}&FEI(-Z/HE2.CT`u4d?F:kNٻ>4] +#՜ +* -Պ4ȁZ@^Dx)Lva+:2ILtJ.3Wξ,|ۄ&l-;xH'&Ћ,2k SK؏$^`T N5BOz}Bz'}_:%e7KDbE y09XT#Zb}ePI{8b.X-T1 CHIhL8'̅ Reqoth]ûi$dL'q:0)wX@Rc=HP]Rt_0}d9}6#,G" אRe.+laU=)Ruױnj_IkF lSʢ?gGu fHa ?@gD]iVsG.dkj@@dԸ}>9nIh~㎗%>G=ap3ŗr̳^]guzt>V$f,\nwJ$ӥYd#sR5~ja݀S1{)[s\|xy#Lc1?S@k@Jb{rrml,aAܨ2y/&pZMv;mu 9n0۰kgiƶ zpt!lS+#@x5H,+gzׯK0ӎmwƺJF7&:Pk~ +o'tګI 5uayPǂJEw{Nt47JwK? J*BZ ?p>H#+ ߁>c@ qe/Hda } -pr^僐\ {%zKᝳ4A6Zz-V>!Mg[Kqn:2i5FxeVH~W$/C*h/&\R\Z2i. +_I[賜G/TG[+$ Ƞ Rf5ڧbpG]%0 هWXn"w {wIFlߗJ.EJcL<@]D/迉*Z!0pa8/Sisѭѝd뫶blMiΕ1Θ1b+bž7*n~3~fˮxW{YPڄd=݉ɉ7Fdfi g?i3@E`] Ϙ̠D<3HOG`#=D`; !b&QV#){`D+1FZNf~2z +*Lƀѿ#0ԃAYPwE`̣X48# Œ]*,û ,*Q F`a*8O#0|t5#Ah,r +m*LcK0(l-a%p`%F)˘)nP?=*(ĪE3Yi|Lϟ +3b +;(DU8Fg +Ѷ p#fsT8^ +2QU8Amk +4Sw7p@- +S'),2)tU9_Rab5a6A8Q&ʅs6*l4́B9\4e>}7fyPn1oTU*J@-;RlE zwٙمMMina|Syq{茹.njtrG^ Мf_pƼ Fg8ПүnҸpUd0o_+Tګl2L4r3.ՄVם=51;7; 3;?3Wsg";6 ^ 4Nł'&h\K-Zg2/Rݙ*o(4U2UOG ohߒ^xƩ6i?=oZo>K~Af7T1kFwn̹*>5=̃2_FhZhΈѨsV-C\[7H妠]a-Dp\˝Pn*Wk;r~qjݿR< KlcFdrjP~Jl?KA}9{|gTniK4Fn}>qZ~4rZ?wo{g  HfK=}79$g.08sD}-)Ɓkj8Ӽi2m*9-I[ ռ79|HO=ߍa^߰q ͽ"̋L;w8?Kȟ2|OdweB/t={гVG\0l1dK!`P|ʏkLu>]BJACk\[1 #)C"J57l> +~k>TIjU5_JG闚mt}F Qt}gP.i\8} g!:[qVPCҔ%)+VTT^ WDZ3 WkAw֐5 dl.VRfR6 @ +YEFlv5.wQ +xrA3o>|gw.{x{y3!;zA[u?&=(|'$_Aν׏zx2[o\?H_ #pxhՁ5>cBX/^q#. '?%&v8C.i2ՂTߔ@}0/m|412Q?eY(d<䥙S4{Xq؜?#Eniz~c|kg&TڮDbѰo.GX7ho6@I6sZv;*ڃx1ֳjCx'ՌlF[EǢRw+Yo/LU)\p2*s +/M3.LOJnPBendstream +endobj +8 0 obj +<< +/Ascent 750 /CapHeight 719.7266 /Descent -169.9219 /Flags 262148 /FontBBox [ -1017.578 -480.957 1436.523 1159.18 ] /FontFile2 7 0 R + /FontName /AAAAAA+Helvetica-Bold /ItalicAngle 0 /MissingWidth 722.168 /StemV 165 /Type /FontDescriptor +>> +endobj +9 0 obj +<< +/BaseFont /AAAAAA+Helvetica-Bold /FirstChar 0 /FontDescriptor 8 0 R /LastChar 127 /Name /F2+0 /Subtype /TrueType + /ToUnicode 6 0 R /Type /Font /Widths [ 0 553.2227 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 + 0 0 277.832 333.0078 474.1211 556.1523 556.1523 889.1602 722.168 237.793 + 333.0078 333.0078 389.1602 583.9844 277.832 333.0078 277.832 277.832 556.1523 556.1523 + 556.1523 556.1523 556.1523 556.1523 556.1523 556.1523 556.1523 556.1523 333.0078 333.0078 + 583.9844 583.9844 583.9844 610.8398 975.0977 722.168 722.168 722.168 722.168 666.9922 + 610.8398 777.832 722.168 277.832 556.1523 722.168 610.8398 833.0078 722.168 777.832 + 666.9922 777.832 722.168 666.9922 610.8398 722.168 666.9922 943.8477 666.9922 666.9922 + 610.8398 333.0078 277.832 333.0078 583.9844 556.1523 333.0078 556.1523 610.8398 556.1523 + 610.8398 556.1523 333.0078 610.8398 610.8398 277.832 277.832 556.1523 277.832 889.1602 + 610.8398 610.8398 610.8398 610.8398 389.1602 556.1523 333.0078 610.8398 556.1523 777.832 + 556.1523 556.1523 500 389.1602 279.7852 389.1602 583.9844 722.168 ] +>> +endobj +10 0 obj +<< +/Filter [ /FlateDecode ] /Length 669 +>> +stream +xmjQsb[zׂi7`m*QFSw}~!لzlM/onm=uq8aRz۟nuLTw7f?Y,<<Ƨ>mz?u=}<n6Y.u|[vzPxXu\ u͖":ߜG޹Vog,KimښMjM_6}_4K/4K/4K/4K/NȦ?G69ϑMlsdӟ#Ȧ?G69ϑMlsdӟc~_~_~_~_~_~_~_~_W~_W~_W~_W~_W~_W~_W~_W~_7 ~7 ~7 ~7 ~7 ~7 ~7 ~7 ~w~w~w~w~w~w~w~w~???????yC> +stream +x| `Ew$MS4mZz %ܴh-](r*!""aS(". +xTEwuE4qBơ7o5J &'S3\ 𩪙[=_9> ָ#pcwpCEwY6=3C!GgVO \Fȸ?gF+3cnMm  +SWSvB*CsΧGTVu^ R{#W=naC@j %ϯRuNuD$D #DO,O QJN_V*4Fj%-!U<2`-ZA$H -=1#13Pҋ&a$DHTH4K$ē>$$$LRH*I#$d,MrH.KHO|28 R@ +I)&I Bad8AFQd4)%ed )'c82TJ"D2N&)"َxRH%D&e_u|(# h⻛$Uӓ4:>QBJ.-0/B}].;z;_4Diuu͚j; +JXPB9UaU.+M].Vֺ\n. +@XXZn fsiUO=i+\ B9M]SVYUV=UiwA*zPn!ɭ,L< ^XU^`M50[HHv+HW\sT S؃YB"FR !Z^\d&a5 cEr〤fQ,vd* Vp"`/pm [;02 +@P *9$lV"W;־4٭N^V9\. A'5uf((p&ܤ4W rS30-l-X5kV`C3/&c.`&%@ v L|)tR8R,[Ij{ +F<Lagjsâ3!ILvk)>uI>I<> I>r|4'5+iIjghR +8vo c3g$(V606>06>c|&UB`uU!,fL-Hr&cAF@K7`ݺf\M1@*>NS;.MMilr}ѵ*!ɚLL"na[8(s SRE 3Iy= Y7ٝbN5T@O Xk7p5%ؤSPIdӒ*qP܊5)v+ښ"PI'ZUee9+o ;9ޮ* >6P +/ªiv7_X= 0Pa\ߦH%m kgüFzTT!E$[#(Amα`3V(k2Y;:^je1<%c+S ŞB+cy, E.RObY;ngBTA~~uI[SeAw4;v]ԿoWq%{$wz%kvjOEɲ럄VÒ+CQ[lCdw8<|SS6;MhHq'Ftޠ|(j4.Hr£V |SSQʢ`0  E C##QhAq(C< 刃XA`0q@*A`" 0 q=ɝcd|q4AU1yRaO] Ğ DMrgP CA:A:3ɝC/ u6uunR;ǔ.Eg?>$ +'fl8k}.ZxTy CDyaq/vV:M (%CLPH8/#< GRӥҩGGrZhv^i_~3}z֩Sq)}%wWەn }LCCh eM&k>K)Oie=!9 n +q4Ji7'ȂX0п;uAJA%fPrPe_ /F*P"3P$x88\@9DR +('6 eʹhmm46Z5tiL~)ULPz'>."W,\jX瀠Ęܜ8G-: 59rr3Mbw`QT(M8Gv&[t)4 }E݊ONO9u^lRF\sUH`[} Gt=q~VмgDn(oQ|ɓcV\l}NWѪ8+n$Aɤ!BRDEAh$*)ǤSO3aC)Y7iSx_CTao5j[mCjLJkgg$Dž;0mo1DDc4D며G;r@5PaVL_+Wgά[ume,[6~ܲe`T2 +ga3\!e-[ +\_^a~`bx fJT6/V,$-١(yԇ a9[#PF!V,'hxWAJ[/sbx90&Ű +_:&;35%OT&TcUkEsr[ X}ع4f*ljj& 9Cͅ=(7Nɢ(, +yB=X +!AtTLGѭV7a]G4&WeQχW[d| %__bC֛״y .}Ƈ:te;KjGW#z:SoqI'ǝ:ʋNN`#bX9f(' +[eyU*MJ/RS}ۀJ%mD(!@|:Ib3z9Q˚~ّ43ÏY@P6;5:~A[k[j[ Dnd2mq" +ЩΖM31qHu[}^V/D:ͯmBA-J` $D `?,boH񔓉InIQ(δ_V(hO(aX;t;Hšawff&#gk#J>ĀҙQ5r--/]TR`^ySz3LFv|$?bDU·q7@[I(WRAPP/e{WM zGzy *N1 AX0R{n{4i%yA+̟<~V UK8-]t7qw54=_VK }#MLWwi:I^Ӯ~{/08F0Ň0ƕɇxX}6Hxpht80:愐c(jR +Ioɵe _UV$G*zK%Bım56MAe!dXY^S)r k3&CT/h//5ONP[fŧǣ~0R0̀e5aٳꞝKF_:ַERW9铻䝫&w߄{p{L,m|W'{ړǥ_/y݇-DA(YE9>wn&>t?4Cv& h6AN1V TV+  gZ g--簣` +;\F[M"ߧCܛxǾ~ySz.xǚ} X]Ny:9aΒes#Z79/? +@l M-WF0A3U8YgQ HlaJ瞏ʺs R!-bY+ۿB<"jRQ ַ/]&7Z-&P8\ƓTgȄs##iBݵ˂e>gn|PlOcmzzXm)Gv"d̹&Lzͅ]\`hb8!!4ZUcD6B&{VNWߓ^>uJjk+VV;P4g1⁳gTYӬ߳c {>8%1|$Y ThW%A  0^EI$ h)եQn<ܵ3ک0_k!ӠV8 1q}blD +7j>0O96+Nq\.np0ܒÍ?AlVc)[FLE!؅ʇ?;~[ڷ|~1:̟,Oy*!cMOК]4rIi?A~VIsTO)~R:>0(4xi!<M݉KTu=g&>$۠HBrY`(uzND-)& F19PJSNbFloZ8A1o_UqMǞ8í&G\-G!, +BO8{&SOvr<&jNyw4MW4gM+|lfu%PQdRTJH9ϐvQV: Rчr..#\>aV'oj&M\+5.1깵 't `?WFR){́CN0mĈ$x3*=" ;ǃh*:16Ep!&Z4&P=2?P^n&8W,C.D,Ik><{C~y}/Ғx+]k$9Kt->9 P 溧yhO^ Pyztu++|L>4u bHb,NʃΨ@j;'^i곛*kocs=X^Bu4VIܷǥ\璃jJ<[5 TpYIJ. IW/Nz|D>!MBjF><׵\xE*#iX{"2=ڙrVCꑸÌ6Iv;מz> W ށ= 4vcXWap6ۀ\Bި{?lRي# +NQ٘IFƂhDD΋ seMb>wFk*"/eqjGxC__/JJ(%JGNgھ;^8칿z,D;`2yw{ +./ '(^*;YN]e +R`f4vtpZ<޾rkjDŽi݆ߚ9 #O>;>BROPV+ 2Kտ^ӥgP*!`d.]&?7w3xW3g.]6E]`[QN*@g C!4B)cd6Qξ$wv'YܙvwF$/pve<n _]_9y |x ,;gnm?]j+7+,lMJ3᛭FpyR~>l6z邔edS;zj&} N 4A|Y<~!^-k.Gx?9㬶=>Y )u)qZzu(㟴Lv]-6Iiyv`a<:0ttYmZ4g N 0WCZȨc6|EJ܎+wg_gf?נTu7i6LX  p} /bȁ7gsol .xPK3;&)vĔ0dg`UN*L2R.ʬ'>S=DN OluowMeICxn$m{kK33b'\9ִ;^N`-&JR)3J_& |]L_M׳SV4. P%8Wd %NM6Ylv" %dğ2ڳCIL 7ҩbT>'A-; ]ZK4{k0w ylH󧦃ϝlJ.TGԈ[2;bw=7&dK!1Q SUmDž¶k=#l{q)irD<%gzхg_2ԶF{ 6rf#юcrPᵼվGKfel]`2Wpy_xӦJsis` n֧XG|]:;niAY_Ma{!^ߒcGRFzw^YAZS"4e}?DXQ E-SuJq'tHjkecE.` AYYb=Zhhc:.%Pp({?8 Jp`ĸ`o{e`|Mkhn7>7B+ fcI\fq$sNjGMqmȘ;5+|8[iŋ,H]B*6q˚'$ ݵA$&O*Pdž ھ%I8|-wmS9^^FXYZV7r]\;+޷jj4l? c:{~tt`~ SR]T.|4Z ni+b B'Dх7)@r. ˬx/1h9hNgnŲ40h1؍1 i` {DO8\ G%Q f#̟lut̀1Ǘo=4tܱ6rNw).PbPkXghCaa*[Ö]ެ&yɱ]wĞ_&H[3orX }&7yOឝZ {a"( +69 f{gh)OJČ`*(84bAA +ٞ` 9 +3tUڈټy{4Mch!6O~/V*K6kKX _Ciބ + ZB uɞDβ|<3=hoЛvbԟ3=A`˺om3!`Z9g +lN _jlo))9w# ۖIkY;MvW.y7HOm9cyK`VZxآ]S"!;9"a88[x o 73x9Ge#$$#ľFSާ9K%qC.z>21#e'~.z˃?߸˜:8ǐqTM W<6#Ke@ }6xPVmR̒ENe~kMEs=5==[ ІiJ8ekoߊ}0mR!|jX~!}cRHJݐg,XQv?c]bvO: n#5|¿gr{DG,d/~JI icn$ gKk'Q298!"5G~$ {N%N F(V X0fS@5,ŪuSc!&  wQ'[P`{HcTa ׋ۋ(Vpka};/ƿTaql(Sob[ᶵxio3oTް5~(e2''B,1:cHǞc.흶⥢ؖ==뎍_'%~cvOx\; c0f5s-#B*zt:oBz: !E]Oo Є^45i iڶq9=J;TA~g7~4!78a#u= 2p νG-cބA[m/%^}P9=XV=]@{Oa 0œӎ/I[貯+%]&Jebi;,D^@s8yU?cxahNmJ| sy)NJ;D&h3RbFj8Cz^E +[h1(`|g: y̰1]\ntO[Mz:q~緯ִ5ҷdvXL4k'v8hh|hd +tſ,ؽ {W޿D绛DyG?(Z\h7P zMo.]m)mށM#7p IlѼ{9L48^b2_&Xǹ c>EIi6O\ʺ-Wݩ7=j9?^97viyp˺1[tlPp;O񾛞PO4`njf+pl {BÃBDSTA+"r*- 3L:/b-;~t}CcJ8zM XHĵ +c`±k +rL,_4,#M/}^6%n_ϟk r6=A=~E=M$`?̽O 4.$Ne)l)#(a{}<-U3>Y)H C @{.$g0V$|<piR#5ڝCvlpC}))p,$ T觑q R/Ob#}3pYIO᧣^ rv Ig5@; 7t |}9#YzN& t',>qxCbSXe[Uju:Z2xVI͗!E!Cޥ=._w^5h F>?ME{MgfxY Jˮ;{Dذ]O4HcX^MNcHdVQ1dU g%Ӗ<({ /lǽᓿRD$r,6̓(: xC;$d'*N7Zb0~V0sE#さdqj>C9(P2Ϙd"?:̓| V{`"g=[=L?{`=sULOyGh&$?189Ƈq b[Y }/0X Q0XxU` aXIF?W1718;܋43fp+?`ڮvz`%#l._18=mm;Oe0Uy 9*"AxKy; Ow38O#VN4L2wɁAd>Cj.$ud.-" ^%Ca7@pa d 5u kY?%~X(XN9z ҵ L-V+Tkʆ*YaxWtK? =ًQE~*St@LhVТ@Y@e/jvQ|(XnI|5 endstream +endobj +12 0 obj +<< +/Ascent 750 /CapHeight 717.2852 /Descent -169.9219 /Flags 4 /FontBBox [ -950.6836 -480.957 1445.801 1121.582 ] /FontFile2 11 0 R + /FontName /AAAAAA+Helvetica /ItalicAngle 0 /MissingWidth 633.7891 /StemV 87 /Type /FontDescriptor +>> +endobj +13 0 obj +<< +/BaseFont /AAAAAA+Helvetica /FirstChar 0 /FontDescriptor 12 0 R /LastChar 127 /Name /F3+0 /Subtype /TrueType + /ToUnicode 10 0 R /Type /Font /Widths [ 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 + 0 0 277.832 277.832 354.9805 556.1523 556.1523 889.1602 666.9922 190.918 + 333.0078 333.0078 389.1602 583.9844 277.832 333.0078 277.832 277.832 556.1523 556.1523 + 556.1523 556.1523 556.1523 556.1523 556.1523 556.1523 556.1523 556.1523 277.832 277.832 + 583.9844 583.9844 583.9844 556.1523 1015.137 666.9922 666.9922 722.168 722.168 666.9922 + 610.8398 777.832 722.168 277.832 500 666.9922 556.1523 833.0078 722.168 777.832 + 666.9922 777.832 722.168 666.9922 610.8398 722.168 666.9922 943.8477 666.9922 666.9922 + 610.8398 277.832 277.832 277.832 469.2383 556.1523 333.0078 556.1523 556.1523 500 + 556.1523 556.1523 277.832 556.1523 556.1523 222.168 222.168 500 222.168 833.0078 + 556.1523 556.1523 556.1523 556.1523 333.0078 500 277.832 556.1523 500 722.168 + 500 500 500 333.9844 259.7656 333.9844 583.9844 633.7891 ] +>> +endobj +14 0 obj +<< +/Filter [ /FlateDecode ] /Length 684 +>> +stream +xjQ\*"SXV7Nvj i*]_n<Ёd&OjqC >Ǿvn3Lʬ[m_'<|pWz7ϻ|p7]~l]]n7z\q3ś~[pN&E/}:}f¿Vad~rqa׳23r|~$Evizִ4M[t4M6}yMjK/4K/4K/4K/4gώMlsdӟ#Ȧ?G69ϑMlsdӟ#Ȧ?G69&s_~_~_~_~_~_~_~_~_W~_W~_W~_W~_W~_W~_W~_W~7 ~7 ~7 ~7 ~7 ~7 ~7 ~7 ~w~w~w~w~w~w~w~w????????7&"|YG8:n ew{P_endstream +endobj +15 0 obj +<< +/Filter [ /FlateDecode ] /Length 11675 /Length1 19784 +>> +stream +x||T-e7 ٔ,m)B*! HB BU)"E(*y*b}X$;{7{}=w3gfE!!(7{?5j.A4eFU. ()s[z*>hjӴ-xnL-2oBU2TUULYP|hR .(}~x_;eIC迴a攪WɛV37qjFA 86ln!F5f4s*<@p{ O0Cͷr%KqFNW e*?3%lfܐ0h@`@J䄜 rݑD^ _AZ!C(`ʀBG( P4A(#J@fP2JA2P&BQ6A(4 ACQ1DP)Ơ2T*P%ơhMDAqNDax$ÑX#30r.8ø(?O'01z}A]~< 2C0V[EխAڪB(5>GWgWz0@80tyl +Ę|XIȊu+u`uJhqVΊ(QJ+gVhؿU +tχѩ;*0"[NЂxQ {h +h<+an瘡!Xwb&HP@Px-O){KTh xQJ +YG@k9耡)!ep;bۭ¨} ~x9JpNp + j~8qT&XU=``<czQ|Z@+` +`r5!0e> +!oUT#SewqjwVo4/A`FK:gEidE"}v*2#Ͽ~Μz{kϗh{ۙs\*O.d-ȥ] ۭI -/HwQ7dXX/<,\큕 +F-aajqؒi8Qjږ#T1ӼTIAN>=ܽ5-~ivτi| 20nop^Dxyڜ(^z^N~!? }U?nO~!!?A2qy\{tl3h`-ZV 㖶I_z^z|ʫ3^9#߸≯^w&YXj!֭ms:9%7Ԡ ·ݔggSr,ǛRrro"DNRйb| Gԛ\DʹF,ujp!KhX!vo?APԗzC??Fw1fœvB rӋ-/v,v{YeQQ;+TC=5| .K{0zX16c_4v]]-_̉[zٟvҩ#c;>$tn&eb(n1np`Sb$͐ &"\戂F +o3gfH(K(4S0 ޣ Z1 m5ѫ2ju5?t)cv4^N޼E ֚t~QJk7TrEkotqq+ wس8X2p]!drMdk#A@&NjDPa\r-ږ̵l'/J©7lC8Q;/hmfp?PyhyQ3Jh<=Sp +#c;2㧯+lLl"؛N8ohD^OQ^G^:fO__{<` СPD) (įgU!*W@Q0 +'Ņv2LTT*MO7qlQ~q:xfScq2&&?ѿ߬:{ZӶvbr7~1*Kٮȭo^ºG-zo 4yHJR(\PVY@5a⬱/O&a.Ʀ H8<߳{?~4b ֢+Qj+GgRULedQP1f +”,RrlUL^4i,w\gӓ!ܓNO]sC樽[?yf\.{ж§mo*{\[=lD5[?9 E٩1¨eT;yQLWBahs9{;LfÃ?zl >sI^)3L.,~T0]@EZѨgYqw{mXфi oZ퇹-,Y6*.,Kj S"6jvw_TL?]sǞN_?ZO,'+, WX,LEݲ.y368jF#&IxD4dRqyk%Օ-8aGIQb+h26;/a/KuJM+U=R{QSK:6C5ZJ N:c{<X:84 _\hʺ8w_&.oF-<.K x<V. ]#%TH\A\ŚT›R;4!M8Nt#0Xx +>q~'tY?|-ܲ%]k[UZ[5k϶QÍ!z>%l9%BiY + l)eu4X Cz#8_X0 j@ڙ7_O&/$sSwwj*i=H>[ah|l0_/^zk뢇;'㩟|B|x d>|d'ۑ\J12%$2Ol`lN LΐwWZO۬r[YcFRX?|^hk>);~ Cm:T}I:{aO!IV> ~Sm 'uDT'%zAI^k +vz|r\9Ko7GuF Em) $Z3<U/nJVC0ݸp0 {,\\:V&{S GgJC8 ag=Ir88gË.Y٩qew=TH](<'&=zr򳯾ΑƔas8g,9T6oI~y=ȓ{\s{#YeW uc)Ӆw8wt +M]xN怏=@1y237݀noojoM[#{ U{_ 6/B믒,9JW'ssY~z[ɗ+8m94"Rf)#X뽿d̅G?kWa{Q= lA6.( +3Vx[XIS'4a)g3_-Zɰ 4sjjt#bGu*% aJWb9i\)_ش$u#qͩEEn<.4!(+g{'6~_;+umTP|ю;Ӷ>^4(`SPPiuD~<d0 $e`d!D2(E+R-K6(\װOwRvm*U7.l&oɗ|'1/iV!<ඐe 7"8|ji 'XG!7qZ\i}1{֙}'E1(r[S]&^t>J#]t.Plw̒k4DJ޺C\[C9ܟt#~[B?zy p'2K ''m"6Z@Wqi"Gd b5L>zKiHvRF,YOb@DY^>ۍQ5=R6Δ[ 0[Ӎjj`^#}7Z:')|%ZpVK, =rҞ5QwB/Q0ߓP_WR2#GG gAq0,eBl\xCR?ԊjGgaν'fWހC +u5&9ԏ\_ᄄzWwgu?'rVEe-{onN UӰ'OK:׎_WXqKe&] S`'huup@GQ` 弘/J}jZ4ȫ|C•ԳAU㬨J!o_\6(*"\é8`!. y\/h̃rLїOY+0zKC:|ٍ;@LN0{}'!|-p9\aAp $ -*DSvQR4FM^3x̍w>e&-~`~-pMFƂŧ@^@٣}f=C;M66}b_ؗ(LԜlvЮcD覓iiG#6Q/|=1\5*qOK,neoɿ`-.'no%_Cm}IxK`l/`hJ6ɵ'ԇdinFh+p(W>;Y\-z2Ԍ\%>=eܔegAoK+r!ب Ņm~nƉCZ7pmOuqmfM-spxׯx(ՃeۉEzg{,vЅz +FI nNxvA^sZ1k}kfG4 =Ǭ )VYǭN:HZ+;C;#zc+ERj?/RĆi$:Ol>}^~ϮmA] ԂCjAr%vrJr'8۸9qÑ;0b]>: 2 rꕵ|bᣊDnGo&2Z?XH1ל.r@[Nx+ƢX{Cv)iѡBN4K +T9]#d (,@ҥv٦@GӲ0f +_WuݽzJw72i//Z[GrdEON*VTŌ[im?ےX2)66U^`5O(w W +7!T8B-)m[ȥUjD +CҦ.{5P7zaN 1:4~ww9B 3=QEI倴#_O1Sj[Vƪcdd\PD ~xcL^p7Rq;KMޞ/@b9qrJ|kۋP2A4u<|NJ^!aJ@YIF*޲2?bty!"?xpD-!KNW=>|a|K$/%0|Kio!%+Fnz2G[\,s$C6݁nwdt{ +F7=vŞK4Gj:_'vm|Pވ8v~܁GMKN/%VXYWEa,p\ƒ$;֜JLjN[=w{ B;~s\cwYQ{JWDeENXr.)䞘ݟ '*},{dۛDKU 6RG?wȥJX&sȤjY"U0X_э.Fo/ffϵ䊨2Κ05,׭yFu>cif_̸E>E?`@xF1rt b-(8yG̜y]9W;2vzRiJP[`Wڻ>(W1>CW܀PߵsDYO0HTK=>}UrAP_$o2{KK4}}%$搩+zs$-_;Qh8^/Y6xBuҹuD:#fߤcܣ,,y^1ePy[ ȅgqvyB$!~pТcXsVy, iBt `v8OO^ fIc<~ǥ$Ep~4ΰ^^k4)-[\ĎF/#!0Ƌ9/SB0,S9 +_?rxP`.4ëpu羧žq.*7av%g;9R>[xs㞛0 +LJ[H*^^n]}LFS؁"Q"kSkweC_Id);w "uBGךʂ\GVR?Ϭ,m_aHCE-0DnUiIޮUP\զܙL< ۶Hz6Ta_&7q~]YcE"gpތ,6zn!+yzc *dxhfKdڔҐIJT[ZPb$Ir 'SgX׬H޶3;)icmm^DEƿ qX;CCtq]~=2twJMZF1SϬP0v˜m H(lonu/ zJWV7z +r@g9dr V>L-n;L@.=ht< R$ax۫B{C 4(U(c| g} ԭE\Z:h*4 e.PzPV9< c4SG*!0WPvC"EQkB[pqy'>;7!Nx@%!6>VkT'NANӝ69u9wvqncݞukswr/qG={{yzux{y/nWUVAUGO4ZV[{o[mMW{E.$ f:ZʭcV\c}\P zN1rƶ(j (?&2,"/.Lȗ"Jd澐at'`_` ~EL/~3%% N ~=˰00eG8U H 2@QE[ *ë +c`+?Q 'WB =(]B:1`AUzY/[9/"5l ]֋ x8:W\@~+(?E,k!lNG +4F,Q؉N1 f8+n4;]XqM(r`5A\pfϳ5~  [TԣbFhzBV,i6У^P#jv=OHK3ŚM}NSSC>o挦9-5ES}jqf˂}N^MkJg6i: 4&m@sN8%\0n + j@a P fͩ >3M@ &AM3J2p8w0]`3U'$ͦXSRlBFC#TƤ,s7fv303o_YuL سr5uf}*u*iu͠95U53fgNuTO,bXڻݷ->Do$hT _,_O':Ӹrp6U:hUȸ|)K}bJ|_as Zף?`k,C^tj6sͼc.L?6uv>6}O/"ZסKH3Kӳ269L6nUddm~ul!ذV# +B0LM!]H.{&Mg:P=LjCqg6^1^P*%IGs h~flgdΡT8a^az0ףaP7Z# @U 42Rd*Jӣ +=sPg0g;i> +endobj +17 0 obj +<< +/BaseFont /AAAAAA+Helvetica-BoldOblique /FirstChar 0 /FontDescriptor 16 0 R /LastChar 127 /Name /F4+0 /Subtype /TrueType + /ToUnicode 14 0 R /Type /Font /Widths [ 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 + 0 0 277.832 333.0078 474.1211 556.1523 556.1523 889.1602 722.168 237.793 + 333.0078 333.0078 389.1602 583.9844 277.832 333.0078 277.832 277.832 556.1523 556.1523 + 556.1523 556.1523 556.1523 556.1523 556.1523 556.1523 556.1523 556.1523 333.0078 333.0078 + 583.9844 583.9844 583.9844 610.8398 975.0977 722.168 722.168 722.168 722.168 666.9922 + 610.8398 777.832 722.168 277.832 556.1523 722.168 610.8398 833.0078 722.168 777.832 + 666.9922 777.832 722.168 666.9922 610.8398 722.168 666.9922 943.8477 666.9922 666.9922 + 610.8398 333.0078 277.832 333.0078 583.9844 556.1523 333.0078 556.1523 610.8398 556.1523 + 610.8398 556.1523 333.0078 610.8398 610.8398 277.832 277.832 556.1523 277.832 889.1602 + 610.8398 610.8398 610.8398 610.8398 389.1602 556.1523 333.0078 610.8398 556.1523 777.832 + 556.1523 556.1523 500 389.1602 279.7852 389.1602 583.9844 722.168 ] +>> +endobj +18 0 obj +<< +/Filter [ /FlateDecode ] /Length 679 +>> +stream +x}jQ\*"Xo @3Tݻ|:wsWWMfXa8waRfjvLánn2w0>uoΎ׻w=lͯv2Wu w}q:bѭ:?enxnv/U}/:.:,y\.&uXNyv\.MϚikڛOY>oOM_vi_i_i_i_ٱϑMlsdӟ#Ȧ?G69ϑMlsdӟ#Ȧ?d. B/ B/ B/ B/ B/ B/ B/ B+J¯+J¯+J¯+J¯+J¯+J¯+J¯+JoFoFoFoFoFoFoFoF;N;N;N;N;N;N;N;NAAAAAAAAxX~/R y\> +stream +x< `E3ߑLIJK$-mzPBK)w[Z,-\(Tȁ|TBh !h(Q *EQ*G#H4 +UJ4Ac +C?C֍? $Pn2/;>wIɻٜB$' K%N:0& +YRRar:*3@ٕ3f3 +TJ*²e53JR@,eU+++NXY):QIE]ee g +'/;bdt$'kaB?>_ 51`@X&,Ry˲Ҋc +ePa$ԻJrr6 q?0:?ë/ +N$8tr INM T194^ =8ՕJ)cDɏT tH)axN"ٚxЉk%9mP$NA1@ކ[%9l3R(A03ZXV 9$9m+_\'OrV . &(`[ +*QT;+XɯzXRRфa=`aXcal)lzQ`j,9ڜDd4$HEkEaو[bT$9+θ'֥u]U 63цM:mqAI97S/OnJ: ޜN͚$g-+əG G@z, K8pٲ"6ix#,G1օ6 +dS0nY(A_iB܇#J0QpV-(8`(Mj<C#AESeXtV`i2h `[t++^`VB]r EVt3uE$=|/Ġ]??lL?(^MV-.-}S359DRq'N+z˳DDd!rwlrX=B>Q*D>6UiM3<2^/΂ OPqsη^-8 v <5֒ +G8u13aRIJl?`vWuwU3UV5PN3e3"cyseNNRk sOgŮsϡLS .Y8P]J 5 +jEh"fEnӂTffy*8Fs*8Q)o cQp +y̲AA8 8D`4L) [vX5: ltDEFFDDG#4jO:J>X_.g_~d ?elcp igvh~,/G1i +N22#U)Jx_? +~~ J ):Ye8m___inj햪.-hT+c|c*nDB'+4KІ5C%˞gcX ++z=C"-JFgg( z6۳)aIfi "__Ȥq'>C47msn3 Xe}1ukKPk"T~n ;qB{n#}fSvmcѦWA"ed%9^ɍ!I?^1Z`cA)2*O#m(F@aKo!bw+TDxjQ1'bc<XYV1VHϲ+Eb_V*ͼxzUmҊ_W¬EZZǮ8sf< `ZH^9=qOLQ..q!A*{UH 1&e2+X+E1_x^{-yrnz{~ARq`++=d0D $ 5diYZLMޔ< [k8x%ɘ}v&8̞3~ޚ5\mẼҖ>P膾nw<şxEˏyK%EKn+;*e_M_E +Y27DK.C+1bլ'0˞3V`.HU&< +CmRj~pZ1Ǒa6]6w^bDM7_hc~KoHL5ҷM[cT1z5 TżciXԩ"1Kd,ؠW ɪ?2;wbx.0c~p ؄M\?Ϯ[xeEXbNP#48(0|%ep0CW $HJ bBnPq8{`g~f\轣TCfX+k` ;#9CϰbG=!DP9Ix/\눦czeY4~pDo(1ʰDIiqs*,JC}>$=E:_@m0Ǵ*LԡR2 %q<]QaQ'1)-'v{sA#ʶw6|"sYBgpECDCG-9N +`m/Bl&8SҮH#E'ϵy}Y/re>ʞGg*O?]6`CHAb':>~7UDceˠ=!Z[%r)YcAjV ,3g.3 l#2,cyKKҰup7gKFG܁&qO+pW+>h雿Rݱ dF: H"7.~* `% ZVRIQ:ճ ujҹ.?/Vn:Y8z)v8QP"NnN4!XqP7VVTEm4v~ ShLT>fQk0޹H]p(qVXg*2wO;ܣ~/=6s{*6j#X4M߈Ґ;ю($TI  ԓXr L:rMuJ$HN6ib++V&ZKz?11,xvGU~d+ N.QcL>LD6h XcX\\?b}t*.>dhM:~Ϭ Wϙז_9dxFTLI6[ P0͗*^9h*>J^M>8j@k(p@E(]=K6l0$4w_6=Q5 +'{+ S`@"|-oYGMg'ы́qҞF>bYEWql1[P%"y/Z wi?-iNqZw?޸44gx?^1ms5-iDh}LRW^ix +<'28qGÜAaτ&q +旎mo~B])@"JGa +wRnO#0Zay`!Xt7c͞$Hjr_-xs]z߉ٛ2Ř(FIxsJ6>3<ƥW]b,Շ+,KIc–V/>H fw|]Gro({%ȍjS +{EH+EGdi͝^qvutb72bМBV 31º~ZVҒcBq|P>.KJ_825et"[{K^ֿG͉7_W~{SzLӛs?k.%}uhn??r,Td:ρNbٙ^婇*.vF=D5{-LsJy%jkдC yBZ!J"pteR*AaP_,n -|yQEg#)(o[*CVj $W`P&;̏RxE:DIĞ3Ҏ[1w +gHff_-tmÖa|Wϖ>ݺZzK:']}m?L=܈g%:d+$*_wMY̍&y\un.*o%1 ,wE]E)5iIp7c{CzU.n|k+3jEso:?2 "|1rt|m~>EXbmT%[ROV]ZZDHrb}?&m{:~rm#zf-I||9e ~f\ʗ!x^L9 +.`I3 M؄y`{'6J.i4ҮLپ3 yi=2 ~0Ñ/x4gFzx jsNy=s^ +,79. 6_YLyTr(8w*y>C{K*N^*C YHfvi\#O>x|5+vM:{܅O2۟fCK̡p{;D|nL՞܈cT"dr# +1@_I|rL6p9A)Z3!hNc1RlfǎzD^CX6AyEo̼K]zlXTNlMr-ّ!6.>Y޽IkV]˨\q-aA ވIƜ;:C숽jt"D9$dQNo!J +DN)'~At L߯]m=ѷƿ??.5f  ڜGt^]΄L m H$z=3{iBjJlWأ?LWzr"a3Si55J=%RkHTɮI-}k%ow߼ڙ7$~R]LL9HΫoUzBtHg3XϪݧ1X]ؗO;s&eo3c~p5hUbq!zH.\`Xr!]'8W ZCZz2 {ݿ⾸Za|B~; +B$!ƎdT.'jd BA`4 +-@& `ms?Wyn3M0'8h?#3Lor.ӽ&%O!1Xbje$ _8 P{ݸ +/b]f,={ ~@ t\A+VA# DP( z8+bᩅ1'WL 7TNJSVjaOmopk,MٷS$K=Sr:7{9fkoڤQ6xq HS;֟`@?LF<^t +vߗ)'0YrȀ|!wIәo~>}A; rl7lRt_Rdu;!WNqN > =bNgjtǒٝ;[(M}1ʬsMeǑ8݁u@bDl7 +;fF}M/Be"Xr.G1!T㉮D6M߹Vlj:>6r'E?JOYy~*Jߘ ќS> f'n#w +eγN4u_zD#u|#QVfJRU4Q>}`& +}Q5\ĉKP&ʖd#2/tQ'n_T#S& ̈́“Ɩ I({mӀ\#cYvi"_t jȽǟ@j v{J1ԥ0˼d|b3wO-zpڀs,=d̢;Anj@5NPL}T~<'K:vWQb|v~=3bѫ,}u#iZZS옾eǏy$ +GNqfGo=:G¨\^-O Po._#ʀ&b-ti≦F")^\7{D/|+1>haT IJ)?>kYi8w^xWΞs]=']haG+BȌS"E%VRJ NPĝwn^oDcv(sbjcz^ir2ȉ+h)YwgcO%6Mϭpd_ꖼDi}YE+K"C+0zic&r1瓩F3N{/ C5M %WI +VF>%I1+oG3Y ެ6EcͯumZs$?o,</,on^˽u TRd'a3]{!Tv+Ii6"Jص!׷&G&u)l5f?Y{'HVԈ6kG<#M!&&eۊv.|\\ttѽ)sbN{8N,x=̼MZ5%(Œ?ncK%^K`;nGE-|B>=CI^>iZ <>83k\n!eECJUJzbVA +K\Oz1ҠW9lW9z\WYy0S>?@~ͶfWڂlnɉcs{gXb۫4sSI\= +wRaӓ0%; W7 n|JD.fȲk,939PqdY)0ǡ2r حe7Q `NG7âP8КbL*X@\/LEG'Js'Z׬+>PARxcOGzH\rh![7l󛹔 }Gm._3KowdMOT= I2] MbϺ^~eV4Dy*}f'{ĤBɗ#bWzBj\IQ5Qϒ-;ׄ`}~YS\oÞm; >d +K>ƦFOiJ#ˌV oܞm"n{j{Oҋ/ڣ'_0Ui߿-pb;D;Y;P,69԰L'͗X8:kWH*c(S-;ь[Fr<^I&*ҋnapGaWMESF8zlzOS6\E|A޸(!qN3xlӱ ]o9Ϯ#ztYwASitMx 2nމǽq/?Ňh@8\I뽡6XMW:4Կ|zxishC$E1b҉} q֬g|QAsWu5yt<xFU at,Inaκ!HOx, q+fol,)Dž=5mwp}PA?İe} !`b#U|7NT߃qoyNQ*(i%%'ڤcNQ@u^4혰[;"]~)ON\ +%u8g-]F\2:uԨ1f}96a}^\>zra ݴQ)5Oplw|ǏM?%J7Trd: +}EGt (ЀAk/ `3Wi+37:v^M E +ѯ_-_*bf}]/mv7I!JDԂS^pw[oH9XVȈ90 Yf&8]!k_1Wxqr]bjLĥrgVv O@ f5 _G6!G6ʅMOl:|둗vp)ΑcP΄ e-fQ#^rZ7: -<6Fnfac{lL'*rƧ0 j*}lci%="=k*ic( ބqÑ`YfrPXAXz[pV;Smtefk|hSޡD.eRib`)oNu\&̛%>r!MN\hWs,5,}rm]ҹJUl+Hq@D^AU2=ߎ# m:H^*,m߫ 6CuOp1˓#…'[h~s5{_{kzF݌ލϽQ= JVj˞{;w`u~[ ùqЌU(ya~0luOvsvϝw+$LTG?.u0)[t۴Qh/2-Y߰]Fݣ56j::C;WģW _й~8sŒ`ib&1՝"bA(p7"zE,ZkJ=jhP>HĀ?ݨ˿);(=U4Bo-0z\H01~Ş_7qrG.uMG*4O ! C FUWFaďD|ftp@QGl >LgE [B( }mV좸yK?OfWq_LGiPx/I'&}8-g<`9dMSX ~_BitXG~;|c'كr0̟\ ƣux8mqsmo/+Y͊rU*= ++pO࿃zܬSmYr14>ST0rUHaUaWtYtm CᯆE̋ٸ^עʣ:% +;: ~.FU}Ws/TFk:[hD"x斵LfGQ z cOsn 0Q( +IvJlw(wo& +/+ +< +gX;})Gˋ)LfRE` #䆡v,a"nVX& +5P!O=[I J, +aҹ¡9 +ɷ>PSXKߠpGGGz +_0 &7\"Qqp i[B9{±O9G?/؏{t9JA H9ϢH@i($SQE oDm.P2$>~ h53o~|Qr(n ZLOh3)&f } 37̝S( Q,4aȔ3,h &9iB9))9m)3g̦ NC76%P7m^ݜ)峀:x?mʬu7k%\K' +DɄ4ZNux*&3!WHKMNY+HJLJ˺qo $^B#gÚx@I;P-e + |}%n 6>Ỏ+?>2[&M P7AX3nzMc0s Pn R"[IZ4K0QnbCO4Omρso?kcmBw-f"+oSS!BkO-9c.]NfO7A~ӯIr<7 jB)mZrd!tc>a8R9TXe!Lp +I lMGo6-6\$E"˼)eۊ:i~Fhxy?p'k'9Yi(Vv*FW83N{x'# + ~U LhR~MfpipEd%o9D^P0XoQuendstream +endobj +20 0 obj +<< +/Ascent 750 /CapHeight 717.2852 /Descent -169.9219 /Flags 68 /FontBBox [ -932.6172 -480.957 1571.289 1138.184 ] /FontFile2 19 0 R + /FontName /AAAAAA+Helvetica-Oblique /ItalicAngle -12 /MissingWidth 633.7891 /StemV 87 /Type /FontDescriptor +>> +endobj +21 0 obj +<< +/BaseFont /AAAAAA+Helvetica-Oblique /FirstChar 0 /FontDescriptor 20 0 R /LastChar 127 /Name /F5+0 /Subtype /TrueType + /ToUnicode 18 0 R /Type /Font /Widths [ 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 + 0 0 277.832 277.832 354.9805 556.1523 556.1523 889.1602 666.9922 190.918 + 333.0078 333.0078 389.1602 583.9844 277.832 333.0078 277.832 277.832 556.1523 556.1523 + 556.1523 556.1523 556.1523 556.1523 556.1523 556.1523 556.1523 556.1523 277.832 277.832 + 583.9844 583.9844 583.9844 556.1523 1015.137 666.9922 666.9922 722.168 722.168 666.9922 + 610.8398 777.832 722.168 277.832 500 666.9922 556.1523 833.0078 722.168 777.832 + 666.9922 777.832 722.168 666.9922 610.8398 722.168 666.9922 943.8477 666.9922 666.9922 + 610.8398 277.832 277.832 277.832 469.2383 556.1523 333.0078 556.1523 556.1523 500 + 556.1523 556.1523 277.832 556.1523 556.1523 222.168 222.168 500 222.168 833.0078 + 556.1523 556.1523 556.1523 556.1523 333.0078 500 277.832 556.1523 500 722.168 + 500 500 500 333.9844 259.7656 333.9844 583.9844 633.7891 ] +>> +endobj +22 0 obj +<< +/PageMode /UseNone /Pages 24 0 R /Type /Catalog +>> +endobj +23 0 obj +<< +/Author (anonymous) /CreationDate (D:20240628153010-05'00') /Creator (ReportLab PDF Library - www.reportlab.com) /Keywords () /ModDate (D:20240628153010-05'00') /Producer (ReportLab PDF Library - www.reportlab.com) + /Subject (unspecified) /Title (untitled) /Trapped /False +>> +endobj +24 0 obj +<< +/Count 1 /Kids [ 5 0 R ] /Type /Pages +>> +endobj +25 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1565 +>> +stream +Gatm:?$G!n%"7lRcssYV9Ln^9R69"&duY38`n>6VqVkm1ck@OEP24Ucod^t8L4kVp4bP)HB;@'&*U+beqI',G3!!^2n@"%$!ob.a`>$62#\-Xa/!Ws9rq3-0qM]LQqOj62:,mo`-`gk?K$&7,2TL1ooZAMN@[9Xo_!-h_ZRhXW]uh[m@9.GLpfcAcQOM7kW4XWu)-R7[KV`+e6Fo#0Gu`q%W<-.HXt3TNTi]nsQZk=AdV&7!oAu6Kk`*6+LJ/kbQUO2AMko9)U5$c>RAK-X2E*Y>PgElnE`06oG('+(EQY=AehCN[.e\O%oC@$Qld=s,70j4iKZG;8jPWWgLk6")KCa7.inc*eh4XX*./u-_I1Z>\([&URLqc7R7mWQ(oQ_mmd=g)n?(k+L_k#4s"Hj>kba')*'3t?S,bMq>%)!EE+!;Dj8&=:A1r;ufa)H=s-"s]"eub1^3Xd3$W1fdSo@,WCHNGsH>JR8=6?4a8qX`cn>"S)jTs)^Qohh%g2QS5$Gd&=fo2OVXa2(i'(0S4*:c+.aMbcmZm_i_Ng:IMDL.G4U5$FKWpAhiQ>jYL$A@$bAQgJI)n4]5[.>'G*=5X@:_#&o)CFQ;Q\AFm`7DQaLT=TQlh_9^TZafWIkuEgk2Z?ZnSJ'-+!Cg:eDPP_l0%)8[`&7fW`+epWaYMH:EEiX9B6^BXS+1Y8hat4G\L;Xk6q-8>8/o?:CkEt,N;AY;rq:>&=8?VCrL4[Q9/i"q3m^iY-+n]j\d&+&2pRXQ#46+($`Ur>C*:j2T)5Zce1)/nmQC_a%`]sB$1c="BF%)Af'bJ.sXpdUHM`L?IsMCr1L[PA3O.'r/Bs]10uAa=+i4T+OTQ^IhoH<5(kXIJkcR1o;MC4XCh"EqY'*#9N#km^T&E2("_!im>16KSZ?nAPP$M3)I4f!UoQ?U3\IQmXu/6h/LMj1fGg!*BgOCnk[_h7b\.-"<*L:^)Ud>J%Sr2*+fI1PfN8H?AtZosXXEI05p"JPI*4Wlf\EeKXIKeL@5'P-eG!SlW9O9%/oL1g9jrn7b>)bL8f6[Ca\a5od;M_1(e2CF6U;6SH'+?Bn!+Ya2[1?eF&C0&sNk^Cgk3:7$B@*fBknF7ua69$=1]dVZsQF&>#>OF]EKB,:ERa1J_KVm0C1m'V6P)au:d*In4HPl.*U/aduSHngK`CZMN9.lak+'bd6DuEt!oASs5I[344bEMgCRcG,?So13GRGu(9``%lVQ#205e44pU,b^'sp\(FDC:u?Oj3dW&kU1q:*tOFcjQ&4gApNs7V3&uW:QoVkaL51=eH!s:9$-=/gNY%MMS:&gU%'!=mB\2L=$pJ7k5l!8rr]:sFXR~>endstream +endobj +xref +0 26 +0000000000 65535 f +0000000073 00000 n +0000000155 00000 n +0000000262 00000 n +0000006539 00000 n +0000013929 00000 n +0000014245 00000 n +0000015003 00000 n +0000033943 00000 n +0000034202 00000 n +0000035321 00000 n +0000036066 00000 n +0000050109 00000 n +0000050361 00000 n +0000051434 00000 n +0000052194 00000 n +0000063962 00000 n +0000064233 00000 n +0000065355 00000 n +0000066110 00000 n +0000080203 00000 n +0000080466 00000 n +0000081547 00000 n +0000081617 00000 n +0000081914 00000 n +0000081974 00000 n +trailer +<< +/ID +[] +% ReportLab generated PDF document -- digest (http://www.reportlab.com) + +/Info 23 0 R +/Root 22 0 R +/Size 26 +>> +startxref +83631 +%%EOF diff --git a/utils_v2/goog/base.py b/utils_v2/goog/base.py index e392163..1aef5d3 100644 --- a/utils_v2/goog/base.py +++ b/utils_v2/goog/base.py @@ -232,7 +232,8 @@ class AsyncGoogleBase: url: str, headers: dict = None, json: dict = None, - data: dict = None + data: dict = None, + content: str | bytes = None ) -> GoogleApiResponse: """ @@ -259,7 +260,8 @@ class AsyncGoogleBase: url = url, headers = headers, json = json, - data = data + data = data, + content = content ) # Note down the results: diff --git a/utils_v2/goog/gmail/gmail.py b/utils_v2/goog/gmail/gmail.py deleted file mode 100644 index 19989ad..0000000 --- a/utils_v2/goog/gmail/gmail.py +++ /dev/null @@ -1,936 +0,0 @@ -""" - - AUTHOR: - - Khushal P Soonderji - - DATE: - - Monday, 25th Nov., 2024 - - OBJECTIVE: - - To manage e-mails in a GMail account. - - REFERENCES: - - 1. Quickstart: https://developers.google.com/gmail/api/quickstart/python - 2. Labels: https://developers.google.com/gmail/api/guides/labels - 3. Messages: https://developers.google.com/gmail/api/reference/rest/v1/users.messages - - DOWNLOADS: - - N/A - -""" - - -# ***************************************************************************************************************** -# ***** **** -# *** IMPORT *** -# ***** **** -# ***************************************************************************************************************** - - -# To make sibling directories accessible for imports: -import sys -sys.path.append(".") -sys.path.append("..") - -# System-level activities: -import io - -# My utils: -from utils_v2.string import json -from utils_v2.string import regex -from utils_v2.date_time import date_time -from utils_v2.oauth.services.goog import GoogleOAuth -from utils_v2.mail import mail_parser - -# Related to Google: -from google.auth.transport.requests import Request -from google.oauth2.credentials import Credentials -from googleapiclient.discovery import build - -# To make API calls: -import httpx - -# For asynchronous activities: -import asyncio - -# To work with date and time: -import datetime - -# For working with datatypes: -from typing import Dict, Literal, List, Any - -# For debugging: -from icecream import IceCreamDebugger - -# For computational help: -import math - -# For base64 encoding: -import base64 - - -# ***************************************************************************************************************** -# ***** **** -# *** MACROS / ONE-TIME INIT *** -# ***** **** -# ***************************************************************************************************************** - - -# --- Nothing Yet - - -# ***************************************************************************************************************** -# ***** **** -# *** VARIABLES *** -# ***** **** -# ***************************************************************************************************************** - - -# --- Nothing Yet - - -# ***************************************************************************************************************** -# ***** **** -# *** FUNCTIONS *** -# ***** **** -# ***************************************************************************************************************** - - -# --- Nothing Yet - - -# ***************************************************************************************************************** -# ***** **** -# *** CLASSES *** -# ***** **** -# ***************************************************************************************************************** - - -class AsyncGMailClient: - - def __init__( - self, - credentials: Credentials, - http_client: httpx.AsyncClient, - debug = True, - debug_prefix = "GMail | ", - debug_only_errors = True - ): - - # Prepare the debugging utility: - self._debug_prefix = debug_prefix - self._printer = IceCreamDebugger(prefix = debug_prefix, includeContext = True) - if not debug: self._printer.disable() - self._debug_only_errors = debug_only_errors - - # Accept the input configuration: - self.__http_client = http_client - self.__service = None - self.__user_email = None - self.__credentials = credentials - - def enable_debug(self): - self._printer.enable() - - def disable_debug(self): - self._printer.disable() - - def debug_only_errors(self): - self._debug_only_errors = True - - def debug_everything(self): - self._debug_only_errors = False - - # ┏┓ ┏┓ ┏┓ ┏• - # ┗┓┏┓╋┓┏┏┓ ┣╋ ┃ ┏┓┏┓╋┓┏┓ - # ┗┛┗ ┗┗┻┣┛ ┗┻ ┗┛┗┛┛┗┛┗┗┫ - # ┛ ┛ - - async def initialize(self): - - """ - Call this once when the instance is created. - :return: True if successful, else False. - """ - - return await self.__build_service() - - async def __build_service(self) -> bool: - - """ - Build a service object that can be used to perform various activities. - :return: True if successful, else False. - """ - - try: - - # Build the service object: - self.__service = build( - serviceName = "gmail", - version = "v1", - credentials = self.__credentials, - cache_discovery = False - ) - - # Note down the e-mail address of the user: - profile = self.__service.users().getProfile(userId = "me").execute() - self.__user_email = profile.get("emailAddress") - - # Done here: - return True - - # In case something goes wrong: - except Exception as exception: - self._printer(exception) - return False - - async def set_credentials( - self, - credentials: Credentials - ) -> bool: - - """ - To update the credentials. Needed for the times when the access token gets refreshed. - :param credentials: Google's custom 'Credentials' object that describes the OAuth-based access details. - :return: True if successful, else False. - """ - - self.__credentials = credentials - return await self.__build_service() - - async def __ensure_token(self) -> None: - - """ - Checks if the access token has expired and refreshes if needed. - :return: None. - """ - - if await GoogleOAuth.credentials_have_expired(self.__credentials): - if not self._debug_only_errors: self._printer("Refreshing Token.", self.__user_email) - self.__credentials.refresh(Request()) - - # ┏┓ • - # ┃┃┏┓┏┓┏┓┏┓┏┓╋┓┏┓┏ - # ┣┛┛ ┗┛┣┛┗ ┛ ┗┗┗ ┛ - # ┛ - - @property - async def user_email(self): - return self.__user_email - - @property - async def user_profile(self): - return self.__service.users().getProfile(userId = "me").execute() - - # ┓ ┓ ┓ - # ┃ ┏┓┣┓┏┓┃┏ - # ┗┛┗┻┗┛┗ ┗┛ - - async def list_labels( - self, - raise_exception: bool = False - ) -> Dict[str, dict] | None: - - """ - To get the list of labels of this user. - DOCUMENTATION: - 1. https://developers.google.com/gmail/api/reference/rest/v1/users.labels/list - 2. https://developers.google.com/gmail/api/reference/rest/v1/users.labels#Label - :param raise_exception: If set to True, any exceptions that occur will be propagated, else they wil be - suppressed. - :return: The list of labels. - """ - - # Start by assuming failure: - labels = None - - try: - - # Standard token-refresh check: - await self.__ensure_token() - - # Make the API call: - if not self._debug_only_errors: self._printer("Listing All Labels.", self.__user_email) - api_response = await self.__http_client.get( - url = f"https://gmail.googleapis.com/gmail/v1/users/{self.__user_email}/labels", - headers = {"Authorization": f"Bearer {self.__credentials.token}"} - ) - - # If the API call failed: - if api_response.status_code not in [200]: return labels - - # Else we format the response: - labels = {label.pop("name"): label for label in api_response.json().get("labels", [])} - - # In case something goes wrong along the way: - except Exception as exception: - if raise_exception: raise - self._printer(exception) - labels = None - - # Done here: - return labels - - async def get_label( - self, - label_id: str, - raise_exception: bool = False - ) -> dict | None: - - """ - To get one label of this user. the label will be identified by its id. - DOCUMENTATION: - 1. https://developers.google.com/gmail/api/reference/rest/v1/users.labels/get - 2. https://developers.google.com/gmail/api/reference/rest/v1/users.labels#Label - :param label_id: The id that Google assigned to the label. - :param raise_exception: If set to True, any exceptions that occur will be propagated, else they wil be - suppressed. - :return: The list of labels. - """ - - # Start by assuming failure: - label = None - - try: - - # Standard token-refresh check: - await self.__ensure_token() - - # Make the API call: - if not self._debug_only_errors: self._printer("Getting One Label.", self.__user_email) - api_response = await self.__http_client.get( - url = f"https://gmail.googleapis.com/gmail/v1/users/{self.__user_email}/labels/{label_id}", - headers = {"Authorization": f"Bearer {self.__credentials.token}"} - ) - - # If the API call failed: - if api_response.status_code not in [200]: return label - - # Else we extract the response: - label = api_response.json() - - # In case something goes wrong along the way: - except Exception as exception: - if raise_exception: raise - self._printer(exception) - label = None - - # Done here: - return label - - async def create_label( - self, - label_name: str, - label_visibility: Literal["labelShow", "labelShowIfUnread", "labelHide"] = "labelShow", - message_visibility: Literal["show", "hide"] = "show", - label_text_color: str = "#434343", - label_background_color: str = "#000000", - raise_exception: bool = False - ) -> bool: - - """ - Create one label for the user. - DOCUMENTATION: - 1. https://developers.google.com/gmail/api/reference/rest/v1/users.labels/create - 2. https://developers.google.com/gmail/api/reference/rest/v1/users.labels#Label - :param label_name: The display name of the label. - :param label_visibility: Whether, or not, you would like to show the label in the web UI. - :param message_visibility: Whether, or not, you would like to show messages with this label in the web UI. - :param label_text_color: The colour of the text of the label. - :param label_background_color: The colour of the background/tag of the label. - :param raise_exception: If set to True, any exceptions that occur will be propagated, else they wil be - suppressed. - :return: True if the label was created, else False. - """ - - # Start by assuming failure: - success = False - - try: - - # Standard token-refresh check: - await self.__ensure_token() - - # Make the API call: - if not self._debug_only_errors: self._printer("Creating One Label.", self.__user_email) - api_response = await self.__http_client.post( - url = f"https://gmail.googleapis.com/gmail/v1/users/{self.__user_email}/labels", - headers = { - "Authorization": f"Bearer {self.__credentials.token}" - }, - json = { - "name": label_name, - "messageListVisibility": "show" if message_visibility else "hide", - "labelListVisibility": "labelShow" if label_visibility else "labelHide", - "color": { - "textColor": label_text_color.lower(), - "backgroundColor": label_background_color.lower() - } - } - ) - - # If the API call failed: - if api_response.status_code not in [200]: return success - - # Else we note down the success: - if api_response.json().get("id") is not None: success = True - - # In case something goes wrong along the way: - except Exception as exception: - if raise_exception: raise - self._printer(exception) - success = False - - # Done here: - return success - - async def update_label( - self, - label_id: str, - label_name: str = None, - label_visibility: Literal["labelShow", "labelShowIfUnread", "labelHide"] = None, - message_visibility: Literal["show", "hide"] = None, - label_text_color: str = None, - label_background_color: str = None, - raise_exception: bool = False - ) -> bool: - - """ - Updates one label for the user. - DOCUMENTATION: - 1. https://developers.google.com/gmail/api/reference/rest/v1/users.labels/create - 2. https://developers.google.com/gmail/api/reference/rest/v1/users.labels#Label - NOTE: Both or none of the colours must be updated. For this reason, a simple default will be chosen for the - other if only one is provided. - :param label_id: The id that Google assigned to the label. - :param label_name: The display name of the label. - :param label_visibility: Whether, or not, you would like to show the label in the web UI. - :param message_visibility: Whether, or not, you would like to show messages with this label in the web UI. - :param label_text_color: The colour of the text of the label. - :param label_background_color: The colour of the background/tag of the label. - :param raise_exception: If set to True, any exceptions that occur will be propagated, else they wil be - suppressed. - :return: True if the label was created, else False. - """ - - # Start by assuming failure: - success = False - - try: - - # Standard token-refresh check: - await self.__ensure_token() - - # Format the JSON body: - json_body = {} - if label_name: json_body["name"] = label_name - if label_visibility: json_body["labelListVisibility"] = label_visibility - if message_visibility: json_body["messageListVisibility"] = message_visibility - if label_text_color or label_background_color: - json_body["color"] = { - "textColor": (label_text_color or "#434343").lower(), - "backgroundColor": (label_background_color or "#000000").lower() - } - - # If no value was given to update: - if not json_body: return success - - # Make the API call: - if not self._debug_only_errors: self._printer("Updating One Label.", self.__user_email) - api_response = await self.__http_client.put( - url = f"https://gmail.googleapis.com/gmail/v1/users/{self.__user_email}/labels/{label_id}", - headers = {"Authorization": f"Bearer {self.__credentials.token}"}, - json = json_body - ) - - # If the API call failed: - if api_response.status_code not in [200]: return success - - # Else we note down the success: - if api_response.json().get("id") is not None: success = True - - # In case something goes wrong along the way: - except Exception as exception: - if raise_exception: raise - self._printer(exception) - success = False - - # Done here: - return success - - async def delete_label( - self, - label_id: str, - raise_exception: bool = False - ) -> bool: - - """ - To delete one label of this user. the label will be identified by its id. - DOCUMENTATION: - 1. https://developers.google.com/gmail/api/reference/rest/v1/users.labels/get - 2. https://developers.google.com/gmail/api/reference/rest/v1/users.labels#Label - :param label_id: The id that Google assigned to the label. - :param raise_exception: If set to True, any exceptions that occur will be propagated, else they wil be - suppressed. - :return: The list of labels. - """ - - # Start by assuming failure: - success = False - - try: - - # Standard token-refresh check: - await self.__ensure_token() - - # Make the API call: - if not self._debug_only_errors: self._printer("Deleting One Label.", self.__user_email) - api_response = await self.__http_client.delete( - url = f"https://gmail.googleapis.com/gmail/v1/users/{self.__user_email}/labels/{label_id}", - headers = {"Authorization": f"Bearer {self.__credentials.token}"} - ) - - # If the API call failed: - if api_response.status_code not in [200, 204]: return success - - # Else we extract the response: - else: success = True - - # In case something goes wrong along the way: - except Exception as exception: - if raise_exception: raise - self._printer(exception) - success = False - - # Done here: - return success - - # ┳┳┓ - # ┃┃┃┏┓┏┏┏┓┏┓┏┓┏ - # ┛ ┗┗ ┛┛┗┻┗┫┗ ┛ - # ┛ - - async def __list_messages_on_page( - self, - count: int = 100, - query: str = None, - label_ids: List[str] | str = None, - include_spam_and_trash: bool = False, - next_page_token: str = None, - raise_exception: bool = False - ) -> Dict[str, Any] | None: - - """ - To enlist messages on one page. Google allows at most 500 results on one page. This method respects that - pagination limit and returns only what Google gives. This method should be used internally by the class and the - class should expose another method that calls this one in loop to get any arbitrary no. of messages as the user - desires. - DOCUMENTATION: - 1. https://developers.google.com/gmail/api/reference/rest/v1/users.messages/list - 2. https://developers.google.com/gmail/api/reference/rest/v1/users.messages#Message - :param count: The no. of messages to fetch. - :param query: Any query filter that is supported by GMail. - :param label_ids: The list of labels' ids that the mails must have on them. - :param include_spam_and_trash: Whether, or not, you would like to include mails categorized as spam and trash. - :param next_page_token: The token to fetch the next set of results. - :param raise_exception: If set to True, any exceptions that occur will be propagated, else they wil be - suppressed. - :return: The messages that matched the given conditions if the call was successful, else None. - """ - - # Start by assuming failure: - page_messages = None - - try: - - # Standard token-refresh check: - await self.__ensure_token() - - # Build the needed params: - params_json = { - "maxResults": count, - "includeSpamTrash": include_spam_and_trash - } - if query: params_json["q"] = query - if next_page_token: params_json["pageToken"] = next_page_token - if label_ids: params_json["labelIds"] = label_ids if isinstance(label_ids, list) else [label_ids] - - # Make the API call: - if not self._debug_only_errors: self._printer( - "Listing Messages for Page.", - self.__user_email, - count, - next_page_token - ) - api_response = await self.__http_client.get( - url = f"https://gmail.googleapis.com/gmail/v1/users/{self.__user_email}/messages", - headers = {"Authorization": f"Bearer {self.__credentials.token}"}, - params = params_json - ) - - # If the API call failed: - if api_response.status_code not in [200]: return page_messages - - # Else we format the response: - api_json = api_response.json() - page_messages = { - "messages": {m.pop("id"): m for m in api_json.get("messages", [])}, - "nextPageToken": api_json.get("nextPageToken"), - "resultSizeEstimate": api_json["resultSizeEstimate"], - } - - # In case something goes wrong along the way: - except Exception as exception: - if raise_exception: raise - self._printer(exception) - page_messages = None - - # Done here: - return page_messages - - async def list_messages( - self, - count: int = 100, - query: str = None, - label_ids: List[str] | str = None, - include_spam_and_trash: bool = False, - raise_exception: bool = False - ) -> Dict[str, Any]: - - """ - To enlist mail messages from a user's account. - DOCUMENTATION: - 1. https://developers.google.com/gmail/api/reference/rest/v1/users.messages/list - 2. https://developers.google.com/gmail/api/reference/rest/v1/users.messages#Message - :param count: The no. of messages to fetch. - :param query: Any query filter that is supported by GMail. - :param label_ids: The list of labels' ids that the mails must have on them. - :param include_spam_and_trash: Whether, or not, you would like to include mails categorized as spam and trash. - :param raise_exception: If set to True, any exceptions that occur will be propagated, else they wil be - suppressed. - :return: The messages that matched the given conditions if the call was successful, else None. - """ - - # Start by assuming failure: - messages = None - - try: - - # Standard token-refresh check: - await self.__ensure_token() - - # We convert the messages to a dict: - messages = { - "messages": {}, - "nextPageToken": None, - "resultSizeEstimate": 0 - } - - # Let's figure out how many times we'll have to loop through the process to retrieve the target no. of - # messages. Google allows you to fetch info about at most 500 messages in one go. - max_per_call = 500 # ... because Google allows at most 500 entries in one call. - iterations_needed = int(math.ceil(count / max_per_call)) - last_iteration_count = count - int((max_per_call * (iterations_needed - 1))) - - # Run the loop those many times: - next_page_token = None - results_size_estimate = 0 - for iteration_no in range(iterations_needed): - - # Figure out the count for this page: - if iterations_needed > 1: - if iteration_no < (iterations_needed - 1): iteration_count = max_per_call - else: iteration_count = last_iteration_count - else: iteration_count = count - - # Retrieve the messages for this page: - iteration_messages = await self.__list_messages_on_page( - count = iteration_count, - query = query, - label_ids = label_ids, - include_spam_and_trash = include_spam_and_trash, - next_page_token = next_page_token, - raise_exception = True - ) - - # Check if no data was received: - if iteration_messages is None: break - if not iteration_messages.get("messages"): break - - # Now that we know that messages were received: - for k, v in iteration_messages["messages"].items(): messages["messages"][k] = v - results_size_estimate += iteration_messages["resultSizeEstimate"] - - # If there is no next page after this, we break out of the loop: - next_page_token = iteration_messages["nextPageToken"] - if next_page_token is None: break - - # Format the final response: - messages["nextPageToken"] = next_page_token - messages["resultSizeEstimate"] = results_size_estimate - - # In case something goes wrong along the way: - except Exception as exception: - if raise_exception: raise - self._printer(exception) - messages = None - - # Done here: - return messages - - async def get_message( - self, - message_id: str, - return_raw = False, - raise_exception: bool = False - ) -> Dict[str, Any] | str | None: - - """ - To get one message of this user. The message will be identified by its id. Note that, if you choose to return - the raw message, the message body will be compliant with RFC 5322 and RFC 2045 (among others). - DOCUMENTATION: - 1. https://developers.google.com/gmail/api/reference/rest/v1/users.messages/get - 2. https://developers.google.com/gmail/api/reference/rest/v1/users.messages#Message - 2. https://developers.google.com/gmail/api/reference/rest/v1/Format - :param message_id: The id that Google assigned to the message. - :param return_raw: Whether you want the raw message or the formatted message. - :param raise_exception: If set to True, any exceptions that occur will be propagated, else they wil be - suppressed. - :return: The message either parsed as a JSON, or as a raw text body. If the API call fails, the response will be - a null value. - """ - - # Start by assuming failure: - message = None - - try: - - # Standard token-refresh check: - await self.__ensure_token() - - # Make the API call: - if not self._debug_only_errors: self._printer("Getting One Message.", self.__user_email) - api_response = await self.__http_client.get( - url = f"https://gmail.googleapis.com/gmail/v1/users/{self.__user_email}/messages/{message_id}", - headers = {"Authorization": f"Bearer {self.__credentials.token}"}, - params = {"format": "raw"} - ) - - # If the API call failed: - if api_response.status_code not in [200]: return message - - # Extract the raw message and respond based on the request: - api_json = api_response.json() - message = base64.urlsafe_b64decode(api_json["raw"]) - if return_raw: message = message.decode() - else: - message = mail_parser.parse(message) - message["labels"] = api_json["labelIds"] - message["messageId"] = api_json["id"] - message["threadId"] = api_json["threadId"] - message["historyId"] = api_json["historyId"] - message["snippet"] = api_json["snippet"] - message["sizeEstimate"] = api_json["sizeEstimate"] - - # In case something goes wrong along the way: - except Exception as exception: - if raise_exception: raise - self._printer(exception) - message = None - - # Done here: - return message - - async def modify_messages( - self, - message_ids: List[str] | str, - add_label_ids: List[str] | str = None, - remove_label_ids: List[str] | str = None, - raise_exception: bool = False - ) -> bool: - - """ - To add or remove labels from messages. - DOCUMENTATION: - 1. https://developers.google.com/gmail/api/reference/rest/v1/users.messages/batchModify - :param message_ids: One or more message ids (assigned by Google). - :param add_label_ids: One or more label ids (not the display name of the label). - :param remove_label_ids: One or more label ids (not the display name of the label). - :param raise_exception: If set to True, any exceptions that occur will be propagated, else they wil be - suppressed. - :return: True if the operation succeeded, else False. - """ - - # Start by assuming failure: - success = False - - try: - - # Standard token-refresh check: - await self.__ensure_token() - - # Build the needed JSON: - body_json = {"ids": message_ids if isinstance(message_ids, list) else [message_ids]} - if add_label_ids: body_json["addLabelIds"] = add_label_ids if isinstance(add_label_ids, list) else [add_label_ids] - if remove_label_ids: body_json["removeLabelIds"] = remove_label_ids if isinstance(remove_label_ids, list) else [remove_label_ids] - - # Make the API call: - if not self._debug_only_errors: self._printer("Modifying Message(s).", self.__user_email) - api_response = await self.__http_client.post( - url = f"https://gmail.googleapis.com/gmail/v1/users/{self.__user_email}/messages/batchModify", - headers = {"Authorization": f"Bearer {self.__credentials.token}"}, - json = body_json - ) - - # Check if our request was successful: - if api_response.status_code in [200, 204]: success = True - - # In case something goes wrong along the way: - except Exception as exception: - if raise_exception: raise - self._printer(exception) - success = False - - # Done here: - return success - - async def delete_messages( - self, - message_ids: List[str] | str, - raise_exception: bool = False - ) -> bool: - - # Start by assuming failure: - success = False - - try: - - # Standard token-refresh check: - await self.__ensure_token() - - # Make the API call: - if not self._debug_only_errors: self._printer("Deleting Message(s).", self.__user_email) - api_response = await self.__http_client.post( - url = f"https://gmail.googleapis.com/gmail/v1/users/{self.__user_email}/messages/batchDelete", - headers = {"Authorization": f"Bearer {self.__credentials.token}"}, - json = {"ids": message_ids if isinstance(message_ids, list) else [message_ids]} - ) - - print("HTTP CODE:", api_response.status_code) - try: print("HTTP JSON:", json.to_string(api_response.json())) - except Exception as e: print("HTTP JSON:", e) - - # Check if our request was successful: - if api_response.status_code in [200, 204]: success = True - - # In case something goes wrong along the way: - except Exception as exception: - if raise_exception: raise - self._printer(exception) - success = False - - # Done here: - return success - - -# ***************************************************************************************************************** -# ***** **** -# *** MAIN PROGRAM *** -# ***** **** -# ***************************************************************************************************************** - - -if __name__ == "__main__": - - import dateparser - - # Create an HTTP client: - test_client = httpx.AsyncClient( - limits = httpx.Limits( - max_connections = 100, # ............ Maximum number of connections allowed in the pool. - max_keepalive_connections = 50, # ... Maximum number of connections that can be kept alive. - ), - timeout = httpx.Timeout( - connect = 2.5, # ... Shorter connection timeout. - read = 2.5, # ...... Like what EasyEcom gives. - write = 10.0, # .... Time to wait for sending data. - pool = 120.0 # ..... Time to wait for a free connection from the pool. - ) - ) - - secrets_file = r"../../../creds/google_tcaoff_test_oauth_20241125.json" - secrets_dict = json.from_file(secrets_file) - - my_oauth = GoogleOAuth( - config = secrets_dict, - redirect_url = r"https://api.thecaoffice.com/converse/mail/callback/gmail", - debug = True, - debug_prefix = "OAuth (Goog) | ", - ) - - tokens = { - "access_token": "ya29.a0AeDClZAYoo85BXRId_n-hwo_amKshzi46c33GaJcsZZvGB7A7OGU2RFYcWBM_BleNBfAFUSJP2NHAvmd7Nsp_U5Kg68hXSy0iO99PNTm3pvKrJSzbkA-rXsVLsCnBIfPUMyNt2nOOVJmGwm17DNN0jAELkm1fPNTju7SZzmuaCgYKAZwSARMSFQHGX2Mis8TZui2rZT1gKySVds-N0w0175", - "refresh_token": "1//0gnqzjMf9YT19CgYIARAAGBASNgF-L9Ir3rcY37nGrV45XyOUBRllEH7Txui7T1JbwevlmDoNw7PuMu149cCWQSwsScuKaZusUQ", - "expires_in": 3539, - "expires_at": dateparser.parse("2024-11-25 10:40:40.833699+00:00"), - "scopes": [ - "https://www.googleapis.com/auth/gmail.labels", - "https://www.googleapis.com/auth/gmail.modify" - ] - } - - async def main(): - - my_gmail = AsyncGMailClient( - http_client = test_client, - credentials = await my_oauth.credentials_from_tokens(tokens = tokens), - debug_only_errors = False - ) - await my_gmail.initialize() - print(await my_gmail.user_email) - print(await my_gmail.user_profile) - - # print(json.to_string(await my_gmail.list_labels())) - - # print(json.to_string(await my_gmail.get_label(label_id = "Label_3"))) - - # print(json.to_string(await my_gmail.update_label( - # label_id = "Label_3", - # label_name = "Updated Label 123", - # label_background_color = "#cc3a21" - # ))) - # - # print(json.to_string(await my_gmail.get_label(label_id = "Label_3"))) - - # print(json.to_string(await my_gmail.create_label( - # label_name = "Bye - Bye !", - # label_text_color = "#fbc8d9", - # label_background_color = "#7a2e0b" - # ))) - - # print(json.to_string(await my_gmail.delete_label(label_id = "Label_6"))) - - # print(json.to_string(await my_gmail.list_messages(count = 10))) - # print(json.to_string( - # await my_gmail.get_message( - # message_id = "19367930033154ca", - # return_raw = False - # ), - # default = str) - # ) - - # print(json.to_string(await my_gmail.modify_messages( - # message_ids = ["19367930033154ca"], - # add_label_ids = ["Label_5"], - # remove_label_ids = ["Label_3"] - # ))) - - print(json.to_string(await my_gmail.delete_messages(message_ids = "19367930033154ca"))) - - - asyncio.run(main()) diff --git a/utils_v2/goog/gmail/gmail_client.py b/utils_v2/goog/gmail/gmail_client.py new file mode 100644 index 0000000..a837c11 --- /dev/null +++ b/utils_v2/goog/gmail/gmail_client.py @@ -0,0 +1,959 @@ +""" + + AUTHOR: + + Khushal P Soonderji + + DATE: + + Monday, 25th Nov., 2024 + + OBJECTIVE: + + To manage e-mails in a GMail account. + + REFERENCES: + + 1. Quickstart: https://developers.google.com/gmail/api/quickstart/python + 2. Labels: https://developers.google.com/gmail/api/guides/labels + 3. Messages: https://developers.google.com/gmail/api/reference/rest/v1/users.messages + + DOWNLOADS: + + N/A + +""" + + +# ***************************************************************************************************************** +# ***** **** +# *** IMPORT *** +# ***** **** +# ***************************************************************************************************************** + + +# To make sibling directories accessible for imports: +import sys +sys.path.append(".") +sys.path.append("..") + +# System-level activities: +import io + +# My utils: +from utils_v2.string import json +from utils_v2.string import regex +from utils_v2.date_time import date_time +from utils_v2.mail import mail_parser + +# My Google utils: +from utils_v2.oauth.services.goog import GoogleOAuth +from utils_v2.goog.base import AsyncGoogleBase +from utils_v2.goog.models.data.auth_tokens import GoogleAuthTokens +from utils_v2.goog.models.data.api_call import GoogleApiResponse +from utils_v2.goog.gmail.gmail_message import GMailMessage + +# Related to Google: +from google.auth.transport.requests import Request +from google.oauth2.credentials import Credentials +from googleapiclient.discovery import build + +# To make API calls: +import httpx + +# For asynchronous activities: +import asyncio + +# To work with date and time: +import datetime + +# For working with datatypes: +from typing import Dict, Literal, List, Any + +# For debugging: +from icecream import IceCreamDebugger +import inspect + +# For computational help: +import math + +# For base64 encoding: +import base64 + + +# ***************************************************************************************************************** +# ***** **** +# *** MACROS / ONE-TIME INIT *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** VARIABLES *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** FUNCTIONS *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** CLASSES *** +# ***** **** +# ***************************************************************************************************************** + + +class AsyncGMailClient(AsyncGoogleBase): + + async def get_user_profile( + self, + tokens: GoogleAuthTokens, + user_id: str = "me", + ) -> GoogleApiResponse: + + """ + To get the list of labels of this user. + DOCUMENTATION: + 1. https://developers.google.com/gmail/api/reference/rest/v1/users/getProfile + :param tokens: The object that holds the access token to the service. + :param user_id: The id of the user for which this service must be run. Typically, the e-mail id itself or "me". + :return: A structured response where the list of labels will be in the 'data' variable. + """ + + # Ensure that the tokens are valid: + await tokens.arefresh( + http_client = self._http_client, + client_id = self._client_id, + client_secret = self._client_secret, + force_refresh = False + ) + + # Make the API call: + if not self._debug_only_errors: self._printer("Getting User Profile.") + api_response = await self.get( + url = f"https://gmail.googleapis.com/gmail/v1/users/{user_id}/profile", + headers = {"Authorization": f"Bearer {tokens.accessToken}"} + ) + + # If the call was successful: + if api_response.httpCode in [200]: + api_response.data = await api_response.get_json() + + # Done here: + return api_response + + # ┓ ┓ ┓ + # ┃ ┏┓┣┓┏┓┃┏ + # ┗┛┗┻┗┛┗ ┗┛ + + async def list_labels( + self, + tokens: GoogleAuthTokens, + user_id: str = "me" + ) -> GoogleApiResponse: + + """ + To get the list of labels of this user. + DOCUMENTATION: + 1. https://developers.google.com/gmail/api/reference/rest/v1/users.labels/list + 2. https://developers.google.com/gmail/api/reference/rest/v1/users.labels#Label + :param tokens: The object that holds the access token to the service. + :param user_id: The id of the user for which this service must be run. Typically, the e-mail id itself or "me". + :return: A structured response where the list of labels will be in the 'data' variable. + """ + + # Ensure that the tokens are valid: + await tokens.arefresh( + http_client = self._http_client, + client_id = self._client_id, + client_secret = self._client_secret, + force_refresh = False + ) + + # Make the API call: + if not self._debug_only_errors: self._printer("Listing All Labels.", user_id) + api_response = await self.get( + url = f"https://gmail.googleapis.com/gmail/v1/users/{user_id}/labels", + headers = {"Authorization": f"Bearer {tokens.accessToken}"} + ) + + # If the call was successful: + if api_response.httpCode in [200]: + api_response.success = True + api_json = await api_response.get_json() + api_response.data = {label.pop("name"): label for label in api_json.get("labels", [])} + + # Done here: + return api_response + + async def get_label( + self, + tokens: GoogleAuthTokens, + label_id: str, + user_id: str = "me" + ) -> GoogleApiResponse: + + """ + To get one label of this user. the label will be identified by its id. + DOCUMENTATION: + 1. https://developers.google.com/gmail/api/reference/rest/v1/users.labels/get + 2. https://developers.google.com/gmail/api/reference/rest/v1/users.labels#Label + :param tokens: The object that holds the access token to the service. + :param label_id: The id that Google assigned to the label. + :param user_id: The id of the user for which this service must be run. Typically, the e-mail id itself or "me". + :return: A structured response where the list of labels will be in the 'data' variable. + """ + + # Ensure that the tokens are valid: + await tokens.arefresh( + http_client = self._http_client, + client_id = self._client_id, + client_secret = self._client_secret, + force_refresh = False + ) + + # Make the API call: + if not self._debug_only_errors: self._printer("Getting One Label.", user_id) + api_response = await self.get( + url = f"https://gmail.googleapis.com/gmail/v1/users/{user_id}/labels/{label_id}", + headers = {"Authorization": f"Bearer {tokens.accessToken}"} + ) + + # If the call was successful: + if api_response.httpCode in [200]: + api_response.success = True + api_response.data = await api_response.get_json() + + # Done here: + return api_response + + async def create_label( + self, + tokens: GoogleAuthTokens, + label_name: str, + label_visibility: Literal["labelShow", "labelShowIfUnread", "labelHide"] = "labelShow", + message_visibility: Literal["show", "hide"] = "show", + label_text_color: str = "#434343", + label_background_color: str = "#000000", + user_id: str = "me" + ) -> GoogleApiResponse: + + """ + Create one label for the user. Doesn't apply it to any mail, just creates it. + DOCUMENTATION: + 1. https://developers.google.com/gmail/api/reference/rest/v1/users.labels/create + 2. https://developers.google.com/gmail/api/reference/rest/v1/users.labels#Label + :param tokens: The object that holds the access token to the service. + :param label_name: The display name of the label. + :param label_visibility: Whether, or not, you would like to show the label in the web UI. + :param message_visibility: Whether, or not, you would like to show messages with this label in the web UI. + :param label_text_color: The colour of the text of the label. + :param label_background_color: The colour of the background/tag of the label. + :param user_id: The id of the user for which this service must be run. Typically, the e-mail id itself or "me". + :return: A structured response where the list of labels will be in the 'data' variable. + """ + + # Ensure that the tokens are valid: + await tokens.arefresh( + http_client = self._http_client, + client_id = self._client_id, + client_secret = self._client_secret, + force_refresh = False + ) + + # Make the API call: + if not self._debug_only_errors: self._printer("Creating One Label.", user_id) + api_response = await self.post( + url = f"https://gmail.googleapis.com/gmail/v1/users/{user_id}/labels", + headers = {"Authorization": f"Bearer {tokens.accessToken}"}, + json = { + "name": label_name, + "messageListVisibility": "show" if message_visibility else "hide", + "labelListVisibility": "labelShow" if label_visibility else "labelHide", + "color": { + "textColor": label_text_color.lower(), + "backgroundColor": label_background_color.lower() + } + } + ) + + # If the call was successful: + if api_response.httpCode in [200]: + api_response.success = True + api_response.data = await api_response.get_json() + + # Done here: + return api_response + + async def update_label( + self, + tokens: GoogleAuthTokens, + label_id: str, + label_name: str = None, + label_visibility: Literal["labelShow", "labelShowIfUnread", "labelHide"] = None, + message_visibility: Literal["show", "hide"] = None, + label_text_color: str = None, + label_background_color: str = None, + user_id: str = "me" + ) -> GoogleApiResponse: + + """ + Updates one label for the user. + DOCUMENTATION: + 1. https://developers.google.com/gmail/api/reference/rest/v1/users.labels/update + 2. https://developers.google.com/gmail/api/reference/rest/v1/users.labels#Label + NOTE: Both or none of the colours must be updated. For this reason, a simple default will be chosen for the + other if only one is provided. + :param tokens: The object that holds the access token to the service. + :param label_id: The id that Google assigned to the label. + :param label_name: The display name of the label. + :param label_visibility: Whether, or not, you would like to show the label in the web UI. + :param message_visibility: Whether, or not, you would like to show messages with this label in the web UI. + :param label_text_color: The colour of the text of the label. + :param label_background_color: The colour of the background/tag of the label. + :param user_id: The id of the user for which this service must be run. Typically, the e-mail id itself or "me". + :return: A structured response where the list of labels will be in the 'data' variable. + """ + + # Ensure that the tokens are valid: + await tokens.arefresh( + http_client = self._http_client, + client_id = self._client_id, + client_secret = self._client_secret, + force_refresh = False + ) + + # Format the JSON body: + json_body = {} + if label_name: json_body["name"] = label_name + if label_visibility: json_body["labelListVisibility"] = label_visibility + if message_visibility: json_body["messageListVisibility"] = message_visibility + if label_text_color or label_background_color: + json_body["color"] = { + "textColor": (label_text_color or "#434343").lower(), + "backgroundColor": (label_background_color or "#000000").lower() + } + + # Make the API call: + if not self._debug_only_errors: self._printer("Updating One Label.", user_id) + api_response = await self.put( + url = f"https://gmail.googleapis.com/gmail/v1/users/{user_id}/labels/{label_id}", + headers = {"Authorization": f"Bearer {tokens.accessToken}"}, + json = json_body + ) + + # If the call was successful: + if api_response.httpCode in [200]: + api_response.success = True + api_response.data = await api_response.get_json() + + # Done here: + return api_response + + async def delete_label( + self, + tokens: GoogleAuthTokens, + label_id: str, + user_id: str = "me" + ) -> GoogleApiResponse: + + """ + To delete one label of this user. the label will be identified by its id. + DOCUMENTATION: + 1. https://developers.google.com/gmail/api/reference/rest/v1/users.labels/delete + :param tokens: The object that holds the access token to the service. + :param label_id: The id that Google assigned to the label. + :param user_id: The id of the user for which this service must be run. Typically, the e-mail id itself or "me". + :return: A structured response where the list of labels will be in the 'data' variable. + """ + + # Ensure that the tokens are valid: + await tokens.arefresh( + http_client = self._http_client, + client_id = self._client_id, + client_secret = self._client_secret, + force_refresh = False + ) + + # Make the API call: + if not self._debug_only_errors: self._printer("Deleting One Label.", user_id) + api_response = await self.delete( + url = f"https://gmail.googleapis.com/gmail/v1/users/{user_id}/labels/{label_id}", + headers = {"Authorization": f"Bearer {tokens.accessToken}"} + ) + + # If the call was successful: + if api_response.httpCode in [200, 204]: + api_response.success = True + + # Done here: + return api_response + + # ┳┳┓ + # ┃┃┃┏┓┏┏┏┓┏┓┏┓┏ + # ┛ ┗┗ ┛┛┗┻┗┫┗ ┛ + # ┛ + + async def __list_messages_on_page( + self, + tokens: GoogleAuthTokens, + count: int = 100, + query: str = None, + label_ids: List[str] | str = None, + include_spam_and_trash: bool = False, + next_page_token: str = None, + user_id: str = "me" + ) -> GoogleApiResponse: + + """ + To enlist messages on one page. Google allows at most 500 results on one page. This method respects that + pagination limit and returns only what Google gives. This method should be used internally by the class and the + class should expose another method that calls this one in loop to get any arbitrary no. of messages as the user + desires. + DOCUMENTATION: + 1. https://developers.google.com/gmail/api/reference/rest/v1/users.messages/list + 2. https://developers.google.com/gmail/api/reference/rest/v1/users.messages#Message + :param tokens: The object that holds the access token to the service. + :param count: The no. of messages to fetch. + :param query: Any query filter that is supported by GMail. + :param label_ids: The list of labels' ids that the mails must have on them. + :param include_spam_and_trash: Whether, or not, you would like to include mails categorized as spam and trash. + :param next_page_token: The token to fetch the next set of results. + :param user_id: The id of the user for which this service must be run. Typically, the e-mail id itself or "me". + :return: A structured response where the list of labels will be in the 'data' variable. + """ + + # Ensure that the tokens are valid: + await tokens.arefresh( + http_client = self._http_client, + client_id = self._client_id, + client_secret = self._client_secret, + force_refresh = False + ) + + # Build the needed params: + params_json = { + "maxResults": count, + "includeSpamTrash": include_spam_and_trash + } + if query: params_json["q"] = query + if next_page_token: params_json["pageToken"] = next_page_token + if label_ids: params_json["labelIds"] = label_ids if isinstance(label_ids, list) else [label_ids] + + # Make the API call: + if not self._debug_only_errors: self._printer("Listing Messages for Page.", user_id, count, next_page_token) + api_response = await self.get( + url = f"https://gmail.googleapis.com/gmail/v1/users/{user_id}/messages", + headers = {"Authorization": f"Bearer {tokens.accessToken}"}, + params = params_json + ) + + # If the call was successful: + if api_response.httpCode in [200]: + api_response.success = True + api_json = await api_response.get_json() + api_response.data = { + "messages": {m.pop("id"): m for m in api_json.get("messages", [])}, + "nextPageToken": api_json.get("nextPageToken"), + "resultSizeEstimate": api_json["resultSizeEstimate"], + } + + # Done here: + return api_response + + async def list_messages( + self, + tokens: GoogleAuthTokens, + count: int = 100, + query: str = None, + label_ids: List[str] | str = None, + include_spam_and_trash: bool = False, + next_page_token: str = None, + user_id: str = "me" + ) -> GoogleApiResponse: + + """ + To enlist mail messages from a user's account. + DOCUMENTATION: + 1. https://developers.google.com/gmail/api/reference/rest/v1/users.messages/list + 2. https://developers.google.com/gmail/api/reference/rest/v1/users.messages#Message + :param tokens: The object that holds the access token to the service. + :param count: The no. of messages to fetch. + :param query: Any query filter that is supported by GMail. + :param label_ids: The list of labels' ids that the mails must have on them. + :param include_spam_and_trash: Whether, or not, you would like to include mails categorized as spam and trash. + :param next_page_token: The token to fetch the next set of results. + :param user_id: The id of the user for which this service must be run. Typically, the e-mail id itself or "me". + :return: A structured response where the list of labels will be in the 'data' variable. + """ + + # Ensure that the tokens are valid: + await tokens.arefresh( + http_client = self._http_client, + client_id = self._client_id, + client_secret = self._client_secret, + force_refresh = False + ) + + # We create a variable that will hold the results. + # We must supply the URL, Method and a few other params here due to the custom looping functionality: + all_messages = GoogleApiResponse( + serviceName = self._service_name, + action = inspect.stack()[0].function, + url = f"https://gmail.googleapis.com/gmail/v1/users/{user_id}/messages", + method = "GET", + data = { + "messages": {}, + "nextPageToken": None, + "resultSizeEstimate": 0 + } + ) + + # Let's figure out how many times we'll have to loop through the process to retrieve the target no. of + # messages. Google allows you to fetch info about at most 500 messages in one go. + max_per_call = 500 # ... because Google allows at most 500 entries in one call. + iterations_needed = int(math.ceil(count / max_per_call)) + last_iteration_count = count - int((max_per_call * (iterations_needed - 1))) + + # Run the loop those many times: + results_size_estimate = 0 + for iteration_no in range(iterations_needed): + + # Figure out the count for this page: + if iterations_needed > 1: + if iteration_no < (iterations_needed - 1): iteration_count = max_per_call + else: iteration_count = last_iteration_count + else: iteration_count = count + + # Retrieve the messages for this page: + iteration_response = await self.__list_messages_on_page( + tokens = tokens, + count = iteration_count, + query = query, + label_ids = label_ids, + include_spam_and_trash = include_spam_and_trash, + next_page_token = next_page_token + ) + + # Check if no data was received: + if ( + iteration_response.data is None or + not iteration_response.data.get("messages") + ): break + + # Now that we know that messages were received: + for k, v in iteration_response.data["messages"].items(): all_messages.data["messages"][k] = v + results_size_estimate += iteration_response.data["resultSizeEstimate"] + + # Also copy the API call params: + all_messages.httpCode = iteration_response.httpCode + all_messages.message = iteration_response.message + all_messages.success = iteration_response.success + + # If there is no next page after this, we break out of the loop: + next_page_token = iteration_response.data["nextPageToken"] + if next_page_token is None: break + + # Format the final response: + all_messages.data["nextPageToken"] = next_page_token + all_messages.data["resultSizeEstimate"] = results_size_estimate + + # Done here: + return all_messages + + async def get_message( + self, + tokens: GoogleAuthTokens, + message_id: str, + return_raw: bool = False, + user_id: str = "me" + ) -> GoogleApiResponse: + + """ + To get one message of this user. The message will be identified by its id. Note that, if you choose to return + the raw message, the message body will be compliant with RFC 5322 and RFC 2045 (among others). + DOCUMENTATION: + 1. https://developers.google.com/gmail/api/reference/rest/v1/users.messages/get + 2. https://developers.google.com/gmail/api/reference/rest/v1/users.messages#Message + 2. https://developers.google.com/gmail/api/reference/rest/v1/Format + :param tokens: The object that holds the access token to the service. + :param message_id: The id that Google assigned to the message. + :param return_raw: Whether you want the raw message or the formatted message. + :param user_id: The id of the user for which this service must be run. Typically, the e-mail id itself or "me". + :return: A structured response where the list of labels will be in the 'data' variable. + """ + + # Ensure that the tokens are valid: + await tokens.arefresh( + http_client = self._http_client, + client_id = self._client_id, + client_secret = self._client_secret, + force_refresh = False + ) + + # Make the API call: + if not self._debug_only_errors: self._printer("Getting One Message.", user_id) + api_response = await self.get( + url = f"https://gmail.googleapis.com/gmail/v1/users/{user_id}/messages/{message_id}", + headers = {"Authorization": f"Bearer {tokens.accessToken}"}, + params = {"format": "raw"} + ) + + # If the call was successful: + if api_response.httpCode in [200]: + api_response.success = True + api_json = await api_response.get_json() + raw_message = base64.urlsafe_b64decode(api_json["raw"]) + if return_raw: api_response.data = raw_message + else: + parsed_message = mail_parser.parse(raw_message) + parsed_message["labels"] = api_json["labelIds"] + parsed_message["messageId"] = api_json["id"] + parsed_message["threadId"] = api_json["threadId"] + parsed_message["historyId"] = api_json["historyId"] + parsed_message["snippet"] = api_json["snippet"] + parsed_message["sizeEstimate"] = api_json["sizeEstimate"] + api_response.data = parsed_message + + # Done here: + return api_response + + async def modify_messages( + self, + tokens: GoogleAuthTokens, + message_ids: List[str] | str, + add_label_ids: List[str] | str = None, + remove_label_ids: List[str] | str = None, + user_id: str = "me" + ) -> GoogleApiResponse: + + """ + To add or remove labels from one or more messages. + DOCUMENTATION: + 1. https://developers.google.com/gmail/api/reference/rest/v1/users.messages/batchModify + :param tokens: The object that holds the access token to the service. + :param message_ids: One or more message ids (assigned by Google). + :param add_label_ids: One or more label ids (not the display name of the label). + :param remove_label_ids: One or more label ids (not the display name of the label). + :param user_id: The id of the user for which this service must be run. Typically, the e-mail id itself or "me". + :return: A structured response where the list of labels will be in the 'data' variable. + """ + + # Ensure that the tokens are valid: + await tokens.arefresh( + http_client = self._http_client, + client_id = self._client_id, + client_secret = self._client_secret, + force_refresh = False + ) + + # Build the needed JSON: + body_json = {"ids": message_ids if isinstance(message_ids, list) else [message_ids]} + if add_label_ids: body_json["addLabelIds"] = add_label_ids if isinstance(add_label_ids, list) else [add_label_ids] + if remove_label_ids: body_json["removeLabelIds"] = remove_label_ids if isinstance(remove_label_ids, list) else [remove_label_ids] + + # Make the API call: + if not self._debug_only_errors: self._printer("Modifying Message(s).", user_id) + api_response = await self.post( + url = f"https://gmail.googleapis.com/gmail/v1/users/{user_id}/messages/batchModify", + headers = {"Authorization": f"Bearer {tokens.accessToken}"}, + json = body_json + ) + + # If the call was successful: + if api_response.httpCode in [200, 204]: + api_response.success = True + + # Done here: + return api_response + + async def delete_messages( + self, + tokens: GoogleAuthTokens, + message_ids: List[str] | str, + user_id: str = "me" + ) -> GoogleApiResponse: + + """ + To PERMANENTLY delete one or more messages. + DOCUMENTATION: + 1. https://developers.google.com/gmail/api/reference/rest/v1/users.messages/batchDelete + :param tokens: The object that holds the access token to the service. + :param message_ids: One or more message ids (assigned by Google). + :param user_id: The id of the user for which this service must be run. Typically, the e-mail id itself or "me". + :return: A structured response where the list of labels will be in the 'data' variable. + """ + + # Ensure that the tokens are valid: + await tokens.arefresh( + http_client = self._http_client, + client_id = self._client_id, + client_secret = self._client_secret, + force_refresh = False + ) + + # Make the API call: + if not self._debug_only_errors: self._printer("Deleting Message(s).", user_id) + api_response = await self.post( + url = f"https://gmail.googleapis.com/gmail/v1/users/{user_id}/messages/batchDelete", + headers = {"Authorization": f"Bearer {tokens.accessToken}"}, + json = {"ids": message_ids if isinstance(message_ids, list) else [message_ids]} + ) + + # If the call was successful: + if api_response.httpCode in [200, 204]: + api_response.success = True + + # Done here: + return api_response + + async def trash_message( + self, + tokens: GoogleAuthTokens, + message_id: str, + user_id: str = "me" + ) -> GoogleApiResponse: + + """ + To move one message to trash. + DOCUMENTATION: + 1. https://developers.google.com/gmail/api/reference/rest/v1/users.messages/trash + 2. https://developers.google.com/gmail/api/reference/rest/v1/users.messages#Message + :param tokens: The object that holds the access token to the service. + :param message_id: The id of the message (assigned by Google). + :param user_id: The id of the user for which this service must be run. Typically, the e-mail id itself or "me". + :return: A structured response where the list of labels will be in the 'data' variable. + """ + + # Ensure that the tokens are valid: + await tokens.arefresh( + http_client = self._http_client, + client_id = self._client_id, + client_secret = self._client_secret, + force_refresh = False + ) + + # Make the API call: + if not self._debug_only_errors: self._printer("Trashing One Message.", user_id) + api_response = await self.post( + url = f"https://gmail.googleapis.com/gmail/v1/users/{user_id}/messages/{message_id}/trash", + headers = {"Authorization": f"Bearer {tokens.accessToken}"}, + ) + + # If the call was successful: + if api_response.httpCode in [200, 204]: + api_response.success = True + api_response.data = await api_response.get_json() + + # Done here: + return api_response + + async def untrash_message( + self, + tokens: GoogleAuthTokens, + message_id: str, + user_id: str = "me" + ) -> GoogleApiResponse: + + """ + To move one message to trash. + DOCUMENTATION: + 1. https://developers.google.com/gmail/api/reference/rest/v1/users.messages/trash + 2. https://developers.google.com/gmail/api/reference/rest/v1/users.messages#Message + :param tokens: The object that holds the access token to the service. + :param message_id: The id of the message (assigned by Google). + :param user_id: The id of the user for which this service must be run. Typically, the e-mail id itself or "me". + :return: A structured response where the list of labels will be in the 'data' variable. + """ + + # Ensure that the tokens are valid: + await tokens.arefresh( + http_client = self._http_client, + client_id = self._client_id, + client_secret = self._client_secret, + force_refresh = False + ) + + # Make the API call: + if not self._debug_only_errors: self._printer("Un-Trashing One Message.", user_id) + api_response = await self.post( + url = f"https://gmail.googleapis.com/gmail/v1/users/{user_id}/messages/{message_id}/untrash", + headers = {"Authorization": f"Bearer {tokens.accessToken}"}, + ) + + # If the call was successful: + if api_response.httpCode in [200]: + api_response.success = True + api_response.data = await api_response.get_json() + + # Done here: + return api_response + + async def send_message( + self, + tokens: GoogleAuthTokens, + message: GMailMessage, + thread_id: str = None, + user_id: str = "me" + ) -> GoogleApiResponse: + + """ + To send one message. + NOTE: If you want to apply a custom label to your outgoing mails, this API endpoint doesn't allow you to do that + in one go. Instead, you should note down the 'id' field from a successful response and use the 'modify_messages' + method of this class to immediately apply that label to the mail in a separate call. + DOCUMENTATION: + 1. https://developers.google.com/gmail/api/reference/rest/v1/users.messages/send + 2. https://developers.google.com/gmail/api/reference/rest/v1/users.messages#Message + :param tokens: The object that holds the access token to the service. + :param message: The object that has the content of the message to be sent. + :param thread_id: Replies to an existing mail if the correct thread-id is specified. If not specified, a new + mail with a new thread-id is created. + :param user_id: The id of the user for which this service must be run. Typically, the e-mail id itself or "me". + :return: A structured response where the list of labels will be in the 'data' variable. + """ + + # Ensure that the tokens are valid: + await tokens.arefresh( + http_client = self._http_client, + client_id = self._client_id, + client_secret = self._client_secret, + force_refresh = False + ) + + # Construct the JSON body: + json_body = {"raw": message.get_raw_message(as_base64 = True)} + if thread_id: json_body["threadId"] = thread_id + + # Make the API call: + if not self._debug_only_errors: self._printer("Sending One Message.", user_id) + api_response = await self.post( + url = f"https://gmail.googleapis.com/gmail/v1/users/{user_id}/messages/send", + headers = { + "Authorization": f"Bearer {tokens.accessToken}", + "Content-Type": "message/rfc822" + }, + json = json_body + ) + + # If the call was successful: + if api_response.httpCode in [200]: + api_response.success = True + api_response.data = await api_response.get_json() + + # Done here: + return api_response + + +# ***************************************************************************************************************** +# ***** **** +# *** MAIN PROGRAM *** +# ***** **** +# ***************************************************************************************************************** + + +if __name__ == "__main__": + + # Create an HTTP client: + test_client = httpx.AsyncClient( + limits = httpx.Limits( + max_connections = 100, # ............ Maximum number of connections allowed in the pool. + max_keepalive_connections = 50, # ... Maximum number of connections that can be kept alive. + ), + timeout = httpx.Timeout( + connect = 2.5, # ... Shorter connection timeout. + read = 2.5, # ...... Like what EasyEcom gives. + write = 10.0, # .... Time to wait for sending data. + pool = 120.0 # ..... Time to wait for a free connection from the pool. + ) + ) + + # Read the secrets that give you access to the app: + secrets_file = r"../../../creds/goog/app/google_tcaoff_test_oauth_20241125.json" + secrets_dict = json.from_file(secrets_file) + + # Read the user's tokens that will give you app access to that user's account: + tokens_file = r"../../../creds/goog/user/test_user_tokens_20241125.json" + tokens_dict = json.from_file(tokens_file) + test_tokens = GoogleAuthTokens(**tokens_dict) + + async def main(): + + # Create an instance of the client: + my_gmail = AsyncGMailClient( + service_name = "gmail", + client_id = secrets_dict["web"]["client_id"], + client_secret = secrets_dict["web"]["client_secret"], + http_client = test_client, + debug = True, + debug_prefix = "GMail (M) | ", + debug_only_errors = False + ) + + # Create a sample mail: + my_mail = GMailMessage( + from_email = "pskhushal@gmail.com", + to_email = "orangebhopli@gmail.com", + subject = "Re: Bhopli is the best! (Thread Test)", + cc_emails = None, + bcc_emails = None + ) + my_mail.add_html( + """ + + + + + Sample HTML String + + + +

Hello, Bhopli!

+

Bhopli is the best, most well-behaved cat in the known universe.

+ + + """ + ) + my_mail.add_text("This is how you should pet her 👇") + my_mail.add_inline_image(r"../../../data/images/cat_petting.png") + my_mail.add_attachment(r"../../../data/pdf/sample_label.pdf") + # print(my_mail.get_raw_message(as_base64 = False)) + + # Test some feature: + response = await my_gmail.send_message( + tokens = test_tokens, + message = my_mail, + thread_id = "1936caffb67996d3" + ) + print("SUCCESS:", response.success) + print("SUMMARY:", response.to_markdown()) + print("\n\n---\n\n") + print("DATA:", json.to_string(response.data, default = str)) + if not response.success: + print("\n\n---\n\n") + print("FULL RESPONSE JSON:", json.to_string(await response.get_json())) + + + asyncio.run(main()) diff --git a/utils_v2/goog/gmail/gmail_message.py b/utils_v2/goog/gmail/gmail_message.py new file mode 100644 index 0000000..bb29eb5 --- /dev/null +++ b/utils_v2/goog/gmail/gmail_message.py @@ -0,0 +1,322 @@ +""" + + AUTHOR: + + Khushal P Soonderji + + DATE: + + Tuesday, 16th Jul, 2024 + + OBJECTIVE: + + To be able to send out mails from code. + + REFERENCES: + + N/A + + DOWNLOADS: + + N/A + +""" + + +# ***************************************************************************************************************** +# ***** **** +# *** IMPORT *** +# ***** **** +# ***************************************************************************************************************** + + +# To make sibling directories accessible for imports: +import sys +sys.path.append(".") +sys.path.append("..") + +# For working with mails: +from email.mime.multipart import MIMEMultipart +from email.mime.image import MIMEImage +from email.mime.text import MIMEText +from email.mime.base import MIMEBase +from email import encoders + +# For random strings: +import string +import random + +# For system-level activities: +import os + +# For working with files in RAM: +import io + +# To work with Base64 encoding: +import base64 + +# To work with datatypes: +from typing import List + + +# ***************************************************************************************************************** +# ***** **** +# *** MACROS / ONE-TIME INIT *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** VARIABLES *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** FUNCTIONS *** +# ***** **** +# ***************************************************************************************************************** + + +# --- Nothing Yet + + +# ***************************************************************************************************************** +# ***** **** +# *** CLASSES *** +# ***** **** +# ***************************************************************************************************************** + + +class GMailMessage: + + def __init__( + self, + from_email: str, + to_email: str, + subject: str, + cc_emails: List[str] = None, + bcc_emails: List[str] = None + ): + + """ + Create an instance of the message that you would like to send. + :param from_email: The EMail ID of the sender. + :param to_email: The EMail ID of the recipient. + :param subject: The subject of the mail. + :param cc_emails: A list of recipients to add to the CC section. + :param bcc_emails: A list of recipients to add to the BCC section. + """ + + # Create the instance of the message: + self.message = MIMEMultipart() + self.message["From"] = from_email + self.message["To"] = to_email + self.message["Subject"] = subject + if cc_emails: self.message["CC"] = ",".join(cc_emails) + if bcc_emails: self.message["BCC"] = ",".join(bcc_emails) + + # Note down the values for accessing later: + self.__from = from_email + self.__to = to_email + self.__cc = cc_emails, + self.__bcc = bcc_emails + self.__subject = subject + + # ┏┓ • + # ┃┃┏┓┏┓┏┓┏┓┏┓╋┓┏┓┏ + # ┣┛┛ ┗┛┣┛┗ ┛ ┗┗┗ ┛ + # ┛ + + @property + def from_mail(self): + return self.__from + + @property + def to_mail(self): + return self.__to + + @property + def cc_mails(self): + return self.__cc + + @property + def bcc_mails(self): + return self.__bcc + + @property + def subject(self): + return self.__subject + + # ┏┓ ┓ ┓ ┏┓ + # ┣┫┏┫┏┫ ┃ ┏┓┏┓╋┏┓┏┓╋ + # ┛┗┗┻┗┻ ┗┛┗┛┛┗┗┗ ┛┗┗ + + def add_text(self, text): + + """ + Add plain-text to the mail body. + :param text: The text to add to the mail body. + :return: None. + """ + + self.message.attach(MIMEText(text, "plain")) + + def add_html(self, html_text): + + """ + Add HTML text to the mail body. + :param html_text: The HTML text to add to the mail body. + :return: None. + """ + + self.message.attach(MIMEText(html_text, "html")) + + def add_inline_image(self, image_file, content_id = None): + + """ + Add an inline image to the body of the mail. + NOTE: This is NOT the same as sending an image as an attachment. + :param image_file: The image data to attach to the mail body. + :param content_id: Inline images are inserted via HTML bocks. This field identifies the image resource. If not + specified, I will generate a random string. You may write a custom value here if you know what you are + doing. For most use cases, please ignore this field. + :return: None. + """ + + # Read the image as bytes: + image_bytes = None + if type(image_file) is str: + with open(image_file, "rb") as opened_image_file: + image_bytes = opened_image_file.read() + if type(image_file) is io.BytesIO: + image_file.seek(0) + image_bytes = image_file.getvalue() + + # Declare the part to be attached to the multipart message: + if image_bytes is not None: + + # Create the HTML block if the image pointer is blank: + if content_id is None: + content_id = ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(8)) + self.add_html(f""" + + +

+ + + """) + + # Then add the image: + image_part = MIMEImage(image_bytes) + image_part.add_header("Content-ID", f"<{content_id}>") + self.message.attach(image_part) + + def add_attachment( + self, + attachment_file: str | io.BytesIO, + file_name: str = None + ): + + """ + Add a file as an attachment to the mail. This file, even if possible, will not be rendered on the screen in-line + with the body. It will be made available as a download. + :param attachment_file: The file that you would like to attach. + :param file_name: The name of the file. This is the same name by which it will be downloaded. You need not + specify this if the input file is specified as a path. Needed when you give the input file as a buffer. + :return: + """ + + # Declare the part to be attached to the multipart message: + part = MIMEBase("application", "octet-stream") + + # If the attachment is a file stored in the local disk: + if isinstance(attachment_file, str): + file_name = file_name or os.path.split(attachment_file)[-1] + with open(attachment_file, "rb") as attachment: + part.set_payload(attachment.read()) + + # If the file is held in RAM: + elif isinstance(attachment_file, io.BytesIO): + attachment_file.seek(0) + part.set_payload(attachment_file.read()) + + # Encode and attach the file: + encoders.encode_base64(part) + part.add_header( + "Content-Disposition", + f"attachment; filename= {file_name}", + ) + self.message.attach(part) + + # ┏┓ + # ┣ ┏┓┏┓┏┳┓┏┓╋ + # ┻ ┗┛┛ ┛┗┗┗┻┗ + + def get_raw_message( + self, + as_base64: bool = True + ): + + """ + Get the raw string dump from the current contents of the message. This text will be compliant with RFC 5322 and + RFC 2045 (among others). + :param as_base64: If set to True, the response will be a URL-safe B64 output, else it'll be a raw string. + :return: The standardized raw text dump. either as a raw string or as a Base64 (url-safe) string. + """ + + if not as_base64: return self.message + else: return base64.urlsafe_b64encode(self.message.as_bytes()).decode() + + +# ***************************************************************************************************************** +# ***** **** +# *** MAIN PROGRAM *** +# ***** **** +# ***************************************************************************************************************** + + +if __name__ == "__main__": + + my_mail = GMailMessage( + from_email = "sender@gmail.com", + to_email = "recipient@gmail.com", + subject = "Bhopli is the best!", + cc_emails = None, + bcc_emails = None + ) + my_mail.add_html( + """ + + + + + Sample HTML String + + + +

Hello, Bhopli!

+

Bhopli is the best, most well-behaved cat in the known universe.

+ + + """ + ) + my_mail.add_text("This is how you should pet her 👇") + my_mail.add_inline_image(r"../../../data/images/cat_petting.png") + my_mail.add_attachment(r"../../../data/pdf/sample_label.pdf") + print(my_mail.get_raw_message(as_base64 = True)) diff --git a/utils_v2/goog/gmail/gmail_v2.py b/utils_v2/goog/gmail/gmail_v2.py deleted file mode 100644 index 64150d0..0000000 --- a/utils_v2/goog/gmail/gmail_v2.py +++ /dev/null @@ -1,464 +0,0 @@ -""" - - AUTHOR: - - Khushal P Soonderji - - DATE: - - Monday, 25th Nov., 2024 - - OBJECTIVE: - - To manage e-mails in a GMail account. - - REFERENCES: - - 1. Quickstart: https://developers.google.com/gmail/api/quickstart/python - 2. Labels: https://developers.google.com/gmail/api/guides/labels - 3. Messages: https://developers.google.com/gmail/api/reference/rest/v1/users.messages - - DOWNLOADS: - - N/A - -""" - - -# ***************************************************************************************************************** -# ***** **** -# *** IMPORT *** -# ***** **** -# ***************************************************************************************************************** - - -# To make sibling directories accessible for imports: -import sys -sys.path.append(".") -sys.path.append("..") - -# System-level activities: -import io - -# My utils: -from utils_v2.string import json -from utils_v2.string import regex -from utils_v2.date_time import date_time -from utils_v2.mail import mail_parser - -# My Google utils: -from utils_v2.oauth.services.goog import GoogleOAuth -from utils_v2.goog.base import AsyncGoogleBase -from utils_v2.goog.models.data.auth_tokens import GoogleAuthTokens -from utils_v2.goog.models.data.api_call import GoogleApiResponse - -# Related to Google: -from google.auth.transport.requests import Request -from google.oauth2.credentials import Credentials -from googleapiclient.discovery import build - -# To make API calls: -import httpx - -# For asynchronous activities: -import asyncio - -# To work with date and time: -import datetime - -# For working with datatypes: -from typing import Dict, Literal, List, Any - -# For debugging: -from icecream import IceCreamDebugger - -# For computational help: -import math - -# For base64 encoding: -import base64 - - -# ***************************************************************************************************************** -# ***** **** -# *** MACROS / ONE-TIME INIT *** -# ***** **** -# ***************************************************************************************************************** - - -# --- Nothing Yet - - -# ***************************************************************************************************************** -# ***** **** -# *** VARIABLES *** -# ***** **** -# ***************************************************************************************************************** - - -# --- Nothing Yet - - -# ***************************************************************************************************************** -# ***** **** -# *** FUNCTIONS *** -# ***** **** -# ***************************************************************************************************************** - - -# --- Nothing Yet - - -# ***************************************************************************************************************** -# ***** **** -# *** CLASSES *** -# ***** **** -# ***************************************************************************************************************** - - -class AsyncGMailClient(AsyncGoogleBase): - - async def get_user_profile( - self, - tokens: GoogleAuthTokens, - user_id: str = "me", - ) -> GoogleApiResponse: - - """ - To get the list of labels of this user. - DOCUMENTATION: - 1. https://developers.google.com/gmail/api/reference/rest/v1/users/getProfile - :param tokens: The object that holds the access token to the service. - :param user_id: The id of the user for which this service must be run. Typically, the e-mail id itself or "me". - :return: A structured response where the list of labels will be in the 'data' variable. - """ - - # Ensure that the tokens are valid: - await tokens.arefresh( - http_client = self._http_client, - client_id = self._client_id, - client_secret = self._client_secret, - force_refresh = False - ) - - # Make the API call: - if not self._debug_only_errors: self._printer("Getting User Profile.") - api_response = await self.get( - url = f"https://gmail.googleapis.com/gmail/v1/users/{user_id}/profile", - headers = {"Authorization": f"Bearer {tokens.accessToken}"} - ) - - # If the call was successful: - if api_response.httpCode in [200]: - api_response.data = await api_response.get_json() - - # Done here: - return api_response - - # ┓ ┓ ┓ - # ┃ ┏┓┣┓┏┓┃┏ - # ┗┛┗┻┗┛┗ ┗┛ - - async def list_labels( - self, - tokens: GoogleAuthTokens, - user_id: str = "me" - ) -> GoogleApiResponse: - - """ - To get the list of labels of this user. - DOCUMENTATION: - 1. https://developers.google.com/gmail/api/reference/rest/v1/users.labels/list - 2. https://developers.google.com/gmail/api/reference/rest/v1/users.labels#Label - :param tokens: The object that holds the access token to the service. - :param user_id: The id of the user for which this service must be run. Typically, the e-mail id itself or "me". - :return: A structured response where the list of labels will be in the 'data' variable. - """ - - # Ensure that the tokens are valid: - await tokens.arefresh( - http_client = self._http_client, - client_id = self._client_id, - client_secret = self._client_secret, - force_refresh = False - ) - - # Make the API call: - if not self._debug_only_errors: self._printer("Listing All Labels.", user_id) - api_response = await self.get( - url = f"https://gmail.googleapis.com/gmail/v1/users/{user_id}/labels", - headers = {"Authorization": f"Bearer {tokens.accessToken}"} - ) - - # If the call was successful: - if api_response.httpCode in [200]: - api_response.success = True - api_json = await api_response.get_json() - api_response.data = {label.pop("name"): label for label in api_json.get("labels", [])} - - # Done here: - return api_response - - async def get_label( - self, - tokens: GoogleAuthTokens, - label_id: str, - user_id: str = "me" - ) -> GoogleApiResponse: - - """ - To get one label of this user. the label will be identified by its id. - DOCUMENTATION: - 1. https://developers.google.com/gmail/api/reference/rest/v1/users.labels/get - 2. https://developers.google.com/gmail/api/reference/rest/v1/users.labels#Label - :param tokens: The object that holds the access token to the service. - :param label_id: The id that Google assigned to the label. - :param user_id: The id of the user for which this service must be run. Typically, the e-mail id itself or "me". - :return: A structured response where the list of labels will be in the 'data' variable. - """ - - # Ensure that the tokens are valid: - await tokens.arefresh( - http_client = self._http_client, - client_id = self._client_id, - client_secret = self._client_secret, - force_refresh = False - ) - - # Make the API call: - if not self._debug_only_errors: self._printer("Getting One Label.", user_id) - api_response = await self.get( - url = f"https://gmail.googleapis.com/gmail/v1/users/{user_id}/labels/{label_id}", - headers = {"Authorization": f"Bearer {tokens.accessToken}"} - ) - - # If the call was successful: - if api_response.httpCode in [200]: - api_response.success = True - api_response.data = await api_response.get_json() - - # Done here: - return api_response - - async def create_label( - self, - tokens: GoogleAuthTokens, - label_name: str, - label_visibility: Literal["labelShow", "labelShowIfUnread", "labelHide"] = "labelShow", - message_visibility: Literal["show", "hide"] = "show", - label_text_color: str = "#434343", - label_background_color: str = "#000000", - user_id: str = "me" - ) -> GoogleApiResponse: - - """ - Create one label for the user. Doesn't apply it to any mail, just creates it. - DOCUMENTATION: - 1. https://developers.google.com/gmail/api/reference/rest/v1/users.labels/create - 2. https://developers.google.com/gmail/api/reference/rest/v1/users.labels#Label - :param tokens: The object that holds the access token to the service. - :param label_name: The display name of the label. - :param label_visibility: Whether, or not, you would like to show the label in the web UI. - :param message_visibility: Whether, or not, you would like to show messages with this label in the web UI. - :param label_text_color: The colour of the text of the label. - :param label_background_color: The colour of the background/tag of the label. - :param user_id: The id of the user for which this service must be run. Typically, the e-mail id itself or "me". - :return: A structured response where the list of labels will be in the 'data' variable. - """ - - # Ensure that the tokens are valid: - await tokens.arefresh( - http_client = self._http_client, - client_id = self._client_id, - client_secret = self._client_secret, - force_refresh = False - ) - - # Make the API call: - if not self._debug_only_errors: self._printer("Getting One Label.", user_id) - api_response = await self.post( - url = f"https://gmail.googleapis.com/gmail/v1/users/{user_id}/labels", - headers = {"Authorization": f"Bearer {tokens.accessToken}"}, - json = { - "name": label_name, - "messageListVisibility": "show" if message_visibility else "hide", - "labelListVisibility": "labelShow" if label_visibility else "labelHide", - "color": { - "textColor": label_text_color.lower(), - "backgroundColor": label_background_color.lower() - } - } - ) - - # If the call was successful: - if api_response.httpCode in [200]: - api_response.success = True - api_response.data = await api_response.get_json() - - # Done here: - return api_response - - async def update_label( - self, - tokens: GoogleAuthTokens, - label_id: str, - label_name: str = None, - label_visibility: Literal["labelShow", "labelShowIfUnread", "labelHide"] = None, - message_visibility: Literal["show", "hide"] = None, - label_text_color: str = None, - label_background_color: str = None, - user_id: str = "me" - ) -> GoogleApiResponse: - - """ - Updates one label for the user. - DOCUMENTATION: - 1. https://developers.google.com/gmail/api/reference/rest/v1/users.labels/update - 2. https://developers.google.com/gmail/api/reference/rest/v1/users.labels#Label - NOTE: Both or none of the colours must be updated. For this reason, a simple default will be chosen for the - other if only one is provided. - :param tokens: The object that holds the access token to the service. - :param label_id: The id that Google assigned to the label. - :param label_name: The display name of the label. - :param label_visibility: Whether, or not, you would like to show the label in the web UI. - :param message_visibility: Whether, or not, you would like to show messages with this label in the web UI. - :param label_text_color: The colour of the text of the label. - :param label_background_color: The colour of the background/tag of the label. - :param user_id: The id of the user for which this service must be run. Typically, the e-mail id itself or "me". - :return: A structured response where the list of labels will be in the 'data' variable. - """ - - # Ensure that the tokens are valid: - await tokens.arefresh( - http_client = self._http_client, - client_id = self._client_id, - client_secret = self._client_secret, - force_refresh = False - ) - - # Format the JSON body: - json_body = {} - if label_name: json_body["name"] = label_name - if label_visibility: json_body["labelListVisibility"] = label_visibility - if message_visibility: json_body["messageListVisibility"] = message_visibility - if label_text_color or label_background_color: - json_body["color"] = { - "textColor": (label_text_color or "#434343").lower(), - "backgroundColor": (label_background_color or "#000000").lower() - } - - # Make the API call: - if not self._debug_only_errors: self._printer("Updating One Label.", user_id) - api_response = await self.put( - url = f"https://gmail.googleapis.com/gmail/v1/users/{user_id}/labels/{label_id}", - headers = {"Authorization": f"Bearer {tokens.accessToken}"}, - json = json_body - ) - - # If the call was successful: - if api_response.httpCode in [200]: - api_response.success = True - api_response.data = await api_response.get_json() - - # Done here: - return api_response - - async def delete_label( - self, - tokens: GoogleAuthTokens, - label_id: str, - user_id: str = "me" - ) -> GoogleApiResponse: - - """ - To delete one label of this user. the label will be identified by its id. - DOCUMENTATION: - 1. https://developers.google.com/gmail/api/reference/rest/v1/users.labels/delete - :param tokens: The object that holds the access token to the service. - :param label_id: The id that Google assigned to the label. - :param user_id: The id of the user for which this service must be run. Typically, the e-mail id itself or "me". - :return: A structured response where the list of labels will be in the 'data' variable. - """ - - # Ensure that the tokens are valid: - await tokens.arefresh( - http_client = self._http_client, - client_id = self._client_id, - client_secret = self._client_secret, - force_refresh = False - ) - - # Make the API call: - if not self._debug_only_errors: self._printer("Getting One Label.", user_id) - api_response = await self.delete( - url = f"https://gmail.googleapis.com/gmail/v1/users/{user_id}/labels/{label_id}", - headers = {"Authorization": f"Bearer {tokens.accessToken}"} - ) - - # If the call was successful: - if api_response.httpCode in [200, 204]: - api_response.success = True - - # Done here: - return api_response - - -# ***************************************************************************************************************** -# ***** **** -# *** MAIN PROGRAM *** -# ***** **** -# ***************************************************************************************************************** - - -if __name__ == "__main__": - - import dateparser - from utils_v2.goog.models.data.auth_tokens import GoogleAuthTokens - - # Create an HTTP client: - test_client = httpx.AsyncClient( - limits = httpx.Limits( - max_connections = 100, # ............ Maximum number of connections allowed in the pool. - max_keepalive_connections = 50, # ... Maximum number of connections that can be kept alive. - ), - timeout = httpx.Timeout( - connect = 2.5, # ... Shorter connection timeout. - read = 2.5, # ...... Like what EasyEcom gives. - write = 10.0, # .... Time to wait for sending data. - pool = 120.0 # ..... Time to wait for a free connection from the pool. - ) - ) - - secrets_file = r"../../../creds/google_tcaoff_test_oauth_20241125.json" - secrets_dict = json.from_file(secrets_file) - - tokens = { - "accessToken": "ya29.a0AeDClZAYoo85BXRId_n-hwo_amKshzi46c33GaJcsZZvGB7A7OGU2RFYcWBM_BleNBfAFUSJP2NHAvmd7Nsp_U5Kg68hXSy0iO99PNTm3pvKrJSzbkA-rXsVLsCnBIfPUMyNt2nOOVJmGwm17DNN0jAELkm1fPNTju7SZzmuaCgYKAZwSARMSFQHGX2Mis8TZui2rZT1gKySVds-N0w0175", - "refreshToken": "1//0gnqzjMf9YT19CgYIARAAGBASNgF-L9Ir3rcY37nGrV45XyOUBRllEH7Txui7T1JbwevlmDoNw7PuMu149cCWQSwsScuKaZusUQ", - "expiresIn": 3539, - "expiresAt": dateparser.parse("2024-11-25 10:40:40.833699+00:00"), - "scopes": [ - "https://www.googleapis.com/auth/gmail.labels", - "https://www.googleapis.com/auth/gmail.modify" - ] - } - - async def main(): - - my_gmail = AsyncGMailClient( - service_name = "gmail", - client_id = secrets_dict["web"]["client_id"], - client_secret = secrets_dict["web"]["client_secret"], - http_client = test_client, - debug = True, - debug_prefix = "GMail (M) | ", - debug_only_errors = False - ) - - test_tokens = GoogleAuthTokens(**tokens) - - response = await my_gmail.get_user_profile(tokens = test_tokens) - print("RESPONSE:", response) - - - asyncio.run(main()) diff --git a/utils_v2/goog/models/data/api_call.py b/utils_v2/goog/models/data/api_call.py index 766a70f..6301dff 100644 --- a/utils_v2/goog/models/data/api_call.py +++ b/utils_v2/goog/models/data/api_call.py @@ -107,6 +107,7 @@ class GoogleApiResponse(BaseModel): message += f"*URL:*\n`{self.url}`\n\n" message += f"*METHOD:*\n`{self.method}`\n\n" message += f"*RESPONSE:*\n`{self.response}`\n\n" + message += f"*MESSAGE:*\n`{self.message}`\n\n" message += f"*EXCEPTION:*\n`{self.exception.__class__.__name__}: {str(self.exception)}`\n\n" return message diff --git a/utils_v2/oauth/services/goog.py b/utils_v2/oauth/services/goog.py index 88249a3..3376e1c 100644 --- a/utils_v2/oauth/services/goog.py +++ b/utils_v2/oauth/services/goog.py @@ -363,7 +363,7 @@ if __name__ == "__main__": import dateparser - secrets_file = r"../../../creds/google_tcaoff_test_oauth_20241125.json" + secrets_file = r"../../../creds/goog/app/google_tcaoff_test_oauth_20241125.json" secrets_dict = json.from_file(secrets_file) my_goog = GoogleOAuth(