From 3df9f470188e14cb87d347cfa9c6e493ac3bf4c8 Mon Sep 17 00:00:00 2001 From: Nico Matentzoglu Date: Sun, 8 Dec 2024 09:15:38 +0200 Subject: [PATCH] Major refactor, some changes to UI --- src/sssom_validate_ui/app.py | 294 +++++++++----------- src/sssom_validate_ui/resources/monarch.png | Bin 0 -> 43005 bytes src/sssom_validate_ui/utils.py | 175 ++++++++++++ 3 files changed, 300 insertions(+), 169 deletions(-) create mode 100644 src/sssom_validate_ui/resources/monarch.png create mode 100644 src/sssom_validate_ui/utils.py diff --git a/src/sssom_validate_ui/app.py b/src/sssom_validate_ui/app.py index df15e3e..3c56e91 100644 --- a/src/sssom_validate_ui/app.py +++ b/src/sssom_validate_ui/app.py @@ -2,144 +2,97 @@ import logging import sys -from contextlib import contextmanager, redirect_stderr, redirect_stdout -from importlib.metadata import PackageNotFoundError, version from io import StringIO import pandas as pd +import requests import streamlit as st -from linkml.generators.pythongen import PythonGenerator -from linkml.validators.jsonschemavalidator import JsonSchemaDataValidator -from sssom.constants import SCHEMA_YAML, SchemaValidationType -from sssom.parsers import parse_sssom_table, to_mapping_set_document -from sssom.util import MappingSetDataFrame -from sssom.validators import validate -from tsvalid.tsvalid import validates + +from sssom_validate_ui.utils import SSSOMValidation, generate_example, get_package_version sys.tracebacklimit = 0 -def validate_linkml(msdf: MappingSetDataFrame): - """Validate the contents of the mapping set using the LinkML JSON Schema validator.""" - mod = PythonGenerator(SCHEMA_YAML).compile_module() - validator = JsonSchemaDataValidator(schema=SCHEMA_YAML) +def _maybe_prune_sssom_text(sssom_text, limit_lines_evaluated): + sssom_length_within_limit = True + if len(sssom_text.splitlines()) > limit_lines_evaluated: + truncated_text = "\n".join(sssom_text.splitlines()[:limit_lines_evaluated]) + sssom_length_within_limit = False + else: + truncated_text = sssom_text - mapping_set = to_mapping_set_document(msdf).mapping_set - result = validator.validate_object(mapping_set, target_class=mod.MappingSet) - return result + if not sssom_length_within_limit: + logging.warning( + f"Your file is too long, only the first {limit_lines_evaluated} lines will be evaluated." + ) + return truncated_text + + +def _get_sssom_text(sssom_text_str, sssom_url_str, limit_lines_evaluated): + """Get the SSSOM text from a string or URL. URL takes precedence. + + Args: + sssom_text_str (str): The SSSOM text. + sssom_url_str (str): The SSSOM URL. + limit_lines_evaluated (int): The maximum number of lines to evaluate. + + Returns: + StringIO: The SSSOM text as a StringIO object. + + Raises: + ValueError: If the denominator is zero. + """ + if sssom_text_str and sssom_url_str: + logging.warning("Both SSSOM text and URL provided. URL will be used.") + sssom_text = "" + if sssom_text_str: + sssom_text = sssom_text_str + elif sssom_url_str: + sssom_text = requests.get(sssom_url_str, timeout=60).text + else: + raise ValueError("No SSSOM text or URL provided.") + return StringIO(_maybe_prune_sssom_text(sssom_text, limit_lines_evaluated)) -def validate_sssom(sssom_text_str, limit_lines_displayed=5): +def _validate_sssom(sssom_text: str, limit_lines_displayed=5): """Validate a mapping set using SSSOM and tsvalid validations.""" - # Capture logs for SSSOM validation - sssom_validation_capture = StringIO() - sssom_text = StringIO(sssom_text_str) - sssom_json = {"mapping_set_id": "NONE"} - sssom_rdf = "NONE" pd.set_option("future.no_silent_downcasting", True) - with redirect_stdout(sssom_validation_capture), redirect_stderr( - sssom_validation_capture - ), configure_logger(sssom_validation_capture): - validation_types = [ - SchemaValidationType.JsonSchema, - SchemaValidationType.PrefixMapCompleteness, - SchemaValidationType.StrictCurieFormat, - ] - msdf = parse_sssom_table(sssom_text) - validate(msdf=msdf, validation_types=validation_types, fail_on_error=False) - msdf_subset_for_display = MappingSetDataFrame( - msdf.df.head(limit_lines_displayed), converter=msdf.converter, metadata=msdf.metadata - ) - msdf_subset_for_display.clean_prefix_map() - from sssom.writers import to_json, to_rdf_graph - - sssom_json = to_json(msdf_subset_for_display) - if msdf.metadata.get("extension_definitions"): - logging.warning( - "Extension definitions are not supported in RDF output yet.\n" - "This means that we could test if your code can be translated to RDF.\n" - "Follow https://github.com/linkml/linkml/issues/2445 for updates." - ) - sssom_rdf = None - else: - sssom_rdf = ( - to_rdf_graph(msdf=msdf).serialize(format="turtle", encoding="utf-8").decode("utf-8") - ) - sssom_markdown = msdf_subset_for_display.df.to_markdown(index=False) - log_output = sssom_validation_capture.getvalue() or "No validation issues detected." - sssom_ok = "No validation issues detected." in log_output - - # Capture logs for tsvalid validation - tsvalid_capture = StringIO() - with redirect_stdout(tsvalid_capture), redirect_stderr(tsvalid_capture), configure_logger( - tsvalid_capture - ): - validates(sssom_text, comment="#", exceptions=[], summary=True, fail=False) - - # Restore outputs and get results - tsvalid_report = tsvalid_capture.getvalue() or "No validation issues detected." - tsvalid_ok = "No validation issues detected." in tsvalid_report - # Compile the validation report - - report = "" - - if sssom_ok and tsvalid_ok: - report = "No validation issues detected." + result = SSSOMValidation(sssom_text=sssom_text, limit_lines_displayed=limit_lines_displayed) + result.run() + return result + + +def _render_serialisation_section(serialisation_text, serialisation_format, markdown_type): + if serialisation_text: + with st.expander(serialisation_format): + rendering_text_rdf = f"""\n\n +```{markdown_type} +{serialisation_text} +```""" + st.markdown(rendering_text_rdf) + else: + st.markdown(f"{serialisation_format} rendering is not available for this file, see log.") + + +def _render_validation_batch(valid: bool, key: str): + if valid: + badge_url = f"https://img.shields.io/badge/{key}-SUCCESSFUL-green?style=green" else: - report = "Some problems where found with your file." - if not sssom_ok: - report += f"\n\n### SSSOM report\n\nFor more information see [SSSOM documentation](https://mapping-commons.github.io/sssom/linkml-index/)\n\n{log_output}" - if not tsvalid_ok: - report += f"\n\n### TSVALID report\n\nFor more information see [tsvalid documentation](https://ontodev.github.io/tsvalid/checks.html)\n\n{tsvalid_report}" - - return report.strip(), sssom_json, sssom_rdf, sssom_markdown - - -# Helper function for logging configuration -@contextmanager -def configure_logger(capture_stream): - """Configure logger to write to a stream.""" - logger = logging.getLogger() - log_handler = logging.StreamHandler(capture_stream) - log_handler.setLevel(logging.DEBUG) - log_handler.setFormatter(logging.Formatter("**%(levelname)s**: %(message)s")) - logger.addHandler(log_handler) - try: - yield - finally: - log_handler.flush() # Ensure everything is written to the stream - logger.removeHandler(log_handler) - - -def _get_package_version(package_name): - try: - return version(package_name) - except PackageNotFoundError: - return f"{package_name} is not installed." - - -def add_example(): - """Add an example to the input text area.""" - example = """# curie_map: -# HP: http://purl.obolibrary.org/obo/HP_ -# MP: http://purl.obolibrary.org/obo/MP_ -# owl: http://www.w3.org/2002/07/owl# -# rdf: http://www.w3.org/1999/02/22-rdf-syntax-ns# -# rdfs: http://www.w3.org/2000/01/rdf-schemas# -# semapv: https://w3id.org/semapv/vocab/ -# skos: http://www.w3.org/2004/02/skos/core# -# sssom: https://w3id.org/sssom/ -# license: https://creativecommons.org/publicdomain/zero/1.0/ -# mapping_provider: http://purl.obolibrary.org/obo/upheno.owl -# mapping_set_id: https://w3id.org/sssom/mappings/27f85fe9-8a72-4e76-909b-7ba4244d9ede -subject_id subject_label predicate_id object_id object_label mapping_justification -HP:0000175 Cleft palate skos:exactMatch MP:0000111 cleft palate semapv:LexicalMatching -HP:0000252 Microcephaly skos:exactMatch MP:0000433 microcephaly semapv:LexicalMatching -HP:0000260 Wide anterior fontanel skos:exactMatch MP:0000085 large anterior fontanelle semapv:LexicalMatching -HP:0000375 Abnormal cochlea morphology skos:exactMatch MP:0000031 abnormal cochlea morphology semapv:LexicalMatching -HP:0000411 Protruding ear skos:exactMatch MP:0000021 prominent ears semapv:LexicalMatching -HP:0000822 Hypertension skos:exactMatch MP:0000231 hypertension semapv:LexicalMatching""" - return example + badge_url = f"https://img.shields.io/badge/{key}-UNSUCCESSFUL-red?style=flat" + + st.markdown(f"![Badge]({badge_url})") + + +def _render_tool_information(): + tool_versions = f"""\n\n + +### Validation info report + +**sssom-py** version: {get_package_version("sssom")}\n +**tsvalid** version: {get_package_version("tsvalid")}\n +**linkml** version: {get_package_version("linkml")}\n +""" + st.markdown(tool_versions) limit_lines_evaluated = 1000 @@ -156,56 +109,59 @@ def add_example(): area_txt = "Paste your SSSOM mapping text here:" -result = add_example() -sssom_text = st.text_area(area_txt, result, height=400, key="sssom_input") - -sssom_length_within_limit = True -if len(sssom_text.splitlines()) > limit_lines_evaluated: - truncated_text = "\n".join(sssom_text.splitlines()[:limit_lines_evaluated]) - sssom_length_within_limit = False +sssom_text = st.text_area(area_txt, generate_example(), height=400, key="sssom_input") +example_url = "" +sssom_url_input = st.text_input( + "Paste a URL to your SSSOM file here.", example_url, key="sssom_input_url" +) if st.button("Validate"): - if not sssom_length_within_limit: - st.markdown( - f"**Warning**: your file is too long, only the first {limit_lines_evaluated} lines will be evaluated." - ) - - result, sssom_json, sssom_rdf, sssom_markdown = validate_sssom( - sssom_text, limit_lines_displayed - ) - - st.markdown(str(result).replace("\n", "\n\n")) - - rendering_text = f"""\n\n -### SSSOM Rendered\n\n" - -This is how the first {limit_lines_displayed} lines of your SSSOM file look like when rendered in various formats. -""" - - st.markdown(sssom_markdown) + sssom_text = _get_sssom_text(sssom_text, sssom_url_input, limit_lines_evaluated) + result: SSSOMValidation = _validate_sssom(sssom_text, limit_lines_displayed) - if sssom_rdf: - with st.expander("RDF"): - rendering_text_rdf = f"""\n\n - ```turtle - {sssom_rdf} - ```""" - st.markdown(rendering_text_rdf) + if result.is_valid(): + badge_url = "https://img.shields.io/badge/VALID-green" else: - st.markdown( - "Extension definitions are not supported in RDF output yet.\n" - "Follow https://github.com/linkml/linkml/issues/2445 for updates." - ) + badge_url = "https://img.shields.io/badge/INVALID-red" - with st.expander("JSON"): - st.json(sssom_json) + _render_validation_batch(result.is_valid(), "Validation status overall") -tool_versions = f"""\n\n + st.header("tsvalid validation") + _render_validation_batch(result.is_ok_tsvalid(), "tsvalid") + st.markdown( + "For more information see [tsvalid documentation](https://ontodev.github.io/tsvalid/checks.html)" + ) + with st.expander("Report"): + st.markdown(result.get_tsvalid_report()) -### Validation info report + st.header("SSSOM Schema validation") + _render_validation_batch(result.is_ok_sssom_validation(), "SSSOM") + st.markdown( + "For more information see [SSSOM documentation](https://mapping-commons.github.io/sssom/linkml-index/)" + ) + with st.expander("Report"): + st.markdown(result.get_sssom_validation_report()) -**sssom-py** version: {_get_package_version("sssom")}\n -**tsvalid** version: {_get_package_version("tsvalid")}\n -**linkml** version: {_get_package_version("linkml")}\n -""" -st.markdown(tool_versions) + st.header("SSSOM Sample Conversions") + _render_validation_batch(result.is_ok_sssom_conversion(), "SSSOM") + st.markdown( + "This is how the first {limit_lines_displayed} lines of your SSSOM file look like when rendered in various formats." + ) + st.markdown( + "For more information see [SSSOM documentation](https://mapping-commons.github.io/sssom/spec-formats/)" + ) + st.markdown(result.sssom_markdown) + with st.expander("Conversion report"): + st.markdown(result.get_sssom_conversion_report()) + _render_serialisation_section(result.sssom_rdf, "RDF", "turtle") + _render_serialisation_section(result.sssom_json, "JSON", "json") + +st.header("Additional information") +_render_tool_information() + +st.header("Contact") +st.image("src/sssom_validate_ui/resources/monarch.png", use_container_width=False, width=300) +st.markdown("Presented by the [Monarch Initiative](https://monarchinitiative.org/)") +st.markdown( + "For feedback use our [issue tracker](https://github.com/mapping-commons/sssom-validate-ui)." +) diff --git a/src/sssom_validate_ui/resources/monarch.png b/src/sssom_validate_ui/resources/monarch.png new file mode 100644 index 0000000000000000000000000000000000000000..4a73b166e9e2715e4902a508642953bc87c7d328 GIT binary patch literal 43005 zcmeFZby$^K`z|_Z0SS?i5D@8*ZYcrj?(Xi6NeQ9?B8%>plAItQ9fFj!l#@;=>8>+S z*ZS6XuHW8gU;FRFt1y9gj(3b_Jb6F&AWT_N3JZe-0|J3y$w*76LLkT$5C{VG9aQiW zk)Pc{;0wC5w2m7DBENF`AHic_Hy#2(Ik!>QcGp&r7cg^jcw%DiWNPum+rb&!4S_rr z@pd*bv$JrgG_|m@aTKE3X=tIMv@sW=(&AQNQ*aiyu(px*b+u6QRa7_gwKL;4rxFpS zeCjO#25_)&H=*=)uy=G5@D`%_HLd`7efwipD#~BCxZ4R)iQPVsQd>cpQryYaf|C0Q z4~rQ)7dIs@{}Xl|PA-0aW=ak=c0N`%K2~;47Iq#1E;a!U4$40tRN!f@=9U7g63_oU z3w$R;W$o_nEWpa@<>mFni}Q(-s}(CdKR-V!8wV=~2Mf4^#m&dj-Nc*4(e3fyBS=`d znYr3HyW2Q9Qr?beV(R4KE<^=p`g;iu&i@SS==MiUKwzxiCeEzvPuOmk^y@}*vw!Y$ z_HecT^>A}DRttLz2Mb4cH*hceKleIYJGnc#Sv&nNhW_W*|MdVMS_OrFp7B4<#lhj9 zN4U95dV*#A5y<~|x0||;vjwZFg`1OytC@wQCz$5r+toM=h`U;txI4M3J2~0^y-~`4 zPnl9&{B~pbDe1Lr9L=4)+!+4#9t#N*cMBmZ4mJ)p7B)T>4nB1@egSq~0S;~!HeLZX zw%@lZIGNj6`utC~f<5Ns`LA1n)|i{PoBV&?Y;GoC>E!BQ0@iHfU}9y#>g;GmMfr~; z1;m}~om|0#!R$Ey{c{;{ab;I0OB;Le3pZ7%XOuFM;ymp9JUlEMPuPD=S3yBQ#?j5) z#L>(`MnZ@R$mfZTjky3XKQFf#4<}GfUQP=Zb1p7^7Cs&xUKTz}c6JMXK7Jk!bKbw- zFX3e7ajONl@BeKO=1yi{jQ>o}gx!?K)Y6=ng^P=qjfInk%Y=oG)53y<-GYr12*8rv zf`jYt(Ue?mfK8g%|9jNiS($?o&CNJWEqJ)NSSqlALQ|W z^yEM9_p-JCgZ^){HzM6VLzs?ElYp^Y^s=r`i9P z;{IRG{*Q3XtW6xPEP$(JrMg`Y>#bAzy)~@=?_K-#yMH;bzi$U#@%H6E?ihUYk3+X` z1Vg(5cP*tC)dqoN7s*J7se6ClPDk_B8#}ux-|m3!IP_J%var*MvCtWFF;@Qw(^AbrRX!y_8XzBLn3>cX7UlCwT%n^ zj05_;zjl0fE;0_9Dbk_usKs!R|MPF!daBYQFfS1Y6BF~~i=B;)4Mjpi!cYE-tty2I z!hhX}8|Thq*m!E_d$w!ru+Sb#k>NJLPpFL$^DiMZ41u&#CMP#?}k3(MGI+5)!y9R zZtC1QnH!p#@(G=?Z?-TsU5p^ocW&!Yk$PC|>gVTIlAD_=!pb^`I{FNkx3|BiXB`IL zzDJnbMdtRkmo;E7qK~1BHOIRttdl>&h7f+SnAZ6;!v9!^aP8{qYEF2k@gRZ1|8Tf? zQhUK&AQuG%Md)yLijDP;UeXvrwEs$pOUTTeb6Jj-I)C;0b<4bGgOg5;{geZ0$U^+a z#s&**?E2M*4x=jGpoJ5y)5bwky_KvaMGe5O)lo<%kGRp4eM*} zpCoa)SZ#GaT1R`WkL1xc=$DaV^DqBJDkvzJSyos$`OM`sKuvA*SfYd9jwI6Z^|1A{ zzM5Ke&f16B7 z6R?eYadsTZsJX*Gyuip;EG#TLD=RB5daVN=+AC>q8T7=b#!zLD6gtISlHX!!>n&;m^nP+nD&%r z<4#OSNU_ea)dNy=o2mE6&?wfdo7(K|ev_M@|0M}*n4*YQR*tZixus>zsJD3*tf!`Y z@Z!9``Af5uh*=Ee!y^y+kQ5#!rarKtEEgA-DcpAT53yhKuO2XuGZ{aTX3J9z(T6QlBp&CCLA|53-<7P+w05{-fW{(ilp zUH_`=>;;znorWJxPwqzlfYOi=ZE3_o(_|;k-4gqbaA^ujf800?Pf|lSU$nQk&m1;5 z)qecgD9PjJXK%ly>Kio}^YG*0Rj6|*10&CayVc7icJy->>)`}U0hNAd`G?UUgGdZpW1}v3|79Z8xr8?Y=&e89fuo#l`jUZ9ti! zDv_wdM_Kxbt@Na%Pkgv{E+e-F{Jie#DKK}f6&b_Z7GIhMAgz>cA*C2e<_-@kvln#w zC&hm#vZ<=7N{t8)=PH2(j!qnNYdEMl|HytFQ1#)1En4R`JeG7ta%X6=j-L#hH8VCO zF)nF}2WNRM(BxXdXCfa-2o@V3U;Vn@1AOcNEyIq8QBt!Dlb~`~%YZu4_19UcPy~qH z+#qx=(rm?Zrwj&4`=4j@TV7;zE%9%rxedTnzHUtI$Odokq)-RND+#+z0&B=Kuj=Zd z&{3??82Hrfm-3~PcPjQV7w?RcPv;;olma_DJJQJu(dVW^^tkz}c+?EF^&9Tp2h!gpl1(o;)6>&&RK7Y%*(;^*((ic(*&k}02a-;sq?ANQ zN8_b1!5N50I_;QW)p3)_!_2OVj=8xKEXYD_HK;SW2Q2U=6Ab`yzFA zatw!a*01V^AJurX3$wFl7w8X(UUMbJpRXKVuMG=%Cv=+U_%&Bnx;6~1LErdYlO6`% zf4&=~ZVc1Di;r~UbhI%xm+SrMb(=Oxtx-XFlZLW##f33X-kkr$(q5w_+XKVgK#^ym zmxI>&{9Wc8)!}#U;s8IFT5#|y4(dNzs|d5Q&4tD5VEfUEOCXY0yP&Eo1n zXXjnLWwe{Di-DkPs9U5)?(ivj(*zTj$W7$x8fLYwgZAR7c%lRXbBdqdW{J{ zrhma;B_;NxXRGO4?{09eTJDJF80WkkRzN!Lzx71#^YXHT;-@$`IE3G~ZH6u^EMQVI z&%EN(GB8NbpzSd7aCdJ^OH8aKVrOnqLEQ7C#ycaXq2q%fo=bE6G^%qVe={+8LJw=u z{dsYAb!}l;b!Ov<8-^!5`~Ca5rIFEGvdoi4TZXa#iOwTUnx)kJOsOJBvNTG|_WJr6 zJ^@@QAu(}By?xw%g>vcwy?g>1eur%{$?P4!$4#2kO%1CNIR%A?fBue~+n#QUlA(5m z@e3+pCm=^`)Q~P22G0~HfB&Y`^D8sPw>N64s+Hoh^P{9s5;sMVkdWry!{WvE-MJym zm2eu@xj}2LS6n3hat9_#^)*Uvrunu7qh}ha{l^k6E-oWqhSJi~X1F{>_&7M)MKQIM z5c@e>8Q(t7UHjo)UmKs4Do;E5SpD(QEUrbL=&pe$<4Zlpse!b*- z?)9+zmEiU1k_e}P$UA|J!t!o^5k}F88sNtI`h>Em{Ls!WwbX_6*Ly04T}7bcfUWzPp( zM>HB86t1ZZG4zhjH(eb(sHkR9=FCCxyaYlPUH5JG>wBqoOR~Ac$dl;Fjpsb@z>anI z4E{OKgNv)VRiUyYUm^HJkqnAs%Bp8WAbq=uV2SrV0%Mp|S3Zb!|Tt(aFBUrPGcDjua*x_))$nPZli}E5$sApnAdNPrKj+Byw*1- z-YmjFGQ{u8l3v_Y=xkR~Smw3dcC!wahmw-gpoOeZ215ZXvaiT_&2}Y~EJ*TTb_aWRQ-f=@F@_$> z0IASbpdD4q=_nnuZ4#FVvfv`Qc^vHDP+zYoI-dG8{^jQdinzo?r&wiU*Wr}ZR9;lX z7jElwKF&(k&3N1-+N|&2-xzc}peTL+-dvDoP$b9>(YW*bbySLFm4BJUflZBV>)Jv8 zx%dOd`9WOnjA~t$1|u7)v-}yyn|Ix2^n!Wv)IlrGd_P56%ZXH99EV#UPgrv~4cV2x zS*irFTy3UG17EMUrly(MrIJJryEO&GKFj?b;8wUQ#1Zjq3d|9iTjl~^y`ezn<>6`8 zQc)RZL~QNt>#I_MGsO7A;czFZ)l|`ScY)N2%_~eEv$a~76z30e1I)ir`Zbf2lW7Y^ zeV{rzQ%^eZYh9WX{W<33gS+ydv~ncN*G!gk5#i(C@n57nJ2>O4js;?UeyE+FbE=_o z>1u2|FU$Ql6Ks>C3Pcl#IyW}3{L`n&URNQNZ6z~;i(S3KF6)QbUA&SXxmVnv)^CXk z5sZfirz>xE+`YVbwKO$p$t0*TjnDj==OTnXRVl{z?pDx8{jfZPW$_Qq;BmF&n0iWs z6qPF58Q*s41Y`_dAUn3-HI`nb6DMq#xpwM^(sr4hnmT)_SfGc2qbL;Qu@FkMvF?FD z@bVyFkpR9M7ha>lle+C+Ja#S&&C@p?b?<5!%!_6Xlq-w;4u{{wzp*PUDw+}t9eqB& z=6~f`r=N;}iFqIOkRgD$W6_spe0O)3+m=L7Pfu^>K7uhyNL#`2&BG?cofM`m`zAtsXfSf-@7mu zx`f*-C{E)j<_9a6HcAZ)3^dQroPiSv1x|znI7G6hiL_cZXMyyS{f#K|)V?*lG}ErI z+Un{iC6o=?*Pr*6>peEhXaZXTtgU~1n4O*NJLc{cDW`2|bnH*z<{i2UsL9W_jGeL% zsI91Qo`o>ZKqXHe#UfR79}n(aoGmVzl0ug~^RLX66#m*a#CZL=C7*MADiRSK2fS z#1REaHn*?hKkO4*&2>^xP=I%r?R*4bNZ}?R#Lq^Y68BTo*a(-CgRxO=OZAJ@fa6E4 zjUuY@b)8DlD1)kTaV!Nv-g?he3O_tY|H_FU_r#m8L*lyPQ)MOIg=sFaN*lhW+_oYRZAG#C!I>+0Fa0mwGdHH(j6Ab#r*7#~8R-LD^_og>9@ z%`)|KOceyL5;HCiycPR&roR^_iSABE11C~3ZE&f90pHR$bL|NtDyiGU*lwP3OL<7=^{!w~&OC zhbapXcsy30o&o6|Td}v*N%VxvBM8hqq_x+M>7M#7m+VhC;r*1Mc zF_}S1-kJqV) zhp$pE)=y!0luuk-OgT;}ai1qh7^Hqs*VEgiu?j$vH0l-7Jl$Mg&{=9n? z7jaNPPwe#aqJXy5*3RytRu#opzEgdu|CG;87E(TYqd-3;Y)2@wAwrF!v|#JxBoe+& z!xL-AlPu4=zxdkyinagS>ei@Lz1M3d_a4jr6Jil;rO?%@Mnq;*#8S=8NY@4Qt1Y7V zLt;h@C?gY-)kiuCJ((`}gOXuu%gf0`_;!GD`NRaxIv%IBe4{rH^g!k9(}BmW!0^9I zZ2Q+;?fN#=%5dtj#B#w<9#timM?^$;fiSWoEu$~CdUBEYMVKq4AES3C0=dFk(QKzc zCWk3bh1|8zUX*a^7{mNCnkF|^kXLTE+_pj%$V$p`0*M}xsTa$q{3w6XQs9}}ewulx z8|o~L71W7GE1RZyFj4?el|gAvqt1ij*+>8ae3^WVSs8GPxFil+FIya$Sh~3#ygkOl zLPjiL@*f77{@T~W0DiJ}fJrE*RK zw?17c)<-jZyIiUqT~CbP2vVGrV=AYkqmvjcJS?5UPObmRIolvum?3lULJbQOWSMbNqaWFoI^CX>Jwd zA6M6KezGqcva+(4rZ;r_b&mb=;oiB!-K{2JBSZ>&CsWBK`&lkALOF4dNLq6OgrApV zdS|rQ%$~2ypM%`6_O61+aEs7U5Gn^;IlsEffPTQ2d*)uOzgV^0jtCV#aLL4Qc) zqu%*>W7UZ6Gl=#UHMilz=A4{%;m^+|ix0W=O2nS`fJ%+C5=HrD;G$a9JoT*-! zUUm8+G0Wwj8dVnh`WabS6RQJ>1Q9eBccU+@xqRdFp;{UmG!6vg`UnZ17fu`QxOuTX zXtY7ywPig^4Vh>j@}`HT!i$+RqElr)pcn z_wenlIc+Q~Ea3F~ky&Z@;+9oDSnvy!<_MnHgj*1Zc61b1Y!QjMe4@cHPT14aa{w+= z2vf){miz(gx)XU}V{6&tqq-T#sSBy~qw_?G3Lh=4i6htZ`%vU!N#vlhk&*p;>h9M# zu&%A;Ew#^@eGJFm+D=Y86P6G-_R`qwH=HSG1~xV}t;IkR=4}o>h{;j9(v6bEPT%D* z8=snLm)MX}Qdh_Sh=Q`8(3qQ>yQ#mf+L zyAhrHUZ;%T^;0y9{bd)8S#hjAA$_*h!qlo1#l^)9t9@QjUtdN1!p*bJ&Q8IVLne|) z!xG4rk8;@zH%X)hel930dIe_eL28CKi!k0Eih06_z-v_ugTc%!ar^Az>lHsFnwN%W z!H(Gn6f(&53}^~iWu~C^HIU-QM!$xRxr|q6h_C0OmjfrMB@84d&hFRwyVmK88A1qG zh?*MV<4b#&&m9AwWe-)x$pis}_oh1!YHjUGdoQ*J|G7<1;-T+VZ*VYTN&U0b#4qGx zxpijzRB}&)fH2?;x*|4;b%ABV@bU5EmqBOAC0+6$e>0Na&m*WXn_kgR1rWa!b?U{{ zwNv+cVyCH3s8#w&j*>6+*ef&?8$W+G-xCf?cry;l-^BOxE&~v4P9$y1DxL0F z+mE^qgZh{5ZNVtezTV!qIY$kk#8p$gp|7(DRUF}2j*_4%=&lwb*drWvHRETRn>2f= zUa{_jq755edP$m!hKd?M)1!x8(w!^vLUbOHe`3vLBE&zTX$W#y|E-s!( zx0zCC9Cy^#JwHFsK=u78URX)OXv@!^KQDZcwPF2!qY_6xby4?J?aA7fsyl1!=<&Lj z^I)+y1xS+UbJV{1kUo%vJ&AD|J!+=9PdH0&x^;YV(ieQw)YK#whpheZ;X|REsRY|~ zD~o}KwGS*4{q}ZtsYi54*4Az|o()$@cyV_vMv;A3{je3n@<-RNANL>7*$rJnN~*D|_eZL^Fl%fal;%wzUvk-x_2Ndd0|hyJiDy$i6{L0gp*+UonJl4_lHg zrlD8)koD5{u^*Pg0$-pACubGbLN`%sPNu z238N+EfqhUjA(L9(iID|R;MpsG2-b17!L(ql7NIR(ji|i?AR7PMa#p}vn(hudv63_ zzLR)+9&h9WAcWlMKdv4U6W5vKHzy?~S`jqO0bGP7`D$DGSvE91&2D{X=O(Y4=j6;! zt7pAnZs!JH3DRfHg<;gFj9{5Kss(T>S&s6E>x>et)fvZ=Pu4tMd}NWXiduJ3L(qj* z>}esz<>g9+U0c>ApTsO%9P#)A1O){0g)E{k9SlY0lh9jpPM@kPk6QNe8&i8ebAC4z zE6PW9%^l9bMXm=-h`D$0EPBlLir5b$vg_6b@l&b$1X%z`LPI>T$XftPdtETGIj17t zf`*26LtW4!Mds2WiZ|bW5Yrg%Bqv#OX7c)BjUv#KQr-C7u1QP83J}EuauE}iia&fv zsZs9h@3&A&z^8M$OyV4$`y#)B}`2$aaG!@tx>B-LcP<7~AHDBIa>y5V6Ieqy1cvvRVqSb5?* zBRQ};0w9?t;Atov92{=Q9zML6q##YDqG z2g|(W1}KF;&rKZJx$av#xC%l2hDJ(4GHZ69>4Frbp%rHcBvV_g^kQqY`*4x=#KqcB zOrij76_qwqXI=sWr*~0Nb6twKIe~L>VI5A2B3Fk|rb(Uq*RZCYxsp=-x_yabPOA)` zRrFinCjbh)5Si4zbyKW0jKQ-|oX)cWP=7apr`(@ykwYY3E29m{8?!Z16%Pc#_bKO| zQ240ex1pW3wl+B$ABwyF+~{>HP>$_6>dJl_{hjD#|CAf042Y&ZlJA0sh6eYx7y`(@ zpf-2qb=hU>+CGmOsQLR}S*kcV*1`*B==eQFgBBqXD~7=@-h7RTiP;`KDh5Eg*py$i zS>pa`xx$3fhgdI0gbD#cK_h~x8m_tSb2flz2kLfQ=k@6{Ct`rk#`fpi=AQfK%mS*7 zOps+ZRn*p=P)9x=C8*NS48|x9wXZO}0qw+lDaK0q^XK;nnop{P!Y7;2r>3Up9Ytn? z_7LK-t5gF#JdWVI*-^qOXFt@G01QXcd3N>q@nex(Z$Xvv37l;?vO){$d2;iPFMmb7 zyZ*VkDd+(}A0uEXUbhHQoA7#1e?Q9Pxp#Ruuh|mSd&n;cREEZ&EGy~z;^KlL zZq%w_rkOcR+3oxNI}HsDnfJz4;{**D%%04(A+?U!RzE9cq@5e?v(v~p%3RU`ub!D#YJxP25l>bTn8EP!?sl2$1FVsR>{ zqPs0@*WL8=Qnx}F6IhLe3ttISczjba;lDDdcVFKQYs4-d+lY00$VJ~5&*kuh9Rs}O* zpBmLv4M0xl`LC#2ZfbwPpj)smo<1MOww;r5S)Nv1m}w`DWDU#M)0@Yf!W{@6x53So zXI@Ru?7l33MCXe9>$0+1=?j%OS~Zm<=L?FZrev07paZvi_blp>Egnf_oM5xmZ|9lBtguKjNz1TV=*g0-!8BuLD-Iq zM2G3?)Qd;s9@ha@Hzdhr?1z-N^Y~AKHEbJkCO9_BA8=6-4n#E1u8M zp!wAHu^w2vTx?Bm%>G~796#H3B&$INpU5l%x+~~d|e+Nr8@A z2u6GDn~gmiEm>BbN^}28goMF0K_&Cg8%Mq^X#uJOyM?$9Nk~aCtGY&1^X}L>IQUn! zwA@HxzYaktzAF<7${DLDYJ;yl^78RiwV*XB1wN2^M@~U0O{zpQX~L0g_~v=85#To1 zsM@8)rZ5cL7=iLe1UN;d57Y1~@|~A4kXuu3DNhhqE4egK%!Ggc&QttDv$l5~v|-$y z@s{p@6fit(Gpl`B%a%=mpy#9;L7Pj7=J2Ib9i-0`cB#jL-4bx+tK%C!UngCxz4D&}hx%0XB-W)J9%%~)xs1Nb5r|KQk%ta+dGFXNyW?sQi`&+}fGgs@#L zJX1yRt0~=Jk~}T1?5I+BI84Jf%H`Pl;mn2Aj3e*G`ws#rPHvujWWtRN4HwtRTL+JA zH`mv7!n+l*TLq3M&FH?3Sjnne5Fmndcc<`&X5!lN@>as|+DqYq)q}IL7&9nIS939H zt{TkkHCd3yxQ-RSH$)r23SJ*N7XWO_w5dYO_$|g$SsXagkD*Jw@l2sJUQw&Dy8IWO zBYv*IBamX?`)aAC5=;+-ldmrs2wpm6v>BiB3Zi^!YPwqKiQGumN(H?ka=`mJNI>dE z(B3jGcpUX~gi@f@G(sqUv-o-LREGyE;5&rcK5+)Ut65TV@@qp+&l=Rc-oSVzq?1s4 z97^svD9N;NsNNL}_ze>7IlFYllW~x25~-+1hiHb6w8#yp-#?Jo`aK%FpUC+w|jaI=z^+w3Bn!^gYlLrVps8qhvI8)keTq(n7`? zyUsZH{qxCA!_4&olgNm9&G-59=fdTlL1QyB{?#Lph_VGDDqK_Tx>lo9~tWe=wl>|38Hy_yS!fA_~ z2xeW1eAR@=qc-LV1ZjHK-2?PgcQQ1?z6Ap?q6F-87Ej9q5)$3laLgc@`;2}@ikY|B zHI#eCjvrKEeJ{yfTI{DPTY6zvpzpE8_<~*}derFnJp?j-`5`aQ;w4>>By3DiTl-TT z4CZ168n$||r2hU$1d;G=D{KwH)*P~*JbAyMjxbTr{=$-yOVQ^KzY4$qDq)Efo>^}th zPS_gvKKYJWb1f!cBlpEgj>d~|q-T-fa|iFb2v6x0b-D7&&rkyA#1g}N?| zOe+QA92Owcm~fxI(=0Z4D~NHsNWq}^ZUSvrgoO7oqd8Iht4ZR^hbI-Eu^Y%BcC+zH zC{1ZsNf}-8kf2V%R-l+E$M`z?Ck_y8Aa@2bY$dpsBP0PttAc_8Osw+BdqW^t;=fGh z03gciSaBz!ho*qJRI0lZO)?*^Sv>ROJB*_rOXj2ID%D2f$zHKwD+s8EmSeD9Y03Bdm@p?bFu^jIJbpjoR89I#C z%J9ZPbho5Qn|qkJroDYjdwxPJEDF|dRy-R#7+-2_N}ytcx~&1!e_FUEe)RYlrP)-j zuM4l=4_nU`OxRD$K$vf@ypO&|8#K2MyS?1vN(i(80DbcU6VbQ`)i_x`zm`TU;;(n_ zunFIiyd?DMwQ+K~L8Bz(4GkM&D%XE9@$e3Vu%yhaMb0?^0qYLL6alR3KUdL1V=Qd! z+qD)UfzGGEBi@BFkUiMe!f_;}_gHd=CjncaN=Jf%iIsJ3{ow2{XAUV^iS!$2f&dqE zFS?_NvFOxaOoAepAD2%4KjZ#>B?csfbX#qUgljtwWw{DIlVP5+uQ9^JfO(=IBA95I zg0}n}%yKg9E*xBb)1J+vjM(VtN7IJk0WCME(?Y)`@So3jAQ5Uol1Td}l1v<+UrixR?&4bcPz9cd2!efQEWl^oQUk+vFzW456J z5?LiBZ|&Y#a3o_BVHpxfKIh3Md_?w*36Kq~R4#{&*&5#pq5?bmuM5GL;cf*Gbb1V5 zl1?>l+zgw)gqIsA*ff`Xl~b@$?hvm%rDe*Lp=u-1U4U#gFcV@Px5*gj*xB0Fyg@XPm6NLrzEypr z`)!WZ)zv$rcI?wa78-Ic&Cy;WdHk_7A>4m&>+M&twZAS+JOtnid{R$LAF;AVDNyOY zlIrqrawwO;D<25^$qfqnV$@o@q5mKTZ)MJdKL+Rh%}w-_P(Yp=mh|Gr|7 zD*$9a)<}d-7_oxl+b`xV%5DJ=mJ7)j+Q6dq#Y&v{{0O5wb6`JCoE@wKfsG1!M>*}o zjc)Di@2g08>)XsZ=(75foO~=ubCpdTUB4{(yuc@d7v^Vn%e@0+fYhzIrKP1gy=K3& zsL6@Ro2iMZ7AO?jVp$7sk#9K(U3w>2wsv_iIKgUAuP$`}%{|;>wDf$S_2|#y6ZE~g zJD@Jc)w&dN3*OqesZV{Ki96+Fv}pg9~Xd9z-OH&cp22OiRvHx$;t3EJRdC_qs{1}j6aSDP4*HrsH z!nY%yOGynrq@ZYti|L>XZktyFiJ!vi7Jwq(MrM0#vAc%M|y2>QnT~!{@ zdnU6DvzsUrO2qrDR~R*@;(Gj1u>jt2o|c~8B&8@{)xV{K2&nUhfDn$gf$%Ygg@cnR%RiqE7>#29{FuS7 zqR7w7Lw8fg`>n^}h_`z6a41YY$Z^!Qv;sx%cNy~dP>*D6 zUFpm#Kf0ulR&e6t>-Oz-Xu zicAHd&-}7@p5|6ooTqWh@Rj9dzr%GJ0{0#4yLT^dxh4l#SXheHv}oo__r2da02&p; zA4U6RYgV$H$?{78X*ynAUGup~1ny24PR^ zS9m-hZp9LT2HPQksU`qj=`iPe5^a^7WU}{EhIX{{%TytlJx?lCv0mY>kfwTI?n{sE zwSyUHvoXDkgw_MRQP5h419h82MMV`>Fe*mwkTnF*pVw_-^7qBS5>9}%f;h3Vwzd`MbzkipL*U$|t|E%PUmdPtTzgE9eR zRU5J;VPWVCnuaPdzHz`_iD*9RN$ynvwQ2&13@eb1f8@1MRtzj&?@ivsrqREng{mxh zlvV4tr$`x(^>5D{-wugDdiPC;eN1ik;6Yd>nBv^^2-Lp0bf#?J}?kGQS#8#?QpHx(B_O$@#I5p;{o^`Md zfd}F(ZlcrpUGx0Z+W9|`8&V>{G_&r0O+(O1@LQcR5SdBl$roHGK~|;|=8YsoKG*}G zx>Plrk*sUS_3dq;kQ3Xstox@zE^JF z{HWfwEf@3dTaOyEc@zChB{iue-A#6BoIxZpjOCctvHatp`|z?sXkSYXgGW)Q&>UNm=>ZqxDUUtlRMm~Xm*N2Pe+nnuHj<5ugVYQ{%!R| z6vi5sR#rO+d~#a=FK%XT6=Hmw{`O6R9!^{2HJ^p&u$!7rPPf z@!GE`Ach7#YNZ+m29Zr=cnxA_R{R-8#em>g<7QdK^Uvd$AtlL$)z#cwqi6X5r;-s< zcyhND5Yng|a3DYoG^nj4xh72lEm&Fo7M7c5Nc1|Gk7wA z0?v@qi^d;>6?^Z60{ZKwL@)@|9bY51F)%QC*J(0Cq-XJaShF zF+x8vtXdBd+R_jz;YOeV;-VI=S}$*tVc-L&W-*jjT-M{FTWWGS=HGe8twhZ)uM}L@ zh-%?Ynkckrzj9Jp$)J@Z-|__-or!gKQg2RQ&?MNV=Q^_i=ebyi?bzvXH%VkD-$)|8 zK~Z}Z2exgG&@?O)aL<(H`(*&_BC>Jv_2RMAZ{bzlU%6X}y{3%$3&S^slzceLRL<;Yz4sud1c>UDhaf0darHCg_yCHnP2&QVE@ zu8G+yu$p9sjnFEq5CqPA?6K4xgwHsNAOqKIR|Y8tWc!Ac{reDRG01p#cehU?J{v$? z%F~ERcf*&SpR7GG^b1}5!6?f7Xr4e3XXVqdc>s?R_9%!nc&s(($cSsdQ>lLa+cRC= zZ>wmzCokjF6#3;90|Bsqq6cC2Kqx6ID?6czCPvNH1oT=Sl%?En6EzvF5EG_Qp9fACvr~d^(`Ke*NO5y>KrZ!;ID0IKeE>KWO3qZ!(wD$;K@3T#hX4Sr zpoCzRYtRr87%jIF1d|EqK@4UEC=(K0kg1+5c6I~S2M#@TXt4Iv(vEsnkX{wEw9G{# zx=GXzTO&8(6#|NVthWof?0{e6WaqZ$$scLUM@uh@@lUV`3zvn5C~kRP5X3YRl%vn+ z;Xs(yA;mmGLjL7sscMS&zk(IEF#$fl{AOQXUfwSgkHM&*Ck{w@{XVTA5)RbbF+o`r zW5O2-_0bKZx!aM`6DFkiLw)b^FH~%hNlDXK@xl+pF{v&MN*uG2MPgsxsgUA;nrUGA_I8Q7LAZEMb%_PERl zU8bL_C>9S+=(Aecea-p+EQp*C1kk5I1MK)Vdn)Rg1r2ivae$Y6WEzB2Se8js@4tQ9 zXnwN4(&zSVUJjHZKeexkYhgQ)@7@U`2jx+=<^&(Teu~ckNGGI^cjq7#zR$GQqEI=4aoEzBq6`Uf+xlL@C)EhLbZgt``b~j~Fm-HrzX4Wfqcko^54)f2Q%=p!VteOR0svuWo?7La3g@^&w^aCd1oR;R zO-JzoZe{j8Ozz1>Kr@IX3)0Zl{SFDrBM;){`9~K|0tMS*WG3812 z`K&QpUe)<4A-u55r0~?-L}s0l4p%AL6|^71aAzf@PVJ@B(^JUR&raa6cyDnHeL&+7 zXORR4hMUr3ClzwvtJ1##EV?0vp~<9tTJO%FH~;~-f#c{k#%!mYwq2!0EzR6aOma$d z6UuU#X`E`D%BJ6+$=JtTUhBEvl;ye{`-HDK*uL01)Xn*rtAgj3JW`Y8a$)di?SO{$ zGiKSp@WZSAQc_ctL9vSs&jur{nH6Z8yPySBfwDm*4shW!xF@kS{i9FVw3t z?zb;EXMv9FgspdDjNdl4A)@w2AsF%t?RDG918~mH{?Pj=FHjTlHR5l}fMVd7Q()~$!_0uZ;PH0sDLthNiAd)}WIFhHj< zBLO*B8ytgpnyHusddai*##pD8tN|d(3z~N0)Zdxsc1|+E`P+8}r%>8BN^a1Hci>1c1 zVde$|>_QGXS=s#1n_F$YT#ne16V6mpa%=r~vRjz@P5}MaVt@_57zyL`Sd75H@fN`^ zG6p1~b4tKKd2yXyP;rw7YOOofbjYFF(_3NS0M2C`D6vQ)UD04a0#=n>4$Iy&PlEHY z&y`WE;or^6#`lz$iI?(8l~Hi%Rd2goDqoXK0fws zs#L_vJzLYijq=Pukv@O*G zZN~V&N>DrB+luQ^kKUB`i|*rmGQDx~T6KF`KPUuJvJCJBGkfso7+i%#p=wy3`DyAB4!tEJS3_vdL&5j_h@0uQF4}$Sf%%DUOtxy~iOdGZ|Tl>>V<)clP&w z((C>C{Vx40&Uuc<<9@&0Z@24Xv+sR7RKUgB;8)140h~jd!Zv5jrTm2aaH!H=GPe)^ zq(h*Da6l&3IB1Y@e%*!^`2ddCXFHQGiAUmHQ#z?Xv&`Qa4pW>u;}5?EoHLQh17T0b z_x2tO-&)pJZ}~(KUP2G~8e6kv%8RMR+_Jt5hQAsfrCeDTVJC(4fZ`{@jL4H7tJdlj z;q)~$G)PJIea}RKr@Vt^I{eUYTmNUF2^29G*BL zchD9r;#`tGE)tBLp^F+|8}PKjZ?8`p#%`=+q^WPn*AJ+ z5tu9qfl4_sH{`jCeI)GN$XsmT*R@*jl~F-Z1V*)LC^XL|S$>NPXEuhmc(8rT#1Y25 z4yMl?*uzqL?Z&mb37}SHIH|L(lm%y{xWWfHEXbNfz{BMz=>uEr<5LI~A4DkntXHeT zw-?M*ti$i@UJc)s0uj99jQ*k<%fufaL-abI zCLTyI9&2`cNYj0SrspbmS1iSd+z(r=3Vqy$7z~$eA{CJZ46GO~_I+k(-1ri)zTelO z49ZSM>#n?hmYB5k0TufaRhVL@e$g=>rsdnW`zJy|#Pvd1S#FhYzrUHBb-ZARbH(xa zEuX41F;^fZ_)_cn0=O213KZUr-uKg>+@z|-yZs@N2EXZh$yj^nC=f|D zC@=QqvOqwZMm*!J+oMN5ig06HU|_Atba?sFr2thoe&V6@=ajYzJETw=k0buBCov|l z`h+{@H~w-aawpH#%TZs(u1pa{iR=iyb#Poy_P1avKAU;m(%HYm_twC%8pmJM3&aW2 zoWTN!3FT^6K&rHgspgbFJDwQ~WTkny-&ytd_F}B_=8@-L5npa(PJG$#AT}(>{wOMLjOeMb(bPCL~hMg}&CEJMK2GR2k&H z2~Yah^!|y754nzP7U5?FC|bV3LRuvDad~b|X^3D9fodT{!FP!}-@}|p+ffS3OtZ^Z z8Yusq+@p=IhKGg4b7_qOje}80vZ-q2!fAg|nK~XW0fDhJtB4DyPaWl=yWa8}Zi_OV z5d={I8f==Lo}Mb+(+!`3=~8S}PESvNJO^8fF3KQQR$^pjMxf`KRKg zIP*(KiGl$hXwZ+gklTp|@k{s|J;Wy1J%}AaOGU#nJZ;qZ{rl_7ybLfo_&q>~bl$_( zJ={=^;m~HiwNUPxdIV}pL8w*#Kvtzo%LY#2%*B*0F8faPy^1o0-_gxeyBTr5kdX0A zjKwKT$3bDu%25JBbM$(=)*sW;IhNIUVC}xco%WpAJ+br5y8nKzCy8(`=`BzESdWBkQC(a?>v*$TrR&$;MoUlDjP&-xeD}z zDq!Ag9qi9~tCWnCG``so0|now+erT4Q($9`(||PbMal*!FlLA>XgJSUq)}E4kz3?V zwj6hNcMs&~aeHlUZjPi_9XB^M)!_ZzK18cwJcg`z2!rmGiP0JuW1Xuwv;`a2mG}Jw z{%It2$v4(=gyU|uOAiZ+i}!Q8>z+sG-Oz1v_Nmy02W7%1Nzv!wLrg9?GbzE%8+uV- zFu*H>Z()h#=ts)RDp9p?e4FUi;uh7_Ff`bMTO*SUt)oCHjAkoOP=YdB<`+~ls+?Z?ReHGh&_?oh^dmDPbb5 z{P)U=oZ8dv>$zbOh|5hNH8!1Xc&tZx#^Su>dW8^@%QUuA_LEV*9}8Lz@<&G(1Z(Jm z<-cjPlF^1LV0NF&m~{R4kwVjW54%8sZ~r#>#n&;05-8ou##fksHl~X-f6B}p5{ta@ zH2?Sk2q-ux2V_i#(T$CP$7HurFy^+B^Z5=c3_&%m8XXk^wUJ_WSz;=#pt?Ffl)fAi z^mp|1#8NOWuCAN8^2X$dJ`g#wb#smqBk*BY_b^H#YG8S&rQ<1`u%I#HOKAah2oZ*p zR`unGv|48Wg2GeOB2|o7LYx32S!bK90<20V&sI4hrouG&c?B$JZOB102=C`0w|!tP z>yrHE3~bnM!5|eeKXs4Z5*9Rl%rWfNh=n7exB+$7h*#8P$=+W@^vHxMb4Sx~2EW%|%M#6q4Z< zddntu$F?P8JWB$nDvdO?4oBp?B)V*kunuYrmW#ABjpFMTBI_A+rvgw*aikyo;#Cr% zUqTd2@L9+h`#vIoOZ%{F({% z-{m$>)SKk92o(eWMtS@{xn)Zqyl1aN0pQ>ERNmy$U~&g!j|^f)ai2IX<^T#Jf)kq4 z9*1RpHMMVJuU@_KgBSxlyE>Ss3nRlv;90A^C)RJMtv&U(9n=&!E{aeVWfzP`o(tjd zYGl?e+9d*-@Ya2ntxI2XF7N84-Sn|UT|(LV8Z0?fm=UjseEGV zv0On#kVmrQ*wAl?Z`?p22UqA|?C$D+u;2y(5wiWO#nnw3T;>abBt;#)6$Y|Seu7{5q_u;dCp)exfq5he?Y zoPgpdn}Wj6^pCG)stF<$?`sT=!!%Tkx7QE3PGh)cq^n!v$V7YwHiBFoT$GlBQ>J92 z3DlhF&|-{(q0w*jepzv3`k72!EtN>m^NRuBT45&<J#`xcwNk=r}dgl%3u9 zh#e+`il=|$G)#oAB)Wt_Gdk`dR@7*AoR@U0uBcy)&IQW;$rll?55ce(^`kbhhn>GkaSLf~kp>k>2a< z>v^6z?2vI8k2IG<!E<1v{)$gTGwqvX*7#@Q!B*_iiWj_RD{B?F~= zr^B<7o6aYmc%Kd&GR06uE7M0O-0iqqEYnDTI67<7?t%IE(e+k4!G;(Uv?5%PM?!>> z`!`Yt%jZW%TqXyF&qsu=BSTO{d&jFvOpOi%r&~;(R<*6pHH+)(>m9+{yrmH#yS+7Q zqHn?urjxABlOq-o2yA{ngV#X*WmuT;2aWpFxv79fd9F`HQbaMq&I+D!-_0%?b%u7` z=7?0H2(4@;`toztlucSpqM!)Hm#8(=KafC(+%%r|Da_>BBl-Jk_vJR_8Opy`$*{kZ zw~f!VEQ6qxL+}z{?o*7G0fobwaLz>5mU9JR4@gKBnslE#tv;pM=8LeA>~Q{g01qw# zm83Dp!+s&Dy4r_tkRbW#j|^7?c8|Y!y;w7WD}#!jgy6o~Tkt>%h6+hqSzBY&zj|Da zzr37)5{N#tYWLPzf-#(H{vO7%B&ucll8a7ye34ss9-QwdrC`GwTz6hO8i>MwJB7$7u*ygk}VcqNQ`u zm%#5(%-%KMBBsGb{pofKB1rNM6>`9U7c8o7DQt?#Ur8H;=)>x-eT|oWehN zsM`*BkG3-txqMY!b`6Do^ME&NdSh3B#&HA)8U}oCw~zzir;J>h3>6&P>mr8_!Bbj5 zakwN-!&=wLb^f%-6(i2;E;NS0?8FV966jKbHzioYc`;BXC0Z!d9iIMN(KhcYo0%F8 zx@~-he64LnjZu~?KnbHyYU-KXm;olO8|@kNVbAj<@@ALD@XXeT3N2dG?EeUN`o@E- z!AWsCj-yx(fr*Lqr4WQR8+wzkaJ80(D&GKDO0wddw2M8-~`_(McU3dIs8A zC-DGh&c}~B7+K*El>$;OW9>5*VAppkUU}JKMLFYIKY--Bk2OBDfPY-Fq_(z}@~5~v z?4wNVhafphXUf;M_`TB3?!-j;{qWc!#TKdGaQbc~@0vA$3$1a1@s6=^_4}p@^|i-v zCCdp=%voRyh8&k{N2Dp%3Z3D-sK2?AeBKow5h@JUK&NS#u)qCdc*=jP z>uCl#cJ(2*9BO!bU0m}?xxUu&kCg)UVF|ExhehC=B@R5IvtFpW$;YzYa0l!M+*RlD zs;iAff~=qB@3QaRj>H&e$=wf@p747XfzKv4IDYMP0(Xb`+dk|aQgbF1g6pmwP&*hyfk5_nRDip9$gTi_ zGI5K}n|zg*`RkH*YE^Ch)9STPXK1s8B_?K6%{itucVNvt(^KxC3)*;IQRBhM#kysu zYw{oi^ToK*&8Iw3Oc_i+HtZLyyOI^!A|2zf>)cLl(V&9~I(LH{1CzUFs9-GJPuuef zJ$rcp(&BoqTluFw5ok@Gr%r?nJdnu!>iZ0x>+?ZAaZvLIXS}Nx;fin9__*N06%`); z6uaO+Abqm%wHS7JrNlHLz$c%~HbdF!(g|*3$=w~_a^VN#ObZQ{k=U!NCB-lI5>DmJ0>KYPW;x|Iy4M{ z?fmAc3heWE+qCWdl=bW^w2%@5OUH<#UL31UVl>^40kN4HlfWcRGXeApVd--0p3>SEz;oMR?ijk z_AmM@r#9)C{vz%x7y;?=GR?RgT)dh{3t;`t%#dd)#flE_@bQh@Y#b%z8$S=kloymo z<62Mrf<%gMWqD}fl;jV{$bLV(SEHFMl;{+`wSUcLTf_crs-P-tjTA8C!S9VKD}@M` zQsXRbm}2pJ??*XcFk>pftaNocWPvUB_ED9&xA(#Q$BDx*H4+3KK~Pgu;4bx5k@g!M zk6vR$-|RQ&rccC)(dmBw1d*NZj*%#jPDyDZZK)5W1e3YtM#se6csqA=Do>>eAF1&ehr@y2xubT&A?e=p9@pEs>^TOy`sCI^4GrJ9u`7(QknE%rKy zjTgb2BADPl=GhhwcqUQw3SatAZM3f_d7pOh90#wS2#5v_NH@QmNg7uqkC>E`XzuBF z_kUiX%#WLyxy!=DnOeW=dM(#rX=#mk#K;&70L{F=)WP7@$$

