From 196779d9b3a5f96f71ab7096724b042019b33160 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 21 Oct 2021 14:30:30 +0200 Subject: [PATCH 01/33] Flame: creating host folder with basic methods --- openpype/hosts/flame/__init__.py | 105 +++++++++ openpype/hosts/flame/api/__init__.py | 3 + openpype/hosts/flame/api/lib.py | 310 +++++++++++++++++++++++++++ openpype/hosts/flame/api/menu.py | 199 +++++++++++++++++ openpype/hosts/flame/api/pipeline.py | 162 ++++++++++++++ openpype/hosts/flame/api/plugin.py | 13 ++ openpype/hosts/flame/api/utils.py | 91 ++++++++ openpype/hosts/flame/api/workio.py | 37 ++++ 8 files changed, 920 insertions(+) create mode 100644 openpype/hosts/flame/__init__.py create mode 100644 openpype/hosts/flame/api/__init__.py create mode 100644 openpype/hosts/flame/api/lib.py create mode 100644 openpype/hosts/flame/api/menu.py create mode 100644 openpype/hosts/flame/api/pipeline.py create mode 100644 openpype/hosts/flame/api/plugin.py create mode 100644 openpype/hosts/flame/api/utils.py create mode 100644 openpype/hosts/flame/api/workio.py diff --git a/openpype/hosts/flame/__init__.py b/openpype/hosts/flame/__init__.py new file mode 100644 index 0000000000..dc3d3e7cba --- /dev/null +++ b/openpype/hosts/flame/__init__.py @@ -0,0 +1,105 @@ +from .api.utils import ( + setup +) + +from .api.pipeline import ( + install, + uninstall, + ls, + containerise, + update_container, + maintained_selection, + remove_instance, + list_instances, + imprint +) + +from .api.lib import ( + FlameAppFramework, + maintain_current_timeline, + get_project_manager, + get_current_project, + get_current_timeline, + create_bin, +) + +from .api.menu import ( + FlameMenuProjectconnect, + FlameMenuTimeline +) + +from .api.workio import ( + open_file, + save_file, + current_file, + has_unsaved_changes, + file_extensions, + work_root +) + +import os + +HOST_DIR = os.path.dirname( + os.path.abspath(__file__) +) +API_DIR = os.path.join(HOST_DIR, "api") +PLUGINS_DIR = os.path.join(HOST_DIR, "plugins") +PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish") +LOAD_PATH = os.path.join(PLUGINS_DIR, "load") +CREATE_PATH = os.path.join(PLUGINS_DIR, "create") +INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory") + +app_framework = None +apps = [] + + +__all__ = [ + "HOST_DIR", + "API_DIR", + "PLUGINS_DIR", + "PUBLISH_PATH", + "LOAD_PATH", + "CREATE_PATH", + "INVENTORY_PATH", + "INVENTORY_PATH", + + "app_framework", + "apps", + + # pipeline + "install", + "uninstall", + "ls", + "containerise", + "update_container", + "reload_pipeline", + "maintained_selection", + "remove_instance", + "list_instances", + "imprint", + + # utils + "setup", + + # lib + "FlameAppFramework", + "maintain_current_timeline", + "get_project_manager", + "get_current_project", + "get_current_timeline", + "create_bin", + + # menu + "FlameMenuProjectconnect", + "FlameMenuTimeline", + + # plugin + + # workio + "open_file", + "save_file", + "current_file", + "has_unsaved_changes", + "file_extensions", + "work_root" +] diff --git a/openpype/hosts/flame/api/__init__.py b/openpype/hosts/flame/api/__init__.py new file mode 100644 index 0000000000..50a6b3f098 --- /dev/null +++ b/openpype/hosts/flame/api/__init__.py @@ -0,0 +1,3 @@ +""" +OpenPype Autodesk Flame api +""" diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py new file mode 100644 index 0000000000..9d24e94df8 --- /dev/null +++ b/openpype/hosts/flame/api/lib.py @@ -0,0 +1,310 @@ +import sys +import json +import re +import os +import pickle +import contextlib +from pprint import pprint, pformat +from opentimelineio import opentime +import openpype + + +# from ..otio import davinci_export as otio_export + +from openpype.api import Logger + +log = Logger().get_logger(__name__) + +self = sys.modules[__name__] +self.project_manager = None +self.media_storage = None + +# OpenPype sequencial rename variables +self.rename_index = 0 +self.rename_add = 0 + +self.publish_clip_color = "Pink" +self.pype_marker_workflow = True + +# OpenPype compound clip workflow variable +self.pype_tag_name = "VFX Notes" + +# OpenPype marker workflow variables +self.pype_marker_name = "OpenPypeData" +self.pype_marker_duration = 1 +self.pype_marker_color = "Mint" +self.temp_marker_frame = None + +# OpenPype default timeline +self.pype_timeline_name = "OpenPypeTimeline" + + +class FlameAppFramework(object): + # flameAppFramework class takes care of preferences + + class prefs_dict(dict): + # subclass of a dict() in order to directly link it + # to main framework prefs dictionaries + # when accessed directly it will operate on a dictionary under a "name" + # key in master dictionary. + # master = {} + # p = prefs(master, "app_name") + # p["key"] = "value" + # master - {"app_name": {"key", "value"}} + + def __init__(self, master, name, **kwargs): + self.name = name + self.master = master + if not self.master.get(self.name): + self.master[self.name] = {} + self.master[self.name].__init__() + + def __getitem__(self, k): + return self.master[self.name].__getitem__(k) + + def __setitem__(self, k, v): + return self.master[self.name].__setitem__(k, v) + + def __delitem__(self, k): + return self.master[self.name].__delitem__(k) + + def get(self, k, default=None): + return self.master[self.name].get(k, default) + + def setdefault(self, k, default=None): + return self.master[self.name].setdefault(k, default) + + def pop(self, k, v=object()): + if v is object(): + return self.master[self.name].pop(k) + return self.master[self.name].pop(k, v) + + def update(self, mapping=(), **kwargs): + self.master[self.name].update(mapping, **kwargs) + + def __contains__(self, k): + return self.master[self.name].__contains__(k) + + def copy(self): # don"t delegate w/ super - dict.copy() -> dict :( + return type(self)(self) + + def keys(self): + return self.master[self.name].keys() + + @classmethod + def fromkeys(cls, keys, v=None): + return cls.master[cls.name].fromkeys(keys, v) + + def __repr__(self): + return "{0}({1})".format(type(self).__name__, self.master[self.name].__repr__()) + + def master_keys(self): + return self.master.keys() + + def __init__(self): + self.name = self.__class__.__name__ + self.bundle_name = "OpenPypeFlame" + # self.prefs scope is limited to flame project and user + self.prefs = {} + self.prefs_user = {} + self.prefs_global = {} + self.log = log + + + try: + import flame + self.flame = flame + self.flame_project_name = self.flame.project.current_project.name + self.flame_user_name = flame.users.current_user.name + except: + self.flame = None + self.flame_project_name = None + self.flame_user_name = None + + import socket + self.hostname = socket.gethostname() + + if sys.platform == "darwin": + self.prefs_folder = os.path.join( + os.path.expanduser("~"), + "Library", + "Caches", + "OpenPype", + self.bundle_name) + elif sys.platform.startswith("linux"): + self.prefs_folder = os.path.join( + os.path.expanduser("~"), + ".OpenPype", + self.bundle_name) + + self.prefs_folder = os.path.join( + self.prefs_folder, + self.hostname, + ) + + self.log.info("[{}] waking up".format(self.__class__.__name__)) + self.load_prefs() + + # menu auto-refresh defaults + + if not self.prefs_global.get("menu_auto_refresh"): + self.prefs_global["menu_auto_refresh"] = { + "media_panel": True, + "batch": True, + "main_menu": True, + "timeline_menu": True + } + + self.apps = [] + + def load_prefs(self): + prefix = self.prefs_folder + os.path.sep + self.bundle_name + prefs_file_path = (prefix + "." + self.flame_user_name + "." + + self.flame_project_name + ".prefs") + prefs_user_file_path = (prefix + "." + self.flame_user_name + + ".prefs") + prefs_global_file_path = prefix + ".prefs" + + try: + with open(prefs_file_path, "r") as prefs_file: + self.prefs = pickle.load(prefs_file) + + self.log.info("preferences loaded from {}".format(prefs_file_path)) + self.log.info("preferences contents:\n" + pformat(self.prefs)) + except: + self.log.info("unable to load preferences from {}".format( + prefs_file_path)) + + try: + with open(prefs_user_file_path, "r") as prefs_file: + self.prefs_user = pickle.load(prefs_file) + self.log.info("preferences loaded from {}".format( + prefs_user_file_path)) + self.log.info("preferences contents:\n" + pformat(self.prefs_user)) + except: + self.log.info("unable to load preferences from {}".format( + prefs_user_file_path)) + + try: + with open(prefs_global_file_path, "r") as prefs_file: + self.prefs_global = pickle.load(prefs_file) + self.log.info("preferences loaded from {}".format( + prefs_global_file_path)) + self.log.info("preferences contents:\n" + pformat(self.prefs_global)) + + except: + self.log.info("unable to load preferences from {}".format( + prefs_global_file_path)) + + return True + + def save_prefs(self): + import pickle + + if not os.path.isdir(self.prefs_folder): + try: + os.makedirs(self.prefs_folder) + except: + self.log.info("unable to create folder {}".format( + self.prefs_folder)) + return False + + prefix = self.prefs_folder + os.path.sep + self.bundle_name + prefs_file_path = prefix + "." + self.flame_user_name + "." + self.flame_project_name + ".prefs" + prefs_user_file_path = prefix + "." + self.flame_user_name + ".prefs" + prefs_global_file_path = prefix + ".prefs" + + try: + prefs_file = open(prefs_file_path, "w") + pickle.dump(self.prefs, prefs_file) + prefs_file.close() + self.log.info("preferences saved to {}".format(prefs_file_path)) + self.log.info("preferences contents:\n" + pformat(self.prefs)) + except: + self.log.info("unable to save preferences to {}".format(prefs_file_path)) + + try: + prefs_file = open(prefs_user_file_path, "w") + pickle.dump(self.prefs_user, prefs_file) + prefs_file.close() + self.log.info("preferences saved to {}".format(prefs_user_file_path)) + self.log.info("preferences contents:\n" + pformat(self.prefs_user)) + except: + self.log.info("unable to save preferences to {}".format(prefs_user_file_path)) + + try: + prefs_file = open(prefs_global_file_path, "w") + pickle.dump(self.prefs_global, prefs_file) + prefs_file.close() + self.log.info("preferences saved to {}".format(prefs_global_file_path)) + self.log.info("preferences contents:\n" + pformat(self.prefs_global)) + except: + self.log.info("unable to save preferences to {}".format(prefs_global_file_path)) + + return True + + +@contextlib.contextmanager +def maintain_current_timeline(to_timeline, from_timeline=None): + """Maintain current timeline selection during context + + Attributes: + from_timeline (resolve.Timeline)[optional]: + Example: + >>> print(from_timeline.GetName()) + timeline1 + >>> print(to_timeline.GetName()) + timeline2 + + >>> with maintain_current_timeline(to_timeline): + ... print(get_current_timeline().GetName()) + timeline2 + + >>> print(get_current_timeline().GetName()) + timeline1 + """ + project = get_current_project() + working_timeline = from_timeline or project.GetCurrentTimeline() + + # swith to the input timeline + project.SetCurrentTimeline(to_timeline) + + try: + # do a work + yield + finally: + # put the original working timeline to context + project.SetCurrentTimeline(working_timeline) + + +def get_project_manager(): + # TODO: get_project_manager + return + + +def get_media_storage(): + # TODO: get_media_storage + return + + +def get_current_project(): + # TODO: get_current_project + return + + +def get_current_timeline(new=False): + # TODO: get_current_timeline + return + + +def create_bin(name, root=None): + # TODO: create_bin + return + + +def rescan_hooks(): + import flame + try: + flame.execute_shortcut('Rescan Python Hooks') + except: + pass diff --git a/openpype/hosts/flame/api/menu.py b/openpype/hosts/flame/api/menu.py new file mode 100644 index 0000000000..65d1535beb --- /dev/null +++ b/openpype/hosts/flame/api/menu.py @@ -0,0 +1,199 @@ +import os +import sys +from Qt import QtWidgets, QtCore +from pprint import pprint, pformat +from copy import deepcopy + +from .lib import rescan_hooks +from openpype.tools.utils.host_tools import HostToolsHelper + + +menu_group_name = 'OpenPype' + +default_flame_export_presets = { + 'Publish': {'PresetVisibility': 2, 'PresetType': 0, 'PresetFile': 'OpenEXR/OpenEXR (16-bit fp PIZ).xml'}, + 'Preview': {'PresetVisibility': 3, 'PresetType': 2, 'PresetFile': 'Generate Preview.xml'}, + 'Thumbnail': {'PresetVisibility': 3, 'PresetType': 0, 'PresetFile': 'Generate Thumbnail.xml'} +} + + +class _FlameMenuApp(object): + def __init__(self, framework): + self.name = self.__class__.__name__ + self.framework = framework + self.log = framework.log + self.menu_group_name = menu_group_name + self.dynamic_menu_data = {} + + # flame module is only avaliable when a + # flame project is loaded and initialized + self.flame = None + try: + import flame + self.flame = flame + except: + self.flame = None + + self.flame_project_name = flame.project.current_project.name + self.prefs = self.framework.prefs_dict(self.framework.prefs, self.name) + self.prefs_user = self.framework.prefs_dict( + self.framework.prefs_user, self.name) + self.prefs_global = self.framework.prefs_dict( + self.framework.prefs_global, self.name) + + self.mbox = QtWidgets.QMessageBox() + + self.menu = { + "actions": [{ + 'name': os.getenv("AVALON_PROJECT", "project"), + 'isEnabled': False + }], + "name": self.menu_group_name + } + self.tools_helper = HostToolsHelper() + + def __getattr__(self, name): + def method(*args, **kwargs): + print('calling %s' % name) + return method + + def rescan(self, *args, **kwargs): + if not self.flame: + try: + import flame + self.flame = flame + except: + self.flame = None + + if self.flame: + self.flame.execute_shortcut('Rescan Python Hooks') + self.log.info('Rescan Python Hooks') + + +class FlameMenuProjectconnect(_FlameMenuApp): + + # flameMenuProjectconnect app takes care of the preferences dialog as well + + def __init__(self, framework): + _FlameMenuApp.__init__(self, framework) + + def __getattr__(self, name): + def method(*args, **kwargs): + project = self.dynamic_menu_data.get(name) + if project: + self.link_project(project) + return method + + def build_menu(self): + if not self.flame: + return [] + + flame_project_name = self.flame_project_name + self.log.info("______ {} ______".format(flame_project_name)) + + menu = deepcopy(self.menu) + + menu['actions'].append({ + "name": "Workfiles ...", + "execute": lambda x: self.tools_helper.show_workfiles() + }) + menu['actions'].append({ + "name": "Create ...", + "execute": lambda x: self.tools_helper.show_creator() + }) + menu['actions'].append({ + "name": "Publish ...", + "execute": lambda x: self.tools_helper.show_publish() + }) + menu['actions'].append({ + "name": "Load ...", + "execute": lambda x: self.tools_helper.show_loader() + }) + menu['actions'].append({ + "name": "Manage ...", + "execute": lambda x: self.tools_helper.show_scene_inventory() + }) + menu['actions'].append({ + "name": "Library ...", + "execute": lambda x: self.tools_helper.show_library_loader() + }) + return menu + + def get_projects(self, *args, **kwargs): + pass + + def refresh(self, *args, **kwargs): + self.rescan() + + def rescan(self, *args, **kwargs): + if not self.flame: + try: + import flame + self.flame = flame + except: + self.flame = None + + if self.flame: + self.flame.execute_shortcut('Rescan Python Hooks') + self.log.info('Rescan Python Hooks') + + +class FlameMenuTimeline(_FlameMenuApp): + + # flameMenuProjectconnect app takes care of the preferences dialog as well + + def __init__(self, framework): + _FlameMenuApp.__init__(self, framework) + + def __getattr__(self, name): + def method(*args, **kwargs): + project = self.dynamic_menu_data.get(name) + if project: + self.link_project(project) + return method + + def build_menu(self): + if not self.flame: + return [] + + flame_project_name = self.flame_project_name + self.log.info("______ {} ______".format(flame_project_name)) + + menu = deepcopy(self.menu) + + menu['actions'].append({ + "name": "Create ...", + "execute": lambda x: self.tools_helper.show_creator() + }) + menu['actions'].append({ + "name": "Publish ...", + "execute": lambda x: self.tools_helper.show_publish() + }) + menu['actions'].append({ + "name": "Load ...", + "execute": lambda x: self.tools_helper.show_loader() + }) + menu['actions'].append({ + "name": "Manage ...", + "execute": lambda x: self.tools_helper.show_scene_inventory() + }) + + return menu + + def get_projects(self, *args, **kwargs): + pass + + def refresh(self, *args, **kwargs): + self.rescan() + + def rescan(self, *args, **kwargs): + if not self.flame: + try: + import flame + self.flame = flame + except: + self.flame = None + + if self.flame: + self.flame.execute_shortcut('Rescan Python Hooks') + self.log.info('Rescan Python Hooks') diff --git a/openpype/hosts/flame/api/pipeline.py b/openpype/hosts/flame/api/pipeline.py new file mode 100644 index 0000000000..297ab0e44c --- /dev/null +++ b/openpype/hosts/flame/api/pipeline.py @@ -0,0 +1,162 @@ +""" +Basic avalon integration +""" +import os +import contextlib +from collections import OrderedDict +from avalon.tools import workfiles +from avalon import api as avalon +from avalon import schema +from avalon.pipeline import AVALON_CONTAINER_ID +from pyblish import api as pyblish +from openpype.api import Logger +from . import lib + +AVALON_CONTAINERS = "AVALON_CONTAINERS" + +log = Logger().get_logger(__name__) + + + +def install(): + from .. import ( + PUBLISH_PATH, + LOAD_PATH, + CREATE_PATH, + INVENTORY_PATH + ) + # TODO: install + + # Disable all families except for the ones we explicitly want to see + family_states = [ + "imagesequence", + "render2d", + "plate", + "render", + "mov", + "clip" + ] + avalon.data["familiesStateDefault"] = False + avalon.data["familiesStateToggled"] = family_states + + log.info("openpype.hosts.flame installed") + + pyblish.register_host("flame") + pyblish.register_plugin_path(PUBLISH_PATH) + log.info("Registering DaVinci Resovle plug-ins..") + + avalon.register_plugin_path(avalon.Loader, LOAD_PATH) + avalon.register_plugin_path(avalon.Creator, CREATE_PATH) + avalon.register_plugin_path(avalon.InventoryAction, INVENTORY_PATH) + + # register callback for switching publishable + pyblish.register_callback("instanceToggled", on_pyblish_instance_toggled) + + +def uninstall(): + from .. import ( + PUBLISH_PATH, + LOAD_PATH, + CREATE_PATH, + INVENTORY_PATH + ) + + # TODO: uninstall + pyblish.deregister_host("flame") + pyblish.deregister_plugin_path(PUBLISH_PATH) + log.info("Deregistering DaVinci Resovle plug-ins..") + + avalon.deregister_plugin_path(avalon.Loader, LOAD_PATH) + avalon.deregister_plugin_path(avalon.Creator, CREATE_PATH) + avalon.deregister_plugin_path(avalon.InventoryAction, INVENTORY_PATH) + + # register callback for switching publishable + pyblish.deregister_callback("instanceToggled", on_pyblish_instance_toggled) + + +def containerise(tl_segment, + name, + namespace, + context, + loader=None, + data=None): + # TODO: containerise + pass + + +def ls(): + """List available containers. + """ + # TODO: ls + pass + + +def parse_container(tl_segment, validate=True): + """Return container data from timeline_item's openpype tag. + """ + # TODO: parse_container + pass + + +def update_container(tl_segment, data=None): + """Update container data to input timeline_item's openpype tag. + """ + # TODO: update_container + pass + + +@contextlib.contextmanager +def maintained_selection(): + """Maintain selection during context + + Example: + >>> with maintained_selection(): + ... node['selected'].setValue(True) + >>> print(node['selected'].value()) + False + """ + # TODO: maintained_selection + remove undo steps + + try: + # do the operation + yield + finally: + pass + + +def reset_selection(): + """Deselect all selected nodes + """ + pass + + +def on_pyblish_instance_toggled(instance, old_value, new_value): + """Toggle node passthrough states on instance toggles.""" + + log.info("instance toggle: {}, old_value: {}, new_value:{} ".format( + instance, old_value, new_value)) + + from openpype.hosts.resolve import ( + set_publish_attribute + ) + + # Whether instances should be passthrough based on new value + timeline_item = instance.data["item"] + set_publish_attribute(timeline_item, new_value) + + +def remove_instance(instance): + """Remove instance marker from track item.""" + # TODO: remove_instance + pass + + +def list_instances(): + """List all created instances from current workfile.""" + # TODO: list_instances + pass + + +def imprint(item, data=None): + # TODO: imprint + pass diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py new file mode 100644 index 0000000000..ac86c7c224 --- /dev/null +++ b/openpype/hosts/flame/api/plugin.py @@ -0,0 +1,13 @@ +import re +import uuid +from avalon import api +import openpype.api as pype +from openpype.hosts import resolve +from avalon.vendor import qargparse +from . import lib + +from Qt import QtWidgets, QtCore + +# Creator plugin functions +# Publishing plugin functions +# Loader plugin functions diff --git a/openpype/hosts/flame/api/utils.py b/openpype/hosts/flame/api/utils.py new file mode 100644 index 0000000000..489b51e37c --- /dev/null +++ b/openpype/hosts/flame/api/utils.py @@ -0,0 +1,91 @@ +#! python3 + +""" +Resolve's tools for setting environment +""" + +import os +import shutil +from openpype.api import Logger +log = Logger().get_logger(__name__) + + +def _sync_utility_scripts(env=None): + """ Synchronizing basic utlility scripts for resolve. + + To be able to run start OpenPype within Flame we have to copy + all utility_scripts and additional FLAME_SCRIPT_DIR into + `/opt/Autodesk/shared/python`. This will be always synchronizing those + folders. + """ + from .. import HOST_DIR + + if not env: + env = os.environ + + # initiate inputs + scripts = {} + fsd_env = env.get("FLAME_SCRIPT_DIR", "") + flame_shared_dir = "/opt/Autodesk/shared/python" + + fsd_paths = [os.path.join( + HOST_DIR, + "utility_scripts" + )] + + # collect script dirs + log.info("FLAME_SCRIPT_DIR: `{fsd_env}`".format(**locals())) + log.info("fsd_paths: `{fsd_paths}`".format(**locals())) + + # add application environment setting for FLAME_SCRIPT_DIR + # to script path search + for _dirpath in fsd_env.split(os.pathsep): + if not os.path.isdir(_dirpath): + log.warning("Path is not a valid dir: `{_dirpath}`".format(**locals())) + continue + fsd_paths.append(_dirpath) + + # collect scripts from dirs + for path in fsd_paths: + scripts.update({path: os.listdir(path)}) + + log.info("Additional Flame script paths: `{fsd_paths}`".format(**locals())) + log.info("Flame Scripts: `{scripts}`".format(**locals())) + + # make sure no script file is in folder + if next(iter(os.listdir(flame_shared_dir)), None): + for s in os.listdir(flame_shared_dir): + path = os.path.join(flame_shared_dir, s) + log.info("Removing `{path}`...".format(**locals())) + if os.path.isdir(path): + shutil.rmtree(path, onerror=None) + else: + os.remove(path) + + # copy scripts into Resolve's utility scripts dir + for dirpath, scriptlist in scripts.items(): + # directory and scripts list + for _script in scriptlist: + # script in script list + src = os.path.join(dirpath, _script) + dst = os.path.join(flame_shared_dir, _script) + log.info("Copying `{src}` to `{dst}`...".format(**locals())) + if os.path.isdir(src): + shutil.copytree( + src, dst, symlinks=False, + ignore=None, ignore_dangling_symlinks=False + ) + else: + shutil.copy2(src, dst) + + +def setup(env=None): + """ Wrapper installer started from pype.hooks.resolve.FlamePrelaunch() + """ + if not env: + env = os.environ + + # synchronize resolve utility scripts + _sync_utility_scripts(env) + + log.info("Flame OpenPype wrapper has been installed") diff --git a/openpype/hosts/flame/api/workio.py b/openpype/hosts/flame/api/workio.py new file mode 100644 index 0000000000..00fcdb9405 --- /dev/null +++ b/openpype/hosts/flame/api/workio.py @@ -0,0 +1,37 @@ +"""Host API required Work Files tool""" + +import os +from openpype.api import Logger +from .. import ( + get_project_manager, + get_current_project +) + + +log = Logger().get_logger(__name__) + +exported_projet_ext = ".otoc" + + +def file_extensions(): + return [exported_projet_ext] + + +def has_unsaved_changes(): + pass + + +def save_file(filepath): + pass + + +def open_file(filepath): + pass + + +def current_file(): + pass + + +def work_root(session): + return os.path.normpath(session["AVALON_WORKDIR"]).replace("\\", "/") From 48cbd28deb0444b53e207123b92f11848c5ec632 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 21 Oct 2021 14:30:54 +0200 Subject: [PATCH 02/33] Flame: adding prelauch hook --- openpype/hosts/flame/hooks/pre_flame_setup.py | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 openpype/hosts/flame/hooks/pre_flame_setup.py diff --git a/openpype/hosts/flame/hooks/pre_flame_setup.py b/openpype/hosts/flame/hooks/pre_flame_setup.py new file mode 100644 index 0000000000..aec9a15e30 --- /dev/null +++ b/openpype/hosts/flame/hooks/pre_flame_setup.py @@ -0,0 +1,116 @@ +import os +import json +import tempfile +from openpype.lib import ( + PreLaunchHook, get_openpype_username) +from openpype.hosts import flame as opflame +import openpype +from pprint import pformat + + +class FlamePrelaunch(PreLaunchHook): + """ Flame prelaunch hook + + Will make sure flame_script_dirs are coppied to user's folder defined + in environment var FLAME_SCRIPT_DIR. + """ + app_groups = ["flame"] + + # todo: replace version number with avalon launch app version + flame_python_exe = "/opt/Autodesk/python/2021/bin/python2.7" + + wtc_script_path = os.path.join( + opflame.HOST_DIR, "scripts", "wiretap_com.py") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.signature = "( {} )".format(self.__class__.__name__) + + def execute(self): + """Hook entry method.""" + project_doc = self.data["project_doc"] + user_name = get_openpype_username() + + self.log.debug("Collected user \"{}\"".format(user_name)) + self.log.info(pformat(project_doc)) + _db_p_data = project_doc["data"] + width = _db_p_data["resolutionWidth"] + height = _db_p_data["resolutionHeight"] + fps = int(_db_p_data["fps"]) + + project_data = { + "Name": project_doc["name"], + "Nickname": _db_p_data["code"], + "Description": "Created by OpenPype", + "SetupDir": project_doc["name"], + "FrameWidth": int(width), + "FrameHeight": int(height), + "AspectRatio": float((width / height) * _db_p_data["pixelAspect"]), + "FrameRate": "{} fps".format(fps), + "FrameDepth": "16-bit fp", + "FieldDominance": "PROGRESSIVE" + } + + data_to_script = { + # from settings + "host_name": "localhost", + "volume_name": "stonefs", + "group_name": "staff", + "color_policy": "ACES 1.1", + + # from project + "project_name": project_doc["name"], + "user_name": user_name, + "project_data": project_data + } + app_arguments = self._get_launch_arguments(data_to_script) + + self.log.info(pformat(dict(self.launch_context.env))) + + opflame.setup(self.launch_context.env) + + self.launch_context.launch_args.extend(app_arguments) + + def _get_launch_arguments(self, script_data): + # Dump data to string + dumped_script_data = json.dumps(script_data) + + # Store dumped json to temporary file + temporary_json_file = tempfile.NamedTemporaryFile( + mode="w", suffix=".json", delete=False + ) + temporary_json_file.write(dumped_script_data) + temporary_json_file.close() + temporary_json_filepath = temporary_json_file.name.replace( + "\\", "/" + ) + + # Prepare subprocess arguments + args = [ + self.flame_python_exe, + self.wtc_script_path, + temporary_json_filepath + ] + self.log.info("Executing: {}".format(" ".join(args))) + + process_kwargs = { + "logger": self.log, + "env": {} + } + + openpype.api.run_subprocess(args, **process_kwargs) + + # process returned json file to pass launch args + return_json_data = open(temporary_json_filepath).read() + returned_data = json.loads(return_json_data) + app_args = returned_data.get("app_args") + self.log.info("____ app_args: `{}`".format(app_args)) + + if not app_args: + RuntimeError("App arguments were not solved") + + # Remove the temporary json + os.remove(temporary_json_filepath) + + return app_args From 0ec42fca3fbecfca25b53959af0949cb1feb94f0 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 21 Oct 2021 14:31:16 +0200 Subject: [PATCH 03/33] Flame: adding WireTap communication script --- openpype/hosts/flame/scripts/wiretap_com.py | 481 ++++++++++++++++++++ 1 file changed, 481 insertions(+) create mode 100644 openpype/hosts/flame/scripts/wiretap_com.py diff --git a/openpype/hosts/flame/scripts/wiretap_com.py b/openpype/hosts/flame/scripts/wiretap_com.py new file mode 100644 index 0000000000..a5925d0546 --- /dev/null +++ b/openpype/hosts/flame/scripts/wiretap_com.py @@ -0,0 +1,481 @@ +#!/usr/bin/env python2.7 +# -*- coding: utf-8 -*- + +from __future__ import absolute_import +import os +import sys +import subprocess +import json +import xml.dom.minidom as minidom +from copy import deepcopy +import datetime + +# Todo: this has to be replaced with somehting more dynamic +flame_python_path = "/opt/Autodesk/flame_2021/python" +flame_exe_path = "/opt/Autodesk/flame_2021/bin/flame.app/Contents/MacOS/startApp" + +sys.path.append(flame_python_path) + +from libwiretapPythonClientAPI import ( + WireTapClientInit, + WireTapClientUninit, + WireTapNodeHandle, + WireTapServerHandle, + WireTapInt, + WireTapStr +) + +class WireTapCom(object): + """ + Comunicator class wrapper for talking to WireTap db. + + This way we are able to set new project with settings and + correct colorspace policy. Also we are able to create new user + or get actuall user with similar name (users are usually cloning + their profiles and adding date stamp into suffix). + """ + + def __init__(self, host_name=None, volume_name=None, group_name=None): + """Initialisation of WireTap communication class + + Args: + host_name (str, optional): Name of host server. Defaults to None. + volume_name (str, optional): Name of volume. Defaults to None. + group_name (str, optional): Name of user group. Defaults to None. + """ + # set main attributes of server + # if there are none set the default installation + self.host_name = host_name or "localhost" + self.volume_name = volume_name or "stonefs" + self.group_name = group_name or "staff" + + # initialize WireTap client + WireTapClientInit() + + # add the server to shared variable + self._server = WireTapServerHandle("{}:IFFFS".format(self.host_name)) + print("WireTap connected at '{}'...".format( + self.host_name)) + + def close(self): + self._server = None + WireTapClientUninit() + print("WireTap closed...") + + def get_launch_args( + self, project_name, project_data, user_name, *args, **kwargs): + """Forming launch arguments for OpenPype launcher. + + Args: + project_name (str): name of project + project_data (dict): Flame compatible project data + user_name (str): name of user + + Returns: + list: arguments + """ + + workspace_name = kwargs.get("workspace_name") + color_policy = kwargs.get("color_policy") + + self._project_prep(project_name) + self._set_project_settings(project_name, project_data) + self._set_project_colorspace(project_name, color_policy) + user_name = self._user_prep(user_name) + + if workspace_name is None: + # default workspace + print("Using a default workspace") + return [ + "--start-project={}".format(project_name), + "--start-user={}".format(user_name), + "--create-workspace" + ] + + else: + print( + "Using a custom workspace '{}'".format(workspace_name)) + + self._workspace_prep(project_name, workspace_name) + return [ + "--start-project={}".format(project_name), + "--start-user={}".format(user_name), + "--create-workspace", + "--start-workspace={}".format(workspace_name) + ] + + def _workspace_prep(self, project_name, workspace_name): + """Preparing a workspace + + In case it doesn not exists it will create one + + Args: + project_name (str): project name + workspace_name (str): workspace name + + Raises: + AttributeError: unable to create workspace + """ + workspace_exists = self._child_is_in_parent_path( + "/projects/{}".format(project_name), workspace_name, "WORKSPACE" + ) + if not workspace_exists: + project = WireTapNodeHandle( + self._server, "/projects/{}".format(project_name)) + + workspace_node = WireTapNodeHandle() + created_workspace = project.createNode( + workspace_name, "WORKSPACE", workspace_node) + + if not created_workspace: + raise AttributeError( + "Cannot create workspace `{}` in " + "project `{}`: `{}`".format( + workspace_name, project_name, project.lastError()) + ) + + print( + "Workspace `{}` is successfully created".format(workspace_name)) + + def _project_prep(self, project_name): + """Preparing a project + + In case it doesn not exists it will create one + + Args: + project_name (str): project name + + Raises: + AttributeError: unable to create project + """ + # test if projeft exists + project_exists = self._child_is_in_parent_path( + "/projects", project_name, "PROJECT") + + if not project_exists: + volumes = self._get_all_volumes() + + if len(volumes) == 0: + raise AttributeError( + "Not able to create new project. No Volumes existing" + ) + + # check if volumes exists + if self.volume_name not in volumes: + raise AttributeError( + ("Volume '{}' does not exist '{}'").format( + self.volume_name, volumes) + ) + + # form cmd arguments + project_create_cmd = [ + os.path.join( + "/opt/Autodesk/", + "wiretap", + "tools", + "2021", + "wiretap_create_node", + ), + '-n', + os.path.join("/volumes", self.volume_name), + '-d', + project_name, + '-g', + ] + + project_create_cmd.append(self.group_name) + + print(project_create_cmd) + + exit_code = subprocess.call( + project_create_cmd, + cwd=os.path.expanduser('~')) + + if exit_code != 0: + RuntimeError("Cannot create project in flame db") + + print( + "A new project '{}' is created.".format(project_name)) + + def _get_all_volumes(self): + """Request all available volumens from WireTap + + Returns: + list: all available volumes in server + + Rises: + AttributeError: unable to get any volumes childs from server + """ + root = WireTapNodeHandle(self._server, "/volumes") + children_num = WireTapInt(0) + + get_children_num = root.getNumChildren(children_num) + if not get_children_num: + raise AttributeError( + "Cannot get number of volumes: {}".format(root.lastError()) + ) + + volumes = [] + + # go trough all children and get volume names + child_obj = WireTapNodeHandle() + for child_idx in range(children_num): + + # get a child + if not root.getChild(child_idx, child_obj): + raise AttributeError( + "Unable to get child: {}".format(root.lastError())) + + node_name = WireTapStr() + get_children_name = child_obj.getDisplayName(node_name) + + if not get_children_name: + raise AttributeError( + "Unable to get child name: {}".format(child_obj.lastError()) + ) + + volumes.append(node_name.c_str()) + + return volumes + + def _user_prep(self, user_name): + """Ensuring user does exists in user's stack + + Args: + user_name (str): name of a user + + Raises: + AttributeError: unable to create user + """ + + # get all used usernames in db + used_names = self._get_usernames() + print(">> used_names: {}".format(used_names)) + + # filter only those which are sharing input user name + filtered_users = [user for user in used_names if user_name in user] + + if filtered_users: + # todo: need to find lastly created following regex patern for date used in name + return filtered_users.pop() + + # create new user name with date in suffix + now = datetime.datetime.now() # current date and time + date = now.strftime("%Y%m%d") + new_user_name = "{}_{}".format(user_name, date) + print(new_user_name) + + if not self._child_is_in_parent_path("/users", new_user_name, "USER"): + # Create the new user + users = WireTapNodeHandle(self._server, "/users") + + user_node = WireTapNodeHandle() + created_user = users.createNode(new_user_name, "USER", user_node) + if not created_user: + raise AttributeError( + "User {} cannot be created: {}".format( + new_user_name, users.lastError()) + ) + + print("User `{}` is created".format(new_user_name)) + return new_user_name + + def _get_usernames(self): + """Requesting all available users from WireTap + + Returns: + list: all available user names + + Raises: + AttributeError: there are no users in server + """ + root = WireTapNodeHandle(self._server, "/users") + children_num = WireTapInt(0) + + get_children_num = root.getNumChildren(children_num) + if not get_children_num: + raise AttributeError( + "Cannot get number of volumes: {}".format(root.lastError()) + ) + + usernames = [] + + # go trough all children and get volume names + child_obj = WireTapNodeHandle() + for child_idx in range(children_num): + + # get a child + if not root.getChild(child_idx, child_obj): + raise AttributeError( + "Unable to get child: {}".format(root.lastError())) + + node_name = WireTapStr() + get_children_name = child_obj.getDisplayName(node_name) + + if not get_children_name: + raise AttributeError( + "Unable to get child name: {}".format(child_obj.lastError()) + ) + + usernames.append(node_name.c_str()) + + return usernames + + def _child_is_in_parent_path(self, parent_path, child_name, child_type): + """Checking if a given child is in parent path. + + Args: + parent_path (str): db path to parent + child_name (str): name of child + child_type (str): type of child + + Raises: + AttributeError: Not able to get number of children + AttributeError: Not able to get children form parent + AttributeError: Not able to get children name + AttributeError: Not able to get children type + + Returns: + bool: True if child is in parent path + """ + parent = WireTapNodeHandle(self._server, parent_path) + + # iterate number of children + children_num = WireTapInt(0) + requested = parent.getNumChildren(children_num) + if not requested: + raise AttributeError(( + "Error: Cannot request number of " + "childrens from the node {}. Make sure your " + "wiretap service is running: {}").format( + parent_path, parent.lastError()) + ) + + # iterate children + child_obj = WireTapNodeHandle() + for child_idx in range(children_num): + if not parent.getChild(child_idx, child_obj): + raise AttributeError( + "Cannot get child: {}".format( + parent.lastError())) + + node_name = WireTapStr() + node_type = WireTapStr() + + if not child_obj.getDisplayName(node_name): + raise AttributeError( + "Unable to get child name: %s" % child_obj.lastError() + ) + if not child_obj.getNodeTypeStr(node_type): + raise AttributeError( + "Unable to obtain child type: %s" % child_obj.lastError() + ) + + if (node_name.c_str() == child_name) and ( + node_type.c_str() == child_type): + return True + + return False + + def _set_project_settings(self, project_name, project_data): + """Setting project attributes. + + Args: + project_name (str): name of project + project_data (dict): data with project attributes + (flame compatible) + + Raises: + AttributeError: Not able to set project attributes + """ + # generated xml from project_data dict + _xml = "" + for key, value in project_data.items(): + _xml += "<{}>{}".format(key, value, key) + _xml += "" + + pretty_xml = minidom.parseString(_xml).toprettyxml() + print("__ xml: {}".format(pretty_xml)) + + # set project data to wiretap + project_node = WireTapNodeHandle( + self._server, "/projects/{}".format(project_name)) + + if not project_node.setMetaData("XML", _xml): + raise AttributeError( + "Not able to set project attributes {}. Error: {}".format( + project_name, project_node.lastError()) + ) + + print("Project settings successfully set.") + + def _set_project_colorspace(self, project_name, color_policy): + """Set project's colorspace policy. + + Args: + project_name (str): name of project + color_policy (str): name of policy + + Raises: + RuntimeError: Not able to set colorspace policy + """ + color_policy = color_policy or "Legacy" + project_colorspace_cmd = [ + os.path.join( + "/opt/Autodesk/", + "wiretap", + "tools", + "2021", + "wiretap_duplicate_node", + ), + "-s", + "/syncolor/policies/Autodesk/{}".format(color_policy), + "-n", + "/projects/{}/syncolor".format(project_name) + ] + + print(project_colorspace_cmd) + + exit_code = subprocess.call( + project_colorspace_cmd, + cwd=os.path.expanduser('~')) + + if exit_code != 0: + RuntimeError("Cannot set colorspace {} on project {}".format( + color_policy, project_name + )) + + +if __name__ == "__main__": + # get json exchange data + json_path = sys.argv[-1] + json_data = open(json_path).read() + in_data = json.loads(json_data) + out_data = deepcopy(in_data) + + # get main server attributes + host_name = in_data.pop("host_name") + volume_name = in_data.pop("volume_name") + group_name = in_data.pop("group_name") + + # initialize class + wiretap_handler = WireTapCom(host_name, volume_name, group_name) + + try: + app_args = wiretap_handler.get_launch_args( + project_name=in_data.pop("project_name"), + project_data=in_data.pop("project_data"), + user_name=in_data.pop("user_name"), + **in_data + ) + finally: + wiretap_handler.close() + + # set returned args back to out data + out_data.update({ + "app_args": app_args + }) + + # write it out back to the exchange json file + with open(json_path, "w") as file_stream: + json.dump(out_data, file_stream, indent=4) From 8fac3175a0a23dbd6361bb6cc698a6eeb499406b Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 21 Oct 2021 14:33:14 +0200 Subject: [PATCH 04/33] Flame: adding flame_hook utility script for launching withing flame --- .../hosts/flame/utility_scripts/flame_hook.py | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 openpype/hosts/flame/utility_scripts/flame_hook.py diff --git a/openpype/hosts/flame/utility_scripts/flame_hook.py b/openpype/hosts/flame/utility_scripts/flame_hook.py new file mode 100644 index 0000000000..b46109a609 --- /dev/null +++ b/openpype/hosts/flame/utility_scripts/flame_hook.py @@ -0,0 +1,131 @@ +import sys +from Qt import QtWidgets, QtCore +from pprint import pprint, pformat +import atexit +import openpype +import avalon +import openpype.hosts.flame as opflame + +flh = sys.modules[__name__] +flh._project = None + + +def openpype_install(): + openpype.install() + avalon.api.install(opflame) + print("<<<<<<<<<<< Avalon registred hosts: {} >>>>>>>>>>>>>>>".format( + avalon.api.registered_host())) + + +# Exception handler +def exeption_handler(exctype, value, tb): + import traceback + msg = "OpenPype: Python exception {} in {}".format(value, exctype) + mbox = QtWidgets.QMessageBox() + mbox.setText(msg) + mbox.setDetailedText( + pformat(traceback.format_exception(exctype, value, tb))) + mbox.setStyleSheet('QLabel{min-width: 800px;}') + mbox.exec_() + sys.__excepthook__(exctype, value, tb) + + +# add exception handler into sys module +sys.excepthook = exeption_handler + + +# register clean up logic to be called at Flame exit +def cleanup(): + if opflame.apps: + print('<<<< `{}` cleaning up apps:\n {}\n'.format( + __file__, pformat(opflame.apps))) + while len(opflame.apps): + app = opflame.apps.pop() + print('<<<< `{}` removing : {}'.format(__file__, app.name)) + del app + opflame.apps = [] + + if opflame.app_framework: + print('PYTHON\t: %s cleaning up' % opflame.app_framework.bundle_name) + opflame.app_framework.save_prefs() + opflame.app_framework = None + + +atexit.register(cleanup) + + +def load_apps(): + opflame.apps.append(opflame.FlameMenuProjectconnect(opflame.app_framework)) + opflame.apps.append(opflame.FlameMenuTimeline(opflame.app_framework)) + opflame.app_framework.log.info("Apps are loaded") + + +def project_changed_dict(info): + cleanup() + + +def app_initialized(parent=None): + opflame.app_framework = opflame.FlameAppFramework() + + print(">> flame_hook.py: {} initializing".format( + opflame.app_framework.bundle_name)) + + load_apps() + + +try: + import flame + app_initialized(parent=None) +except ImportError: + print("!!!! not able to import flame module !!!!") + + +def rescan_hooks(): + import flame + try: + flame.execute_shortcut('Rescan Python Hooks') + except: + pass + + +def _build_app_menu(app_name): + menu = [] + app = None + for _app in opflame.apps: + if _app.__class__.__name__ == app_name: + app = _app + + if app: + menu.append(app.build_menu()) + + print(">>_> `{}` was build: {}".format(app_name, pformat(menu))) + + if opflame.app_framework: + menu_auto_refresh = opflame.app_framework.prefs_global.get( + 'menu_auto_refresh', {}) + if menu_auto_refresh.get('timeline_menu', True): + try: + import flame + flame.schedule_idle_event(rescan_hooks) + except ImportError: + print("!-!!! not able to import flame module !!!!") + + return menu + +def project_saved(project_name, save_time, is_auto_save): + if opflame.app_framework: + opflame.app_framework.save_prefs() + + +def get_main_menu_custom_ui_actions(): + # install openpype and the host + openpype_install() + + return _build_app_menu("FlameMenuProjectconnect") + + +def get_timeline_custom_ui_actions(): + # install openpype and the host + openpype_install() + + return _build_app_menu("FlameMenuTimeline") From 1a1f6e7039e690678af4f31854fb9903e5b82b29 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 21 Oct 2021 14:34:02 +0200 Subject: [PATCH 05/33] Flame: adding settings and icon --- openpype/resources/app_icons/flame.png | Bin 0 -> 74845 bytes .../system_settings/applications.json | 36 ++++++++++++++++ openpype/settings/entities/enum_entity.py | 1 + .../host_settings/schema_flame.json | 39 ++++++++++++++++++ .../system_schema/schema_applications.json | 4 ++ 5 files changed, 80 insertions(+) create mode 100644 openpype/resources/app_icons/flame.png create mode 100644 openpype/settings/entities/schemas/system_schema/host_settings/schema_flame.json diff --git a/openpype/resources/app_icons/flame.png b/openpype/resources/app_icons/flame.png new file mode 100644 index 0000000000000000000000000000000000000000..ba9b69e45fa73d3f9298e77ec4a167a38f7ecf7b GIT binary patch literal 74845 zcmV*MKx4m&P)e~&FgEY`8E2Dh<9#OCiw%Y~Ha0mK zyaW@T0UHBLhGoI*vUyo;RF+1XJahBC{h#lgQ{8oYMp}(Dl4eHz&D8CxQ>Q}ry?wqu zRo&IW6TkFHN|9m}p@+jp4(m9q<*;q!@gKGMT+H(lt5A}N0c~R%Ha|YSJ?JKQsO|~YDSfmNFLzuLk@S@_CCu?kz&~( zC6E+n8W>`_+QQ>(3-2Q9E2W?osh4s)hacD$0mEV`Qk)T_1d`%(0>ev>=kR0>PvP(+ z(xImmCk_mGeV4;`YNC_mxVu5*#XK;9qg>(*M zWdpYof6l^JDV;w_u?VCDlHz#KM@kkyPwH(f_h&6qoEYXviR9xPKF^`jnVlj=6h5$g$4_ziRSwVN(AAl} zB7g~Ew|vME3%Cmj0n3YE2@(VtNa$k71_2N0!AN3`085m#zV{Q)#{@Jz>84NNMgTmQ zk|&+x!Epki!V^OP*}_X3uz+Pza0eQ~hy^UIvfgx88gRV+jl;({+(deeQlv-;B!vLa zX1s`$IAHXaGqAoCmI6V*5+Hj>pM^l!&*1;e-+OT;fJyA{la80)#^DD{7LRnQq*w|tsbKgE%f(znIa#~31vHP zo`|RgEMj@Ohb?g@8*T@F3+eh?DOM3u0$Du(Q9PH!pOCI}dpZG+0?T6{e1{1mT^?f@ zf(ZrQ34R+`as~k&%jpDG4E`GqpCu)h6e}DlfvkLBb=&`s!z)N9S5Fr(9EFu$B`|3m zNaUwj#$YnR?Zj9q*#!)Hoi1RW2-NOAa3*eRwm)3PVaDD-R$J+)?!^Iz2-W z7>@c1=|X#7Wzt9&+grt8GI5J#B0Q6HQvOr}L~=dpom(l9tRSQWvT}eqj$h&M8qx>M zP9+{CeRl+wt+qM8U;FYQ#`&4p)!Ff@ymfm1AFqy)0O z@gp4GNP1iDslwf)OLu>sNdv=FDN-C0OePrK!m|Ddc$o220XJyi=8#*G87Yi%Ybmjd!|#*M z@1;16ND1WhVgo60;A(6s@t+(%j=;n*m&{C&V!42c1fKUVTKMTi_Q?Xq46iRZn9N9V zYLF7h>B9>-+(1kF&hamE_!vU!aimyDFpuPyEc|#P`(!Y|;g2}{b21~v(jz61Q;#hi z-bs4D{89lAR$;k^kEC-PDONIUjs#2eAmG8-rNV!3_&*%>CNokj9Z~{0wSd?0_Z$Y2 z87Bz5EBr1rlrQXOXLP{W~26&C=J80?VI`CRp zygT8;OdPi+GgG8kEnp(St9bE#8-%5L9WRXBZeO(`df+etl}A0{(WEDkAwoIGAc`fLa8Z$DXJeV_L+ao|bv6e&`8U?Rbj z?7vC6ZrsTN&r9K%kq;!~DHefr2VcdX@OCyl`F*lORxQ#A_xGf$n4ihbJfV774860PiL> zh>JivgmwJDv3fm+f2ETnk2Z(5bNKC)JW`}MIoLUbXFPCD;oLe=;GD*}jdMJ;i^qx7 z135n6Npd{Mce1bT`~in|@Q?mo$&3^!Qk)#v8~H6#GI?AwZ;`;WBELrZBzuZuAZ6Oe z#>J$=O(#npALQ_olsrm{V;+Q|S91LK^>xu4<~ND1WFz$&5Np_BU|;avr< zW~g$9UZv4lNpNu-qZ1q z{GVe1X2&A6Ide$ljSF~iZ1RY|9k%N;_37hDN-y0 zus4FI&at#Rp5Z!Cz*u6H;A%4CY(+{SF@`w&8;7SQGZuj_a(F9~2VQ1#%JJ@ZzLUOj z@eZ0Tw1%iv=`G}oRIk@3CMjF)>+PrhR;4{UQ=y=1kOl?@s4#b!4o=so&@)2)gM-wh z2|AFOqk+jjDp!aGuI{HFtM8?I_IA;|hubuG#SlHEE1>o+qQi6Jv?6)b1qAzc7uYgBlLuUDGK(^ z(YI@Tlq=ULbIu4ok;~GaSeD&3LRa@s(xE+NdYEO!D)*Q5wzKTOo(fIXdMT*XsI;AB zeG{~APnD)wmTNYtUF@ZEx3{T2d5|8R9;Q;cO^s}u)(#BO2pwW(&rvweA#6}?a1C7; z%+djxq9fG-X*)MO!q#Pm4w|gcXj8M@r2e51+7cY0huSTwPn9T}38|6E(x%>S>Io0h z17SDiCo|Lv>(s|}474U`_f(bgg&uCJ#dUwb{BC^M0vAjkTxHVq98(T3nKJ=o^^{Zpv{ztww|LEI&V(rv2dzO;q}+z;(16O$xZ4^EXszVswlSREMZkZA)L< z#eO<(TZ<}u3?G^3rS8%YU9hP_NA{1>!Tff*yiuXC>V7)1^#Zy)Tc!Q`X85;7G+pNX z>S|M-`_(Kos8#Hu_4|jYz2+btnW<8yzK+5*8LIK`i4PssvjO#$%e3(@?{{xV0Uzsj zt(#gwlZx3nJ_h`gZe}S6=HHq7#>;jjMaKmu5xmsx4>|l~GH(&U!j3=7VLX{}HX?P& z6x%p_i%yO_Fy!^er;9v*iH9DhwbNrgJyd;8rBVH>vGUZLX4}o{!q)5`RHyg-?o?&w z*TQW3M;p!2C%30|J*i%;T$C%chl6l(uxoa7pr=~N4c3`Ba&6ky-KI^md||~zlFenP zl+99+4Oy0dB4NPBij5%dvaj-qpUni+qCgBHKrQ5QDN-yq*tz+~baL|< zun`~;*o?CtDS_zQ&p;k0c3GoWBLA0^Jnl_qo=V)^&eQ(NT;uSO!&~MmwV$63f?t|$ z)n8U?hp(A!1+O1(Hs4rj)&H*12;Vwht^fU4*nCT^RlB}kpLzXEZS1v;^4uTyPgQ=c zyEXO8TT8WH-g;#Ee~gV49zPV&Mb~b~U3|@4^@7~t>QE~)SLp377TR4w@xs}5rcw*3 z$kfqYEK-TD7<)P03_u})k_dlhGi`aQtI`gr(h8}}`R!aFMAKxY1_ESEMk(E-I9;%F z6eNOk_C&o1*b3N;rFwJXipAMZAXrZaR}&|?MD*PpUdBJlzvO_`Jx?nheZf=c;e4Y$ z`sn_#){*gQkxg+UlgY8Y9%xWFP;0k0)x+@WTG)Q_Oso0i=|=t8F*kPfNT&SE^=rC*e7HXMjCJGLD-KfE#&fgH^-mlKH(WAVUNbjQ$gv|s z+lwtaw<|*zwQAHo-Jp7qB~DT=do6uolZuTNg&ExH5>TBPrv~qZ^V&g3^)R3sydEPT zB`5WA-~ojx^Q1Td;9SKy%h8vSp2H^s*bLZ?Cnht_CY;R#@(d1N;jku|ah$-)pfBei z;s=r$rwiY@x;P?dMJNGo^o%_W-@=>m2F+$mup>LnybHVs$Bfz zzA$|8g{ZuPXRpW{3^&iU^F8Nh>)kv0 zGChy0m%F=nJ#B{Pl_xKryD z`~&<#GUIe2?8#HJtH1=34KNgR_>o7cceX;gu96x^yF$kX7xsS0@CSnYVXh;SfqfS8 zskB-&*{IXrN|_#*n+fikp6I@Le6;&3hxd1Vadh9%*AE}K@aDsZuDoM%?25zn>Q#-f z^{l~s;f33K27hyF_rPy#>h1fTjopRodJFl#s#9?NXrpoc?po~)<6-M9VHm!ZP5a-q zIaKS-8z!2;-!z)^>NP?Ay5VAY-C(8is=2A)cY2z^|2Q{Oe(rg_^t|)RGtVv_3ZJm1 z9&A4+Tite1UuN6Ivoq%u4mA6Rnqg*Brk>eUXlF0Z*RpHJtCX$fsKm5U%C%`I8_;0D z{tXjOi}$k)&js@|vfoUodL;xEC zTLPPM*5Pct3k!Epe2haOnQ@%Bfx{d5hlh78pHUR=dK0bP7tppJ1}~ltYS&LrPOUp{ z(OJ85zO3^0a{+L$=!EB{to$`>;jlFHIxu}EtQ zB^oJ~WZRd^QGdQjr40Opkjkwl%{7}e!^AYjq0(;Z#5~9=2pU=GDyU4hGhx`C2w~qd zpgFl|X|&yLAL1Ug3fWwc4KuBJJKPtxoBJtfw+i_j^=7lws)rMkt@fTu*r>297|4}^ z5_7|Jt9EF#5zb=Vt0#jyhDy}cu9Y8c*XmP^B6T+#ROlI`jX{<624xzn_EMqQrgp1A z{ex?1Q)Y~IawqB&U6jeRsTyQyYkv<7&_239?4|q>ctLgQ9a&2wt#R5tRiS*LmphKT zu-de7%`nl-!SpVyGk6!)vGAmuK807_;^}qFkDLhV9Da$z|4U}9W}IyV@&XPw(uqAF zh~=K&$l-68JetYOGl6H{`7hMiU#8gyre8eMu3cZL)z`Hv6}tR!SJTwGVVa(qp**gr zePZy5!=-(3l0=RLSa|+`F|4zZL_i!CzmJXNK(5GyQlxdIZd%L4GRTC|m&?l?UTCku z`tWq7>NT2Z)P!K_>;b_C$cl%QQ5PAk5;2+c572hdVvlB4eFbh-oQF)g-3~`trwUmv z%LEi=YHY;!w%d&((CNw*f+9N?^)?+UH(I-!L8IAS$OS!Nwp|Z`(aC1(U?ps|irk4l z%-Q6Mtya()-`8l))>|3sWqV%8=4rTCY}Q+|tp{2;DpWI+W8*#0KTP@7EIm3^r+g8& z@iI{|^KKd$rq;|M+Eu}EDNuuVsK)!=H?oe-&x|sGG^sk-!#mNEd7v%*{nQ`sPYL8S z5eUE}!Y(NN2`Q1B$co!|YW-I^d?J~#T5&cJ2wsB;H}ynbKC_#YIIy(IvcdE3*h$U9 zyJ-5MyIwp|WdfwENsp(Ue6qt}%!4z=o(D3|D_IE1d_SG21Kvf0O?)5z#Fj85=p)X$$PX)vSPi4AZrz5qhkjpH401du62SuO`+(L|II`7>A zjloS}X{fgo17H;)9>}zBZZs-@9)Hd;0ZmE;oPZa^k+F6wJjCX#nPu;%$bDwIYc#@O zPp#1yZD-re3eGx>I}Sx*;cj?ixQg65`rgg}~n9CO2K=|U!u1KdVRAjbzL z60D^KA~`3ScO1Y=;_$E`UQfO1akdZ$-Zu6TIx+IVT^es-@_^^Etnky{yPrxk`>DP6 z?*FxKdhYt^YIQ9Jf##;C>4}$KMy(6arANjOQx6+E?*e{4ft(B$s>_!dp{N}X#p|f! zfxHj~i@gEv+hCy}q_UAoWlLADkjem)N+FvOomnQ5!%Qeg>Q$Oxf}CTLsk0%(oDJ^u zlKC6>Ds8l*jbp2~6^EUDkjzl};*Z&d=+6QNB(4wtK}9|ZMP;;&9f|M=i{>E9Wk7LZ zo8t$YVe0^sXgkmQ(4ET#8Rmz2JKR-mHI6ixk$N!fr=Cz_wn_WO8m+^PY%A<%hV0Me zf>JhLn+cnT4>a0kwAjlZeL;cxyNm5sd#>_elUu1}<)Owro2>r+K^mY*dVu$%I@86T zCmZB?lM2IY=)7QxcF`P-&ka(6NeILeu31BuZe;>tV%T39p;Cp(gT19@aeyv7pTDE= zef02npAg7}o0&lNojn8sFp0pE_%jZdC-aU2AQ4=!u6j@8Y#@-IBfT0pkvWfBI9$)< z@!4d?a=`N+y_>p54^ZR22Y%z>>6yQts#ey)kTsf3>S9Cv_-mg^4;0u!@reo}Yqs#{ z#(tr+gBjs@IGX-r!Rl+VA;-K*l}QEU0?(q0ulk1zC0gIr#e~vB8#%0lr;;nMksaZ%A638!a8gR0&=^J zEv9r-CR6lI**`S1oG7DGjJ9Qjg#xC+@Guk6ILq6-e*qIGM%pTDQ+GF7jd9HFmsrC4 zALN-f$}QS8)2JT_^X;&UNoz1yqV7zlHQQ<)JltrHvFFF~Owf}lP){L`fw`&O&F0K( zD;t)$Q^j_cx_Y{4u)EeeP@bbh^=>LPxIb*GGsPY{XJeDbW{=QA?1A(!fn2hsLI?I8 zqpAZaR757AdyceGgcE;JAvTN z2z-GjLLRt159EPceU~ME|9gK*_2~_?dsqLn56m8V^K89#F>Z5(ahjZ&rmMDYqmgT_ zq92TpQlCs_Cm;sTw!ra_fGcw}JXW0xl~2e(D$Y}>@ixb1wl|xnwWTiF)YVPru$QvA zyGJJMyIBt3VW!!j(Q26va5z${(M+Spq$Dm0u`Q*11~&%Bq~f|Ko{A*QTd_~g(+^F@ z@)-V(&fn!I$5n(paa>$m5NONzOg>U+w@B8(GjMGJxsf@*Jq#0E$qX`eSdR%O$h3km zJj5hA%EzOP{VlNPQ(%5>gu#Jkv$?k!G@E%Dek(BH1XSlkHq&nJtx~Ol8xy)RMd~Z$ z!fdNObGTI>Wx{E6vv=56XjVq6)!IyMgtmoMCY&*v*tDH?Hx-HmrdWAl%+&Hi`D+#!f) zC5Sa}k)V0*(BZ~~j6VD0st>fRRfM~hXE`FP%w*AIfIZJltM}7x;8?)>;lMwgU@B~f zyR+1;@No*8A&qj8JxtZLFq;`~hQa=PHdD)Gvf~qBeWsM7`cx}N#R8LkE=#pSlUk(` zZQ4Cd?U4g3nm_<15qKcik-lhZvB1KMD7S1cL{A2*l|Y`%0rz*~RkVx6r#QTx$>a89 z#&SdOkMHE`(HiZjO)@+};7&+TBYd z?6s7#IT<3uaM!-_938Cc&=1gr!mS5PT|#$V28zBogWAzUmIk;8kGT zP$mA3Nd2O!a_4;j;+;fr2T#eB6Q*sp*{y>1>=?>bHxMP zmVk7H_h~W%?}su|Y}OCv!*)3gGW&BuxI1hIyO?|SwS(|Lroe|c+n#P?h-`eAn!^WZ zbh<*>>IMo%f)z<10F%hoq|2YcqgX8PYTTb9eaqph!fGUt%Q$?M!`fuVaRASUfIRL= zW-LbpZ~7<7hYh+kKYdj@2yUo1+s}~uhzRy66hksTIY~cx%@e48;dZ+F;C||7Bim_; z7eJ@YQ^Xc#VcGG()$7;qBh82WT$Y3c+HSsd(t}$yi;f;7Z zw2%*y+(no2OU07du*Y>P`KUaeR}qR+M3+ukmRM~Bg7sX zwdz$=Z`@F8HlGn0It*jAL#j6F)X(PO>Cb!y-AZ+8%vC6dwLp%BPFvWSj%}G5rdVr! zIVS_G%)ETStFxmW7m4DCy6bt@39kVmP9+kT#IP416DAc5dWCYU@GyHM80tE=caY9w z?`#W`OJAW#O)iK#y>`t_)1LCI#PQH0hRx7gk&hXMy<*dAUB+=n%nW|irARB@X=lV; zJnp?3c$C!%cqdgw{6(bcK7+Vs*C+W_MczpuPDf;D!^XKZEdr|#Jwh$opLlM@bfs8K zAS#RV0eC4aV{awgmkGjq3RL)Ew#`4ieEW`iseA9(V3F#j3^lm!!P&VLMIZo^2wrvj zY7W1W%sUS3N+<7B{;hnQ>e^C)T|U9KyyVA2*4zA5h;n_R^P?qF4868 zF|4)Xv04a(9b;LK1}@@a0ng(#D~&vWpSkVB6xNzFd*9SmvoqBj<{H&!IIAUB#&TaN z7XN5byIiH`{lt&cJ-r1Q9UqtHFWlAXV*=GYmaa3F$I|obaq)%9$r2|{2(QACha?T5 zS!ne-qG}{!Da$Y|iD)xc#v>HWqjY6*a;MkU-hR4ZU`WC_ef>-*C3*H_FMB4B%udpt zxjFGrrW!Sw(8o|$5pyVPgoXEPUtHV~iSs&iuQcIyJKBAVM1~ka40*;Xc6+Kin%|k0 zc;MxewF@Hh`r^{g*t`1UQhq)73$3kK!M&SX+X~|3kb9)fTx|-Uauf zFBzngE@=lu8tUC$$kbspeZNawuMvpV-Pu;$Pp290uH~76-1|jzI2VTBE>QN9nSAiM zdN)ntX_$6pOiWJFFFo^FbYQ~}-FxuB(I?c+{&u7R zUj`)g9FC?BB=cMyjx8jOD|y#>w=qSLD`BW-5=vI=c{m+S#pGdL4Ai6ixUAFDGJ3q^ z)?p@-YO^6S3~g=b?xl14hvg_D!(%PbxkeK66mlN$v;RW_#=wKKNq#yBso%B^zFxAH%$*rP0;T042@Q+G~2{0vvlbe z+~Fl_3`_mGabyyGA=3R&jI&6@`g za`{7}qi;DdJN+ySVd<@>7~WCq6v$TNqhn+AYd`u-nmd0heS7yKvSf&wVuRY#g|Sec zE9&G&(@DyJs9948l@=m(v5n5Kg|JAn)!l;ehnC9X@>a&xZ8A9`qH0F1?a0Oz*R(Y% zLaW^rv@8T-6S+KyWDXPStrm6X^0dCEht36=tXWH!j;y0C{R48TeULqpozoNa;PfP3 zK}?gFI|=n+NDvAh;?st-Zb_QV9X4zwPmhMlV2oW+^DIOsq34y8eF{w1+SKPkOc!lp z&O#JLqB^)LvHoaZRPLvv_U5HcSH2W2q3wuJo>R$ZGPf2og}R-~jMLsmy#x)$i6nN#uD<2NMM;SQTc1SK45^auwKy_z&(G+w^XfCwh|0P= zIi9h+E#65(0x(!A(boP!x`@f-inZ%yJJ8il<4hh8vS)HXhh6NQjMb|0kYf?c#xt2< z*%tW%qvIDFethWsmi9yB0gSBnCnAl&&81jb6NM2tP^wsxdc>}A zORLOJYlZPI%k~TXH1A1NY=4l*XXuU+1%DQ1!_UuV`>0rMQJ%fIda-@(^21MbqAGMQ{VWf=DmZw53%^vg@WBM42p+n6OwJPS=IaUdv}#&YMSXr2sz zMQz7*xnmg*k7?QsfH{vw-jBjw$y~io4K{#1`8;jx>8A^rL@rymo-SoV+1NKgRWA3) z^c3AUH73uW;8tNw#N&CBZp@pw=XKoGKI#`EZA5{-?|t-O}C&_N)oGv43b2 z&GZx~pJ0#=uRYPXP@j6(0*u$w;#ctnd4NP#)a|`1jX?1G#@!2x9c$dp;m?-ilI$mg z;4S}1I3)e8$?kJ9wd)#`c^MN(K@FrDGNOdzTG!J{=M9h06&p6v73(+A z_8~TixvbpPb??LxdXP!wKxK~RnhhnB0z8v!v=0%9*m02inlxeijy&}pBrc!&Ay2NL z`vHnLZ3~V7&<^4eFL~`Pq_5&A9qDxB*VlX+)Px$^37U-I}vd;5B1 zF*{c;aI+U*j*q?d**BM>uqh={=mZ4IXoSA0YVjvS_6e9}{C_z73hCm+0z*7|cH z&i?slt=gjb>7?98GS79u7dcs{*fKDw4GRY{VkjL?pC8|mteo9Xx0$3Z5Ovcm9etIp&j?-rrF5C;BXgwaQ5kah0MQj(|rGz0C*gc?V#7y7jpj>R?19J?u^2D2U^x3gAZE;9FzR>*cq^nA{oK#dSDSM*IyEVa4n}Zx zBe_lH`*3G@QVzHYZ_gCOhF1+zRHZ!QVfipe z=;V52{w=$0<>uFia@t4yYgx_Kt>j_EP0C3HQCU3#`rWuP7~-0(R%OVmuUMjU*c-WQ z<3_q>(^k4<-3IC^m1M21d&fswtLeXQ+D_ZLdS&s*F?mBHULI_O z6~Wq(n4jZjZ03Jek(O5tjJvA^|86bfIDcX2o<}A94=aO%Y$EfNTqaWOcKEbxHu#yb z!S3A6`2yXV$SaAa*`*7j{(1oXFMe446!Ik$4EUCH|L@KSGlW3%Q&O1r8Pg2YgEcbLr9i~ z80?hOLcSP7I6E*}dVnI*kvvf~8A@8imZWGH%*tusR7Yf5N_2g>MdGN%l}IJYet3qsuruC_QG#Zk(+94PDbr9PB1F9eOtFr2k3 zZDyzt(7$I|^wNj&y*sCJ#pr(hPh5Du#g;$V3)#S%H<7+4eKEL$16-|Tz1+!4@IWwA zyjb%15{JKC&Ex?*q0I)RwT(u?K6zu1K;4kYP{l?Hc`l;DLOw^MR0kG@{P*7rYujkDDIPo^#R_6>4L=LK4?BX5%GvzKxa%3 z&+#B&!N!4Ni8l84Q&&Duj~+QpAOEjg=r#ZTDSG9nKT7Xq&tz(1oPK8WcKU1+c{NlmKd5~`C&baOh-1ilzNV}|k_OuOgT#u@sqr@4d^`JQR@Z{KK zX++zyEEh$&Mi@RWPxU9PALyde;1HGi2dOkL7}c>7U^WO(oWmw92C#X{e%^Mu6Uc9H zcu`XL7{KkiZ{{x!48E!{6I@Bt!8MdEj8IojmmN1NM$vVlY~0xiYoMpN8~PdPAD}Pa zb0>9AOw;A-Hqfj+$DsOgQGpl%FYS=jageTf#>N>FUyMs&yOI}^Eq#Pc4Uz1J=$cpJ zG(&4j_Q#W18_uvf5^#AgHwvzd{#*N2uk}F;JN}|#&{2{@*YU)^4!60nFDv5;h)k88 zNC`4TVQVwAA+fj&{#q^VLqC(rCjPE^3k7=Q@F?AQ%eU#ePyZ{u;u9aCf4TV^M6+f3 zxosEH?=hMD!uAVkNB@xAEHR0@zvTTWs5c(Ia)^#$)J=T241P~Nj+1O1ASv-lp8xtg)zKVV`af?(VJ=U?*jRC4!@ZcE+;H!0>P~o z|Bw_u2F6Lr18*Zd3sB{=tU~!*hKhWZDzqR5$Q$RDNR)~$0-c>IkfUhUK66u~Z<_mn^+I#mq0#wP#6?%-Eyc6#G_TYxAuv@NC(dp~} zi-JZgysALKc^kIZXkYkjTE7%K}VK2nj zKPG%V7spmClR~3nh9binvy_Y@>2QV^%pkT_rXuD^|D`S!&wsky)u=i`BT<)}T+}^- zjkFqer`5R?xebX4%uCSvG#*tBxsz!_Cf2wPcbVLI8<4p=T@ap%b>ZPSLW$~UBEWR= zPwkWhUV7sP=?$Ox6y5#M1N7vfwe&memHf{OFQ=;q*HE61`xJX6 zWtn&4&tY{S1vIj9j)Z3F` z3dzB|(888qWyrGAvg2fj7jk%RmhQRtKJ`M{-U`!DjL!O4d`$R828P0`Kuu9#hS(^; z8B$S`0?}49LN!FrGl)%%%TtJ4+O}aK5$a@l)P~h7O=I{e>Z|L6>f&m@gAAm7wRd9V zN$;X^Ls3!NmUeR*(PX^UrFC1qo-95F2xwd9Nji1WXVk4jWlA50M?1*FHh=nkq#-v#393#B5(?FEFF07MzTcb)rwvD{rNsRoLlT;70V8= zQLt5u0p>$6kFxBr90>$3TKT=C@GD52HkT2bNJN3?#%?qg~PJF{cG9QuE&@GHX)RU+zB0B7&eL}>v zyt3$*Kb#~RF`Q<9qB#dOImmMa0idiJU`4!IWB03x8FvW_w>_mu~+gd7w(`-2Zn`{ChN86^}P{M_?zfU)QBE&1!79< zPT=Rpa=(PO1T|-FM%8^z?O`sRDzgXZgb4#=_O;M}iAPAnlsO>w`XDsFV6?KVU%I@RY04 z>3dr-w6RExDa;LwDXHO(T9GWs+xCoyc?MDsbUV#rRIl|#%7{my#1RpVWa~C6B<;p= z?PuKAXjsZ*(%(o=)7ZMNomg}+8cpp0^WZg7BPXi`L#54$*&QMx1=b!fe%BNp08h z0tex z0~-r+Vn2`6IPeL)hOb(`%w9U7hkD#%1Hlck5KBEyqU9YQ#p1Yr@j5LP?t3c``yC_){xt^|^Xn z9acUxV#2X@GQ^|dI@6K9qW?+~P6mQfGHF{G&2vHJAda@Y$1ZH?P?zhoyp2+Zx2(L8 zUfW)51ut`L-vAw+n4lZK`ek~>``=Bk{+EBI+wQ)Lp2FYvZ(Z>Odd{{BXdT{Z(yY@= zvmvkS#ZirqrS81!HtE;ydlHIdnsm-hs<3(zL#^&xjj)l+2Fr;+UP!uJ$zt#i{6*+`9%mz({M&1AXyNWEJbD$x^N$_p zXBb&qJ>e4uA<$G;u}Hhd$LPVG_tTGV+^Uo5u8_Y4{GYv4=n2eFNL!KI84wuKz;(jP znGOu4q(bd7>X0Z5p;cq*&co2A)HrLKswit{nGpu$YTd}dRMdz|T0I_jbuS8%=V~=M zWEegYqb&t|4b2ys`ZwBqTvmv{^xQC*5QoT2Pll>AuESKSr^MmB4J&7JA%rm-E-t~6+*iB!g^~~zOdD+$U%NJcn+k5({ z%9sA|O7Q*?_uJ|GA?WTnna1_gK}(YU1iY-c7F@u%sP|n1p>%sN*US!Y*gQ#VF5l~A zu56g?TI`*sc=O?}CxvGU%Y{I;kh1Y&@Sn8!gTJ~^zk;axc%l#YpC2ZFXXu4#;zSS<(YdmY@xOpi@in`NLxuYVrF5a zs2EsNvwA@Wet9I5hRcv)^|~P{F~k`5l01_+U6~7s^&+chQ8(8S_0{wu zuLCtl?YTlyq4vom4p`8LIE)-X1hNkip(tTUooGjhLkUHmHHmZ(hgZ_3JbNYU`Ug1- z(4K<_>0ST+8Tx~Fyp3-7*N;(U^a%aJg_qL*+;J6MGPp*TO_^?D!X16|hp-Z?fZRA! z({VHsyYH$)=oF(n1YNmsU3ad%`H^atzA@43N~{*ZhQXFC1{nT=%{=2+E(G!}THMQ_ zc5(PeX7C4-8D|wXmx;Db^Xb*1Y_@23#@bowPF^_|#q6p;vO@1H2Lky=k>0K@y8qw- z+Wo*o^o-5h@MzG0yBadb^btLhE$D)$RdMj2-Wbzut;xRH83_+yVl%g7N@5<)(Pr=q&i*Eq=CSyH7N;&OS~j?^W3I(IVA-9sDsf&okYfAX7O zr8m<_{f8@rNu#{g_4Y~~rqav%__5rbDcE(Rcv&n1Ot z8-g8t5?{r3;9R0yn4zo~Y~QlGsKo;{d@^M%qWElfW*Qm2-CcCk?YGhvu5(j=Kh@gJ zNKbGyBLLgvYLa#k3*z=oB6+@%fkBINRl$=xQEY@Y?&c}XQbt$d+C5Jfe@;&eD*-sU zgXyTT#w5f1He9`qDEuJ3m3Ul_oA`EqnGRXSWt>M6-&LhXUWTiz4)q$e&k>=B7Xdx> zP8@kjn^FxvY-r0o5b2YZhunE25s|Q`Q_AIO6B9{SAx~fb!L9UX?|m1&{9SLS+it&| zp1yW7{l?|j(A8_!36WrkQ|5C#CJ}@0C9q3Lh_@U%$CC)-tbV>4y?ytiba?k3`q3@hX@ajx=TDS6_$Rx@qqzn0j5$8dFhmAK z`lO}Qs9HIzQspX(hLIQ;iqf)i>u#$Ft4j=O)Ox(@kXfE8Xo$TPE#daqscAxWqxKMi zQASjZwCcF}mC~&rQCr$K*Pgr8SY;Fsxmi6TlQ=vM=UqUq+sMl>vWVoO7w74m2?ztk z5)X@Mf1R#Qy5}+(+AuUsLp{B8+x_>^>pt`Wdf7YPM&G>U7P@wL9leN&#J{1~zd(MhVVNS0(?Nf4Cbunu>yL`op)qmA>}vZ_`tU*3k2ym_#!4EA**G zGIE(*d!$-!cOSfG#Xm^6Ak3z{oz%u1FNWw0&jX)H0>MKuFGvcH0oaar@PWqr&{HtV z!Id1INR90IRLBOhcpgr|=ydhZ*be^iPfwLNlP7ULgNOS1>E;K1NR@+!=)Y|_hbC&Z z#7Qlh9kC7r5s`;8$eIJp001BWNklQY;4h2>u}BO7E9 zpA1|VoDK}NGuqm;=Lx`|TAk_%xq@c2wJNDM>H`E~h>?pP$;^qCL;J3mmX0U%RVHoY z!GDv-1g32};_wLoq(EE0K}Z9C&~wILbd3P4ooJ{_yb8!WJr$Q$;&5`;zB?{fFSj?` z*GHSyjL@!w2k37;dL#YOKmI-4{GFTW>1#LAFI{>iZSL(8Z=}|?g+0mpEk<#dfx0c~ zPo{VmGLp1yo%>bckGZ)qldL|NZF>i~ljisU8;bX^pAnoX0>P~miyek~Cm-NtF>$nf zfHCMA<+A!9puF!Ic3X_Mo_x`%31i@dVi(*A9G~j$qy^y7RO=18<<2|l8ot8JW;14Z z9lW?i(W4pdXfp99Q0oMO`B`j7kV9^oO{d4YH)d31Ijlfo?sUWf3JV z#9@-P4ONQ!5bJ6;tUoZ$B6Et)kb62&z6GmHWoEpcZWt4rt3MvXGUBnauDp_o8F(WO zgm~!6WG>H$L-J6+5lpNbla2FA+>lvR56cF7K_qMFkpuhbuWtMhz5MNOqkHeTlb*fx zJbK=RJE$jLkoC4&LOe0#2-t`0YV-rOV}qX$_El3cLJ&xc^7#y1F|an7yQ*L|Zn4jL zzzaDex2K&c0(muuZAsxV0A^0fV;*3_utF~+U0E;W zN?#xR(|HFcv%2L&+eS*s#CZy4;E~sk38-VJ%;e7aJ5MAUszTdQeMV4zFhWtn(K4|o zq+v*iWQ6YDvzuQ1!S~Ut-uX{7_ULZ+v=j;Trt3cylPPO9F?s*d%aUqMcyfDY=Y0&#IjdY$kXBJDZ20eopkN` z%~WgKvj>rm)~{v!Q(khsPd^eTV56<{TldX~uqvz{bLi9vn$yh1UAtM3paHOwE zyTXpjhh2dhF=jOfTS@S(2#?g>2X`e^>`)S?K5Z$)(etPA* z-%fA-mk-lmrB1)R<4U?%0>O@Ij-;+su5TXyF?CQ~t=t@2esKqT4 zHdc(klUr6q4lv^OrzGf;hw4h%ws{(+<9ig&po@M)5=KZKLBzvO&^JN=QP?&f!cu~9 z4<<@oR=#5r9&HOzX-COAQ-u8F&=`VbNo;q^Jkvp|G z-30P09G;dG9s_uj*J2LhS%r~&APo^-SX0+{f|CMPv5d#9wLi=p@|be7GN8-=otv>m)GdLU%OaHHA*h- zcTj6q+Xh628&cRdPaRvAryiNB$F8VF0yM6L<~t&Z z$;^3F+9vd~nG9`RJ0gbV9iRIQz3iXfMtgQXKtFr_C3MC5jWlbo+I4GVg*bKzk0kl4 zKnm?NnvL)~c^sg#2C#jLd9x~PD{Sm(!|5atO!B-jDSQmT`}lrRm_qUOo&EH+`$y=T zyEC+V_Tb@qvvCl&;JVY#W*___nwGN`h$434MhOdA7DnM~cic`Fm3nARsf+3`B&LDS z&Y~`1?Ml*&P*g(IbkaSlC2mnfT3&}s!bmSBBgjaILF$sE%3M3DV6vzV&5Ot)5?L8n zv_mJZPx~#I611pR5~(HHRe9W2L^@=~o{W>EZZ+s`DHBH?5iyAvar0zMu6afZF!s*K z8xc4Xkj_}X4p+H)T2Z`%c)a$tf7X`t*L3tKXdYoz`H;GcMLK8QI(l^U0KMu1@1eJU z{9|-Mwn)#paEC0Rg2ftfApKvbi@W-KDDO3CW>KR$D;s9}7JC5=8++O&)K4dYyo$r- zr0^KPTe#Apt7G8a$#2oU6W^eFCca9CD^q+4nr+1f*fxoCQSyByCy!3%?a8G|?Y3ymDm3SX-$Mvd}8s zkeOS!v6C!fL+^&290Td?yXK9UMZ^$n6L#R|^@@(jsmzI!wM;XnNY{qU~4>AB}$MCT8$mERGnmLV;xD!#h|@i>WZ3#+mv zU&(tG05%Y|@OS|m3tM|yaJmTO91ee+6dnUua`S!6z?Ec13J;l09QgMVY@tk03@D?E zj*InjM$&_%%vhRoBxKfI5w8D1wReCI8_4o>Fk?Q9?DC?&?I z6Z2{n1EWal0{%RPMqR{=tuKp$m~fX9H#$GjQ6}oItKT&J>79;AbNsn-u8yc)({W{^ zbQo9Y)Ge<2W5g4}8Hoy8yDF2OLGF=&|6B-`X>s!)5;u~-cN*QsYb1p>QLp@LB;WPP z+Z7ivXj)~c6Az`HvvwU#SLWz-ANdfy^OK*TE4usW$F`qO)pkpQc+Nrmt8~X%c2A>0 zo0bM7IXW=2l&8!DJe{mK=4EkKf)*56R0aF z@JXody*r)dr1z|@$lV!fCwZLh?ssw$oU#ZgqWAuP^{zXqr&6OGBY0PcE%mOIN=X-& z4U9pQXIuH#tq<*qHD~>E##gRQMQ*xLbzZ&l=~rrHqFj}Ufr^^9R?SF8A-d67Wy-$Y4c4DLs+9OI`Hv z!2`7Gkw@sMo3>H8p(k=@8E0C|g1FF;t8GO?Ad#+_&*(#YsRqT>+fiL--4c%tdNdN^ zjHG5;bFNL3yL3l|eiX*p1W>g)qzOs8%6uY-;r7?bBd-YvjnqO%E|9@sTfOeMSlvM6 zd02>tzEo3XI%MU%4mUKV!&U@Nvf(QPO=A}WyyZ*UiOI%!Rc?QyV+wukQq-MJm=fvt zfqd~s)@`7@<74!vA9yc)>z41(v$kxfP5pz>(5U+oqpwin!J2%Ywzitt#oWQUn(zTq z+jzWy&3#f*xYRfu1oCEze+$Qh4>IFEloX~|09*L5Ze;_oHK0~0$0vXMAYrsTgl&8? zq-mFFCCm~eZ9r5X2TXo`^X|Lof>JlF?dl?oM>~QM>mWt~ACdXDc0DYR^hI&+k~t2c zC-x1eWkqotZt>A!;WX|OtLwM6+}VET4>%@frjopqk`si=!XzWw6(Z;Dyl2NZQ%7ph`-VJ*Au5ZkCRZ3#C$z? zL+b_ysmPwkTmJ3S^#0HO8$Eew9qm}ZK|EXZAz2syKC|UqQ0hL?TwUwAA1BNvelRIN z25|WPHYr?coN5BWdRL3NZTBOzxPx?7Be;yi6>I`84=Ga$$fnQ*I{XlrL5WJti-}Go z_R2?}S`T7_eSP%32kxiJ;Uo0qOd4y4O||b$M}0Opwd(utsqLvDo|oUKtNs zpGA@~`;3gOlSK8Hk@f0z1SHiV>I`Z}|Kci~NZU|>_TIIrLEAN%pA~K5M8{VqW#f7} zbhOea%k{wx)9G;zg#d)Z_#=b;ZQGJ1p}V_F$w!9|T^nwzAC@At5+7eXPVtXPY;e(_$ z@^}H;3mbgOa4HD|b^ldTcnloj@F8Z}vu}{|1aWPxMNh4^=)culG{Dzo&CJ3VXRrm% z$S-seD>Cr)_l9 zkW{gJog32T;wIy3d%xA#GqL)jYE|aG1E&-Bz_$KfP?C;izLRA?kEnFASQ5!>byP#S zMSRQJwRHQVyXen9^nRMyvzMN}^<2vEZ_U5sN*t%K6*h4%)>#U(jUOVlmB$O%;8SMj zPg%u`U*>RCQg{q}m=DnBlfo272TZtxFhkuzfr3ou*Qv(FH_M*6P%C0VH)2j?+j+W2 zHH7+L;n(lJlZKmZx?pgK=9-Pzia=A8r4m;tPDXk$Vo}F933Vv@<*Y8>kotM^;}&5H zCuQ`*%dvc^*9^8w=3703E9aN@hnu1%d>M2^Gj0cf!PmJ49J?^C$Rh#OjVtp#GnWSf z?ik9meAP2;w{e9zq6=`MZ3O&&^r8W2$U6>!>;&YZroHGmSgJ1~r(`;MQ=sW|RI0SQ zam_G|m&^3JkAH-|`(NLuC$8H_rD8E!0tIE7p@y>=W+OkGlph1I!LY@r1gDBX@*G~5 z6dnWj(c%tD3PvTkhRVScXpXL-Fw;eaZ1OHFjdh%_20e*m?;@7RSNHgc4Yi`LyPNLY zvx^Sx-AC7M*(UFR(7IyX&OweC&ZKyDE|NQ=t3yij%f_U)-OPJ0&DjaqO4!Wf1#B^F@{;3J5y-D| zI6o;o25w|#y)`LJ@mT2Nv#+1e{{brIi+V3TCCv?~5pWTT^CP2dwm@_JPZx==cwuxJ zttNf<-h1e>{$c9P<*6w{4cf9^O?wsC*0tql#T`*|0ysnGbRq-l3>tJSCziOlT+ge- zBOFAdNpI_(Bo`v8L`wH6M(1&fA><`n&5F9~1U_qel_bfsBipc0T1$C3(x}Ou5$A0P ztBa^pK*vBB5-e`SAmkxVh>?PP7fLLm5Rn7wboE)?rh|>Xn8q}%oGBpJ>WY)DIF4Z# zdmZbChv-wcevdx!J^j?xRg~L+TkUo*QYcuVvlnJ7Z%oRM0odYSPYRa;r-DGh zbc=Zs@)la$!AM~U35(O!WSh?gl=Dv94!y`mMW)k}1tOB9XXE^|AH_+I`}uFa|6a<^ zmFdd08)(KY^X?&On_)B5IL0-(t*@%>x(+#kmUA{ICW%-_;=&XrGdbWyemmL^bV_TC z;l-sJaU;0H%u2ZiBh{$c5G9OAUA|M(f|?eY-|oVQdlyk`l#Znu9BD*yi>tv7#p?+8 zHzEPXUgD;f!J&ZMc_vog5l5geER(qlX%2F5g>JPh#M-8i7G&B3(U+g6d`J=1wco6E zLia6~&C{zlfq|fk$)R_36vTy$@DPaP)RnE_WrmLC zqz^9BD?oAo<8VOSnN!0sHl!|4Nos`d)BzxpKJ@1>bq-;>Vs0J3?MvXr_4R|9e zH1EUYm^%Y;=oT{dFkFy4D;Ex_<9&RY7IzR*c!;lb zNuES6WO6uFB}el;ggA9I*PX|r>6F~roG?=EA+QGlKd!Q2Hp&`RTvxoASMS($6kmLMj|-!a0C<89o6fGyig}a z1fiY?$iwpDgJ5+)SJR^FGQEI)xqWbb9qR9=uiSMf-L><6HZDO|v)() z$BRFwCH6v=nm~Sy!+A;JG4L^F(!)t%isM7OI7sc%5VZ;el*?y>yqv&hj*^B_V)1~C zq$3@J#TPK46ibz~`g?om)(3Y|YjT>dUcZrMu)69zxXQg6VIMqcWJ{uMJEI(#pcu}$ zI~5m~togmx3Hi8~N#mQHTwytG+Ao!O1zd(F)}(Lze4s(w0HC8mcwl4$CC160Ja%6ddaYq z1cI;ql}X_-@Sn7}ollX#=G@n4YyQi$Isa8QMsu}#J3FH}-Wg~oZHFjnof7a>BSgbB zdX|^+McO|$MmryQn4Y*{i!9+C(Nk1`PY036gLyZ;EOb5ggi+Ltkq;4QpIlyCGT!gV zC`AU`(jwCmQ8|mu)3Zvv?j#Tk#K0ng{Xs;#7zZ3LGp;xZ{X-D#jZ~+%+KK@deQz$o z47V76jY}cmii+Uv**C5{{ z>XClO`i@LoUD6+G)8!-G=7ZN$EYe)PMmzWJp*=^&;{%lffDMH$Jzl^@py#3|?-LW}4L4f`dD zP9*g^kdSg&%(>ir-#xUsP@)Z8y|SvR=sC+7HF1=Nq&G;-ZxIqF_lXHHn3s88g~eQr z4jnC{KYDOn(litO>qy3lqlg8rr`mqp18?u)jodA_b3 z8}mM}b#pUF3pvWJ>+cjh=ZI%)b}l$De%NWGAn`(ALqDFB9|N$_&rJ$X6iZ1Uf07g& z19%u_F*}`N0etpINS`^9;ZUa^Ozx?dtCeXst#vt(&5%W=%rm(0`!Et2mh#Wgg-x-V zz5bqFy5r%8s6H`8S8v!zbB%`T*@^FfSZ6B+xeB+a_Zh)5qng5_*A>fRBQ7bFfuXV; z^UKOn@Q(`ubgjG;i>pc?*^596ASN56btA^nw4~GmSL*tmc@P6?MpO)__AQcOFGa59 z;|X#L&TFu^5Cpba5DU^-9c~hmQ9_A_v8=2RjFxkRB1T@ej*PTD>6=)84e@W#@24G4 z)M07eZC8iJ#nUo9m#zCn%~}mluBJ!;wsbKMVK2s39}`POAW!G;l%((&_yjZOTvC`~ zai~sxgTvRUGX4dcsU8f1Y`5i@IgZXg+gKD=^cIca(sq65$-VLdmmg56SfGR6FqWvu zyo2eu_B?kXX?7hcs470)P?$3)i2Avn$lr-59?X|_G@7K4q|8UO_nt)IXiCy(GU}_Z z6&p^`wgTGP{JJ$iu1ro&mxeKQgg~*8SHr8GL~PL2xC_}>+Rbl7p@T5gJJC55-MU+O zolK3o5{j!&NG94x*GFv3+p+#S;#_=6+ys(#acrZ)8g=#h2P=rH^D0z>9rsRU*b^~|UKuN*wo)@- zkY$D;GNRCdOt-H6*aPS^$|9HGh`kbn4 zkA#;EfeRg&Czw}8`Vcj!8IjQysVZp7PD86!jjkDQlrddLP)@G(=rCa0j53V3w1cgA z6^HhJ5HlSQC*XSt}9~kK_{^OcL;nmaS@SZ6srkDq_sh>>Bj{(?h*zS|UQV_@=(DB#v zEe@Yb3R9dsiu@H6WaL>-K;Lkgz%~|-kUSy7`J(xPNP@S?$uQQVkI>`RZ?g6+*L5Y* zPYj(!#Nf3V%$qvbI!z{@fyTT&>)5TRw+m%jr$nct0@q?^TWY6GhN6+-R)N_-QH-iX z+?6wft9^zXG8kUf^L535y5=noV!%6|5O>6&!&jX|kj-SVZLqMx@HXFA4q!2vuON$|`dB6*b_jBk?W*h}CXQspprcx{cg1>v4M#2njqIz~yru=(z zjrwaFt@Z#coW87xeJ|jzxa#v+FQO(W%fc!nVjjPS=6gtb#=Hl#2(1Yk6=RaehNtJ?Rlj8ASJxE5#s*4 zI(6%Y?IN$m`XBFy9R%o-+YeEU^g51-)Fp~qAJXVAdB@kg_y8sJs5o~x6{NbM<|DHI%rZZ%aX8!azUxL(8v_K_BPAqgF#pCVcxHO zg|4nc&933{Xue3TgO5;U&lGiEo~PTM^ZT+-DdyuzH~s(Yy$QS}*Ig#|uUmDuw=ex( zZ|X(6guKbJfidtKCdm&HCWFZsuswKe zVq+n^BV>8sv{_3_y}o_9`%*do?bNAr?|ofQ?(X;GsjvIII_H1Zs=D=kb(X4nFAhy* z{gBkQaaS4lv>&edW(DMDjQd01I09cb?g#J7zczTu5d6Lfh`F*4hwbpHxbZXFZ6hGv z9tj8$7LgnB2o4nBmdVT&3MFGAKAT>`lm@?6N11I}wt z<}vvAQ;)-Z8DCk%b-4mUL@Qe~5j7FVasuXQB~{L^1T6*7hKR3EvdA_kJqeMuyXmXS6MMX@0fp`x)394B)&T|@RQBM$3^aMw&v zVFfzf(?KC>S1YCEPQCGjQG!;rGzq1#@>Z?2{$#Du+K!5)V7y!orm9t#s#HwRGwiHv zZZ17=;i>J-jZMHuN~r?usl?3t_|~*0R&4I#O&xDmjEsYJ4Ce6s@b72|I4jU(_IMm&DSH|BWyD11!LdnJIy zQ{C{oQ_Y`QgXUY>9U~whq$y;`{FU4pQ?Q?W_d1mx|DnktM9r^`h+fL;n{rk**5P-5 z>h;h%vIxKTnNPq%8CQgHU0R-!DsMzUaEozVfwd#av(O}V$!NEFH=k-7BxO>p+VD?L zKG{l)NeNhNs1PN2ZJUZ7r^DBcC&w;*j5MA0Dky}7I{QGQP7u_iAXqBI@#$g|oeBH! zgt?PnE{>I-3fkTCrkv$=SXi1Vg}qL_Svwbnn|*WFe$%xjh)%7;QhN$MKk+cM&TYdh z7M=&=&pHI{@#n(AHy(hk2Ofe?_D{q4Pnb)xFv0tXB0<1?6F-laF|=WcLAH*K?-V$S zdJjPRMZmnZm_M!jQWI{WUGmCt;NxfVg}0ir9yI=SV>1dC95Lx%4#D%*;iWe|A8vVY z48C#yM6!&d(!EcG^_aPIM3Kr+IJ$gi8tFZ=cp_!?yA@Bw6< zyBG#s29~vA9On9K-=ldvy?wB z@g^}AniYXV7VS<4KL5x=aP8C#%$6$9>Gu+@*GxmeWBb4)L4k?tr>1`syFzl6!(UX&v%BN?` zlmB6^I`y^#;}dV1nVR~!v2y86g}KSM965C0_exOuvx%E#KC)dq`}uhB_)~|ERnBfL zpWR$H$8l?V9Lgt5PXNwBp#@Mc_YF_ic_OhTV*+uFi$G=NQ z(9z4e(T@iSU&Dm2V@GHFfjL_EjlEVEdi^d0VFBV!%j}OH zf9JsF*bR_tk#n;EA1r*iZ_J8sMnHbbH|&7_YYJwymfyZzZvREQTDh;;uRM04-`zTR_&___Xh3(S zXoR5#y)HiY5YxnYq1T77+k;*M2YyP1gJb#beZ;+_ZXXJUaIr@~tE5Jqs2u-ALAdrm z`}Q61lfEG1Lp)A+qFRN=&Ypqgr6qXI!6T*zLK7P{wJkP9${fc7Q5q50F?r@`vD=-IR7&QF zCAA&+%ezlbtz3o;(bksZ1XJVxIxJ+2JQsv3g90c=(W>cbeDc8L#5)hpPQ7V&@A|XQN-QsOpc5Q3eOk!5_89T#27k;goIWtQA)rgd^Z8d zipSl$K~GJ5dW)hEHnz9mD~~=3-!{JhVk=BT^n97*gL>!U>v%umWpR5D&@agoSyuZnW@R23xdj9 z`oY+{Tiwc6>T!9y8AnivS`b3l^cVt&VtJU31X3H5BgdtMaBf!KMK1sEePb3pT>*Km zZ`c7J^bI*K6>v;BGE@{nF)SLVq(`^#;LaFm6P+>^JX_4h>hqw)J=!QMkB7a#N-2B|+M6hh6L)M87Ic>2vCm_N? z%Uc24!E?$e0M%mg+{{?zFBiwhe{pVn_7~!~@<*-8-2Jt7VY^lDQO^Q<6NR8p%UqL( z8gt~hV%QD$zScKn!P60t+aUWAFo~*GdOPshwlUZ+CP^x#DH+*Rq`iQVhq*B@nQE~!Z$Q0Xa0R}_Nphsu_@@&D?U(yoaak&P+G@VRFsW`RDzOsXPzcggywNI zL73Naw?Ey^w!!c~DkB!9QVG8D_+wD*#&BYC8tPqK=&!9AY>(SYHgJfW#Ibe`gp96V z+98D`y%q{2ajAOQ@yh^tPcfg)r|=5_IThNBc(f);hSYgDZI2Krtn)O2iE(wC#{q zAUzVaNICk0*0O0&uxN2qjnqw|fx~{37Cp)3fPIQ`7`CU1rGHzkjQvWzT>WCZ6anrI zy|Tlm%#mZ?V>ev98;;+VfZ&T0v%a?daoAm>b36@%b93Tmpj53I4WXBE&}jUqv3OSy z?8+cYs?TF2@ropmPn_wL7*{No=(Q$~oqh^#oL|5P>$oXyRfKe2eJGEKUE{Tfz<)F^ z=S&Z8>9ZR6hd16rIueQz z~lRO;Z{l!UUYal}IbUHMQb=CMJ%vZ*sCA|>sEm$FtB550o zI8lX|CRsy8liL!dE3x=vQ6e}}pcN5GulXpA)U9>e<3n+@^R!qO%L>D&H(4zGUzKX~ z*5*uQ8PniX;Wkr_9M2#K7iaz06mmRreJ1QiK>i!wumkS)4LL479{$>=$vyJ*Ps0Oe z9)`2E#*^J%?}C1BTKN=#CM8>5DRG1#ZLZ#t5a<-~+__>6X(SyVt<9o3x zaO&(?*jQbI>*p4r>kX%2>ugP0ob^ha6%Kb^!?ujTdchfSI1cSJ365H0k3lk&WgP{j z)mlGr-cnS2Xrfg7qt@i~S2v<5=*MMfhZSg+#-SFJA*5$-()*Jm$1?!P$;i!F@Ji$G z8`&2{Rsnf6;79MW;U3dvTJ?=NE(02;+0~zbZhHyhFrtr_Cc{`A|8}R7q(?&oJ;AAwt`a=nus9Uz&qrhD5@KPcMnwaC9_K-keSX$z? z(-8^u>QI)ABYV0&oINTQ3cZ<1`2)o$yrRG3!fiy7GK)=A z%lyfnivUxkQn@^wc+-&bJDn~KV;vqFqu0B)hde8xBE$lRa3svF9LuyuT{fk5eYX^b zrE&x=NRxQ7h@FMiDNVkRhw+tS`KycN=x-($4%esV55mIi6dahFgL&f?W~N}_&^$~p zTn(k!lk^JpxR<HgML_V=a#_~~-v_&EWR7nJL0eqQ++}x?UV+1D z+7q#yYZFu^kO(VF$GO|9z60d?5J(0)!A{#`$7x>z8P3x3weWz;O}Zf5`kuTDy!)P`0|bCM*LQk2G}Lc6e66VljNnyASv7Pq{yY89S5e-5@c zx8Q0+zBmtJ)3^zxsAP&pMMaVgREJjn*}8KgluXm9sE{|{PK!p`E&tJHM2cbf^&7#-Px~veL&rM~?jl;pUI}_7T9v7ixts!uH&w z&>OEozl#T7+LI&4{>8_9!w$G8_g_pv@OrywA%f2t_i^8t<8mSthp%-H)5}+5<}>0n zG4h*uWoU>Z1{u$;x?-j!F-XPBpTufwbbR zp+Jk%V^C73cxec=+qQ@Iz+Xj$;q)?H8=$SfI*a~NTwnwwTrWn!m*TLp4BJL-Hfj)T zwJz$ywFY$RZKy$)zQ{;l1=I%R$gw|x9F1I^1>X-B`9WVyKyEeexNjVRPns^$wr|XF zIf3&E=sJLG8y8SWWU`!m;HU^2)W$@b{5FJV!482@zChqE}hwoS_*t1XZnufR$YL}7Tg zQ|LZYX-05(;~31XUuE3Ui@S}hV0QT!99*r!$;l#&$9-sStef5lo)4!1Idbe12uFX? zw~xS4U=0|FiwQ{9$#X>3M&-Cv=!k2@^wFZA7|LtK!FD6kG=?F+PodD1F4?9KGh&&T|2# zXQ9(-*}ce-W1nF+T)m?PTueY-}p6_%kQIT z&6O4P4;LFPIvwdsh=J+7RLd1Ox3UbYt7~xe>>Lq(G6ydVF707+(l+-sBuN&riAZpq zF5~5|23km3?P*Y)ibyQYN1;I%7FK2o(Z*5({naPn zhHI~Z*(ii|qdxTbpFEMBB6#B4z9Z$rc4>=r9&d zSp@j$_ETGh?#j6{55Ur+UxcN{zO-w%{MeV_g2{X4;m^U!_8BN#vjElE8Gw2d`uOU1 zx3T*jNbFf0{%G(WVOtxf7mRL`U8e@n6kB_X-PSrlW5pbgW#c~suwk|xe1mkEKL5K- z-wNJ|j&fWo2v>jFw~xS!jhpa|ik;5bDRm`NyM9WEZ8iQy% zNE5#sO4FN@YREi@O-mjgGV}T$1SHK?rwxytIRgi)6EImULbs3Uk=!{w6|W5r2Ko`S zLtGz=2>oD5Byd}h-Ut`L#Yk(MC)7!RC`wu`B7?6#DHMWkuix2jMN{q0p&Oue;A&_s zJY9DUw2f;_9fj`9ahST{W|+F>S}2BH=(P-q?s&Q3v53i0JI!(G^}6H&Fs|2zxYL2Q z*+RDkk+Bz{4@Hv}mr=y+Hq~a<+0@9%SWg{J<1y z!B7OZf8;&BP4RDx`&Yhk2=HrHFE*-{^*t*&E)iaQ?;p}OC>nyS8S?pB`^8(e<~y6+ z<{d~t)EkNWq5(0pHTt=`L1e|iv@wwuM{_)i0=^-hvnTmAUdzOrNBjO1vD2q)&HwX; ztX}=Hm%|%g_v7%^&wK(N+g^vsutZCG5dnFq28OhW<5?831@`e4H(vZmH27u_6)~DC zAurw~lZn$wdTya~Z+gy${3kHP3~xQi2ye zs|Ur;JqG9MC*i&Wk3jRuHTaH$FMzRUAA;K0^I`FUFF@}bkHJI7r(yXk-!P(B!JWkh zP%|R4ujc+AHca@C5us|S2t5>YBLqQ#(B0mq4{zbq`t?>F zro$dY(}!SlW1YUz*y#sQZm+=&cf1rSD z&MJiG9*6DITQK&lB7E-qUhf>_98U*#-2Gmh9RCN%)=Bt%7@-9_3CI_Xdya1$f{z>V zc!h7waryDXj)3$UF`P95va!{CXRF&10YQ#U3kciTPtp)GvC^HdlCvWQ_O6(UP@DLP zs5zW2Lj@#;&8;oC{`d)a$G>_5{Mq9V!ADO&4s&Jv&5}H6vzYw3dTA>MZ5yS2v{^s) zE~rPN*|ZEyr$c#tu1U+#ynq4%_`Q>k>eRw-w#OD5fD`?f6sD4J+K})T^fta~ z+Pnzu?M-OHq`5mU!fd|FGgvxa{!s_}u6r*%cXTqfWqS^bem^=GduC2jUv$N2g zo`TKu7a%N^@QY0h0>gd#NgHtHL2(DbB=2d{-?(|aiy0cskt zh&CRF>8S&7zG1|&(-Z-D$_U7D7La-krdrD|b?`Vmy1EU=jer!*eyw!R!|{Fg5$UzGnJUfBzwfN@dsxt8jX@2NxFR;cWF9csy)F`^+P-{n$E8yr2Y+y($-wO93My zA2XYm`}QIDGK|oIQGIIswBfUS;|OGZG(N{AM$K^zLyCB(NE6DIh!H}YWMe;%`d~Us zsT}15U6W^KQWt2PC3f*EO2rZ^udPDOkkOIJDZquioDvyIhoVH35L5^?5RllgoxDpZ z7;NWs$*>d+M{z*XB*R@4Pla(_tVT*@lW`?OkmbR_vr7O&{6Pf0xCzBABO2kgaO}EU zVb%yi>F9B|`np@79+jbY_0>?SoPYy2+y)iXd8p6b025c=0@ZQ^MMKoFXc;BB1Q?w zx5MZ-A6XlbB3Luxan=ylF>}{k3i*qFSPnjO!tJ$e?-V6M;P@q%((J|&4ojy7-P$HiF86Q(-kSsI${YywZp?lZXf;!h#q${s+yngX<8~>6w)w40 zHkBv3%IVA;6O_{JPp20?H$yTY?g z;2N}myf8$FSlAMQ1gYe#W+5CRyBLF-kCB!a*Q&&x#JDM}Kj4=tHX02$b^aV29Gigg zs7PPbkR?!n{Mb!oumhR`q z!O)?uQ3CP`-!KCA-*@L-kNd_PR|vs%;%+n)coJem*2K$5vBS7Zc-IPvl?YAjsE4(t zZA_28jZ+O;u`V83J9Yj%j28+pU8+EbK5?tm&W+-_OvweOO=Pr@9G~(#Eeu%H=r0u= zB)u9d80eKA3B?uY8H8597q>~4zL|fF0vLe|N>IV2tF_0VP4($Sb&eeS2g2R=`}PsY zEFkz6#^?FQ5%{cc$Z;i+3}F?51{9H}kjjM3v1dUb4pp*caj`ti(F`Kqgy=?79_APw zn?!ROto!ux5|mAFzEXi!FKL%mI%y-d)BGw)C~OrJDVo4(R7eKqMFM&4JI0y(xQd9< zI!~W-iTLR)9_p%fyU^?faagPx5jMBDyL6nV3=uxfS?EB!`-C|yYqb8c&x)QS$9_dt z4o6KuZAf9*ulohSZ|BQ`tS{xtaT$Rm7@0fwQivy%hlWTCoCG8$4(AKCTduchM?y0@ z9$4XMt3u8u0IU0@dWgD>0;x$>t~?RJq5k+>*jE;nR<(+E}WIhkz-#Y zD~F>dpf;p1OhB?eeU3}Z^JhQy6iAOcWs3w{1b!K!qZC#u_zy`&EMXEBA45Vu>szYRivZ<)peV5@av-d~CesN)GtPZEA&cfM=rHi_AlV&@97M`rs z%=Q2@W~WUruw)9u8?_w|V2&L72EyU^%wHB{5|BH5!w6)pG{+SJK5)j%Vg-(ufOpB- zWe}lA=+%KU55i$Lz!}{XOw{PuYLiQ%LJ&f;)q=AtORz9D4&zY-J@*^wlmY)xgT&9J zkQT%!G`K9*CT1f>#wre)n4_sp8paKZhlq^sGwq47^#eE>_u*u3dkMnr6^LqUJG=Ti zz00Z4I0Mx}8{g&uLD%`DaE=_$EM(>KfzE|5AP0@R%Cn8amwZExy#W#z-uIShb&)V= zDpm-VJV^{fbj14UZ!F~MCUW3P4(SnQkr)^B4k~n}P57|cndKFjDV1TesIM~N^puws zfE{gkUIb$?N`;Y!wa{PSj3Okw44H*O%d%~9ghdijkY2KAw%s^B78d%?uM`fJ+rbUc z4X!ornn73KZ6`hnLFri#P239Q!T~UOVc1|`jvUVrWaaWBz#3uU3rN=0SBR{Y=D0$L z0M`~dP)pa|(`jT%e!!jr88{`r-Xt=;jti@+=1#o}Gq0|k3IG5g z07*naRON~x;l33HEi+C08m93p`md|2=*e~5C-SqAdlzh^Tqq706eo$2hEhDb0v?-E zyC2_C3A@+eTULsdF(_B6gU+-&sFtBH9zuDh4MA}`+f&v#a_n#HhRahQnDPbW`CfPw zzG2)~d}EHi0+JZMvj`{AmDZUo$&Y_5d8Y4_-QI*+)PN}yVoz*{R4$v|mlc7DFB$<^ zT3y7Iz4eR`4pRWFCa}!3qgb$3%M$I)|YRU_n90`+%vEhZHFZ*Aqxs zY}k@!+`Bz)1>dn4jX^LKL%-63xZ1X^Up3)MG<^j_TMe*m@c_ZyEx1#PySo>6Dems> z?!_HSDO%hiKyWBf+}+)+kMG|5{z1-}v)S3**;(LD8RCzG9jv=@yzp9%k0g_Yw(GKc z%OqnR#!Ai~&^_I!bz{>U^|8MlT zn|=yqna23k5udX_2g!pG%AG5>n=vv=9G*!vrgn=)yz%EBecBH|*thoaEvpjOU9Wwy z*4L_Te^`&xU~`iG=WNH}de3Rct4-f>6$$-I5^mt7pz4OoJ2KrFy7wj9cY=|{6z+^7 z(%y>%={kej)F`HeL~)4?KN*fq_?rsNWcn@SH8Td%pW7zP`VHf6$aR!rxR@xGP3cj{ zdVBsRnpV&+oifbloo>}@|GK?rRgoqM`@CF$HkJ%^-tEw?j8Nn55Jks5mrgf!=>$P( zO(kXPuraPj)j8YTOQpy5k94xv*A@AFpF2q*7Nf>78SH5}I{4FE z?yU|y7OE~jPgs+|O2pp@6dskl-U}xu-ep%6^cJiWb>`=FMv{>2W;4$BY*<#0@TdHn28ueK<)(PzW=>DQqoxS*V^{vqxGDM_PZ#FlD8nR3UEnd3D6$waCoK z&B9T*$bq=_Jm?oQGErom>yIdK3fGQ$m-lHDGDlT7Ka9U=m4-`|&~q?5o2Log)eOLk zJCtgc6uavP6?KfwG74;YyF2441+y+wv_U?#H@QthohHBZy(WvI@oTc_`nI((o+-Lv zQ&v9OdoAQ2zs@j-yA5ivh4($rX{Z(9BRUupOU!0>6No&2aH5`hRIMN19eWr6EV2O{aSJyJT%d4Fwe4d^+Vijyr2@jCU*TtD;05wH`Wi635B!eRk&FfA!_f%VwO z-{#m>TJg^e77ccVp(6i&+fi7a=E3gpseS3Pv~4QT(RCm@gAVj&l}>PUCa<7su?+r; zq$P4$LZNec>F>E7PUbaP5BVYZ=XD-EkrwnH*LGb>*9wj#`9*fAq1_- zn6HEevZdx_`Me%@j0)-Bt904=EP@YWP6O*<*mBy~VYUC`rF6pu9TBuTHbKV|d zA|4wQ-$^t^lf>DDKV;iAnOI;yAMksry2lH6nX{#@pFq4(JUn_P?n}P#0o`TPM_6+I zs0~zH)hcR!mi)neITofGH+w8mpOLh*(_J)hP40uqoXjb!+`o5Q0T=0ZWzrw7i_(6^ zrI3DQ?N;knkoy(ZExwipuiI10?#PwoXD*-Jkj3VdJ_$rc=GsYUgfaEP1S<3X-lS{R z`|exI8~=pit67qBT4hOta3?3 zk~snD^6x&0=Yh6)S>KRAx*wztE$&La&y8M~7`UmQv|_T1yEc4BbZcLlr8@5B_dQNJ zLMN(Sf;RHg^W|rZ#y;kP@xN9Rpv8oJaFR;ccFicLlA2P8Gl^K}3GsID;-;0uNRS<$ zcgqzlh({5jh$4mrVw5>QlWZJBqUwCHs z)!uhh|MP+7Qi_9gSrr=vvqcq>T|WG6qv`4XCnf@24UTA~7lS-rnGIQ8Y_p$v7S33S zb8^X6o?_k*`@-#d>^?FTVpOdm4P!m5d27ulChoS*^yU~HP=cNt`$~nkj4I^Z-uGe$ za+=FVrYodqKJ{RKA?T-ry=4NRniU0Y&K}Mo6C~RgLX#~k_6i5+{(+|X2U5$XoE&kS zEmZWOH3!Rw4ZIJAke?5qMyh=7+p6--hNc5a{&Z?De4r0!(v@!@I6T;VQ=D2BL zMlk8IE*ig%hx`D2k%f7nf+oo@vkc_5VwIq)+$Rh0EeE(Es{$s3q-d6TsIa5NYRynR zAjDNQ&NPa)DBree(k8ZY?Q4|`|3)2Dbfk*Y|h@;PJMKr`r`CH48Wks)0{1uwe>50n6wuvD-?vBG->~-E)AaUbE z4Go}o1V#Wgav6o){mX)*o3C)^oWtAklhR3^9^*_4^!~)Hl{!9~HX=3Z&(71l3RvN30kc z$NFwkO}?=`568Aut9)kney4`6CO?i}1znYM23__iv5y2(wg;OzS0mE`vkg4BbQ;%$8#=CX3he(}Jxz;j;V1>MRD1 z#W0X6z+zj!iuNEV6vEcetQB$}cbU2A@-HI)-23q!&l8=+pVh6_Xv(qS;Tl`mqY(au zQDHEy^!H&24}$M4)V|kV~IeposOU9Q?&AYxl+*1BWCT5M!luwR;VpmxyWkHq|gSziizZy_WkeS`&ztM5j~oWab0ivDh`qa`$_M+-(JYw66E9f z&jT*VzE7O^zo5Tv40S2N?NSY`WhIcsa1%gzM}81m?n}7cD1gjotG7lITb~m$=5*?a zlQB#K&QXn8>W6^>%0LeOt;H;9*)mHa4Ev8mWdkuSt9D_t2UB1oqZhu%bFD7SfG4u^ z;Bu214wl$1e6}9tOQF^gen1QjKI)RiZCO?7{!LgrVJc5knOx#h+^Qp}!c4|><(45M zKClFRz_K5IK2~&^Z7qwIuWNo(n>~inxk&Q_FJJ$nsrj<(`;ICF{%66a5xNk1brpK1 zJFVlcboGhnXZ3w!ZZl%e7&Cn+*JAA~J|07X>G&7bl8tZ_*KR#2H^PW%qBS9W6gI&G zL;ZNUEmI9XeUvTZ-=DTLJ8%|!46JT}dqjO%Ea?yk@}Iv_w%-V)v~6et#|8*gnbN@9 zrWEI7?1II0vk=yzfmYYARPe}ahCLb=?v+&BM!&G}82?(;bHIP43R=7=?Gs1^HwGtQ z6(ryf)*Hl)_V8{X2A&vK%^p8PJOPHDkh5-j2z3ZX5_k5*$_Emw_N$CvMK0Mr{P5-n zFH1i3CaeDwK0U$`BJENlg-^577kJ;%liyk)MPgg2ojACR06VaTHI#`S=Dyd@$m=>I zU(RvtqxF%jk1-hSzZJ4zvgbasAfv7av$}bQnbaey;3XC*hU{-G)=yJTi*7S5mj#ld zz^XCp&}!(1yol)(s|g#}vTv^_*g&iej;6fNg0c3esp-I{?c_ek;Y=~{rv2XL?f0cK zrIWZL4mayCBxP-E+@YAgK^t^K3m>7u=6d%X7B{o}N80W3{L-aVu_$WhZ2o^bspGFU zty$V}uM9Ee?z2M*6^biyy=*b?O~Qql%KaHfWrczD`oTM9SWcyUsK3jQ(xCl{d(c+O zwPsG8l>tVNmC`|hm>}|ax%$`U5r@b7k#7}$i98epU#HrF^tte%yyKTT$6C<(#D~^? zK!+$7TIdYDkVuIL?Gw44qDb9~5JRa#O(VMFVqznW4>=PjZUu}cG@FR)9)49-fvPuo zJt%Fl*qXK*i7+eQcOyXaW*(s0R%Bf&GX`iXM18YcfLaO4N#?wdXT%?$M>Xsb{Osv)Ec?3oz z#~cS$K|s8?AdOgRDsl`&nT&;mH#-*cOcW^*LlZ8tyEAbWZU^GPTC5~R0dyUs=5#Yw z<64h}j3lThJHLKmM9D=ci74S@ljl(1MXuaUTS`CZkD|e1DwJpqnLOEB{vJ;ho$X9w;#e zCCs>>sX5?TVSHL{6aHcOx!gyE@Q_#L=TIg73dW_)=Xi*BO8UpeTm?txc8!n=T>&Iv zk8SwJiv$A%Ry`gd!su6RuHviSdp?0bixE;S@?GV{zl3&{>jfLc&5L64xXgG8Iq_V1 zSr{8!1Z;+2k%lRRzri-g^Pha+N^xnb7HLPJEOl}s185|SWYwlbZVNI<8mYfAXUnYo zRu1(_CDf7t#YW-$E0tzwA8y*JxzM=*x}1vCwrCF=#HebWEaY&&?)gd^_ig!002tUX;(#@HZ4TL z{RMEkY0Wxl(XLhi@nzwpp>SKz6$x)s4}P4&Z*s@z9d_){|B~5^Dft4wYYI>$M-N~ zA~UPb=Xeq6y#AwM)n{bz^mV`i9WgtFUABtn@KpjyjTnVjc1hqBL|^s`q{JbUuh9WQ zt1iYO%4EF4CQ0)AJsWWo4==I5=(o^UwP_h=^(sHR(rWL3A^a2Wf#A>M=`rT!XR<{- zi9aGsDu^-xTEVB{3Z|lwIk-=9NH&0r#OGla2o5(%uX8HC4Z;6DR^TT%^U0P3k$Ev! zeR^yM*Hz>qLIts+pv+?ZtN}j1BAU74KZvg7m0$dwD?UW`C(2d>P;iUetBz`bOt43X zpp|EPMA{|2RV}4b2KM(FrurxA)d-5$SDW6;B!GWayHtjChMpB}0M3@{om_&*0SJ(RME;`wXCx1f09Z~W=S_+P4Wq*Tq%u+ASv^D@e zPP5gQp^%!Qg)u*CR}`UoKU~sU$3JZZv6R{93+2{0%?x2ex~v`1?z3SnAU98~BIf>Y zJ2_92MBvEy7j5{=odzn#g{ccE5zYVb8~U<+`Ssg^_=tXtXwcLHv{4g`0=-s&9i|PG z&f0dlawMzATDeVekP$Z)Q0QX%$YZ!DNQJ1;X=0muu$uU--8R4#BS5ES0>ajjQFTXW z!jCYaNv+C9JkSNSg{p5g zU+9qVf~K z28}K$u2Z(-uqAy}&dT+s!+ znBk#@IVq<<1xvN_;VxJpMY=Mie3+%*q3saty~)^0;>&mxSoO>p2#ExFyfOY4#|t@; z>z2!N{zHpjsI01Zw|HUwu!i#}7?cD&wL-B`EvZF*1EXAY0v zbzml;Y$TcW^yL($cZm9`qKOQT8g}N&wTdc$U3Jk3c(&6Vf~xgoBMl=RhEWXI(<;*Z zLquEm9u=pd*Zhu^&k}p5YU>apJVLhiB|r0)sVGHyuJLiEgVO3eutbK_u6IhC0ImL|711sx#UY!4{`VvxVr}YsE}@QQ zi7`h=@pShO^q-wye!|sMPw@TKO4G^0yc=@e-XCLN_V1usDnJ= zJh<6F_q&1`s*P^eb#5|UZiF}GJtYe~(j*{4Pp@K^biM?vz*?0vpdd?J7z`D%3D$W45ZO7^`$ZzzOr*meV_8F5d-BoN*mdGf=xb zWl;=P#B|*GA>f>n&^#wzq%P9YvToP=)0tJD2e#)n7q7pkbxY?q=AnoGAGV)~eK_ed zG2?J)Ypg!5RW3MtnW`{+jDY)5Uud|`+oCF6qDRc2T%$4ChC z2RMP0gXu0;vWy^^=w^s^f$8EQ{qkbTCY`*}#`Oo>RKf|zI|UdwqM7PSiPV60JO zkxXZ!zKfk!Xnd;X7heM&gOw2QU&O~Y%dSs`|Lms_{9-QzrFgone27~=#>m|JQ;x0E zmB_X&_VS%S?iuibEK~2zZY-c7f8}G!oX^Vrdhcdqll28j=_jOZl9ji4_{Cx zzdEpj=lnt9HG8EMoB$5K8#!t#c?1d21z=ard}R&Iy{U3@Yx$~&zn$lvl^^v2r2A=Qpm43-=NX_W@+ z5Ul^agoa9WRQu&v#kOTDQfd8D&+}_$uc9l$rn_EU@*hGx3zikpn2{ZTC6#TCI4iKhJ6Ee;(xuCY9}6Px@2l^X{USQ}w#Ow^%SD3Y%VGqU|32GbsMiY(J_LmYk#5Bu}Vols{TMF-M!d(*&H% zDc8TKQc<5GHKONB3~;CdGRyMyqRq~rr`>YNi2wMc$MQ8YOv!ryHM-8KHY93}q#skd z3;WLOO+zzf@+`P+ajO~a;8-tcWA$-R&j?uC_Kxc{7k0t>0Q%**3?hEy5O=^l1H5!q zQ(Ckf1XVK-j}V~d)-BG^>ZHmbVoqO!2m-%glnicjGe8U}7#9-vps(X142x@0y_!-IoZhNfXr=Pzh z-Y>~kndBN3?lmcK+OsRosQ4t52DairO1>gq?XcnAl$I{|rD(nJ0HwI3{j6jZUbUq< zRziO_*|(Z)v32gL&AANuvWN}}~x!D%L0>#@Kmb;fKC z6rGq@MkMw46*Yn$Ysd6}7m_ZfSyRz+_02$c?PJmGp5m3!4Lb^%_}d+u)zZrn&N|#+TlC*w|L`?=cv9XtBnB&~b))igKQ4KqWMQTp07uN* zR&tU;w6UKjZ%FuF!8LJ@{-ce6$GHC_PF4M_)G(GX(=K%Ak$ZvV7P&6`5MG%#W4zgEY-o1}PqXFb?#d zG)7xp7n~<)53Zr@0F05#1muf)ZWa~coL)&W1u}*OmWdB z$Om=RGH)gF8_EKg)stQyFK^H7h}8RMt|;ErRcnQrpXUn z|536TiLkE9@9R#-nFLgt#svOAU1~>B(n|SsCe!fIBaj)Oey9{vJC7+h#~+RMY`Ha( z==*KUJAUG9vn~clzSiNC%g8{9t{aI%ewO@W@pdV(mb$N~hpc-`r;+m^IR48sO{5Gw z2toM`*aU2ezwa_34ttYt79aDOFTdQHtJ-D0jFU=oCoQhi>q#7+7Tgv8c%62Abv zj@p!1wA(40I;KGDN^J%IUkji_bC3m(VylEqPuX@0L(hkvFEhv!P@xY*?t8=zxuPSz zmBXU6st6Ef?{85JE&HoPjKO*sFeF>VC5S*56A=eOvH#MA$+yx3=?lPxYA~4!_8}~P z5MgYZc4ifq?5&xj42B}upW$c>NPyfg7I7Y@*X8+e4B2xLUY^1rY|-p=H*|{~r1y-~ zBM-lqO=T@FDR)#SANHxULd0*Z`B06+UWKqVhKO?Gy*O+13?7b{8%IQFHrf_5-oj|LFT~lNWz1OKaj2~iy`l`{_tr^Jt*@MA z$W2BA>OI(AF+aAvf}quI;=xXkwTp?tZ#|;!;(7WB_fJd|Fzu@5SULv4oHT`GKypZ$ zZFNi|HKMi8J0uu{J6M8#t>-=R^Dl}SHVPU#ZcfT|Ql6Z+;~WkSfmtk)6B zCs6)1Mp5V)N}U0$s1NYG4jZedTvj3&4$+r+wZoV{7G|klzg~6mDEwBD^mYF+cocuG zon$$4j&b&bSNXDi?&W{G_iC@}VU?m>WWZKqJulCu7ii=}ew(X?R=0f0+_^Tz)8FU~ zGuYsR*3q)vYgL=J)yfNPWIIf^7YBClFPZ%t5^yw09hPLt66hvC(rXzJ-`?9T^E3`G za>j6b=9!DJPraV?>+h}W3H%;DjQG#QFuD<%yp)M%#dX?+t(Hd%R>Ll-@$Y2nDxI6$ zqZ&eaW;mvyIJ`Mmg%!h}5)ag!3!PmDke{d*7TQiLja zld)bOM#SxWAhU`fj1`sUz6PW@j|5j+yjqHDRADh^edggBja)6Cg7sPH9Dg!3dlgD|GpR*Vk{BdCn)+S91Zhy{l9JLcH|b ziKSlLDuD&OcRYUQmFi9_ed2Qtul>f)z*dT7UfMVc+}lQXZMk~BLFep64{Z7_^O+nR z1M5r%d1xD=riuEC58mpVbs$3iqUY;+O1-O8AoGU`>iq<(=XbVri75>b56tQ@!UjrN z-h`jCUB=mpeUFiRUi~?XAe${sV-wb7>lkCx=n*$c>5jTZ2 z8W*$~Z*5j|(^sE;ae7jC?4)L==u6^S86-cjm1mj^ z2iub^?QU(+S_bSl#6?}1?^$RQFN;c~Ai)KheUy=+L{mJF4 z2s?w=QfmYTx&cMm)sd8`Fw{z$xxLiO_LO7a7`!u2lkK_2nG;3@8|E8(}h9hKbz|-9?-v67O;&n=?2>p#d;wT7v=UE2T1Rl!c`v z&brJdeP61NAJNU+W_bEg>GS_`vMnH?2u}7Ik?tZSnfc(o>*00=&N95GZFR%FBFg5C z;Lb=I+AZV?(nzECUSFtX60g6}wnb`DR?)SjtV6wjXswXm?ScI*v^`iO1W{A)U23_7 zE7^aBGZ-K^lA1LeVo)Qm=bj&b1c?}7)(C#Q+mdB?Z33w;FuQWp;TQEl-t9I52i(nSHKQ zG|Wob{8$GXsLOfYm^EWj!voj?UiZMxBmavC$6SA;?t|SXCpW(o!=r?PS`vZcdnX`b z#T3=c8EXV8b@=6hIJ{zX^MJABYH%~yajI^E2wE>??|)hR2lx8*GLP~7zX}9j;%9>T z^~3wNq%=FW42#Gp#p&jED6D6^E@~v!5V|l%S=HcjS}RHR@-NV8l_DFkMEePO3(4|J zhp{@8i>!OoxJx8m!AX@^c)vI64rRLVX(&DgPy@=qDLOJqr#e5rAGSpXAF+qCEbZ0J zi*})$;P-N{6RGXdy!W$LwxO#DC!I-L2+oPF^MFuK`?sTDjDIK8aDjFVY8LI~@MtvmlU6iAj4wshx2DQ>_OEc2-}?-2oUW1d0~&d`LqU({Ng z={Ehi6dC^lN!lo^Byl?wnrcrDEy$Oeryy8qOqGAWL|mQH$x7?b;Sh+>p*DQGjJ81v{cQL}*RzlIk8Aal);X;r^J<7& z#b=wuYGO6$ti_~U$LQ_Z^iPot)f@3Dsma(eoR-(Sjp)2>1_IsxO)TznEL$g31OSbu(U%%)DDX-u^SWBk6CtpN}n$Iwfni+_xV4hx%qwKhI2C`@GUtQMu~f+s0RmH321Re^jS#IXtAbQ0j{M z-b}|fa*4ZKiZUi;f|>#X}%5Snz}%_%0LbU8KsTJ~Nt4?a@!lg% z-dV9Oe)>n7$$A5Aw40q-UE+ZW6D?IdBN)}T3JT=V>W%(NOzwTC3@5r1w{svk%}%dwn8T%GQU=!e zzCLM&9&N^lgMxvI1I($R1?$PZ;#*#C!K61L#qJ!r3N6DFdo88N&rrMH%ZND1Ap0su zDms2_PhQviZca$abiYoEU!FxK_RA*{N74aOTb^fB%f+Pw+zz#>1Y3O6F*gG-d~+sQ zErw6-QUy19BS>}~p!A&)l(}&m7qV1stYXYf+XRYS_Z+)i}Rs7DlzbTWP zr`crVIlb}XViHhUdAVT|npoKe`r+Y%)C$x}D|U+Y>QSfBSke+Qo= zSnD8M_6iG=Bp!-@^Kd621#x-DKgxnI>y&LLApV17Qh7OvR?DIg(*qe9Yz2S1Phzk7 zw6RDJ0qvA*b`^2ikN2iurdtwiUQ)}byP!LKA$rkMZkDe z)>TqRcnZ&~@xi3_UgnhPI_+)*oK1J-W>~IM^pK4Vhg#&+i1E=qB!8Vm(MBpgyTZog z+=zOQ=xH%kbohbLk&Vgl+gYpw22gWmk!g^}v3pW+-Si@2$3if*{&GLR6r74<<5mVso=VeVLZc?jqR- zx^pmnT3P1agm0T9o;}v{Uw7rG4ja)cQfsj;$_qGyq+AEk=;Lxhbi3bv9#Vc~G(7=Up!j}ewZ z3SN00?{o2p3JD&o)|g@ZaF}SJW-;Rt9Usz+-0;mN&%zrbk9Tl<2`SVArkjI=k!7(U zE>hJGw>EAuB|9b&gPi2mLY-L;1Rr1e*_e=wa7SY)At z0U}~9Yo}0{DdtDD#O}P!EGjaGtCxr=Cg9#T3@>y&hS7JM{|pZxWVdJHLD`~?JCXuN zLvf#Q{T4rzNaDkTRZJ5COi+fN^qH2=+q8004(^JR?g}1}Y>N<_rR{1#***)NJ6|hL zK2E={m6|4h0WvB2HoYh*j;5FxBK)+4RxWa{#Ey}bVW%(V=Foo+gzRd?2SmbQ&Ojn^ z{d~dkPx^g@)wc{xqOLyFj!IR<jd~hIhK5l@P}{G+^OcH8MW3Nk z7Ek9m1tnUS97fa+e*o8CWA>#*MWor!b0amwd~*PZ6M+Lv__sPb(NtoCJRnsy9b6Ru z&#dL@l(d)`0u03Tk>=H|cpx4nWzm~h87CgaXAQJ_7OSx?MBI3gf!JeBl#IcQQZ<$A z)SYVWXiSQk^i`bmyYwj6OAs0bXB-g0>Ks^T&c2>{2j`QyJul2(Uj zT$hC@eC`K*?c7`rEi+b5JX0uEFW5H{B(Wks!N@41J#ziLAc*4;!jp9C}a?b)A!3fbeKk~MB z&+`T&M&|c3`2%R2XdFlwD1*@(lp~V7@Uh?imIaV2Sp%el!vbiLe<20fP|)`YeU#dztyLf&A~;KM?BQg`lUnr8|mYvlpxb zzgt+hN#a!U<`^L&95$lBA1^`)?P-TV+mVT(UQGc`K*Uu1VQ1Dq{)u|LkJmAGv}pga zQLx2Z^Xa_v>dUU7_pERvf9<=a6Zo_~%Z68Az%eHs`74WD(Khz<`uW0AJ;Hbvg1a9RSjs1GY8<|`-f&{nqzO`IIEJlRcBydunY z(UBxb!|60Boe_7tf-SjG{2D~AFs~5-fXz%UZ4wLGPA1Hd<0tN(Sr416sxz9AX`8>W zt1fKwSi|#i%$%$?7Ci4hs?%1}qGsyHn59BgM7t6ZM_5M)^m&Nf>Q}GWnm_XP z`jcE`{4?ZY>39Z-&o={xcj|*K3CNFsQuEj5+iUYW{cOJXsDTEm3L?JwsGm@6rEy1? zNdy6R;F~%&_HH&qCRx0+XzYJHeEsAWvS_Rrm_kWC)`TKzT4YpNy0ld+L{bOwCgeI> zpUGo?yVm`&p0QQ9BtWO}8J+k?(x=jIIb&HN2wc(YV_q{MLR%-6CmA=3-*r5b_>u1i`dpZTDm#dR79#)GWkwu#nc$DEGu-^{ z0PMKpL~r;R^9#Ku$?^6vk2^=&AmtC}&@ME24?@~5wB{1yOHIgYG|vd?{bbI*f(H68 zuK|glZjq!rT;Eia+_JUux~d+AiIP;@TVs4sS}iaaOy~?P(jQ^8un#NUJTx+}9N}br zsVHUUUI=8UUO)joZ=G{G38|+gzT-~sup82x`?PGlK!44=Jk-HEZR548sOVZyI!sLHx)nBDCSt}M| zw|0tAs)gCv+2KTJ&{%0UqH63y&PjfI#Cwv#jg?alL99@=x2QmSKjJV13Hze}FpCW) zV@;N!A!Dy1l!W+w1IK72e{5(TPkh2QZMcH%yA=*0XR_d7FJ!FP)UNemYs%WM>%l z93U5j3qN+C^}SrYVe9j_64q43_1)m2^}RmARG^+=1)=-D8mzJs6FzczgZLs z$BsD@IQMhM^_DzJe^{_loq-%cN~_KfzQ4OG**_Ll*GDKIjr;=U;5@|4a|G7*T_SQM z6F(@!cP==i8!A>KT0Lk=3Pm!Jmf6o;iPpOP9U8UuIAiJKzkDbqL6m$7;iB{aSw$qp z>Y*7f*dW&qyD9kvJi%)8WfOSiXJ5~A9lDonX$Gn zF^B%(Q7ceiSAIKm%OqiRuX*c+SeHvNX2qv1-UC%?Su&*XB~=%@u)l9x)cX%>2v|G> zvjXya3hO+wyu45Yu(b{$3m1N$livErE|@wcc=EqQa*qF~3D z6M_{5*t0p%^v{VW0Brdec+N^}eaX6!e)xHPhzbfz}H)<=T9yT8FdNvVJ1x1bPO3r0Bt3T=c?JQX&THt$t*Y*gl z$+DYhEju?bHZ@fRKy^6EE3Trf5`cg2DPNHv=#J;f%Ysz{zyBBu)dc;8${T-i(*)9k zRde?dK!jeZTWh4kk9+#b;}zXVH0`FE0GC?I6r{kKcT<-t3o8I!_7sr@*(Qy&5 zd=IA7SouOHeAR>}9AL7SC$FdHIjw?gn3nOwOJ;aol@jJQJSn;yOHFAQA1vdMq8!CX zvM=ym$*5}5-Q)?I98*`vDAdSr?H^#ucO-UmLCCwMjp|H%BnfdLC#^(W32yoJhm{T5 z*O-AwV(I8`{P4JsYBNcwf;uIwA^dfB z2$Dk7Chtd?J+Z+FF|O1CX#Fg+>Cs?nd_KFc{MPb&6^()=eN>d5L8J&hTY%!cmDsjh zRHUe%g2IoUIzA<9$IK>7DbAG&YCJ4m&fVoyf*r8W_r_zD^^1h5gy{RfazzTR^aN*O zxNx;fd=jK$t092e^pLpe%(kA()t0EfpUJW=5Gj0#oGDEqX!~&q4HL@MxG3mYt9Ng4 zaUB|h@NI<)0-I^Gld>tEs_x|W4HtE4yzh}GFdowrQJCSI$Is7NaqB)sU*D?{2)i_S zLla&nVjXW3d0C*&TVJ~V#V);OIFg051Y3oF*qySU8xSr+1=_*x(*Dm*s@BE>!Sav^ z;;58}Tw>tapfU?{YS5t*6lPKkY$^r^9Q@TY1SM54Rro*_8E`;712QqrK?0;xaWRYE z%rY>8^>}h;?7nPy2E5}H26|zvzbBGKjsRZBTjR6_;AiFX+6iS=%8M(owl%STwDVB- zU|_9upf?ck)XOkLZXh)=K0^J!Q=!z2Is`f<1bAs3|O%^bks zc0S_m$#np#roC84mKV(Ql2dlYg3t_*y+5Ud8n#^4QFMCkt3!t~Ok^BS9apzbwL%Bl$DU zVq2Zz3&iYx`99(6mRM1G-YWjo3_v!!C(({R&W4Qz5LFDVr773HFvaLnPO^K zScUQ;=I!(pkoxOOMszt-$260x*`vaFimZ<*VaA$i&NtU~ZMKa=JbN>aaG18201Dliu!v-@ocow zxG|<@xrWS=#7y;=?Fe5dNtWi>v}$y)EuOo27Pe_JozTB?;X$l$&hG17uoY^AB$34G zwNF%N`oXqcTg~zgY;}#6N6T zKah#Vd>y&-N9K3%q*0-FBx?v>eYZFCl3T`hUSVjyW$3KJ6bf8r1$mPJET`)`uvpJ* z2Is!8N`-h+drU#W=I8e+6Or{l$`TDqk0hF2)Ru+iWuEruAKJh!ob^ThjWq5?fFwvc zn--%<-dK~jBCb#p)z{Rc*XL-4L$JJQM8JoGJT$b2*(%2X37EB_XDaVge^Ij}t#9ac=O!!579rT1odUzZiW`CaRts2jT#iElh^vo)d zsXfe`xnBR?1Zr_VeTQL#dgA5RuUKK+G?l6Te*g+W^}g_qfgFGw7&!~JuoKnkMjs0g zN#pzWaViP*C6Y(0fxr~=0D|-mQz$ZFHEH`Y+X4a`@a)s17>y7-{kI=R2NdGmcn=eGO0)?ISn z==z5H4sJK@pQ`Kl)hfIB-=gbxe#va?{6c;6sylP7-FKdB+w=u?H}SdpsP&<`NaLR{ z$LcSV&4Hg5(}lvOpP$0|2m8(%e9S#0r*F~i6+m}3&!~h~KvFCm^`xtTRyuFjjXoA0 zMDv{LO;b$g%;n_H5VSd=@;?L_^H*0Nq`?KxBJ$)ZZ>B@zp12}!Wf%1v;^zb|^I0Uv zuB)S^6~RateThll&BIH|LQ(^y$0CA?3r|WmCfgI$+2)W|=u~3Nh(3qgiC3406o7EQaC;hU$=t znaD6IDjGJ7U=))b-Gy|dsSs~jT}ZX8EgWm#QZ&L=As-8loN3s4e8A40v{FM;nQ#zO z4G|>k!)FaX<^{y*wQQtF8bMmY;S~_VV_LczcIXBliwC6#G%(G{6@qjfLidyrLZKpV zDN#~@q(`KcRj{0DPlsz&ckRbe7;e&OP3RDJTN_NZ{zZ^`6kjBA`}IFVhHJ zKx!ESA=G*@AM*;j8w;~P<1q3FA)~r#K!4}TKXDfXDiKj}b#&>?;D5-f4cA&7qDzU_ zEVq+csug)0YuY<8$?jOkGHHGXuTJ5}g>1(w6uezY@{UOGcLw1|OHy;}Dy}{UE)G7s*Z-)!vGLEMEiE4?wl@ACRTmu1 zu)BEb8Zv3>5}!RK-j0v6phPq6Xr`d^jrv$z)XM2)8lelwBU-u|cIpNni-7?d`dJtT zr(@2?Ua1fee5`Dg@Jfh+DpAQ+8I6K96q9E!iK~W;32I56IF)l)j{%BEN zC>R)RuCKeJxw-LA%$DFcGxZ@R92QFCl~5bb`|;sp*`iiXpHV<+?ZVo1`A@zR(TzS9 z7Nz22GCaf*`$3aD3vv@G;d!{^PZa0%63WCx%bWFGwBfd*VuHQJ3iRg2Ms&2c;^g=k za*UYiN5@M_E*J44zb4U@A&8vn3vJ;_z{@)xi7e}OQwbrEMWtwvn<7ud^F+I4V#B-r zl$aG+v|@3$ZuGIRFleBX0$(#g+eL8d6-4QwbS?;76cp)6h2Sh&?hUv{1-)}h!EN6; zdfFp$r<&<>23>9Kh{R(!$vhC7PtQ{I-OdYsiNfT`mSvnv5+cDxfW|DOaB!(qm85qe z!~%{es4gvcyAHX^T-x2Pt&&)s&GPDE(F5IG9Ut1%-1)h9ux=vBJPnfur+xTXSs*U2 zWfgiaghn{f1%&V$-B1M==msAPi@0To5HJv9G)hmjrTch6`HpHxaux83f#7n<+K#a- z1yzC*d6~5wJna-vHA>tAS=-r3TL+^zF+pPqBA@=tQ>!i}PWtpuiGtIdOYUWFMz$k* ztVBL{I(WO3r@FX2ZP3xq&jg6Q0hx;YgOaP!KP{l@D=euXtC{GUX{Mc4~Qz(@;*F zs(rV(mt57cgn(^f)2h`N&ZID!$%q+PoYN(-3wED=TI$+TgkSQUPYHvpPgm|NI#g}R zdNL`5M|OJ&uSIz%6l7iLsT2x%M8cu5)lKbR+!Sy8r&t~ZGn0o!Gg>{D=flSeLakgb zS1!A#Bp}mNrV9N11Qr=jJZkXp_-_n6a)5E5QRGu11%DI8N@8Yd@3@9b^WvX_nHW;? zu_x)K%Mz}z(7J(WB#I48Ku%9iU^175O?xUibyP*So2twck>z-to87WJs+u6E@o^;{ zyrLM}P=n-Zjfk_}#N&yO2$6V%?LfY>vH5SK&GmOC>&(n(3>a)MF%>uX{#-tMtU%Pt z<lbTX--#=mvmL8w3SuS>Q{p( zCcCvrpm=}?KnC*jC@f(g^-t09t8y{7qY%H=Anc@##MKeDofLE zMFYHmYOE~E#X>5M*E3O~;&fv%v9k;l_3_xFsK2x-E!-l<9gl0Bq2`+=V5OeZdzl(zm5=kSudEbJj|DI>DbG(McREZ9wpEG0rxFq~`^5q26t-_+fWtYzZN)C9sd z?G{MIXf~7}bw%z}M-jYg$H`J~E>vWQF$@R~Le=Ay%7sHTBJDiz>1s}z1jM$iY&;bC zju{R80uc)>i9FT~0No=j43@BFqKH-b7~0VWgiSHW-L=DOisY4W@I)D=iHwJzb?7D`9@2wRj)w;{SZy=G*t%vd2GdEtg-MWa zVdBE!cDhDK96Xsa!aX0=btKEFEll`}(lIhQqQMg~?FN;0y%Di4B^nGp7>UOIZ9LpO zH53S8Bo;=0Ll^_~EYPtojKN?KgH|K*W+O_`Ad0k!p?=*yd@L{2%HhAz4TNd}LTXdX zE!L%%|KvOCb)%03Lh%t9?q@{y4C76;L{DNGw}~z!l|kG`&js>UAwl2|4Jw1TfFx;aIbD8R1*;9xMQDgtbkS)F4K)Z779@rV#pzaG!Z1- zS*vXBB$Tf|_n3uUXFo~2uHV8-t1R*2n%pCAj9$dqQt33hTHDal(u|`c!xehPIE%}m zZ0bdv-N|vh51IB>Eo)aAlnOx#5m}{Rh$5nWKK+-)!hygu@o?SGO0o7-p|u@$YX|I> zPFO7+Wr1vz2UaHA+70^o=Q1{OMe@XS(e>eD$%D9?IQ(pQ3SzIMX{aV3grDk$D!5!X z_*fvge9a$2T5yO{8aa1*7*>`iiSZJ8IrWt>^7OPSEZZrEVrV8H*)%qFuYnzj;P}`m zf|go9D6Y?Kl^RkVZ62$P@{*2Vx#b)|kZn2x+v@0zdu+U(w9z<*Wm| zkRR)YDya1$e9SdGoJ$;Q%I81Ov#lMY;U#OH=|)xXlCH|p<^zlF9~F0xs;1-TltfvY zakYKjdW`0>=ub}}%mqZ0mpaDJ!N(y~8=lJ$QIAOQOs-l^u6xwp2T#ZY4X`@~l`rG$ zf5^56duOCq!1<&?W;-#IBK{hM>5q6|)<%7}ip}1k3Z0jjv zO>YC%4>n-kECQ8pKWd=oaq)@4% zfn+*`jjLB96p!KP=!h8qki0I=C8UBwo}YV7u1QAam1>@cNP4^z39cP-wWBk+j0=Wn zQM`XrA5qpa&2%gnIAq!Oa4{c-U8sYdkIg<5;xKdU$lBh}u?nGRD>8;Inm&9iDb&i@ zGit!u1muUhp$eYMT%s=B=wp7-wDXlb)NOw?)~vn;o9g1-0n2PJdbZmTT~UHM#eKi( zoN@)l3y~)o7|wxWUgdCY)6!yL-i~z}Fj*|{1%we25T4~>dk(?!9yx*2rZRY{xYzC0 z5${ewz1Q-~5~aOoR`qSm9w?ah!R$yXTS{kRJV4Mh!TdfY8&YPU*%9*ucWoy3xn{VzfTMLw{2MfdTX=6bb51AJgX#kHc?U|>UnFG^L&YKpja1+W9Rw}I6OLx@myAJ;qHOs zA$ia3^%O)}K&G6x;C9Bj5e79`K=f0|mOR-W5#b&UD<80|0UPG2^yWCGE^b0tFp$*#h zXgFw(uNmycnlmS`y8raE4&4K1(9zq2_7f*?6DF~1AcLtfdRC+y(D&hE@lh*Re;4YH zrwYy{AcXJfhAOz4xkho_=wtq1+BOe%&_=cZB(pg&jP4M$OY9EqN&*(aEm0FBGtcyk zN~TiSx^^9cb#WXT8A8aCE4AfGx!2(|;GWytrlNZl=V|rgt*&o9T$ahTMA-N{l^J}o zKDA@QDB{GFRUGIq#xazr!%#9lCt)~Qhry{B3t^0P?nZ3)I)riw6mxlXQ9gVu3M5C@ zDj;|31|78s2rVe29eU3O+LY^B-RNWfL8M$LWtoU%Jp;@-QAq-II95@rV%*9TcRd|H z<@ru?UaNSn#_o-qFqY4uFPT8t3V2FTjL2K0rpbqUiDjU32vHO{dcF&K-z<=OK`AUzpLH>LEdr8a;Rm|03a--)KIRW%)IfCcFls`&Sb*4RR^4$ciSUrT z!{06;E4vpwSrU}2AZb}E>*nVQdDPd{VGk1!KK_wSBVg(y9qLt$c?gu1@gyi(-nm9X z;O2S3MMepYs~qAiPGN(u)38{6Ysg0XsaC0Z}VQ)8ILA|BR?bKnVY)8>--X<|5JNOgCe^I)q()N+}Sy06xEt-`9#PVDRNi%p*!lJFqN2RWHiVeaxP~b0fYR;^WqQNji{-hL0q%<#;=GO*!;70~Ga4Cb8J$Y6 z`{Bdr{>kI${^^tR5?0^yIM)5guhB4)z&f)I&B-DP7Cn~NhmVDWhmp^ZAe)=MGi>GF60cZzJpp|n-HYit`m9_UK&RPP z2#0ZWY=m!LQeH$7yQ>zSbCcMSO-YA>!A%}15?1Gp>DR^8L5hYv4K*Q};V?~;x{=0) z63Lzelf9Ya69;i(;xJB(&sR8t6JrN(v~M4JgE^$*jR*uS1ZkF={)PGQF@LxLwQXNQ zTz}T_4??X1GQq-+bYm6JMfz9>l=ksp6dyqj1IU&vNl1cJ%f-VL2bX*mSrh3>lVcTd z&7Ae!3=MQY9k6Ze-Le&@Qwj8?rx3AeX`KgBJ6xk6d4A2Ff;>tua#1~9uD70ZeV)tq zdKYe{@IX|WmoW_*|Dfy5CUUvt{l`w?`+Yt5!O$5NPS0aFgCEj4PVd8iKXeQaJQYGT zZy^G?M#hJa`Ns{qp$cdpMB@CjqE-PR{Ht!Lf*YBu)T|qQ%ss+ZgolVttDsBB7IU6E zTEW)5yQ-YE!SDJ@8gFT3yed-8Z*EHI5Ve=dX3^Tz%r{VcYM>9ve4dXph-L+u>n(9` zZ^4NwqjPjTlnV7lx0J`skMdmHE{@zAkp;y$3>s)RLPiKiT?VD5VU!xi7ATCO&_01v z@dADvO(GXe!pi7hh7TWe3(3hh>h>!5mTstq*$Bvgptjjqv`N>^y3xnn zJZB)3FM{T#Dw0$hFm1C$dB^YGofCmke0rCP8ZW5^(Sm`gWD=XZ*PyYb8T$tN5it0C zS&?5h=%Q*=23@Nd+Yp5BRS03GS5v$vo~80td0v#;MK-l3>?J-sOQbE+rk!|=e5Vbp z4+CpL3l!M7*0E!I2v4*oaZgt-3MTvBQaSlveE66b5GU8N=`C?QaeYmgjety|_C`+vtThmzR}zp254a5X@tH zaw{$vxe>d^pS!@}O6;1v8r$<%;Gzq!LYR4x7)#LfF!knq_?S!7%E`p>#Pu~{HUdKU zXWdW*&topr7TxG$jxiM#ArVI)WguJ1c?em=ydH#;=kl#<_b@p{Wo=tYKV*2l1y9WQB*;G#V(xbclwBG4LPcLWnc+^T`L z#n0zixqjfx8K9TlIiynV`}5&LgXH4p>Gmq9mE$>PDzd~Hmqmi`MS}^93yZ> zgy1R0IioNN^i)E)B+Da;`ki}l*-hn<1WvH7O(Zo%nE_LB`tI7*tMO>x8R+XfyuOE+ zmMa41J9WK0Ey_8s(oRCaZJxSfixw#kP6i!#h7Y z%<7!z!6|Y>OJbIp4<8;77q8ci6+qlRL zLl|bs;YbcnvQEks2%0iGO&3b^U#EX>Oyzh>(DvE)w)ZbQcg-xHgCVJu1MXLPVTyDVfJ(l;#cxFTnH3R%CI@HW3do?JaF&cks`F zk=eUw!OC8Yi8Gtg(0(m$c=w+m)X^?H3=#t6aY*5K$qQeAAASC>@vYl$$M4^GBfk5A z_v4#i_#8g|p7-Ilz1QN@crSXBdU5y5-K?%=qmjPfQ_TQW~95+vbqT9@q`f_Rm1P+jhc&|SS~hwjqj zp&)Fl0fk~CYin_XVK!lW08B(S;D-0S3z3cvppSVJCr+^Y!V$dS`On8MzxYMm{oxPe z`4_okp@S@JSictUz4g`j=3C#3_q^&4(O#^_;i(}EXQ%k+kB{>gl7nBM+pFL%-EcOX zi-0`H!jrnO0@(c>UZfj+%pDr-79N^|?WjYjlq=-9V^K!Pa8up|gB18`bD47O1R@FiojeEXuCw};mkKpPH_Q-eTf_5Gl9iKqY&@fLwZ~M*o_T@L=gPV4vJJf}vBYhZ~ z8ig}ZryiwJiK!`UU%MW$#s=&k>PN)(ER6G1@=B91 zPnRuqtzvwGOPo_)*+jk)4OvHt1dke$E9oe_5MEXahtt(%A|EOhbIpy7Xl?I8OXsR3 z5!zO*LD;l`jhpd>FMSE$`|M|M<3)SL8=nED=X<7!T(522g}ZM0J-p}ES7Jl+I_AwB zWlA&RdS*UWJS6wldK3Q-x}g@FtAKnRwe^+lX5q!U(Z}4Nedo1k-*qk8wq1*Dt?L@P z;?cFGQYprFSu%)#-E|%G5G78;DlLN!d4DfasZ_ZiLtR*xZ{LA|d=@>431|AQC?i`^ z#H~z{=($~*s63DJveJhU2Ir^-&Ut;&nv6(clx;falSgis;~#t*chf>PU+h1fN%a;7 z2hno;1nN$nS{mWhX(SFF#04jh<89rm)w?_=ghD2M`||6s@AY@#Bd_{XtX{VYID7=9 zkrCd7=X@tVRw{^liG$Au;&S5jS;M&O$2IMlRSEyX!fSM61>F6>&9}ZxH~M(?xb^T+ zJ|Ijx-mX2ef8DVohd%Mt@ZhVng#up%E_9^CRijjt#LwsT3Slth9A6R_{**qImUUDR zqT{Cd6LZ-yo6YdS+B@HQJ02}%@wKNPLwhK~w>9B?E6%yZIb`FaO}d_vxCYumn6l|w zo$H|dA~gbj6n2)7af2VFoi2zUi%z>L4o?|sr&tirDi6ZC+Xxwdl4u$^2;?F5;aUnX zaANor{{3a|!ON4Ei!%TKAOJ~3K~#5Ksb@~ZVHSGEC$Kf2$C{RzH-JcHGWhbh{}Z42 z)pyX}9KlVEThMX+t1xICLu26yJn=*e_K);1vAqYOhHG(Qb`#cjjw5^Fi;x{GA#NVS zuS5HB>gfQ2^>x^tw6L)~fnw*?NDUXzV1@CU(vvtI9LCn3)wm`Q!Nkt(m^gC=bpaa( z*n2x*hH=&SGw5olM(wvRKZKx8>!_@_?RV9{rw^g3@~!Y83mNY_bIvBA#8{n^D=O| zFv=oyAJ0=NT?uYkV3NA8)~!QxTPq&vJA)u~y?6`pf}%~`?0Od}xVg^d3;E-B%KX|? zQL^)PmEH9?%DfNNm8J5S(3%!`AQU$X6S+*WXXF|5jO@pW(F2Pg#tz~{&o9wh7sShU zTt0bih<SnjBmbBV)$p&M((vk?$NtzO8>m?F;oEtL;J z%?3UBC5vpaAZ_lex|Cy$1BobSH7Z+H+1Mnbk) zz(z#POHo_}LHz34oEBxC6I7l|d(X@ViYf2`b5(~9BCZw-s&Nk`Cawpf#Khz?dc#%_ zR*C&AOV4GkKWCA{6~OpTtljwgc>Rv+u@GpE$MF6g8*tYJ*W$Okuf`-TR2=LBaw*U_ zl#k^J$+fi(8=SNEL3p;mlCQCl(2W(aiiKNrqmO5Y64;%y5o5$3Mk-SPjeT&hf>>9L zIY1<((Wxt4k}^1S?Xb3N!zioC<23tf+nTj#>+HlM{bvwTiw9*1;%gW33aA|-caPbb z&dY85hTL5Q9ZL{}KZLCv@_#VjjTm)he4#&Y(N96aU=y+aX{%r0hU0iBB< z@=f^5t6zbI#-?Z^-nr=|_~CEA8gIPzdL|%6q=!zj8Te%2?*%69g=dVLud*3Y$ur((xC3_D~XJ#p<1M4K3_mQ z7RO~fcHqg8K}_VbvinM%o8ofXu&U`fa1vJ~ofml?LwFvd3<-ktEZoXMI7lq$AjdjH zU5bl9^&Am#y1bNk!i2*z5occ0?)m_BHY{$~QE#EMu7tnaLtSZ$kITDu;_ElvhW~oa zZMbIlrFdp|2xr(01HXCD3;S4VAda2B;&eKQvx&Q(6`svBUN30>_*zu&UKVbB;O1Lt z2L>O{5*xpDC;yA4KkDY8?7G5K@{@^d=C(qyApX$h)K@-T7c~8rrp+q5$Rp8EQ)k7L zS|u*R=1lP=DG&#YjE`a0hE4eJTi=GyAAAB&j}D`Owl5JC*k4bxYy!L z(dBV9WmjGgH>+v8GVWX#ZQUZ;=Tm-VJyl<|QKa+BDZT15hy?BLHplDUyJOv^XDriV zWoo$xntPD04X4v+i`U_fE3ebD&x+Yh<2_gWWN-lAI5UX@V-Mliktb0++KOO(J$5He ze;V%+0`orF*x@@YT&?F-z<**6Zs_O210md@8>)cb*sZ$J$GPFyF%!p*m^gaGLLyyc zH`d$*p?h(sj2dweuOj6JVf8}qtPtsP4=`jv0`dzM?$?b~@JgnX^}5l=IUvG9lo4XI2&78P;#LLrIpDQ z49U-q%^~V(q9Ub6Dnzc3M_nwAOSWyp~~Q56$|$xQ%V9w&od+>VM~gY5%-Rs zgKqmhdZ9~)g1X-)(Bi=zYuBNrvl9>Yo_4lwS3e}tM#(v!(<%|V=j`0;a1t_)4`p!H zXHn6j8aVgP#8SFJm(RroGLL%}qJD`_*%clKc`qVgEh1!Rvn_-y(%&K?Mrlz(l)bxr zA%{O&vw9v^eat<6Z|iP6apiC0AFh5r)^>K`v9VE1=JQhAeJm1)Q)_)_)F*VqxneE_ zgqF?My0Gw7Ofh4+(Z?*2Imto~O4*Z`D(2nc3Q4+^)Io?!seG)RD$-FBHBVGf6Uw6P zFs|CM3lr>)|HuT*zEadD8WS9%#6g@ym^?N~H2hRjFt{*qA&{r$LP0KvEGX+Jp>PM; z-8k8~XTpVp>I%US*{)kcfRQg2Bq6&f(*dC_;D6p~pqc-*i#qhGOkM_1dP0i_8{>7jc*jmWIy}I) zz~DZQtmEwu=d$4y2RiT1sgV^siPea^qvx1mv~O%H020@h?hZ2 zJ4xP-5DcXQ;(AFmM4Z~>KUgIqhU&5c7X~#z&OH$sC(#kzU?R@DS-9OoFi2a^%kiBB z1YL1I^W@0;xT$q{dLKGk0ztfCeFuJh@o(Xe*S`Q!c5`ufd>qUlnRAly;X-m~tvBOG zS@k3caV9SpiI%G@+OGTT~A4R63+FA zG?Bj&Covu(^9`9I5s@B^GqBD@h4RU6@_Y(n>vAy;qRQCp+GEUaEwp+_Bnkz*zPnpD zFMF&F)#J0PFT<~&|3bX#(!DstZaR*qQYf&`*&idB7Z8UMm)3%hLU8n)VXg&)@Qo-UD?-fkZ&m z6K0vOybmbR5p{XHREB#c-mWYY0oyV{hGi6)DCStmFHk7t^Ek~&^|Jap+^|Btk4ord z-qoA8Y{BQ(uEC2}bzzjf_hZQvvL%0el(~ZB&RTE6iIa(&=M?iHAUPI3rW>o^CY<9w z^gh^^mmWtTGloKTg5A;P4DvqspC*xTNrXz^QlawrQFsaZpS4Tt2F4OoxR{AYq@@{; z^q)ptuKg%0DGidkAyx{?JfRq#sN1@*5wVkaNFm@kBm|J2hVUYkXzMq2q=-VRXW<}$&hI)Kz=MH>+ z&u%=wt%dGHaA+#QA4KMRB+nYeoy4KFfV>al<~hZD2ngXW7LMq~DtIkZ(v7;&$Jt>R zHv1#AnNMmWozF8)lnX)Bs5BiE%S%()7zgK856L?a0^=2+LZN`GckjW8TpGQ}DTMiM zgc7QH*-?mqD;y;!?nsq(T9u=`4##GQx5R})iU>E#AVfoSS*aOX%6s8mT237jBa_M$ z6GriL|LFKg;J`7&4u$m6(;UBdPC=hyJx?uG_@iwVh3cW=duI#;7N zn@3+Z%fD~!XW*j-NY1SF=9{?qtGaP6Fkb?aW8nk3u?ntW;eY8yAGLtC-A*%dPa?_g z?3^7#q(JaLO!e2PbVB=U^=O1lPZtzgdA_E-4VyP@!u@@x_`)$>qO2&fvr|QIyC=&{ zi6bX@PW*1SQ&}lTAn3nrOlI;`B2*7bRD|@1(-^LZctyhy1MZfXm6aY#r+N=(3zLVD z!QtE_4(I1TJj1-jqlGEFvihYXNaFaq^sFuJ*#J1QMr5Sh!HnsDP(g_}vF?zV%@}!$)O>a@lzQtZrV(%yKo>nT4%JIj~ax;~qy`n@B;eLp`-k|}!`q~@t zvRiJ!haS2Y0UCs+yL~F67@r`|>)3cwN)o-eM&~HbFZadbW^rErDqpx%$hP=@)m6ve zpL`sy)26Je)KtDt-WJ5(SgiDvl`i~Me~0W08pKOhUxNSIQ0scH zvtuFl!kRDS@Vcty(~cV>tYxm3*JK%jls)ii5 zkf174$t1RQuR+(EHMsB0NilWTTWA`}RYi3#5!5ta-T;k`OFJ*Y$1HdSc@(+a(c#@v zB9H#@%@JvR2k-Wx?lgDRy6O@WV;|Iwq1o5mc@i5(8vlhy*1X)Z!lTNwBg3i zo90wJe4Kxf9QiukUIoOx^CljI`4$kuyKolDk!WS%ci9KzeYBmAnGhKOVttI%hfvJr zL3{BE?lojr4Fp*dwcyaS za!f?pS=mci*ge0YxC^i^!>89=Bj1w`AG3$##@~hD%Cmv~`iOhy6$>FC53ukJ-B<-Q ztKjvz(MJ{Jf5yW1FMyuR6gKN<_#i!%+_|JZ??#-1Ej*92aiij)#Z( zcsCLM!RX>4ifiX`BmlK&0q>Yd zWEX?#WTzqqY?FyiiAgSPLRm`BUqA*(jp2Fi_1GI-h(9+UD;UI$wa&cyCIt7+D;DY( z{bwxDUsEl3{pJ3nZ)-wZcq2L@+tF^fA(PLQisDI1PQD;&4^);rqU}O)HmH~&`?16% zF5kKhvGz9JeN`U_mTQE9I*<2!lGwdzmE>B(Tx0g|Gi#D#*> zE#-8Bi8Ujt3rp0YvJ`c_5&34talQgQsLC?&@inXA!^gQoav}*xEg-J_Q{6ZpSSSJM zLG2GPAwT~Oy3xmUTpeGFYw9=RlDf^P3x-0BFav7L!x?OrV&P~950a=Wz#(lzJi7L} zJs09wE{(xt5+ObvRu=L$sE*2}=XsQ%?A%1`sv=hxgC8lz$=~rf+^eNrF#$~EQJRl= z$T~DWLgFLD#S%iIR;o*Z;TtTnH18`_=bev0;+4tZhK>$g6pg}%k7t25KyYI%phwmA z=*IcLLJ0_gRzjZEjaBdlrnqxA*KviSafgBWZ3VPuWmjy^6q*c_H zWt_Hz*CD#HTrWcf;bDku(S;?-IDrP!nWzM)9f)qZ`3QD_c^~XNpIf`~_Pp}3aFAU1 z2Hjo-eW-mu!e} zfC$2Myb+J6D5BP#1uuwy#}Wx#vuih!u?U_T8sJ-()C9@(<+kZYAtKy=(7Lneg?^SK z1~N%6UT;JmgZ4#i2pP8 zac+XQE6t=nnpQdB^_?qn4(iNhBEq8- z=LMutNN++Vc(y3-2nDqzvs5{I51BA-9Suq=Et!prAKaQA9jO&g~u%QNqFV zC{ubPAm>2{gmybB>5Ua=8&(Yz29%d$`k1duJ!9uLNxhf~=cUbT7^*2U&` z>_dER^0V-Fvl zhLOsdVjB|9C4y0%txHh8cu>T}lwTervgH|~OgfXnb$j;WL@AHHsRTln{FzkyX)3#y z$12&Xfop*BfheJ-Bg^;Uj4FsS!qxH$iFhBXiV_L#l^Am1oaY-(7nU<_GEbnV@lEAv zWzPIzl7#^lLd7ETG~3I^N&|6UEn7hmXyE)Y-8kP^3;`kh1!|uqKwgO7l~n=R+#%$% z`;p0xAS<~_P@-UOPPZjD<$!qAO*z6rgc0m$ViGs(z5uCs6#EAI5f22)N!Aeok4<4F zGS=$|c6W^`kBfuyN2K2*%c=LngA@@*Aow7Sw+y{SRjyn_=x9rUndy?B4XAyZ@pjkA zQ+n2%flouuIPjAzZvu44zM0d0q{w880K(%FMiN z?F547c|_&7gY06~7KuaAnXM&Ow~JtK?}8TX}&JNUScO;CT z4Gm-8F0nJKd`A|4o}`xsCD z9A>GnR4CZ$cAe)@RNcpW?L0=q?PTs#0dT&ps#cwEuA8&@`8 zjOI`)a>bk)5s>iQTdO;9Pt+*MyKxUPxh$^Ub0LoA(-=-A*KsQ#wMJ&9PebDneH*%R^`{o<5 zWBm=-+_I`Z9=6+yrIK<(JkXe1-SP98PA(Fu6t;G*LidLC_~q#y)Z4+bF0pBFJO_Dn z+V$k?Q2CYexC%g)QH^*Z1dm28L=z>)yKtq2CuDsY=s(q?*;nKe@IW)zgt(~M^YM?` z79womA5Yb~34@RG5t7s13c+!;fH?Cm-MG+L3IakPw{2WER>A8b293^pq}FXharJt% zM(SFlcCec$m2;c_XrWOP-Yc6|p}Sdw&K!+oaN)n>*0t+SQ>D z?qOcY6B9J?)`yRZAWkE0s|CcBOQJh}sR#&7Rrx=xfjn8@~M%XhP6VOqD-Jp8lx`7y6EvV1IGNG_YrO)))< za7p}_mWqH7zRJRPbYm5)gAkF;ddB&LK%fDp6~SaCS;`dhLNArv^C@5-qWb3mFIj~uWW+X_r!Hk(D<4|7maFV1fNg4+%j;RDrihUx1o0( z2{GYCr-=!P2oN+4gs8)h#)9VgA6@S1a0EYLUdWS^z8B)dh2$_258||1K(nsy(v6FS zr6eE(nteb6>$Tv8sC_NTibT-b&*PR{R)EFa4b#9_o`wSc&>))`t0f~6)PLlCPkYyGQ!2UGAxoyNA9$Yh>D zCVLp^Tn1&kW=QT7AEKgC521PlW+E|#i`Q>N{pwY?_jC^$y;FB*f}XE)x;H&j9zj2! zitFXP(7UQ+9jB@k5_habtoh*iEEzH_1PtoJS{M{*Mpl@{LPqeoKNjKx-1{Kzs`VzA zeyX&%a8S=!Oe{44A$*U8FY3lBAZGaA?2CA@o^d|lo=2MT%O~oQOcqcy@`5xeL$acx z*Q(>htiV(%jjQ)ufRjc6!^s2!>Y-7U$h#I|KNmO`DudpBwd?0)<;7MI6An){pVBM; z1(|pRVDmxlg#vjYBw=4YwIsT)eJmm*XZ>$X|Fu`c|6}29-MH9TiULA-H^iokwO|Vi z|A&1cJN1l}j_g!FrjoSXdWMUH&YP8(m(N0?W63}=lfkC;cC1>zp6`1#ZP#6eO2Xld zE8^EHSF%-uBzmXaN^Nq%@T3)-dQvGWl~IsF5~Qt0SaOPea7lJth%m)Mb2yB<2Z!;X zKNfP{LUI*N8Y1qh1;mAaq8pb2ma2fzGks|feEOTK1%4^ID4In@oK*|x z-s80poVet$R0V|a41|kUE2w|)ow{))IY z8-{7LviH23iRv~iT-bslt__Ef7#c#KKNfO6f;ft}s#XvOKCK&<3YM~f5WWI2y|-4p zlPUZgbmK}ze20nV)nRl+n^p%byHoY&E1q}v=JjOA!!S+V-L?by<_0`D*oQjWb=OnI ziJwgj2$e*|OY}Oq6J{!>pR>|+=+{Z8bRL4YU0&8OXb?SPnmEHmWdI8s2HCks*?H|< zocjO(AOJ~3K~#Ty+FzZ1K0$KRY;JPD%EI64#-#+~vLFAOZd+=gCI0uZaDkpt1^ZaI z{ehcr{gIxr((sa_N6<8Q5~cn7{@|IB@sEzCrrJ42mWU_WiyRoCH66V3O2$zvVr+aI z?|SuXaiB4V`%fN26V1NTPe34JC4wfKmzcff2xm)sQAs!|*%b6V)%ID=LD>cNT4z}k zd+X{ySRZfx!kJQ}AU0K8Sdg(^mtL%p6|6Z%JYUP_NnPT;b28*tN0UW|X}IfxidcGX4VY@|9< zxta1R!AtyJF(6cHqmo!uvMI>CB8!Ag`)F4v{O$lk-$^7(;=xo444PtmlzD-#-?$O) z+EXj1l*1R0RMDJQO2Qw6(V5 zo@5HS96iq3$4Z3cpxaTqc+fAJo<_Lj#e=Y1JP?Hsv+zFMI0Nor;XTX~S&@ICH~jiD zC=MFP94q|6WM=B4>0Gwm**t>;gB^@gQ4*&Z5lJMI*wECBm%rlW_}0Kl7}>nol~xx6 z9o58PHt-6BH?E-DDwWsER6>Qa^ciFd1$2ZXw3GW|$!zYOebJ7e=YovTi;MH~pwGQO zk-*zFtj8zUt<|&73f}`++F;^A(;7dC*^PUAgoQuXjY}2F#RF0JAjCq$0S+PY-daIl z+I+6G1vQ;bZjh(~!d(*w8=H*a8z=I)j};2VcCqZdD2TPB2F@v2L^_j23nQ%CnD_C$sS#w7DcEWNfXeIS zOsHh9W+POx>$Xbp3c=aRS8G$d2D-za$Yl{UEnME*&O{`Kr^iPTwE|B>1I9b!cH>WN zMA_%FI35UrHhVn4ypTWIupV#Syh+cV5xxhqbiurjKVpZEu~6#_3F@-C9JN2zVo6}R zc_0M(fPYIKYXNZ^Fk+dy{t;>?RcTLSqZAX`^L(TWM_dd6l8bz1L+jjMB{j2 z=NcsPIXpHx%!`IhqtsvpOK50By15bA=H^8U`KBgxu3Clf<_k6dpnWWDNUkC8qgL^t zul^Q!Tvk|)9*Dw?5KrX_>KRo)(|i9f^F$Wy0rYc?TmJR0keq5V#s(+fKAB5?C|4*p zac(8ggQ$|b9%3vco6jS_!s}jfD}IR#Mwo!mo%uB4_LA{Tpr#?nx}tzovn%L!{d|>d zIy5n%YoWC;BbhYfb^zCPtVSF2xb7bwz@f=;)CYom!a$uJ{P}9zerL#Tcr0rK-5F?$ z1bSY`!N~;Pwq`Y6GYc=o_du2&n0UO59X^WMr|V`}co7Rf)iag@ma~8m=n;^!>6*Hq zh4(QLS(aaxjyFH~c@&P-HJ&&z`GJ#@L;pLI&s%)MKqVZIO9@%LZ)6m|by5chY z`>}&)t+wuiP>H;Co1RwC?dl}Dty*1zURFV`BikV}t*aQzWq7yMm91U4puP#cQwiKN za1v960vdy1E-tj@BW_t=j|L*|O&20(iUoQ?VbxD!!7#{#U{^;w{^8<_YQ7`#1!QS~ zi3i=((jYjk->4PT)kPa6E=w$T0U^-hoY`zNb`J}G&P0U7WLe{-&m2e7k;giRdJcc| z_{7BTXL2;73gT{D2?&c9iUs78Y5dMDzlBG_CVGd55vE7hDk85&=n8s%C8D1RdU?G) z1^s#|mGju>kZ136EGvZL^43m1)=|i2@$=r3czkRK4NN$~wuO=@wvn_EUre`cCy!4@cFpodcG11%e@xDar4Ya=%=T_oFK7dYl*9Hd1 zMsfYN9oTs7bMf61N6~Br#1s1}5p*T!gs#_{Ng(vH3VIzcD)G6e97QvwBj7XBEh!>om{_-I6Aq-N5N5`z;=zT-6Lje1 zt3kD|+Njo+x16`U%JT#|5Z%+Ps=ic;dr+_3uods!c?q5yZ^C0eNAa15euaBZ9;2;F z&_E)YFQ8Z`qEr-t`nk*^r1Z=Zk*mCi?y`E*Dz&#FJ2!el;& z9gVGc`>u;{`?kH(8^Fh2Z#Yz*2R^(H==%;n$6NfMHIi^3zB7#qVYZ+;%mbhY5{ zKpztjx$ABvP*t7mbz;|{m($Z;sP?_*(ZJWOu)GZO9ukEd_oTX`b-1j(3l}!EA(GFd zXJ7!oIDQxp_nii<;AoD<5wK}HdRCt=j5O&#@3Jy@+@L4FBWPLK=1BOHO~Kg50%%T( zow1e<=pz|m-qi(6>_2r0^@_|4zYA;ez{G>rq5TDfCs8ZtAuw0U7{d4}m-p z5|IOX#!|%#{^jGC91C?PCMG_a&gWiBTb1(}sU^DKHZULD(qNffq5u}CfojSLp=V8eABvAkvudK+$9G3 zX!b zhz~8Nf%OgRBUl}acZLF?ZoXwZpN=ZnIgv_XOIH`{RqZ%5GK9Di4}zD>b%Iw-sJKG4 zd|enS)saVjDt3*jVje@8G)A%+-lcZK>a}?HMOWa1mt2LHx2{I|vQ^%f~kIA|RiK!N)pipuE5XWSS-r4z@FC1jJcxr{juj{#gq7-nD9~UPP4TUDZ5Dyb z|J8Ds;`@rn_aV6Nz8g;r41R8KDzS&STig*C7#kVEi>|&JiH&P;pr3gk-mwpz_$xsd z6(ux;N_KBP!P7p8Te?7=MxL*_?qXiV&XzV@(y7=s#BUBCWMXj?r`fer z{rY$ukzkN{62i-%F%467NzvelDLoBK*i?6w$?PQ-=X%ESn#0jU8>5l;=9*jo{b=)A z{y;5}yEJhso5d|nO}KOYIz6ilz6Y{!kW+PMJ77+$%&GirHh^7LSm^>nAW!6*EX*)^ zJPq`Pe|V`cHhjhZ`~>O42W}c18@*#Znc71gG9)0$bQ;~UIQHCdEgncuBAoX;D@37^ zi0A@wHs}H(K~Y-Zmn`I&aO4p(EUaQev8S~idpo+&9f}}7nZS_~C-A_bgLs5_7^jAZ zK+iOZN27>_BT@uN6NGq(fpO}VQUc-%iLjZDkkIsjqDgB$EHuSpPp+sOATMobz?V0y*R#st3&_GiE)t|I)@L(Sm-=O2&BDLy87m1ZwF}D& z`T~Ckvzv2GKa>y4tqGPaQucl%(wS0@uZI%@#e^Iako6lkU?^mxkfEEPD&V*1o-{S! zCDuwe>B{Ck$~v@d`gktOr`)EQx3Ml>kJ~oy!27Sb4)4G8D!izn4dc%o!ry)Kn|SA) zpU0nm%PjfW~z%n5tIFs7P9 z;cs_@LbrE?gZ~t>!=*xr)`5uGX?&685)SrKR=D#Bk^^SDNRYUOIA=wR2jP71Kon@k z#6Lo;T(1@6fqZnSZonA($IoHsaQennK66JVo4tVX4l<0u+k;{3x$bH_UPvQA;{-Fo z>vdG}N)+@wna(oera^E<`Dn0%*wE03i#k_hS92ShOC~0VhVayp!?^$H{dkgj8fRFV zt}`AJo`yx9hU$`%-h}9$QiI~IharNn^Kup~ij>_~EKOcZ(6)2YaPWz`Nch`!B>v5i zZ5-bi4x-U+!#+EUBW>LX6i93T-0N8QhMut!aXtwM z;Wie&j@fl*(JsCpVsTCJ>t-_YNMntm(44yfICl4ONgU>vL zr;i`Qz{m&}g*qD32!%NL^QpH|G{pFY5(d>hCE{E#xTv@SB1A)o2u;jdx zj|T(4jE3re)X*6E&1u`n8kr(4hy>B>3y6im?LGT#N$tj%bIf;j!eu{E2c4z|Ijm5GR{W< zA-t4@Z?izpgRK?xWJ7wsF$u|%m}B|+?|s4OJ8u8(>B;_&rwjS6$%%1Xx@$L#om+5X zWEf$!5KR||YLJ{t9M1>A^I{M@%*c98T|F*jg0Zu?4ef@7%*ZH?GwxSSPX$cKsF~%&QI^YlnDrVBIJ=Muc9Oc!(i7>-BNU9VpL$B#zerjdu=<^ z6AcCrN1~Af;h?oY5Q-ickJO);noOd(a}{EN5bXRA`b$%=GQNQLs128kXPA?4ya%&e zLq=0|U&X@Rdd5n}`6?iU8zJr%>hz4W;0_i($wZ`2&sb=@{@(8e#`hh5*O7^_4~!?1 z(RxO<+poS7#{vc;PU(GA^D=alNHoN=!!yj=s0#+Mxw#b=ws&G{T@&hZc}(>6x8(ag;b=a^{F<6h|zuq{jk zwzqa*4-0DoVFVIW=s$HD`wt$#BhNg8XPCz^J~;tK?o2RZh{zS;N-TtMxPFIzE;z`A z`7R)Y9W4Bqh1Gh-SwM9@!$jmqdd5QG#_xVHm^_rYeKb4uJ~Nw(ZoJ|$^v8lQ)1Ilj z9syB2o@HJ`A}1C@bk@~lS8F@A);FQGWFkK@iry0^@#KL6c=XT#oajA+Oge*bB#bz9 zP039Q#Rl?HFiO-ER|gq~@GR(@hBF!tMRz!|&_vUK0)O*^Z%!Ndy&{dyp*@xTirgmew>hVn=g3)`g>pGqFhz z4dLYR9z1s7Aod?SioT&iz7`=Sr`mFHa5|l&GeP4QEJjcGGYl)micA@nHEdf}Do{di zJ`@^^gsllXRq83kBcpWzb2y*PpNzT+l)|i65$i$ z=-t?jo%^4_zU|M&ruH+)CF_x})*?4`4B7f9f{7$@kpSw0rY|7AfSfa!c)S5ZJZ3Y~ zitsdq=h35QoNw?2#0BkK_n$0Wt!JDCBM{>vcQ7HD(lh51>p%C&U}HAx>6^taqX7P01HhWFe?ki;rr|ozs$!ZOR zvQuLg>M~PxxrsG{Lxoe3Zj@q!xOo34j&HjHY35mMd2k<|zW4@gN*-du*Mwp>Pn7m#xU6OI_fSOv zhp8LRW6%$R+}PPFD*XWeiiJDa4|BF2%(KGUrg{T*Fc9zTFjKJz3fT-F2v0K6$XX`4 znwoLVsRZ}FXslkm$vNPy_@Xg`^|j4nVg%M z&dxpe+jGwUk!*}ajt6S|N2`5Rd#e34J41n*&7rE`U3CpDceKuKzok7?|HIbktZQ02 z<}7M#YFIS4IdpZnrR}<=%HYkOiptf&z{tZbFCN@B81#0}iuLu$a{~kYwedv#K(7oP z8Idv1xRj3!Nm*=Mkh7Ag@QMco52aj^2;aqSm=)$rNG`DED8BGv&N?*@)XBp;g63gC z14(66Oe{AZFsFP?jEP)gAIKH0{%~D$Yr~vGBvdt&j8EoC1giZq&mWWvyng8%)NkP3 zd*$gzACr4GZj|*~ABpYQ`=8O6NN-0=+n&x@?N2s`!w-fc;q{Ta(5<1m;Ehe~txH>? z^;gx0Lsv!`8n3ErsJ*r=nY^wgQF&9vyp~n1Z9^M^1Mw|mud98gWn$0XiIIwa@3@Sg zJUSq;$}y?zKPIv9m?S-D*Q7R~|E=)~pOhoQYNBk?G$u_Ve-{9xNU`Q7<~HW4Q}B04 zM8-RUSkORT4N4I#Qe7b?v(yPVR9mhO>bLC!xggR|8}J3g%@e*#3Fy1JIX)qc11F@a zr|)>*?q?5e-?eMs<6XPA9~>UuTpx|D>uhda-q|sCSxbF%X(U+py+#;BU377Kjekiv z(s*OEI=H;y$k?i}M&G9SqX%{*dq%s4Bjbk}d!8K(cJ&O82dl*E?-$>}0f|?5BpDx* zvay&XCKJQ;pQIW@rFSxE5W%}RP3L-o%INJK>v?UjEsK2 zF50-bsj2C^&AOtftqm@ziqzfE8ZY~4WgxIN+CKb1!;wSVChEd_W)Jkd6c~&RddqzY zdH#U(d&Z?Yeo|y;RMe9sSze~{NLdWM#l31072-)u=t=_pD`*iUf59BYT+|EShrQv0)rqpzp+sU~K*|=zD{HQa z_ygC6Dt$k%J+Ezze`5IlllApGTW6Pdc?Wx6jx{$7Hyk=XCL=NN)SeXI;L*vQq(m|< zp17_g5NTEAm5QWC;>aVJ+;+lllUYp=Qb<^HZ-tn-==1?bduz;nyaUJ?U=&t*gjDOq zSxM(95bAdawDaQ2M(l%x5O-1I@ayGUmd+l^c6!hc2<6z1*#}4}T!Ewq9;8cEH;MD( zUp&C5q4f)Wu+giX5RxOTIfkOAm~&3e19N+sn7K~#NCO&3`XKTYUCBNxrm&(@zO1@a zAMmf+2M8hAW6iBQ#mqIQU`}Ih|3J)KCuzVftjxe+96q@7?E`6mhZ6_%s|um-IwASa zg{EIQ%;Ou9*_Yzos!yw)vk#E7lgWoo%!7t5WovD}PX~PF2n$@P5B^u|gM^UuvE~q> zq%fy4d7rHnGnZ)|8Nd}t_FS&IU4?@?ry#EUSCFno{$U>^gq$8Vj<@UOtCpO!sh$D> zEelm^>;oiyXdv!zu9zOkC<~nlC<_lGS*=EL(mqHCF{p7=>jkS07)Lhq?Q^Pb`v7r9 zCQCl@YVaUp8H|{7r%_q}69Ip_Mkk2VveJZ<1lHWZiUD&a6Z62_#BKrRD$T z{?xoJZ~E#q~uWJn5UNomS&|4rVol2W7Se}ZD(@{4J0?zi;1+l z%s$|N$5p?E@}&nmA=Al)rbjtKa-sLwhMj;ax`@y^Vjm#c!7Z%ZfCm*}feR$FoDqNt zgL9F)bt0iA7a=8s^~~QbW)5U(9+-Oz#LPvSM-Jc$B==y+y-G}B#!SJ(k3Q_b(bdQk z_CZ351vQQf#PlYIu#(S|hs5NDU`v?f2pY)rfn_*)3WV(g4nQl-dmuHEefB{@3Kcbu zbM*2BORu*LI{_7R(P$zl5-A%%h(2YT^--V&mt zvf=k)3Mls42S{$9fy@ja7h@!|?E?-$sp$=n8cC*SErd)vY8*i^-9MoZ*@m6+Ulkfg zJzyUoxrGKYbJVJC5Ywvw(I(mOu$(BdNR13b3XC!1MvCcSf=7&&PH>xb)d}Fbg(m|P2eIPDE1IaJ2t%ZIB_-2Kg zKiTqE)%_5&+R1ccWsiT-(z|WLnSdH`SgE6_8@H?S1R6+wg2VM6i&>R8?d5zr(C^?6 z5Uc)@Tw+zp{e-3SZNr&>RR&fY*NQnTCwT-7B)>seBU*E2nu%-YfS5>?2h~Ve6NtEm z8pp@<0wKb%)i#_dSV3PcCTyMLAvBP}0232S_cz%GvH%A1N7W`Z6MnNJj#1OVO8F8o z1w`?Tx&>Cu*ve&CZk|H}DI8F@49Qjq{l)&WTXczu-g-!lWUGCU5Ibrd9}p8s1-;QW zoDDdqLr5-?WZ9Rz0HA>s9+hIsqAXQ)qz+}pnCefeKSOFJZ1G86K}}=6UOsH;BPw6& z9a*towMLpGiKkSLs<6GaRm}vwq)CyYrs3BMwy2;F zs@|G3 z-luv$ewA{Admx(XC#sFYGmfHw22z~hWq^{_FqB+14SP~dnB}C@OjrSWU#6U@!Y`mbR;z zVB3r28EP8Xj(V5sB1>+cot+A5vE8LwAtt(t6d^Q_(g5ax$gL|>Z?KKI4WhaJE@rz6 z+NFlFKjjWGEvR91=;a;w(b7A`-RP;)@o&{iF$d!;=vm~@KuQnnbHhkb;0__dxd!cc zQ9k7fG232Gsi9!SLR>}-11k!wGO!(m*03lml1*FKbV2CtePWCQ=VPQqpn;SwjjAt- z+1_%aW>LBfs{Uo^X-i#dDkzpm(nQSy#Ws<|hw22-n=RFydS8w~#yGaA)~OEJ#z+Z3 z10ew4Ru_orM~&G3=>~h`&xqOnK!_*Qt)?=Ra#x9>W)aZKxnjb!A#6jQC+=1xNEhS; z-J@D7W>rZ_7#avU73`0rOB_n3AcD({lJc2?!jH&?hxUpw8R!Ky7OY~54mAp_9@opv_XdnDYH|EQMeBgP8@ZKNQ^DAf{z9f+Gk?fd)d-0a0A2 zwu*yy6wpJqa=rrdI4EXM9tZdkwp}2sT<~og!V41jBC{SE74wqBw`*iaC)9{0lil>i zk6fB^FM+| z6udA|;sS<(GTyUNhe?6aBc`lBv_*`G5Rw)&5RwfZ)my}@N>HU0Novy(8cZ?=D=d^0 zLDl6=s(%W5i<1nYfe<&r_6+h&P`MSW6oi?=WXQ|`b3lX}3Sz=Ekf)KCXGEm%pn;HF z5*1Tt38sR!yjZPNq})N=CN3t&26d-k7N}KrH02ILT!IEdrjr^mIVY$V4575fOopcx z1Bg6B=@@KFLCDP*PPv1S96TFqD8np-Pwp&Mu(7 zQ*R<J}o4yRA8&4pw{Bpjk-IT8?Y?tnx^WXj_@ mB{bSPYzgNTjDR9V!~XzB%rJgMHNZar0000 Date: Mon, 25 Oct 2021 14:00:16 +0200 Subject: [PATCH 06/33] OP-1920 - create command for background running of Site Sync server WIP --- openpype/cli.py | 22 +++++++++++++++++++ .../sync_server/sync_server_module.py | 20 ++++++++++++----- openpype/pype_commands.py | 13 +++++++++++ 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/openpype/cli.py b/openpype/cli.py index c69407e295..a98ba8d177 100644 --- a/openpype/cli.py +++ b/openpype/cli.py @@ -298,3 +298,25 @@ def run(script): def runtests(folder, mark, pyargs): """Run all automatic tests after proper initialization via start.py""" PypeCommands().run_tests(folder, mark, pyargs) + + +@main.command() +@click.option("-d", "--debug", + is_flag=True, help=("Run process in debug mode")) +@click.option("-a", "--active_site", required=True, + help="Name of active stie") +@click.option("-r", "--remote_site", required=True, + help="Name of remote site") +def syncsiteserver(debug, active_site, remote_site): + """Run sync site server in background. + + Some Site Sync use cases need to expose site to another one. + For example if majority of artists work in studio, they are not using + SS at all, but if you want to expose published assets to 'studio' site + to SFTP for only a couple of artists, some background process must + mark published assets to live on multiple sites (they might be + physically in same location - mounted shared disk). + """ + if debug: + os.environ['OPENPYPE_DEBUG'] = '3' + PypeCommands().syncsiteserver(active_site, remote_site) \ No newline at end of file diff --git a/openpype/modules/default_modules/sync_server/sync_server_module.py b/openpype/modules/default_modules/sync_server/sync_server_module.py index f2e9237542..4bec626744 100644 --- a/openpype/modules/default_modules/sync_server/sync_server_module.py +++ b/openpype/modules/default_modules/sync_server/sync_server_module.py @@ -699,7 +699,11 @@ class SyncServerModule(OpenPypeModule, ITrayModule): Called when tray is initialized, it checks if module should be enabled. If not, no initialization necessary. """ - # import only in tray, because of Python2 hosts + self.server_init() + + def server_init(self): + """Actual initialization of Sync Server.""" + # import only in tray or Python3, because of Python2 hosts from .sync_server import SyncServerThread if not self.enabled: @@ -722,10 +726,10 @@ class SyncServerModule(OpenPypeModule, ITrayModule): self.enabled = False except KeyError: log.info(( - "There are not set presets for SyncServer OR " - "Credentials provided are invalid, " - "no syncing possible"). - format(str(self.sync_project_settings)), exc_info=True) + "There are not set presets for SyncServer OR " + "Credentials provided are invalid, " + "no syncing possible"). + format(str(self.sync_project_settings)), exc_info=True) self.enabled = False def tray_start(self): @@ -739,6 +743,9 @@ class SyncServerModule(OpenPypeModule, ITrayModule): Returns: None """ + self.server_start() + + def server_start(self): if self.sync_project_settings and self.enabled: self.sync_server_thread.start() else: @@ -751,6 +758,9 @@ class SyncServerModule(OpenPypeModule, ITrayModule): Called from Module Manager """ + self.server_exit() + + def server_exit(self): if not self.sync_server_thread: return diff --git a/openpype/pype_commands.py b/openpype/pype_commands.py index 5288749e8b..0a897e43e4 100644 --- a/openpype/pype_commands.py +++ b/openpype/pype_commands.py @@ -284,3 +284,16 @@ class PypeCommands: cmd = "pytest {} {} {}".format(folder, mark_str, pyargs_str) print("Running {}".format(cmd)) subprocess.run(cmd) + + def syncsiteserver(self, active_site, remote_site): + from openpype.modules import ModulesManager + + manager = ModulesManager() + sync_server_module = manager.modules_by_name["sync_server"] + + sync_server_module.init_server() + sync_server_module.start_server() + + import time + while True: + time.sleep(1.0) From 19b5d47b2494498d19701e1441ed1b0a05aa34a8 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 25 Oct 2021 16:46:45 +0200 Subject: [PATCH 07/33] OP-1920 - skip upload/download for same files --- .../default_modules/sync_server/providers/local_drive.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/openpype/modules/default_modules/sync_server/providers/local_drive.py b/openpype/modules/default_modules/sync_server/providers/local_drive.py index 8e5f170bc9..2961a07cdd 100644 --- a/openpype/modules/default_modules/sync_server/providers/local_drive.py +++ b/openpype/modules/default_modules/sync_server/providers/local_drive.py @@ -84,6 +84,7 @@ class LocalDriveHandler(AbstractProvider): if not os.path.isfile(source_path): raise FileNotFoundError("Source file {} doesn't exist." .format(source_path)) + if overwrite: thread = threading.Thread(target=self._copy, args=(source_path, target_path)) @@ -176,7 +177,10 @@ class LocalDriveHandler(AbstractProvider): def _copy(self, source_path, target_path): print("copying {}->{}".format(source_path, target_path)) - shutil.copy(source_path, target_path) + try: + shutil.copy(source_path, target_path) + except shutil.SameFileError: + print("same files, skipping") def _mark_progress(self, collection, file, representation, server, site, source_path, target_path, direction): From 421ac7716ea004c3738aa1c5ceb00518d05aa94d Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 25 Oct 2021 16:48:04 +0200 Subject: [PATCH 08/33] OP-1920 - override get_local_site_id from env var --- openpype/lib/local_settings.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/openpype/lib/local_settings.py b/openpype/lib/local_settings.py index 66dad279de..af8c3cdbc8 100644 --- a/openpype/lib/local_settings.py +++ b/openpype/lib/local_settings.py @@ -522,6 +522,11 @@ def get_local_site_id(): Identifier is created if does not exists yet. """ + # override local id from environment + # used for background syncing + if os.environ.get("SITE_SYNC_LOCAL_ID"): + return os.environ["SITE_SYNC_LOCAL_ID"] + registry = OpenPypeSettingsRegistry() try: return registry.get_item("localId") From 02b78dc7f8ec7a5c55e270156fef31f0f33ec8ff Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 25 Oct 2021 16:55:11 +0200 Subject: [PATCH 09/33] OP-1920 - added syncserver command --- openpype/cli.py | 11 +++++++---- openpype/pype_commands.py | 7 +++++-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/openpype/cli.py b/openpype/cli.py index a98ba8d177..583fd6daac 100644 --- a/openpype/cli.py +++ b/openpype/cli.py @@ -305,9 +305,7 @@ def runtests(folder, mark, pyargs): is_flag=True, help=("Run process in debug mode")) @click.option("-a", "--active_site", required=True, help="Name of active stie") -@click.option("-r", "--remote_site", required=True, - help="Name of remote site") -def syncsiteserver(debug, active_site, remote_site): +def syncserver(debug, active_site): """Run sync site server in background. Some Site Sync use cases need to expose site to another one. @@ -316,7 +314,12 @@ def syncsiteserver(debug, active_site, remote_site): to SFTP for only a couple of artists, some background process must mark published assets to live on multiple sites (they might be physically in same location - mounted shared disk). + + Process mimics OP Tray with specific 'active_site' name, all + configuration for this "dummy" user comes from Setting or Local + Settings (configured by starting OP Tray with env + var SITE_SYNC_LOCAL_ID set to 'active_site'. """ if debug: os.environ['OPENPYPE_DEBUG'] = '3' - PypeCommands().syncsiteserver(active_site, remote_site) \ No newline at end of file + PypeCommands().syncserver(active_site) diff --git a/openpype/pype_commands.py b/openpype/pype_commands.py index 0a897e43e4..ed3fc5996b 100644 --- a/openpype/pype_commands.py +++ b/openpype/pype_commands.py @@ -285,13 +285,16 @@ class PypeCommands: print("Running {}".format(cmd)) subprocess.run(cmd) - def syncsiteserver(self, active_site, remote_site): + def syncserver(self, active_site): + """Start running sync_server in background.""" + os.environ["SITE_SYNC_LOCAL_ID"] = active_site + from openpype.modules import ModulesManager manager = ModulesManager() sync_server_module = manager.modules_by_name["sync_server"] - sync_server_module.init_server() + sync_server_module.server_init() sync_server_module.start_server() import time From a96b0b8d989d52a9a138859f1960c4f485c9c0af Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 25 Oct 2021 17:44:39 +0200 Subject: [PATCH 10/33] OP-1920 - fixes of names, tray not triggering --- .../default_modules/sync_server/sync_server_module.py | 7 ++++--- openpype/pype_commands.py | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/openpype/modules/default_modules/sync_server/sync_server_module.py b/openpype/modules/default_modules/sync_server/sync_server_module.py index 4bec626744..d8a69b3b07 100644 --- a/openpype/modules/default_modules/sync_server/sync_server_module.py +++ b/openpype/modules/default_modules/sync_server/sync_server_module.py @@ -694,13 +694,16 @@ class SyncServerModule(OpenPypeModule, ITrayModule): def tray_init(self): """ - Actual initialization of Sync Server. + Actual initialization of Sync Server for Tray. Called when tray is initialized, it checks if module should be enabled. If not, no initialization necessary. """ self.server_init() + from .tray.app import SyncServerWindow + self.widget = SyncServerWindow(self) + def server_init(self): """Actual initialization of Sync Server.""" # import only in tray or Python3, because of Python2 hosts @@ -719,8 +722,6 @@ class SyncServerModule(OpenPypeModule, ITrayModule): try: self.sync_server_thread = SyncServerThread(self) - from .tray.app import SyncServerWindow - self.widget = SyncServerWindow(self) except ValueError: log.info("No system setting for sync. Not syncing.", exc_info=True) self.enabled = False diff --git a/openpype/pype_commands.py b/openpype/pype_commands.py index ed3fc5996b..bb7ad152dc 100644 --- a/openpype/pype_commands.py +++ b/openpype/pype_commands.py @@ -295,7 +295,7 @@ class PypeCommands: sync_server_module = manager.modules_by_name["sync_server"] sync_server_module.server_init() - sync_server_module.start_server() + sync_server_module.server_start() import time while True: From 5b4f266eb07bf57355e408bf34a35ad405087c29 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 26 Oct 2021 16:17:34 +0200 Subject: [PATCH 11/33] Hound and Review comments --- openpype/hosts/flame/__init__.py | 4 +- openpype/hosts/flame/api/lib.py | 192 ++++++++---------- openpype/hosts/flame/api/menu.py | 2 +- openpype/hosts/flame/api/utils.py | 12 +- openpype/hosts/flame/hooks/pre_flame_setup.py | 66 +++--- .../hosts/flame/utility_scripts/flame_hook.py | 4 +- 6 files changed, 137 insertions(+), 143 deletions(-) diff --git a/openpype/hosts/flame/__init__.py b/openpype/hosts/flame/__init__.py index dc3d3e7cba..48e8dc86c9 100644 --- a/openpype/hosts/flame/__init__.py +++ b/openpype/hosts/flame/__init__.py @@ -24,7 +24,7 @@ from .api.lib import ( ) from .api.menu import ( - FlameMenuProjectconnect, + FlameMenuProjectConnect, FlameMenuTimeline ) @@ -90,7 +90,7 @@ __all__ = [ "create_bin", # menu - "FlameMenuProjectconnect", + "FlameMenuProjectConnect", "FlameMenuTimeline", # plugin diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index 9d24e94df8..a58b67d54a 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -1,56 +1,51 @@ import sys -import json -import re import os import pickle import contextlib -from pprint import pprint, pformat -from opentimelineio import opentime -import openpype - - -# from ..otio import davinci_export as otio_export +from pprint import pformat from openpype.api import Logger log = Logger().get_logger(__name__) -self = sys.modules[__name__] -self.project_manager = None -self.media_storage = None -# OpenPype sequencial rename variables -self.rename_index = 0 -self.rename_add = 0 +@contextlib.contextmanager +def load_preferences_file(klass, filepath, attribute): + try: + with open(filepath, "r") as prefs_file: + setattr(klass, attribute, pickle.load(prefs_file)) -self.publish_clip_color = "Pink" -self.pype_marker_workflow = True + yield -# OpenPype compound clip workflow variable -self.pype_tag_name = "VFX Notes" + except IOError: + klass.log.info("Unable to load preferences from {}".format( + filepath)) -# OpenPype marker workflow variables -self.pype_marker_name = "OpenPypeData" -self.pype_marker_duration = 1 -self.pype_marker_color = "Mint" -self.temp_marker_frame = None + finally: + klass.log.info("Preferences loaded from {}".format(filepath)) -# OpenPype default timeline -self.pype_timeline_name = "OpenPypeTimeline" + +@contextlib.contextmanager +def save_preferences_file(klass, filepath, attribute): + try: + with open(filepath, "w") as prefs_file: + attr = getattr(klass, attribute) + pickle.dump(attr, prefs_file) + + yield + + except IOError: + klass.log.info("Unable to save preferences to {}".format( + filepath)) + + finally: + klass.log.info("Preferences saved to {}".format(filepath)) class FlameAppFramework(object): # flameAppFramework class takes care of preferences class prefs_dict(dict): - # subclass of a dict() in order to directly link it - # to main framework prefs dictionaries - # when accessed directly it will operate on a dictionary under a "name" - # key in master dictionary. - # master = {} - # p = prefs(master, "app_name") - # p["key"] = "value" - # master - {"app_name": {"key", "value"}} def __init__(self, master, name, **kwargs): self.name = name @@ -85,7 +80,7 @@ class FlameAppFramework(object): def __contains__(self, k): return self.master[self.name].__contains__(k) - def copy(self): # don"t delegate w/ super - dict.copy() -> dict :( + def copy(self): # don"t delegate w/ super - dict.copy() -> dict :( return type(self)(self) def keys(self): @@ -96,7 +91,8 @@ class FlameAppFramework(object): return cls.master[cls.name].fromkeys(keys, v) def __repr__(self): - return "{0}({1})".format(type(self).__name__, self.master[self.name].__repr__()) + return "{0}({1})".format( + type(self).__name__, self.master[self.name].__repr__()) def master_keys(self): return self.master.keys() @@ -110,13 +106,12 @@ class FlameAppFramework(object): self.prefs_global = {} self.log = log - try: import flame self.flame = flame self.flame_project_name = self.flame.project.current_project.name self.flame_user_name = flame.users.current_user.name - except: + except Exception: self.flame = None self.flame_project_name = None self.flame_user_name = None @@ -127,10 +122,11 @@ class FlameAppFramework(object): if sys.platform == "darwin": self.prefs_folder = os.path.join( os.path.expanduser("~"), - "Library", - "Caches", - "OpenPype", - self.bundle_name) + "Library", + "Caches", + "OpenPype", + self.bundle_name + ) elif sys.platform.startswith("linux"): self.prefs_folder = os.path.join( os.path.expanduser("~"), @@ -157,89 +153,74 @@ class FlameAppFramework(object): self.apps = [] - def load_prefs(self): + def get_pref_file_paths(self): + prefix = self.prefs_folder + os.path.sep + self.bundle_name - prefs_file_path = (prefix + "." + self.flame_user_name + "." - + self.flame_project_name + ".prefs") - prefs_user_file_path = (prefix + "." + self.flame_user_name - + ".prefs") + prefs_file_path = "_".join([ + prefix, self.flame_user_name, + self.flame_project_name]) + ".prefs" + prefs_user_file_path = "_".join([ + prefix, self.flame_user_name]) + ".prefs" prefs_global_file_path = prefix + ".prefs" - try: - with open(prefs_file_path, "r") as prefs_file: - self.prefs = pickle.load(prefs_file) + return (prefs_file_path, prefs_user_file_path, prefs_global_file_path) - self.log.info("preferences loaded from {}".format(prefs_file_path)) - self.log.info("preferences contents:\n" + pformat(self.prefs)) - except: - self.log.info("unable to load preferences from {}".format( - prefs_file_path)) + def load_prefs(self): - try: - with open(prefs_user_file_path, "r") as prefs_file: - self.prefs_user = pickle.load(prefs_file) - self.log.info("preferences loaded from {}".format( - prefs_user_file_path)) - self.log.info("preferences contents:\n" + pformat(self.prefs_user)) - except: - self.log.info("unable to load preferences from {}".format( - prefs_user_file_path)) + (proj_pref_path, user_pref_path, + glob_pref_path) = self.get_pref_file_paths() - try: - with open(prefs_global_file_path, "r") as prefs_file: - self.prefs_global = pickle.load(prefs_file) - self.log.info("preferences loaded from {}".format( - prefs_global_file_path)) - self.log.info("preferences contents:\n" + pformat(self.prefs_global)) + with load_preferences_file(self, proj_pref_path, "prefs"): + self.log.info( + "Project - preferences contents:\n{}".format( + pformat(self.prefs) + )) - except: - self.log.info("unable to load preferences from {}".format( - prefs_global_file_path)) + with load_preferences_file(self, user_pref_path, "prefs_user"): + self.log.info( + "User - preferences contents:\n{}".format( + pformat(self.prefs_user) + )) + + with load_preferences_file(self, glob_pref_path, "prefs_global"): + self.log.info( + "Global - preferences contents:\n{}".format( + pformat(self.prefs_global) + )) return True def save_prefs(self): - import pickle - + # make sure the preference folder is available if not os.path.isdir(self.prefs_folder): try: os.makedirs(self.prefs_folder) - except: - self.log.info("unable to create folder {}".format( + except Exception: + self.log.info("Unable to create folder {}".format( self.prefs_folder)) return False - prefix = self.prefs_folder + os.path.sep + self.bundle_name - prefs_file_path = prefix + "." + self.flame_user_name + "." + self.flame_project_name + ".prefs" - prefs_user_file_path = prefix + "." + self.flame_user_name + ".prefs" - prefs_global_file_path = prefix + ".prefs" + # get all pref file paths + (proj_pref_path, user_pref_path, + glob_pref_path) = self.get_pref_file_paths() - try: - prefs_file = open(prefs_file_path, "w") - pickle.dump(self.prefs, prefs_file) - prefs_file.close() - self.log.info("preferences saved to {}".format(prefs_file_path)) - self.log.info("preferences contents:\n" + pformat(self.prefs)) - except: - self.log.info("unable to save preferences to {}".format(prefs_file_path)) + with save_preferences_file(self, proj_pref_path, "prefs"): + self.log.info( + "Project - preferences contents:\n{}".format( + pformat(self.prefs) + )) - try: - prefs_file = open(prefs_user_file_path, "w") - pickle.dump(self.prefs_user, prefs_file) - prefs_file.close() - self.log.info("preferences saved to {}".format(prefs_user_file_path)) - self.log.info("preferences contents:\n" + pformat(self.prefs_user)) - except: - self.log.info("unable to save preferences to {}".format(prefs_user_file_path)) + with save_preferences_file(self, user_pref_path, "prefs_user"): + self.log.info( + "User - preferences contents:\n{}".format( + pformat(self.prefs_user) + )) - try: - prefs_file = open(prefs_global_file_path, "w") - pickle.dump(self.prefs_global, prefs_file) - prefs_file.close() - self.log.info("preferences saved to {}".format(prefs_global_file_path)) - self.log.info("preferences contents:\n" + pformat(self.prefs_global)) - except: - self.log.info("unable to save preferences to {}".format(prefs_global_file_path)) + with save_preferences_file(self, glob_pref_path, "prefs_global"): + self.log.info( + "Global - preferences contents:\n{}".format( + pformat(self.prefs_global) + )) return True @@ -263,6 +244,7 @@ def maintain_current_timeline(to_timeline, from_timeline=None): >>> print(get_current_timeline().GetName()) timeline1 """ + # todo: this is still Resolve's implementation project = get_current_project() working_timeline = from_timeline or project.GetCurrentTimeline() @@ -306,5 +288,5 @@ def rescan_hooks(): import flame try: flame.execute_shortcut('Rescan Python Hooks') - except: + except Exception: pass diff --git a/openpype/hosts/flame/api/menu.py b/openpype/hosts/flame/api/menu.py index 65d1535beb..184881c6a7 100644 --- a/openpype/hosts/flame/api/menu.py +++ b/openpype/hosts/flame/api/menu.py @@ -70,7 +70,7 @@ class _FlameMenuApp(object): self.log.info('Rescan Python Hooks') -class FlameMenuProjectconnect(_FlameMenuApp): +class FlameMenuProjectConnect(_FlameMenuApp): # flameMenuProjectconnect app takes care of the preferences dialog as well diff --git a/openpype/hosts/flame/api/utils.py b/openpype/hosts/flame/api/utils.py index 489b51e37c..bd321e194c 100644 --- a/openpype/hosts/flame/api/utils.py +++ b/openpype/hosts/flame/api/utils.py @@ -1,5 +1,3 @@ -#! python3 - """ Resolve's tools for setting environment """ @@ -9,7 +7,6 @@ import shutil from openpype.api import Logger log = Logger().get_logger(__name__) - def _sync_utility_scripts(env=None): """ Synchronizing basic utlility scripts for resolve. @@ -20,8 +17,7 @@ def _sync_utility_scripts(env=None): """ from .. import HOST_DIR - if not env: - env = os.environ + env = env or os.environ # initiate inputs scripts = {} @@ -41,7 +37,8 @@ def _sync_utility_scripts(env=None): # to script path search for _dirpath in fsd_env.split(os.pathsep): if not os.path.isdir(_dirpath): - log.warning("Path is not a valid dir: `{_dirpath}`".format(**locals())) + log.warning("Path is not a valid dir: `{_dirpath}`".format( + **locals())) continue fsd_paths.append(_dirpath) @@ -82,8 +79,7 @@ def _sync_utility_scripts(env=None): def setup(env=None): """ Wrapper installer started from pype.hooks.resolve.FlamePrelaunch() """ - if not env: - env = os.environ + env = env or os.environ # synchronize resolve utility scripts _sync_utility_scripts(env) diff --git a/openpype/hosts/flame/hooks/pre_flame_setup.py b/openpype/hosts/flame/hooks/pre_flame_setup.py index aec9a15e30..368a70f395 100644 --- a/openpype/hosts/flame/hooks/pre_flame_setup.py +++ b/openpype/hosts/flame/hooks/pre_flame_setup.py @@ -1,6 +1,7 @@ import os import json import tempfile +import contextlib from openpype.lib import ( PreLaunchHook, get_openpype_username) from openpype.hosts import flame as opflame @@ -76,41 +77,56 @@ class FlamePrelaunch(PreLaunchHook): # Dump data to string dumped_script_data = json.dumps(script_data) + with make_temp_file(dumped_script_data) as tmp_json_path: + # Prepare subprocess arguments + args = [ + self.flame_python_exe, + self.wtc_script_path, + tmp_json_path + ] + self.log.info("Executing: {}".format(" ".join(args))) + + process_kwargs = { + "logger": self.log, + "env": {} + } + + openpype.api.run_subprocess(args, **process_kwargs) + + # process returned json file to pass launch args + return_json_data = open(tmp_json_path).read() + returned_data = json.loads(return_json_data) + app_args = returned_data.get("app_args") + self.log.info("____ app_args: `{}`".format(app_args)) + + if not app_args: + RuntimeError("App arguments were not solved") + + return app_args + + +@contextlib.contextmanager +def make_temp_file(data): + try: # Store dumped json to temporary file temporary_json_file = tempfile.NamedTemporaryFile( mode="w", suffix=".json", delete=False ) - temporary_json_file.write(dumped_script_data) + temporary_json_file.write(data) temporary_json_file.close() temporary_json_filepath = temporary_json_file.name.replace( "\\", "/" ) - # Prepare subprocess arguments - args = [ - self.flame_python_exe, - self.wtc_script_path, - temporary_json_filepath - ] - self.log.info("Executing: {}".format(" ".join(args))) + yield temporary_json_filepath - process_kwargs = { - "logger": self.log, - "env": {} - } - - openpype.api.run_subprocess(args, **process_kwargs) - - # process returned json file to pass launch args - return_json_data = open(temporary_json_filepath).read() - returned_data = json.loads(return_json_data) - app_args = returned_data.get("app_args") - self.log.info("____ app_args: `{}`".format(app_args)) - - if not app_args: - RuntimeError("App arguments were not solved") + except IOError as _error: + raise IOError( + "Not able to create temp json file: {}".format( + _error + ) + ) + finally: # Remove the temporary json os.remove(temporary_json_filepath) - - return app_args diff --git a/openpype/hosts/flame/utility_scripts/flame_hook.py b/openpype/hosts/flame/utility_scripts/flame_hook.py index b46109a609..2233b97d32 100644 --- a/openpype/hosts/flame/utility_scripts/flame_hook.py +++ b/openpype/hosts/flame/utility_scripts/flame_hook.py @@ -55,7 +55,7 @@ atexit.register(cleanup) def load_apps(): - opflame.apps.append(opflame.FlameMenuProjectconnect(opflame.app_framework)) + opflame.apps.append(opflame.FlameMenuProjectConnect(opflame.app_framework)) opflame.apps.append(opflame.FlameMenuTimeline(opflame.app_framework)) opflame.app_framework.log.info("Apps are loaded") @@ -121,7 +121,7 @@ def get_main_menu_custom_ui_actions(): # install openpype and the host openpype_install() - return _build_app_menu("FlameMenuProjectconnect") + return _build_app_menu("FlameMenuProjectConnect") def get_timeline_custom_ui_actions(): From f6076af2ad5e513eb1fa7f6fd22c00d1e3161810 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 26 Oct 2021 16:26:34 +0200 Subject: [PATCH 12/33] Hound and review comments --- openpype/hosts/flame/api/menu.py | 31 ++++++++++++++++++---------- openpype/hosts/flame/api/pipeline.py | 7 ------- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/openpype/hosts/flame/api/menu.py b/openpype/hosts/flame/api/menu.py index 184881c6a7..b4f1728acf 100644 --- a/openpype/hosts/flame/api/menu.py +++ b/openpype/hosts/flame/api/menu.py @@ -1,19 +1,28 @@ import os -import sys -from Qt import QtWidgets, QtCore -from pprint import pprint, pformat +from Qt import QtWidgets from copy import deepcopy -from .lib import rescan_hooks from openpype.tools.utils.host_tools import HostToolsHelper menu_group_name = 'OpenPype' default_flame_export_presets = { - 'Publish': {'PresetVisibility': 2, 'PresetType': 0, 'PresetFile': 'OpenEXR/OpenEXR (16-bit fp PIZ).xml'}, - 'Preview': {'PresetVisibility': 3, 'PresetType': 2, 'PresetFile': 'Generate Preview.xml'}, - 'Thumbnail': {'PresetVisibility': 3, 'PresetType': 0, 'PresetFile': 'Generate Thumbnail.xml'} + 'Publish': { + 'PresetVisibility': 2, + 'PresetType': 0, + 'PresetFile': 'OpenEXR/OpenEXR (16-bit fp PIZ).xml' + }, + 'Preview': { + 'PresetVisibility': 3, + 'PresetType': 2, + 'PresetFile': 'Generate Preview.xml' + }, + 'Thumbnail': { + 'PresetVisibility': 3, + 'PresetType': 0, + 'PresetFile': 'Generate Thumbnail.xml' + } } @@ -31,7 +40,7 @@ class _FlameMenuApp(object): try: import flame self.flame = flame - except: + except ImportError: self.flame = None self.flame_project_name = flame.project.current_project.name @@ -62,7 +71,7 @@ class _FlameMenuApp(object): try: import flame self.flame = flame - except: + except ImportError: self.flame = None if self.flame: @@ -130,7 +139,7 @@ class FlameMenuProjectConnect(_FlameMenuApp): try: import flame self.flame = flame - except: + except ImportError: self.flame = None if self.flame: @@ -191,7 +200,7 @@ class FlameMenuTimeline(_FlameMenuApp): try: import flame self.flame = flame - except: + except ImportError: self.flame = None if self.flame: diff --git a/openpype/hosts/flame/api/pipeline.py b/openpype/hosts/flame/api/pipeline.py index 297ab0e44c..85b9f7e24a 100644 --- a/openpype/hosts/flame/api/pipeline.py +++ b/openpype/hosts/flame/api/pipeline.py @@ -1,23 +1,16 @@ """ Basic avalon integration """ -import os import contextlib -from collections import OrderedDict -from avalon.tools import workfiles from avalon import api as avalon -from avalon import schema -from avalon.pipeline import AVALON_CONTAINER_ID from pyblish import api as pyblish from openpype.api import Logger -from . import lib AVALON_CONTAINERS = "AVALON_CONTAINERS" log = Logger().get_logger(__name__) - def install(): from .. import ( PUBLISH_PATH, From 97a405b5a119ba7822c062cb69111ba43e4a0342 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 26 Oct 2021 20:13:12 +0200 Subject: [PATCH 13/33] Hound suggestions --- openpype/hosts/flame/api/plugin.py | 10 ---------- openpype/hosts/flame/utility_scripts/flame_hook.py | 11 ++++------- 2 files changed, 4 insertions(+), 17 deletions(-) diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py index ac86c7c224..2a28a20a75 100644 --- a/openpype/hosts/flame/api/plugin.py +++ b/openpype/hosts/flame/api/plugin.py @@ -1,13 +1,3 @@ -import re -import uuid -from avalon import api -import openpype.api as pype -from openpype.hosts import resolve -from avalon.vendor import qargparse -from . import lib - -from Qt import QtWidgets, QtCore - # Creator plugin functions # Publishing plugin functions # Loader plugin functions diff --git a/openpype/hosts/flame/utility_scripts/flame_hook.py b/openpype/hosts/flame/utility_scripts/flame_hook.py index 2233b97d32..f482126624 100644 --- a/openpype/hosts/flame/utility_scripts/flame_hook.py +++ b/openpype/hosts/flame/utility_scripts/flame_hook.py @@ -1,6 +1,6 @@ import sys -from Qt import QtWidgets, QtCore -from pprint import pprint, pformat +from Qt import QtWidgets +from pprint import pformat import atexit import openpype import avalon @@ -81,11 +81,7 @@ except ImportError: def rescan_hooks(): - import flame - try: - flame.execute_shortcut('Rescan Python Hooks') - except: - pass + flame.execute_shortcut('Rescan Python Hooks') def _build_app_menu(app_name): @@ -112,6 +108,7 @@ def _build_app_menu(app_name): return menu + def project_saved(project_name, save_time, is_auto_save): if opflame.app_framework: opflame.app_framework.save_prefs() From deada2422e39182151285cfc03f032ea298842ad Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 26 Oct 2021 20:36:04 +0200 Subject: [PATCH 14/33] flame hook adding docstrings --- .../hosts/flame/utility_scripts/flame_hook.py | 77 ++++++++++++++++--- 1 file changed, 68 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/flame/utility_scripts/flame_hook.py b/openpype/hosts/flame/utility_scripts/flame_hook.py index f482126624..bce668a389 100644 --- a/openpype/hosts/flame/utility_scripts/flame_hook.py +++ b/openpype/hosts/flame/utility_scripts/flame_hook.py @@ -1,3 +1,4 @@ +from __future__ import print_function import sys from Qt import QtWidgets from pprint import pformat @@ -11,23 +12,32 @@ flh._project = None def openpype_install(): + """Registering OpenPype in context + """ openpype.install() avalon.api.install(opflame) - print("<<<<<<<<<<< Avalon registred hosts: {} >>>>>>>>>>>>>>>".format( + print("Avalon registred hosts: {}".format( avalon.api.registered_host())) # Exception handler -def exeption_handler(exctype, value, tb): +def exeption_handler(exctype, value, _traceback): + """Exception handler for improving UX + + Args: + exctype (str): type of exception + value (str): exception value + tb (str): traceback to show + """ import traceback msg = "OpenPype: Python exception {} in {}".format(value, exctype) mbox = QtWidgets.QMessageBox() mbox.setText(msg) mbox.setDetailedText( - pformat(traceback.format_exception(exctype, value, tb))) + pformat(traceback.format_exception(exctype, value, _traceback))) mbox.setStyleSheet('QLabel{min-width: 800px;}') mbox.exec_() - sys.__excepthook__(exctype, value, tb) + sys.__excepthook__(exctype, value, _traceback) # add exception handler into sys module @@ -36,12 +46,14 @@ sys.excepthook = exeption_handler # register clean up logic to be called at Flame exit def cleanup(): + """Cleaning up Flame framework context + """ if opflame.apps: - print('<<<< `{}` cleaning up apps:\n {}\n'.format( + print('`{}` cleaning up apps:\n {}\n'.format( __file__, pformat(opflame.apps))) while len(opflame.apps): app = opflame.apps.pop() - print('<<<< `{}` removing : {}'.format(__file__, app.name)) + print('`{}` removing : {}'.format(__file__, app.name)) del app opflame.apps = [] @@ -55,24 +67,44 @@ atexit.register(cleanup) def load_apps(): + """Load available apps into Flame framework + """ opflame.apps.append(opflame.FlameMenuProjectConnect(opflame.app_framework)) opflame.apps.append(opflame.FlameMenuTimeline(opflame.app_framework)) opflame.app_framework.log.info("Apps are loaded") def project_changed_dict(info): + """Hook for project change action + + Args: + info (str): info text + """ cleanup() def app_initialized(parent=None): + """Inicialization of Framework + + Args: + parent (obj, optional): Parent object. Defaults to None. + """ opflame.app_framework = opflame.FlameAppFramework() - print(">> flame_hook.py: {} initializing".format( + print("{} initializing".format( opflame.app_framework.bundle_name)) load_apps() +""" +Initialisation of the hook is starting from here + +First it needs to test if it can import the flame modul. +This will happen only in case a project has been loaded. +Then `app_initialized` will load main Framework which will load +all menu objects as apps. +""" try: import flame app_initialized(parent=None) @@ -85,7 +117,17 @@ def rescan_hooks(): def _build_app_menu(app_name): + """Flame menu object generator + + Args: + app_name (str): name of menu object app + + Returns: + list: menu object + """ menu = [] + + # first find the relative appname app = None for _app in opflame.apps: if _app.__class__.__name__ == app_name: @@ -94,8 +136,6 @@ def _build_app_menu(app_name): if app: menu.append(app.build_menu()) - print(">>_> `{}` was build: {}".format(app_name, pformat(menu))) - if opflame.app_framework: menu_auto_refresh = opflame.app_framework.prefs_global.get( 'menu_auto_refresh', {}) @@ -109,12 +149,26 @@ def _build_app_menu(app_name): return menu +""" Flame hooks are starting here +""" def project_saved(project_name, save_time, is_auto_save): + """Hook to activate when project is saved + + Args: + project_name (str): name of project + save_time (str): time when it was saved + is_auto_save (bool): autosave is on or off + """ if opflame.app_framework: opflame.app_framework.save_prefs() def get_main_menu_custom_ui_actions(): + """Hook to create submenu in start menu + + Returns: + list: menu object + """ # install openpype and the host openpype_install() @@ -122,6 +176,11 @@ def get_main_menu_custom_ui_actions(): def get_timeline_custom_ui_actions(): + """Hook to create submenu in timeline + + Returns: + list: menu object + """ # install openpype and the host openpype_install() From a2330d218c0e069d2ec4f3074c0a3e9bc2b415fa Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 26 Oct 2021 20:56:47 +0200 Subject: [PATCH 15/33] Hound suggestions --- openpype/hosts/flame/scripts/wiretap_com.py | 41 +++++++++++++-------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/openpype/hosts/flame/scripts/wiretap_com.py b/openpype/hosts/flame/scripts/wiretap_com.py index a5925d0546..d8dc1884cf 100644 --- a/openpype/hosts/flame/scripts/wiretap_com.py +++ b/openpype/hosts/flame/scripts/wiretap_com.py @@ -10,20 +10,26 @@ import xml.dom.minidom as minidom from copy import deepcopy import datetime -# Todo: this has to be replaced with somehting more dynamic -flame_python_path = "/opt/Autodesk/flame_2021/python" -flame_exe_path = "/opt/Autodesk/flame_2021/bin/flame.app/Contents/MacOS/startApp" +try: + from libwiretapPythonClientAPI import ( + WireTapClientInit) +except ImportError: + flame_python_path = "/opt/Autodesk/flame_2021/python" + flame_exe_path = ( + "/opt/Autodesk/flame_2021/bin/flame.app" + "/Contents/MacOS/startApp") -sys.path.append(flame_python_path) + sys.path.append(flame_python_path) + + from libwiretapPythonClientAPI import ( + WireTapClientInit, + WireTapClientUninit, + WireTapNodeHandle, + WireTapServerHandle, + WireTapInt, + WireTapStr + ) -from libwiretapPythonClientAPI import ( - WireTapClientInit, - WireTapClientUninit, - WireTapNodeHandle, - WireTapServerHandle, - WireTapInt, - WireTapStr -) class WireTapCom(object): """ @@ -231,7 +237,8 @@ class WireTapCom(object): if not get_children_name: raise AttributeError( - "Unable to get child name: {}".format(child_obj.lastError()) + "Unable to get child name: {}".format( + child_obj.lastError()) ) volumes.append(node_name.c_str()) @@ -256,11 +263,12 @@ class WireTapCom(object): filtered_users = [user for user in used_names if user_name in user] if filtered_users: - # todo: need to find lastly created following regex patern for date used in name + # todo: need to find lastly created following regex patern for + # date used in name return filtered_users.pop() # create new user name with date in suffix - now = datetime.datetime.now() # current date and time + now = datetime.datetime.now() # current date and time date = now.strftime("%Y%m%d") new_user_name = "{}_{}".format(user_name, date) print(new_user_name) @@ -314,7 +322,8 @@ class WireTapCom(object): if not get_children_name: raise AttributeError( - "Unable to get child name: {}".format(child_obj.lastError()) + "Unable to get child name: {}".format( + child_obj.lastError()) ) usernames.append(node_name.c_str()) From 3b67b7d4849c5910eceaa0f521a7fb92d43634b3 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 26 Oct 2021 20:57:11 +0200 Subject: [PATCH 16/33] flame hook: renaming to something more appropriate --- .../utility_scripts/{flame_hook.py => openpype_in_flame.py} | 3 +++ 1 file changed, 3 insertions(+) rename openpype/hosts/flame/utility_scripts/{flame_hook.py => openpype_in_flame.py} (99%) diff --git a/openpype/hosts/flame/utility_scripts/flame_hook.py b/openpype/hosts/flame/utility_scripts/openpype_in_flame.py similarity index 99% rename from openpype/hosts/flame/utility_scripts/flame_hook.py rename to openpype/hosts/flame/utility_scripts/openpype_in_flame.py index bce668a389..afbd44afc9 100644 --- a/openpype/hosts/flame/utility_scripts/flame_hook.py +++ b/openpype/hosts/flame/utility_scripts/openpype_in_flame.py @@ -105,6 +105,7 @@ This will happen only in case a project has been loaded. Then `app_initialized` will load main Framework which will load all menu objects as apps. """ + try: import flame app_initialized(parent=None) @@ -151,6 +152,8 @@ def _build_app_menu(app_name): """ Flame hooks are starting here """ + + def project_saved(project_name, save_time, is_auto_save): """Hook to activate when project is saved From 65d860d3f8cc54d86ca8c9f5acb63a68571866a8 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 26 Oct 2021 21:06:55 +0200 Subject: [PATCH 17/33] Flame: custom script dirs variable changed to plural --- openpype/hosts/flame/api/utils.py | 10 ++++++---- .../defaults/system_settings/applications.json | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/flame/api/utils.py b/openpype/hosts/flame/api/utils.py index bd321e194c..daebea32a7 100644 --- a/openpype/hosts/flame/api/utils.py +++ b/openpype/hosts/flame/api/utils.py @@ -1,5 +1,5 @@ """ -Resolve's tools for setting environment +Flame utils for syncing scripts """ import os @@ -7,8 +7,9 @@ import shutil from openpype.api import Logger log = Logger().get_logger(__name__) + def _sync_utility_scripts(env=None): - """ Synchronizing basic utlility scripts for resolve. + """ Synchronizing basic utlility scripts for flame. To be able to run start OpenPype within Flame we have to copy all utility_scripts and additional FLAME_SCRIPT_DIR into @@ -21,7 +22,7 @@ def _sync_utility_scripts(env=None): # initiate inputs scripts = {} - fsd_env = env.get("FLAME_SCRIPT_DIR", "") + fsd_env = env.get("FLAME_SCRIPT_DIRS", "") flame_shared_dir = "/opt/Autodesk/shared/python" fsd_paths = [os.path.join( @@ -77,7 +78,8 @@ def _sync_utility_scripts(env=None): def setup(env=None): - """ Wrapper installer started from pype.hooks.resolve.FlamePrelaunch() + """ Wrapper installer started from + `flame/hooks/pre_flame_setup.py` """ env = env or os.environ diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json index 8aa7f3c1a3..2866673f4b 100644 --- a/openpype/settings/defaults/system_settings/applications.json +++ b/openpype/settings/defaults/system_settings/applications.json @@ -103,7 +103,7 @@ "icon": "{}/app_icons/flame.png", "host_name": "flame", "environment": { - "FLAME_SCRIPT_DIR": { + "FLAME_SCRIPT_DIRS": { "windows": "", "darvin": "", "linux": "" From 25557e7be8a7058f727acc4cdb89bb48a897b234 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 26 Oct 2021 21:07:06 +0200 Subject: [PATCH 18/33] hound suggestion --- openpype/hosts/flame/api/workio.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/flame/api/workio.py b/openpype/hosts/flame/api/workio.py index 00fcdb9405..d2e2408798 100644 --- a/openpype/hosts/flame/api/workio.py +++ b/openpype/hosts/flame/api/workio.py @@ -2,10 +2,10 @@ import os from openpype.api import Logger -from .. import ( - get_project_manager, - get_current_project -) +# from .. import ( +# get_project_manager, +# get_current_project +# ) log = Logger().get_logger(__name__) From d01ffd9373a0cc2f5196804da9e53f4c660e80ec Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 26 Oct 2021 21:24:11 +0200 Subject: [PATCH 19/33] flame: fixing contextmanager for save and load preferences file --- openpype/hosts/flame/api/lib.py | 48 +++++++++++---------------------- 1 file changed, 16 insertions(+), 32 deletions(-) diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index a58b67d54a..2b3396c420 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -10,37 +10,15 @@ log = Logger().get_logger(__name__) @contextlib.contextmanager -def load_preferences_file(klass, filepath, attribute): +def io_preferences_file(klass, filepath, write=False): try: - with open(filepath, "r") as prefs_file: - setattr(klass, attribute, pickle.load(prefs_file)) - - yield + flag = "w" if write else "r" + yield open(filepath, flag) except IOError: - klass.log.info("Unable to load preferences from {}".format( + klass.log.info("Unable to work with preferences `{}`".format( filepath)) - finally: - klass.log.info("Preferences loaded from {}".format(filepath)) - - -@contextlib.contextmanager -def save_preferences_file(klass, filepath, attribute): - try: - with open(filepath, "w") as prefs_file: - attr = getattr(klass, attribute) - pickle.dump(attr, prefs_file) - - yield - - except IOError: - klass.log.info("Unable to save preferences to {}".format( - filepath)) - - finally: - klass.log.info("Preferences saved to {}".format(filepath)) - class FlameAppFramework(object): # flameAppFramework class takes care of preferences @@ -170,19 +148,22 @@ class FlameAppFramework(object): (proj_pref_path, user_pref_path, glob_pref_path) = self.get_pref_file_paths() - with load_preferences_file(self, proj_pref_path, "prefs"): + with io_preferences_file(self, proj_pref_path) as prefs_file: + self.prefs = pickle.load(prefs_file) self.log.info( "Project - preferences contents:\n{}".format( pformat(self.prefs) )) - with load_preferences_file(self, user_pref_path, "prefs_user"): + with io_preferences_file(self, user_pref_path) as prefs_file: + self.prefs_user = pickle.load(prefs_file) self.log.info( "User - preferences contents:\n{}".format( pformat(self.prefs_user) )) - with load_preferences_file(self, glob_pref_path, "prefs_global"): + with io_preferences_file(self, glob_pref_path) as prefs_file: + self.prefs_global = pickle.load(prefs_file) self.log.info( "Global - preferences contents:\n{}".format( pformat(self.prefs_global) @@ -204,19 +185,22 @@ class FlameAppFramework(object): (proj_pref_path, user_pref_path, glob_pref_path) = self.get_pref_file_paths() - with save_preferences_file(self, proj_pref_path, "prefs"): + with io_preferences_file(self, proj_pref_path, True) as prefs_file: + pickle.dump(self.prefs, prefs_file) self.log.info( "Project - preferences contents:\n{}".format( pformat(self.prefs) )) - with save_preferences_file(self, user_pref_path, "prefs_user"): + with io_preferences_file(self, user_pref_path, True) as prefs_file: + pickle.dump(self.prefs_user, prefs_file) self.log.info( "User - preferences contents:\n{}".format( pformat(self.prefs_user) )) - with save_preferences_file(self, glob_pref_path, "prefs_global"): + with io_preferences_file(self, glob_pref_path, True) as prefs_file: + pickle.dump(self.prefs_global, prefs_file) self.log.info( "Global - preferences contents:\n{}".format( pformat(self.prefs_global) From 1d8acaa45dbd01d2dff4c014958ccfd1fc96a877 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 26 Oct 2021 21:36:40 +0200 Subject: [PATCH 20/33] Flame debugging `io_preferences_file` --- openpype/hosts/flame/api/lib.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index 2b3396c420..48331dcbc2 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -15,9 +15,9 @@ def io_preferences_file(klass, filepath, write=False): flag = "w" if write else "r" yield open(filepath, flag) - except IOError: - klass.log.info("Unable to work with preferences `{}`".format( - filepath)) + except IOError as _error: + klass.log.info("Unable to work with preferences `{}`: {}".format( + filepath, _error)) class FlameAppFramework(object): From 211fc22e090253d0be7b971dccd9a87f369f485d Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 26 Oct 2021 21:36:57 +0200 Subject: [PATCH 21/33] Flame: cleaning Resolve mentioning --- openpype/hosts/flame/api/pipeline.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/flame/api/pipeline.py b/openpype/hosts/flame/api/pipeline.py index 85b9f7e24a..26dfe7c032 100644 --- a/openpype/hosts/flame/api/pipeline.py +++ b/openpype/hosts/flame/api/pipeline.py @@ -36,7 +36,7 @@ def install(): pyblish.register_host("flame") pyblish.register_plugin_path(PUBLISH_PATH) - log.info("Registering DaVinci Resovle plug-ins..") + log.info("Registering Flame plug-ins..") avalon.register_plugin_path(avalon.Loader, LOAD_PATH) avalon.register_plugin_path(avalon.Creator, CREATE_PATH) @@ -129,13 +129,13 @@ def on_pyblish_instance_toggled(instance, old_value, new_value): log.info("instance toggle: {}, old_value: {}, new_value:{} ".format( instance, old_value, new_value)) - from openpype.hosts.resolve import ( - set_publish_attribute - ) + # from openpype.hosts.resolve import ( + # set_publish_attribute + # ) - # Whether instances should be passthrough based on new value - timeline_item = instance.data["item"] - set_publish_attribute(timeline_item, new_value) + # # Whether instances should be passthrough based on new value + # timeline_item = instance.data["item"] + # set_publish_attribute(timeline_item, new_value) def remove_instance(instance): From 86c528dd4a836f444d28f2a63fe84314660eec6f Mon Sep 17 00:00:00 2001 From: OpenPype Date: Wed, 27 Oct 2021 03:40:31 +0000 Subject: [PATCH 22/33] [Automated] Bump version --- CHANGELOG.md | 8 ++++---- openpype/version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eca2a8b423..68409c4db8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [3.5.1-nightly.1](https://github.com/pypeclub/OpenPype/tree/HEAD) +## [3.6.0-nightly.1](https://github.com/pypeclub/OpenPype/tree/HEAD) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.5.0...HEAD) @@ -8,11 +8,13 @@ - Tools: Experimental tools [\#2167](https://github.com/pypeclub/OpenPype/pull/2167) - Loader: Refactor and use OpenPype stylesheets [\#2166](https://github.com/pypeclub/OpenPype/pull/2166) +- Add loader for linked smart objects in photoshop [\#2149](https://github.com/pypeclub/OpenPype/pull/2149) - Burnins: DNxHD profiles handling [\#2142](https://github.com/pypeclub/OpenPype/pull/2142) - Tools: Single access point for host tools [\#2139](https://github.com/pypeclub/OpenPype/pull/2139) **πŸ› Bug fixes** +- MacOS: Launching of applications may cause Permissions error [\#2175](https://github.com/pypeclub/OpenPype/pull/2175) - Blender: Fix 'Deselect All' with object not in 'Object Mode' [\#2163](https://github.com/pypeclub/OpenPype/pull/2163) - Tools: Stylesheets are applied after tool show [\#2161](https://github.com/pypeclub/OpenPype/pull/2161) - Maya: Collect render - fix UNC path support πŸ› [\#2158](https://github.com/pypeclub/OpenPype/pull/2158) @@ -103,7 +105,7 @@ - Settings for Nuke IncrementScriptVersion [\#2039](https://github.com/pypeclub/OpenPype/pull/2039) - Loader & Library loader: Use tools from OpenPype [\#2038](https://github.com/pypeclub/OpenPype/pull/2038) - Adding predefined project folders creation in PM [\#2030](https://github.com/pypeclub/OpenPype/pull/2030) -- TimersManager: Removed interface of timers manager [\#2024](https://github.com/pypeclub/OpenPype/pull/2024) +- WebserverModule: Removed interface of webserver module [\#2028](https://github.com/pypeclub/OpenPype/pull/2028) **πŸ› Bug fixes** @@ -125,8 +127,6 @@ - Added possibility to configure of synchronization of workfile version… [\#2041](https://github.com/pypeclub/OpenPype/pull/2041) - General: Task types in profiles [\#2036](https://github.com/pypeclub/OpenPype/pull/2036) -- WebserverModule: Removed interface of webserver module [\#2028](https://github.com/pypeclub/OpenPype/pull/2028) -- Console interpreter: Handle invalid sizes on initialization [\#2022](https://github.com/pypeclub/OpenPype/pull/2022) **πŸ› Bug fixes** diff --git a/openpype/version.py b/openpype/version.py index 49b61c755e..6eb58f6fcc 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.5.1-nightly.1" +__version__ = "3.6.0-nightly.1" diff --git a/pyproject.toml b/pyproject.toml index c49ecabdab..1a112d2071 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.5.1-nightly.1" # OpenPype +version = "3.6.0-nightly.1" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" From 82f1cb9a0f5716d8e6547eb95b4b42a52daba39d Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 27 Oct 2021 14:42:03 +0200 Subject: [PATCH 23/33] flame settings fixing darvin to darwin --- openpype/hosts/flame/api/utils.py | 2 +- .../defaults/system_settings/applications.json | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/openpype/hosts/flame/api/utils.py b/openpype/hosts/flame/api/utils.py index daebea32a7..4ae7157812 100644 --- a/openpype/hosts/flame/api/utils.py +++ b/openpype/hosts/flame/api/utils.py @@ -31,7 +31,7 @@ def _sync_utility_scripts(env=None): )] # collect script dirs - log.info("FLAME_SCRIPT_DIR: `{fsd_env}`".format(**locals())) + log.info("FLAME_SCRIPT_DIRS: `{fsd_env}`".format(**locals())) log.info("fsd_paths: `{fsd_paths}`".format(**locals())) # add application environment setting for FLAME_SCRIPT_DIR diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json index 2866673f4b..79711f3067 100644 --- a/openpype/settings/defaults/system_settings/applications.json +++ b/openpype/settings/defaults/system_settings/applications.json @@ -105,7 +105,7 @@ "environment": { "FLAME_SCRIPT_DIRS": { "windows": "", - "darvin": "", + "darwin": "", "linux": "" } }, @@ -656,12 +656,12 @@ "FUSION_UTILITY_SCRIPTS_SOURCE_DIR": [], "FUSION_UTILITY_SCRIPTS_DIR": { "windows": "{PROGRAMDATA}/Blackmagic Design/Fusion/Scripts/Comp", - "darvin": "/Library/Application Support/Blackmagic Design/Fusion/Scripts/Comp", + "darwin": "/Library/Application Support/Blackmagic Design/Fusion/Scripts/Comp", "linux": "/opt/Fusion/Scripts/Comp" }, "PYTHON36": { "windows": "{LOCALAPPDATA}/Programs/Python/Python36", - "darvin": "~/Library/Python/3.6/bin", + "darwin": "~/Library/Python/3.6/bin", "linux": "/opt/Python/3.6/bin" }, "PYTHONPATH": [ @@ -722,22 +722,22 @@ "RESOLVE_UTILITY_SCRIPTS_SOURCE_DIR": [], "RESOLVE_SCRIPT_API": { "windows": "{PROGRAMDATA}/Blackmagic Design/DaVinci Resolve/Support/Developer/Scripting", - "darvin": "/Library/Application Support/Blackmagic Design/DaVinci Resolve/Developer/Scripting", + "darwin": "/Library/Application Support/Blackmagic Design/DaVinci Resolve/Developer/Scripting", "linux": "/opt/resolve/Developer/Scripting" }, "RESOLVE_SCRIPT_LIB": { "windows": "C:/Program Files/Blackmagic Design/DaVinci Resolve/fusionscript.dll", - "darvin": "/Applications/DaVinci Resolve/DaVinci Resolve.app/Contents/Libraries/Fusion/fusionscript.so", + "darwin": "/Applications/DaVinci Resolve/DaVinci Resolve.app/Contents/Libraries/Fusion/fusionscript.so", "linux": "/opt/resolve/libs/Fusion/fusionscript.so" }, "RESOLVE_UTILITY_SCRIPTS_DIR": { "windows": "{PROGRAMDATA}/Blackmagic Design/DaVinci Resolve/Fusion/Scripts/Comp", - "darvin": "/Library/Application Support/Blackmagic Design/DaVinci Resolve/Fusion/Scripts/Comp", + "darwin": "/Library/Application Support/Blackmagic Design/DaVinci Resolve/Fusion/Scripts/Comp", "linux": "/opt/resolve/Fusion/Scripts/Comp" }, "PYTHON36_RESOLVE": { "windows": "{LOCALAPPDATA}/Programs/Python/Python36", - "darvin": "~/Library/Python/3.6/bin", + "darwin": "~/Library/Python/3.6/bin", "linux": "/opt/Python/3.6/bin" }, "PYTHONPATH": [ From c04a86ddbff933d6604672307265b221d9c68e30 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 27 Oct 2021 14:54:17 +0200 Subject: [PATCH 24/33] Flame: adding filter to sync utility script --- openpype/hosts/flame/api/utils.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/openpype/hosts/flame/api/utils.py b/openpype/hosts/flame/api/utils.py index 4ae7157812..3a36b30784 100644 --- a/openpype/hosts/flame/api/utils.py +++ b/openpype/hosts/flame/api/utils.py @@ -47,12 +47,20 @@ def _sync_utility_scripts(env=None): for path in fsd_paths: scripts.update({path: os.listdir(path)}) + remove_black_list = [] + for _k, s_list in scripts.items(): + remove_black_list += s_list + log.info("Additional Flame script paths: `{fsd_paths}`".format(**locals())) log.info("Flame Scripts: `{scripts}`".format(**locals())) # make sure no script file is in folder if next(iter(os.listdir(flame_shared_dir)), None): for s in os.listdir(flame_shared_dir): + # skip all scripts and folders which are not maintained + if s not in remove_black_list: + continue + path = os.path.join(flame_shared_dir, s) log.info("Removing `{path}`...".format(**locals())) if os.path.isdir(path): From 432fa380f9ac15f6c7fb1d1a6dba99d9da1838dd Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 27 Oct 2021 15:38:06 +0200 Subject: [PATCH 25/33] Flame: adding exception for not maintained files during sync --- openpype/hosts/flame/api/utils.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/flame/api/utils.py b/openpype/hosts/flame/api/utils.py index 3a36b30784..0a3ee68815 100644 --- a/openpype/hosts/flame/api/utils.py +++ b/openpype/hosts/flame/api/utils.py @@ -51,17 +51,28 @@ def _sync_utility_scripts(env=None): for _k, s_list in scripts.items(): remove_black_list += s_list + log.info("remove_black_list: `{remove_black_list}`".format(**locals())) log.info("Additional Flame script paths: `{fsd_paths}`".format(**locals())) log.info("Flame Scripts: `{scripts}`".format(**locals())) # make sure no script file is in folder if next(iter(os.listdir(flame_shared_dir)), None): - for s in os.listdir(flame_shared_dir): + for _itm in os.listdir(flame_shared_dir): + skip = False + # skip all scripts and folders which are not maintained - if s not in remove_black_list: + if _itm not in remove_black_list: + skip = True + + # do not skyp if pyc in extension + if not os.path.isdir(_itm) and "pyc" in os.path.splitext(_itm)[-1]: + skip = False + + # continue if skip in true + if skip: continue - path = os.path.join(flame_shared_dir, s) + path = os.path.join(flame_shared_dir, _itm) log.info("Removing `{path}`...".format(**locals())) if os.path.isdir(path): shutil.rmtree(path, onerror=None) From 40142effb6f353367110db4b82a24a6c6bb3ff11 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 27 Oct 2021 15:38:17 +0200 Subject: [PATCH 26/33] Flame: missing import --- openpype/hosts/flame/utility_scripts/openpype_in_flame.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/hosts/flame/utility_scripts/openpype_in_flame.py b/openpype/hosts/flame/utility_scripts/openpype_in_flame.py index afbd44afc9..50ccbb521c 100644 --- a/openpype/hosts/flame/utility_scripts/openpype_in_flame.py +++ b/openpype/hosts/flame/utility_scripts/openpype_in_flame.py @@ -114,6 +114,7 @@ except ImportError: def rescan_hooks(): + import flame flame.execute_shortcut('Rescan Python Hooks') From 1f5b8132edb7370afa7fa022ea51a4183ecb85eb Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 27 Oct 2021 15:44:49 +0200 Subject: [PATCH 27/33] hound suggestions --- openpype/hosts/flame/api/utils.py | 6 +++--- openpype/hosts/flame/utility_scripts/openpype_in_flame.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/flame/api/utils.py b/openpype/hosts/flame/api/utils.py index 0a3ee68815..a750046362 100644 --- a/openpype/hosts/flame/api/utils.py +++ b/openpype/hosts/flame/api/utils.py @@ -59,15 +59,15 @@ def _sync_utility_scripts(env=None): if next(iter(os.listdir(flame_shared_dir)), None): for _itm in os.listdir(flame_shared_dir): skip = False - + # skip all scripts and folders which are not maintained if _itm not in remove_black_list: skip = True - + # do not skyp if pyc in extension if not os.path.isdir(_itm) and "pyc" in os.path.splitext(_itm)[-1]: skip = False - + # continue if skip in true if skip: continue diff --git a/openpype/hosts/flame/utility_scripts/openpype_in_flame.py b/openpype/hosts/flame/utility_scripts/openpype_in_flame.py index 50ccbb521c..c5fa881f3c 100644 --- a/openpype/hosts/flame/utility_scripts/openpype_in_flame.py +++ b/openpype/hosts/flame/utility_scripts/openpype_in_flame.py @@ -107,14 +107,14 @@ all menu objects as apps. """ try: - import flame + import flame # noqa app_initialized(parent=None) except ImportError: print("!!!! not able to import flame module !!!!") def rescan_hooks(): - import flame + import flame # noqa flame.execute_shortcut('Rescan Python Hooks') @@ -143,7 +143,7 @@ def _build_app_menu(app_name): 'menu_auto_refresh', {}) if menu_auto_refresh.get('timeline_menu', True): try: - import flame + import flame # noqa flame.schedule_idle_event(rescan_hooks) except ImportError: print("!-!!! not able to import flame module !!!!") From be36900ffa037b4c98df70b87f68f4864e34a9aa Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 29 Oct 2021 13:55:47 +0200 Subject: [PATCH 28/33] OP-1920 - added always_accessible_on to Settings --- .../settings/defaults/project_settings/global.json | 1 + .../projects_schema/schema_project_syncserver.json | 13 +++++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/openpype/settings/defaults/project_settings/global.json b/openpype/settings/defaults/project_settings/global.json index 45c1a59d17..46e5574eb3 100644 --- a/openpype/settings/defaults/project_settings/global.json +++ b/openpype/settings/defaults/project_settings/global.json @@ -319,6 +319,7 @@ "config": { "retry_cnt": "3", "loop_delay": "60", + "always_accessible_on": [], "active_site": "studio", "remote_site": "studio" }, diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_syncserver.json b/openpype/settings/entities/schemas/projects_schema/schema_project_syncserver.json index 3211babd43..88ef2ed0c3 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_syncserver.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_syncserver.json @@ -26,15 +26,24 @@ "key": "loop_delay", "label": "Loop Delay" }, + { + "type": "list", + "key": "always_accessible_on", + "label": "Always accessible on sites", + "object_type": "text" + }, + { + "type": "splitter" + }, { "type": "text", "key": "active_site", - "label": "Active Site" + "label": "User Default Active Site" }, { "type": "text", "key": "remote_site", - "label": "Remote Site" + "label": "User Default Remote Site" } ] }, From c69c15310fe1a55ffa9daf1410ad189ff125adb7 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 29 Oct 2021 14:02:24 +0200 Subject: [PATCH 29/33] OP-1920 - implemented always_accessible_on Needed when new representation is created to map wherever it needs to be synched in the end --- openpype/plugins/publish/integrate_new.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/openpype/plugins/publish/integrate_new.py b/openpype/plugins/publish/integrate_new.py index 451ea1d80d..fe780480c2 100644 --- a/openpype/plugins/publish/integrate_new.py +++ b/openpype/plugins/publish/integrate_new.py @@ -1028,6 +1028,7 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): """ local_site = 'studio' # default remote_site = None + always_accesible = [] sync_server_presets = None if (instance.context.data["system_settings"] @@ -1042,6 +1043,8 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): if sync_server_presets["enabled"]: local_site = sync_server_presets["config"].\ get("active_site", "studio").strip() + always_accesible = sync_server_presets["config"].\ + get("always_accessible_on", []) if local_site == 'local': local_site = local_site_id @@ -1072,6 +1075,12 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): meta = {"name": remote_site.strip()} rec["sites"].append(meta) + # add skeleton for site where it should be always synced to + for always_on_site in always_accesible: + if always_on_site not in [local_site, remote_site]: + meta = {"name": always_on_site.strip()} + rec["sites"].append(meta) + return rec def handle_destination_files(self, integrated_file_sizes, mode): From ba87f9bc09a14c30a0d9082c295d0803ba7f6219 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 29 Oct 2021 14:47:08 +0200 Subject: [PATCH 30/33] OP-1920 - added documentation --- website/docs/assets/site_sync_always_on.png | Bin 0 -> 27817 bytes website/docs/module_site_sync.md | 39 ++++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 website/docs/assets/site_sync_always_on.png diff --git a/website/docs/assets/site_sync_always_on.png b/website/docs/assets/site_sync_always_on.png new file mode 100644 index 0000000000000000000000000000000000000000..712adf173bc6049a2f55b7ff24d4d6cf09928ad3 GIT binary patch literal 27817 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 literal 0 HcmV?d00001 diff --git a/website/docs/module_site_sync.md b/website/docs/module_site_sync.md index b0604ed3cf..31854e2729 100644 --- a/website/docs/module_site_sync.md +++ b/website/docs/module_site_sync.md @@ -140,3 +140,42 @@ Beware that ssh key expects OpenSSH format (`.pem`) not a Putty format (`.ppk`)! If a studio needs to use other services for cloud storage, or want to implement totally different storage providers, they can do so by writing their own provider plugin. We're working on a developer documentation, however, for now we recommend looking at `abstract_provider.py`and `gdrive.py` inside `openpype/modules/sync_server/providers` and using it as a template. +### Running Site Sync in background + +Site Sync server synchronizes new published files from artist machine into configured remote location by default. + +There might be a use case where you need to synchronize between "non-artist" sites, for example between studio site and cloud. In this case +you need to run Site Sync as a background process from a command line (via service etc) 24/7. + +To configure all sites where all published files should be synced eventually you need to configure `project_settings/global/sync_server/config/always_accessible_on` property in Settins (per project) first. + +![Set another non artist remote site](assets/site_sync_always_on.png) + +This is an example of: +- Site Sync is enabled for a project +- default active and remote sites are set to `studio` - eg. standard process: everyone is working in a studio, publishing to shared location etc. +- (but this also allows any of the artists to work remotely, they would change their active site in their own Local Settings to `local` and configure local root. + This would result in everything artist publishes is saved first onto his local folder AND synchronized to `studio` site eventually.) +- everything exported must also be eventually uploaded to `sftp` site + +This eventual synchronization between `studio` and `sftp` sites must be physically handled by background process. + +As current implementation relies heavily on Settings and Local Settings, background process for a specific site ('studio' for example) must be configured via Tray first to `syncserver` command to work. + +To do this: + +- run OP `Tray` with environment variable SITE_SYNC_LOCAL_ID set to name of active (source) site. In most use cases it would be studio (for cases of backups of everything published to studio site to different cloud site etc.) +- start `Tray` +- check `Local ID` in information dialog after clicking on version number in the Tray +- open `Local Settings` in the `Tray` +- configure for each project necessary active site and remote site +- close `Tray` +- run OP from a command line with `syncserver` and `--active_site` arguments + + +This is an example how to trigger background synching process where active (source) site is `studio`. +(It is expected that OP is installed on a machine, `openpype_console` is on PATH. If not, add full path to executable. +) +```shell +openpype_console syncserver --active_site studio +``` \ No newline at end of file From 6f3944d01aa54d3f15c0d88ce7f0c8d617f96f50 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 29 Oct 2021 14:48:25 +0200 Subject: [PATCH 31/33] Hound --- .../default_modules/sync_server/sync_server_module.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/modules/default_modules/sync_server/sync_server_module.py b/openpype/modules/default_modules/sync_server/sync_server_module.py index d8a69b3b07..82c1dc178a 100644 --- a/openpype/modules/default_modules/sync_server/sync_server_module.py +++ b/openpype/modules/default_modules/sync_server/sync_server_module.py @@ -727,9 +727,9 @@ class SyncServerModule(OpenPypeModule, ITrayModule): self.enabled = False except KeyError: log.info(( - "There are not set presets for SyncServer OR " - "Credentials provided are invalid, " - "no syncing possible"). + "There are not set presets for SyncServer OR " + "Credentials provided are invalid, " + "no syncing possible"). format(str(self.sync_project_settings)), exc_info=True) self.enabled = False From de8cfeff7f996ee2d33ca0b235ea3c60ccc66ebb Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 29 Oct 2021 14:52:12 +0200 Subject: [PATCH 32/33] OP-1920 - renamed reset site method --- .../sync_server/sync_server_module.py | 28 +++++++++---------- .../sync_server/tray/widgets.py | 4 +-- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/openpype/modules/default_modules/sync_server/sync_server_module.py b/openpype/modules/default_modules/sync_server/sync_server_module.py index 82c1dc178a..1fee0b4676 100644 --- a/openpype/modules/default_modules/sync_server/sync_server_module.py +++ b/openpype/modules/default_modules/sync_server/sync_server_module.py @@ -146,9 +146,9 @@ class SyncServerModule(OpenPypeModule, ITrayModule): if not site_name: site_name = self.DEFAULT_SITE - self.reset_provider_for_file(collection, - representation_id, - site_name=site_name, force=force) + self.reset_site_on_representation(collection, + representation_id, + site_name=site_name, force=force) # public facing API def remove_site(self, collection, representation_id, site_name, @@ -170,10 +170,10 @@ class SyncServerModule(OpenPypeModule, ITrayModule): if not self.get_sync_project_setting(collection): raise ValueError("Project not configured") - self.reset_provider_for_file(collection, - representation_id, - site_name=site_name, - remove=True) + self.reset_site_on_representation(collection, + representation_id, + site_name=site_name, + remove=True) if remove_local_files: self._remove_local_file(collection, representation_id, site_name) @@ -209,8 +209,8 @@ class SyncServerModule(OpenPypeModule, ITrayModule): """ log.info("Pausing SyncServer for {}".format(representation_id)) self._paused_representations.add(representation_id) - self.reset_provider_for_file(collection, representation_id, - site_name=site_name, pause=True) + self.reset_site_on_representation(collection, representation_id, + site_name=site_name, pause=True) def unpause_representation(self, collection, representation_id, site_name): """ @@ -229,8 +229,8 @@ class SyncServerModule(OpenPypeModule, ITrayModule): except KeyError: pass # self.paused_representations is not persistent - self.reset_provider_for_file(collection, representation_id, - site_name=site_name, pause=False) + self.reset_site_on_representation(collection, representation_id, + site_name=site_name, pause=False) def is_representation_paused(self, representation_id, check_parents=False, project_name=None): @@ -1240,9 +1240,9 @@ class SyncServerModule(OpenPypeModule, ITrayModule): return -1, None - def reset_provider_for_file(self, collection, representation_id, - side=None, file_id=None, site_name=None, - remove=False, pause=None, force=False): + def reset_site_on_representation(self, collection, representation_id, + side=None, file_id=None, site_name=None, + remove=False, pause=None, force=False): """ Reset information about synchronization for particular 'file_id' and provider. diff --git a/openpype/modules/default_modules/sync_server/tray/widgets.py b/openpype/modules/default_modules/sync_server/tray/widgets.py index 45537c1c2e..b401411db5 100644 --- a/openpype/modules/default_modules/sync_server/tray/widgets.py +++ b/openpype/modules/default_modules/sync_server/tray/widgets.py @@ -411,7 +411,7 @@ class _SyncRepresentationWidget(QtWidgets.QWidget): format(check_progress)) continue - self.sync_server.reset_provider_for_file( + self.sync_server.reset_site_on_representation( self.model.project, representation_id, site_name=site_name, @@ -786,7 +786,7 @@ class SyncRepresentationDetailWidget(_SyncRepresentationWidget): format(check_progress)) continue - self.sync_server.reset_provider_for_file( + self.sync_server.reset_site_on_representation( self.model.project, self.representation_id, site_name=site_name, From 1772e7bf8d887c059d28ca923448e5fd925285b8 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 29 Oct 2021 17:15:47 +0200 Subject: [PATCH 33/33] OP-1905 - implemented exit on key interrupt --- .../modules/default_modules/sync_server/sync_server.py | 1 + .../default_modules/sync_server/sync_server_module.py | 1 + openpype/pype_commands.py | 10 +++++++++- 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/openpype/modules/default_modules/sync_server/sync_server.py b/openpype/modules/default_modules/sync_server/sync_server.py index 2227ec9366..48df5aad1b 100644 --- a/openpype/modules/default_modules/sync_server/sync_server.py +++ b/openpype/modules/default_modules/sync_server/sync_server.py @@ -246,6 +246,7 @@ class SyncServerThread(threading.Thread): asyncio.ensure_future(self.check_shutdown(), loop=self.loop) asyncio.ensure_future(self.sync_loop(), loop=self.loop) + log.info("Sync Server Started") self.loop.run_forever() except Exception: log.warning( diff --git a/openpype/modules/default_modules/sync_server/sync_server_module.py b/openpype/modules/default_modules/sync_server/sync_server_module.py index 1fee0b4676..281491eedf 100644 --- a/openpype/modules/default_modules/sync_server/sync_server_module.py +++ b/openpype/modules/default_modules/sync_server/sync_server_module.py @@ -771,6 +771,7 @@ class SyncServerModule(OpenPypeModule, ITrayModule): log.info("Stopping sync server server") self.sync_server_thread.is_running = False self.sync_server_thread.stop() + log.info("Sync server stopped") except Exception: log.warning( "Error has happened during Killing sync server", diff --git a/openpype/pype_commands.py b/openpype/pype_commands.py index cfa16012d0..e160db0f15 100644 --- a/openpype/pype_commands.py +++ b/openpype/pype_commands.py @@ -3,7 +3,6 @@ import os import sys import json -from datetime import datetime import time from openpype.lib import PypeLogger @@ -332,8 +331,17 @@ class PypeCommands: def syncserver(self, active_site): """Start running sync_server in background.""" + import signal os.environ["SITE_SYNC_LOCAL_ID"] = active_site + def signal_handler(sig, frame): + print("You pressed Ctrl+C. Process ended.") + sync_server_module.server_exit() + sys.exit(0) + + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + from openpype.modules import ModulesManager manager = ModulesManager()