From 1f38928b9079aefd3e40edf9ee96032e78a8eb76 Mon Sep 17 00:00:00 2001 From: manuel83 Date: Sun, 4 Nov 2018 00:47:26 +0100 Subject: [PATCH] Lots of changes --- .idea/workspace.xml | 906 +++++++++++------- Dockerfile | 14 + config/config.yaml | 10 +- test.py => config/plugin_list.yaml | 0 core/api/decorator.py | 14 +- core/controller/actor_controller.py | 32 +- .../plugin_controller.py} | 76 +- core/controller/system_controller.py | 2 +- core/craftbeerpi.py | 67 +- core/database/orm_framework.py | 6 +- core/eventbus.py | 2 +- core/extension/dummy/__init__.py | 10 +- core/extension/dummy/config.yaml | 3 +- core/http_endpoints/http_api.py | 5 +- core/http_endpoints/http_login.py | 10 +- core/mqtt/mqtt.py | 15 +- core/mqtt/mqtt_matcher.py | 8 +- core/test.db | Bin 49152 -> 0 bytes core/utils/__init__.py | 1 + core/utils/utils.py | 3 +- create_password.py | 6 - logs/first_logfile2.log | 38 +- logs/first_logfile2.log.2018-11-01_21-24 | 60 -- logs/first_logfile2.log.2018-11-01_21-25 | 60 -- logs/first_logfile2.log.2018-11-01_21-26 | 12 - logs/first_logfile2.log.2018-11-01_21-31 | 60 -- logs/first_logfile2.log.2018-11-01_21-32 | 60 -- logs/first_logfile2.log.2018-11-04_00-26 | 60 ++ logs/first_logfile2.log.2018-11-04_00-28 | 73 ++ logs/first_logfile2.log.2018-11-04_00-29 | 59 ++ logs/first_logfile2.log.2018-11-04_00-31 | 116 +++ logs/first_logfile2.log.2018-11-04_00-33 | 13 + main.py | 17 +- tests/test_app.py | 23 +- 34 files changed, 1143 insertions(+), 698 deletions(-) create mode 100644 Dockerfile rename test.py => config/plugin_list.yaml (100%) rename core/{plugin.py => controller/plugin_controller.py} (55%) delete mode 100644 core/test.db delete mode 100644 create_password.py delete mode 100644 logs/first_logfile2.log.2018-11-01_21-24 delete mode 100644 logs/first_logfile2.log.2018-11-01_21-25 delete mode 100644 logs/first_logfile2.log.2018-11-01_21-26 delete mode 100644 logs/first_logfile2.log.2018-11-01_21-31 delete mode 100644 logs/first_logfile2.log.2018-11-01_21-32 create mode 100644 logs/first_logfile2.log.2018-11-04_00-26 create mode 100644 logs/first_logfile2.log.2018-11-04_00-28 create mode 100644 logs/first_logfile2.log.2018-11-04_00-29 create mode 100644 logs/first_logfile2.log.2018-11-04_00-31 create mode 100644 logs/first_logfile2.log.2018-11-04_00-33 diff --git a/.idea/workspace.xml b/.idea/workspace.xml index 2a92031..65b9c38 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -2,7 +2,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + - - + + - - + + - + - - + + + + + + + + + + + + + + + + + + + + + + @@ -72,89 +130,70 @@ - - + + - - + + - - + + - - - - - - - - - - - - - - + + - - - - - - - - - - - - - - + + - - - - - - - - - - - - - + - - + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - + - + + + + + + + + @@ -171,25 +210,6 @@ - 7// - 7/ - 7 - / - /// - ///7 - ///77 - ///777 - ///7777 - ///7777/ - ///7777// - ///7777/// - ///7777///7 - ///7777///7| - ///7777///7|| - ///7777///7||| - ///7777///7|||6 - \n\n - \n\n cpre cpr cp @@ -199,6 +219,25 @@ .core from.cbpi. pr + call_initializer + cfg + print( + G + GETA + GET + GET A + GET AL + GET ALL + Create dat + C + CREATE DA + CREATE DAT + CREATE DATIN + CREATE DATINI + NCREATE DATINI + N + INIT + pINIT p print @@ -208,8 +247,8 @@ from core. - $PROJECT_DIR$ $PROJECT_DIR$/core + $PROJECT_DIR$ @@ -221,27 +260,33 @@ @@ -252,10 +297,10 @@ DEFINITION_ORDER - @@ -331,6 +376,20 @@ + + + + + @@ -572,6 +729,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -582,19 +765,19 @@ - + - + - - + + - - - - + + + + - + @@ -629,6 +812,9 @@ + + @@ -645,38 +831,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -706,10 +860,287 @@ - + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -722,195 +1153,28 @@ - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ae7a12c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3 + +WORKDIR /usr/src/app + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8080 + +CMD [ "python", "./run.py" ] + + diff --git a/config/config.yaml b/config/config.yaml index c1d358e..e7df109 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -1 +1,9 @@ -name: Manuel \ No newline at end of file + +name: CraftBeerPi +version: 4.1 + + +port: 8080 + +username: cbpi +password: 123 diff --git a/test.py b/config/plugin_list.yaml similarity index 100% rename from test.py rename to config/plugin_list.yaml diff --git a/core/api/decorator.py b/core/api/decorator.py index 14eb58f..7445830 100644 --- a/core/api/decorator.py +++ b/core/api/decorator.py @@ -74,9 +74,19 @@ def background_task(name, interval): return real_decorator +def on_startup(name, order=0): + def real_decorator(func): + func.on_startup = True + func.name = name + func.order = order + return func + + return real_decorator + + def entry_exit(f): def new_f(): - print("Entering", f.__name__) + f() - print("Exited", f.__name__) + return new_f \ No newline at end of file diff --git a/core/controller/actor_controller.py b/core/controller/actor_controller.py index 0cb1279..240827a 100644 --- a/core/controller/actor_controller.py +++ b/core/controller/actor_controller.py @@ -1,19 +1,20 @@ from aiohttp import web -from aiohttp_auth.auth.decorators import auth_required -from core.api.decorator import on_event, request_mapping +from core.api.decorator import on_event, request_mapping, on_startup from core.controller.crud_controller import CRUDController +from core.controller.plugin_controller import PluginController from core.database.model import ActorModel from core.http_endpoints.http_api import HttpAPI -from core.plugin import PluginAPI class ActorHttp(HttpAPI): + count = 0 + @request_mapping(path="/hallo", auth_required=False) async def hello_world(self, request) -> web.Response: - print("HALLO") - return web.Response(status=200, text="OK") + self.count = self.count + 1 + return web.Response(status=200, text=str(self.count)) @request_mapping(path="/{id}/on", auth_required=False) async def http_on(self, request) -> web.Response: @@ -27,7 +28,7 @@ class ActorHttp(HttpAPI): -class ActorController(ActorHttp, CRUDController, PluginAPI): +class ActorController(ActorHttp, CRUDController, PluginController): model = ActorModel @@ -50,12 +51,22 @@ class ActorController(ActorHttp, CRUDController, PluginAPI): if value.type in self.types: clazz = self.types[value.type]; self.actors[id] = clazz(self.cbpi) - print(value.type) - print("CACHE", self.cache) - print("ACTORS", self.actors) + + @on_startup(name="actor_init", order=2) + async def lets_go1(self): + pass + + @on_startup(name="actor_init", order=99) + async def lets_go2(self): + pass + + @on_startup(name="actor_init", order=-1) + async def lets_go(self): + pass + @on_event(topic="actor/+/on") def on(self, id, power=100, **kwargs) -> None: print("ON-------------", id, power) @@ -70,8 +81,7 @@ class ActorController(ActorHttp, CRUDController, PluginAPI): :param id: :param kwargs: """ - print("POWERED ON", id, kwargs) - + pass diff --git a/core/plugin.py b/core/controller/plugin_controller.py similarity index 55% rename from core/plugin.py rename to core/controller/plugin_controller.py index 4789dc3..a21d37e 100644 --- a/core/plugin.py +++ b/core/controller/plugin_controller.py @@ -1,9 +1,83 @@ +import logging +import os +from importlib import import_module from pprint import pprint +import aiohttp +import yaml +from aiohttp import web + +from core.api.decorator import request_mapping from core.api.property import Property +from core.utils.utils import load_config, json_dumps + +logger = logging.getLogger(__file__) +logging.basicConfig(level=logging.INFO) + +class PluginController(): + + modules = {} + + def __init__(self, cbpi): + + self.cbpi = cbpi + self.cbpi.register(self, "/plugin") + + @classmethod + async def load_plugin_list(self): + async with aiohttp.ClientSession() as session: + async with session.get('https://raw.githubusercontent.com/Manuel83/craftbeerpi-plugins/master/plugins.yaml') as resp: + + if(resp.status == 200): + + data = yaml.load(await resp.text()) + return data + + @classmethod + async def load_plugins(self): + + for filename in os.listdir("./core/extension"): + + if os.path.isdir("./core/extension/" + filename) is False or filename == "__pycache__": + continue + try: + + logger.info("Trying to load plugin %s" % filename) + + data = load_config("./core/extension/%s/config.yaml" % filename) + + + if(data.get("version") == 4): + + self.modules[filename] = import_module("core.extension.%s" % (filename)) + logger.info("Plugin %s loaded successful" % filename) + else: + logger.warning("Plguin %s is not supporting version 4" % filename) + + + + except Exception as e: + logger.error(e) + + @request_mapping(path="/", method="GET", auth_required=False) + async def get_plugins(self, request): + """ + --- + description: This end-point allow to test that service is up. + tags: + - Health check + produces: + - text/plain + responses: + "200": + description: successful operation. Return "pong" text + "405": + description: invalid HTTP Method + """ + return web.json_response(await self.load_plugin_list(), dumps=json_dumps) + -class PluginAPI(): def register(self, name, clazz) -> None: ''' Register a new actor type diff --git a/core/controller/system_controller.py b/core/controller/system_controller.py index 43a5c1f..c6ca36e 100644 --- a/core/controller/system_controller.py +++ b/core/controller/system_controller.py @@ -15,7 +15,7 @@ class SystemController(): @request_mapping("/jobs", method="GET", name="get_jobs", auth_required=True) def get_all_jobs(self, request): scheduler = get_scheduler_from_app(self.cbpi.app) - print(scheduler.active_count, scheduler.pending_limit) + for j in scheduler: print(j) diff --git a/core/craftbeerpi.py b/core/craftbeerpi.py index 442d691..33edc43 100644 --- a/core/craftbeerpi.py +++ b/core/craftbeerpi.py @@ -1,5 +1,4 @@ import asyncio -import importlib import logging from os import urandom @@ -12,11 +11,13 @@ from aiohttp_swagger import setup_swagger from aiojobs.aiohttp import setup, get_scheduler_from_app from core.controller.actor_controller import ActorController +from core.controller.plugin_controller import PluginController from core.controller.sensor_controller import SensorController from core.controller.system_controller import SystemController from core.database.model import DBModel from core.eventbus import EventBus from core.http_endpoints.http_login import Login +from core.utils import * from core.websocket import WebSocket logger = logging.getLogger(__file__) @@ -24,25 +25,29 @@ logging.basicConfig(level=logging.INFO) class CraftBeerPi(): + def __init__(self): + self.config = load_config("./config/config.yaml") logger.info("Init CraftBeerPI") policy = auth.SessionTktAuthentication(urandom(32), 60, include_ip=True) middlewares = [session_middleware(EncryptedCookieStorage(urandom(32))), auth.auth_middleware(policy)] self.app = web.Application(middlewares=middlewares) + self.initializer = [] setup(self.app) self.bus = EventBus() self.ws = WebSocket(self) self.actor = ActorController(self) self.sensor = SensorController(self) + self.plugin = PluginController(self) self.system = SystemController(self) + self.login = Login(self) def register_events(self, obj): for method in [getattr(obj, f) for f in dir(obj) if callable(getattr(obj, f)) and hasattr(getattr(obj, f), "eventbus")]: - print(method.__getattribute__("topic"), method) doc = None if method.__doc__ is not None: @@ -68,6 +73,18 @@ class CraftBeerPi(): self.app.on_startup.append(spawn_job) + + def register_on_startup(self, obj): + + for method in [getattr(obj, f) for f in dir(obj) if callable(getattr(obj, f)) and hasattr(getattr(obj, f), "on_startup")]: + + name = method.__getattribute__("name") + order = method.__getattribute__("order") + + self.initializer.append(dict(name=name, method=method, order=order)) + + + def register_ws(self, obj): if self.ws is None: return @@ -80,6 +97,7 @@ class CraftBeerPi(): self.register_events(obj) self.register_ws(obj) self.register_background_task(obj) + self.register_on_startup(obj) def register_http_endpoints(self, obj, subapp=None): routes = [] @@ -117,24 +135,51 @@ class CraftBeerPi(): else: self.app.add_routes(routes) - async def _load_extensions(self, app): - extension_list = ["core.extension.dummy"] + def _swagger_setup(self): + + long_description = """ + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus vehicula, metus et sodales fringilla, purus leo aliquet odio, non tempor ante urna aliquet nibh. Integer accumsan laoreet tincidunt. Vestibulum semper vehicula sollicitudin. Suspendisse dapibus neque vitae mattis bibendum. Morbi eu pulvinar turpis, quis malesuada ex. Vestibulum sed maximus diam. Proin semper fermentum suscipit. Duis at suscipit diam. Integer in augue elementum, auctor orci ac, elementum est. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Maecenas condimentum id arcu quis volutpat. Vestibulum sit amet nibh sodales, iaculis nibh eget, scelerisque justo. + + Nunc eget mauris lectus. Proin sit amet volutpat risus. Aliquam auctor nunc sit amet feugiat tempus. Maecenas nec ex dolor. Nam fermentum, mauris ut suscipit varius, odio purus luctus mauris, pretium interdum felis sem vel est. Proin a turpis vitae nunc volutpat tristique ac in erat. Pellentesque consequat rhoncus libero, ac sollicitudin odio tempus a. Sed vestibulum leo erat, ut auctor turpis mollis id. Ut nec nunc ex. Maecenas eu turpis in nibh placerat ullamcorper ac nec dui. Integer ac lacus neque. Donec dictum tellus lacus, a vulputate justo venenatis at. Morbi malesuada tellus quis orci aliquet, at vulputate lacus imperdiet. Nulla eu diam quis orci aliquam vulputate ac imperdiet elit. Quisque varius mollis dolor in interdum. + """ + + setup_swagger(self.app, + description=long_description, + title=self.config.get("name", "CraftBeerPi"), + api_version=self.config.get("version", ""), + contact="info@craftbeerpi.com") + - for extension in extension_list: - logger.info("LOADING PUGIN %s" % extension) - my_module = importlib.import_module(extension) - my_module.setup(self) def start(self): + from pyfiglet import Figlet + f = Figlet(font='big') + print(f.renderText("%s %s" % (self.config.get("name"), self.config.get("version")))) + + # self.cache["init"] = sorted(self.cache["init"], key=lambda k: k['order']) async def init_database(app): await DBModel.test_connection() async def init_controller(app): await self.actor.init() + async def load_plugins(app): + await PluginController.load_plugin_list() + await PluginController.load_plugins() + + async def call_initializer(app): + + self.initializer = sorted(self.initializer, key=lambda k: k['order']) + for i in self.initializer: + logger.info("CALL INITIALIZER %s - %s " % (i["name"], i["method"].__name__)) + + await i["method"]() + + self.app.on_startup.append(init_database) - self.app.on_startup.append(self._load_extensions) + self.app.on_startup.append(call_initializer) self.app.on_startup.append(init_controller) - setup_swagger(self.app) - web.run_app(self.app) + self.app.on_startup.append(load_plugins) + self._swagger_setup() + web.run_app(self.app, port=self.config.get("port", 8080)) diff --git a/core/database/orm_framework.py b/core/database/orm_framework.py index 11500eb..b5ccbc9 100644 --- a/core/database/orm_framework.py +++ b/core/database/orm_framework.py @@ -33,7 +33,7 @@ class DBModel(object): @classmethod async def test_connection(self): - print("CREATE DATABSE") + async with aiosqlite.connect(TEST_DB) as db: assert isinstance(db, aiosqlite.Connection) @@ -42,7 +42,7 @@ class DBModel(object): @classmethod async def get_all(cls): - print("GET ALL") + if cls.__as_array__ is True: result = [] else: @@ -113,7 +113,7 @@ class DBModel(object): else: data = data + (kwargs.get(f),) - print(query, data) + cursor = await db.execute(query, data) await db.commit() diff --git a/core/eventbus.py b/core/eventbus.py index 5935bab..06be95a 100644 --- a/core/eventbus.py +++ b/core/eventbus.py @@ -64,7 +64,7 @@ class EventBus(object): self.logger.info("EMIT EVENT %s", event) for methods in self.iter_match(event): for f in methods: - print("METHOD: ", f) + f(**kwargs) def iter_match(self, topic): diff --git a/core/extension/dummy/__init__.py b/core/extension/dummy/__init__.py index 242015b..bfb8d9f 100644 --- a/core/extension/dummy/__init__.py +++ b/core/extension/dummy/__init__.py @@ -1,10 +1,8 @@ -from core.database.model import ActorModel -from core.api.decorator import action, background_task -from core.api.property import Property - +import logging from core.api.actor import CBPiActor -import logging +from core.api.decorator import action, background_task +from core.api.property import Property class MyActor(CBPiActor): @@ -36,7 +34,7 @@ class MyActor(CBPiActor): return self.cfg = self.load_config() - print(self.cfg) + self.logger = logging.getLogger(__file__) logging.basicConfig(level=logging.INFO) diff --git a/core/extension/dummy/config.yaml b/core/extension/dummy/config.yaml index a356541..0029445 100644 --- a/core/extension/dummy/config.yaml +++ b/core/extension/dummy/config.yaml @@ -1 +1,2 @@ -name: Maneul \ No newline at end of file +name: Manuel +version: 4 \ No newline at end of file diff --git a/core/http_endpoints/http_api.py b/core/http_endpoints/http_api.py index 47c467d..1a9d424 100644 --- a/core/http_endpoints/http_api.py +++ b/core/http_endpoints/http_api.py @@ -1,15 +1,15 @@ import logging + from aiohttp import web -from aiojobs.aiohttp import get_scheduler_from_app from core.api.decorator import request_mapping from core.utils.utils import json_dumps + class HttpAPI(): def __init__(self,cbpi): self.logger = logging.getLogger(__name__) - self.logger.info("WOOHOO MY ACTOR") self.cbpi =cbpi @request_mapping(path="/", auth_required=False) @@ -21,7 +21,6 @@ class HttpAPI(): id = int(request.match_info['id']) return web.json_response(await self.get_one(id), dumps=json_dumps) - @request_mapping(path="/{id}'", method="POST", auth_required=False) async def http_add_one(self, request): id = request.match_info['id'] diff --git a/core/http_endpoints/http_login.py b/core/http_endpoints/http_login.py index 06a53c4..5ba9c7f 100644 --- a/core/http_endpoints/http_login.py +++ b/core/http_endpoints/http_login.py @@ -1,10 +1,7 @@ -import pathlib - from aiohttp import web from aiohttp_auth import auth from core.api.decorator import request_mapping -from core.utils.utils import load_config class Login(): @@ -12,10 +9,9 @@ class Login(): def __init__(self,cbpi): self.cbpi = cbpi self.cbpi.register(self) - cfg = load_config(str(pathlib.Path('.') / 'config' / 'config.yaml')) - print("######", cfg) - self.db = {'user': 'password', 'super_user': 'super_password'} + + self.db = {cbpi.config.get("username", "cbpi"): cbpi.config.get("password", "cbpi")} @request_mapping(path="/logout", name="Logout", method="GET", auth_required=True) async def logout_view(self, request): @@ -26,7 +22,7 @@ class Login(): async def login_view(self, request): params = await request.post() - print(params.get('username', None), params.get('password', None)) + user = params.get('username', None) if (user in self.db and params.get('password', None) == self.db[user]): diff --git a/core/mqtt/mqtt.py b/core/mqtt/mqtt.py index 7b8b717..f17bc6a 100644 --- a/core/mqtt/mqtt.py +++ b/core/mqtt/mqtt.py @@ -1,11 +1,10 @@ from aiojobs.aiohttp import get_scheduler_from_app +from core.mqtt_matcher import MQTTMatcher from hbmqtt.broker import Broker from hbmqtt.client import MQTTClient from hbmqtt.mqtt.constants import QOS_1, QOS_0 from typing import Callable -from core.mqtt_matcher import MQTTMatcher - class MQTT(): def __init__(self,cbpi): @@ -44,14 +43,14 @@ class MQTT(): def sysmsg(self, msg): - print("SYS", msg) + pass def ok_msg(self, msg): self.count = self.count + 1 - print("MSFG", msg, self.count) + def publish(self, topic, message): - print("PUSH NOW", topic) + self.cbpi.app.loop.create_task(self.client.publish(topic, str.encode(message), QOS_0)) def register_callback(self, func: Callable, topic) -> None: @@ -64,12 +63,12 @@ class MQTT(): message = await self.client.deliver_message() matched = False packet = message.publish_packet - print(message.topic) + #print(message.topic.split('/')) data = packet.payload.data.decode("utf-8") for callback in self.matcher.iter_match(message.topic): - print("MATCH") + callback(data) matched = True @@ -84,7 +83,7 @@ class MQTT(): # await self.client.connect('mqtt://broker.hivemq.com:1883') for k, v in self.mqtt_methods.items(): - print("############MQTT Subscribe:", k, v) + await self.client.subscribe([(k, QOS_1)]) self.matcher[k] = v await get_scheduler_from_app(app).spawn(self.on_message()) diff --git a/core/mqtt/mqtt_matcher.py b/core/mqtt/mqtt_matcher.py index 51bb22f..08a1914 100644 --- a/core/mqtt/mqtt_matcher.py +++ b/core/mqtt/mqtt_matcher.py @@ -38,7 +38,7 @@ class MQTTMatcher(object): parent, node = node, node._children[k] lst.append((parent, k, node)) # TODO - print(node._content) + if method is not None: node._content = None else: @@ -58,9 +58,9 @@ class MQTTMatcher(object): print("...",key, value) node = self._root for sym in key.split('/'): - print(sym) + node = node._children.setdefault(sym, self.Node()) - print(node) + if not isinstance(node._content, list): #print("new array") node._content = [] @@ -155,7 +155,7 @@ if __name__ == "__main__": m.register("actor/1/on", test_name) m.register("actor/1/on", test_name) - print(m.get_callbacks("actor/1/on")) + m.unregister("actor/1/on") diff --git a/core/test.db b/core/test.db deleted file mode 100644 index c6756e40779a0be9a8ed8359db52a48779f52b52..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 49152 zcmeI((NEh(9Kdm#5&{OA(h0c{nrf97qE#&+LfhM#w$X|fU`^6(s1K1zt|21WEIC#X z;(?4mVSh}e{Q>(E`mldtf5gr{;>01TgtqP>`Wo3ue&_h^_qjWZ3!(hF*p$L~=eLhs z>Ew)ihG`nl9LF$>wAvTc-rMHXlj+_I^=J+}ANMqEJW1pi7iNw5?tV+iw(E6Tu59|=z|;8` zr9!?^a4Pxd#ey?XV69n>+FGvHIB^@ITJ!6|`7vL5@hV?h%VnIM-HNmGrdWI&o^~kC zqQY<1hfAe6l~Hka{YLo@rLFCJY0vq-u;;ABYJBXdD`%|C=KPGkwQ3qouP#nIzqHgZ zBvtoB`r+&9K(njaffozi=W|JWdD*;bXad(elxnx)Ne2;~ZL{ud?Nkci6-v%vo8q~8 z?onStC94JY+v5G`l8*2?emlxPdWVj0j4R09EI1IZ6#Xk@dv&+`Mza>3zS#@ceD7Ve z5ycgJzr=5ix{!3+4I!&i93A%{tW0-#RwFKkh#e`8gMBWB7({M^_{eY9Mf*{&7Hv4% zPo5q3y@S!CPuA^FAW?!Fo10PDU_9H8wq?9V;YB^JuR3JbzvQ8ivL*Th#L8Sgn@QT~ zwAp7@PisrlQN zA!FU`9PImUyRPoEo)n(E5lF;vZxr9gxxU{Y$ZL_OXo(|LakXEj^NEeO=!2 zcXlJ9M^4mpx!?|DW#pZt{qUiA`E@+;D5L4oiQXo`;eli+@0BS%U6;aY_1#w=@5Cz9 zv42zBr`Sf4sMb9&leTS}-Bj2*x2A?%c$kW{Ee!q7O^b?k5sP+Fsgry%W)Tl`j@|&o5M+KaYsL91&Yru`i|) zhP|>>5goa)eU=YCa5%rXZzhao9gJdw>;KQ47uQDs0R#|0009ILKmY**5I_Kd|5PAY z|F76fL0EH9M{JLTO{wo)ip&M$)XzxvWY8v+O*fB*srAb