From 542acd30a9d4a378d86c3b902e83074636275846 Mon Sep 17 00:00:00 2001 From: Nnenna Ndukwe Date: Wed, 31 Jan 2024 08:34:46 -0500 Subject: [PATCH] docs: simplify Python tutorial (#6073) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## About the changes - Reducing the amount of steps to implement the Python app feature flag - fixed spaces & indentations in Python code snippets (mentioned in past PR review) - `git clone` my forked repo so we can customize that code in the future as we improve it, without being blocked. Closes # ### Important files ## Discussion points More improvements to be made to the HTTP calls in `routes.py` as I continue working on resolving some issues I'm running into in the app. --------- Co-authored-by: Simon Hornby --- .../python/implementing-feature-flags.md | 97 ++++++------------ website/static/img/python-tutorial-404.png | Bin 15911 -> 0 bytes 2 files changed, 34 insertions(+), 63 deletions(-) delete mode 100644 website/static/img/python-tutorial-404.png diff --git a/website/docs/feature-flag-tutorials/python/implementing-feature-flags.md b/website/docs/feature-flag-tutorials/python/implementing-feature-flags.md index 7a2a1c5dec..24392bd24c 100644 --- a/website/docs/feature-flag-tutorials/python/implementing-feature-flags.md +++ b/website/docs/feature-flag-tutorials/python/implementing-feature-flags.md @@ -37,6 +37,9 @@ In this tutorial, you will need the following: ![An architectural diagram of our Python app using Unleash feature flags](/img/python-flask-unleash-architecture.png) +This architecture diagram breaks down how the Python app works with Unleash to control feature flags. We connect the Unleash service to your Python app using the Python SDK. + +The Unleash Server is a **Feature Flag Control Service**, which is a service that manages your feature flags and is used to retrieve flag data from (and send data to, especially when not using a UI). The Unleash server has a UI for creating and managing projects and feature flags. There are also [API commands available](https://docs.getunleash.io/reference/api/unleash) to perform the same actions straight from your CLI or server-side app. ## 1. Unleash best practice for backend apps @@ -130,7 +133,7 @@ In this section, you will clone an open-source Python application called [Flask Use this command to clone the repository via your Terminal: ``` -git clone git@github.com:pamelafox/flask-surveys-container-app.git +git clone git@github.com:nnennandukwe/flask-surveys-container-app.git ``` Next, navigate into your repository directory and create a `.env` file. @@ -155,7 +158,7 @@ UnleashClient==5.11.1 ``` -In `src/backend/__init__.py`, import UnleashClient: +In `src/backend/__init__.py`, import `UnleashClient`: ```py @@ -168,9 +171,10 @@ In the same file, call the Unleash client for initialization when the app runs w ```py client = UnleashClient( - url="http://host.docker.internal:4242/api", - app_name="flask-surveys-container-app", - custom_headers={'Authorization': ''}) + url="http://host.docker.internal:4242/api", + app_name="flask-surveys-container-app", + custom_headers={'Authorization': ''} +) client.initialize_client() ``` @@ -209,14 +213,24 @@ This will require us to: - Create a delete button - Map the delete button to the delete method -In `src/backend/surveys/routes.py`, add `client` to the existing `backend` import statement. The full import line will now look like this: +First, we need an error handler to return a simple 404 page to stop a user from being able to delete a survey when the flag is off. We will use this function in our delete method. + +In your `routes.py` file, import a module from Flask that will support error handling: [`abort`](https://flask.palletsprojects.com/en/3.0.x/api/#flask.abort). + +Line 1 will now look like this: + +```py +from flask import redirect, render_template, request, url_for, abort +``` + +Add `client` to the `backend` import statement on line 4. The full import line will now look like this: ```py from backend import db, client ``` -We’ve imported the initialized Unleash client into `routes.py`. Now we can use that data to pass into the `surveys_list_page` method. This will allow us to check the status of the enabled flag to conditionally render the 'Delete' button on the surveys page. +We’ve imported the initialized Unleash client into `routes.py`. Now we can use that data to pass into the `surveys_list_page` method. This will allow us to check the status of the enabled flag to conditionally render the delete button on the surveys page. Add `client` as a parameter in the template that we return in the `surveys_list_page` method. @@ -226,16 +240,21 @@ The modified return statement in this method will now look like this: return render_template("surveys_list.html", surveys=surveys, client=client) ``` -In the same file, we will create a new route and a ‘delete’ method with this code snippet: +In the same file, we will create a new route and a delete method with this code snippet: ```py @bp.route("/surveys//delete", methods=["GET", "POST", "DELETE"]) def delete_survey(survey_id): - survey = db.get_or_404(Survey, survey_id) - db.session.delete(survey) - db.session.commit() + # if flag is not enabled, return a 404 page + if not client.is_enabled('delete_survey_flag'): + abort(404, description="Resource not found") + else: + # otherwise, delete the survey + survey = db.get_or_404(Survey, survey_id) + db.session.delete(survey) + db.session.commit() - return redirect(url_for("surveys.surveys_list_page")) + return redirect(url_for("surveys.surveys_list_page")) ``` The server now has a route that uses a survey ID to locate the survey in the database and delete it. @@ -250,7 +269,7 @@ In `src/backend/templates/surveys_list.html`, add the following code to your sur {% endif %} ``` -This code wraps a delete button in a conditional statement that checks whether or not the feature flag is enabled. This button has a link that points to the `delete_survey` method we created, which will pull in the survey using an ID to search the database, find the matching survey, and delete it from the database session. +This code wraps a delete button in a conditional statement that checks whether or not the feature flag is enabled. This button has a link that points to the `delete_survey` method we created, which will pull in the survey using an ID to search the database, find the matching survey, and delete it from the session. Your surveys page will now look something like this: @@ -277,55 +296,7 @@ Next, return to your Survey app and refresh the browser. With the flag disabled, ![Screenshot of app in browser without delete buttons for surveys](/img/python-tutorial-surveys-without-delete.png) -## 7. Improve a feature flag implementation with error handling - -If you turn the feature flag off, you won’t be able to use the delete button to remove a survey from your list. However, if a user wanted to bypass the UI to delete a survey, they could still use the URL from the delete method route to target a survey and delete it. - -They could do this because we have committed code that is only _partially_ hidden behind a feature flag. The HTML code is behind the flag, but the server method that it talks to is not. - -In a real world application, ignoring this would cause a user to perform an action they _shouldn’t_ able to. Luckily, we can use a feature flag to stop the delete method from being called manually. - -Let’s walk through how to gracefully handle this scenario: - -We need an error handler route to return a simple 404 page to stop a user from being able to manually delete a survey when the flag is off. - -In your `routes.py` file, import two more modules from Flask that will support our error handling function: [`abort`](https://flask.palletsprojects.com/en/3.0.x/api/#flask.abort) and [`jsonify`](https://flask.palletsprojects.com/en/3.0.x/api/#flask.json.jsonify). - -Line 1 will now look like this: - -```py -from flask import redirect, render_template, request, url_for, abort, jsonify -``` - -Next, add in the following error handling method at the bottom of the file: - -```py -@bp.errorhandler(404) -def resource_not_found(e): - return jsonify(error=str(e)), 404 -``` - -In order to render the error message, we can call it from the `delete_survey` method only in the case that the feature flag is turned off. Here’s how the updated `delete_survey` code would look like: - -```py -@bp.route("/surveys//delete", methods=["GET", "POST", "DELETE"]) -def delete_survey(survey_id): - if client.is_enabled('delete_survey_flag'): - survey = db.get_or_404(Survey, survey_id) - db.session.delete(survey) - db.session.commit() - - return redirect(url_for("surveys.surveys_list_page")) - else: - abort(404, description="Resource not found") -``` - -Now, if you turn off the flag in your Unleash instance and attempt to delete a survey directly with a URL, the 404 error will return. - -![Screenshot of 404 error rendering in browser](/img/python-tutorial-404.png) - -Learn more about [Flask Blueprint error handling](https://flask.palletsprojects.com/en/3.0.x/errorhandling/#blueprint-error-handlers). - ## Conclusion -In this tutorial, we installed Unleash locally, created a new feature flag, installed Unleash into a Python Flask app, and toggled new functionality that altered a database with a containerized project! + +In this tutorial, we ran Unleash locally, created a new feature flag, installed the Python SDK into a Python Flask app, and toggled new functionality that altered a database with a containerized project! diff --git a/website/static/img/python-tutorial-404.png b/website/static/img/python-tutorial-404.png deleted file mode 100644 index d24a6f3b2c2a4128c404275a1f82201978fdfc05..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15911 zcmeIZbyQT{_diZbmq>RA3ereO3`k2V(n?Bq_W%+i5+c$mAR*l&H8j#CAw3M;J;VUt z%kz%U^XTul)_1M%zn{DAoqIU7_u1!l_BnU&&3g?s1p-`ZToe=(0wqP+7bqy`U?5$I zeFylZ+oF?4LBR)E%gAUb$;dEjxHwu^+nJ-FD87%^!P3?2A-jVr*|1YQ=&_EMnB?!kiV2|2`i0wpo?0;;z8ayPl1QqTd!Nr>G(ow(tpKw z?Q(Vg=Gt`?WI32_a0jJ^ls5JOjTRQ8LK1s4&S6fB`m?+^9~3l=EPOkZ(Dnf`zMSkf zl3p;ZJ>jXRBnqF>YUfqlfdLdtNqbT0%C~SP?K-htW(o*!D*_j?i_MxMYggR?y zIVu;Q3h`3si!|=;@fT?v-M()mo%tSx&!A|$fH`$+pcEK}iA!Xr5eeG5aTJz z&vjzowPlqR=`^&8$HyenkH#%?ue0K=g$I6`s1maKQ}(Sf3)~NoE`5+HaCGyi`=&O{ zu15o3dFmy9M=Rf`XnXx!ZwfU>CkHVCN0Wt_X(Hq-IFSA59p%?Iif`d7*9EA;iI}!` z+!#HUoslw3LV+S0NpvK6i<)B?L3ZIVTxm<}!zpLtt*4DinKZ;j`@dK-sB^+Yh4S9( zsbB^~vm|uJclj{(L|Aeh8(HrE!u40FVPxSZF~#jzJ%1a59j?XX@nax@Ks~LB9Z!lJqQo#}gT&weCl}a^Cr)d+FYDkI!uk!Yh_oO5U2`>8?j+ zWM&5;;taW3xUP=RcIU3W zlm9FYqN9CrghGftEvSKL0NwzCJ^T{4AMTVsEC-R0(|B$?- zR{M>+onuu0Mwh|6w6Oief&hV}%{-Ag@!qGssA8|BoQX;AR5M+xV9_PbGV89eD5ewR z=xi_AdT-}WAon45B@WXa<0f3#&GKt}zk9LkwmXt}uMa--u{j6T_M26n?#GN+1LRuX&S2n&MSck3Zrk`6fngwcpU_X9I3z@a zggW~jB@1~t-BJiIu_G%Aj*RI?R#r?Ug^+NJ7Ti2@TeO*!Q;BhmqeMzIv`f|x_7PSs*5xTl}oFbGl z-QUjy{kWU&m34S7MtRYI$pXR=@CEFbZ&6#L7p09!yGTcI#&9}AT-t=%q}!~|9=Vgw zlf0Lu%O)Ekko=(3p|bR7>BW+dDf$6MMci4Eh!k_|v7!en&kNS~GQBKhaTBo~vC(m* zAM2GEK7Wpnj8FTF7XK+eE>^2$^!>=vBYvoI=yIs-f_HxR zdnz(HTe>3K>-o}y(S9VW$qTpZZ6Mw;UlDdgbEf~c|9ZM}rt^C-- zCT((kgwNViD8DE=sI8ajc^$9Yt~G+pf#L$q~~Dg%qrk0j7Qd6mzfS#57m`b1*~VmiB~n&hnM$Vs?XP# zM>rKS>8=JwI{9Xti{Nv7DP~W*?nPTu9ZmO4yx)4c;%5KI-EMKp+bp%a*`?NXRm{<; zdF81qj;G%8=;q=0_<+{7{@(PK#cslq_Kru~m(Fn0N9Jj+?2yavL$08u>yh?xmLapu zZljFT480673EwWgD0s}wliiBMLW*wNDxE~(Ik8UPIM2kG_e9 z1Ojp2Kb(&aji_&X4)00y1wVf0-o|i5bjWkChdqoRPE>&JMQlZ#M6XLR#jt!=kaJy@ zmq|p>%Y0w8mMOKb)Ew~+655Yrji-eZMNoq8f6s!3jnEcHpcc51xHcx=?zXyZYvZ%hSW!O4v#=kc?pNz~`0>0~sYjIc=RFIYnLKLM zbiNwRH|!kb?y5&gwHEqNk(Z5J6g}ymXVUU91D0p*Pkb_BB|2(4DmnzIs3$cCgmvM= zB2SZR1rq!Hw25{1XCC>Hdungl#r?vw0Hw93Re5vs3%fgPiC1~c9o4U44J=dyRx;YQ z?uAB&nuj`4uyNZxvVPXWXBk zBqSt)^?G0Xz$W>i+Q=u6NA`N%E|V)X+Qd58IUC^(5|=dax@r51nP+wWbKJA@H7phi1|x=rQ$)?K<#rj!*g5NhamQGR zWmKf90{Sqgut>D<`;o?vFB9ZL3Bm8^2pvJDF1ecprz0!f54t0w3Fvu5B%PkRW$(SY z=;?2#K;hJPw+lH6D@O981zj8xdl0Y*~s4N?D9xF4Td%# z!R75WIR~F+zOMG|CK^2HcBgMxa$Q;0x+$_OttfllNblTqB#(?T5N4m>uJ3FZcNM!l zEfPETlw3#;h;b@gZoarW#JWRdb6tL(^QEt5YhC8%-A;@t!<>J=KXOTFH;!jcPQ6gQ zFOv#XwBx)fI!ZI;dww3WHpe5&BZ(lpDLk+|QQk_#x<0%rY6|cRKyV%uwN5H8tT+9- zo;bJPlKR;a>?yh@3FSG@LYQ6OvXVv;PgUR_oXSt&gkJ|=2NmQEursYu|prHSikA{Ns&Kd>d z-*wc0_wDr&cy8PL>y7>?3n`wkWug72Haa*9{Xc1xN}voyT2n?z33zLoxtNRLmRorYPm~~Y(7-bw?%o&BadAWI+ zC2<)U8O2@RSUh3&>d#SDfd+ zb0&$KHgI+OkxOeWtNs#r10ehB(gS`U0MG3kc-VO&o>6b2ps-yk$x6TULfuZsNure_ z>*gemM8n1Sm_>_4E3w25AF6N7Z5wA$FHxpHa9PHntUWokJm?v z-l|~8O8xWBvc-w~xW6^+TZBi7N<#eiJ4jKgO`S^mpY^3M>eB*4j1m`&UuR&42ma$$ zJbOs=TUNMKA}P@QAx&}L@7<%~fL8ua?yvN^3))@qYjCi;H!~yJKW|vR`0rV0uhpd5 z%K9A3xz&H?k`?(G>yNSa;V7QDpA$^1y#FWT;ejEUhrefGyNROWK)Io<_2U1O3o6OS zeNFm*hzj66h?|`eHn}3Ko5=nf<7n}C(C0bwp)y1gf_8Ot9wz-sd?s`5lLQJ3{%(-X zZ$+|3zjH5}MS#IU2EN>`QcnT}fGI^?-awU;CH;ML%Z;U><|!n92(BPZ>T8?huS#>1 zU;?4=heqFB%pvEisqIaOd75a^)ueK_jZvFYMFp}yTCOW(@x$JISlg*vpjSWLZzp=k zRX58oe)xmsO0AUY_n{L9ZQ{ISLwd~E*EzLfQgm=aR#R--I^L5OL8;UKz>2IfN>FbI z!&66CTj)WEa>64VELx|Qz&P8We*oteG*TxR)67Wj=Ch^!F5%{ii=q*#-{(&X;|}r1 zeVRa7yx&W*8n6MT^3>9)R|nH+tP8u#8u+71<^L(xtg72egOZoqEuP5oqahH8*5wCN zx}1FB??MUUX3|LBho|~)l|jKBg08FmPSe);?l}Cf!+z)6jXWp;2lPs8(!jBa*I^dI zYu@|0d?PPa{w|T^nemiGsy*7mVcZ{h!>-Ph>I+$T?Kwv_p=%bkjjLU(OY&OWvOkGr zw_&f@?mPJRg%pTgod`5B0xqvj%}nYEecO_4++FC+wbRe%3Zbq;62b;~4F6m*w?N#v zKg@l=VcW>MNRn>ldxyL|JqF=+YT|K4-n=o*!Q5F1s{HP>WEI|o)AmvL{;kkQ4 zvrg?IzMF;lVelBxytXyR@AzgOmPd=nfr`4WhM*IP^BVRpb$)1X#bTK4>KiTmeX67| zBBjjR97l7NeoaaGx6C=&{?XqKJ0Sg35oc3fh?7YpGY<50K{x&RKPU6w-IO(*;(j3K zidg%;{7Jden8(9ZRqi(pCxtOB10cL^T-zX&v#gQdu(M{;kcRjz|DVu4B`r`nJK*z_ z+>DmESkE!J+^Hk-T=4h2EYq;O_uIk=qQB)bcxzn8h)!t6t|~lf@0`o1SzLU-1>I!2 z+8Q>h%(LTsT<`Zg87rcoXQ2!X4xqipqqb?=vbjB*JoQX^@<+PEj>#5(it8JbM&W)H z>mC3L6Xe-}$@Wx*382hPCW`f7JOSq)N+w?ZE*jaar)c&hV6r@;cjUjZjY?7=1L#ab z#**Jlg4Avm*oLRZ2E}h}f}UWoA~#V+ZGIohx*MRa)4^Er;NMIB?}!93vGLmcEZC2< z6lcj>Ds9-MUi!Iy41iXPcz@{JEJlL-`Y7|}rE`jjw@Sbc2Rv+r>qRy->o8f{UUAhR z?yY)kT(&L!&epg@R`!EC^W0KmOV9b5)9C!!*>OH7y<{xL1>;Gwua@ovCVQ%jo7}f7=oyhTQdp;sG$ity=*5&RvZXc=0u@a5?snyE=>xX*nT4lkFE7U2%x-SgX^N_W)A6Eao$7 z>3xgYm}Yd8GWo_9bl#&LCNL<%lFD-g|EZ}?;23Z@QzgS_RXgL*00`-9(6gD;@M=@1 zTQ1Si$NF^iXr_8e7~!;2Ga1bcC(S&Qv#_doshxbA>6=E-Kk{(vKRjAwIt z0gr+kpOjrKauV@Pr-=i35EDR4KIwDNNrA8}S0p_-gU7t<<8F9F>$Cm^2F#*Pz5Wq1 zZ!X64>#na94Zw~R2EE#GG)Z@#P|^uCBn8*>LOh$cUc&%1`Mo**!4ea~XV&$maZq$E zZNFEinSXE$yMC**!D8!J1|#fkV90`o#@;7aZKjOr(7cFh9=%G|+KB1LKktH%!>w6$+s#rl9I2@A}2f-5z=cdkz>iS^Sb!OS!{-9^HDmi8+3gTm|LTKHU~g2mV(Sf zKc`3t2Wr|@!y(t{s|C%=*sr>`AK8Rl;UPMH(@d>=J-lfz?5;?CX^$2PZk;tjPM)TF;eGr8D;xd;1|dEr8d!Lt_2((_0nX zpDd7XbZCnM?c;%p+coUeI{O;#Lf+1L!J|gBNE1m&0~v14y4!`D52C=`1F0qpQ9{!N z3*WwlE&yD|?6*-gmoJDWZ(zbaha?ao8)NxI5D#eJ{GcHfx@OUf<>cziItuXdM27F~ z_k&;Ab^eFTJwah)z7r4~cz29r!I!UdlV6Tb*n+;0gPZHV#qY(bHKx||ir_%i8grUb zEpVXoO^B~$jpIv&Zg1~62Wsch2~)m`*l03)_Vq63T`-LU#H8guRZhOiTDm7;-Mo)& z+S-CT_%N>jHI*>U#AKyjl|?85dUNGW+A+17>PR0@cPN)$@1yTSnR=GGfb6n>S5t&6 z*w@WzkA;>qe56k*)OtwW+lOe|n=*?T=z{5{7$WK?F}oI%b^x{NoK`Xb>_Re1#s#4a z<_%G?D@_xe1bW_xMr|r~SfYYTUl=EFTJ^M~OzqTVdIheX_t{#XRQnhOEY+c)gC{j; zk2N-K03AYO5L0NE<}qy@WSDik{zev3%}6x@+u$9f>i9$;WVx6tHpW?&Csxi{xHEh1 z3W1}S3~f$0=!(vBmZbUkD^KUq@r?ILKe>S&NwnB5r2Oi12CFNj25E)_Iwv;>b_rW7FA_YK5D`IoLF-5v>ac!EBinplBw~qmlJ*pdCgB8ke}{hTBi}e z-@ek_<;UpO(=bm>0v=e_!6?Oyg87N(Q+i)TaF&KccNj?4ygc-+=f|re_gT(`_!FNJ@L4+p;nZ$B!?jb(N}`|QFL+d=6na}Q zktF5(X>DDoaMOk=ti;jZ)^8PiN~#^bJAdR$f-~*mnYMvFS0u50E8b1fFL z+k@UT56Z~G%{3xWOixQ&{mj{L!9a~(Y~{12OPmY}7Gy*dRO)-UzBdIdp_lJNmuB;6P1(R9 zs`P-Dwx${WzRy+(MVZ-ZfAYqrhOSNhBX=`g%YjJ3@nYixOgZMP@v;o;B&wR~~5m>2WJ)xii6 zvvSy{=I*O6-lad(BR7_kexhEV4+V7b^@uBYGussOi5yaLBQ0jlXrfWD4g;w+0uT)g z_ElGTP1;>bXD1C;DO9V#Hl5KZ4LL)uD}Zom92=c?xpG{Tm=toG#m%6=qGBp#Zowjp zCh}jG@lFjJ0-sjFJ{TcQq3(7c&RV@TG@N{xmW9 z$ZLvU+5mLqmM~*|@*>+WOyT_;U|Y2iB_OJ{rXY-Rn2}*rE-WeN0lyhXGO-otq}AAx4xqA z@mI^5_z7H?g51?d8N!DudyaxGEg8?8 zGM-a)Ni-;m(E07RRx~1_L^qr%g+qzeqg+|=Kg?sM(U4dK-A7*M_C%j&AR5{0Do{}0 zGfIafZ+!>df$}u&w>@ISL?rNsWj!lgnNyA)Xm~IFy3b1dS*W$%Zq1;MZF<1O4^X%^ zhJy_@gHJp5YAp51`_2y(3bC1`X7sI!aD0NQ`LO!PBk!(!&OOiPPKkz(0Ih!L5}rVy z101W0_V7z~v5834!o@lEUd~#7sd;vfM~`ECo@>aVm_of8qYQd|QK1#rHZuEIgmA5Z z9chJG%iGh)xZ8L%IMx1CnJ*rT2kF&1EM_Io$wI!;65-I8+ z-B_1}VHt36&f4k+IfpW(=t#gkuTCZ{5uR7oqd+)ztpPlA=@WDu&k;6V82P87Q73#k# zkcm@eITQ`3r}I?x5@U~@^dw zMi@V(1Z0Q`pNAsQN!+iJ413Xf%^aKWE^xrs7F0h!$CjVB{jBBP7Snj~DY21NnS3$^ z(z+1S$6#3wy{fXfbSYW(E*)QR?UkNioA=@wjrG^bzgdM z|Ai2ICBnGPt5s84eL%;i;Kv^DMGtGf-0c%OD*1U2{BlC`XsKrc#^%yNqA+Dw{OTY( z3-hsi;h_Y0Tgp7)>1X0Ai|VN(e$aYWaOLrN1S${3s6`Xz%XM8xOySapkd(X$yXig` z#D-PfzOCH!6-3xDNp`#=swd>t^B9JBG>#C;l5K5oz|q4SSdit}`O0BXY_AcO`*FCc zPm(kHp|rLixS)4DE@B2a3{c7Oa62YBR+QaG`dy?9lpTZ=-RK4PIz?_OB|kN9c~pYW zJW8@CrHWUX|HB-VlNe($88J5K;ql?CfL&fu_JKK~>6OnUG=S z9lAX)1@StbtzTWjpFRpvqc?WHzwuKxY&Rrpi*b7nW4~F@9C$L9NUbqT&ES~dGJj8hPSo2F)F*yJ~)4ej*nhX7s@!m=O2 zxl|!l1o0kSYo>B&i<8i6_hVk;R7d|UgCIU%i)Ma(?K4u`VZ* z?LFwpk0TR_2XY9m-K3+KiZsY`GdYR3kUaxOuGk=N!hvtZ;LqyEXb0Ql0$rC;uO;qK z!SxBnw#&~NVgt;-3%f*T+as&OrgGlJ$csBh*5!{x`Jk9L3sc9GNaOQFzZ_;E<_3%c z5WMcSD}y&X-0)F1eEGA&NaYP{LyR!?pbQG|{OjlpbddI2@<#$4q}&=wC%aQm@(jff zTyojp;Tq!b4r`ZT`7G>Qt2IjywIZoY*K=$YJk?!Kd9ubz=L0*pH>Vi~n)VIdRCEGjqz`0m; zmOc>>auE#@uDjrp-m>}iC9Dzw6*sAUHQb3AE+ zUc{gCf#ud(qGA^53WYJ?^-g0Q{pjso8|R+rLpeCrFJB69r!5LodWYHjtLwdbzCW~} z?nAwyr@AwN*6Rcfib`c2It#;u5=cB<{nqDO_Q2^?L!U^dJecUWnYdn=TIcFL z+CnVh=umsu*Au48J_rigqlB@`@s_&X^2mz{s8794f#RjF^=1Mg4&dV%*f)J zq#bLOth<3yb`AMZ?cBll}M5v6u*wpPGF`TW;V#Gc8OV%VE zn*lqe_q^M`?G=2rGX#Cp^U0|N6$@V^gkoj2rd8gsn*3zD>NQK$L%Ydc{%BVpn#g@ksy^lOz>392IwT9v&7q2<@gLmNhhGzqrAm|t3wcPf$hX(4g zpuDR*;o&aPLo~H+(FE0o^n8ju2&J|DTOZ}G&zEWSZJ!(|XS6$_>8~DY=DmT}?&z-GRXcx5m2L^P zD+rjFnn)xoxeNAUXS}mXWd^2#h7p|lPjT#?jOtc5_g8iKWF25{HPe^H^Cf>^qz5wi{ia4GzdBi9}iV`A{6R z8ODuV3N5~VH=MUN;Fa47K_=Y?MNghGY=542qTXD8T;m+<5qX?!>Mfy8P%8>p`P=*1 zq0qzxCM|3bL&y6g3My%bb+UG={&M~zD_>~i`0P~$&H3DqD(#=}wX%i;yeHX|2~*Y9 zV`wY>8NjxuJjP22TkAssWT$j1JEmLNHS{iKcYUa);^fRoApy&3w~rII%SA4tds}dI zSLe77$%WL zRUM)YbVTkL$)EAf55GEz;JA)7sNx$=XGR(a8{|dpOEXp^gw1zBYFG6j%!2&)L?*tHyej<2*VWgyHWp#`d}V4P zdZ@XuVzBKHzVyL>qx&$EP!VDMW?qN+$cG&wIlIQ&ktxS@(pIK*K0ay-kBd00v7h{A zO$A${N&2xR+4BDD-e*m<9_FCyB?>6EBlsxQUMn(M@v|&`tlY{^nkAYx2^qAD>X z+u{<99%ZA3cNc;`A^fEWm6A>2J!9e7;z#6EB7jjQQw3j}3E8MD)|7s%C}gAMS{5H& z2`D5RQLHw4!g(u3xOWAAWdTLHKVbDccRQQ`(Rj~41^dQ3XM}+PAl?Un81pn~h<1uH zA-jTj_Gy^MXG*ok_}hjM_u$|UTd8|8gHEWi2ysw>-fZq{!b3LuTQHyEKAaE>S@fhm~bEcA9jxI+r$-kdHKS=rscbPm8q zGi`eJsD1swM5X42dz;iY<4ip!8^2NgMQ|W^>pv;itdZ3 z&T9W&l|F3H;1-Puu{Z_=ECXjqv6$YK zI|dJtB6*%Haa5$er1AHl!kA^s93Ftx=IDXf0}uu-!l2{geu;Yi&tQ zsr{a5dZ5s8|B}T}KHiJnS)EsDQ~^rCgr(Er&$?O;(`FE$o~qBk45z*l++Vh*#(2Pq zgV8DW!PV3oEqDlX{}-zArJiLvnXj!V5kq_(pT#zCq6#KD@1kbg;F9ZqA@7_H;W?^s zl_dLAFP5+}`1rA{d1xkuRBq4(27!S1kvPw_L98{pddjLtqVvW%ph~86%wIjz08HOV zY{fGPZ*|HkC}Q4svPs5s-rLe4E3$H_P!FcC68OxR`Vni>$M3FdFs=hv@7OTFh zX9qz>UFLaYazoq%L%Wk?SM<+diQ0lnW*kWUSfib3e+&tO)%=>Niyi8#*V<=HtCS1Y z@h6Q(FPPXMav3_PWb(`?t5lZ~$HOvuF(y8@NETW6S*V%g#77dFc*kJkJ)q4%q4ynn zCeS+}j$@{wbA21PSxTg^w+-Cl@{;A&yH3H@NT^x1DY!*ZFyqVz#cRN(uL*obKfuU zCZPlaRwos`c`Aq(81Q`)v3h$<5Io-OrOhfgT3y|@o(k(8#o+l7S}W(wZ(Se z8aVIQy1*9E%_SjQ0=u{G-^G;N(1QWjTI`8XtnrH<2Mu)Vg zJW5i0>Kc4Xhro?=Lkv;Q7B8(H%5Vinan(I+dw!kxdCVDM zW00WsU3e|cRZYlavP@xk>~amdvN5UI^|7=XE7?$Cm-PJYT3$h7vKdLxgrsRSjrYRl z&^`Pvh{r2q&nc64z@h*B(=u{HVej4Y0T%|RZ0SdgnWX8IoDm5-!Bx3KDd`cGpL{bD zlwS$#ZOQi`JQAD}JW^kPQ{RyibO=WLtTN<*Zyt>JkWktHwl7i7z(O5s1fP%WX*eu8WgkgB(qVJh)D5ZJi#ad@221= z+WdYY@jb|67UuH-4P0?F>K@R53s2m<_70x!F3n^M7K~&o3cEl%J)j^uhu@*#$5tb= zMk)|MV=t-C`MV#TP95;f!X3YG|KY*50fK0X1{7?6_ibmvZzEceTt6{?4;PTK2jp0D zZdLL}z=Ajs)e;R2tw;I8XR8X37h)*+JsKb=njHus5C#Gpcdnc_vI%O2Jd276y&Rg=e^9PfCvX)+A8ipVr9ajfv|{7Xb9lKdQk5Z zP07a${89;Yl}$MQK0@Lkp}PgLXR+5nT!*AxWe?lI3!!;0_&(rHtUIW5OE8mEAPOoP1~xGxkO)d^k>x;6Oip$KHP==Z6cpf#i^h$Ojee3x zc>k?+n^?v)g?ak=7-&GesNIgOh4i0wp_V#w;ArOe} zy#*+=aTfhQ^!}>%1FxfPZ)wT&5jQv3ul4Zo(96%SKOjo@FOL30w1pyw0SAge@$>Py zICLTU2N^~lVmxSHUtdLe`ThZB8&80u6aZ)bl8#{bIf-@4%zHvbEa|Aoda4*pkb{2d4X d51RHGt1TgrG}mC@_Ma