From 40aedbe41f31ea8dae1f103178ea99cd869ce411 Mon Sep 17 00:00:00 2001 From: user Date: Fri, 29 Dec 2023 15:15:21 +0100 Subject: [PATCH 1/9] [WIP] Remove set_scorep_env, set directly into the environment. --- src/scorep_jupyter/kernel.py | 27 ++++----------------------- tests/kernel/scorep_env.yaml | 16 +++++++++------- tests/kernel/writemode.yaml | 14 +++++++------- 3 files changed, 20 insertions(+), 37 deletions(-) diff --git a/src/scorep_jupyter/kernel.py b/src/scorep_jupyter/kernel.py index 87062c0..809ddf4 100755 --- a/src/scorep_jupyter/kernel.py +++ b/src/scorep_jupyter/kernel.py @@ -30,7 +30,6 @@ def __init__(self, **kwargs): super().__init__(**kwargs) self.scorep_binding_args = [] - self.scorep_env = {} self.user_variables = set() @@ -42,7 +41,6 @@ def __init__(self, **kwargs): self.writemode_filename = 'jupyter_to_script' self.writemode_multicell = False self.writemode_scorep_binding_args = [] - self.writemode_scorep_env = [] # TODO: reset variables after each finalize writefile? self.bash_script_filename = "" self.python_script_filename = "" @@ -72,17 +70,6 @@ def comm_files_cleanup(self): if os.path.exists(aux_file): os.remove(aux_file) - def set_scorep_env(self, code): - """ - Read and record Score-P environment variables from the cell. - """ - for scorep_param in code.split('\n')[1:]: - key, val = scorep_param.split('=') - self.scorep_env[key] = val - self.cell_output( - 'Score-P environment set successfully: ' + str(self.scorep_env)) - return self.standard_reply() - def set_scorep_pythonargs(self, code): """ Read and record Score-P Python binding arguments from the cell. @@ -160,7 +147,7 @@ def end_writefile(self): # TODO: check for os path existence self.writemode = False self.bash_script.write( - f"{' '.join(self.writemode_scorep_env)} {PYTHON_EXECUTABLE} -m scorep {' '.join(self.writemode_scorep_binding_args)} {self.python_script_filename}") + f"{PYTHON_EXECUTABLE} -m scorep {' '.join(self.writemode_scorep_binding_args)} {self.python_script_filename}") self.bash_script.close() self.python_script.close() @@ -171,10 +158,7 @@ def append_writefile(self, code): """ Append cell to write mode sequence. Extract Score-P environment or Python bindings argument if necessary. """ - if code.startswith('%%scorep_env'): - self.writemode_scorep_env += code.split('\n')[1:] - self.cell_output('Environment variables recorded.') - elif code.startswith('%%scorep_python_binding_arguments'): + if code.startswith('%%scorep_python_binding_arguments'): self.writemode_scorep_binding_args += code.split('\n')[1:] self.cell_output('Score-P bindings arguments recorded.') @@ -249,7 +233,6 @@ async def scorep_execute(self, code, silent, store_history=True, user_expression cmd = [PYTHON_EXECUTABLE, "-m", "scorep"] + \ self.scorep_binding_args + [scorep_script_name] proc_env = os.environ.copy() - proc_env.update(self.scorep_env) proc_env.update({'PYTHONUNBUFFERED': 'x'}) # subprocess observation incomplete_line = '' @@ -306,8 +289,8 @@ async def scorep_execute(self, code, silent, store_history=True, user_expression return reply_status_load self.comm_files_cleanup() - if 'SCOREP_EXPERIMENT_DIRECTORY' in self.scorep_env: - scorep_folder = self.scorep_env['SCOREP_EXPERIMENT_DIRECTORY'] + if 'SCOREP_EXPERIMENT_DIRECTORY' in os.environ: + scorep_folder = os.environ['SCOREP_EXPERIMENT_DIRECTORY'] self.cell_output( f"Instrumentation results can be found in {scorep_folder}") else: @@ -369,8 +352,6 @@ async def do_execute(self, code, silent, store_history=False, user_expressions=N elif code.startswith('%%enable_multicellmode'): return self.enable_multicellmode() - elif code.startswith('%%scorep_env'): - return self.set_scorep_env(code) elif code.startswith('%%scorep_python_binding_arguments'): return self.set_scorep_pythonargs(code) elif self.multicellmode: diff --git a/tests/kernel/scorep_env.yaml b/tests/kernel/scorep_env.yaml index b34a98e..b47279e 100644 --- a/tests/kernel/scorep_env.yaml +++ b/tests/kernel/scorep_env.yaml @@ -1,9 +1,11 @@ - - |- - %%scorep_env - SCOREP_ENABLE_TRACING=1 - SCOREP_ENABLE_PROFILING=0 - SCOREP_TOTAL_MEMORY=3g - SCOREP_EXPERIMENT_DIRECTORY=tests_tmp/scorep-traces - - - "Score-P environment set successfully: {'SCOREP_ENABLE_TRACING': '1', 'SCOREP_ENABLE_PROFILING': '0', - 'SCOREP_TOTAL_MEMORY': '3g', 'SCOREP_EXPERIMENT_DIRECTORY': 'tests_tmp/scorep-traces'}" \ No newline at end of file + %env SCOREP_ENABLE_TRACING=1 + %env SCOREP_ENABLE_PROFILING=0 + %env SCOREP_TOTAL_MEMORY=3g + %env SCOREP_EXPERIMENT_DIRECTORY=tests_tmp/scorep-traces + - - | + env: SCOREP_ENABLE_TRACING=1 + env: SCOREP_ENABLE_PROFILING=0 + env: SCOREP_TOTAL_MEMORY=3g + env: SCOREP_EXPERIMENT_DIRECTORY=tests_tmp/scorep-traces diff --git a/tests/kernel/writemode.yaml b/tests/kernel/writemode.yaml index 9fddbb7..9c55b67 100644 --- a/tests/kernel/writemode.yaml +++ b/tests/kernel/writemode.yaml @@ -6,12 +6,12 @@ /home/runner/work/scorep_jupyter_kernel_python/scorep_jupyter_kernel_python/tests_tmp/my_jupyter_to_script.py - - |- - %%scorep_env - SCOREP_ENABLE_TRACING=1 - SCOREP_ENABLE_PROFILING=0 - SCOREP_TOTAL_MEMORY=3g - SCOREP_EXPERIMENT_DIRECTORY=tests_tmp/scorep-traces - - - "Environment variables recorded." + import os + os.environ['SCOREP_ENABLE_TRACING']="1" + os.environ['SCOREP_ENABLE_PROFILING']="0" + os.environ['SCOREP_TOTAL_MEMORY']="3g" + os.environ['SCOREP_EXPERIMENT_DIRECTORY']="tests_tmp/scorep-traces" + - - "Python commands without instrumentation recorded." - - |- %%scorep_python_binding_arguments @@ -60,7 +60,7 @@ - - |- %%bash - chmod u+x tests_tmp/my_jupyter_to_script_run.sh + chmod u+x ./tests_tmp/my_jupyter_to_script_run.sh ./tests_tmp/my_jupyter_to_script_run.sh - - "a + b = 15\n" - "a - b = -5\n" From e8bba94536f4d6db87966b98101fb28a51f088fd Mon Sep 17 00:00:00 2001 From: user Date: Thu, 28 Dec 2023 22:04:34 +0100 Subject: [PATCH 2/9] Fix extracting variables names. --- src/scorep_jupyter/kernel.py | 44 ++++++++---------------------------- 1 file changed, 10 insertions(+), 34 deletions(-) diff --git a/src/scorep_jupyter/kernel.py b/src/scorep_jupyter/kernel.py index 809ddf4..c5b754c 100755 --- a/src/scorep_jupyter/kernel.py +++ b/src/scorep_jupyter/kernel.py @@ -31,8 +31,6 @@ def __init__(self, **kwargs): self.scorep_binding_args = [] - self.user_variables = set() - self.multicellmode = False self.multicellmode_cellcount = 0 self.multicell_code = "" @@ -213,6 +211,13 @@ async def scorep_execute(self, code, silent, store_history=True, user_expression # Prepare code for the Score-P instrumented execution as subprocess # Transmit user persistence and updated sys.path from Jupyter notebook to subprocess # After running code, transmit subprocess persistence back to Jupyter notebook + + try: + user_variables = extract_variables_names(code) + except SyntaxError as e: + self.cell_output(f"SyntaxError: {e}", 'stderr') + return self.standard_reply() + sys_path_updated = json.dumps(sys.path) scorep_code = "import scorep\n" + \ "with scorep.instrumenter.disable():\n" + \ @@ -224,7 +229,7 @@ async def scorep_execute(self, code, silent, store_history=True, user_expression f" sys.path.extend({sys_path_updated})\n" + \ code + "\n" + \ "with scorep.instrumenter.disable():\n" + \ - f" save_variables_values(globals(), {str(self.user_variables)}, '{subprocess_dump}')" + f" save_variables_values(globals(), {str(user_variables)}, '{subprocess_dump}')" with open(scorep_script_name, 'w+') as file: file.write(scorep_code) @@ -357,40 +362,11 @@ async def do_execute(self, code, silent, store_history=False, user_expressions=N elif self.multicellmode: return self.append_multicellmode(code) elif code.startswith('%%execute_with_scorep'): - code = code.split("\n", 1)[1] - # Parsing for user variables might fail due to SyntaxError - try: - user_variables = extract_variables_names(code) - except SyntaxError as e: - self.cell_output(f"SyntaxError: {e}", 'stderr') - return self.standard_reply() - self.user_variables.update(user_variables) - return await self.scorep_execute(code, silent, store_history, user_expressions, allow_stdin, cell_id=cell_id) + return await self.scorep_execute(code.split("\n", 1)[1], silent, store_history, user_expressions, allow_stdin, cell_id=cell_id) else: - # Some line/cell magics involve executing Python code, which must be parsed - # TODO: timeit, python, ...? do not save variables to globals() - whitelist_prefixes_cell = ['%%prun', '%%timeit', '%%capture', '%%python', '%%pypy'] - whitelist_prefixes_line = ['%prun', '%time'] - - nomagic_code = '' # Code to be parsed for user variables - if not code.startswith(tuple(['%', '!'])): # No IPython magics and shell commands - nomagic_code = code - else: - if code.startswith(tuple(whitelist_prefixes_cell)): # Cell magic, remove first line - nomagic_code = code.split("\n", 1)[1] - elif code.startswith(tuple(whitelist_prefixes_line)): # Line magic, remove first word - nomagic_code = code.split(" ", 1)[1] - if nomagic_code: - # Parsing for user variables might fail due to SyntaxError - try: - user_variables = extract_variables_names(nomagic_code) - except SyntaxError as e: - self.cell_output(f"SyntaxError: {e}", 'stderr') - return self.standard_reply() - self.user_variables.update(user_variables) return await super().do_execute(code, silent, store_history, user_expressions, allow_stdin, cell_id=cell_id) if __name__ == '__main__': from ipykernel.kernelapp import IPKernelApp - IPKernelApp.launch_instance(kernel_class=ScorepPythonKernel) + IPKernelApp.launch_instance(kernel_class=ScorepPythonKernel) \ No newline at end of file From a3f734fa64d275f0bfab0d868d608ad91146ad54 Mon Sep 17 00:00:00 2001 From: "s4122485--tu-dresden.de" Date: Thu, 1 Feb 2024 10:50:33 +0100 Subject: [PATCH 3/9] 0.6.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8bf8560..33e3a88 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta:__legacy__" [project] name='scorep-jupyter' -version='0.4.0' +version='0.6.0' authors=[ {name='Elias Werner',email='elias.werner@tu-dresden.de'}, ] From b9ac77ec0c59befeaea9e979708cbac3aaf0e9bb Mon Sep 17 00:00:00 2001 From: user Date: Sat, 27 Jan 2024 12:58:13 +0100 Subject: [PATCH 4/9] Add currently executed cell number and code to multicell mode output. --- src/scorep_jupyter/kernel.py | 7 ++++++- tests/kernel/multicell.yaml | 17 +++++++++++++++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/scorep_jupyter/kernel.py b/src/scorep_jupyter/kernel.py index c5b754c..31c6ae1 100755 --- a/src/scorep_jupyter/kernel.py +++ b/src/scorep_jupyter/kernel.py @@ -101,8 +101,13 @@ def append_multicellmode(self, code): """ Append cell to multicell mode sequence. """ - self.multicell_code += ("\n" + code) self.multicellmode_cellcount += 1 + max_line_len = max(len(line) for line in code.split('\n')) + self.multicell_code += f"print('Executing cell {self.multicellmode_cellcount}')\n" + \ + f"print('''{code}''')\n" + \ + f"print('-' * {max_line_len})\n" + \ + f"{code}\n" + \ + f"print('''\n''')\n" self.cell_output( f'Cell marked for multicell mode. It will be executed at position {self.multicellmode_cellcount}') return self.standard_reply() diff --git a/tests/kernel/multicell.yaml b/tests/kernel/multicell.yaml index c728000..796ac2f 100644 --- a/tests/kernel/multicell.yaml +++ b/tests/kernel/multicell.yaml @@ -18,12 +18,25 @@ - - "Cell marked for multicell mode. It will be executed at position 1" - - |- - print("c =", c) - print("Sum(c_vec) =", c_vec.sum()) + print('c =', c) + print('Sum(c_vec) =', c_vec.sum()) - - "Cell marked for multicell mode. It will be executed at position 2" - - "%%finalize_multicellmode" - - "\0" + - "Executing cell 1\n" + - "with scorep.instrumenter.enable():\n" + - " c = np.sum(c_mtx)\n" + - " c_vec = np.arange(b, c)\n" + - "----------------------------------\n" + - "\n" + - "\n" + - "Executing cell 2\n" + - "print('c =', c)\n" + - "print('Sum(c_vec) =', c_vec.sum())\n" + - "----------------------------------\n" - "c = 350\n" - "Sum(c_vec) = 61030\n" + - "\n" + - "\n" - "Instrumentation results can be found in tests_tmp/scorep-traces" From ca1d59ced040f1c61aa45f268fc60be13f187e09 Mon Sep 17 00:00:00 2001 From: user Date: Sat, 27 Jan 2024 20:05:29 +0100 Subject: [PATCH 5/9] Add PersHelper class to handle persistence with different serializer backends. --- .github/workflows/unit_test.yml | 2 +- pyproject.toml | 3 +- src/scorep_jupyter/kernel.py | 76 ++++------- src/scorep_jupyter/userpersistence.py | 187 ++++++++++++++++++++++++-- tests/kernel/ipykernel_exec.yaml | 3 +- tests/kernel/multicell.yaml | 4 +- tests/kernel/notebook.ipynb | 84 +++++++++--- tests/kernel/persistence.yaml | 44 ++++-- tests/kernel/scorep_exec.yaml | 2 +- tests/kernel/writemode.yaml | 10 +- tests/test_userpersistence.py | 85 +++++++++--- tests/userpersistence/os_environ.json | 1 + tests/userpersistence/sys_path.json | 1 + 13 files changed, 381 insertions(+), 121 deletions(-) create mode 100644 tests/userpersistence/os_environ.json create mode 100644 tests/userpersistence/sys_path.json diff --git a/.github/workflows/unit_test.yml b/.github/workflows/unit_test.yml index bd4c2f1..451c05c 100644 --- a/.github/workflows/unit_test.yml +++ b/.github/workflows/unit_test.yml @@ -39,7 +39,7 @@ jobs: pip install --upgrade setuptools pip install scorep pip install jupyter_kernel_test - pip install pyyaml dill numpy pandas + pip install pyyaml dill cloudpickle numpy pandas - name: Build Score-P Python kernel run: | diff --git a/pyproject.toml b/pyproject.toml index 33e3a88..3b199bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,8 @@ dependencies = [ "jupyter-client", "astunparse", "scorep", - "dill" + "dill", + "cloudpickle" ] [project.urls] diff --git a/src/scorep_jupyter/kernel.py b/src/scorep_jupyter/kernel.py index 31c6ae1..5b3e725 100755 --- a/src/scorep_jupyter/kernel.py +++ b/src/scorep_jupyter/kernel.py @@ -3,16 +3,10 @@ import os import subprocess import re -import json -from scorep_jupyter.userpersistence import extract_definitions, extract_variables_names +from scorep_jupyter.userpersistence import PersHelper, scorep_script_name PYTHON_EXECUTABLE = sys.executable READ_CHUNK_SIZE = 8 -userpersistence_token = "scorep_jupyter.userpersistence" -scorep_script_name = "scorep_script.py" -jupyter_dump = "jupyter_dump.pkl" -subprocess_dump = "subprocess_dump.pkl" - class ScorepPythonKernel(IPythonKernel): implementation = 'Python and Score-P' @@ -45,6 +39,8 @@ def __init__(self, **kwargs): self.bash_script = None self.python_script = None + self.pershelper = PersHelper('dill') + def cell_output(self, string, stream='stdout'): """ Display string as cell output. @@ -60,13 +56,17 @@ def standard_reply(self): 'user_expressions': {}, } - def comm_files_cleanup(self): + def switch_serializer(self, code): """ - Clean up files used for transmitting persistence and running subprocess. + Switch serializer backend used for persistence in kernel. """ - for aux_file in [scorep_script_name, jupyter_dump, subprocess_dump]: - if os.path.exists(aux_file): - os.remove(aux_file) + serializer = code.split('\n')[1] + if serializer == 'dill': + self.pershelper = PersHelper('dill') + elif serializer == 'cloudpickle': + self.pershelper = PersHelper('cloudpickle') + self.cell_output(f'Serializer backend switched to {serializer}, persistence was reset.') + return self.standard_reply() def set_scorep_pythonargs(self, code): """ @@ -201,14 +201,13 @@ async def scorep_execute(self, code, silent, store_history=True, user_expression """ # Ghost cell - dump current Jupyter session for subprocess # Run in a "silent" way to not increase cells counter - dump_jupyter = "import dill\n" + f"dill.dump_session('{jupyter_dump}')" - reply_status_dump = await super().do_execute(dump_jupyter, silent, store_history=False, + reply_status_dump = await super().do_execute(self.pershelper.jupyter_dump(), silent, store_history=False, user_expressions=user_expressions, allow_stdin=allow_stdin, cell_id=cell_id) if reply_status_dump['status'] != 'ok': self.shell.execution_count += 1 reply_status_dump['execution_count'] = self.shell.execution_count - 1 - self.comm_files_cleanup() + self.pershelper.pers_cleanup() self.cell_output("KernelError: Failed to pickle previous notebook's persistence and variables.", 'stderr') return reply_status_dump @@ -216,32 +215,13 @@ async def scorep_execute(self, code, silent, store_history=True, user_expression # Prepare code for the Score-P instrumented execution as subprocess # Transmit user persistence and updated sys.path from Jupyter notebook to subprocess # After running code, transmit subprocess persistence back to Jupyter notebook - - try: - user_variables = extract_variables_names(code) - except SyntaxError as e: - self.cell_output(f"SyntaxError: {e}", 'stderr') - return self.standard_reply() - - sys_path_updated = json.dumps(sys.path) - scorep_code = "import scorep\n" + \ - "with scorep.instrumenter.disable():\n" + \ - f" from {userpersistence_token} import save_variables_values \n" + \ - " import dill\n" + \ - f" globals().update(dill.load_module_asdict('{jupyter_dump}'))\n" + \ - " import sys\n" + \ - " sys.path.clear()\n" + \ - f" sys.path.extend({sys_path_updated})\n" + \ - code + "\n" + \ - "with scorep.instrumenter.disable():\n" + \ - f" save_variables_values(globals(), {str(user_variables)}, '{subprocess_dump}')" - with open(scorep_script_name, 'w+') as file: - file.write(scorep_code) + file.write(self.pershelper.subprocess_wrapper(code)) # Launch subprocess with Jupyter notebook environment cmd = [PYTHON_EXECUTABLE, "-m", "scorep"] + \ self.scorep_binding_args + [scorep_script_name] + proc_env = os.environ.copy() proc_env.update({'PYTHONUNBUFFERED': 'x'}) # subprocess observation @@ -276,7 +256,7 @@ async def scorep_execute(self, code, silent, store_history=True, user_expression proc.wait() if proc.returncode: - self.comm_files_cleanup() + self.pershelper.pers_cleanup() self.cell_output( 'KernelError: Cell execution failed, cell persistence and variables are not recorded.', 'stderr') @@ -284,21 +264,18 @@ async def scorep_execute(self, code, silent, store_history=True, user_expression # Ghost cell - load subprocess definitions and persistence back to Jupyter notebook # Run in a "silent" way to not increase cells counter - load_jupyter = extract_definitions(code) + "\n" + \ - f"with open('{subprocess_dump}', 'rb') as file:\n" + \ - " globals().update(dill.load(file))\n" - reply_status_load = await super().do_execute(load_jupyter, silent, store_history=False, + reply_status_update = await super().do_execute(self.pershelper.jupyter_update(code), silent, store_history=False, user_expressions=user_expressions, allow_stdin=allow_stdin, cell_id=cell_id) - if reply_status_load['status'] != 'ok': + if reply_status_update['status'] != 'ok': self.shell.execution_count += 1 - reply_status_load['execution_count'] = self.shell.execution_count - 1 - self.comm_files_cleanup() + reply_status_update['execution_count'] = self.shell.execution_count - 1 + self.pershelper.pers_cleanup() self.cell_output("KernelError: Failed to load cell's persistence and variables to the notebook.", 'stderr') - return reply_status_load + return reply_status_update - self.comm_files_cleanup() + self.pershelper.pers_cleanup() if 'SCOREP_EXPERIMENT_DIRECTORY' in os.environ: scorep_folder = os.environ['SCOREP_EXPERIMENT_DIRECTORY'] self.cell_output( @@ -346,6 +323,7 @@ async def do_execute(self, code, silent, store_history=False, user_expressions=N try: reply_status = await self.scorep_execute(self.multicell_code, silent, store_history, user_expressions, allow_stdin, cell_id=cell_id) except: + self.cell_output("KernelError: Multicell mode failed.",'stderr') return self.standard_reply() self.multicell_code = "" self.multicellmode_cellcount = 0 @@ -362,6 +340,9 @@ async def do_execute(self, code, silent, store_history=False, user_expressions=N elif code.startswith('%%enable_multicellmode'): return self.enable_multicellmode() + + elif code.startswith('%%switch_serializer'): + return self.switch_serializer(code) elif code.startswith('%%scorep_python_binding_arguments'): return self.set_scorep_pythonargs(code) elif self.multicellmode: @@ -369,9 +350,10 @@ async def do_execute(self, code, silent, store_history=False, user_expressions=N elif code.startswith('%%execute_with_scorep'): return await self.scorep_execute(code.split("\n", 1)[1], silent, store_history, user_expressions, allow_stdin, cell_id=cell_id) else: + self.pershelper.parse(code, 'jupyter') return await super().do_execute(code, silent, store_history, user_expressions, allow_stdin, cell_id=cell_id) if __name__ == '__main__': from ipykernel.kernelapp import IPKernelApp - IPKernelApp.launch_instance(kernel_class=ScorepPythonKernel) \ No newline at end of file + IPKernelApp.launch_instance(kernel_class=ScorepPythonKernel) diff --git a/src/scorep_jupyter/userpersistence.py b/src/scorep_jupyter/userpersistence.py index b0422fa..48b26ca 100644 --- a/src/scorep_jupyter/userpersistence.py +++ b/src/scorep_jupyter/userpersistence.py @@ -1,26 +1,185 @@ +import os +import shutil import ast import astunparse +from textwrap import dedent -def save_variables_values(globs, variables, filename): - """ - Dump values of given variables into the file. - """ - import dill - user_variables = {k: v for k, v in globs.items() if k in variables} +scorep_script_name = "scorep_script.py" +jupyter_dump_dir = "jupyter_dump_" +subprocess_dump_dir = "subprocess_dump_" +full_dump = "full_dump.pkl" +os_env_dump = "os_env_dump.pkl" +sys_path_dump = "sys_path_dump.pkl" +var_dump = "var_dump.pkl" + +class PersHelper: + def __init__(self, serializer='dill'): + self.jupyter_definitions = "" + self.jupyter_variables = [] + self.serializer = serializer + self.subprocess_definitions = "" + self.subprocess_variables = [] + + # FIXME + def pers_cleanup(self): + """ + Clean up files used for transmitting persistence and running subprocess. + """ + for pers_path in [scorep_script_name, + *[dirname + filename for dirname in [jupyter_dump_dir, subprocess_dump_dir] + for filename in [full_dump, os_env_dump, sys_path_dump, var_dump]]]: + if os.path.exists(pers_path): + if os.path.isfile(pers_path): + os.remove(pers_path) + elif os.path.isdir(pers_path): + shutil.rmtree(pers_path) + + def jupyter_dump(self): + """ + Generate code for kernel ghost cell to dump notebook persistence for subprocess. + """ + jupyter_dump_ = dedent(f"""\ + import sys + import os + import {self.serializer} + from scorep_jupyter.userpersistence import pickle_runtime, pickle_variables + pickle_runtime(os.environ, sys.path, '{jupyter_dump_dir}', {self.serializer}) + """) + if self.serializer == 'dill': + return jupyter_dump_ + f"dill.dump_session('{jupyter_dump_dir + full_dump}')" + elif self.serializer == 'cloudpickle': + return jupyter_dump_ + f"pickle_variables({str(self.jupyter_variables)}, globals(), '{jupyter_dump_dir}', {self.serializer})" + + def subprocess_wrapper(self, code): + """ + Extract subprocess user variables and definitions. + """ + self.parse(code, 'subprocess') + + subprocess_update = dedent(f"""\ + import sys + import os + import {self.serializer} + from scorep_jupyter.userpersistence import pickle_runtime, pickle_variables, load_runtime, load_variables + load_runtime(os.environ, sys.path, '{jupyter_dump_dir}', {self.serializer}) + """) + if self.serializer == 'dill': + subprocess_update += f"globals().update(dill.load_module_asdict('{jupyter_dump_dir + full_dump}'))" + elif self.serializer == 'cloudpickle': + subprocess_update += (self.jupyter_definitions + f"load_variables(globals(), '{jupyter_dump_dir}', {self.serializer})") + return subprocess_update + "\n" + code + \ + dedent(f""" + pickle_runtime(os.environ, sys.path, '{subprocess_dump_dir}', {self.serializer}) + pickle_variables({str(self.subprocess_variables)}, globals(), '{subprocess_dump_dir}', {self.serializer}) + """) + + def jupyter_update(self, code): + """ + Update aggregated storage of definitions and user variables for entire notebook. + """ + self.parse(code, 'jupyter') + + return dedent(f"""\ + import sys + import os + from scorep_jupyter.userpersistence import load_runtime, load_variables + load_runtime(os.environ, sys.path, '{subprocess_dump_dir}', {self.serializer}) + {self.subprocess_definitions} + load_variables(globals(), '{subprocess_dump_dir}', {self.serializer}) + """) + + def parse(self, code, mode): + """ + Extract user variables names and definitions from the code. + """ + # Code with magics and shell commands is ignored, + # unless magics are from "white list" which execute code + # in "persistent" manner. + whitelist_prefixes_cell = ['%%prun', '%%capture'] + whitelist_prefixes_line = ['%prun', '%time'] + + nomagic_code = '' # Code to be parsed for user variables + if not code.startswith(tuple(['%', '!'])): # No IPython magics and shell commands + nomagic_code = code + elif code.startswith(tuple(whitelist_prefixes_cell)): # Cell magic & executed cell, remove first line + nomagic_code = code.split("\n", 1)[1] + elif code.startswith(tuple(whitelist_prefixes_line)): # Line magic & executed cell, remove first word + nomagic_code = code.split(" ", 1)[1] + try: + user_definitions = extract_definitions(nomagic_code) + user_variables = extract_variables_names(nomagic_code) + except SyntaxError as e: + raise + + if mode == 'subprocess': + # Parse definitions and user variables from subprocess code before running it. + self.subprocess_definitions = "" + self.subprocess_variables.clear() + self.subprocess_definitions += user_definitions + self.subprocess_variables.extend(user_variables) + elif mode == "jupyter" and self.serializer == "cloudpickle": + # Update aggregated storage of definitions and user variables for entire notebook. + # Not relevant for dill because of dump_session. + self.jupyter_definitions += user_definitions + self.jupyter_variables.extend(user_variables) + +def pickle_runtime(os_environ_, sys_path_, dump_dir, serializer): + os_env_dump_ = dump_dir + os_env_dump + sys_path_dump_ = dump_dir + sys_path_dump + + # Don't dump environment variables set by Score-P bindings. + # Will force it to re-initialize instead of calling reset_preload() + filtered_os_environ_ = {k: v for k, v in os_environ_.items() if not k.startswith('SCOREP_PYTHON_BINDINGS_')} + with open(os_env_dump_, 'wb+') as file: + serializer.dump(filtered_os_environ_, file) + with open(sys_path_dump_, 'wb+') as file: + serializer.dump(sys_path_, file) + +def pickle_variables(variables_names, globals_, dump_dir, serializer): + var_dump_ = dump_dir + var_dump + user_variables = {k: v for k, v in globals_.items() if k in variables_names} + for el in user_variables.keys(): # if possible, exchange class of the object here with the class that is stored for persistence. This is # valid since the classes should be the same and this does not affect the objects attribute dictionary non_persistent_class = user_variables[el].__class__.__name__ if non_persistent_class in globals().keys(): user_variables[el].__class__ = globals()[non_persistent_class] - with open(filename, 'wb+') as file: - dill.dump(user_variables, file) + + with open(var_dump_, 'wb+') as file: + serializer.dump(user_variables, file) + +def load_runtime(os_environ_, sys_path_, dump_dir, serializer): + os_env_dump_ = dump_dir + os_env_dump + sys_path_dump_ = dump_dir + sys_path_dump + + loaded_os_environ_ = {} + loaded_sys_path_ = [] + + if os.path.getsize(os_env_dump_) > 0: + with open(os_env_dump_, 'rb') as file: + loaded_os_environ_ = serializer.load(file) + if os.path.getsize(sys_path_dump_) > 0: + with open(sys_path_dump_, 'rb') as file: + loaded_sys_path_ = serializer.load(file) + + #os_environ_.clear() + os_environ_.update(loaded_os_environ_) + + #sys_path_.clear() + sys_path_.extend(loaded_sys_path_) + +def load_variables(globals_, dump_dir, serializer): + var_dump_ = dump_dir + var_dump + if os.path.getsize(var_dump_) > 0: + with open(var_dump_, 'rb') as file: + globals_.update(serializer.load(file)) def extract_definitions(code): """ Extract imported modules and definitions of classes and functions from the code block. """ - # can't use in kernel as import from userpersistence: + # can't use in kernel as import from scorep_jupyter.userpersistence: # self-reference error during dill dump of notebook root = ast.parse(code) definitions = [] @@ -36,17 +195,17 @@ def extract_definitions(code): ast.ImportFrom)): definitions.append(top_node) - pers_string = "" + definitions_string = "" for node in definitions: - pers_string += astunparse.unparse(node) + definitions_string += astunparse.unparse(node) - return pers_string + return definitions_string def extract_variables_names(code): """ Extract user-assigned variables from code. Unlike dir(), nothing coming from the imported modules is included. - Might contain non-variables as well from assignments, which are later filtered out in save_variables_values. + Might contain non-variables as well from assignments, which are later filtered out when dumping variables. """ root = ast.parse(code) @@ -63,4 +222,4 @@ def extract_variables_names(code): if isinstance(target_node, ast.Name): variables.add(target_node.id) - return variables + return variables \ No newline at end of file diff --git a/tests/kernel/ipykernel_exec.yaml b/tests/kernel/ipykernel_exec.yaml index dbc6b37..6dcda3d 100644 --- a/tests/kernel/ipykernel_exec.yaml +++ b/tests/kernel/ipykernel_exec.yaml @@ -10,6 +10,5 @@ b = 10 - - "" - - - |- - print("a + b =", a + b) + - "print('a + b =', a + b)" - - "a + b = 15\n" \ No newline at end of file diff --git a/tests/kernel/multicell.yaml b/tests/kernel/multicell.yaml index 796ac2f..3178fec 100644 --- a/tests/kernel/multicell.yaml +++ b/tests/kernel/multicell.yaml @@ -14,7 +14,7 @@ - |- with scorep.instrumenter.enable(): c = np.sum(c_mtx) - c_vec = np.arange(b, c) + c_vec = np.arange(b, c) - - "Cell marked for multicell mode. It will be executed at position 1" - - |- @@ -27,7 +27,7 @@ - "Executing cell 1\n" - "with scorep.instrumenter.enable():\n" - " c = np.sum(c_mtx)\n" - - " c_vec = np.arange(b, c)\n" + - "c_vec = np.arange(b, c)\n" - "----------------------------------\n" - "\n" - "\n" diff --git a/tests/kernel/notebook.ipynb b/tests/kernel/notebook.ipynb index f8a7dd0..f0490ba 100644 --- a/tests/kernel/notebook.ipynb +++ b/tests/kernel/notebook.ipynb @@ -71,7 +71,7 @@ "metadata": {}, "outputs": [], "source": [ - "print(\"a + b =\", a + b)" + "print('a + b =', a + b)" ] }, { @@ -90,7 +90,7 @@ "%%execute_with_scorep\n", "import scorep\n", "with scorep.instrumenter.enable():\n", - " print(\"a - b =\", a - b)" + " print('a - b =', a - b)" ] }, { @@ -132,6 +132,15 @@ "### persistence" ] }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%env JUPYTER_VAR=JUPYTER" + ] + }, { "cell_type": "code", "execution_count": null, @@ -142,7 +151,10 @@ "def f(x):\n", " return x**2\n", "a_vec = np.arange(a)\n", - "b_vec = np.arange(a, b)" + "b_vec = np.arange(a, b)\n", + "\n", + "import sys\n", + "sys.path.append('/new/jupyter/path')" ] }, { @@ -152,13 +164,22 @@ "outputs": [], "source": [ "%%execute_with_scorep\n", + "import pandas as pd\n", + "def g(x):\n", + " return np.log2(x)\n", "with scorep.instrumenter.enable():\n", - " import pandas as pd\n", - " def g(x):\n", - " return np.log2(x)\n", " c_mtx = np.outer(a_vec, b_vec)\n", - " print(\"Inner product of a_vec and b_vec =\", np.dot(a_vec, b_vec))\n", - " print(\"f(4) =\", f(4))" + "print('Inner product of a_vec and b_vec =', np.dot(a_vec, b_vec))\n", + "print('f(4) =', f(4))\n", + "\n", + "import os\n", + "import sys\n", + "print('JUPYTER_VAR =', os.environ['JUPYTER_VAR'])\n", + "if '/new/jupyter/path' in sys.path:\n", + " print(\"'/new/jupyter/path' found in sys.path\")\n", + "\n", + "os.environ['SUBPROCESS_VAR'] = 'SUBPROCESS'\n", + "sys.path.append('/new/subprocess/path')" ] }, { @@ -167,8 +188,8 @@ "metadata": {}, "outputs": [], "source": [ - "print(\"Outer product of a_vec and b_vec =\\n\", c_mtx)\n", - "print(\"g(16) =\", g(16))" + "print('Outer product of a_vec and b_vec =\\n', c_mtx)\n", + "print('g(16) =', g(16))" ] }, { @@ -182,6 +203,25 @@ "print(df['a*b'])" ] }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%env SUBPROCESS_VAR" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "if '/new/subprocess/path' in sys.path:\n", + " print(\"'/new/subprocess/path' found in sys.path\")" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -233,7 +273,7 @@ "source": [ "with scorep.instrumenter.enable():\n", " c = np.sum(c_mtx)\n", - " c_vec = np.arange(b, c)" + "c_vec = np.arange(b, c)" ] }, { @@ -242,8 +282,8 @@ "metadata": {}, "outputs": [], "source": [ - "print(\"c =\", c)\n", - "print(\"Sum(c_vec) =\", c_vec.sum())" + "print('c =', c)\n", + "print('Sum(c_vec) =', c_vec.sum())" ] }, { @@ -305,7 +345,7 @@ "b = 10\n", "a_vec = np.arange(a)\n", "b_vec = np.arange(a, b)\n", - "print(\"a + b =\", a + b)" + "print('a + b =', a + b)" ] }, { @@ -316,8 +356,8 @@ "source": [ "%%execute_with_scorep\n", "import scorep\n", + "print('a - b =', a - b)\n", "with scorep.instrumenter.enable():\n", - " print(\"a - b =\", a - b)\n", " c_mtx = np.outer(a_vec, b_vec)" ] }, @@ -347,7 +387,7 @@ "source": [ "with scorep.instrumenter.enable():\n", " c = np.sum(c_mtx)\n", - " c_vec = np.arange(b, c)" + "c_vec = np.arange(b, c)" ] }, { @@ -356,8 +396,8 @@ "metadata": {}, "outputs": [], "source": [ - "print(\"c =\", c)\n", - "print(\"Sum(c_vec) =\", c_vec.sum())" + "print('c =', c)\n", + "print('Sum(c_vec) =', c_vec.sum())" ] }, { @@ -391,8 +431,14 @@ } ], "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, "language_info": { - "name": "python" + "name": "python", + "version": "3.11.6" } }, "nbformat": 4, diff --git a/tests/kernel/persistence.yaml b/tests/kernel/persistence.yaml index 0ab8ff3..7cb9a63 100644 --- a/tests/kernel/persistence.yaml +++ b/tests/kernel/persistence.yaml @@ -1,29 +1,48 @@ +- + - |- + import os + os.environ['JUPYTER_VAR'] = 'JUPYTER' + - - "" - - |- import numpy as np def f(x): return x**2 a_vec = np.arange(a) - b_vec = np.arange(a, b) + b_vec = np.arange(a, b) + + import sys + sys.path.append('/new/jupyter/path') - - "" - - |- %%execute_with_scorep + import pandas as pd + def g(x): + return np.log2(x) with scorep.instrumenter.enable(): - import pandas as pd - def g(x): - return np.log2(x) c_mtx = np.outer(a_vec, b_vec) - print("Inner product of a_vec and b_vec =", np.dot(a_vec, b_vec)) - print("f(4) =", f(4)) + print('Inner product of a_vec and b_vec =', np.dot(a_vec, b_vec)) + print('f(4) =', f(4)) + + import os + import sys + print('JUPYTER_VAR =', os.environ['JUPYTER_VAR']) + if '/new/jupyter/path' in sys.path: + print("'/new/jupyter/path' found in sys.path") + + os.environ['SUBPROCESS_VAR'] = 'SUBPROCESS' + sys.path.append('/new/subprocess/path') - - "\0" - "Inner product of a_vec and b_vec = 80\n" - "f(4) = 16\n" + - "JUPYTER_VAR = JUPYTER\n" + - "'/new/jupyter/path' found in sys.path\n" - "Instrumentation results can be found in tests_tmp/scorep-traces" - - |- - print("Outer product of a_vec and b_vec =\n", c_mtx) - print("g(16) =", g(16)) + print('Outer product of a_vec and b_vec =\n', c_mtx) + print('g(16) =', g(16)) - - | Outer product of a_vec and b_vec = [[ 0 0 0 0 0] @@ -44,4 +63,13 @@ 3 24 4 36 Name: a*b, dtype: int64 +- + - "print('SUBPROCESS_VAR =', os.environ['SUBPROCESS_VAR'])" + - - "SUBPROCESS_VAR = SUBPROCESS\n" +- + - |- + if '/new/subprocess/path' in sys.path: + print("'/new/subprocess/path' found in sys.path") + - - "'/new/subprocess/path' found in sys.path\n" + \ No newline at end of file diff --git a/tests/kernel/scorep_exec.yaml b/tests/kernel/scorep_exec.yaml index 6f03caa..009f357 100644 --- a/tests/kernel/scorep_exec.yaml +++ b/tests/kernel/scorep_exec.yaml @@ -3,7 +3,7 @@ %%execute_with_scorep import scorep with scorep.instrumenter.enable(): - print("a - b =", a - b) + print('a - b =', a - b) - - "\0" - "a - b = -5\n" - "Instrumentation results can be found in tests_tmp/scorep-traces" diff --git a/tests/kernel/writemode.yaml b/tests/kernel/writemode.yaml index 9c55b67..fd452b5 100644 --- a/tests/kernel/writemode.yaml +++ b/tests/kernel/writemode.yaml @@ -24,14 +24,14 @@ b = 10 a_vec = np.arange(a) b_vec = np.arange(a, b) - print("a + b =", a + b) + print('a + b =', a + b) - - "Python commands without instrumentation recorded." - - |- %%execute_with_scorep import scorep + print('a - b =', a - b) with scorep.instrumenter.enable(): - print("a - b =", a - b) c_mtx = np.outer(a_vec, b_vec) - - "Python commands with instrumentation recorded." - @@ -44,12 +44,12 @@ - |- with scorep.instrumenter.enable(): c = np.sum(c_mtx) - c_vec = np.arange(b, c) + c_vec = np.arange(b, c) - - "Python commands with instrumentation recorded." - - |- - print("c =", c) - print("Sum(c_vec) =", c_vec.sum()) + print('c =', c) + print('Sum(c_vec) =', c_vec.sum()) - - "Python commands with instrumentation recorded." - - "%%finalize_multicellmode" diff --git a/tests/test_userpersistence.py b/tests/test_userpersistence.py index b0ca7db..24f7826 100644 --- a/tests/test_userpersistence.py +++ b/tests/test_userpersistence.py @@ -4,12 +4,17 @@ import json import subprocess import dill +import cloudpickle +from textwrap import dedent -from src.scorep_jupyter.userpersistence import extract_variables_names, extract_definitions +from src.scorep_jupyter.userpersistence import extract_variables_names, extract_definitions, load_variables, load_runtime PYTHON_EXECUTABLE = sys.executable -subprocess_dump = "tests_tmp/subprocess_dump.pkl" -userpersistence_token = "src.scorep_jupyter.userpersistence" +dump_dir = 'tests_tmp/' +full_dump = "tests_tmp/full_dump.pkl" +os_env_dump = "tests_tmp/os_env_dump.pkl" +sys_path_dump = "tests_tmp/sys_path_dump.pkl" +var_dump = "tests_tmp/var_dump.pkl" class UserPersistenceTests(unittest.TestCase): @@ -32,7 +37,7 @@ def test_00_extract_variables_names(self): variables = json.load(file) extracted_names = extract_variables_names(code) # Extracted names might contain extra non-variables from assignments - # Those are filtered out later in save_variables_values + # Those are filtered out later in pickle_values self.assertTrue(set(variables.keys()).issubset(extracted_names)) def test_01_extract_definitions(self): @@ -43,23 +48,61 @@ def test_01_extract_definitions(self): extracted_defs = extract_definitions(code) self.assertEqual(extracted_defs, expected_defs) - def test_02_save_variables_values(self): - with open("tests/userpersistence/code.py", "r") as file: - code = file.read() - with open("tests/userpersistence/variables.json", "r") as file: - variables = json.load(file) - code = f"from {userpersistence_token} import save_variables_values\n" + \ - code + "\n" + \ - f"save_variables_values(globals(), {str(list(variables.keys()))}, '{subprocess_dump}')" - cmd = [PYTHON_EXECUTABLE, "-c", code] - with subprocess.Popen(cmd, stdout=subprocess.PIPE) as proc: - proc.wait() - with open(subprocess_dump, 'rb') as file: - saved_values = dill.load(file) - # Easier to skip comparison of CustomClass object - saved_values.pop('obj') - variables.pop('obj') - self.assertEqual(saved_values, variables) + def test_02_pickle_load_runtime(self): + # clean sys.path and os.environ inside subprocess and fill with values from file + # load dump and compare with file + # merge with load runtime + for serializer, serializer_str in zip([dill, cloudpickle], ['dill', 'cloudpickle']): + with open("tests/userpersistence/os_environ.json", "r") as file: + expected_os_environ = json.load(file) + with open("tests/userpersistence/sys_path.json", "r") as file: + expected_sys_path = json.load(file) + code = dedent(f"""\ + from src.scorep_jupyter.userpersistence import pickle_runtime + import {serializer_str} + import os + import sys + os.environ.clear() + sys.path.clear() + os.environ.update({str(expected_os_environ)}) + sys.path.extend({str(expected_sys_path)}) + pickle_runtime(os.environ, sys.path, '{dump_dir}', {serializer_str}) + """) + cmd = [PYTHON_EXECUTABLE, "-c", code] + with subprocess.Popen(cmd, stdout=subprocess.PIPE) as proc: + proc.wait() + self.assertFalse(proc.returncode) + + pickled_os_environ = {} + pickled_sys_path = [] + load_runtime(pickled_os_environ, pickled_sys_path, dump_dir, serializer) + self.assertEqual(pickled_os_environ, expected_os_environ) + self.assertEqual(pickled_sys_path, expected_sys_path) + + def test_03_pickle_load_variables(self): + for serializer, serializer_str in zip([dill, cloudpickle], ['dill', 'cloudpickle']): + with open("tests/userpersistence/code.py", "r") as file: + code = file.read() + with open("tests/userpersistence/variables.json", "r") as file: + expected_variables = json.load(file) + variables_names = list(expected_variables.keys()) + + code = dedent(f"""\ + from src.scorep_jupyter.userpersistence import pickle_variables + import {serializer_str} + """) + code + \ + f"\npickle_variables({str(variables_names)}, globals(), '{dump_dir}', {serializer_str})" + cmd = [PYTHON_EXECUTABLE, "-c", code] + with subprocess.Popen(cmd, stdout=subprocess.PIPE) as proc: + proc.wait() + self.assertFalse(proc.returncode) + + pickled_variables = {} + load_variables(pickled_variables, dump_dir, serializer) + # Easier to skip comparison of CustomClass object + pickled_variables.pop('obj') + expected_variables.pop('obj') + self.assertEqual(pickled_variables, expected_variables) if __name__ == '__main__': unittest.main() \ No newline at end of file diff --git a/tests/userpersistence/os_environ.json b/tests/userpersistence/os_environ.json new file mode 100644 index 0000000..2dfdf15 --- /dev/null +++ b/tests/userpersistence/os_environ.json @@ -0,0 +1 @@ +{"VAR_1": "VAR_1_VAL", "VAR_2": "VAR_2_VAL", "VAR_3": "VAR_3_VAL"} \ No newline at end of file diff --git a/tests/userpersistence/sys_path.json b/tests/userpersistence/sys_path.json new file mode 100644 index 0000000..dcf0141 --- /dev/null +++ b/tests/userpersistence/sys_path.json @@ -0,0 +1 @@ +["/new/path/1", "/new/path/2", "/new/path/3"] \ No newline at end of file From 72fb0d23ad25d326b4168578edab9c2c7edc97e7 Mon Sep 17 00:00:00 2001 From: user Date: Sun, 10 Mar 2024 13:27:45 +0100 Subject: [PATCH 6/9] Allow defining directory for persistence temporary storage; minor fixes. --- src/scorep_jupyter/userpersistence.py | 42 +++++++++++++++++---------- tests/kernel/notebook.ipynb | 25 ++++++---------- tests/kernel/scorep_exec.yaml | 11 ------- tests/test_userpersistence.py | 4 --- 4 files changed, 36 insertions(+), 46 deletions(-) diff --git a/src/scorep_jupyter/userpersistence.py b/src/scorep_jupyter/userpersistence.py index 48b26ca..728d3b0 100644 --- a/src/scorep_jupyter/userpersistence.py +++ b/src/scorep_jupyter/userpersistence.py @@ -5,9 +5,9 @@ from textwrap import dedent scorep_script_name = "scorep_script.py" -jupyter_dump_dir = "jupyter_dump_" -subprocess_dump_dir = "subprocess_dump_" -full_dump = "full_dump.pkl" +jupyter_dump_dir = "jupyter_dump/" +subprocess_dump_dir = "subprocess_dump/" +main_dump = "main_dump.pkl" os_env_dump = "os_env_dump.pkl" sys_path_dump = "sys_path_dump.pkl" var_dump = "var_dump.pkl" @@ -19,15 +19,18 @@ def __init__(self, serializer='dill'): self.serializer = serializer self.subprocess_definitions = "" self.subprocess_variables = [] + os.environ['SCOREP_KERNEL_PERSISTENCE_DIR'] = './' # FIXME def pers_cleanup(self): """ Clean up files used for transmitting persistence and running subprocess. """ + full_jupyter_dump_dir = os.environ['SCOREP_KERNEL_PERSISTENCE_DIR'] + jupyter_dump_dir + full_subprocess_dump_dir = os.environ['SCOREP_KERNEL_PERSISTENCE_DIR'] + subprocess_dump_dir for pers_path in [scorep_script_name, - *[dirname + filename for dirname in [jupyter_dump_dir, subprocess_dump_dir] - for filename in [full_dump, os_env_dump, sys_path_dump, var_dump]]]: + *[dirname + filename for dirname in [full_jupyter_dump_dir, full_subprocess_dump_dir] + for filename in [main_dump, os_env_dump, sys_path_dump, var_dump]]]: if os.path.exists(pers_path): if os.path.isfile(pers_path): os.remove(pers_path) @@ -38,17 +41,21 @@ def jupyter_dump(self): """ Generate code for kernel ghost cell to dump notebook persistence for subprocess. """ + full_jupyter_dump_dir = os.environ['SCOREP_KERNEL_PERSISTENCE_DIR'] + jupyter_dump_dir + if not os.path.exists(full_jupyter_dump_dir): + os.makedirs(full_jupyter_dump_dir) + jupyter_dump_ = dedent(f"""\ import sys import os import {self.serializer} from scorep_jupyter.userpersistence import pickle_runtime, pickle_variables - pickle_runtime(os.environ, sys.path, '{jupyter_dump_dir}', {self.serializer}) + pickle_runtime(os.environ, sys.path, '{full_jupyter_dump_dir}', {self.serializer}) """) if self.serializer == 'dill': - return jupyter_dump_ + f"dill.dump_session('{jupyter_dump_dir + full_dump}')" + return jupyter_dump_ + f"dill.dump_session('{full_jupyter_dump_dir + main_dump}')" elif self.serializer == 'cloudpickle': - return jupyter_dump_ + f"pickle_variables({str(self.jupyter_variables)}, globals(), '{jupyter_dump_dir}', {self.serializer})" + return jupyter_dump_ + f"pickle_variables({str(self.jupyter_variables)}, globals(), '{full_jupyter_dump_dir}', {self.serializer})" def subprocess_wrapper(self, code): """ @@ -56,21 +63,25 @@ def subprocess_wrapper(self, code): """ self.parse(code, 'subprocess') + full_jupyter_dump_dir = os.environ['SCOREP_KERNEL_PERSISTENCE_DIR'] + jupyter_dump_dir + full_subprocess_dump_dir = os.environ['SCOREP_KERNEL_PERSISTENCE_DIR'] + subprocess_dump_dir + if not os.path.exists(full_subprocess_dump_dir): + os.makedirs(full_subprocess_dump_dir) subprocess_update = dedent(f"""\ import sys import os import {self.serializer} from scorep_jupyter.userpersistence import pickle_runtime, pickle_variables, load_runtime, load_variables - load_runtime(os.environ, sys.path, '{jupyter_dump_dir}', {self.serializer}) + load_runtime(os.environ, sys.path, '{full_jupyter_dump_dir}', {self.serializer}) """) if self.serializer == 'dill': - subprocess_update += f"globals().update(dill.load_module_asdict('{jupyter_dump_dir + full_dump}'))" + subprocess_update += f"globals().update(dill.load_module_asdict('{full_jupyter_dump_dir + main_dump}'))" elif self.serializer == 'cloudpickle': - subprocess_update += (self.jupyter_definitions + f"load_variables(globals(), '{jupyter_dump_dir}', {self.serializer})") + subprocess_update += (self.jupyter_definitions + f"load_variables(globals(), '{full_jupyter_dump_dir}', {self.serializer})") return subprocess_update + "\n" + code + \ dedent(f""" - pickle_runtime(os.environ, sys.path, '{subprocess_dump_dir}', {self.serializer}) - pickle_variables({str(self.subprocess_variables)}, globals(), '{subprocess_dump_dir}', {self.serializer}) + pickle_runtime(os.environ, sys.path, '{full_subprocess_dump_dir}', {self.serializer}) + pickle_variables({str(self.subprocess_variables)}, globals(), '{full_subprocess_dump_dir}', {self.serializer}) """) def jupyter_update(self, code): @@ -79,13 +90,14 @@ def jupyter_update(self, code): """ self.parse(code, 'jupyter') + full_subprocess_dump_dir = os.environ['SCOREP_KERNEL_PERSISTENCE_DIR'] + subprocess_dump_dir return dedent(f"""\ import sys import os from scorep_jupyter.userpersistence import load_runtime, load_variables - load_runtime(os.environ, sys.path, '{subprocess_dump_dir}', {self.serializer}) + load_runtime(os.environ, sys.path, '{full_subprocess_dump_dir}', {self.serializer}) {self.subprocess_definitions} - load_variables(globals(), '{subprocess_dump_dir}', {self.serializer}) + load_variables(globals(), '{full_subprocess_dump_dir}', {self.serializer}) """) def parse(self, code, mode): diff --git a/tests/kernel/notebook.ipynb b/tests/kernel/notebook.ipynb index f0490ba..f1fa621 100644 --- a/tests/kernel/notebook.ipynb +++ b/tests/kernel/notebook.ipynb @@ -20,6 +20,15 @@ "SCOREP_EXPERIMENT_DIRECTORY=tests_tmp/scorep-traces" ] }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%env SCOREP_KERNEL_PERSISTENCE_DIR=tests_tmp/" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -93,22 +102,6 @@ " print('a - b =', a - b)" ] }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "%%bash\n", - "comm_files=(\"tests_tmp/scorep_script.py\" \"tests_tmp/jupyter_dump.pkl\" \"tests_tmp/subprocess_dump.pkl\")\n", - "\n", - "for file in \"${comm_files[@]}\"; do\n", - " if [ -e \"$file\" ]; then\n", - " echo \"Error: $file not cleaned up.\"\n", - " fi\n", - "done" - ] - }, { "cell_type": "code", "execution_count": null, diff --git a/tests/kernel/scorep_exec.yaml b/tests/kernel/scorep_exec.yaml index 009f357..16fe8df 100644 --- a/tests/kernel/scorep_exec.yaml +++ b/tests/kernel/scorep_exec.yaml @@ -7,17 +7,6 @@ - - "\0" - "a - b = -5\n" - "Instrumentation results can be found in tests_tmp/scorep-traces" -- - - |- - %%bash - comm_files=("tests_tmp/scorep_script.py" "tests_tmp/jupyter_dump.pkl" "tests_tmp/subprocess_dump.pkl") - - for file in "${comm_files[@]}"; do - if [ -e "$file" ]; then - echo "Error: $file not cleaned up." - fi - done - - - "" - - |- %%bash diff --git a/tests/test_userpersistence.py b/tests/test_userpersistence.py index 24f7826..9aef91c 100644 --- a/tests/test_userpersistence.py +++ b/tests/test_userpersistence.py @@ -11,10 +11,6 @@ PYTHON_EXECUTABLE = sys.executable dump_dir = 'tests_tmp/' -full_dump = "tests_tmp/full_dump.pkl" -os_env_dump = "tests_tmp/os_env_dump.pkl" -sys_path_dump = "tests_tmp/sys_path_dump.pkl" -var_dump = "tests_tmp/var_dump.pkl" class UserPersistenceTests(unittest.TestCase): From 1ef9b8c0fc40f7364025084fbb7cc719235b99a7 Mon Sep 17 00:00:00 2001 From: "s4122485--tu-dresden.de" Date: Tue, 12 Mar 2024 11:06:38 +0100 Subject: [PATCH 7/9] - create a Path for subprocess and jupyter dump instead of pure strings - have a function to return these paths --- src/scorep_jupyter/userpersistence.py | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/src/scorep_jupyter/userpersistence.py b/src/scorep_jupyter/userpersistence.py index 728d3b0..3fabdaa 100644 --- a/src/scorep_jupyter/userpersistence.py +++ b/src/scorep_jupyter/userpersistence.py @@ -3,6 +3,7 @@ import ast import astunparse from textwrap import dedent +from pathlib import Path scorep_script_name = "scorep_script.py" jupyter_dump_dir = "jupyter_dump/" @@ -21,13 +22,25 @@ def __init__(self, serializer='dill'): self.subprocess_variables = [] os.environ['SCOREP_KERNEL_PERSISTENCE_DIR'] = './' + def get_full_jupyter_dump_dir(self): + """ + Get the full path for jupyer dump + """ + return str(Path(os.environ['SCOREP_KERNEL_PERSISTENCE_DIR']) / Path(jupyter_dump_dir)) + + def get_full_subprocess_dump_dir(self): + """ + Get the full path for subprocess dump + """ + return str(Path(os.environ['SCOREP_KERNEL_PERSISTENCE_DIR']) / Path(subprocess_dump_dir)) + # FIXME def pers_cleanup(self): """ Clean up files used for transmitting persistence and running subprocess. """ - full_jupyter_dump_dir = os.environ['SCOREP_KERNEL_PERSISTENCE_DIR'] + jupyter_dump_dir - full_subprocess_dump_dir = os.environ['SCOREP_KERNEL_PERSISTENCE_DIR'] + subprocess_dump_dir + full_jupyter_dump_dir = self.get_full_jupyter_dump_dir() + full_subprocess_dump_dir = self.get_full_subprocess_dump_dir() for pers_path in [scorep_script_name, *[dirname + filename for dirname in [full_jupyter_dump_dir, full_subprocess_dump_dir] for filename in [main_dump, os_env_dump, sys_path_dump, var_dump]]]: @@ -41,7 +54,7 @@ def jupyter_dump(self): """ Generate code for kernel ghost cell to dump notebook persistence for subprocess. """ - full_jupyter_dump_dir = os.environ['SCOREP_KERNEL_PERSISTENCE_DIR'] + jupyter_dump_dir + full_jupyter_dump_dir = self.get_full_jupyter_dump_dir() if not os.path.exists(full_jupyter_dump_dir): os.makedirs(full_jupyter_dump_dir) @@ -63,8 +76,8 @@ def subprocess_wrapper(self, code): """ self.parse(code, 'subprocess') - full_jupyter_dump_dir = os.environ['SCOREP_KERNEL_PERSISTENCE_DIR'] + jupyter_dump_dir - full_subprocess_dump_dir = os.environ['SCOREP_KERNEL_PERSISTENCE_DIR'] + subprocess_dump_dir + full_jupyter_dump_dir = self.get_full_jupyter_dump_dir() + full_subprocess_dump_dir = self.get_full_subprocess_dump_dir() if not os.path.exists(full_subprocess_dump_dir): os.makedirs(full_subprocess_dump_dir) subprocess_update = dedent(f"""\ @@ -90,7 +103,7 @@ def jupyter_update(self, code): """ self.parse(code, 'jupyter') - full_subprocess_dump_dir = os.environ['SCOREP_KERNEL_PERSISTENCE_DIR'] + subprocess_dump_dir + full_subprocess_dump_dir = self.get_full_subprocess_dump_dir() return dedent(f"""\ import sys import os @@ -234,4 +247,4 @@ def extract_variables_names(code): if isinstance(target_node, ast.Name): variables.add(target_node.id) - return variables \ No newline at end of file + return variables From 0b81ae7734341a185c4740b7484d52d47cd1552d Mon Sep 17 00:00:00 2001 From: user Date: Fri, 29 Dec 2023 15:15:21 +0100 Subject: [PATCH 8/9] [WIP] Remove set_scorep_env, set directly into the environment. --- src/scorep_jupyter/kernel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/scorep_jupyter/kernel.py b/src/scorep_jupyter/kernel.py index 5b3e725..d9c36e4 100755 --- a/src/scorep_jupyter/kernel.py +++ b/src/scorep_jupyter/kernel.py @@ -223,7 +223,7 @@ async def scorep_execute(self, code, silent, store_history=True, user_expression self.scorep_binding_args + [scorep_script_name] proc_env = os.environ.copy() - proc_env.update({'PYTHONUNBUFFERED': 'x'}) # subprocess observation + proc_env.update({'PYTHONUNBUFFERED': 'x'}) # scorep path, subprocess observation incomplete_line = '' endline_pattern = re.compile(r'(.*?[\r\n]|.+$)') From 0be0815b8b477b2a622529d46664c6dd546517d0 Mon Sep 17 00:00:00 2001 From: "s4122485--tu-dresden.de" Date: Fri, 15 Mar 2024 10:06:09 +0100 Subject: [PATCH 9/9] fix readme for removal of %%scorep_env --- README.md | 6 +++--- doc/scorep_setup.png | Bin 27058 -> 28731 bytes 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4b11f3c..7437222 100644 --- a/README.md +++ b/README.md @@ -52,9 +52,9 @@ You can select the kernel in Jupyter as `scorep-python`. ## Configuring Score-P in Jupyter -`%%scorep_env` +Set up your Score-P environment with `%env` line magic. -Set up your Score-P environment. For a documentation of Score-P environment variables, see: [Score-P Measurement Configuration](https://perftools.pages.jsc.fz-juelich.de/cicd/scorep/tags/latest/html/scorepmeasurementconfig.html). +For a documentation of Score-P environment variables, see: [Score-P Measurement Configuration](https://perftools.pages.jsc.fz-juelich.de/cicd/scorep/tags/latest/html/scorepmeasurementconfig.html). ![](doc/scorep_setup.png) @@ -127,7 +127,7 @@ Enables the write mode and starts the marking process. Subsequently, "running" c Stops the marking process and writes the marked cells in a Python script. Additionally, a bash script will be created for setting the Score-P environment variables, Pyhton bindings arguments and executing the Python script. **Hints**: -- Recording a cell containing `%%scorep_env` or `%%scorep_python_binding_arguments` will add the environment variables/Score-P Python bindings to the bash script. +- Recording a cell containing `%%scorep_python_binding_arguments` will add the Score-P Python bindings parameters to the bash script. - Code of a cell which is not to be executed with Score-P (not inside the multicell mode and without `%%execute_with_scorep`) will be framed with `with scorep.instrumenter.disable()` in the Python script to prevent instrumentation. diff --git a/doc/scorep_setup.png b/doc/scorep_setup.png index 37efc8ede04ff41cce3a0854c9e9301a5301d05d..5e9388e5e414c2dc63df86a132b16f40c8a1a18e 100644 GIT binary patch literal 28731 zcmd42RahNemoAC~2u?z9OCU&aw}k`<5Zv9}-Q6u%2=4B#3wH_b?(Xh>im&_MeQ|a_ zr%(63*cYr-)T){_YYu(iF=mLgq%bl9E&>Du1hUA_AF>b-P=~;A%X?Vh6QPpn1^9xu z{;6UI0fEx}_6HeDg@OkG@fkwo2cNvN=Fzf?`loB6zVq3WD*VAr+hE}}72UN;XHw@i zAGjS4HA6mwB-JEgu+od@FnZ)fMb|*K_}?k<^LOP)ITJM{pCW(o$yhhs+&x1!(GGqg z!=2x~cRt#LOixTrO(clBb>DqVRY(vgL+1|$RoU%I)~J?RG^mr3kuAjeqw&ADNtau| zd^`B!kA~sr?*M!O$CMQChyR@7mzVxH6Y$T^1sO-?h!7lFqn&rN@Qb#UG1Jf1d)G-8 zD(_`}?d=&uJY4PTF$unnvyrFU)p*`X$Ig9QXwjgoa>{L~_MqhQd_8-9ey+3iH>xgr zKtWm>?Q(Z?H%geWGWF}r7tYj(?(QFwDSvqxt}ibIil?*2jm>wcy|8H2D`?=~X7x$N zT$zpO$@6_z`s!jFr1Z3jPmw0oqU{${p1X`~qi`VsKUidJ9(Q(bx0{-&cxsi-r~6Bq zHfa03F`t40iU@p;&I~@ky@gt;!FW14=E!m59FNDF6ke}1Ezf%xI5;>6$#kA!;2sX= zv(K~TTKPiZxL3!E@iN&$Iz~$Nc5ukJ{>8<`7Ai}-f1;y9!ot4N&ne3?Z%od97%h%Ie(~dF)=Z! ziS{=MwRCA_7t2d-#uS##Nlx8$?f)pl$AA( z_w^~d!ETq)eoxx*WT`)vO3C}ll3mN|nFSgase|wkH`y{zki`zzG@z% z`{Oa#{eC;%{q^~l*YItID z^8Dh0Ql9A&_`FKWbvbF+l`fu^FH=t(&lbKsC@yETSYTGIHSOsQ#|sG#UK`64z(#`Q z=jW#$#beNYx3aPVadk8w4XA}m9wgVx-KKnzJiIqB;j@>=Q@foZoP()CIb$B1*UFZ| z!7776h+pcZAKvC(p_muvAh?ZdtQjoCbRh>g1#|M%Iz%kmPX}IvxVQsEUQaqM34V=< zvW5CC+U0KggnbFO#4XFNGMsoU0dadlPoEK@ASEi5bq z{8k_`o}tNlWJE-sR-0#mJmp~Xbg>fnsYJ1O{O~$%)rWuaXln~FOtZ|B?;9(|V;Mdc zg~3%rByWDq2E5EzQhZR;e&2%~!-AVtGLAO`#^w4`<7x6B7Q`Su7$r`1o`}>xJqO3^{NTytxq5=^{nDV7D*y^c79eiEZx|S*D`#%I}d0q z2G#-z)^A%zo%aOr09n(fiCiv>0)a@CsYqK+-`E|Gu;ub4w*kwGD`Nr-kD7qfK0`1R zQ@!5WCtlOFQ2H~t)_z~s*4Fm#-@n@X6S@8Y0gxH8h1kr-Xw`4pM@8`g1Mf}dN9I~t8yb=UCb)gSn+|qy~2j*kE+RE+X z;$nX=6#~3jKcmOr-(RRT8e>$d4OO@gXXGg{=$g-!V>H44G~AJLJYQh3c7oE zVDxyf|M>AEwVwzcv+;Lr?KKV51L+#nrZ>0!!{!rNbicmZ(7h{WFJjwVTxc$!FhQE- z^Kq}SBPkpLK_~=wfRUS%tE6x``~qC6_1@T*l+;uU^3d0URFD%O6N|xZN(P4Uy03ux z1wX1+(j4B3-O*YBvwicM9e!|P(*HPNG^f<74vK8>s%J9b!GKtY;1K$cJ)vz_eyhU^ zL)*Cd$%g&r%9fBPs%YPo`O6O&QXP%AmF5pdD4K)J;}2dWUzUISNoFNywDIi{Acn`- z`~BNMS-yWN{@*u>cK)vm|0CEpIAm7@hlc76C$T^v5pc$^S}e>By*=ptmzM~1a>)#7 ziZ{bL&XMge#Vh?vPL57Qg!#P44q_JIm!U zU8E2Uicd-s1;Q-^5O1BfTQGnHW!SrR^?bQEI+!hw3=KtuNK8z8lR2PFM@Ko)UC%%B zCqJO0cL8x49tDMV>bIS{c{THNB3vcCKwtsKqYR8$9)DbYo31YoA^vB_w*r$Hxt6WoWN>&lGpD{(87!F{u_?zHvRLZ0)PagjMwN9xoqfi(og_=T8wBd$-y z1s)X@OoL&|R`z+%o)btY{Drc0h7uS6v)7vblJGMg#UnaB~*H!xsuxzJzCf3fKA z?@!@!`7<^)#yEY7FzMi_e#y#&RcTG*rG_<$j)9L7Gt9de=;#OpP+3`7gy|fG=Mfw_ zqOUv3$gTl*Syicbs3L&o_eBu&z&e)tAYvtSmr1#M60N#%^F>VgDp04J_1}mG00I0m(Fr*^D)C8~5wbZ!iTaLB;G@D2_x9=o+PQEhIvL`6;&k?WuybBkLc)$%q9~weIiebfVcl?zc}@Zn!8}(N z1~RX1s#8tfGvV=O?s^;+ji~~SBJ{CBxYD9sud6LmtDRvUiSu*&zU4>01@7t;lKmYW z=7y(A9jHuH7n%7MPR~sAdsaacj9H1Cp5fg}#oDurl3!MnFGIg1I*b!FD+PsxvD~Hy zmPRX=u$dUtUvYEo$Ujk9#h`>sv%k}(*!!r*n4{hNA#|XWjY313lex2OLXQ>k1OJo8 zPDYt*VS#+%$5=`Q{@j7c*q(qpP@a_Gp_|_b;~0TS4)!NL{C=<86m{?p3z<+>6ZFBGJNo9JIgu%}9}=r(WCD zQeiJfk^;DvS%TYpB}|ykFk0CQyJBuINRk68&+0oEo4#2%gH&Nza}8#Vlx@!_ca&ze=6ZaixZj)R2`*+q`)S>WrI%re zPrp42j^Q@+rx5o-prHirWarL3*cQm2eEs|^21yYB=UF@6w4^#SxVW3{rD@y)gbJT0 zOiLyrqIB8EyCqsJEv>G2S}yu^R14j;oqBoPHPilI(BIge%^n-_=%e06%n-E*sI14VB2X-W0PON3Dx(cOl~ z8~U2Gv~C!wvmyV;!ZtErRqypH*jDjJZ-pkT&8TZ8D&cVLp^`@P|Ll?SIq#%y6e|&9 z-wR>1YGMbHd%sW=&%%AUKDUfh>fh-5OYUO6LvH`@0E4@db-%KNmzi^(*26IB2Dm(3 z-dn3j-lWLL$ZeD_uNXiChZgOJoRJk3yj)f9$ERp{l{eZt%WiA%N? z0X~MK{NV*PbhBfXjZ)q)l>!%Tc;Gj=yx&PlrVXw}1XEprmwRr10U^!mQI{SViUtW$ zq=GWrZa8{N{N@^Xsh~vzD2(>^_v129?w2)hj^?`n@l*2=Xpg3G=j7+BdA7+7BsrXm zERM#H!-=$J=GYzwPT9(R*CbA6J=|k8WagVrmF8%aYl>h)S<-%;ZL&Um<$@-%7Ye~G z7ZzfuT87Ee^Q1J^V9PY-_`?Rr(kYhRAyZ1?*~NEmNDiA5mS zFWW)bn?s0AE%U_~f3G&psc#JIppRE`(Z7N>7tt7!;b?wm< zrZc9d@=xo($S+@$Hqj_K62N;B`(hG0X4v<%iGkc-2E1N)*XvYgf{<|d<7qV@?oOIc zPY$--7BTJYLS0Qfice$i6*++*x3#4&M&wY>5vDS5k$#i)|b5N}`pP+!lk zSgP9dhg9rj;Cy>)>&K5D?||Ghp!c)#?4TOyEu}R_z@U(GK?17$W^QPXqN_KqVsdfO zx&ZQ~fvoIxj)tUw!eo$XT!oczEv|yh3xf97?M^}4h@lJz~_hi+@#bT)9OBiukR!AZMQst)n& zHB*-y%r?cdD#lcYYc6iEyWWWPEeoJ*KD^VtuV>ARw*3~m#Vs}dfOuJrv^6)Kb&QN& z03m3wM764()mX%)V43ojn=O__3cOzH(cqmlGkII#O5x3Pa&i-QkAZ7d)TmC*y&UXu z=yP@H8iw37VX|mUwVGl)vL-k!BP;9b*fTkKU1Hpzp$I`; zM7cP|uDmQQ&Kh4a<|hM#X0GWR_4?osqrQAe9O_Sp2q!CqaS-Lw${u5s%V$^ComNw- ztYJl3`r7&HPVF(hx$-1j()2?68!!{rSGzt)wE2d7o<^ABiCA}3 zli@*BRD24No_@GvyN3i{QdyN@UEPrx7F#Fxt*1nX}7h+VBL~>reekCKs1(}$s zBW~Z{>6jM_jav1F5MgZW=`nxPophRKmi1ANoJb0w-R<{2cPV*2feFQKR+|0k{Oh>^ zSQe+~@RvwDDJJFzkc?;ggri3~vSgef0?2bpSz zdC`cR9Uq3!m{m!;>4MB*Z%chE^??U1CWb8X>oHr!qf*IoVQ5j~X@^XUSLw2n)X|0X z@+>%U4wI*w%XH=5-#cXIJwZ0=1JIk-L@Kqqa0e(FV{!I}c57G9}6rizXIVzq= zP!vwZ^I<8!i_{Vd7`r^l8G5Ah_jw*$%Rx{S9BGDxtwz|@aFe+UVwT2f&X z5Lhr>pHLo$ZEDiP-CS7ev=PBduY|0vt+~DA{&d>pIRFC$LjuT&Rz^lfu$LFe$+6t> z6F}PlE<1vV2MkP7CV)27@xtMF*5xp^Y=|RPv~D246xr>328(Ds;};QK$zxcFjhwND z9sO)!*lRXjrAUAtrlReEMvbPPsVZ#`xdZn2QMi*9Tej6`Em_)^V+Qy>x)%erkNhz*+XJyC0I>s5a`+s! z@BA{YKlM5l%Mr)c3MuF$e@or`D zE&i(f(Y#j@z5ecSI1u-+TX~Son6g(NKnLzGH84d*MZGa=-IzE3PNnRQq>81$eb=Gf zmg52d{SbH@5lEG9<2u|5>n-Ug^CYM)P^Vnc5)_MRX6YypP*KU*+3UM!YJsQ+WXgl3 ztDerz>;QKHMhXGqWVO-g3(1#T&lrH`Fg2z8O*COy`wDO{PfZ3!Ms^QZCWG~#|jBV*NH zrSZjsr3SmG4zQOJ0NxkDR$v+|DrV-ZG9>;_0QJ=UynPyYy##h*$ab)K)(^`eN*hEU zP1iOS|0H0xq)2z-B@f&NfK0)YY=AN80yu3k3mG=5^{ef+e!%a&`U6#&l`1bem6fR~ z1SF&wh&MVc7-8^kmQ(23E!l4>dp4{G=>ZGIfg+`L2x8JPuZDOxF@pM{sG_i^Q4TAAMp7kHq#Fy(^mDO4^EWqI3@}fEmwZ;Bi zPWwGHrE*QdOzE*&Go{7;e_NcV?32*AYj1CFVm@vVC>bDPD=ijT0i6>83PH>S89>>> zxK?%XLPPsAsU^c^AhLFQg$;FxRF5%ekawWH$79ghMq60bw6?~0F}!1xPWzU$xRyBw z?RDixU|=1Y^rA1T4oO18wR(r+maXgH>-$qef5k;#6lg771%6I4h%R{IN9Nwu)pT!f zA!9WM{k3|*BhGv_x&*EG>+H%l-j|t3vaUP&d5GRmGyZJDCX;2Sgzu#Q&a;yf#aDZtgN@Vi^)}^ zHy7hs{H&SLZ2|pv{mSVQXOZ`{-WX>h*$A`8rsv(($xf^yWPldi=LEKm3kdXCJ)vym zF-GQtQQupU(Q&7yN%ti~dx(Vkb2B8X$RmlLh&02C_Pgibh-|k^U(%W|^mfQJSMf!w z6%=gLCVq=xU6>oaQOzfaw}Q%a5&W6$i;KyaG^%rwjkmTp)%Kc<%a~<)$4y_JIIUoW z%^4?o`_Fbw$t0g(hc^fBZrZ;%S||_Pjj+h5RH=~#t$GBWCr=?MpRt_7>8jk0pdA!n zCqQ)dQ;=Gj6w6^yn*6?kQBIu%O76 z*q%r9@#jwpXZKeIsk;II>J<>YaKMX*xVK%(k-?=H_S_|!OH!#-2&g2kh@mX2JMu#{ z<%4@#wW|hb1BT6R|HyQ1gne@h=01^hxYg@EY0$UJ%567WL-715O8mtVnn^WHXZ#N4 zXq1*&uHUlKl2it!`R7p7(>ZQ;T>ir;l&2Pdo>9}Xji@L<0i#)OUt6bl& zjaG~neV)x9?;HqA3qOy(n*;0ZR3J3K{HqtB_1#C5k?iE&@U&2rsNWT#CVU6Uh)Ps< z$Ab>|!wo8oOqz3lbAmbWZ}CQhKqwV#hTlfnl&o30Zu~Rf{TjHNChTd_Ap2m>BmU;* z9>+ZJW(e@{3pAQoEiEm9lDjy-S>xe}+1gee=PVjA`anrjW@ap7v|E!zo1=dx#Bvm7 zEc1F_ngssLeq>_k-6XQs+X;-cZCp3%VZ6d-vvDJ^Y>ztX6C7G>aXC zZlKG|@^+2_kc6l5ARAUF;x8fE<= zPm20^e`1?0bEONUlW*fyL-;ehN1mEmmWO$dWN+QREgIfMmn&L!>8u@!AKiRbz)vlZ zErw}u7Koo}J=*Xr5ENoDFAbJ{MU;JS(X?ceV+0fPV!JQPYC|rA? z9_>Kh_Q$47{0wyZ*C{0!R~U}?Px|?vJht>PsTUh)A|G!_x~n-Q1L9}^X)b9E{6=_b z0=hVGaB#MER*K|0Y1rQ}xHi+NX<-ljhHo-qXGm#mv)uFplQ9{rkVzWwjYti;ylg9T zr9J=F2tv6SR+G55>DA}qbYS|ugAWStOW1b5nPw~5OC0#slU%R=4nO(BOn(ITDj`|1 zw&rrj@KNpQ59N&P?R!!i<1zoBGD}k1b=ZxFRFbZ|Y2MTLcb-FLKL@`{tiwa-+dM?J zSzndPp>06a(m4>0**&1zvEfiPcvTvm(or08{Wxx8((SZP)l+jkZ%UqFSa%t z9UJ@9`gHMCkk%QSuA1$JyxDgFeSrr%GKb(uf+iDwC1>-to#9Z7fEXMRg+jsrL%`pM-Ekm(xs?D*~3# zeD105gmA2$Jv|xmiDR`WxZly)eRX__@AaJ+*RMSwrOlegm&x0b`BL*B*}bmG`-RRF z7bclY5R+`wIGnQy2vV5l+elI3$4HsK2~5aNyZs`SiM>19#9nc+#5A%inkcz7)=V7> zeUSVeAH`RJI%OgjW|gwh|-79uOrCslae7$(ymQ&cs2&kiP$8&Yh; zkFqbZCrBnPs9Q!pfFEFHpCNOjVa2{Gev`pP!bVKOIfiPStR1j3pHJs|R6IAIKb4$~ zeKBzNi>+rPY!|zPJ^SdQes4g9NH(gyG4NoHeM@huq=bJt(LcoM(;^`+%f7btj$>Yt zAXcS0sigNv#ZiD22VyB93D8#uP+ zx??>mzjE?)R`lLzulm=O9wj#G5h*=h_h>+7WQfL_lz0{53@n+YtlaWU=80$R9Ngl_ z4pwFSiLJNX&a8RXe5g~fvFQ@bBq6Cz=ynH9g=5dHh6l5p*A%Er%hj*#Kqca^(L+*> z(yyq_`tSAo&1FpbwZKbQL~qa4<(0^b%K%GX9~nzC7y`=aWHV>9b1Rnl-M9XVys^tV zsJw{SnnoYEz3Ro^R}_1Wlq`5QLTWyw^BDsTizFs$_PN=wwq?c${NXGo&|(=quIX%{>N&3qU8JVl6`pEv{f%o4feK zGU5X{7I_S%a7yZxzCq#G%}L0mlbDOuD@>Jy_Fu_OaJ@u#ybD_Hug2xZb-Ob=!!i!> z(v#&vxn6xU_J%0ZVyE-V1}go1yLww)CNsyU0I4oG8>kqCCiE4xuDXWmV4gT3M@h18 zeRb8IyCz5l#&fk)t$l6U+aq@R+nU>bQoLW|9IxIxQjy7XOx%T7C#44K3Z_g$$Y-Z5 zE#ceRA`mt-^f>z6H6ghu4vzGHQPQ$+8S)Fazj<>X)Sp@7E`0V!9lGOTg3} zCHX)yfBx{^RnXThbk~_`?t^esC3WHpp&b@depl!$Ljl$1B8C5%Ql~kVG_gXdrF}U# zhJ%;QZ~#A@5zJ4euHJv|2@|dOGSYLUK6O$Hf5htN^1<_dP`dsjvjM4{(vaRhqK>!} zOGe?!?=rnrA;^L!VaC^CGx2ngcSeXS<+NF!+@)5w7=~aa7R;x#WtYZPQR28V9cSxz z5H2E@`}5|l)8Yy^RR^FIW6$a+<( zy22VIT&IEwIe^RcvaYCUBL3}=wZ^{~$t(TZ^Yrp=6g*c)-`qG#JW%8B6fZbtaKU2j z=H2T-hMogqdwU_Y$l#u;Vm))tPsRF;a`LogdB|Mvv2x>Qb;onT<@QHO40(A}q^;#x zQOpsqOz5`pazztVJl^K{n&^>Al@%xEN~V{mY= zj`|;j{?bLO)hb@9YJfAOIMv2eqV#neV}@m9Bre^7&2q_Rxlu7mjV3>9_Mhez$}AB4 zp>0dO?-)BMyU8Y7x_yQn6ff37Jy4bMOZnA#UUrC^0YPqKq5!LFar#LKWr#jybugcS zcR7BM>@7d9ijKRswI`=J+(APyFcWNDb~P!1uCOTm*Z7Sf6P5T1&4`nK2l>H|ld1j= z4Zr@2Ej{puxBfmc^*v#Dtlp4ay_H{iYS+wWsSu#CZfQ1qdH)kZPQg=zD_PmyOQe8#mjd-P|diljMuQ1mIEtEBXv zo}c008~fKO2*XCGb(h)g&;_!c9aecV-`w`CK8)*Q6OrMyCv+yA<=xkML*T-}jvLJV zP{eOmUy9GhjC~|PuVEd$LH)c}%f0nyFm2ZNy)k}olQ3dy?#zD=4`aE<7lzBu9?x8$ zjOGY@f4PK$N}6EIK^=WLHS-+ILV6^i=ar*N&c2&_{9xkhRZwSKzPWU+R)In6o^ zy-{gx*7aRlZl0jbIIHp2UI`u?^lIdZn%97GarH3mXr&NOXAiOrH;?BIl)&-k331U0 ze{cLs*~E0Y%cc=(D1WI*&1tCFhX2QjT7StqqGp1Xgu)(e6Z3RkH}R#y_U_BKl(fUA z!iFK3+XMyms?@hm>4MK+OI8s%9#uH$5-kyDD{q$3JX68GB#Mdq>4tZ9Ea>E(emmy| z?rv*nO{~-g?z>3^)E(r}U8g)_t91$t9kxjBXKW<{mqd z?9Q|eY+(zhDc2_ZHSw%5G=+=19*d2rS!6WM>(?o_(FMhgk7_{P949o;`S|-??&aBJ z?`8>n{d%OU`ukxgT1;p5ij?*Fcl$bOpzLxc@}$%2_s-0KD{Gd~%yu3-RI|FgPj>Q0U)izFn zj%DEp&~4`PLqwj?E_KnUBM~Re1TUWm!rbOQrQ6tv#Zt;svW!e8GnHt44&3Cmop|sU zGzn^Hfkr`rL3LxH47j$(aHPaQi9MXKxy$B?@$lU=8knj>@fNkHHHULMIvxsYh1$(H zVZPU)S^cy~ZMHRWq8u@MGFY#PYEA8k8gF4FM+tb&g4E*4W-~L>yw?FMK|@NF{zFAf zgcwl_%34^fV>H;>aqE+Kbal4VyFWKg=TMc&=;ufG%pOGcJsrni9(X6wBmIZFUkqwb z5MqZPb(PLC0dT!S2nPp>L6DxC-_*8>EGj83lg_MjYUeBE&wK;aE#?p`3l)wj8m}1n z=BtKXIILw$zlv7P#P`P`oP^1X?qDY#_gC%;+hcM+gK`u4NMT$WAa3qv@%k$kN(&B+ zgRXVn9IiaG!SgKTiN63^cHd*wbwCGFVnuQwdRO7&AuErw8Kc~e#fCiQ)>BQ&u2w|P z<~-`W!`|oB=IIzBBk>)Q;+#KrN6dtJahLN-s})&BlvJ2j8-G4nn+qwEgKpg25=wks z9N^fhXIs&Ad?Mm@Cj z4n|qo$rd>lONF~idFfh`=oLNDI+}k(9Nri`1OyJz)#Ee^6_V2h)-eWC28?Pzw96Ew*M^1GY zqwt((3d2J!plX*-1A$Exxb8s3xYaZBsx*`?|Ngngg7d|Q-573M>6yh0H97JCsxh7Y zp_p4j^4%oQ z7D=MP`3{-pr)z5#g5p{2K88X_8_(DrUY7*e>7czSjQuzWGBFR?>21&p?!UIYhjS5tYiT0LU zRspqov<>=(MQ0YEKybQQAUF9phc-O>y{eA!^Bu@6YwK<+h7=l~Nakp9^YPeg1<5 z+L;*2Pta3ErOM*G9UkLnjn`cD}Vv|Yn7xVUA=0~ zgFBB_+rkv9PC!%IVUT5=J&AXiFm++*Wm=m|E?WGhXh78idr2G341E2=tT3s*iZ1R) zpD^~!9N|&&SY0f4wA0b1XIu?OMxA4iw6{Ce1kty|O9syGoO!=yh zv!!8EG|uOfuj4%m;nBr-xDFkUdfu|PO`Y)JBsqq={2ej|1N~%h&?97 z>Dxsios}0Y@COhS=SPe&Yuke%Jj=I+)AJZu+sKSo*bpH6MRCL9#+~MACmx&zvKUJ; z^@D_8DCP>@jrE}dKt}rmMzM~X{Ny*nYDNFhaj6g=VbCjEaQY&(o(0{VYbSP`*DE|h zv0^WiIp%nIZO$HA3!}Z5G)sotY!O$j1Cdd6+qU$zWtit)9kB%@%L%d@ef=gv-OB?= zg)6ML{M(D;AiFJd=*qGAWJKVCLs7KH6?1&pG0;VEF=G4BOi+6`^P^u2VveI}Q>B(n zp^NN%C!`}Rsl{h3R=}klAL>}+{Nko{J~j^xMl^rG+0}VT^Cwt(O}+=u%4(6@tX2!- z#GY^>E*CMYP=BN-ulQ)u#=qoBy%)UlM~ms8zk0Q!wb)}%V~oDs-nOf25}hSYo`BxB zEFIbCu=Nw{^aZuDw27!Hm{E3abK@i=$UxgrhxEirf#%?We`8-C=`I^8%;5)*r9e&pcTGza4i{K)GLi>? zJ{X!rsLbYj^^sp}3dr7pgeh9M3g6?u!LnlVdL&XGoQ$^Fk&?Z-2Tk*Qjc*s^V6OI{ z9?DhZW5bSSh{qQ|X%Ipui%!G$VCCq|Du2U4Pm?(Y&uP`WQNdA1&K~bth&x((?3yu- zg0q*EC_=oL=`ErHO{IHq@?398AM1Ws%2u(mTkGW z`HE0^?drPHn%*wFb4>1Ne&6u=WL}_HiY~ON#g4c zOWr8K_pu+`u?MxAh4z?NI!_p(sQvxc{k_WUzxM6n&uTDsaz9S zF}JBaEQalOhnWb5DTpi|IMjKJtrkHQ5az6|k-4*Py0v#-KZDpyPz)6hlZR&zz^wi- zi|4&J&+4b+nDjRHp++V*0Z;*{B(Y|!Im3JR;s`cAzD>DCR>^-!7O6ua43G(5t*OeH z^I{zER8LCh18yIyx!A9~h9nb5nm-&TrsZ~kry+KRaP=D&WQvwrn#UcHw2UxQ9Gb^| zN&FhPaJYkMJHd4?XqvvcbB)q-dXfA?xsil(sTDNPa}9nW#e3mxdc2 zPHo~mXcYnWQ)Hjjy)*uy=ifv^KkLfGx1{2Z{2Q=uAgU~N;}qzaN7B^PtTXs$Bg#3E zHZWR`kPHC4x2dybYQrih`T9+R`H7z zqqWT{qbB}bRGcae&4Q!e*(KtfN53fz^0)3bwy4a^QuwL542ik-hL+q)6$A|_D;K<- zO>mjhbs1gl@yfBkBjDSuegE==pWoh+f6n-@ph$qjZuy~UB(V*&(q2CJFg~|Rd0x4xxu&)|tFB3k7A2kNqQF2VKSH%+^LLLREQ1Qxg@1qFMctJ#3r?s%my z&9Ioh<*m@2nU=N~<`zMkND#!L!z3f;4(CJq&W9Ox@3>7?o9TDj#Mi|#HSdkZPkZ_< zZ&SYnw1f(e7h5pZ3@?nkUFqKBZnedoZ|ri-GkYaMLP`L&>>#7nmn6A)?{i{2VQ}VT zannyQk`$Yb!J|I^Lc|xc?HOUGlO7l(DxcTdgGbYc3-7#yGDbK)uj9`8yw4 zepjdGUjEsyvnDWGMP8qEp<>q^DZ=nxADtFYxkFCsi$6d15ja|?h&fL+%c{~Z6Gv%# zMA~F4#Hm;_+d%EEI!XO9rF6&u85k`ip3;~OM^-{|($0!$-@re0r*iaHD#ZJL2DqC9 zYvjN>4`sgF1nNIgm5drkd6my_n-VZ{XbqMz?vP&nyWru!e#O_>WcR`+BoC;Th2B-*TDQIzO}=W#*Qz|ji)YN9XtmSNkiEy z;1^{X4zP}Vlfx0t3!G8f((uRh$)ULm*e1%cfA)gOd{}yDE21DGL#Keq{aCVqip<8? z&kDI^qvP~-^C4)g$OX~2E6wo6$*6xYJ#;hFZSo>nvF}*sHUy<#EFJqHE`z)6WZvgS=eg@vWq$ zT@L)@Et0PgbSe1D1@rYF0nn^afR9$4`HPp^EumIdDv$M(-AF$-F+VNs;%Cb!!mNhL zz?>gTNr)ec<(c`4f5a9oSb%dx#Z;!_EaiS_91|PY)qcE{&C>Dvv@aDGNBLo1DC+U} zRyf6GXX~Jf)gt}&kfnS!arYkBQ^-`vV>H6VlwwEJpYu|=9s>SXF952J+4LVxDQ*dC zVE?e^S?{q_(RAiyqTXU}5P6WBj16b>D--QdjiIu#vaX4XSrxr-KEuc%{p5ay`FiiB z6mquNIXH!9b*#aGh7&PV?T}d*&UhH<5d1ygLVM8xxJ^~p#KfmTG88toDjd@g9J;dP z(ZsR*<&TL@+vd~>U4!dtkpnW#14>rc`lpC^OuMUe;XTehnWM1VP@&MohH=m8n)abH zt=dG8-@|iv1b2hHx+rNaeF9&q6*pYz#m6QvLDzW*{13;WV~qql2viJ{_X^8W5$qb;KTzMsD0P-Hbo?C@+oVT#P@(C$wzYy=c56T`kcc7;G|O z+1I=A^hluFxg};=C!KkEbaarlJ#bT%(CB}l(K=g4cX6+Ova_Xvq_PUUV%r>yvHxWm zPQ%q4{*3vIvtf`+t%WscnPxw{I?^=AdO2Mfx>q!Jg)XQ<-z)NDQl5Ao~nWyzpK z9h8+K&Ek~1c{*_al9DOW`*Yu*btyfdPEUz*Y53BPs?6>n-HpR+=a*WO+w)~hmqoe6 zw!+Qn3v52m^ZWfVx5+mz>4LqYZPNtqj>HXpfN$vHEKAsa7O zlrF@Xi<@5<)DLRk#_1xni(RhosLEBU-M-UIq9K-HxAH1JMHiZJZal<0UMU+|M=B{%0{+a4J(&CL8xGIkl)&ZjG9Zh4ht;iAsLFps+Xj6-@oo=7uA298v zUs=ZwNl!oc(jgzli8{+K`J;nF?~j9#wKp-kqBXq?mRk|K9UWoXpQ8AXJPR6e59Y)o zkK<13rQvB+5nYjSf`@C4@oD$ODvl*ko-e7A0-A&JdyY#BLdzy6o52HVk+Wo%JJnWa z0wGK*>!asEI6C%qfmL{H6s+Lv#Mtw1%W6fxzEKx-PRw68it7c`l77O#lnJ1b?%2m@ zBR8&%tMcVHC&U@t$d#JxDM-zJN_N7*xy!sbmf1w>oftLT%!eu13c4OeYgP~zJVzEy zzWr=|LiK|=YdK33+0w16=*7-kgFI={O_r^M3t>+W9I_1eA~5w{*mPdaNm5U&{WI!{ zhsU>A5A?h2>$a);3BP8ZJ((5ep+ioz==z9ywdw_w%qzOoze_oeVOq~<3qxulMf?b% z9Rmk9M8Uli2x7k|s2i{Qspu~Dt2@4$4Ss7b%w7z3X~sGYuku%*OG}(iUyoMOVLcFt zb6I)Oox4c<=pIB>4c;#@wwzmTd};oW+IzHNe&ha#6Tk4Vo&`zy5~yC|e-1FejOZC?N=ZTl2?Dd)Qj`m(+MQSn=6-V+Zbaw7;#&(D25&jrXavK%B(e8R+ z^b?U*=V?%DF|~rGvEbzhTdYfIEwZRe;bc~6LB@rmhCv>YctKb7)8!cQ!#&YfdXVbJ zq&sU8)QY$`?(BqyufGyw&gEttU!G*4=*S!mFPTw?j*luRn|g;gC@bi{@v+1ys|kqM z^dBoL!1s`9Tj+TdYfq1}7{Z)}+ix9=Pd}NA+l=6OLe&e-T5wUV;E1RRJeSsXbRZQ; zS(zELJ<-f?O%m84svRAd#8h|O=Ey8+rc-)yRwU~wLUPSdLQ!+UUxC+hHQ@dd5sC~3 zDviCzHGd+(K61p+NMKXMjn-XW3NrCZl?pEXq8(^KMWDZ&H2o4WM!|dYT+H^xf>|Fs z(4?fY(zkg-w*Fd4K|xj=er8T>b-~&~jd(?UkSWz`XR7yp2=sf{VVVsa+0ip{e2^2P zy04j@XV+>+J;cI-7s|Lpe4Z|DZn36!C69&S|LN_#qpI4Hb-&SLB$!DO^mq^fm8d8x zK@1cpvol8uH9JF)kUFl2zF>KK4`{vSFE6AF*8HqZRg>lvy^% z*T|vLPDPXOc9&c1o+@+spZA0h-4NzTK7OcRi_zB}_VndS4bQPRr9})|LX$gxjQ{>_ zF(mt4)0Zg2-JU+K3RXsX&8D~0+2>ZI1x0eRBrA+al@?mvKU(R&@6DL2%ZUzNYA`;% zU7Cl%)a>A}jh$^?+oR{tCCuB_d{j*yQFxqk`Etu{#lY&@I?`PEs^!#0s~W6{2VYhi zFl?CEx6wMD&86{RQSeewAc?#bA#=gdqsX87@U_9QpT&%aEW;hbjwp~Ab&4a+eL{Iy z7FX8nI{A<;ZmH{pOXR7d79MYY^&FcI@!r%DD&NQ39@iA+SsjQF{jOWl5vMsa)WFhW z$h__#k3+BcCd*{`Zf4eePnAn6WV+9+m%lx~m*IGytncH-ZM8JDBPNqy;~X)>)8?<( zw#~X%wJtSiyyKQV&z)vv?5%2YKiU5H*|Q>Sn_Snlu#Qf?jXJ+mP2%jh>!+2$D|AO4 zw#D40%aOS2@MDa#aK%*Q=Y}n@hWmJ0JXc%Fom|^0%Dlvs$hz+oI;?u?bb=h?oEVa* zk|!@3yth23v$Q@UY+*|Yhj5;q8Rza^AQ!GY#2HLm9r zQk-9h^tyILPDvfr5?gqd=tI+G+xMMzU;d+q&c=J@hdRDTl;%t&Nmgv)a41%(n7YPv z=upM+$J6wQ)e)VG&xf>mKm9TA#UNYl)SZav<3I9iR&5zpvbs{A&E?(kjPS{DndI-4 z;wOxaS8wIQS`!KJR>9#L9B!|!N5zb0RJwlM%#-2J9y&|qwYs$PZC$F0e*2n6Hi-{a zOa}RPLyL~tA;#ANSmu2-JWR^+R6QLl=p1NeFUmY~nidhd zHuW^@Q}>pjv6V^5;2kxJruxBDj0P+4k~x)g>T7oF`AE3V~rLk!l==sCHqZI1BE)=9glnq4(bmVxnN49;q3GN~OI>b* zUAG=q!sYT5-6B~~9pvA8+p%Bx>9P2^R5ef2_uZWq;iQ6=T-zj3+T@q{vbtg<_bW>a%hB_N{LC5Qv5a~J z-8Zco7Fv9t7@vCQkYK&<@XlrSxH(%f*Yhj=40%7doEg7v5xROZBQ+rUNJf<57wST$ z#-f^J9YXA--p_p7Dm`1f-tfTbC|i@qqe8muJ!_hT8ou`6zwd38Z!5}tdoIm|z3qJ3 zpP6)*u#p(`)z2&Ce%RazffA2pTGE95x~Fkhn~O{Nj+q|t(!`e zaWyrm*YD7noSbwVhzS~-nJEQ*>cNAJ@FmuYQz*N<7Vdc9> z!U5piyu7ZVq4UnpCyyULF6%P&qeix!57+>1$DZ8OR9>)SD2MbS-n(yURL=J*E6Vwa+t?Pjqpef|BVDC(1QulEfORyREUUdVaQCbDapPEk?uRzblDppz6!sJOYg z)n7XwM{VZSp$6~H&Wj-F#P+tewV4;r0&%qI4K87G^)?v=pcP`qbU5aPJVDK&S^Z*TgTO~j)Wpo15^e-WIGiC zSivjQ4xlVNJ}!=w;FUez^YIEIOQ^}5TNp|OZ((yxObjVWuQ(jIs&wPe%rm1M3Pwg8 zK+4=1*Wz=Uu}IIz_?B)&{U_5!B{1Tc?e7{I8s(WzvsM(B>B0I-CMFrCb^AfuF+14d zi-MlG26iT<_+C#>&$;1@>W5N^I(c^Ydp`Qqq2>Xnzw|?laXox$vV=j;W_pm)UlnF9 z@4vO4RaelWI22Y>Tq_$?s)VRjPgT z2i4!i%7>~0)CKrNPqpicps}&Btf-)%KZ@nAim>virToFebAFnmqPEr#gop!^rPoGL z)-zCt@9O&Wcpn~{jhdRePTxK~J>B4IoKoS^L`5tYVItHdJ*PJuJaot#XpfVpPHm#4 zHR^bMQ`V}?k3%k)?>2*;^W!gH6xY+Sd;K%zl)@ct}iZ z465nfr0g<32d>Z2qesu2J*#hN=?%h4w3PRAaG}KQC4vG2iK^WnO=$#V@3HdN317))W%R_+H-Pnln5nTXbm+a z4|EiZaVmrhu`2Tl3JTU|GckOy=Wo};SY8%lHfnEe)n;n^;-VlUvsXxHCoeB=wsl9! z2dv2XA-&ad_SHFQU@g+p(xOnva?j0;+mL2)=Np`!1GOx(xn(s8^>n~1_I>G?X?=2m$xbE1k>(5`VbY;M#C%1i)opeA38epJg5j% z8US?h2DIa2W1XO(RYr(KR94EBS5#2uL1wFsSK$QGh+=zpc-TV3e(*)*ZrzAePO2VE z0Z*Q!16v1HnsJC(z@0m5P;UvEUZUw>%KXBDfxiCRC`orY6}-XSJ9nM|-VcTC2}c6Qh4=8`{tDjW7vOneDEvc05_%u8i(PPZJPy2^?C^ze&CT1H zm^7kk<8pI@VV7zOu3J;I3F~d9oFpc``=~`EK1#!Ok}>RpdvUQC2rI*_c`8gj-@m&j zCi0MQm4xhju!*RI3vBSm5P&v9F%h7mV-@+3KuIL0x#i~P`=Me~SCJxgW2!iokpQ>faCiiJSs=F^Sm9%Q$K2=H{f}a)YDFvQ4#gZ*4EZW z0+x-zqBliw8xD?+wv+wxeO_{g(*t$VSuT>rMQ?FUvr`4(5fKVEZiq-oNI2ZMVPI{Y zDq>7ZEu8S2_-1$IisR3$M$ArtbP}_F`YVX?v)VNdwv4(xi2+S$2MddwswzD|h@=Fc zJk`V)K)uT63iz#Bj~%sWid7BE$S7t)I16}3+$0b@Jy^>i#Ykm|J$wmP9rqAX zr{nUIc_mq=Rak6gZXfY(sOL9RQwOSUC!IKPqAv@jw8{t9P7T(3p=_Su6QVTn8_Kh^ zvMrP*W@P~bv-l=Jx)6R+29xfPW+uTaLfux};hjQ8mX^udk(=sGE<`O0&k}?fOG+ll1}#}P_kF#G#iVD@HX4Qp9Sn1#oRkOI$9|^f0yxi zcL^q_?yio!E1V4t4R|w=^=1PtIlNq4TxX=Ec?^xK!{yJN+t`?*(*qRZ?}raR`Wz~p zsA)Oq@9P^Wa<{F}@k;b?QM`5W%NH-UZryq-CWfOe-#!|LIxRY0hna*st_Np>n}=r} zK(Vx(9AV9#Fd*Mz0FPSgVP91=prfOMWw80#e1t^c7O@p=q@j6{nYmL~Sh)UvQYpx5 z#>~GKWY$Pw_qOZFItAA-6N81@9o`GoU+(Ja0v$_*Og;mJG@2jyVlOr$U*D~Lj}CxJ zd8x#67YYFD57<$OTpy_fPY~0~yr+RtOG^uDj!ER|0hpcMtVUgxFbS)Mq^Gs}^g{bT zhc+&>d-rvBEB8>};)iuc-J3>^RTy`;pWkVH{ixbzWep7vspu}C{mO%Q@G_r0duC&2 z_q(WQ`0JaCc6bL;9Wfu?zKv?HcHtdt9_{Pv1FJwfZ#NBDZ#Q8w1glROzvCQV{woc5YD$(9g;zOVghdtTPJV~!Ld{ifb|3U@}T;w zH$s$`C{)h?jnNI*2UuYu|AW{5kV$a;Ex`Cro;(?R{L21Kn>HO25wYw2v<({p?hF*W zG`l`o6t|yg$+pz^lTm3;AL+ke{&zk;#epEV!P7N!LBGX0UR%~>%+VL0=4FUAd5!%M zbg~Bmu2`;gY$rc0juxlxOoGY*Oe!7QDlEqBK`k{^GTCN%!TzXq`@_J%U6#$6N)3h! z--MXG2M@YBI_7bw)|*inJlc6n7g-ZplnACIn0>bf&G(n$`WG(HC2QsE2laCuHGSug z9}iGzkBQa?MkIOWtf?s%g+d_(1O@`Ea3?R%(Yql@n*C-#db&XUG!wP~!-?K9VmShd zX9I!KHzGM5rXjpEsHmYKgMl?NG7_JjUK6`WAqGFjKYV&aoK}uil6IcoY`fDI7$(3$ z-vbC$b6F@>a;SH3&=cQ}JrX1KG?psYVca13dPkg3QPC+3hT(??+aL+=f{P{yUA12> zm_%j>V!t`H?a`x0s51xLv?5}4$s~r)6Fe>f0l(m2Mz)(%N~9-;brO;~0`A^jYgqcQ z>t{}fs%H^EuKCwTQ$j{K6%-We^E;eIPu1;(t=P)Kl2nOW_T8AockbK?2=?}-^$}ai z9ZccFtAB}Y>9=+3#_QGe9tQ`x5VnC)|JG1xoGiVJ`Hs2e?_WE5TJ z=qTZ*Onfu`90G2^Zy2#?Y`CzYJSF_()$ZCNr|XcG!GI*T6By+YPYTV6c6}LPG zxmVInXkm>iK6iRaKn9qgIp>Y;dQV^2?y zq=m&nJT+xvety1is`k8tlT%MumjuQGXPc=KB0BT%p239}8ynjLV+rYE^bHfLv%lXB zA|LoZj2A{Rx)-Fk~Rr`@oqL zU{y^7`c+yZ7Fb89qjV@`j*c%b#n*l^tHjE60lryUQQ_q{+0Ui0?f&4)kr1mE6m|y| z78E#n7j_NAeXoxA`1!LpO4_-5yl>(a9(lql>YVFm{UevGsyYE&XX)?rvZ&1)j(D^9y zkeq&jfiZFg*GE+hVLFH0wAkK1{kgf3nsRk!Fr%}!Q}`RJx{^6+@!V{xTluWPqEPs%r>>Qz5K>qVeLYo4FgE_P|Vsvd?t3srRa zgVe;KnVPTX(pIqF#%yC^@&Iu$Dk^HYB}bv`kVg8wO*{6Fb$jt{2M-p@ioohB>Kt60 zo3k0~lrVCJ@B@GLMNAAAf1K#~Wx#3^3~0+LD?gN$x&hprl$>m9YpV}uB-5&m`4;w3 z-~T5b0zEx_LgD|#M_|Nz&O6og;oou-o<42+wr?xktnBP;h?So1?$gG`v3`l>#SHZH z1f4I_w9dxXHVSs`!-o&Aj4J4jG~yh!D?fh@e5N0`I|1Sl}8aw7RBd_vmQq zhsgZp&f1vs3F?yAcXM5qMSxf4gdaIEVMkfPWCMG3lZgqaG|Z+x;=>)q5v{GQn(R+5<#!Y>j-0@HG-JsDRaj9~HTJ_w(4fd+=vC~U1Znh_ zZ!nkj81kd}mHt{79K*5Bx2UW_iNg5gq#;-u<&0utVqig2ZQQsKcdS*09RrlEgv@XP zW(q23k6b^z7xgT&K< zCm~gYNXSFNQ!Tq6?iNmI$jF81hQ8XdbLTxQo0*xJ1jF_9KmTy6ei9m5VV+tPTP_R= zs&1BLg!6>QfUii{NyqJY8u;LjEA!t8d?KD$1@M+xxGbGBX=`3(XU8Tacs_srJ4ZGS z%+zPs&wglHb!&c9frtjl#>R%!nVi%9uDW^)E<pn<* zEW&BzxKaBTU46p0g-|f9d0xVr;~{RJ_{jEePC}o-f8->Hi;Le9aOF&90&F@-Gi&qt z^A(ywSfaS6wK+b4U0G7mN_M2;)#XvpC;bHUTAY@I>#u3k{L}yfToCR7L8Enbbwxnt z_8_lsqpdm%)#QH64mAkDA^+U`u$Hr_xKH=i#eh$|B^)}hs ztwp*D5T175y@7g5Hz?UWF)?AbGmivtNIzgG<%L~YwqEy>lg}L8KR_aFDIPR)w8VGe03~$-=@ixI8?~FDUpPygZ?#IP>D` z!%f=|TY-~uxh4CMVp}#8WWpe8PF7aYkYxe8*@?rr<a^7!%IbpR_r(4#TE>v)R@!XQZW7A{F#TwEkvdyL5V@@z*aU9!rhOMk#& z>cNBn>-C)wtvDXTb?!a8-1}*fn+rnh&SO;YeEJut;TmCGy!?dtL8Q*m*tit$E5H_#olGYCMnqKO57W}q zpTo11ZJ)|u(tJwuD}=JDTAe%Yf>DZbU0o?$94YAS@AnG}`wTNav{aDb4MYE@S@p2+{GCTj_+rf8SJU}HZPK~N0P-xK+tBM=P%(ij(dh44k45DD~ha#o#? z+h3X=>|^ZYtgz$Z4&9r6Y5tC%z3{6I@bwV=%G%m;2%V}II`ET#+3D@=-MV9kq`v-M zEPG=R#7nKCx;s1H1oImb0a(IKSG>9S4(h-DSWm|EZ24Hp`%d`T-|?eXf;qa8j!t!? z5g)cXc<|uH&6|}=o0^)&fYkPfCktMYVw-{(;>d`E5nUP(k`%%;*Dxl62t7%41LVei za?g$_InqMld7;~&`G%F5oMOVo$RCM>2!)T8*wApeu4fIOVJQvn49K(Zxcqa&kBWaV`@2Jq@vCW49?y@_bRN{$d_1|?SFmx|x}VKw~k6#edpb7u=6 z!;5mZPfbmcvtBLvv4}bcLsd2lB{2dK9nE_Y%;~)!c>Vw3Q-5;~{#h2{AKU~*XeWMy z4;uk&2J#6zza4_e91)nL=Xs0nAihjdhhT?%cVvb7Z9I7S-B-V2QsH0j9Z1xGm_ra{nW|+PSyj1t0|fTW8qy z_V_d%*l#E-#BX3`UyLFQ?aWU7GL4mJL*#F zw-D9{x*JzKIrJ-}GX*!|902G7KiiA60`-b+9DxY4XwLLNFowCJQ7W~OXJ~P;W0g+) z#*O^#Xv>jwy~Pkg{j|Ri>Q9KGmqrvM1`!0CzErzN{Epx$=@tSBwRu-eDD8E1Dp?)S z?Ux%}RylDyDA0;YXDF15Z!Hmn6$1?2!RL3gBM|{AMDEf3v^8pM*%? z0sc7G7VjK*8%Yh*64Ok5m^*0g(DeFd!S-TyM1kg!u^}~OTkb{>i_(X`457J@~83Yh3-RS)5Ha?BS2SE~ATb+7c_pSy5DwREJTm>mfTNaR4q zH%K7mT^;a)^hMq|(-V$n>ptO1O=5sPKQRa;)vGE0VoGrSc-Q5NNyqt&6ERALXxRF@N5f4P{al!7+ zw|5bR$Nq5>B)gqd~W zVP18m*j6KWE?(@i%y`m)Pb7>Ek`AHsro1aJ1xlfe;R2A>tXV@ovG zclX8vkN(3nLJS`d7nf3&7U{q92Umz~`|tAyl|^Qi*^M8+h}0qX0aSKbd3pD{`_!@T zM?$2c_oBfJx*x!8u?p!AwSKMiQ@Adp8tr0UCM7*YeLE4rUp4&|PK@e!F^( z6)RFh7#D>ygeJUgdrG?y&crH2%umUV*t)FmKQ$F!nq9(X4qqnm@M-L$WK9`#OBfDl zB$AM6G+z?cc!>@)m@u1YXf7e(d_W=j}}CH zf(5Rw`QG1V76@1Ke@| zZNqQ%5xcEib>EwNOGWo0Aw-x0IC8zyje6g4hYy*L$TDpiYclkSsv9tKmT7_mnssW= zx7?yKWc;A45FELuHqL zEW!l3Lrxuur4~*)(5je1J~+A%_xkmoBS)rR(+}z7U)6_jj@6IM?@YEXrh+{|9xmR~ z{(pD^3q9}zrh3eUnEy;P5Dpmvx7*m*F2K1OPcSO(Z7a|RW}joW+J$cR2^1g ztSO0T76r51g z-@$xEHikGs0IiIE1B73LSPaLWJ&{$v+aaln@8>RRSJdfcUiBj#sZLoHZXL+peFE*k z)~#DdNH%1~sOlS8f$Dtn>=}y@Yixvkbj{b?TQgP=fAvk)r3}RlB@GQvkZ>ieQ!NmN zE@&bGpYS}H95A3rBIdt}!l@F3(wL{cs*tgXPf8M^<@tCOsiBiIO@>6*EHp%#oN2L+ zZX7~#lIr4*FHz{4^ay&BB)UAMV#NCAdc&b+#T=_Pbu{3@LlB+7fIvi0VD!f|`;-)v zxH+Ja&fAH;3i&z`p|R>O)G@+A!-oG5xI|lIH83?DyFECA`SjN)>S()6)kq@`a|`3` z>E#7=PYs>8p6FSIM>Q}ofH(O9x)*(RzRn#Y!(k+jTEIOz3O8`?%M8zLu4F1O)6X^Fea!ed3QC8 z+fzXky0>rNA`G^Jp1w4IOGVC|3qHvkcuqizZdkj4PDnnHv8thwLs&S7psZspg@v)M ztgJw|NS7&upL~k*4PS~~U59`EWP(-4x|1>|M6}DY5GCtK;O4z&cKH9|1pC<)6QK^s znUVGT?p^kS4}J{bNr=0h^z?{G$1*ddq4gZ>51ORi z#^6|3SZt)FrL?*K;#VE=J^HI}&p#dqT5$a?I5tr$TWpJNfFzl-7tW-dzIf-q00o;C A$p8QV literal 27058 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