J?{>rxc^Gv^!JdSj`@E-18FUbPbFnYyfy*NpKpz=#%Sr9cr8u@M zuK?2ZW<9auKg4WA2KX)%m)l&wp$NBh!`qwwrCY=9SH-KKn8!$FuR9w1n910 zBcvw;TuLMo7c1~2!B>SiNHL|))~@%?mtkssvQa;li+ZM}%;LNlESXN(AEgmtB^Eob zS?{iwZ{(RpdB~oqJAHO`AbaA@|32JHurzFQ={nen(ECMH8pcFMrYQLVK_LH0*~278 zO+Aai8!RSz76wAX!tN^Y8!3ALt?-o6S6E2MIhOUaHKr=ftV-nb)IVspc`937@(j}1 zodyp(b7WW*yN|xqnt*~eKs#=rJt97ypQHiGJHh&nM(W*zc;ug#CI$M_I`Au8QTeQ8q}uqKbVFEiU%) z{oK*`1Crjp>CbAxdJ`Qr=LE?|i~V*@Y4I=r_xY&nx)-*FBS!m+ze(jNaQll&OEdbH zJ^&Q4*KX)nt{bhz=NTzwQt@vR^I;#NI_g`4jj}C zAbfkpn{532eE@}uw?WdtW0l||9-_jx0>*Yv{&)ROV`ZQ{rS~vEiezgVQ(KV47{n@3 zv@LA;p1J?67P1+h#9>`dJYA;$W12^Bry*$Bmkc9D^$~nF^!(KL7SK#@6j8Qu;Wn&4 zuJ(K870LoXqW3@O`7Bm=ekz-Uj|vvGECC(;;Tde{cj1BkLu%r{nyhDu?>c7sqvi?# zIAT)I^dAdd1W3+^!VlbcxZvr4yLZdkqIetF-J_I#tG{<1+{1`~#x29{t&pXC8YZkN z$i^4VQ&;N$zxNS4R6{S;z}JRUJk=NnS_rUw*vDR2lMpO4Aue^iI3*A-QDL_h+w)Tn zKI$I#(ak!Sak2<0odaQ-yJ)uv zM2on;-w>{7!B59}9u;&HiX$=@T*H7{&*DOBaL3EwXDAqgxYzObCczpAA2b?7fKsa+_uchh z#>lOHFz4Wg|F_LV0E|l~>lCc=;U-awM(H4{gNoo%3A0kxYC-Q17>n|;^7Iw|Fs58m3O$Ku6ao(s6>oHc(OM` z#}DR>%s(GycirGY1p1?(gs$?}_5r_P)lx7Rv7BBoWFtj^tQoRMsIXDLfOIW~Es78s zc4o8e825jBVs`|)iZ2&;neB2Ov8!Y)tbApZhmgn{#SPbvLc-9%P01fs)H^URAj?bv z%z{yZ9qnk4&G6yHfC5)1SXTY^?E|$eS!U<2EOIaYw|U$CYK=@3^?mPWk?q5rQS9%FXm?3Q60nITSHRs~zcf1qD zLy!IT-S%gt)gKWn;UaReF$YMYNimV6ciHg4(_5r(0b`#7!I9hXcRt#5?Vv+~f!84P zKriiD*$+^eN_o3x@z2nDd$eC^emCLy?_S!pa&vQQTB&pOLBt*^o5HqV^g5KR@~m%) zC)~1iG$^Mu>m4~FvtkGXU=kP07=-VUW@ioVp!=E+$c8@1XUH(AM{D_f`})=Fdp<$_ zziK2nJ+8j~hJq~qT}NQ*2orBg7N@4lV>-%Uu{U`!oTbM?L}9N8n7zJ$>wG+$YXf;9 z*b&Uj2+a51-n6f_poy*sM>v09mhoWvS3jh}bU+))-mMeIQhv*vtdm~cJ^aavj5iN0Ugh@+5+N{lc6M&(NS@y*@8d*+B3$wqq9er6k0ruZ&_N>I z-``&ggb9-+z(ZGqE+c*Kh~uf9&DdaCYVAFE1KZ!9P4 zzIl`;n9o!}0GJ>|&wXsMBIYF}h01HE`_aI}(;{>)fRhHX_NVk>dvM09l?@-dgKN01 zrDPu}sz9d;5`~%Ecz6vpi z&vyl(on0@S{BY}3kgkD2%Opl9u(_VOqtW+q_0hw}{I`0V^eG?G&LZ4Wa*tI<;QqYUQiS27+*`D<=eBJ*>%VvNQ!5mF)Wcs#EilprI?Z5v zJYrwN@R)(SGvG}AA57*~Zv|xgn0QFRH5l}gDd9RSn&|3A5mWGX!1~9}PIfz-B}Oh} zmeXU+9w>5mxUYiC4c;qvc=woU$4zmZp8ac@^r1Vwn?|_+3)x~?KfGY0eSL0Rf`Phs z?{WkLrBk~r+*DQV*bn~b!bL5mGOq?tW+3~Oi+y^cH{pFluaY*xdW9@#fJq(s`eY|; zEezBm3g!TazMr;dVrqIUD|F#s^+5xLke_x@;W?~fE~_RM>NhquV!lsr#kB@VhhYz= zG?~D9Uz7WV$S&$JUwrEf#&KB=1F_q>L11s#6HM@i2y$W$nt#qIG2d!DUTya9tLwiG zK^M9&U(J?jDT>ovZ;iBByoA&h-h!h*6yNwen#6|qL-|Yj=<=y>1-EqdrU6-o8Y^YW<3q3cH!ygI?RvfG({$02sohWRE zP-{DKG6yZ<-(}umOvg1?!JdE~UyXY$H;?s3 zZVG4|C=&;OXqDvv8eZeclw>E&E!v9#H2zsQN<;%n1ofyzKL{Gf{!n>6_TM#(Z2R?C zuf?jeqGDHueSaJjmZqx`bIZ#|6+tyHxmULgmtVJPdmwRO%jBr5r8P-)+M+jT`URG7 zpM|l6W4rO>t_Z#9qsq~0>AeUT??lK|*A*Xn7(XdtZdea0TYLF;hF;sGUD`9^5$ArX+M`^F%aIG(WL=l&;Iw-^1#(&h~ylFc7%o9abjn` z6Mc%i2L++!$IW&n35@~A@+n=u5W7k~?UwSiDtyNS0yvu?2nb$tvsd{| z+p7}s-v||WhAbwWZejNa!2Tsz4FMV>?IGC1B4T3b&54bf)LEoogLdW(*^QB5sO?R- z{Ux`O8%6PYAhLODnlMkfP@U{BZws1_BP=*Jc@VkxN3;EUTKvnVpK>a#a;!ewLiGPG zaxZ8E0?NC+jFs~xI_BCsaInznH7@fi#l~eqxF_QHxKAE!IJDt+H}eX!>dcL00(*!S zCtpch-?W-d%c!rv|Aj}X3xvU3&*&D`rw!UJzUiv?<^6dMPhBz)!l9YO;4n)csJY+IpXc2 z^zGG+`xP@ObmAsMh zBM+?Y_JElqOIzEVYYe4J^cZ>A9Ix=uoA>b87apbO^cDttwNZ8q9;*e|sV$FV=(*Iu zf6M+>4s<>Jl*~DWAyK6YP?ho9aV>L{VL52=B<#- zb=emOVOkg(po;%=>zeTI-g7^)Mh2!{iKM@mT+Mq4)#4Iz_!8)OjPFTKwq!Jqok^kD zXOpzZ2Y@OdC2s=qfnbV~j`|sb0QVrqI1-DsRf!}3$r|b~fUisLB5ltP@P<`L|LKKu zcse}`Z$Yw}q5Is)lfY%&1Lp~#yOlj)g(3Gczaom|(#R)q`SSAfTkl|uHva%>@DQ1x zHs@A(%u`Mj3QzvoA3QcsuK7oQ!Q=59xY-V-0*Lec`*d2w1ObV&NfHnNt$sr2h2oLrQ{6YuHn%6as|Q_E2Nc!Q;7D8*QcHmrY8Vd1$^@Lpu+WmYe9X6M!1;vFJ6}04>hJNuElu<|gK$ zgkWjc!j__yz`yU?8bUnP?5Wfq3%sPCEdpQ`@uiMDz~=96W;gI`iK0&jS&PLwDdX>B zkNGodAQ{J!0@d%{y?a&N_s7`S5j}Y+T%T}O%YUnk)}1aJSAN09GsbNSaWn$tA2O%Q zu>W;9i?)EoqGL7z1x&&IjEF-Lf-jCi7w+quEM0kj+b!XsadyKek#1jt@zvC8%)c#% zTM*te0&*g(5rOHk<4lVfD!B0;1CY}6vYQsXuk!_beqhWqL{1T^?g6X(y_t61O-Ndm z(X^g?J&IJT>i}=0d<04;o6|OkOnx`tw5Y@JV_jw1GI!zVwlB~dyFAhF&nP-XVE)*` zW9rbELpJ7}3YF0HU}m#pPb;2(ziSgii=6^M25e<{SvFi8kCcSOo2oH=9ov)+pY9KD zRO5{6U?IWcVNvQ4;1lBy;F>m9aGk|jbLdn zbm1Fu2Iw?9ljn5uAwXjqq!f>fVHJLY0-6KoXS?GJNZiv~sJzEMx05~m!~nN&U$@{4 zS!?U1b?>t2!A5))VdFk7q$PWy&V1p*5?wBXCoI!n;i;Y4*$UWm90mPIEBCmv+j+c|6UKN;yt)qRGjOHIPko8U+?mFtUlFas7%2S-iZ9 zcDxP2;_@<+rDfgG2Jf!wwN<0)D4)Xe#1-b7mLuHsVND-;MBjwp?K$Kq!?AJ(YnzJe)Ya#$jJF^cfDOxE~U$V+%Ww zi<0SND6X$OSpyv4f~X3)1dt^@f{wHH%64E{>tFv(u5=^P+poIJ z%dRd++(!zmB+F#=P$QIyNsaybxD1xHMAgd-e5)gOXFskapF4G|6Z9rcub@*dQz$lt zO;aXvUlW2uLOh!nBlk1PPp3hjyssBhzt{Yby?crK~_ z*EbN0oYa?JfZC_z+Wg_pgGiA_?8hodV|6$#vuVtfnG1sc&>-G=o8v?DlS0>82uf)!iO!DMjd;%M!dM6W+)`>~A4K#n7nraUeLiaBmyDG?D@UTk(Kl>L^7xp$ob>#_Q`6`x>`ohbu< zS9#l&UjcgWh56RSlKZ2dE?MH&-7^m};06EUAxpz_oSgr_NLN?am;DZKTUj!Zs$|^T>wG(#wHA|XqZ+SX?l~3q zOPMrgfZY4-s;$=wB+=AaGMLKIa(_G0-@L<_Y&?aNtF12cDDSmeGN6Aa__F{xoP&Sa znBI6cQKRmgjrKiS3F+mn2p}@~CBSDhyl;xlseO22=9zu(>`gYH@Ep$yRcdEgI6QqjkAe?b zJ zp{JmLKdkOr!_50H8O{Q`_OVjp$Opfx9j4l9R>i|hZP+nf4@qH+^4j)iER|l!pnn2v zYSEJ`eveoYw6|W>PbH{<7~`aTuqeu=0#lIz@X2&!8O>>R)ryvO(i9+yDqvi(@4Yi@ zc6lUun<$bpW4xnm)Mn8<@$!(zk_!NRJa_{5=sQcHx-|V>8>+C;43)_5tg#X}9bF zl=Rwau^nH({&{Ut#DoaF6nGmk_uye<&^GMkve{4}N22fW=8KU7fT(>p&sKnk zXXp(VgGSP^N{QNA7;WhX7cmSogdP$l?5kU9dckXbGmEklU@goVn!f3oM?*01z8vnx z9($yRJex-O_s#ncz0;ux-QZD+SkkALc{LHxe!8jA+b6u$GDM)jKMcwby7vXNtA>$V zJK*<3J8D5iNTTY0Gz8(^>B9n4iibI;UQxB1>Co{4bkcm`##s1A z+Fkz=IJK;J@|R|k23G{hM$<4);7kti+a#zZ8?RK+>VnI{?sJEsFyI8Ez^xo+$K^bj zzzlU{^yZ4z^~LdbAmRB`VfMXvVmW_gs;_W-af8{{BHLH8>x)HoD?5T|+u>M}G;6a9 zaIs_I^6@>%ry4bY#?!AQCqC=JRnaI)hZ0;qU_&$872H0OYW)7||NqeqOYCS2qZ;+@ z84Ic&eh`V;`3%u)h(toWTlC;(y+_VKZS1ovzqPdvXf_iG>LzudidSEaIdFG#Gsz=h z(AUu!0A}(p5%XG3tv4_-u9T!EHde<=y>&TK94l+Sjxfy3p1G0)neAe$A+^gvCF_d*K@6C5>T8rOXOz2pBGvs!PcC|64;ahtGmiU7booWwjWVQV?VB#~ z^YT*d-89j~4T%^o?%a}Mb{Z)Bbv!L?{jWN0H@MTvulflJ$^j5puj1r z!F!W^el?_IV9I)zXSM6;fb&-BkRSo3{pk>2ypPln;7rM{qdW2SA@bMdR58^!u^;1@(Jp+2GvC7ulf{1y1>4>hn!_x<1;G74pIY^yG0FatvK}M z1-kPd=zbVlG&B85B04hJ9?&adQnm1VakR|=QimKndOXA8!8_ydiEW)zCQJ6%gS zUt@=3$U#9~-q76|1^kD`$`57e7#8+52+SYS#*K&A z=*wocLkWj7rultEeDR#D_Z6BP5~wzn*y^`a-vATD1f4EBv~l7whVX+C*hJOWh+64< zi;p`se2;>cZdou#%wS=DS?A62P#D(xq&E&pIJ-ZtZPzAy9*cD?Wj*$%QE5N=;&Oe$ zRm$o1n=9Fvf`U#O5&}8($fHN-gAv7+Z#h`J1eRicD>5ec#yj8pm2e+wv5WrRA=Yv3 zOGx7*u4TTJ_VcH?+ijhqtO)vR;a!`3?lr8Nwa6hc2J9VKo!RYX}_W;U4Y&@K1a<=Jw@%9})TGGGnYq&Cu zjl;(wGwV0EkI#ohUQNsLTFb+x__Tzfc(SA8LtSPspl4*Pl6dl{^H_;zxj zs#RS17h)l$Dg|iNosqT7!{V3!L%l4R(K9pesA%gi)rjoQ!j$JKnv({`RYF1EmREkp zW%uJvbSx9RmMa2vt}Xrin&6N6erpv8H@juON?zgXxb~r97NeQZrfu%%{gtbAhlvX{ z_f8g29xVGdoMYGmM1WR2L@59Mv5|=h69aR|zaoHxgG1KEU$RvE>|N^&(xw)}``Q8g z+QepWoUxh<4Z^}Pg@~fjF(ibtt`XDxsC-SCpzxdIETg$N%Pgr`BXM&zWGDhw|MciU znlifV$DJwB(*6(BUJWD&l~F`!iv{Mk0jZLy7umlxWWfv;n{f5gz~LoX{;^JM$_lQQ zc^)=s4v!Rs9BsnNvZSt_v#mLXoN`d*aWD3;6$A>m)k>(sQ}A5su@}dI1llu;oxZU4 zTZ*8N|0Cd~&?BIy0qc~?#4dOATq32Du=*I3s`UF-6wK+V6m{TZdN_L#g3Tvo5pJE- z>1P9Cwhrbway(Oq4p8=Ak6YUm!uq{M@%T7mRK_FImjkxGdmqO`y$K8BB8wj{K^>(I zme&cv42azvE6l&9$Bx!Ee!p-a$L5XcpLDc!Frn6B0Be`XE);~j1teCZORWdkt&@G^ z$|F2*?=M#G!|Hw?X1U)ae5&#PyDW&B%ht!dpeOY`@%3#Cp&66d`X-FMhjxU(X+Q9E zHU+rTxdYY2dm3Vm>2`zgn&IdSaGE`u)F*#J4dxeSiLVjQ9Y6BGQlP`1U9ao~M@l z(qa^@aGxnulEf&_+|&fO6mJk-MDSDQ;#;2v@6m@N?E2X6>o6zX9|HgHTj zrnlIGBgN9il78994#CGGV!w0DQaQ>LJWB`5zw}8|6p<_8GGZCIL;(Xd$$=FZ$47G> zziRt+2aA!0Dn?gRGbvQO7RZj?E;UC@5YeE;Y94;{2$c)j-`d)ywlT>bc_}FYs&CI| zs09^3zjjN0?J7@CjlidBAntd*Zg|p5uH=!bL6iLKz)vB@5+=a|QKw$bYG?&<10zI2k1a&!J8gE| zW(8~9UTFd5*A`}th*AS2+3P$*Wg%i%ucPI|9W6pLEYQ+pd+^=@=kdj@rFxGe^ULQT zJA3kPUqn_`*7`UHz9^}Xg-sr;H^)Ho{`*Klp;P$)s>eCXFm+F4EdPlU|>rl3Sz4PQ?kC+!WZINH4E=kiP=F z8VuW*S9o|hGw2aokcjpRLU|}L3-0dI#8!(x$Zl z0m<5hjosy!!Zio;QDOYRZ9XWi{QC9e!cN9HIw~@nKp+5!h41AaHvr zY$riqaj(ZW5hh=FPn&lW%r1W=J{^b5_6HsEJyx*fEhon`R1mofm*D8ZBpT_B!x1gVwLHdt(4Q*@m)KY*hwnj*sk^RBAZoqvMNt+;9<%sqC zqrYT>CG9vJw=P*aOvXI25n071`c~Vv{3!~qprO3LZB&Y^_cbb_?GEKv-C%>?WsP@^ zZe6vL3wl&Lw-dS)u0VBImMZCMhu1>Ij=KQm*M9Q8s)Jy;;)o_97c}hv8OnFY1_qMW zCJSH+g3ucL)3d|l`+bZ)ET9!Mk&g87?rt$*zU@o?c-;E$Te%D;brfG2^Oly7_!Bl* zGYdO+tH(+OG-t=k@GW#mo1)CQDg=}KDV>E)Y=1kC@xxofhQ1h#qp#E89e7}!LGR5T zH4znak=7nm4}<{`HlCS`!K}%M{vjR?f_VZ1Zvmok8jf$E>qE1~&q%-h*fOepLhe-o zgvLXA#y`~WTD(B>y2VjT*ijaZ6+5!3$2lTxr>1=k`QF}|d7W{j7gBqydj-PR304Db z@eg58*J{H$l>huGyI9u!3k(cbf={S}7%Y8Y*cI=wxr)fO zm2WxHDxntGzk{9C#JCo`)p29JE(CH+jIDpUilgjxyw6ctzD~WTFx#R)*eQU)xS0I% z%ibH7)3DX(M9OJA#Dr3EP~8_q^xa0#Vv)tM-8~&|f88g+idk@fgh0(SVpWx)uxPqM zK^*9Qa4TEo2x52^>X1Oyu!L3d ztmq!X&{M%u#tcrv$VK!$5pyX+@dcc`w^@XLg^7xYnRnyulX7f11mI4ErwaNf@L)~m^b-sB7Lyh(m zuP9JflXP~$>i=E6aZv^||ACyoeKL1U~L&DI=P}u7tD@xbc;P<29TeT+A~53 zsJuWNT5V7fKo5eYVN}?MIV8G`qgEZJCINieMlI*a&9E^vZH!YiXO^x8FV$z0MraPQ z15x{T;A|H!So+F9o!Qpm)v~!b4+k8U=>cf^!}h*3^prHM-8}*DgYd|T0eaqxN8-8G z(oy$7AMNQ=z6u2ly}pU(t1OHab>L&4HB(0G9P_I^ZD-ozP9QUjdi!?yWFgXeVRN*Y zII^<7?kv`C^=;-5#8Grd2AGXPxhHObN<>!pbtl6?BIDuo7<12zU@~f=~Xu3 zp)wL4|Gs0DL-+(GBI&5<)m2>3^TsMW{}*R4$Z#$L1rSHoNS=&V(@bjWeu1U-r$OCu~(igZTof{ZqngvHaZMT_>4-eRSa&*f3}9 zEal&t9?m=cJ=M_$14q7!mFruJsHzjS&PE3>s8t~dw&78 zH48Pd&l6&5I=s(}qsjP-Z?5q$sV7_Q(1Ts{wz>Jwe4UzmRsqdtD*)Pt_>2D!+hHPm zVYw!PuxB_ayrrn&gh&_+bY^*AxkH*UkDm@X>D)&`LraLd4HrZ^f#e(mL#5*K3OK-x zE~*;4*8d)bhL$TIYL1oLzi!w%TgFOh4S$EClTKB-)}+L)_YL*4+zIOp(=_%8S~Q%G zbbQ{)5QT`;>mhG=F59fk!uRG z_Aos4L&3M!MV~%;6*8e>V41RDLg(nEJ)2Dir(vB z)_<6ogp^c^_0y21xQk7$Dv*+O{le-GNl>h7Mf%-*;!*!>=pQ4xdHb*$CGI!XxPK$*Ok zaQ+#DO`wWlQvs+OefK_B`ge&Z`VNm`&cBgdvCGfQ{DY8v-gGku8c$?PD84yxCir05 z0lbP3JVCPZ_g*6OhbPF-x2wn$$$PDl-c~;ZJgCi`Pn>iO3yB7F`BJEwn$OyW@FXsOPFYp> zC@+?$w;#c_p+kQf!P|x4-7cR%@03q}@F-=frOxRX~)b^8eLzWlBGJCRN9=A-Grp9kwy&T zTrYk6=jS)?yzl!w&wbz5eO=f6<2mpBZDn+w90l5=14ZPWDSxyjSy=CHfhVZoC!)A) z?VaGyZfRMDO5@d{sY!w%1w)IP^M+D%fO6YF%8|n?aIU%5Wj%7t)vTEDeDlM;xjD$N znz}OGzrI0xr)>8HHw!8{E3#`iqw@JLNdDJ(`a0bb7ndZbRV_!}m7jL1kyol#(rU`RuyYfpqL*Bk?As>166o3>hd{)M}| z&KN(Wt8>|94t?=^;^-3iNE!{xgnEhvugFGluUYxaza;AJM;Ovk9UHpz`2EDVxFXD8 zPK${Uw+97Gebh$ObZc7;fwpc``YiLdrcqp%M9l#F)172Fs(-46&V98yczU#NexOby zVYHC<_A5laj+x%Xod=qM&jWx39;HpVAeyF~AJPUsee&1dJGe09NjP(t@-H^OZH;OJ zWdz%Oqj&#FEhlzTQH*;p- z-3_t%sE~u>IY%-IS?jHBGsev9_^j`-;)QcqX$b4NqMCiHmIK6B)oZ65NqE=MF%w7{ zNHC1<@>?7$ynygTEmEu^n_vP?FlIH(1%sbzC2a9r#Z?8Kc^B8}&`OslvWj+kezhKC9l%H}j5Hmy^MA^t2ulkQrVE%jV{Bh!}BrZXD70GQF+m!e#M+*4*_Y@N(24t2;oA%f4CXCT#tB zUt>+l`>x%)zB!Pl72&R|m67q$XR}~GR^RdowA{37X`a}q>#U892luM&cARG!MCPIn z&COlyX5lfSH}}PiC5aTWNglxS zw776#1saN8-XKMpJ<1Gyec_rh5Z$lS)6>Qgt9*%J5OPx5F+RuOza#{=`wyIWZdzSY zRe}%K=-j0rgK%a;vQe&zx`AiMwS2^VsjjM;Tx^^7s?WZ0K6s8MINI5bnx;UQ1PZz!_TGF5s{F>eN{`@uM+mmdc! zk^OsT2J1fz6k_jL>K3O4O>IudM09j?M?A%U-rlIYddFaV_ehVsbscvEo7`iJTLykU zaS&wJHg7sY){s4w^mn!KxxL+9tfOR-hLl(kP6k(|J}JZZRQ~&b)3G*xvOTnQ0;JAY zb!vm<-WH*1;tbK|%ira{SWlf5X_q3(%fxM$IEp7}B6G)Fls6)2KEm{=VnGm&*GeKOY%nlFyI0Q8Iu?1eY=N=k~+O6IxAZ8PIxE+GKRI+3+ zS))`kpZH*31eo5$oI(aer?YV%C<5^3!%{s6X6TmtMHFPLvn2DQC!L{`GsNB*9FL&+ zD@ohLyGD*GWbuPOm}$yL1uA zRu+sba(L0%-rCxK;kl`0gz|ayQVPTYLuR{_b8w~%Wzc#@G?Su+OhY&eiF;p`f!4d? z|Kq8ip7a%&a^?8?#1{kUHTrnBDGfP^^E+>uUUj=+hfzKlS2-ESN$foq!l>#Nf{ae~ z6J0C%)?nmpNfiAYMXE&oz0(Q{r&`9%;MXKaV+p=d>LA%rSrc zt1h3^{!xE{Y5W^K$^0=VFdz8|>76iBs&m-nYu4|0x&E*dhW>bKLwc-mxwjPFS!)m` zy(I?MZ7O;!*q}v8&--5nxRy4#V}Dh zEdh6d3=hsY4+wr7>v7ko?7O|S&hn&!bE-zR^YDelo>hQA?Q1OoSx<_(Sc!W)4U*<-u; zph$mPRYF7%=kw|9p95MdI(9SF!j$W)35F!m4iNz8Y(V)_iQ-6pOG0NG%x99Pc;0^v z2EnnZIj+68qGJWg@gxbJk=3Ge=ZBR>+{W3HCu?ZxrV00Nk}d5i(XE-GlSF%AARVk+ zSIXL>8U#J*R5ay5<7}ni~EO|KBBfaw>4bV_j%I0J0 z9K!eml8fQ=NP!1HyH2X?0L0vbpB;D*3u0Q33>MVM;1c?c_yk~a+ zvVxt6fv{SZY%PtiWU020>Q^~q;L%?n%oMtGX_VUF0uj3c?Ti#3DA{oP!{dxpk+lap=ty>qlRx{`5W8kHfrs@ zrWyF4SZz)D;?R%lL`qFs$@wTS(Yn8`6`M}^9gb^em zx$yAt*{#6?i)Hr-&SNxq8U?M|RkFvLb7Cgna&i~a(zdMs6@={yhHJ5e6?G-s-;#a8 zT^qrSz5Fbd4C)0>>0z4f2eNKTL+r43CEivb$*VL^z;=I=F`&YK3>m5-OY#@>eTCD! z!*c&CFP~MpMqdHv-_0$Ahyz=GGBmW3!(aX)Z1`mSV j%2l@1`P_4jRXI!XGd^+GL@{KXgrA+}Hroo#+(Q2YoBbi1 literal 0 HcmV?d00001 diff --git a/src/sssom_validate_ui/utils.py b/src/sssom_validate_ui/utils.py new file mode 100644 index 0000000..63bdf54 --- /dev/null +++ b/src/sssom_validate_ui/utils.py @@ -0,0 +1,175 @@ +"""Utility functions for the SSSOM validation web application.""" + +import json +import logging +from contextlib import contextmanager, redirect_stderr, redirect_stdout +from importlib.metadata import PackageNotFoundError, version +from io import StringIO +from typing import Callable + +from sssom.constants import SchemaValidationType +from sssom.parsers import parse_sssom_table +from sssom.util import MappingSetDataFrame +from sssom.validators import validate +from sssom.writers import to_json, to_rdf_graph +from tsvalid.tsvalid import validates + + +class SSSOMValidation: + """A class to encapsulate the results of SSSOM validation and conversion.""" + + def __init__(self, sssom_text, limit_lines_displayed=5): + """Initialize the SSSOMValidation object.""" + self.sssom_text = sssom_text + self.limit_lines_displayed = limit_lines_displayed + self.sssom_json = "" + self.sssom_rdf = "" + self.sssom_markdown = "" + self.msdf = None + self.sssom_validation_capture = StringIO() + self.tsvalid_capture = StringIO() + self.sssom_conversion_capture = StringIO() + + def _run_sssom_conversion(self): + if not self.msdf: + raise ValueError("No SSSOM data frame loaded, run run_sssom_validation first.") + + msdf = self.msdf + + msdf_subset_for_display = MappingSetDataFrame( + msdf.df.head(self.limit_lines_displayed), + converter=msdf.converter, + metadata=msdf.metadata, + ) + msdf_subset_for_display.clean_prefix_map() + + self.sssom_json = json.dumps(to_json(msdf_subset_for_display)) + + if msdf.metadata.get("extension_definitions"): + logging.warning( + "Extension definitions are not supported in RDF output yet.\n" + "This means that we could test if your code can be translated to RDF.\n" + "Follow https://github.com/linkml/linkml/issues/2445 for updates." + ) + else: + self.sssom_rdf = ( + to_rdf_graph(msdf=msdf).serialize(format="turtle", encoding="utf-8").decode("utf-8") + ) + + self.sssom_markdown = msdf_subset_for_display.df.to_markdown(index=False) + + def _run_sssom_validation(self): + validation_types = [ + SchemaValidationType.JsonSchema, + SchemaValidationType.PrefixMapCompleteness, + SchemaValidationType.StrictCurieFormat, + ] + self.msdf = parse_sssom_table(self.sssom_text) + validate(msdf=self.msdf, validation_types=validation_types, fail_on_error=False) + + def _run_tsvalid_validation(self): + validates(self.sssom_text, comment="#", exceptions=[], summary=True, fail=False) + + def run(self): + """Run the SSSOM validation and conversion.""" + run_with_capture(self.sssom_validation_capture, self._run_sssom_validation) + run_with_capture(self.tsvalid_capture, self._run_tsvalid_validation) + run_with_capture(self.sssom_conversion_capture, self._run_sssom_conversion) + + def get_tsvalid_report(self): + """Get the tsvalid validation report.""" + return self._get_report(self.tsvalid_capture) + + def get_sssom_validation_report(self): + """Get the SSSOM validation report.""" + return self._get_report(self.sssom_validation_capture) + + def get_sssom_conversion_report(self): + """Get the SSSOM conversion report.""" + return self._get_report(self.sssom_conversion_capture) + + def _get_report(self, capture_stream): + return capture_stream.getvalue() or "No validation issues detected." + + def is_ok_tsvalid(self): + """Check if the tsvalid validation is OK.""" + return self._is_ok(self.tsvalid_capture) + + def is_ok_sssom_validation(self): + """Check if the SSSOM validation is OK.""" + return self._is_ok(self.sssom_validation_capture) + + def is_ok_sssom_conversion(self): + """Check if the SSSOM conversion is OK.""" + return self._is_ok(self.sssom_conversion_capture) + + def _is_ok(self, capture_stream): + return not bool(capture_stream.getvalue()) + + def is_valid(self): + """Check if the SSSOM file is valid overall.""" + return ( + self.is_ok_tsvalid() and self.is_ok_sssom_validation() and self.is_ok_sssom_conversion() + ) + + +# Helper function for logging configuration +@contextmanager +def configure_logger(capture_stream): + """Configure logger to write to a stream.""" + logger = logging.getLogger() + log_handler = logging.StreamHandler(capture_stream) + log_handler.setLevel(logging.DEBUG) + log_handler.setFormatter(logging.Formatter("**%(levelname)s**: %(message)s")) + logger.addHandler(log_handler) + try: + yield + finally: + log_handler.flush() # Ensure everything is written to the stream + logger.removeHandler(log_handler) + + +def run_with_capture(capture, task_function: Callable, *args, **kwargs): + """ + Run a task function within the context of stdout and stderr redirection and logger configuration. + + Args: + capture: The capturing context (e.g., StringIO for stdout capture). + task_function: The function to run within the context. + *args: Positional arguments to pass to the task function. + **kwargs: Keyword arguments to pass to the task function. + """ + with redirect_stdout(capture), redirect_stderr(capture), configure_logger(capture): + task_function(*args, **kwargs) + + +def generate_example(): + """Add an example to the input text area.""" + example = """# curie_map: +# HP: http://purl.obolibrary.org/obo/HP_ +# MP: http://purl.obolibrary.org/obo/MP_ +# owl: http://www.w3.org/2002/07/owl# +# rdf: http://www.w3.org/1999/02/22-rdf-syntax-ns# +# rdfs: http://www.w3.org/2000/01/rdf-schemas# +# semapv: https://w3id.org/semapv/vocab/ +# skos: http://www.w3.org/2004/02/skos/core# +# sssom: https://w3id.org/sssom/ +# license: https://creativecommons.org/publicdomain/zero/1.0/ +# mapping_provider: http://purl.obolibrary.org/obo/upheno.owl +# mapping_set_id: https://w3id.org/sssom/mappings/27f85fe9-8a72-4e76-909b-7ba4244d9ede +subject_id subject_label predicate_id object_id object_label mapping_justification +HP:0000175 Cleft palate skos:exactMatch MP:0000111 cleft palate semapv:LexicalMatching +HP:0000252 Microcephaly skos:exactMatch MP:0000433 microcephaly semapv:LexicalMatching +HP:0000260 Wide anterior fontanel skos:exactMatch MP:0000085 large anterior fontanelle semapv:LexicalMatching +HP:0000375 Abnormal cochlea morphology skos:exactMatch MP:0000031 abnormal cochlea morphology semapv:LexicalMatching +HP:0000411 Protruding ear skos:exactMatch MP:0000021 prominent ears semapv:LexicalMatching +HP:0000822 Hypertension skos:exactMatch MP:0000231 hypertension semapv:LexicalMatching""" + return example + + +def get_package_version(package_name): + """Get the version of a python package.""" + try: + return version(package_name) + except PackageNotFoundError: + return f"{package_name} is not installed."