From 440482904bad46497b456327a4675d8430df8ed4 Mon Sep 17 00:00:00 2001 From: Luiggi Tenorio Date: Tue, 21 May 2024 12:32:22 +0200 Subject: [PATCH 1/3] use tmpdir for testing --- tests/conftest.py | 129 ++++++++++++++++++++++++++++++-- tests/custom_utils.py | 23 ++++++ tests/experiments/.autosubmitrc | 22 ------ tests/test_config.py | 33 +------- tests/test_endpoints_v3.py | 2 - tests/test_fixtures.py | 57 ++++++++++++++ 6 files changed, 206 insertions(+), 60 deletions(-) delete mode 100644 tests/experiments/.autosubmitrc create mode 100644 tests/test_fixtures.py diff --git a/tests/conftest.py b/tests/conftest.py index be699e0..e871f58 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,31 +2,56 @@ # Reference: https://docs.pytest.org/en/latest/reference/fixtures.html#conftest-py-sharing-fixtures-across-multiple-files import os +import tempfile from flask import Flask import pytest -from autosubmitconfigparser.config.basicconfig import BasicConfig from autosubmit_api.app import create_app from autosubmit_api.config.basicConfig import APIBasicConfig from autosubmit_api import config from tests.custom_utils import custom_return_value +from autosubmit.database import session +from sqlalchemy.orm import scoped_session, sessionmaker +from sqlalchemy import create_engine FAKE_EXP_DIR = "./tests/experiments/" +DEFAULT_DATABASE_CONN_URL = ( + "postgresql://postgres:mysecretpassword@localhost:5432/autosubmit_test" +) + + +# FIXTURES #### + +# Config fixtures -#### FIXTURES #### @pytest.fixture(autouse=True) def fixture_disable_protection(monkeypatch: pytest.MonkeyPatch): + """ + This fixture disables the protection level for all the tests. + + Autouse is set, so, no need to put this fixture in the test function. + """ monkeypatch.setattr(config, "PROTECTION_LEVEL", "NONE") monkeypatch.setenv("PROTECTION_LEVEL", "NONE") @pytest.fixture -def fixture_mock_basic_config(monkeypatch: pytest.MonkeyPatch): - # Get APIBasicConfig from file - monkeypatch.setenv("AUTOSUBMIT_CONFIGURATION", os.path.join(FAKE_EXP_DIR, ".autosubmitrc")) +def fixture_mock_basic_config( + monkeypatch: pytest.MonkeyPatch, fixture_gen_rc_sqlite: str +): + """ + Sets a mock basic config for the tests. + """ + # Set the environment variable + monkeypatch.setenv( + "AUTOSUBMIT_CONFIGURATION", os.path.join(fixture_gen_rc_sqlite, ".autosubmitrc") + ) yield APIBasicConfig +# Flask app fixtures + + @pytest.fixture def fixture_app(fixture_mock_basic_config): app = create_app() @@ -46,3 +71,97 @@ def fixture_client(fixture_app: Flask): @pytest.fixture def fixture_runner(fixture_app: Flask): return fixture_app.test_cli_runner() + + +# Database fixtures + + +@pytest.fixture +def fixture_pg_db(monkeypatch: pytest.MonkeyPatch, fixture_mock_basic_config): + """ + This fixture sets up a PostgreSQL database for testing purposes. + """ + # Apply patch BasicConfig + monkeypatch.setattr(APIBasicConfig, "read", custom_return_value()) + monkeypatch.setattr(APIBasicConfig, "DATABASE_BACKEND", "postgres") + monkeypatch.setattr( + APIBasicConfig, + "DATABASE_CONN_URL", + os.environ.get("PYTEST_DATABASE_CONN_URL", DEFAULT_DATABASE_CONN_URL), + ) + + # Mock the session + MockSession = scoped_session( + sessionmaker( + bind=create_engine( + os.environ.get("PYTEST_DATABASE_CONN_URL", DEFAULT_DATABASE_CONN_URL) + ) + ) + ) + monkeypatch.setattr(session, "Session", custom_return_value(MockSession)) + + # Copy files from FAKEDIR to the temporary directory except .db files + with tempfile.TemporaryDirectory() as tempdir: + # Copy all files recursively excluding .db files + + # Patch the LOCAL_ROOT_DIR + monkeypatch.setattr(APIBasicConfig, "LOCAL_ROOT_DIR", tempdir) + + +# Fixtures + + +@pytest.fixture(scope="session") +def fixture_temp_dir_copy(): + """ + Fixture that copies the contents of the FAKE_EXP_DIR to a temporary directory with rsync + """ + with tempfile.TemporaryDirectory() as tempdir: + # Copy all files recursively + os.system(f"rsync -r {FAKE_EXP_DIR} {tempdir}") + yield tempdir + + +@pytest.fixture(scope="session") +def fixture_gen_rc_sqlite(fixture_temp_dir_copy: str): + """ + Fixture that generates a .autosubmitrc file in the temporary directory + """ + rc_file = os.path.join(fixture_temp_dir_copy, ".autosubmitrc") + with open(rc_file, "w") as f: + f.write( + "\n".join( + [ + "[database]", + f"path = {fixture_temp_dir_copy}", + "filename = autosubmit.db", + "backend = sqlite", + "[local]", + f"path = {fixture_temp_dir_copy}", + "[globallogs]", + f"path = {fixture_temp_dir_copy}/logs", + "[historicdb]", + f"path = {fixture_temp_dir_copy}/metadata/data", + "[structures]", + f"path = {fixture_temp_dir_copy}/metadata/structures", + "[historiclog]", + f"path = {fixture_temp_dir_copy}/metadata/logs", + "[graph]", + f"path = {fixture_temp_dir_copy}/metadata/graph", + ] + ) + ) + yield fixture_temp_dir_copy + + + +@pytest.fixture(scope="session") +def fixture_temp_dir_copy_exclude_db(): + """ + Fixture that copies the contents of the FAKE_EXP_DIR to a temporary directory with rsync + and exclues .db files + """ + with tempfile.TemporaryDirectory() as tempdir: + # Copy all files recursively excluding .db files + os.system(f"rsync -r --exclude '*.db' {FAKE_EXP_DIR} {tempdir}") + yield tempdir diff --git a/tests/custom_utils.py b/tests/custom_utils.py index 9148a98..b45b868 100644 --- a/tests/custom_utils.py +++ b/tests/custom_utils.py @@ -1,4 +1,5 @@ from http import HTTPStatus +from sqlalchemy import Connection, text def dummy_response(*args, **kwargs): @@ -10,3 +11,25 @@ def custom_return_value(value=None): return value return blank_func + + +def setup_pg_db(conn: Connection): + """ + Resets database by dropping all schemas except the system ones and restoring the public schema + """ + # Get all schema names that are not from the system + results = conn.execute( + text( + "SELECT schema_name FROM information_schema.schemata WHERE schema_name NOT LIKE 'pg_%' AND schema_name != 'information_schema'" + ) + ).all() + schema_names = [res[0] for res in results] + + # Drop all schemas + for schema_name in schema_names: + conn.execute(text(f'DROP SCHEMA IF EXISTS "{schema_name}" CASCADE')) + + # Restore default public schema + conn.execute(text("CREATE SCHEMA public")) + conn.execute(text("GRANT ALL ON SCHEMA public TO public")) + conn.execute(text("GRANT ALL ON SCHEMA public TO postgres")) diff --git a/tests/experiments/.autosubmitrc b/tests/experiments/.autosubmitrc deleted file mode 100644 index 4b894ee..0000000 --- a/tests/experiments/.autosubmitrc +++ /dev/null @@ -1,22 +0,0 @@ -[database] -path = ./tests/experiments/ -filename = autosubmit.db -backend = sqlite - -[local] -path = ./tests/experiments/ - -[globallogs] -path = ./tests/experiments/logs - -[historicdb] -path = ./tests/experiments/metadata/data - -[structures] -path = ./tests/experiments/metadata/structures - -[historiclog] -path = ./tests/experiments/metadata/logs - -[graph] -path = ./tests/experiments/metadata/graph \ No newline at end of file diff --git a/tests/test_config.py b/tests/test_config.py index 64b1245..a4c8341 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -6,39 +6,10 @@ from autosubmit_api.config.basicConfig import APIBasicConfig from autosubmit_api.config.config_common import AutosubmitConfigResolver from autosubmit_api.config.ymlConfigStrategy import ymlConfigStrategy -from tests.conftest import FAKE_EXP_DIR from tests.custom_utils import custom_return_value -class TestBasicConfig: - def test_api_basic_config(self, fixture_mock_basic_config): - APIBasicConfig.read() - - assert os.getenv("AUTOSUBMIT_CONFIGURATION") == os.path.join( - FAKE_EXP_DIR, ".autosubmitrc" - ) - assert APIBasicConfig.LOCAL_ROOT_DIR == FAKE_EXP_DIR - assert APIBasicConfig.DB_FILE == "autosubmit.db" - assert APIBasicConfig.DB_PATH == os.path.join( - FAKE_EXP_DIR, APIBasicConfig.DB_FILE - ) - assert APIBasicConfig.AS_TIMES_DB == "as_times.db" - assert APIBasicConfig.JOBDATA_DIR == os.path.join( - FAKE_EXP_DIR, "metadata", "data" - ) - assert APIBasicConfig.GLOBAL_LOG_DIR == os.path.join(FAKE_EXP_DIR, "logs") - assert APIBasicConfig.STRUCTURES_DIR == os.path.join( - FAKE_EXP_DIR, "metadata", "structures" - ) - assert APIBasicConfig.HISTORICAL_LOG_DIR == os.path.join( - FAKE_EXP_DIR, "metadata", "logs" - ) - - assert APIBasicConfig.GRAPHDATA_DIR == os.path.join( - FAKE_EXP_DIR, "metadata", "graph" - ) - class TestConfigResolver: def test_simple_init(self, monkeypatch: pytest.MonkeyPatch): # Conf test decision @@ -61,7 +32,7 @@ class TestConfigResolver: class TestYMLConfigStrategy: def test_exclusive(self, fixture_mock_basic_config): wrapper = ymlConfigStrategy("a007", fixture_mock_basic_config) - assert True == wrapper.get_exclusive(JobSection.SIM) + assert True is wrapper.get_exclusive(JobSection.SIM) wrapper = ymlConfigStrategy("a003", fixture_mock_basic_config) - assert False == wrapper.get_exclusive(JobSection.SIM) + assert False is wrapper.get_exclusive(JobSection.SIM) diff --git a/tests/test_endpoints_v3.py b/tests/test_endpoints_v3.py index e38c75b..429fe0e 100644 --- a/tests/test_endpoints_v3.py +++ b/tests/test_endpoints_v3.py @@ -14,7 +14,6 @@ class TestLogin: def test_not_allowed_client( self, fixture_client: FlaskClient, - fixture_mock_basic_config: APIBasicConfig, monkeypatch: pytest.MonkeyPatch, ): monkeypatch.setattr(APIBasicConfig, "ALLOWED_CLIENTS", []) @@ -28,7 +27,6 @@ class TestLogin: def test_redirect( self, fixture_client: FlaskClient, - fixture_mock_basic_config: APIBasicConfig, monkeypatch: pytest.MonkeyPatch, ): random_referer = str(f"https://${str(uuid4())}/") diff --git a/tests/test_fixtures.py b/tests/test_fixtures.py new file mode 100644 index 0000000..83e21c2 --- /dev/null +++ b/tests/test_fixtures.py @@ -0,0 +1,57 @@ +import os + +from autosubmit_api.config.basicConfig import APIBasicConfig + + +class TestFixtures: + def test_fixture_temp_dir_copy(self, fixture_temp_dir_copy: str): + """ + Test if all the files are copied from FAKEDIR to the temporary directory + """ + FILES_SHOULD_EXIST = [ + "a003/conf/minimal.yml", + "metadata/data/job_data_a007.db", + ] + for file in FILES_SHOULD_EXIST: + assert os.path.exists(os.path.join(fixture_temp_dir_copy, file)) + + def test_fixture_gen_rc_sqlite(self, fixture_gen_rc_sqlite: str): + """ + Test if the .autosubmitrc file is generated and the environment variable is set + """ + rc_file = os.path.join(fixture_gen_rc_sqlite, ".autosubmitrc") + + # File should exist + assert os.path.exists(rc_file) + + def test_mock_basic_config(self, fixture_mock_basic_config: str, fixture_gen_rc_sqlite: str): + rc_file = os.path.join(fixture_gen_rc_sqlite, ".autosubmitrc") + # Environment variable should be set and should point to the .autosubmitrc file + assert 'AUTOSUBMIT_CONFIGURATION' in os.environ and os.path.exists(os.environ['AUTOSUBMIT_CONFIGURATION']) + assert os.environ['AUTOSUBMIT_CONFIGURATION'] == rc_file + + # Reading the configuration file + APIBasicConfig.read() + assert APIBasicConfig.GRAPHDATA_DIR == f"{fixture_gen_rc_sqlite}/metadata/graph" + assert APIBasicConfig.LOCAL_ROOT_DIR == fixture_gen_rc_sqlite + assert APIBasicConfig.DATABASE_BACKEND == "sqlite" + assert APIBasicConfig.DB_DIR == fixture_gen_rc_sqlite + assert APIBasicConfig.DB_FILE == "autosubmit.db" + + def test_fixture_temp_dir_copy_exclude_db( + self, fixture_temp_dir_copy_exclude_db: str + ): + """ + Test if all the files are copied from FAKEDIR to the temporary directory except .db files + """ + FILES_SHOULD_EXIST = [ + "a003/conf/minimal.yml", + ] + FILES_SHOULD_EXCLUDED = ["metadata/data/job_data_a007.db"] + for file in FILES_SHOULD_EXIST: + assert os.path.exists(os.path.join(fixture_temp_dir_copy_exclude_db, file)) + + for file in FILES_SHOULD_EXCLUDED: + assert not os.path.exists( + os.path.join(fixture_temp_dir_copy_exclude_db, file) + ) -- GitLab From 8e847bb04a7b5a7824a57b2245d1c4145b016ff7 Mon Sep 17 00:00:00 2001 From: Luiggi Tenorio Date: Wed, 22 May 2024 17:12:35 +0200 Subject: [PATCH 2/3] add parametrized sqlite/pg fixture --- .../database/adapters/experiment_run.py | 4 +- autosubmit_api/database/tables.py | 7 + tests/conftest.py | 162 ++++++++++----- tests/custom_utils.py | 35 ---- .../metadata/data/job_data_a007.db | Bin 0 -> 356352 bytes tests/test_auth.py | 2 +- tests/test_config.py | 2 +- tests/test_database.py | 2 +- tests/test_endpoints_v4.py | 2 +- tests/test_fixtures.py | 65 +++++- tests/utils.py | 193 ++++++++++++++++++ 11 files changed, 381 insertions(+), 93 deletions(-) delete mode 100644 tests/custom_utils.py create mode 100644 tests/utils.py diff --git a/autosubmit_api/database/adapters/experiment_run.py b/autosubmit_api/database/adapters/experiment_run.py index 541fc7d..aaed948 100644 --- a/autosubmit_api/database/adapters/experiment_run.py +++ b/autosubmit_api/database/adapters/experiment_run.py @@ -15,14 +15,14 @@ class ExperimentRunDbAdapter: schema=expid, ) - def get_last_run(self) -> Optional[Dict[str,str]]: + def get_last_run(self) -> Optional[Dict[str, str]]: """ Gets last run of the experiment """ with self.table_manager.get_connection() as conn: row = conn.execute( select(self.table_manager.table) - .order_by(tables.ExperimentRunTable.run_id.desc()) + .order_by(tables.experiment_run_table.c.run_id.desc()) .limit(1) ).one_or_none() diff --git a/autosubmit_api/database/tables.py b/autosubmit_api/database/tables.py index 38dd5f4..c28b56e 100644 --- a/autosubmit_api/database/tables.py +++ b/autosubmit_api/database/tables.py @@ -5,11 +5,15 @@ from autosubmit.database.tables import ( ExperimentTable, experiment_run_table, JobDataTable, + ExperimentStructureTable, + table_change_schema, ExperimentStatusTable, JobPackageTable, WrapperJobPackageTable, ) +table_change_schema = table_change_schema + ## SQLAlchemy ORM tables class DetailsTable(BaseTable): """ @@ -59,3 +63,6 @@ wrapper_job_package_table: Table = WrapperJobPackageTable.__table__ # Job Data TABLES job_data_table: Table = JobDataTable.__table__ experiment_run_table: Table = experiment_run_table + +# Structure TABLES +experiment_structure_table: Table = ExperimentStructureTable.__table__ diff --git a/tests/conftest.py b/tests/conftest.py index e871f58..5b8be99 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,15 +3,14 @@ import os import tempfile +from typing import Tuple from flask import Flask import pytest from autosubmit_api.app import create_app from autosubmit_api.config.basicConfig import APIBasicConfig from autosubmit_api import config -from tests.custom_utils import custom_return_value -from autosubmit.database import session -from sqlalchemy.orm import scoped_session, sessionmaker -from sqlalchemy import create_engine +from tests import utils +from sqlalchemy import Engine, create_engine FAKE_EXP_DIR = "./tests/experiments/" DEFAULT_DATABASE_CONN_URL = ( @@ -35,17 +34,12 @@ def fixture_disable_protection(monkeypatch: pytest.MonkeyPatch): monkeypatch.setenv("PROTECTION_LEVEL", "NONE") -@pytest.fixture -def fixture_mock_basic_config( - monkeypatch: pytest.MonkeyPatch, fixture_gen_rc_sqlite: str -): +@pytest.fixture(params=["fixture_sqlite", "fixture_pg"]) +def fixture_mock_basic_config(request: pytest.FixtureRequest): """ Sets a mock basic config for the tests. """ - # Set the environment variable - monkeypatch.setenv( - "AUTOSUBMIT_CONFIGURATION", os.path.join(fixture_gen_rc_sqlite, ".autosubmitrc") - ) + request.getfixturevalue(request.param) yield APIBasicConfig @@ -73,42 +67,7 @@ def fixture_runner(fixture_app: Flask): return fixture_app.test_cli_runner() -# Database fixtures - - -@pytest.fixture -def fixture_pg_db(monkeypatch: pytest.MonkeyPatch, fixture_mock_basic_config): - """ - This fixture sets up a PostgreSQL database for testing purposes. - """ - # Apply patch BasicConfig - monkeypatch.setattr(APIBasicConfig, "read", custom_return_value()) - monkeypatch.setattr(APIBasicConfig, "DATABASE_BACKEND", "postgres") - monkeypatch.setattr( - APIBasicConfig, - "DATABASE_CONN_URL", - os.environ.get("PYTEST_DATABASE_CONN_URL", DEFAULT_DATABASE_CONN_URL), - ) - - # Mock the session - MockSession = scoped_session( - sessionmaker( - bind=create_engine( - os.environ.get("PYTEST_DATABASE_CONN_URL", DEFAULT_DATABASE_CONN_URL) - ) - ) - ) - monkeypatch.setattr(session, "Session", custom_return_value(MockSession)) - - # Copy files from FAKEDIR to the temporary directory except .db files - with tempfile.TemporaryDirectory() as tempdir: - # Copy all files recursively excluding .db files - - # Patch the LOCAL_ROOT_DIR - monkeypatch.setattr(APIBasicConfig, "LOCAL_ROOT_DIR", tempdir) - - -# Fixtures +# Fixtures sqlite @pytest.fixture(scope="session") @@ -154,6 +113,16 @@ def fixture_gen_rc_sqlite(fixture_temp_dir_copy: str): yield fixture_temp_dir_copy +@pytest.fixture +def fixture_sqlite(fixture_gen_rc_sqlite: str, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv( + "AUTOSUBMIT_CONFIGURATION", os.path.join(fixture_gen_rc_sqlite, ".autosubmitrc") + ) + yield fixture_gen_rc_sqlite + + +# Fixtures Postgres + @pytest.fixture(scope="session") def fixture_temp_dir_copy_exclude_db(): @@ -165,3 +134,100 @@ def fixture_temp_dir_copy_exclude_db(): # Copy all files recursively excluding .db files os.system(f"rsync -r --exclude '*.db' {FAKE_EXP_DIR} {tempdir}") yield tempdir + + +@pytest.fixture(scope="session") +def fixture_gen_rc_pg(fixture_temp_dir_copy_exclude_db: str): + """ + Fixture that generates a .autosubmitrc file in the temporary directory + """ + rc_file = os.path.join(fixture_temp_dir_copy_exclude_db, ".autosubmitrc") + conn_url = os.environ.get("PYTEST_DATABASE_CONN_URL", DEFAULT_DATABASE_CONN_URL) + with open(rc_file, "w") as f: + f.write( + "\n".join( + [ + "[database]", + f"path = {fixture_temp_dir_copy_exclude_db}", + "backend = postgres", + f"conn_url = {conn_url}", + "[local]", + f"path = {fixture_temp_dir_copy_exclude_db}", + "[globallogs]", + f"path = {fixture_temp_dir_copy_exclude_db}/logs", + "[historicdb]", + f"path = {fixture_temp_dir_copy_exclude_db}/metadata/data", + "[structures]", + f"path = {fixture_temp_dir_copy_exclude_db}/metadata/structures", + "[historiclog]", + f"path = {fixture_temp_dir_copy_exclude_db}/metadata/logs", + "[graph]", + f"path = {fixture_temp_dir_copy_exclude_db}/metadata/graph", + ] + ) + ) + yield fixture_temp_dir_copy_exclude_db + + +@pytest.fixture +def fixture_pg_db(fixture_gen_rc_pg: str): + """ + This fixture cleans and setup a PostgreSQL database for testing purposes. + """ + conn_url = os.environ.get("PYTEST_DATABASE_CONN_URL", DEFAULT_DATABASE_CONN_URL) + engine = create_engine(conn_url) + + with engine.connect() as conn: + utils.setup_pg_db(conn) + conn.commit() + + yield (fixture_gen_rc_pg, engine) + + with engine.connect() as conn: + utils.setup_pg_db(conn) + conn.commit() + + +@pytest.fixture +def fixture_pg_db_copy_all(fixture_pg_db: Tuple[str, Engine]): + """ + This fixture recursively search all the .db files in the FAKE_EXP_DIR and copies them to the test database + """ + engine = fixture_pg_db[1] + # Get .db files absolute paths from the FAKE_EXP_DIR recursively + all_files = [] + for root, dirs, files in os.walk(FAKE_EXP_DIR): + for filepath in files: + if filepath.endswith(".db"): + all_files.append(os.path.join(root, filepath)) + + for filepath in all_files: + # Infer which type of DB is this + if "metadata/structures" in filepath: + utils.copy_structure_db(filepath, engine) + elif "metadata/data" in filepath: + utils.copy_job_data_db(filepath, engine) + elif "metadata/graph" in filepath: + utils.copy_graph_data_db(filepath, engine) + elif "autosubmit.db" in filepath: + utils.copy_autosubmit_db(filepath, engine) + elif "as_times.db" in filepath: + utils.copy_as_times_db(filepath, engine) + elif "pkl/job_packages" in filepath: + utils.copy_job_packages_db(filepath, engine) + + yield fixture_pg_db + + +@pytest.fixture +def fixture_pg( + fixture_pg_db_copy_all: Tuple[str, Engine], monkeypatch: pytest.MonkeyPatch +): + """ + This fixture cleans and setup a PostgreSQL database for testing purposes. + """ + monkeypatch.setenv( + "AUTOSUBMIT_CONFIGURATION", + os.path.join(fixture_pg_db_copy_all[0], ".autosubmitrc"), + ) + yield fixture_pg_db_copy_all[0] diff --git a/tests/custom_utils.py b/tests/custom_utils.py deleted file mode 100644 index b45b868..0000000 --- a/tests/custom_utils.py +++ /dev/null @@ -1,35 +0,0 @@ -from http import HTTPStatus -from sqlalchemy import Connection, text - - -def dummy_response(*args, **kwargs): - return "Hello World!", HTTPStatus.OK - - -def custom_return_value(value=None): - def blank_func(*args, **kwargs): - return value - - return blank_func - - -def setup_pg_db(conn: Connection): - """ - Resets database by dropping all schemas except the system ones and restoring the public schema - """ - # Get all schema names that are not from the system - results = conn.execute( - text( - "SELECT schema_name FROM information_schema.schemata WHERE schema_name NOT LIKE 'pg_%' AND schema_name != 'information_schema'" - ) - ).all() - schema_names = [res[0] for res in results] - - # Drop all schemas - for schema_name in schema_names: - conn.execute(text(f'DROP SCHEMA IF EXISTS "{schema_name}" CASCADE')) - - # Restore default public schema - conn.execute(text("CREATE SCHEMA public")) - conn.execute(text("GRANT ALL ON SCHEMA public TO public")) - conn.execute(text("GRANT ALL ON SCHEMA public TO postgres")) diff --git a/tests/experiments/metadata/data/job_data_a007.db b/tests/experiments/metadata/data/job_data_a007.db index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..7fcd6487e0efd128f66e1889e268c268079f8b1b 100755 GIT binary patch literal 356352 zcmeEv37lL-oqu05({uE6LK1R7$S|A{lJq%O0;Dt3NgC#&rzZyyn#rVdz+@(wnM5Qi z%%FJUiDKe?EH1c$$0~cR|Qnm_4u#u>bf55|5v{{Ue$Z`YIier2y1e(mbfsnQ#N@$|>6Ub0K_D0mY;0)>1nT}L5D09C#Q(zZ zj|l$){|`bsMf$G!Cmdt#(U-?!2Ld(G&jn(iiXE;$HP!+Dw11WX%YbFTGGH073|I#K zw+!^v)Gj@1W$?g(@jaF63O(gZ2R4=abA9=1CU%$ij7*Qzb{F%xQog0o-;>|bB2{P^ z=x^bb&L{sEA30cA{gPGTx}_^u24B5!dSv%lg;(JJkFcYaa+`Yd;^!^rH?-j2fjw}Z zQhrOm*wQ~xYUv;D?QIz>7W#6|I7VN-zjPt{(dfjH@#)H>=a;;k zUt3ElzXOiRj>}6&Co3b^C zr;kkOuWEAQIn&QQRPp`o_~;?{shda+_&FnEW20jequ1&Wet5#?td;S~SKQL(#nx@7Xs$4fc zIf9K}|MT(ji9HoB!7V-c&AH*;QcHp#X!6LobRM4DH*#HZXs87qt6tF>o!_n%zs?=4 zR8?={$h7`CE0aFNLIZ-vN!pjYkvP}EuXc3*fw4W46?*%4Ca1Ry=RU+v&&!7U3zrY) z&u35Nh5YGT&7RiP>ms#FyH^GS*x^ha9s?(;JaS}u0>77eM^R4lf7L(_7bU&iV5oNK z1s4Q&V#mUMI8`}(q%uBQaeWOd-Hfa7e9}=}2)|tY!meQ5($%YjFIk7jtXy}fGI`)& zWqi5}cTW8lQjVyWiSA3i-_FgQy9@hYbVBp!W%w-`B@b5l$T)Of{tsSx>cI8>eaz^@ z!9!ye(s8<*5I@QE#PrCRZ|UBV17n6C!(BacV0@qN&w=T2Q#tLLO_w)yWa>}_Y@Qc@ z_Zc`?nI0jw5ZU7Gn6%4uoVfCVZSmj5e;xlt{HO6B#vhM=JO0n{e~f=Q{`vT)<0s?y z$3Gl@fBe1i+vD$uzd3$e{I&60;xCEc6n{bdx$)`vMEsg~C4O~$SA1Li@_282OMFxO zqIgF<9bX$?9X~t1D!wAVB)%Zt7_X(LBB!%|mI2FvWxz6E8L$jk1}p=X0n318z%pPN z`13L_oQCrarQoBOgpa`leDtq_kKVQLap@ZP*m@y+Y`FkF^3Q^gp4ITN>3sOeod+M6 zoC_Zpp93Ekoeduw&VrBit?{&Kg)n+ zz%pPNunbrRECZGS%YbFTGGH073|I#KvKcrf7+xJzH60naVwXfe77DKnaaBt2Fc}m$ zHGUI8w%s-V|Bd)pVN~EVF#CUB{6q2g#oq&S|8I)FKK`osOX&~DkL;giz%pPNunbrR zECZGS%YbFTGGH073|Iy%1JyE6njM9zm(LdAvu}12d=_Sj9%1t=Q4w^{5+8o!ESa(I zoSB5rwwV$5OwU{npNW|qd|o(1qzmWF5Wzv~43P<}nhC<^>4)KCIjMarseB5l+e`!w zjVc3P53 zz+W=}2U0Fc^%IzYoS!iBx(` zGO;GrwkFx$l}L3#2G43=lj!V9CA(6oO4kj~tE}Ji%Yzf+)B7XQ=&{!Bf&R^fEv;QG z$6Cp{&_lzU`U<7;wtR7@FwhU#t(monwVm143tL+Iayz!?3Z+8-7FgbT2+C3k$Sn<& za=miKP;PU+v~#F8pC2q0`tndON$M5zrD7r1OMaJlJ|3;m3;#aW3TsIhHW#{cB{(4| z-8(WiRlx(pm+>QGW2E*_X`q#mN5P*zhkh_Lw-j3DDu0lgWb7e_g0bx3ptOBwUS>ztvoE14GUxAk9uIy?%u)> z`O?ZeFQg*hw+SdB+0d2YRcsyZzig;*MIKh}#p+10QUbLzaEkunzHY1x-&61%zd&b5 z4uj7;vqYa1*MT$=Bu7smcw=3IX6?6TR6*;$ipby#wzeIi((pm<3hI{kK1gQ_VzI$tV2--+* zzAO&K@5j(KaysV{ZD$6Cq1%KUJo zdGk|X0cojaITtd@L*2!~U};FOiAOHxi^KGC`M-F}#P4K{ciuFxJ~=X8r!@GD-^qE& zHAwkur}iVey}8onfnuL1C7E#6e&CPfO~9heB&#Ps2u&j2-(5hakCD5X>*;}KaiAZ@ z**W%ISCRsGd|f%eT)-~CePH<-$#0)e4!Awn+uIEne;K)?M4~H!y@BR3eJ_c&ZhC9T zLEyXJ1;`gEu0T2$UFw{WqQ5NPYZxjJnw@Ka?|#?7E|*}3T5FQJ>%i5Y{VxOBUw3|J zXaM_&4mgnSbqx++_u^a_$?&@}1_E!3L<(;w^Q5G^H=m;|oHtR-@jE%c(kJQcCFp2| zHs?t{=YZ##>>37j4~VDH3LNe$a~g}DesYKTDd@8cn;RY0k`!aZ8yn~$@J2H8Y z-0!XQezhJPPp;iPHM+JkMRHkx13zhn%yf$64G$qWV#44XEayIq-N2s8?j!q1IrKU? z(p&Zvu&-aY@6g_)Vz@W?{JjBQk92>3ng+*2#(wp-VVzB13hzG?EG$nnN%Yb z&vlhI4Rz;p#nM*rYLv!uNFfv&Li%GlY8cO_XT%VKZHJy8R0YE-M4-7 znjPI4_2ySbM@C^U1#AM+d-S2*N~0JZ9WgcrSXY1Q!071Sx!Z%g&9WY3-9bE`KswFtQ(uI0PzPV){*@t=oSI%_&YUe%~e%icW83r znsu$uxBh=M4xg=K8ITw_zBT0U|2xjU%m44_%Cr&Rzp{SsUDp4%{(sr}|HL9%|6dwd zu>L=BMy>x(9t@A0ZT){j3wJ)O|Br+7z=+!cncp~u_5azReyjEW`QR7vJFWk3{eQ{l zvH!FTSOzEq$G3*(^8bmmFZut;L{~BcssQ5qSJofC)cXI{|F`}>3JtpESl;{PuQ{3Z}Ty*UwE-E>;x ziiVx_FO1$;_omvrYHp9*7Jhx`AA^5@3eT{=+0cTPKz)vgxFf5A^-GsV(<>K6qf5iF zC@8x3?F+_Y7)!xL#~3leMNyJtM}*y}u541xN+&?>U0GkbjHN-4?+*6nOZlD<(QhVD z{X!1CCXs~yS}+sx`3XSNjAGS=1Idv8WvnG9O(s(9iOy6ykzPA-WV-swmC4DPnV~q4 zaHd2;Y-vo8uw_dyM@c|^pNbGbr#saw0G;Rrz?ZPpqk_&NXgn0dR!^}}&7*`Rl}!pP z+Cp;e{zzvoX$DlQWK>HWRQY`HOp1z3r>F=3R7&>R$<6KT%C-X_@Z2%A>p+nsQB3_` zr?q9AXaBO}38n#P#|)pwrg>v7 z0n528UEtt}qO{nrX~MywRfZVM_h_0WUVs8R)h0kI&ylvKpn&i*+tlP`pk88Ny@r9L zmVrdd!$3L>=vT0`YJLnb0-moS0MD-wkj&@_(AE?Lc)6Obf#q83+odKA4MIGqS6&&^ z(E+olF67CstUD-$#H9cc%`7c)$S}4BIJW4c`Y+OO0TmP`K+>x1o;3CJJRoH=*E9eR z>m(jx=Qxk{@Nlr5HVawA?%^Qa;mYdlN^}7J5KDDB4&)=UCeTb_%Ox`8WHuono9gJ{ z0xvVW>jCT<8SGgO*rB1|Itmsqhc4&i0tOLB?(VFkD@#O3zh>!oDpqQkqyti@eGgKo zQh|ar-64<~stBMm8?(V^OCYMX!}ZnMVH$Il?#iZJ3=q={F<0MY={q{ORoLML_n+%; zZ#Wfg$@WAhkrh-Zk1@CG?#hIenPMGqpf$t5fu9-Xm@}?-546mH_e(5&O~vds!wc^F z9YRwKErF8GI&T!(LUBK}KxZzl1yEWm44{0hFvpZ~y;`7Q20Wi+>9aaa!U`|A?-K|~ zHnfN+r&%U50Oi9h-S5Du-Uu(a?+xBIsaRIT zoN`_~l>4NuDHuQ-+!*l^Ky5H3KxcGR8_WqvWIY7HTu9dqcd&HV{0P8KX2E?|dGRZT z74awA^$(=Broiu|YBmxEHnb)f*r>t;Q!b!Ga=(uds|-B6nWc9)TvTg<7u=WjQXyGZ zB*L#}($v$_KrQfr5YV8tz(B(s7MN3zaleHmyV4y1|J5wLUPpm!HE09)$WIWpTp)L^ zsjTzPVXC7?OAPQx5U^?uFkm$sU=B6qe%T;48DPGUrI)Hu^#*vsN8XM|HO7_P+Em7Q z(=b#KIAt=3{(nK>(Lj7f^M$dqnie%KXy~gy8a-6^vf4M-ye@K6_{Px3gHJ$({L^y) zU^d*+5(sMFFSwr{_C7n@T1&kanZeC=rR9egveKPhNpKGjzni5ybhndqCUZIZGm{bj z%iyiL9hPp$W_)}Ke$DR6GO)L>_)4OK(w8 zyI(OD&TORMl-CFZlXf0aZJ}thN{7s$&TKpbU}@hm02aPuP*}vAsSw~^Cgewxpn`W} za10N>f~D8!P?00yGq!MMIRz!ZNg$?_^EB$p1SAAJ8(0Ytv~L*z0pBt-?Guf+3s45$ z?A0s)lmX}N@C}e|g!Cc@Q1!1F3vc|Cre!K`4~RP9_yEei($*9l5Q1i7r+X<-eSHlD z>WfA@EWJP!78%(Ap!YNdzTM#|mady01@unec(aB9<^6#$kjeVI`fFfHx6(z0tn*+_!-cX%I5V-6eDzG^JIal3{L=^X;8$T+Tpf=^RV zPXv9}xON2)p?%i?BJf><5+NHj4+%-xm!KphGC;sCmaf*3ARm!6g3~E(xkT=2Gik@U zFxAoHC2t#}%K@$SZ3EEa+Xh7|nRpM_gzRY$uY}DP#W#AW;BU+_!3?io5fLfDGTtTiLR{Q7h~xZ9bVET=d#B;YBkRo zzYIY}q7%m5ecHNTWkSnLX(_N^w8xYMw>_q)VZ2|l$ACJO0K`F-YE{%8d%XP-3Qje- z1Wd|l#M(k}PnE}-L!H@pD!?+@V+xCO`w|2`&Msxqdr8LS_Wp{c-|A42Bhd$R_)-c= zvAG0O!fCC#G69L%<0SyWXpc4R(`}C_pp5MG5_p5q+RwHO*Fv&HcOAsj{#UP#QLpHCwv~_%PaI4IipKrfj5K_83hxF!5EEzV5J5wLLD~p&>)Ivjm`DAEl|M zCxY4IQ-BDgJ*Gs+9lrETQxe>--9SPTrVaOgnx)U{NRW@n8o^Bzw_GCi%P@lIbY-SG zdc0_l7Xn(NJ*H?S6X*g<2HT}9+65%QU)*~yOCMLk`q<;r7K&9mlLA$G_cl}!NM$-~ z{QuK)T*F@VQ^>&atr3&{pFQX9p`!M#4D@q{FRiRU`faQKxB7pp|F`;okH%ZF-(>A2 zyZ)bD|Bo*^*-D%<(J21+s}FLo9oP8c3lQ>5yZ+x?%fR`p*#(i)cKttSQhvb-=C$>V zp$sG{owrY1m(Z^NM;62|Givq!Y#nX8{-3;9pk4p(DfA!hsh{2q9N$_qhyM@rZW%U7 z0|6eeF#zbkV8RNf;*Ng1((~xMt^aTRf9wBS|KIxm@B-^00cKy&&xJC({=e9C!utPq z{eSJw21ciOP6yGh|7X|#)9qGZakOofp2uP={i(Xc z!^3rRg#jcJ>0~;Q^o#4_1Ef?S;#4N;kC~x$K!R6xNJ+pj07?Q%;W$F#z9MKI#g$wm zKtmW?&tpNB=8zp8UPrM>!a{a=QjC_iP$*XvaTZ59bIDpjer6fnfnYu&2qL@gA!g#`o($JL&M2L6hhBbh|D>|eIFbF`? zI3>}cXqkyF9$O{wfCwo8SboIP&m26}iw+NcQPV6XB_X|!&_49BcG{YP0>aO1(}i9J zKyxSmX1W1xYMt~HEr8HG-O+kQCl}Zk4 zE&w{bl0!-dh5%^j0Jq80ypv3TCXtcK0uf(f>7N`js+AlbTCd?kCd=Ai>takT^)D0sp62s!nluXbFWapGtn@6G>;a3sW6E zT;OG9_i6y^6&zBq7yv-QN*VzfD-JR8=u!!5A_+L}WvME`;lw=@s&pa+snod{stBMm z8?)i_0i@RsOY81v;!Ai}PvOEQs~GQt!FMtP%rsZlKL?`IFmJE#lCfoA^p@Bu6!%o=vpCe5jpqU^uO+6i&=ONvq|0~ROA>tNx|hT_1EQ5I!A7(4 zB1$exyzs<-P*9G!WtS}lKI_T^BxGYYa1KE5nqmq8O|hnZN~T!cCK7z-0hEC-jpr<6 z=~M?$^`>~?iH~Snret9$>JDl32IXF9YYGm~7RS!^QlRSpH55p;SfaqO#jK^m!dMW~ z@tg=t_4A_udz*zP4rmyVOe|wSTJ1q$KwDEVpj4&Cxb`gI!)uIblR#sv;X@c>i4DgX z3pPkR-qGK%^aqEHYK`&26BlX75LT8^F{d?l>gkD~*7(|1Ai`^nDG_LmDG_pq?|IA} zYb;10@pwmn%F-`%B*;f(jbJIoEtkmsa);3zGrUkokC&L^(K7+9*Bn!{XpSjb$ppH< zlHfbJ3t$!+fIiC74^*%|`v2o!qgbUgDNs%Jm?4EK0;xJ{@Pvh^t0W;sEWbbYx3+5G(&Z1hqpY%X-;UiGAuZ*W{1fG=e8WKw&mG*HZKaqR?r zEpC3>JutYl%=(nxfi2_^e82gg{N~(nFN#)M^E(C$Jy6z42Doq|r@?Nr88XQln;3dBSze3N%>naOYa@_n1~xVLvJtV2g~y0;GZUp7>@B0t<;Air%z zhLsY?%|Olm;XYa!zNh#vp~ELX!uq*h*z7%rdqPi5kBsjbncRcd=4=&wjPExscB~(t z7{{IBS)FnZ?5bSoM^5p$1ltz8Fz2^+|35;vUH{*%|8Lj-FZ7bWwUv70Q+ECT;&5LE z2(jz`b4x>72W(`YPLV!ycqmWI6EV*4&CdVBfS%G=4k=r`18tR&$?5&1mWMpYsN4Pj z*4h342Dlhx>QLpcW&S@H&IaH=`)3)j3{=Cw@vU{-{SW%O{~7nJBa8-;nYi~?c1>Jj z_5W7?Z}tCH|L>8$OGEtRjn>Bh+xUMQ|4)41sj(xIR{wAP|6;CxXmh@3{y&j`h4zWfv-AICoVrZZA_G0cy?G+KKyMnPAKhDF zAH@DGz4`Jccv(V$1=nK?ZR^K*60TAhE)od{{*Y%C^V#K=_~YbF0G^TQE&3qJ)G5T#04&b9OZcK+Ya|J(V0+IjTliurzs4zTn8qiZYZ z!blU;cK+Y4|0gD>t^c3z?%Te3&5rJj_5Zo1mUI?91(dF?+dpxzvTkg;GCnbRU}D|K zk!e5we_^208~;!C|NCi8ZDd(E9{gkA_fSRso7tFy@Cak9R}@3pomej#lh_6kKwTLT z7m?~*lZ5RbVH1dSW!JH1v9wN&i*Uty1>06Niet0L#1zC|raGiOxSUuo@Wfb|2!UX+ zUK;^|G1iMh;L77NXpPHKtd|4`w$8yVc_51eN+bZx87!UU0IELLE112T;`CmCf;v-0 z+%@F@Y6=c$tk)%83e;FH4FyuHmqbC5?WZRwNOxsA0RAZ~Eu9|)=ywFO&(<&?MR2iu zn|AixQ@GOB6bvX;S*+K^z=tu`OT&kV^^({~IDyx+L8QCVZNNk=ON|a2)y8@SvmF{T zM64I1A~|Q|HEjw|PfrAk^|}a%FvfaOBIIBq@d&`081R_GAc&)Z;3T9nm0j2WmZd-H zNRW@n8bKY!Ete<{K*AV$P4Pk*>?dUC9&x{W(kjp@P-N zdIdADp;)Ce$xz#~&cYIgDgvoY+02^ttZ_%9tAh1Qmqycz7Db~=X+&Er7F{3HNA{~w z6CKc9t69*aC!ld3ouuI_rF>7Q>PFrA?t1LYm4}hF!LGU5pf@C6y)d{|zUh*9Hc7LD zYpt+!je1+CsgY@;ZLsCH^fKiV>Bb~Fq&=2Qb;O-f@M0V6oU09bqVhdpVY4T0hWIm> zfi`$_CrekUV13$P%L$5A-@sr4CueIVLluEkreikT0Vs{}|Fz!czyr)>aBR^XIScxn zP8eu~QP#Jz^e!DdFKp_D+}u2*!6pP5bn` z69OQz?v8xt@Vz$;?}QWAvvh+4Xddr`wI9&5OuPW4SthD`Cj@?=rr>}OG#kr!DNx@D zH56cvE#pNB(#kqnNI??b3Bfa=X>xuPusgj_!+`5eFPrYj_$*JO59nkhx{H^Zz_1 z)Xx7Ga7VOOR3Z>%0SXW#8z=+`qyb6!rtQr%JFB^6`eZc+kX^!B90UEiUZH*1btNf~ z&8u>Lxqw}O`@r%wlHXeWzt#U+{Xcnlt^VJ}|3l}>hnlSZ-_HNr`F~|fpS&0UN%Q|U z{@>r%vn7@R4+F=yHt+!ezWkqS006yx&j0|K&ba>H$aF9Z0I>eQUH^}~-14rQX!?a{ zU&)1WVptc_UDxXWt^W_rXJUL57oYSUl7Q(9GMa#)8Ke!7c^{ue=Hx*N@*5n1kcLDK zRu1m2Op*q>J=fdY4Pz>okt5=K70LCz2H43@xd!;|cMa@v$zY1z|IhCKhbtpm|KIxm z&>vX;pD$2n{eSEKKUw~NA`nh^yd0Zh8L$kv7&zY7$X$QF^xtxgpD(hg>>W1#0ErVQ z!S^j1va$bQ#_$(9_#v35_>+xdTZ zlC1yV-w*3-$n9jFl%V2V85E(Sd!L~hBF-kY-9s{b_-CGH=dP4;0X)+rka0f^bk_pi zl{te&`e7mR91Zs7N}C6YeK!7|-rm+d)1b(QP=6c$-@0z!p}kYes`R{l+WCL$|6Bk6 z$?^Xe1Rf2h~hAYq&0}81Z1x*)s<<3H3{S_*j^Yy(z2C(XTON0m#N_!u4M|EcfCC(j?Du$ zI+LkPn-rTOhotc`5vjpK(gp#7F(i#b-~wsQG6mp!s8bic2D+cfvLm4lwDb!KA$U>WT(g+;o3 z4=U9jGZPJn{)VMHb*RXZ=mXmFZ3;@Yxq!*n9@8=biP__=0KsUFHSJRk5r=z%jZ?M9 z%tQm2H?s5=2hcq1amy{5mZ^k@cP0T@vYHTaUQ=*D?eP{b1*$#PP~fr0Kml1JNWRTz zlwjGQeT!bf(re~N0lU*f8U|eU7#OJ19@CnF0i|KH5b@2xhtVEu_;A@{WJ9&b%tQkd zH?s61hmET3am$4oG88+TOsA`|$F!cF2xgD-K!njAQzGOJU%vlHf@+VE1Xwm`-=b-j zo~t84J|b-dtA0Uo%O!GGo1W7i174`3$BXv32hbYrF-0qxKo3~e9wS&-HfZ0X{VW|+ z!TQ+aRqvo!r86l|=d#Cu6sicMGG(*jZa`_Y$C@Pwc!w|W(ltHE`Ya-g_kv)LcO8Sk zSWH`Wc&o6-!L~;;Fq)cRsV~p7FHv|M1+* zbGq5;|2->3;QN{~{Z8ND?EJr7|KHC4gZ|3S|F;eg<;imH<44BEpwE>ygRt}ecK-j# zng0*M=6`nozo(ZEYOnUGW8iopX7vA=voHJqFtOg&l_4^Nd}ZJ2%dP(pw(O~h0kHl* zx*O~@qHOp7quOI2g|56uMnbIrU&@z<@}=QH(pKDJb4Su`{eP?fr=oo^j^W=}to|QV zD6pwvIalo7TG&RGG&t5;+Bt}88yv!o{6_5Yt7|Noh-{~rO_hEZ=wg)?rwVTO2YSw$M|1*j9grkmCwTQG#NH);0&IYaq2u8i3M&ISp8zP{LqW?!g$pnA_xi=D%?`hQwaPXyB&UI|1P^@fxPSL&~H0TZ6H?Wk`iL>M*GTdQ`TQ1?cGuVYH(UzP`|4-}a@uJ@F*?`ulH>7AK6`%`j z*4wAy{yY2HNjE@JRt4*$H+<1oDOTxB3RJ0wGgJ{sWy)s5y8xw8Z)gzu$#`MZt+OlX z>hcopT}jaYuYQ81pXl&vS~yqCiDo`^XLB|B|1|8$=hN}=LpsdS%7m7g(iOmhQEwJ+ZhMTONf|{&i5*xL?D6U^vGg?^D#spAZKt4Ao10E_WE~Bqu1r8;_IL+C zFxq2H`}Ejj1O%#ErTZnL#PATV{v=DEbpTakj6$>{nrhIrOqV?d)EQ@oEahHlYYGks zL9?;#UJ6uutf9bTkC6gte@?VOKtT%Z@#+t=bpQM)pbSjDK*NB`9s>i?o}CH<+M0p^ zr7Ds&Te}VTFxq1cA1-^0Y)Bh;ayF7+k5}Kp(p?T4&KoDWAXl`KY>y{9HDoCE7^sl; z^i=R^>gkDK_IMbGFxq2EgxulFj{uNBg74%ke9RMIk5|8irFZH`kdH_k!Qp?WxaAVL ztL;cQI&f1RJzgMYX0!xojrN$Ll}wp|(ps zoS}+9D$_9=9s-m`d#njI)yLz}%7m7gQW01%+GENB+6WmUqE_Sa@KMJQQ@G1#ljOR~9(%m&n-rYO zP^c z|I^bCwO9SrFmSvOH~Rm~*;oC4kQpSw|8HMY*|%ZJ`u||rtp4Bn|IiG;jW5G%UtzE` zROW(M>;KE@d%dHr2~+U(y{-T6KQ2M$vz5USQ~>ylj<7)t_n-(LfYIM6-`j$dIm)@| z^p4NT`ISCNXXjHu$_4ODyZ%28WtDlPRB52h^!eOX?aOH$6X?-O`MyD}V8@=UFIUX> zTm3&qu*1V(Al3T+*8i7we1M0^=P@HAhgSdpBMHsPf})f;Lk@aPSZ0@gusBG=v`Y=FyN(i zwssu&FzO98e1I3=Y2gVbK$X}aQoD<9VCjXr7Oqia!Lfx9>+PF0WRNLGxtU8^cmH7DlCIN{ARm!F?r#rL+;WM6IgwsF zabz011XCS7ULa;>bPUiM^@bF!B=PfrO?T2P2`rHuU%Zc{F%_(j-tcYjq*$dh$xz#} zNdeVRMIe>wmRz zZNH-6TzW$QlW~Grw1wiHYN5_-JOHqa_L#!*+G7m9^PnPacf$u+x<`k~vB$UFLqWN0 zZmKiw8;^%&0ur;w`vHQ{9&6gC#vTJ8cW?@tXaI9NOYe06%|rix+lw_V(`{u_zF{fa znt}srkN0^gQ0=jX0*yV!;5*MfCH8p336|bAKMH79eA^BU18yq|3xPYaEJ|zD))WjV zRgtXO+P%Ps(H?8~fF50i4s3XOD~Q{H4VpH*lBL%BtN0;x>LY}Xa|BDxE!cO^RNMyhU1l8MPtW0Q`DeVRpjP{ta zfHp#fh{1Pi7Mf^4{9Kk^px$MVJ-+$hC^)ytO|&PS*fnjTxTi|D#d)#%|EFdS*`D?3 z%fRu%f;r~@ak5{1;7PYmUTcUyxyR1`!|<)0|F`r1&!wC0N2W*jlkVNl{|^jU{r}U~S+rOFjAP(6yxlhnDE zqx+9o|G$3=P$QOq(2s=ZJ#V0vPXQ5jTlvZmJk#p`t^R*#bG}&aALt=t6o$Su$~HN`Wsv^Q{r?4l?*!tjo6m|})HKkz zxuLB-9X(Wcy!Lf9?~9xaeAT^dc!5%BN{S< z-VmuscV-o1mQ?T%+ng#uOCR3R>cbKIsba+XV zbgih(JtMCFU$XJkQ|8kDQ=})Q@S2lFzO8@MW6~1!ql>^xE?^A z2GezJfu(*GwOenv;+4x+5Wi-eXme(L-@SO@2Z8RX- zz>;>O4#yy0ayJF#GP>zx)(Onfl?h19AYTU%jP_X5J~j3j0J-Dw08j#K@ws4=F-e#36GF)~xIcGc`ef&v1JrT?v9|0na_LveOclhcvohHF|t}J4rfq*8K7V1clk4PKA z#h;+K>7?D54nP^{9K zWT;>S#j|dxB9O{-%!a1`rO_U1f=#(?qj-~RQn;N32H&|TASN0n#y-Z9IDm-KSe_m_Owr1296g_@tOY@Ry)y^bUD_fTL-t} zoF~A|3fcL8JO6Lj|EK#N*!h2X>RWm_BXdN!)SG@@SCU!t<>!2-df{DFndK!f|2_(L z`OQyUfpjiM(yhNN-)pez|J(Kd?fU=aT(Ns=VcQ&2>Zy56sT&u~14C<`axdkFO68&9 zLWx`xE^bI>9l3vC=l?wmKVCkZAI7kasmkPmkuf51aNmK-)X3!M{sTuVLdi}z-89sl z&lO8{{vR|;dn&t+?9)-V?~q;pujS7>|8Lj-f7-gD_QIb6296gN&E@|SXI~xggK5?T zkr{+46RXd&{y&&QtN*w9e`p5Q|F{0X)&H}N8R-m;&(I8+dDjie@Zq0%4#;p9PZ=5F znO6Tlm*4CYm<&CdUH>1N6j{gK>i@0(ukK$c#!jsNPsfnPF>J=CFQFT~{2BEBUkSv| zZ$2~D)pS{7t|3u>VRV1pt7~6c^UlbJ!gqw;7`!#`l|Z}O(fW&3{4WHZhEZThl`_Pd zau+Stu_oCD3$1o#g+3m#QXs}_BfGZU^2)1N8r8`($dPc~rtONUC2qxk3e+ZvjI_`! zsm-K0qD*Ks(Efwse*s`HDhxG>E=6G|5b*_NQ)>jGL>r(fvb5bnRE@kYM1k)7F$Jn< zN@U$2LQDHCDyyw2NFWT&#%AJP4pfPuh66=nC^+Ej$qEj#UD*ua-^|iw^W%U~uuMaN zEHR`MNNdb0WNB*(3Y4nI)@$a9$<77{`S&mN3yS2_Xc*RYh* zu^=CjHiPZoqrl}71$4?$!kg;o0RuBLqccqa*Qhe2aPfl5LwVRc&V@GBm2H=@NXG`D zOdWvqOqR}7(dt!(ZQHj~u+o_ntO;jfT|*TCRpw(hJktm;jWR=nzK^4$`?NryR$7Gl zux=pJl>!0$>Kj>lkq$6vm2=1pmuv|xu1fZw$!2`i#lpuT7tP|nNM*vyOlhV8XfWyw zB_*H-c1jF83fM^yldqm;>A5Owx6W|M<}Xonl9UjIznN^>3E0sVio2>j+8irH;?)B# zqg|%BWVOk1sT6RIkYte1zxtmF4qDf zM!T#bM6%0*jaRs2Jo}LMAmur9t zqg|#<$o)QV0fL2;h+GmZBszfsm^{I>LC1o8MA{7UAEUtK5@xF?=(H2ZWU8YFOzm<6 z;2P~Rg)5mt4_esuOYPW*sRpDMu#`~I>h1E9{3|F}=}ZdNloQ`%s3M@se1KgJ15Be` z*2J3fexIXbx0l4;xw43Pt>i@kW_B*KVK|B9GKwYFJb=Q>i^CDo-MTu$P64WEb;aKC1+pq|Jj6VlKB3WTR!_k>;GH- z-}?X7|F`}>d7bp^C&z{>NX)EV|KHgg()$0h^Ko$e|JwC1$Ibg_cA?E${XYyTS^d9_ z|0j>D)&H~ABE}C9I)?rN{r?3Z?|%mA|IY-ShEZoowKBw-f{T_xT?y#_lUXpQ4{6vS(~NRUmzEA! z0BY*#nczCZmB55iXGocl=Q2GkWQg)kWg(db1}ZFFqhmonB5ei_-AaMWB{FDvZE;f_ zJz!u4^#7*=T%*pA!j(YR9CrekUX!Sb7*h66oRyvb{RqEypRRmO- z56~H&1~82}LxaE%0OJM@F#blvwlYLUs&)e;=I3+WEV;zARqG7fSH9}hIraZ!S@%R+ z)=`)$&0Wl~l8Yv>F|0D-1$2fhfCi(^P*MVVV3T+P7i<#S^SO4GwCY*3I(Wm{S6)rg z(TQmJ0yB<=R9h(SsunBg|Ca+Uqg|%Bm|a%yCT?1x-pv$*Dt+z(mRvGfDxVZ~xqW3P zMa6BeU|rg#LRTg*al5<>Fc|H!ripU9tRhN~_!JdUlH4R1AI5Z+^FUT_m)ln^)HF_E zmj&$PTy|NmDM%m;fn8qeUBm9=n)dC$PH9B-uYnR(k z->hMS*;~#UEEd7Uy zR&STvR}4|G(wP*jX=kSoLlps4<^$~VB7kYM%bK`TZsR1tB*K~m;YnHCRKvX3QI?MB z0FzcZhyK59*LyS(sQfYn8zdOpFAUYMGT{a6@+m-r(JoUO&{RkmDeR;i?nPn4mKL|X z@*0*VRM>9)f7`BRicU4X1Wn3m$J#=1SCvPbW3~E!d1lxC+cGdW1IG)en&$ucWWTp# zCuZ!2;BZ}T=l@|S)UN+$*Z+fNVAub%>;KvH|LppILl}q9mn-J`VVt2j+?T-&ErIs{=Wg-oyo|ff%u!_H^lFYZ;W3Z|4IB%{E_B=X?~#j zH_bORzb*c(=DXvInoo`Wra2S)NbDQU`(r<99*p(H*2S)E`dO?w_O{rn*o&K9*tECl zolR#q<(t0Vw5{oZ#*a3>rSY4MyBeR@6l=Vt@mCGMYn9e_#KC`nSQVgYR!xR=*_rU-jwehofJw-xvL1{Xn!gdRz2Z^n&O$(Z$h7 z-B0VjT=)LETkEFkcGq22x4!PIx|-S_)_$S(&f4R(v$d18SJZB(J)<^M^H|MSYVNDK zqvka=Q#Hdioi)p9>LULhc{K7+L2h0Y4q2EQNtTJZkh9l=)z4+o3E?A#urtzj9k4E!Z9 z5RU{`1?k4DWcupxsgd9s_FWnYA6pO!p2@Ot5OuO863np-?YQaGX^~*Nt^&*qjIWFY z*Xzn~!sw7+DNdqHER6)$1!FkFEDyt15D3jDKn@@f`dtFD2Z7LR1ab`mp&42r*CG&2 z1pu-aFI7{9K=%8UB9MIuq{##1N(4gl36K#4LcdEuo{d0gHUim=Kxl>*$S4BQQ~)4X z;iYQI5XjYjr3j>qKpH(jh7kzOCqQ;05c*vLQbHg!8-ZMbKxl>*$Swq;sQ^H>;iYQI z5XcU{QUtOcfi!r4^dS(MPk>yGKJOwFfzS#FWD^3R83afUFI95{1hUbu6oFiVKs3JvAQvMLS|$Uz2!YUi z3CIRKRGk+H9BMrtik8WT>cT_OeCbe~$VsjDP<+O!1394;kdt=ggk}&<+7Pwo2*^p+ zuM|1SAP~)O0Z1Bw&@vfF3W3mk2}lxw)c6BQAP`ysfviIyG=l(Hij%SBgL` zL?D{q0+0(32rZL=JPU!)dE1QPKFay|l~6%fdI2!v)3Am`$xYL0+F&haZnAZH^G z&2It7SqOxd$v|2W2+fy(oQXif{yBlYWOk&K{WNu+)a&6WVvuZl!X0HX0oR0dGj9EnN*YEO?ueF4d`=#J>|_=(_~;y1;o;=AG(#m|U`njdZc za`QdSw>Q7Cd7^o+Io-T4_Q%){V_%PbJoeVu4YA#^jj^?{Q)4wvKW+L_(_KxkX_|;0 zjea%ylIXp4KdgHwT3dHh-QPqv)ZG(3tM1g=|EkN@ez5kDx@&8ns4LdyYtOCSQ}bAD zxb{TtlG>S?Y4FrvSF@ldRrC3po|^YZ-W7Ra+!%a8{G;)2H-4+} z_f4;BYHqr|@nBlu<@eCI~v~H@cf34He4E9+whBqv4(%H|91TYFdFgP z`ZqPC>fhI}yuK-Vy#9h{Df;93k?8O0x9a?vGvHM}bh@_%V-pNK>Q{{SF!0^e!HLlQ z8k%9?JNOL8@zB`#uMY(9xujXjiw_>|IcI{;5<1O$E!=Vj{$(M!IjjaAGVm=p$tspZ zo~Xb#@rpGEp@9C`ub2Y*1_ILjngIIxGH`9!x#TA+@K4ZbtYHPxvlVy*0j)3q`bPvr zYfwO6LqIeK0euy(SaT2x=qrB36wp5)AkD7{pf4jJTBrc}5(1(HGN3OapydWYUqC># z1_ksm0-`wx=<|5RnuAb45BU{SK%YZEnqLz@pG82lPyzHA1VjsDK%YiH%M5@%g@9-c z3g|%uL~{_(C-I6k2cdvI;a5xnokT#IUlTwNARt<(0Qxurq6IRbk0BsUD1&&0K8k>7 z4GQRf1VnQX(0zEtnuAb4_xcr6K=&XZ&94cdk02mgr~vvf0-^;npua~z8V#rb^dSU9 zYfwNRL_jnL0et|kSaT2x=>2}h6wuuWNb_p~=q?0A3l%``LqN1Z2K09b$RPC#ycYpU za=E~r2#Dq&pgZu2H3y-9{?@OU0(uVu()^kLx*Y-0LIu#@ARt;G19~?C(uD4aJM=CD zL~GDH^iBjsa}dxw@QO7Dp@81*S4;uD4FPF>O#rkPQfvkX`U z{#*=TUq_!#@&PO0|jou&W9FY=kpru zL}-TGv%LcQElK7bcr5~=IeI{^LqM7u1kfw-iZz84(5wB5DWF#&An%q6ffpelT7ZCF zihw-35d>a{fM`DLh+l?)XpSDx%Mp;K1_5+4Ua_W-0(yyGF$MHuxXg7yx{SVTYMzgH zXg=bZ!Qlj&A>E+oAslHL{=h84p*eauH=&`?)Brdyz{}N?Q8YKAk<%0-ni~)dwO}%s zBRCX8^9h*eA{P2x!ZM9xJQ@SYAC+SWP2+m<3W4t-9kd3$yN|4#Z8v9w0>f59txB|F`;oyZ)bD|IfSho%9AkaEAkGH|>8A8C7grW5m9hS5x_TT88k zGLWa0J2&P2fu4MM(@=LlS1fIX@Nj&Lc(BMJlwR{?>Y+hYh_s*7fi?%k zCr747_qP(J`v;0WX@yn0{=arV2zQ%hBtA{_|H0wFWbm{={QL2T;(r%^b$mL$BfcTt z5|1?hsQHV{?{B`f`MTz3H*ah{tGPDzdQgZu z2yO5=sLi$v7#YwvEZXX>H5Fc?FQiTKfT?i5zL1{tDt#e6?3MaL`pa$lLi)>LeIfm2 zzrK+EvQJ+~f4Ny-NPn4M5pLIX6J#*_cK@Qc`WL;yzvz|zMK>=G`^dpw;9GLkw`AP6 zWUp_@RlX(Ld`tR$OE&wKTsF@-1ofEeY#e7j1fLO@-&0 z3ePYVo@y#=HWk(y3IqRRD*Uyn@aLw&ADIdtH5LA|sqibN!iP+SpD-2PYbt!dsqk-2 zg>N?%zR^_pYE$7$Ooca|7WP%AUS}vfWGLHjD7)HFw!=_1XeiriDBEZ#%Noko7|PBu zl&v(BEishE3}rR?8%T}Q8dKpproxq`!X>7{n5ocowShkvei`_csqkl}!XKInziTS| zhN$|~F_8ddmgrRJop{#5u z+ioZuFqCaElwD#d%NWWoG?blfC_CLyw%AbCWGIU?glYpX4}LuoIxo}|dR^$c&>utJ z4__M|41Xj1u}F91{K(yrTWhx0Y^XW2rna`Z_6bnTr|N%L|HYbzt^G*tr8VEFnT$Lfxhnk2 z@Xg?P-xb;v{CfN=@jVScYIvyOjSYPbq54uV>i;hG(%5vY9J@4@jkUxYn||H&Sko7q?txVcZfUx% zX|$=osk7-USgqi{8Xt>(EIL^C{krRdC*rp@+y&P*3-JNl8a6gOt6_2d|J47e{%df& z+v{Ile|`O)`u_U%`j-0o=r3W!qOsn^SKtxajt)Ko$G6LMSomd&F5M+$GN`XI@iPgxO(Tp)iTGqKIJ;sgLMxC>fmOX z5uCpyyoUC`(u9BDV{@GAqpow^AAMF8=SpbL_4jj}>qD+{eK7ilD$doRJ=fpOajy5e z&UI(>msOmrTYIi|&vCAI#rp#gN%vIznXfE(c#iTzDoeo$*Y|P%R=&@_Xzv_n9dVuI zYX3t$dyYfxbRBAk|Dj5A9O`n{p$6xuUznpjXMS9dnTw~)#n+mPN6p2%%*8|I;>)xT z$(yygw`y}=q0N2a;;=Uw`gyv{XoOPWGV2$Gy}{XPBM-dO_5FnF`)gg_U*`Hg+Z>i-(XUs(PO4w8QNQk1zh0q! zEvjEHRljahzqYGi*QsC6Q@^fKzb;k3#?`NN=BGUHra8)AH%Ix)=P17^erw=VuVpd+ zD%h#sreFsg;ZL6Fo#R|d*SQkp)$$p(|6daJX)x|HM1x9+nvO5go@`%}gq=rHU0JbP z9@&)y_Q--=0oyC@`N49Q&d_bsK#qj3adm6|l?HvIgpeFOkxaJ34q)kov*C`eOzhEs z{y6OamjDdLeTFpQv^ZnrL6phzEP*JU1vD|1PH_-bzt2$J+CEK8JYE3X4-8?avh8ap zj!b*Q!?iU934|foXK0<519hJv4F@>z$N`JHnid=D2o6#yz#m|#W_}#7d%avk0iIu> zAmg*Qj<%+tz$-a{eTLQoA;x`%G=#uBw1*DJV0+F8I+BUXd%pjlEd94bN45J5)vfuJ zh7BS!CZ}RgK`QNRRiXgY)YCJ8q|IDAvj&(j?lVN0ki)JxWYH)RV&@&k0&I-g0Sx?z zrJw0okdH{4!J0cLaJfVQopScxG1bun24-OYzY77bai1XyS8^p?XkjBMvE7(Jn`r~2 z|H{&1Dq8(MLv?HRQLxgP6s(C3@w^+V2&gh2u+Pv10Moe7kS64wk7)|PeA`yCs~+80 ziQVxuZ01^d$By5y^amYa(kkcJXJ}dGwVKs4_+@Z5QpvVdGUMENrMdf6CcMBtL(c*l zjQb2x8qib-j2t$c*9|+-36Ouv(l1ol?tO-qWe!kuRESQF1rINxNjdjcTPW_T7Ax%k zw;FI6?J~thdVhg|!-8#&R9M7R1Efb;`hgA>ITAL*WtmQjO18ZSD(UP3rz;bfxLrOU zFc|H!rirR{nIa;a@OltsvVi6hmcHpAs@^Uy%Pi6~PQ}_H?1Ya|($*9t5Qe}mpXcR3 zwaXd~RJ+VM$OuCudS!@_d&iE4So+fZIH31B{e2AuinS#az?Q(Gt5O=Qwx*y!sfuiY zT|O5GG1_GfA+lZObfm<~r=TO104hGp(kC1`spOYDfFOCh;3>aT4JgJ>AP6^TNSO|E-y=7OTkKKQm`hSooEeJ1XP(1u*+uw zOru@atQ^B_90$xcB8G0;>B;FGPj;m`ASU)MmOi8dOj_j}b~*O>?`hUd;+MhLz|Gjx zep`R~RVKWEU2X*$jCPsQpx9*r+u7SwY%HH_1IX`W={+iJw_T2X{v<^wn_jxLytH|z zwou$v<V0mw?wJoK>UkbArTA741a#{b*+f3b?l)S=4Mfzi>uj5aJAT5T{{`Go;&o zlV~=Vif;QXpSW1F@uVC*NMR>^ z0?V{D1qp;9h%>yz%YhnasNq0~Gh`fOy0RjqOmL8B1N`kQZI~Yi^j`nqKQt7`(SwwN zgcD}Q1?**zte3Sl1qDh~9%pzl5Mqoo)DQx?cDY5viWZr!jEFg7baX&e7Eo~kO9_XL zYU2!>fAB>O8&3Qna*@e8VQ30KO+7smJkIbUV8R$@NSTo1*gPzx#RiRng-j9{SjE!W zIu_(3vSu(%fy*T<+KhtEq{X9es-p)C%s~AA27qgfGo*0wg33er((s@MZBm583$&?D zKza&GOI5V`IK$@eeT9OR&ZJ;XI}z`ODgvs^2gDh!2bjhI=0GGM-Q0VVBI~T(VyGgZ%6x!bP6JG%UDm8S#cdo1%vLubi}3I)2AG&? zSci+JHXYz9>~h_@jAjKYei@7nx)@u!I=d_?6JEeBr+@~dU8Xdkso)p|?1bAc6H^Va z&td6VDr~o1u3HzT=w#DNFEFLI%c4-+Ri*FZ#Gw8^^uqxBXa6h%&r}9ZoO+sT%Kk$a z@Mo0uLkaIIS*8ot=z!^Z@R1hGd&)jZrna7bA^Y2lxj_iQr?WBaOPR>Mow)z~6O#w7 zpBSHpkbQuNg6=|pnbjog@@h9E+p`iin$U5L^eXomn zwaSIzK!6WB3{skKLoJ#Sp7c?MU#ik&j0TyE8S|QqYIcJ zJx9K~Z~NvoJGwIo?85j`OqJ1*(b38jE}kJRq;+UF=4@q8+34tqk>0ybe~S9L5_s)ZOc#o{0!8Fk{m9;=}cxb8T~#(DPATt8aR>;`~M9B2BXeU zqjgbrh7?g++Gi+5v)ag{_C5FgElcln5LK@;Tz>K;6sV#pQPhJ7t(tv?C<#zgkU-c2 zo#B9&1660J;Xu_Hat@q*h6D$R4B&q=OYfK;2kc&VX(&+CgM@y_L!nQm2-1i)oo~NSK>kOAau#$q6&ZJ;9 z?lVN8LKOj3<_vU(mjO(p&X8_$q&ypZzES|@K~By-Lkut(&8ysfvdGeQ9bj_kIrRVW z$8TO)js8EAPE@(i5XkFkWx@;S3@-&5j5v?R1SW2mw*m&EUDh;F)h=^HQa}zjQ_ub{SzyaXQM~($*9t5Qe}mZ}D=V+GPy~s$J$BB%I-P&H;46cb{x!>Ad-IfW1=u zu}3u&DApD!NIH^eg#v9&L4i`0+vUwbh|w-<2$AhFrGo_FiN~4Kk%Ewrlc%zDnnOpm zb~*moM>T9n_7=JD>!>vK^h|KOoChY1c9}9E_xl1eXCdPV#5oH|2nsn_&r-9F1^I}q z8L)0zF5$L{fbNj`GgBQsU}~3p0Itz4Q@E0~^q@^Sx^Rv*0W(ey{DG#EAr-COF2^5Z zy|#2F1#8C9h#RU1s4^d5m%9O`(JpJ^PIY#f1>w0kfNilz@aaC5#&m$mV>gFgZhqo^ zO#~{x42KN}#?CsMzADeNUuD7z*yT+?gV8Qi8a#HH1>t#MXP~>E*~QY;Dr~o1Zhqp$ z6rIa10~#s5R9h(SsunBN|J(WhXYRzeMTQKVSiI6W|Ia4-#ZyXq;}isFk-2~HlJ?s9 zf3fqQo&V?S+jG&Go&UG<|1DH(FTXw6`F~)5(qi@hp0EsjUsIY0eS@?5|59SeUKNhDTK^wb@5k_6P@LtvOCsQ$26zkIti017 z1_s#pe;fagGHe_F-)jAT@MUfMf9oNu{~sBfsz75f4rLH?!{=!2{(pA=KO6sV_5W_S z6?BFppwlqw45^ZWsM%z1uMO6o?UI&h1fLMHI=a#zjeht_mUio88stbQt68!9!4{4F zzYW%(cLX@H&ag8{%Y;S)-v~hee>Gq*>I^kn7gc9SFCP}vRWx>pCgWMeo#C?qu2E-5;Y!jz588wh^MPn#Rpomg zZei&h6|G)pxZ>3BQn1pQ6s##{O=UwB0afM$bcVYCrcr08S<0Hf56XZckOKfZn_SRF zp6yD*?Y$p*a!jtCoB}3~-5ff@<)3}ls=4+5B=n~};j`TZzh;LzTAARe~2BXf9 z(txJI16x(k0u3;N3HSN_%URl{-ejBLP*kez&1TvpVXUr9VB&Up2VgMT zWlaDx6FxU4MzlOi&e2CJkAteqV-+EU3Yn$O3s`y?-%F%XN>sd_)TT!Ba*5pG!aKPL z%rw=}0|sUwF0TY|jdq#Bl}w=rts0AoXvwST{ZW=;Dq6i=Uj87v%hH(?tWw~mp^AVi z^8t2w2w)oRvL^17_xmouL}lyB0?c5S@B1Q4U)2HDG;)3d(b7NhE=>d~uO+jQk=Rh4 zXTQpX7qH7kpuuRDDGfTitgI{s-97exu-nu<_kDt;HcKH*RQgj~E3z00M9cv54 zUDaZ>`hWKbg8lxCWZ=Z&Rddb%lgWO$b873#P|x`ONadb~-fQRo?fk!;|A(pm!CZ-K z2w~^{^^5JxZ%>N2TQ0Ec|JnI}JO7UYBDTeVUH>13S4g*N*Z;HY|Jn8bMy5yY`hTQ< zEDj8ms#?;I1P1*{>;DC<{{I>2@>)<511A=@82x|d?5qAi%n6gR=ZD@4v!st&|KIxm z*8jKuKiQTKOs#%LGh(pwcKv_N9)vdj-^TyDn}zlNF%-t?|M{j0R{!6+dup^YV*P*X z|HCGSe;)sTVc>#5{QTxKV_i*`HRc);^%q9(tou~$BQ+04?hAi7^o!8);3-fA1TT|w z30|n?M$(vYe=40=lY|}CVLi31D+_c_*~+~SUc}NSRS2OMyqvap{kbT@l+MH?9_d69 z*1FEN3sHojijYRguWTTAIXsu(#h^4%(WTN|$#yj>4PuZ)<=#)!u+*RfT!rA}w5HhE za|%%j8>w`vQ?Q{Gfk@P0WhQtzG?(B-lBB2@0d~TDpJ6M4j>^3!|C^=%QDM6UFQ+wr zhoX};Efh`CxwqOvaaXliL5Mm5xQs$nii^k{U9eJ;teCavG+80=-jhFN>6bcK6qKYD)|ClNh!_Gy#sPy-h^lF#iV&4vehMB)p@SEQ(iuSWC`&(Z5LF|> z3^Aa$Y8t03L`B#sNrb1|Ep1If0$~V*s0Y0qs6tc?2Z|6?aNvm21P7p+zW3xKEPZo+ z954zh8VY22DW$+qji#+BDDbicLew!J#3)475CTd@Pa}7<$$}10Ti<)~A(p=6&{1uG z$Z3sj8a5n(Ddj@avMB&H_4G_|A?mfjgi(k}nUMQ^4-1YunXv#*e>*VnF_u25V?jP5 zYX(sYTrQD2+?1q5Hr3Gsrb5(f0IpGpO5x%K(TxdRXp@roSfI^-T|W7KmOiYa^|8wh zU!h>7GbvalrLm!kfGYEG!^{CdN&4+*bXBl^>C$LyNh}&&8jeMS(P%6dJu~Jk^3UOs zO%Om}N}~997KH~LAKCNd3+E3FZ|W&t z+Z7iCmjl5SB-lh;5ZQj@3#YSmro(sj-R_E2SJCTLo*ua~g-tlxvK>-P(%d!0-C*7B zJ}(Dqx2xem>2?JNPEeBIfNVv^50$_) zy4}4%h_Tz%5F&NEj1D3-^fU>wRnr&3EJYnUoVH4?$Q7~#$L6Z&6=&S4VZ-T8DHjRn zjYi}ySIX{7D=_^)h zB2alPnGJ}^t;!w3Wz|A z`A;Z1+2f%XnD&j!gV=H?6n9m5v^gu7WO-Y@I8+$uClbEQ+QiyqB1sfE zuz%_H90-N`w~&?3pfHty+|ocP*DGfXO_JBNDn`N2}5kIV`rNxfpeR4n9r$?p6*d>Tb0s(&~>_5VFq|KAO-lb1o)(25QT z=~#Sa5qAFH&i~u_|8lO_y|u8-Ws~jte|G)9tt|Y*&i_w|eF(sw!25G~d#<-vSei^n zR|2NSp~$ZP_vEbq7YzMd0RFRomVsw511A=>@&N!cVFZ7k0RZCd^8o-c9E-CuWG3w0 zEC9gz|E<>lxBkEN|E>S;yp>u1pA3E|FRF7>f|EHouu8VZAj2qhddKenXZQb`8eLn# zVF6}oNb3OC(C+`2>mS;jFWUA0?fU=L|F{1CGuUCZ(4JHVPAodh*Z-HCeUJYSaxC!u zi3-;G|91W#wz|U?cI*FJ|KIxm*8lG(GO*T(@zIL)|6yGWGA&me?(YY=8{y?(<>2nh zq`xeLRJZ&8+5P{7e9^}L+xY*zcK*MWi5##y{WGlp_k%#_2T!WAWe;W<_{(A7#Im!w z>mOuO|LZh?c29*ZzC@ul?7WAupo+!+qUbhx;!h;SH@Rl7n9%ppRtW z_grtjzb98D->0TW#`la&?vb~;hx}u>&??#Xo{xx(PmH4nz{dr8@+Dw)AC<#D(3pT>H7LoE`*0cm@>aaUYA6k4D<~5<`E4!3Ph1~Z!HhO z`*&}?EDps(JBGHA(>Yf^TqMyXh?*a=nBP|5VJwIm0|u2}+4cX)-Sic@%h8j_8=u^j z3+(#;cKv_5{=fDAQT1Wh|Ks6{*8jKuzxDqifS3k3{JH#ptN;JYaaQe>{NFHeV$nH# z5Wu7Vr}O{l@w)~AQeA0S`RtP)fYmA6?fgIYcCG%OyUUyr>;Hp-nJyVY+}$$ghb)!B zhZwMcEP7Fvb0MP)uYHBV(vTQU!GVWj9%d5Jf#(0>Z;AN53_t8G&@HpDJ~=X0mzFqE z{BJ3rx!-*5kz64i=K#lEn!n`px;^;D5!^Ax+=_ARAlCn5?mzMMUG6_@ zPLU?AKcxS*8i1$21|S1|i>v@h!FN0VFUt??{J-`8OL@9U02w{;mvmeIU+CQVIMF;Q zvGM=V{qiw5I(FBWE9U!QCA8vjUj_&7_yFtF*pbPDq@&{Nq#Ybju7!a3s-xsDAI=Zs zZ2O+d?j!rik&MnJl!L;@;Hvs8Oa{Lz<@*N9Y|CyOnUp|T|KCWe@s^NLSH5n!jsLgt z|2F>r$%+485coDP_FY+T#0t$rZ7zwWuU6E#;wt_Z&&^g!?)sId0w;Tq12 z&W7gNmBX`FhRf;m?!=P$|LuJXbX`Yzu8t&I&m)mUb{s2?ZRKHGimdb0%TA0e9or|- z3+c$O;0Rehe#ExqNOl@xLTV)~(5APA-V|_Y@1pDW0=K0%K)Hl<3%3v_1rpY6O2R6= zO$oV#N4UI_LP)qY_n-ftnLV@jzqgMFu&_H`nHBAsIs44_&FuNl?D_urA#@=GuX}i2 zraSFu4SZCN<|~KRM+GVDHo~r$TT`MniKGaKl@?WW{037#x=^tYJ6}|eh;SGIz>r}@ zBTDy(!1oV5JSx)!2e^c&9L>!q)|C{bFx?dvlSs|@MjR=uOi?)&%FY*+!-$*V!`Rj9 z4?qO46QZ97WXjvH{ZToZn>z>{9or;sFFOumKI#}FY@zN|HYx`MDZB@8MFuG(Tp}iu z4;I9`>hxuGLl!_1&_ud{gQbIBH8hTZRNvNGSzb|n0KUMu;N()ZkCvjK1?Wj07TW{0RuA-E$c3T z8yPK&;2NgThgL)i_GJlE4ew@uO6lP;8*PYPUh(Gy*7zn1>-p_+XSiKH^Jah<87+%q zBwNphvT;6iB2us~s|yGDXWs>YMS3>}Slrqd5>9=Aik8*9{8b8|%r?VhBa=$$o-e7g z7y=M2>rS8{GFleVfTqHSQA7&%WeHOaaDQ8--?d=}?DF!j5W0Zr%_O~X%og$kSkVV| z`8NPpq+KRlV)pl85Rrm?S;ABUq`PFghl3^VL=Wlm|469(wl|#(x67hTV^Vhc4!{s; zmzg2T+2wR^+HaSIsRlGNGR=F47Gal{RWO4yU~SWxQth&+sY#G_`Babt+b%O4aCSKz z23K>KSq~>$Q&V77MP9)!p9Df8?J`41&@MyZ zZ~q>eFx5cAgiL!qI!d<7O@GO-;j_2tRFa>ln0h=D$}Z0X6OneAm@w_2@3|I{f_+)S zR09KfnT9zQ%sa|q@OA=jmYB11I^l(OkE+8124-CRzYF8NhHK?(WZ-Pmy7TS-i_L!D zQwzT@#s2@x@N4>ZxBu_<|8H{V{}m>k|G(4!!&clPH@ltw-|7FI{@?BYyZwK7J!eT< z?BgF?{6Ba8->=&L-|7Efv`M z`XA0bbnAb&{&(wt?wp84z7oOxSX>IIBO+-}+Qt8K`u|0EHbjc%;X#e|V<^B$wUgr$ z`GRncKtKF|&OTzbdf;SYac&sVjfsR3XxHS{@%~)jSYgM7v7)}PT01;4HjuV@etd9Z zY{+!MnVFqCu(;k|yc14{n~fL?PXF)p|Ndns=>IPT`9`G9@M^y*-?!-VRF$drL{EDX z_J!#fxJM?74`DiBt2aV)hU;ru{oiD0H!__`PrJcO5wVJvZkYNG(EncoFe7z_E#bm@ zAK+AP!oTqCLJiZoqp!K@$cWCTGi+JEA-owT8>wW7noDW#{FMbSX9^3i0~#WA zhC~DYu=2r9_GXj5ERt8AgMP;3>*s*ZaP1!vI{Mir?nU~4P{pOS7>O;^y=rN(6xK7Z zUuXnekvc=d71SAKl7V9kG9;foHx95w+HLnH!SZy5YY!1BpUyCo%z6qdu1sS>#0zB$ z4S*q1XUGguMrR0!Qa(9VhLn@%wgVcG20cWJ&>61nV+N;RXP8N*z30^GEw-j6K`^us zUx)`euyuwE2aL`TI7s@$&%lAuTAte^)9V+<0SV03yuwi6R}W^AsV+U7EehD0ngXk; z)EO?+10j(*Lxzxm&JgGbOj#tmJl6qqh?Mc@C|PH?=1&`2ed*}d2YE(%{JN)yS%1{z#89VVohW{jd(;A4b|iW>~amj zjI_(-erP=#YTma2qZ6K;l@X>IUPymVrY~}U#jVe0msh==d=uGb7;L0_!rWa{oBfpq zFJPCefrd!COf)cd8J+Nau!X4x$bTc#XKdI3yS!=-q4S$walVfp$84eQRhAgA%T<6Y z(k>G&G5gyXz+KDlmxrbrAU!D4$2nNyPV|tjN)sxd?M?SY_~nT*jY-+%rGO#QE;B=v zvCDwS@0W+B8qh$zWK19O5G}$kuWDolr{CJ9yG!=V6E!sn(k?Fva$wtKh6BbfqZ6Lp z(P*jx|9fQmlf`jBeSPIK369{;`b%^ zm!1QE;z#zm{eQRr@Am)Q{=YaBk9O7ag}h(U3mryo?6t&hLM}*G*d;KW9at(rBQGBWuK_$_WxVl{(o*P4}M^phnaaHs0b4|i1~4B zWTYSu6YV%Wd%W0j>|_yy7e{70rcV80_WxsW_`f^<|Ds&=I#7y%vrVn%TmOs2KCS;v z-3J`8_Q<~Cqni_M{qNTQZvF4p|8D(XoD)A<7Abaj{{PPZ-~YDEt^YA1fm{E(_|}mBGF@rcLF*?uZos#nA=C7Gh7QWBXx$?gbVK#I_cez=}pLU zTvP4D0nm``f<^EBGWkS11h@p9;f7lp+rpb6*+}#xv)N2(`u~)!EO-H(;ToVJQfEjs z7&=3}xQBCwrT*W5oyh{^dt_o&I(RGWunl`ZOXv(Kp=_X`mt_leuUc9xrKtp+;cCDY zsWT*8p3cyKrNS5*u+nLOG%u4ce1%W{zhUp&36-ZaBv5Hq|L>J)Oo$lt|5pKqNSz@w zL=Byxfk^594MeFVpgAPdG4FvaU1zvq?_Oqb8ahJ>tpxqQS5uQ9b%v{g9N0QTh66)q zXmFtP{{{z%9>9N#OmA8o2XX|rFccU%LkX+|{l8aJQ(%p4r8B$|2#M4gGK6TIp+Sez z|0_CR+Quns1n3Z{;L%a4&Tzw?Z!v6W{h&m6Uj4sUk7q*Z44Z+8NSz@uVb1%$i5t!t z_RV0x0yu>{y18Gb9UKeh9cdWsd60mcCFbm$Hs-UaIy_)t5cK~m0dAySCb)(v450N- zY-p+hsW4R{ZMMF-tYvW^6at%Wr%>eGL1>u<>i1O(k?SY)UeAMkr!#m zKm)D#5ST3EW!1cXG|Wrh%Kmkl}sQx=2- z0GFjl?v&|nkB(C9a`XB}88)A9(s(}!CG)9(Dg`6JWQ2hY#2pH4|)$8nVQYyM|WAoouH ze^Dv@#a>M=k z+&I3y(C;zs_VIGOcziz%7FeV(r)7xh&i~)}|5MxnW@+ly{}bc6F?h&kP8~ZY9+t6@ zU)5;^ZvFr7W&MAV>Hkw8--y&1uJfz%l~f~{5&C~|tf}?`PbDxb)0;^ZAOD?eWoorG zEWFOJ?WOxR`oBr)43il+P`Nwn9jYDF8R{xpx?$=&Kxdc)n2|ce>%xWi8ZbCKz$zEC zDc;66asX%HC1K$^d!I~(hDnHaO4k3ko%rka@Mb7B(#cFZoyh2uEG>PKdgJS_EO-H( zVFGA~)EN>D_!A`nJEL`mvg<)Z+zpWLl!?_U;jOU4+D?oTI@-1ow@(PijZ8X~O=&c2 zq3%@{E9eY609T~WkZ=h#TL6~v8iPhOh=Mx-(ovaM@sYR_p_+Y$PqgyN5f;YlQJ2B8cNq0w$0wg3{FEmC}D?*-`Sd) z1i=vK4BLYo*g8Xo14CzMa9{}D6$ju0@!0IROh#0PQaF%(UCvNos0SqlVVZZgrl!Ce z+sGE^46g@5B6WrgAzEjs>F{EAK$fBd+#w#D-7b?65~5U{VcX1G88)hOSp8PNZ?0^CTuOmMM5ljFI<#ApC*U}6)d8jyC#A|*#+iVH1%|#fv!tmmK9*W5({l+^= zbUxFYNG6QmTx_B4RhAgA%j*DFq+KRlV)hTfO3NrqdKOEeTWP8WV2RYg!4h}EVc5EN z3!$=YFM!H;5lOf*jY-+%>i|QfU1o-8z%FA)r7ZU&X{tdi`7=tXat~1{HmRI|?pe+Z zPM@^}*l8~&iPc+dO-+Je2<-B;K@M!Y%y1B}%g8}OOTG1A1;P7c$sfw}}NE-xdXIO-+GSRoUfhfRISL%n;(U%P5!)Oj#Ja4_(rl*VZsJ`Es8yklhJ>t$M_G8Wtcn-zo^z@W?Rzu|#^Cyb`9`^rhVU_g+|(p^{eX}>V8!B<+@MQovpjG?qJ<;-3@iu z)Gev~M(yWoKT!KX?cKGT9c~tDdPYuYPZJQ}u6F%~utw-c{9Dm8|+)Re#lgTl!~9?_K(tr8h2p)6$og z&Mp0yB~L8*=#n2SIlkogmtMEz{Y&FZ$|}FMWOe2HDnGqsqVn&TY_9C6d|l;m#bXuU zt-P<|)ykt4w^nSaxV!w7iYqHVRBj{IA)}<3T7O;sxr2zoHBy%{bEP4I2mjPz7~w#7FLY!%3?>bI1&6b6(7E`GS*ej z6vKnNu}^SxHAhy|ppunG=bUKwjI4+t(|)0iLF@QLk5{+ZW` zYF{HZg=|v|V*5=q0PPGbCO{Jii21YtR6syfXaO2WKvZA?8bd(L z!7m!n8xRoHAV8xCh;k6n2)3BH2>}`oD<(ig2#EQ#05phzsL%p*GXkOl6VOcvh>0+- z0p$@8)gVAS5D?`cpzYXV<|YJaTUapx${`@;(*n={0-{0-P(K2q0u#_y1jK~q)qwgC z5Y-?+uSY5?Wp#^9Y0-^#FP%i=^eV8>_YCt^* zh-wg^ZUjU*2&fBN%-n03Is$sJfJHO5K}_{YQh#Xg#>7MSTO-whJae|hl({u>ktqXAfQGBL>VTaS_DM- z1n6}Lh;n#9mmnafh5%HLEoKS{P(xTT0g5A_U=Z_I1p=Z11XP89D8mF)j({ki097L( z%HaXkARwlO091)BW(o<=(y(Fzv;+aI3G`6ePZ1Cm2tZ}8A|T2z0F}LhfGD2;l_4O? z;Q_@E5K{vHmHj_#F;j?u%Kj~^7y*_23<0eU0(ubvQ2_#a2?0@t3Frj`MEMA)>?a6_ za#%oRFC!qPh5+=h*kY!T0R1?um;n6<0bLaY^c@651qkT-2#7LFK+hu}$|pcSKtPnk z1NtEXVrmFL{})@#6cV8Cg%uN^?;@a8K|s$TASysW-$p=`VFLO(HjeVG=8MLC1HYXq zq{e+Kte6`2O>Eqi!Nxs}jiUl=+}GUx{~{OmuKI<^z}cpa;rst2AOv z`!7B@*XH*B^@hbA#krG5rs*`9n?|;dPv(awca8Opj-szxiwO0FTR{W4ZG97i;(hDs zmfY^q{D64#DyAjz>UGCxzt{{BS;uC@&BUe})PfUIhH}I7p@F_aZZJ=8Y}F(wK0A~f z+6u3B#b-ezCUW|BObp*Vp5L3B7|!DhUhFQb1ZroX=J3Q2Rfg-7`Xo%c;$~RCZ!kAJ z(1)+1=TA<}9GIFrfNk@f?@lX6yvf3zQ4HZUGdqJPfXOJ6 z-e*U~^4s%N45jLmTgUo_i51Zpl?!sQA;M+mj!$eI8X1@vL_b_?{I*=7f5+rFoJ%$c z$0Mm*ot!&W6fGFviQ9Dgt`lQ}3bp#8vE0tQdS#BN(Plv{uic}41?)AGl*w&*YQ^am z@t2M_-9IwCO??Fx;BYZ>A!8Db(8-S$#x`t^RY}26Ce?Bs`C2!6om}EIywb8Y$%Ht(^7!W`S&g z|Hft;#b@y;;sfn5%?xc4jc)21ffcU98ele3x4`%=&!wAhR%jbs{681}&&B_9`~L$2 z+Bg(OCgpaln%VtlsX71u&<*O`)ZEF#!d}6Q8^NePf6kr%=l1_k?LU6xB<`wqoS2)v zt)t~`vHriv{QtLt`$nXnVPGSRMBkbA1UP1aFMMZjx>IN2(lDFuO=b_)ef@w;M{Ms6 z-p_E^rrT)i+x*=r?wb%^;;C$Brd#`nM^w?S8|E)F@H6ZKn2~;l%%+&y^-(H!xypsW z_tMW$bI{$JNW!A`Z8F`@0Tv4~pVN-|0_C~W(fd7ShhNo_o-8Th%hj1I&CgI*7QBF; z;p>5hNIyfO0bd#VU}t-?@|*_^yQ??T1CZ~6bQ;oc+OYk8h8?{hBXoFo3r)g1+kq|A zy{cBVlR(*|A_n{nw*an4KSRPL-r@ydW#nlN2CQ@!z?qS0o`WUsgu}3-_g+FJa!gPV zq)`u8u1sS>#NhvbBVdU1Gh~J+zTgZXO3N2B2BK6J(CnA#kcX&rKf{jR70lqo23W8& z-dPS-Z?QEs34$T;GrS?lf$e9=aDZ=d0~{o~Da+s>nFjnjWx8c?98h2P`~^b+e!oRQ zI-z^sqJXWbDX^*{Tj2k{83>8=Gh_&X^=g2Qgf~aiwB1Eh)=fZ%NFyE{rTQ6m^b9d< zh{dw__5?gtFi~Zb-n4B2Wa{xuAZcfgE^GoOBK-`B33J}}v5@J_%D}C9P;~ZYx&i%- zGWByTn0KUM(EWP^+$^y`Cpz^2h^oT_24=wjzZc*}+GT=kn8Ej^RSAfa;3=iQ^S%#^G7t(jhbPoqu zjLP%a<+hifBdMGkpl8{!BeG3s^CmsDBs0(Q9@Xo$4SM1y6Q^+6t4In|}wC2V1; z0rHGYtO3JYVTZN7{QHE?G`)l-=}ly8q3%@{E7;{Oz!hni3744tRo7`)898Mcu<%%^ z{QyfO!-{!RmbjBdHAScl+l!|}^<=#ni7V5X5HZ;0PQVapmzg1I*=3E$JJCRQR5}4j zCS=;{jnC5Ua@)%t%-}SwEx|U9J+OL641rzF205_pGQ)vomo*3883>w#WH;c? z%QU<=4ydnx^3MzfrnMys!j3>-YibIDY=K?Q03nffnIXim%bE`FI0Q{cq7$gNL8h%9 z9o`@pE%HrSaBsDWX#2@5!-i*XiHoqa5SV&A6Ur{9fr&`FOiY;bzVA^Nhw!K@$$~J| zz(7)_PL2ihjx-Ej`X&K4ON`k$-D4bj5LJf<49vLw{|j?F+qHU;44iH12;KkJk7(Ne zJ^%uFX5hDF8@%@!cKiQs|KIulJO6*NE${aK*R;6(|A0Qmc9~(%9BSr4$mGhb{sodoSB_FGTSkA%BrmP z|AqhmT-Bkn#^)QKZ2WZN2OHnj_?E_ljiZgdjn_0bH2kFD*@nj({(JQ&tM9Jvt6oy| z`KpgKe6Znc!~G3!Z8*`ezoF2ut)UxE2Dq}Jp`k4PLj3FTr{a&sKN0_6{JrttiJy)i zi|>mM$G5~2@wM?w;}!M)TK`=Am+Swo{-OF0)t{~Z-TJrGAFuwy>Rr{%RbQ>Tqv}xo zuKK?Ew)$mtuhxCL?n`x_s{26QJL^u@73)Uodg`vOi`TwX`wW~S@X^{ouD!4JclHCt=iYMQElR{f3Y|5g2G)d#CvtG-wDzN+6^ z}EduGCsSG&#qvz%YMpdzsP5QhtGbF&wiTEeu~fjBA@*TpZzI5`{R7}hxzP3 z=d=HW&whZ<{yjeXx0Y3O!~*WIceA;t*xci6ZjsI1$L3D3xkGGjj?KM+&Fy7#Q*3T4 zo7=+Xu4HpBWpiuU+;aQL-eO;N*_R#m8q|KIKZyZwK+|L@NK7XlFDPy~1WzdQfGB?CT_ zcodC0|6fR1_RmkJQ({lj_F#|pR(JmYg14e@?FU7CkUe=ykKots|GWKvYfJy<+W)@@ z{r|_neIwG(FmTj3xoW^Vu%{hj?k2#6Qu~&svLY)BzNMW9n_gNjQ?u>8!TTAmdg5Dj z?7p#2L%tiCL=S|k>@?iABC2TD4Rdn{{0!d!FeCj8nPbb9gP#UWtK5t}_)c@s-J49p zqWAA*dWr+gIPIt}P@X$0mj5;rTR_!PY=EC(7DE1(;%5klC&k6QbOW%Gdicr_4aXXE0i=gy`V z&u|zpMEV&rL)7pygu$rBXF^6!*5k9QH=PAEe<9O@9-`9y3|B0>of({l_n?H9^n9PK z-ePNN5(InTXE+q(!1gm_I57MSH3#Y5tcSws3=}Qb7+JBI^ zfCB*diHfPmGokzpZw4kJ{S1i-bKcjG)$7TWcW{d`UfFJ7;B7Mb4sM|t%)BEFgQj~4 zxLIO8`suWHI!ja?9xyNi{{J@t+(^4ja1B!!K%4aZvlQA)Cm=m7lkdD1-Y&0bDkHGQ zH(6Ly-q|e?RWwwS53tL5fEj6*ndlp8-q(QXL0Il=na?M2R$@c|fMK9-`9i^0FK5 zWd^5ZZ4q|bdrqz1Vryy=1Vdn#b3qPlyUcK4+hxT;()(>f90*el_}?MZe^?v`IOQzc z{7(!8mbFC+Qd!;>u{AXXLAJmy4*(&Nc9|i>w9AT)z?3CSHBfQ4ObZ?zrP}3Xn~Mw^ zhP_2D!X_%F9?yib%l*Jaq+KQ^%z0nW(!_!|ghywoL6PbL22RLyietgNBMpO1-zDH? zi8(uW8S`0G9Ud?+p7lZK2a5Yo9TInn+-!#O z=&kCIz~t~bxBu_<|EK1r4vuRaJDHGzW(HYc4y5<_2H^FFP&X-YvtaGKPvxp>=UuoP{sNOdcJ9pN%-}A zHJ4Y!t7FyGD z_`@myJ1KRB8aB9kbOYoznG!Z^zs|6J&jW;xezu9*Co%@%;AqB$C{KqlMTyjrGfI9TFN_;}RsSxKl2Eg`kr2+_!uX-r55=>Km843RoR zW{6rkL+yQ|!kih1(rG|*sZ5u9h)UNP*6;oeW^kJ7L4s|>KC^m@t*J>641vyYUyuV^ zXUK41=?paoD)^bfK`IIOm&#PPI1Z?)ngXjTvIRQBHv%D% zIzxsKLuaVzP$3}=Iubqbp6(TyUiIiGRcBbg>u;F>;pqp7i?DEzOg)|nr8B$*n26LF z5)o8!c~8>vuj+V2y9Guo?j#BdTbqCLdsz z_W;aDyG-tf*0Z6^rUES8wuBR&%97(=m}+oBJSx)(4zRfOdF=A4r#6#sBHIkf26`!W zr%JQSy0YK}?DB4)A<`}r4VGQj-fCH`|JSgSJpg%9rUN!?zg=GS9gLE7bsAP2TxW;n3yvgRPAqb+L=GAY2{E7O+6aX@|jrQHk#rd=iq(xvGCkpifx zDX_-2vdaY^B+@Q3gcx>N(~;Dnnl&Bi1W>V2rnE;#sdjnQKRnH_;n`c_A}mlTQ;%mt z+2wIyBGN7s6Xv|{d(>guP%DWw3(0O^phc#291G?hX&C&&Ap&len6q=25zjQL4i6ZZ zar%GjwT8R6SQ$9mln&khmyf8}?579W_T-=NPzv891BH_!sy^W;JhTfzAQ; zkzsLvaBx@FTj2MN59P=E@h3C9ryJ@U9)Q1Frss-NCyVIH)ju-4Ex#Qr^i32-NXId` zGdDI4(pOP8voW<1&u@iCdskn+kRRTD)5uog=;D?|yR9!I) z@#8IKT`}%P1||k`qO-6BMp6Hc$#EENgSkn4E7i5*JH^+X)_0v46Qjwxm;Sve9u)NzSRe-dq?rpDlkf{RKUx@{RO1W3cq|9Y zPW)o^mm1`FJqaHi%#RC7iC%GMtnOPw>)`4}zFhsPwTHL|;RHm+VplD!2XdpiVZ-BD z01sX0`d85U`^|!|<+ssBfyJ%=^F!@P&4kf4(}z#Z92L(3{~7{@Wq)pbd<3Vl?x^k( z`++!Ylm;KSS3};8$d?#?KX)}w__lbSDN$1xg%5f z+s{vOah^M^!0G?zk4#S=JU_#JI^~fM9mjELulb)jA#OGPYqSxaeu>n{Im5B z)mPTOSUXs=qx#Y6@~Y!YA6W83D0KfE1C9a5zy-|!NONxoVV7T;t2~wt9g4-{*>GQ# zzZg!)3252=V?v)_M!A(gs&KB}IJJla& zE?Jn7xD@yC%PnuRzBozKOT>llN`T4c+AoLJc-RpIn&#usD8Hszk)6xM*2+eS^{0>6 zgbtOX9|PH@rNFaDhF6NmwYZa{`rSk|F1k&oGhWaQu1ph+Ox?3^OhVASF0*)D8z&uQ zsSvLX)lB?rPe2tPh zz&G_Td!6!gU>D5rBvuBtJ*Y7^H?L;P*@3!FR-(?fg0$iilR7rIKM7Z%r5^iZ_D zmFX!%{4IMb<@qvLgNm)Gd&;Vc+@DD-91ixIR>MQTZHhDJua`^+c&Pyyi|e-tBCyTi zNOlO?>=&%7PTaRI#wboKknI%RqC3>=+mq}C| szK4M2GbIF>uMz58!frW8TSz=^tqSA3EM_g#Z8m literal 0 HcmV?d00001 diff --git a/tests/test_auth.py b/tests/test_auth.py index 5fef206..9bfc5ee 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -6,7 +6,7 @@ from autosubmit_api import auth from autosubmit_api.auth.utils import validate_client from autosubmit_api.config.basicConfig import APIBasicConfig from autosubmit_api import config -from tests.custom_utils import custom_return_value, dummy_response +from tests.utils import custom_return_value, dummy_response class TestCommonAuth: diff --git a/tests/test_config.py b/tests/test_config.py index a4c8341..748da2b 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -7,7 +7,7 @@ from autosubmit_api.config.basicConfig import APIBasicConfig from autosubmit_api.config.config_common import AutosubmitConfigResolver from autosubmit_api.config.ymlConfigStrategy import ymlConfigStrategy -from tests.custom_utils import custom_return_value +from tests.utils import custom_return_value class TestConfigResolver: diff --git a/tests/test_database.py b/tests/test_database.py index 518523b..632c053 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -13,7 +13,7 @@ def count_pid_lsof(pid): class TestDatabase: - def test_open_files(self, fixture_mock_basic_config): + def test_open_files(self, fixture_sqlite): current_pid = os.getpid() counter = count_pid_lsof(current_pid) diff --git a/tests/test_endpoints_v4.py b/tests/test_endpoints_v4.py index dc83894..6e7fb38 100644 --- a/tests/test_endpoints_v4.py +++ b/tests/test_endpoints_v4.py @@ -7,7 +7,7 @@ import jwt import pytest from autosubmit_api import config from autosubmit_api.views.v4 import PAGINATION_LIMIT_DEFAULT, ExperimentJobsViewOptEnum -from tests.custom_utils import custom_return_value +from tests.utils import custom_return_value class TestCASV2Login: diff --git a/tests/test_fixtures.py b/tests/test_fixtures.py index 83e21c2..75809d4 100644 --- a/tests/test_fixtures.py +++ b/tests/test_fixtures.py @@ -1,9 +1,15 @@ import os +from typing import Tuple + +import pytest +from sqlalchemy import Engine, select from autosubmit_api.config.basicConfig import APIBasicConfig +from autosubmit_api.database import tables +from tests.utils import get_schema_names -class TestFixtures: +class TestSQLiteFixtures: def test_fixture_temp_dir_copy(self, fixture_temp_dir_copy: str): """ Test if all the files are copied from FAKEDIR to the temporary directory @@ -24,11 +30,23 @@ class TestFixtures: # File should exist assert os.path.exists(rc_file) - def test_mock_basic_config(self, fixture_mock_basic_config: str, fixture_gen_rc_sqlite: str): + with open(rc_file, "r") as f: + content = f.read() + assert "[database]" in content + assert f"path = {fixture_gen_rc_sqlite}" in content + assert "filename = autosubmit.db" in content + assert "backend = sqlite" in content + + @pytest.mark.skip(reason="TODO: Fix this test") + def test_mock_basic_config( + self, fixture_mock_basic_config: APIBasicConfig, fixture_gen_rc_sqlite: str + ): rc_file = os.path.join(fixture_gen_rc_sqlite, ".autosubmitrc") # Environment variable should be set and should point to the .autosubmitrc file - assert 'AUTOSUBMIT_CONFIGURATION' in os.environ and os.path.exists(os.environ['AUTOSUBMIT_CONFIGURATION']) - assert os.environ['AUTOSUBMIT_CONFIGURATION'] == rc_file + assert "AUTOSUBMIT_CONFIGURATION" in os.environ and os.path.exists( + os.environ["AUTOSUBMIT_CONFIGURATION"] + ) + assert os.environ["AUTOSUBMIT_CONFIGURATION"] == rc_file # Reading the configuration file APIBasicConfig.read() @@ -38,6 +56,8 @@ class TestFixtures: assert APIBasicConfig.DB_DIR == fixture_gen_rc_sqlite assert APIBasicConfig.DB_FILE == "autosubmit.db" + +class TestPostgresFixtures: def test_fixture_temp_dir_copy_exclude_db( self, fixture_temp_dir_copy_exclude_db: str ): @@ -55,3 +75,40 @@ class TestFixtures: assert not os.path.exists( os.path.join(fixture_temp_dir_copy_exclude_db, file) ) + + def test_fixture_gen_rc_postgres(self, fixture_gen_rc_pg: str): + """ + Test if the .autosubmitrc file is generated and the environment variable is set + """ + rc_file = os.path.join(fixture_gen_rc_pg, ".autosubmitrc") + + # File should exist + assert os.path.exists(rc_file) + + with open(rc_file, "r") as f: + content = f.read() + assert "[database]" in content + assert "backend = postgres" in content + assert "postgresql://" in content + assert fixture_gen_rc_pg in content + + def test_fixture_pg_db(self, fixture_pg_db: Tuple[str, Engine]): + engine = fixture_pg_db[1] + + # Check if the public schema exists and is the only one + with engine.connect() as conn: + schema_names = get_schema_names(conn) + assert schema_names == ["public"] + + def test_fixture_pg_db_copy_all(self, fixture_pg_db_copy_all: Tuple[str, Engine]): + engine = fixture_pg_db_copy_all[1] + + # Check if the experiment and details tables are copied + with engine.connect() as conn: + exp_rows = conn.execute(select(tables.ExperimentTable)).all() + details_rows = conn.execute(select(tables.DetailsTable)).all() + + assert len(exp_rows) > 0 + assert len(details_rows) > 0 + + # TODO: Check if the other tables are copied diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..186a17e --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,193 @@ +from http import HTTPStatus +import re +from typing import List +from sqlalchemy import Connection, Engine, create_engine, insert, select, text + +from autosubmit_api.database import tables +from sqlalchemy.schema import CreateSchema, CreateTable + + +def dummy_response(*args, **kwargs): + return "Hello World!", HTTPStatus.OK + + +def custom_return_value(value=None): + def blank_func(*args, **kwargs): + return value + + return blank_func + + +def get_schema_names(conn: Connection) -> List[str]: + """ + Get all schema names that are not from the system + """ + results = conn.execute( + text( + "SELECT schema_name FROM information_schema.schemata WHERE schema_name NOT LIKE 'pg_%' AND schema_name != 'information_schema'" + ) + ).all() + return [res[0] for res in results] + + +def setup_pg_db(conn: Connection): + """ + Resets database by dropping all schemas except the system ones and restoring the public schema + """ + # Get all schema names that are not from the system + schema_names = get_schema_names(conn) + + # Drop all schemas + for schema_name in schema_names: + conn.execute(text(f'DROP SCHEMA IF EXISTS "{schema_name}" CASCADE')) + + # Restore default public schema + conn.execute(text("CREATE SCHEMA public")) + conn.execute(text("GRANT ALL ON SCHEMA public TO public")) + conn.execute(text("GRANT ALL ON SCHEMA public TO postgres")) + + +def copy_structure_db(filepath: str, engine: Engine): + """ + This function copies the content of the FAKE_EXP_DIR/metadata/structures to the Postgres database + """ + # Get the xxxx from structure_xxxx.db with regex + match = re.search(r"structure_(\w+)\.db", filepath) + expid = match.group(1) + + # Get SQLite source data + source_as_db = create_engine(f"sqlite:///{filepath}") + with source_as_db.connect() as source_conn: + structures_rows = source_conn.execute( + select(tables.ExperimentStructureTable) + ).all() + + # Copy data to the Postgres database + with engine.connect() as conn: + conn.execute(CreateSchema(expid, if_not_exists=True)) + target_table = tables.table_change_schema( + expid, tables.ExperimentStructureTable + ) + conn.execute(CreateTable(target_table, if_not_exists=True)) + if len(structures_rows) > 0: + conn.execute( + insert(target_table), [row._mapping for row in structures_rows] + ) + conn.commit() + + +def copy_job_data_db(filepath: str, engine: Engine): + """ + This function copies the content of the FAKE_EXP_DIR/metadata/data to the Postgres database + """ + # Get the xxxx from job_data_xxxx.db with regex + match = re.search(r"job_data_(\w+)\.db", filepath) + expid = match.group(1) + # Get SQLite source data + source_as_db = create_engine(f"sqlite:///{filepath}") + with source_as_db.connect() as source_conn: + job_data_rows = source_conn.execute(select(tables.JobDataTable)).all() + exprun_rows = source_conn.execute(select(tables.experiment_run_table)).all() + + # Copy data to the Postgres database + with engine.connect() as conn: + conn.execute(CreateSchema(expid, if_not_exists=True)) + # Job data + target_table = tables.table_change_schema(expid, tables.JobDataTable) + conn.execute(CreateTable(target_table, if_not_exists=True)) + if len(job_data_rows) > 0: + conn.execute(insert(target_table),[row._mapping for row in job_data_rows]) + # Experiment run + target_table = tables.table_change_schema(expid, tables.experiment_run_table) + conn.execute(CreateTable(target_table, if_not_exists=True)) + if len(exprun_rows) > 0: + conn.execute(insert(target_table),[row._mapping for row in exprun_rows]) + conn.commit() + + +def copy_graph_data_db(filepath: str, engine: Engine): + """ + This function copies the content of the FAKE_EXP_DIR/metadata/graph to the Postgres database + """ + # Get the xxxx from graph_xxxx.db with regex + match = re.search(r"graph_data_(\w+)\.db", filepath) + expid = match.group(1) + + # Get SQLite source data + source_as_db = create_engine(f"sqlite:///{filepath}") + with source_as_db.connect() as source_conn: + graph_rows = source_conn.execute(select(tables.GraphDataTable)).all() + + # Copy data to the Postgres database + with engine.connect() as conn: + conn.execute(CreateSchema(expid, if_not_exists=True)) + target_table = tables.table_change_schema(expid, tables.GraphDataTable) + conn.execute(CreateTable(target_table, if_not_exists=True)) + if len(graph_rows) > 0: + conn.execute(insert(target_table),[row._mapping for row in graph_rows]) + conn.commit() + + +def copy_autosubmit_db(filepath: str, engine: Engine): + """ + This function copies the content of the FAKE_EXP_DIR/autosubmit.db to the Postgres database + """ + # Get SQLite source data + source_as_db = create_engine(f"sqlite:///{filepath}") + with source_as_db.connect() as source_conn: + exp_rows = source_conn.execute(select(tables.ExperimentTable)).all() + details_rows = source_conn.execute(select(tables.DetailsTable)).all() + + # Copy data to the Postgres database + with engine.connect() as conn: + conn.execute(CreateTable(tables.ExperimentTable.__table__, if_not_exists=True)) + conn.execute(insert(tables.ExperimentTable),[row._mapping for row in exp_rows]) + conn.execute(CreateTable(tables.DetailsTable.__table__, if_not_exists=True)) + conn.execute(insert(tables.DetailsTable),[row._mapping for row in details_rows]) + conn.commit() + + +def copy_as_times_db(filepath: str, engine: Engine): + """ + This function copies the content of the FAKE_EXP_DIR/as_times.db to the Postgres database + """ + # Get SQLite source data + source_as_db = create_engine(f"sqlite:///{filepath}") + with source_as_db.connect() as source_conn: + as_times_rows = source_conn.execute(select(tables.ExperimentStatusTable)).all() + + # Copy data to the Postgres database + with engine.connect() as conn: + conn.execute(CreateTable(tables.ExperimentStatusTable.__table__, if_not_exists=True)) + conn.execute(insert(tables.ExperimentStatusTable),[row._mapping for row in as_times_rows]) + conn.commit() + + +def copy_job_packages_db(filepath: str, engine: Engine): + """ + This function copies the content of the FAKE_EXP_DIR/pkl/job_packages to the Postgres database + """ + # Get the xxxx from job_packages_xxxx.db with regex + match = re.search(r"job_packages_(\w+)\.db", filepath) + expid = match.group(1) + + # Get SQLite source data + source_as_db = create_engine(f"sqlite:///{filepath}") + with source_as_db.connect() as source_conn: + job_packages_rows = source_conn.execute(select(tables.JobPackageTable)).all() + wrapper_job_packages_rows = source_conn.execute(select(tables.WrapperJobPackageTable)).all() + + # Copy data to the Postgres database + with engine.connect() as conn: + conn.execute(CreateSchema(expid, if_not_exists=True)) + # Job packages + target_table = tables.table_change_schema(expid, tables.JobPackageTable) + conn.execute(CreateTable(target_table, if_not_exists=True)) + if len(job_packages_rows) > 0: + conn.execute(insert(target_table),[row._mapping for row in job_packages_rows]) + # Wrapper job packages + target_table = tables.table_change_schema(expid, tables.WrapperJobPackageTable) + conn.execute(CreateTable(target_table, if_not_exists=True)) + if len(wrapper_job_packages_rows) > 0: + conn.execute(insert(target_table),[row._mapping for row in wrapper_job_packages_rows]) + conn.commit() -- GitLab From 67b5dcaa2dfcdeb516fbacede68e49dec6f57d98 Mon Sep 17 00:00:00 2001 From: Luiggi Tenorio Date: Fri, 24 May 2024 10:21:29 +0200 Subject: [PATCH 3/3] mark db backend tests --- tests/conftest.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 5b8be99..1728ee8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -34,7 +34,12 @@ def fixture_disable_protection(monkeypatch: pytest.MonkeyPatch): monkeypatch.setenv("PROTECTION_LEVEL", "NONE") -@pytest.fixture(params=["fixture_sqlite", "fixture_pg"]) +@pytest.fixture( + params=[ + pytest.param("fixture_sqlite", marks=pytest.mark.sqlite), + pytest.param("fixture_pg", marks=pytest.mark.pg), + ] +) def fixture_mock_basic_config(request: pytest.FixtureRequest): """ Sets a mock basic config for the tests. -- GitLab