PK!hpsolenoid/__init__.py__version__ = '0.2.0'PK!#_11solenoid/app.pyfrom .config import ServiceConfig from .eureka import EurekaClient from flask import Flask from flask_cors import CORS from .solenoid import Solenoid import time import datetime from collections import deque import logging import signal import sys class Trace: def __init__(self, req, sess): self.timestamp = datetime.datetime.now() self.t1 = time.time() self.t2 = self.t1 self.path = req.path self.principal = req.remote_user self.ipaddr = req.remote_addr self.scheme = req.scheme self.referrer = req.referrer self.method = req.method self.req_headers = {'Accept': req.content_type} self.url = req.url self.response = None self.status = None self.resp_headers = None self.session_id = sess.sid if hasattr(sess, 'sid') else None def _req_header(self, req): if 'Authorization' in req.headers: self.req_headers['Authorization'] = req.headers['Authorization'] def complete(self, resp): self.t2 = time.time() self.status = resp.status_code self.resp_headers = {k:v for k,v in resp.headers.items()} def complete_exc(self): self.t2 = time.time() self.status = 500 self.resp_headers = {} def render(self): trace = { 'timestamp': self.timestamp.isoformat('T'), 'request': { 'method': self.method, 'uri': self.url, 'headers': self.req_headers }, 'response': { 'status': self.status, 'headers': self.resp_headers }, 'timeTaken': self.t2-self.t1 } if self.principal is not None: trace['principal'] = {'name': self.principal} if self.session_id is not None: trace['session'] = {'id': self.session_id} return trace class SolenoidFlaskApp: def __init__(self, config_file: str, cors=True): self.log = logging.getLogger(__name__) self.traces = deque(maxlen=100) self.config = ServiceConfig() self.config.load_config(config_file) self.app = Flask(__name__) if cors: CORS(self.app) self.client = EurekaClient(self.config) self.solenoid = Solenoid(self.config, self.app, self.traces) self.heartbeat = None def register_service(self): self.client.register() def start_heartbeat(self): from threading import Timer class Heartbeat(Timer): def run(self): while not self.finished.is_set(): self.function(*self.args, **self.kwargs) self.finished.wait(self.interval) self.heartbeat = Heartbeat(30.0, self.client.heartbeat) self.heartbeat.setDaemon(True) self.heartbeat.start() # every 30 seconds, call heartbeat def run(self): self.app.run('0.0.0.0', self.config.get_port()) def route(self, rule, **options): def decorator(f): endpoint = options.pop('endpoint', None) self.app.add_url_rule(rule, endpoint, f, **options) return f return decorator def trace(self, rule, **options): def decorator(f): def wrapped_f(*args): from flask import request, session self.log.debug(f'Beginning HTTP trace for {request.path}') t = Trace(request, session) try: resp = f(*args) except Exception: self.log.exception(f'Exception during HTTP trace for {request.path}') t.complete_exc() self.traces.append(t) raise t.complete(resp) self.traces.append(t) self.log.debug(f'Finished HTTP trace for {request.path} -> status code:') return resp endpoint = options.pop('endpoint', None) self.app.add_url_rule(rule, endpoint, wrapped_f, **options) return wrapped_f return decorator PK!W#)#)solenoid/config.pyimport json, os.path, yaml import logging from typing import Dict def _url_or_path(url): if url is None: return None elif url.startsWith('http'): return url[url.index('/', 8):] return url class ConfigError(Exception): pass class DataCenterInfo: def __init__(self, cls, name): self.cls = cls self.name = name def render(self): return { '@class': self.cls, 'name': self.name } myOwnDC = DataCenterInfo('com.netflix.appinfo.InstanceInfo$DefaultDataCenterInfo', 'MyOwn') class Port: def __init__(self, port: int, enabled: bool): self.port = port self.enabled = enabled def render(self): return { '$': self.port, '@enabled': self.enabled } class ServiceMetadata: def __init__(self, instanceId: str, hostName: str, app: str, vipAddress: str, secureVipAddress: str, ipAddr: str, status: str, port: Port, securePort: Port, healthCheckUrl: str = None, healthCheckPath: str=None, statusPageUrl: str = None, statusPagePath: str=None, homePageUrl: str = None, homePagePath: str=None, dataCenterInfo: DataCenterInfo = myOwnDC): self.instanceId = instanceId self.hostName = hostName self.app = app self.vipAddress = vipAddress self.secureVipAddress = secureVipAddress self.ipAddr = ipAddr self.status = status self.port = port self.securePort = securePort self.healthCheckUrl = healthCheckUrl self.healthCheckPath = healthCheckPath is not None if healthCheckPath else _url_or_path(healthCheckUrl) self.statusPageUrl = statusPageUrl self.statusPagePath = statusPagePath is not None if statusPageUrl else _url_or_path(statusPageUrl) self.homePageUrl = homePageUrl self.homePagePath = homePagePath is not None if homePagePath else _url_or_path(homePageUrl) self.dataCenterInfo = dataCenterInfo def render(self): return { 'instance': { 'instanceId': self.instanceId, 'hostName': self.hostName, 'app': self.app, 'ipAddr': self.ipAddr, 'vipAddress': self.vipAddress, 'secureVipAddress': self.secureVipAddress, 'status': self.status, 'port': self.port.render(), 'securePort': self.securePort.render(), 'statusPageUrl': self.statusPageUrl, 'healthCheckUrl': self.healthCheckUrl, 'homePageUrl': self.healthCheckUrl, 'dataCenterInfo': self.dataCenterInfo.render() } } def render_json(self): return json.dumps(self.render()) class DiscoveryServer: def __init__(self, hostName: str, port: int, ssl: bool, servicePath: str): self.hostName = hostName self.port = port self.ssl = ssl self.servicePath = servicePath class ClientOptions: def __init__(self, requestImpl: str, maxRetries: int = 3, heartBeatIntervalInSecs: int = 30000, registryFetchIntervalInSecs: int = 30000, registerWithEureka: bool = True): self.requestImpl = requestImpl self.maxRetries = maxRetries self.heartBeatIntervalInSecs = heartBeatIntervalInSecs self.registryFetchIntervalInSecs = registryFetchIntervalInSecs self.registerWithEureka = registerWithEureka class ServiceConfig: def __init__(self, serviceMetadata: ServiceMetadata = None, discoveryServer: DiscoveryServer = None, options: ClientOptions = None): self.log = logging.getLogger(__name__) self.serviceMetadata = serviceMetadata self.discoveryServer = discoveryServer self.clientOptions = options self.fileConfig = None self.configfile = None def load_config(self, config_file): self.configfile = config_file if os.path.exists(config_file): self.log.debug(f'Loading Eureka configuration file: {config_file}') with open(config_file, 'rt') as f: try: self.fileConfig = yaml.safe_load(f.read()) except yaml.YAMLError as exc: self.log.exception(f'Error loading Eureka configuration file: {config_file}') raise self.log.debug(self.fileConfig) else: raise FileNotFoundError(f'Could not load config file: {config_file}') def get_option(self, option): if self.fileConfig is not None: try: return self.fileConfig['options'][option] except KeyError: self.log.exception(f'could not find option:{option} in configuration') raise ConfigError(f'could not find option:{option} in configuration') return getattr(self.clientOptions, option) def get_app(self): if self.fileConfig is not None: try: return self.fileConfig['instance']['app'] except KeyError: self.log.exception('could not find app in configuration') raise ConfigError('could not find app in configuration') return self.serviceMetadata.app def get_instance_id(self): if self.fileConfig is not None: try: return self.fileConfig['instance']['instanceId'] except KeyError: self.log.exception('could not find instanceId in configuration') raise ConfigError('could not find instanceId in configuration') return self.serviceMetadata.instanceId def get_service_metadata(self) -> Dict: if self.fileConfig is not None: try: return {'instance': self.fileConfig['instance']} except KeyError: self.log.exception('instance block missing from configuration') raise ConfigError('instance block missing from configuration') return self.serviceMetadata.render() def get_service_metadata_json(self) -> str: try: return json.dumps(self.get_service_metadata()) except Exception as exc: self.log.exception('Error serialising metadata to JSON') raise exc def get_eureka_server_url(self) -> str: if self.fileConfig is not None: try: host = self.fileConfig['eureka']['host'] port = self.fileConfig['eureka']['port'] ssl = self.fileConfig['eureka']['ssl'] servicePath = self.fileConfig['eureka']['servicePath'] except KeyError as exc: self.log.exception('Eureka server block misconfigured') raise ConfigError('Eureka server block misconfigured') else: host = self.discoveryServer.hostName port = self.discoveryServer.port ssl = self.discoveryServer.ssl servicePath = self.discoveryServer.servicePath proto = 'https' if ssl else 'http' return f'{proto}://{host}:{port}{servicePath}' def get_app_url(self, app: str=None): if app is not None: return f'{self.get_eureka_server_url()}/{app}' return f'{self.get_eureka_server_url()}/{self.get_app()}' def get_instance_url(self, app: str=None, instance_id: str=None): if app is not None and instance_id is not None: return f'{self.get_eureka_server_url()}/{app}/{instance_id}' return f'{self.get_eureka_server_url()}/{self.get_app()}/{self.get_instance_id()}' def get_status_url(self, app: str=None, instance_id: str=None): if app is not None and instance_id is not None: return f'{self.get_eureka_server_url()}/{app}/{instance_id}/status' return f'{self.get_eureka_server_url()}/{self.get_app()}/{self.get_instance_id()}/status' def get_metadata_update_url(self, app: str=None, instance_id: str=None): if app is not None and instance_id is not None: return f'{self.get_eureka_server_url()}/{app}/{instance_id}/metadata' return f'{self.get_eureka_server_url()}/{self.get_app()}/{self.get_instance_id()}/metadata' def get_home_page_path(self): if self.fileConfig is not None: try: return self.fileConfig['instance']['homePagePath'] except KeyError: self.log.exception('could not find homePagePath in configuration') raise ConfigError('could not find homePagePath in configuration') return self.serviceMetadata.homePagePath def get_health_check_path(self): if self.fileConfig is not None: try: return self.fileConfig['instance']['healthCheckPath'] except KeyError: self.log.exception('could not find healthCheckPath in configuration') raise ConfigError('could not find healthCheckPath in configuration') return self.serviceMetadata.healthCheckPath def get_status_page_path(self): if self.fileConfig is not None: try: return self.fileConfig['instance']['statusPagePath'] except KeyError: self.log.exception('could not find statusPagePath in configuration') raise ConfigError('could not find statusPagePath in configuration') return self.serviceMetadata.statusPagePath def get_host_url(self): if self.fileConfig is not None: try: hostname = self.fileConfig['instance']['hostName'] port = self.fileConfig['instance']['port']['$'] except KeyError: self.log.exception('could not find host or port in configuration') raise ConfigError('could not find host or port in configuration') else: hostname = self.serviceMetadata.hostName port = self.serviceMetadata.port return f'http://{hostname}:{port}' def get_port(self): if self.fileConfig is not None: try: return self.fileConfig['instance']['port']['$'] except KeyError: self.log.exception('could not find port in configuration') raise ConfigError('could not find port in configuration') return self.serviceMetadata.port PK!ଋ7  solenoid/eureka.pyfrom solenoid.config import ServiceConfig import requests from requests.exceptions import RequestException from requests.adapters import HTTPAdapter from requests.packages.urllib3.util.retry import Retry import logging class EurekaClientError(Exception): pass def requests_retry_session( retries=3, backoff_factor=0.3, status_forcelist=(500, 502, 504), session=None, ): session = session or requests.Session() retry = Retry( total=retries, read=retries, connect=retries, backoff_factor=backoff_factor, status_forcelist=status_forcelist, ) adapter = HTTPAdapter(max_retries=retry) session.mount('http://', adapter) session.mount('https://', adapter) return session class EurekaClient: def __init__(self, config: ServiceConfig): self.config = config self.session = requests_retry_session(config.clientOptions) self.log = logging.getLogger(__name__) def register(self): self.log.info(f'Registering {self.config.get_app()} with {self.config.get_app_url()}') try: res = self.session.post(self.config.get_app_url(), json=self.config.get_service_metadata()) except RequestException: self.log.exception(f'Error registering with Eureka server {self.config.get_eureka_server_url()}') raise self.log.info(f'Response code for registration: {res.status_code}') if res.status_code == 204: self.log.info(f'successfully registered {self.config.get_app()}') return True self.log.error(f'Failed to register: {res.status_code}') raise EurekaClientError(f'Failed to register: {res.status_code}') def deregister(self): self.log.info(f'Deregistering {self.config.get_app()} with {self.config.get_instance_url()}') try: res = self.session.delete(self.config.get_instance_url()) except RequestException: self.log.exception(f'Error deregistering with Eureka server {self.config.get_eureka_server_url()}') raise self.log.info(f'Response code for deregistration: {res.status_code}') if res.status_code == 200: self.log.info(f'successfully deregistered {self.config.get_app()}') return True self.log.error(f'Failed to deregister: {res.status_code}') raise EurekaClientError(f'Failed to deregister: {res.status_code}') def heartbeat(self): self.log.info(f'Sending heartbeat for {self.config.get_app()} with {self.config.get_instance_url()}') try: res = self.session.put(self.config.get_instance_url(), params={'status': 'UP'}) except RequestException: self.log.exception(f'Error sending heartbeat to Eureka server {self.config.get_eureka_server_url()}') raise self.log.info(f'Response code for heartbeat: {res.status_code}') if res.status_code == 200: self.log.info(f'successful heartbeat for {self.config.get_instance_id()}') return True elif res.status_code == 404: self.log.error(f'instance {self.config.get_instance_id()} does not exist') raise EurekaClientError(f'instance {self.config.get_instance_id()} does not exist') self.log.error(f'Failed send heartbeat: {res.status_code}') raise EurekaClientError(f'Failed to send heartbeat: {res.status_code}') def get_all_instances(self, app: str=None): self.log.info(f'Retrieving all instances of {self.config.get_app()} with {self.config.get_app_url(app)}') try: res = self.session.get(self.config.get_app_url(app)) except RequestException: self.log.exception(f'Error retrieving instances with Eureka server {self.config.get_eureka_server_url()}') raise self.log.info(f'Response code for registration: {res.status_code}') if res.status_code == 200: self.log.info(f'successfully retrieved instances for {self.config.get_app() if app is None else app}') return res.json() self.log.error(f'Failed to retrieve instances for {self.config.get_app() if app is None else app}: {res.status_code}') raise EurekaClientError(f'Failed to retrieve instances for {self.config.get_app() if app is None else app}: {res.status_code}') def get_app_instance(self, app: str=None, instance_id: str=None): self.log.info(f'Retrieving instance of {self.config.get_app()} with {self.config.get_instance_url(app, instance_id)}') try: res = self.session.get(self.config.get_instance_url(app, instance_id)) except RequestException: self.log.exception(f'Error retrieving instance with Eureka server {self.config.get_eureka_server_url()}') raise self.log.info(f'Response code for registration: {res.status_code}') if res.status_code == 200: self.log.info(f'successfully retrieved instance for {self.config.get_app() if app is None else app}:' f'{self.config.get_instance_id() if instance_id is None else instance_id}') return res.json() self.log.error(f'Failed to retrieve instance for {self.config.get_app() if app is None else app}:' f'{self.config.get_instance_id() if instance_id is None else instance_id}') raise EurekaClientError(f'Failed to retrieve instance for {self.config.get_app() if app is None else app}:' f'{self.config.get_instance_id() if instance_id is None else instance_id}') def out_of_service(self): self.log.info(f'Taking instance out of service for {self.config.get_app()} with {self.config.get_instance_url()}') try: res = self.session.put(self.config.get_status_url(), params={'value': 'OUT_OF_SERVICE'}) except RequestException: self.log.exception(f'Error taking instance out of service with Eureka server {self.config.get_eureka_server_url()}') raise self.log.info(f'Response code for out of service request: {res.status_code}') if res.status_code == 200: self.log.info(f'successfully out of service {self.config.get_instance_id()}') return True self.log.error(f'Failed to take instance out of service: {res.status_code}') raise EurekaClientError(f'Failed to take instance out of service: {res.status_code}') def back_in_service(self): self.log.info(f'Taking instance out of service for {self.config.get_app()} with {self.config.get_instance_url()}') try: res = self.session.delete(self.config.get_status_url()) except RequestException: self.log.exception(f'Error taking instance out of service with Eureka server {self.config.get_eureka_server_url()}') raise self.log.info(f'Response code for out of service request: {res.status_code}') if res.status_code == 200: self.log.info(f'successfully out of service {self.config.get_instance_id()}') return True self.log.error(f'Failed to take instance out of service: {res.status_code}') raise EurekaClientError(f'Failed to take instance out of service: {res.status_code}') def update_metadata(self, key, value): self.log.info(f'Updating instance metadata {key}={value} for {self.config.get_app()} with {self.config.get_instance_url()}') try: res = self.session.put(self.config.get_metadata_update_url(), params={key: value}) except RequestException: self.log.exception(f'Error updating instance metadata with Eureka server {self.config.get_eureka_server_url()}') raise self.log.info(f'Response code for out of service request: {res.status_code}') if res.status_code == 200: self.log.info(f'successfully updated metadata for {self.config.get_instance_id()}') return True self.log.error(f'Failed to update metadata for service: {res.status_code}') raise EurekaClientError(f'Failed to update metadata for service: {res.status_code}') PK!-D((solenoid/solenoid.pyfrom flask import Flask, Response, make_response from flask.json import dumps, request from .solenoids import log, health, runtime from .config import ServiceConfig import logging from collections import deque CONTENT_TYPE = 'application/vnd.spring-boot.actuator.v2+json;charset=UTF-8' def shutdown_server(): ''' Function to gracefully shutdown server. Only works with DEV Flask server :return: None ''' func = request.environ.get('werkzeug.server.shutdown') if func is None: raise RuntimeError('Not running with the Werkzeug Server') func() class Solenoid: def __init__(self, config: ServiceConfig, app: Flask, httptraces: deque): self.app = app self.config = config self.traces = httptraces self.log = logging.getLogger(__name__) @app.route('/actuator') def actuator(): return Response(dumps( { '_links': { 'self': f'{self.config.get_host_url()}/actuator', 'templated': False }, 'health': { 'self': f'{self.config.get_host_url()}{self.config.get_health_check_path()}', 'templated': False }, 'info': { 'self': f'{self.config.get_host_url()}{self.config.get_status_page_path()}', 'templated': False }, 'logfile': { 'self': f'{self.config.get_host_url()}/actuator/logfile', 'templated': False }, 'loggers': { 'self': f'{self.config.get_host_url()}/actuator/loggers', 'templated': False }, 'env': { 'self': f'{self.config.get_host_url()}/actuator/env', 'templated': False }, 'shutdown': { 'self': f'{self.config.get_host_url()}/actuator/shutdown', 'templated': False } } ), mimetype=CONTENT_TYPE) @app.route(self.config.get_status_page_path()) def status(): return Response(dumps({'status':'UP'}), mimetype=CONTENT_TYPE) @app.route(self.config.get_health_check_path()) def health_check(): return Response(dumps({'status':'UP', 'details': {'diskSpace': health.get_disk_health('/')}}), mimetype=CONTENT_TYPE) @app.route('/actuator/logfile') def logfile(): l = log.get_log() if l is None: print('No log file configured!') return Response(l, mimetype='text/plain') @app.route('/actuator/loggers') def loggers(): return Response(dumps(log.get_loggers()), mimetype=CONTENT_TYPE) @app.route('/actuator/loggers/', methods=['POST']) def set_logger_level(logger): self.log.info(f'Setting log level for {logger} to {request.json["configuredLevel"]}') log.set_logger_level(logger, request.json['configuredLevel']) return make_response(('', 204)) @app.route('/actuator/env') def env(): return Response(dumps(runtime.get_runtime(self.config)), mimetype=CONTENT_TYPE) @app.route('/actuator/shutdown', methods=['POST']) def shutdown(): shutdown_server() return Response(dumps({'message' : 'Shutting down, bye...'}), mimetype=CONTENT_TYPE) @app.route('/actuator/httptrace') def httptrace(): traces = { 'traces': [t.render() for t in self.traces] } return Response(dumps(traces), mimetype=CONTENT_TYPE) PK!solenoid/solenoids/__init__.pyPK!շDDsolenoid/solenoids/health.pyimport os THRESHOLD = 10485760 def get_disk_health(path): res = os.statvfs(path) if res.f_frsize*res.f_bavail < THRESHOLD: return { 'status': 'DOWN', 'details': { 'total': res.f_frsize * res.f_blocks, 'free': res.f_frsize * res.f_bavail, 'threshold': THRESHOLD } } return { 'status': 'UP', 'details': { 'total': res.f_frsize*res.f_blocks, 'free': res.f_frsize*res.f_bavail, 'threshold': THRESHOLD } } PK!msolenoid/solenoids/info.pydef get_info(module): name = module.__name__[module.__name__.rfind('.'):] group = module.__name__[:module.__name__.rfind('.')] return { 'build': { 'version': module.__version__, 'artifact': name, 'group': group } }PK!k, , solenoid/solenoids/log.pyimport logging from typing import Dict levels = [ 'OFF', 'ERROR', 'WARN', 'INFO', 'DEBUG' ] levelMap = { logging.ERROR: 'ERROR', logging.WARNING: 'WARN', logging.INFO: 'INFO', logging.DEBUG: 'DEBUG', logging.NOTSET: 'OFF' } reverseMap = { 'ERROR': logging.ERROR, 'WARN': logging.WARNING, 'INFO': logging.INFO, 'DEBUG': logging.DEBUG, 'OFF': logging.NOTSET } class LogRetrievalException(Exception): pass def _find_basefilename(): """Finds the logger base filename(s) currently there is only one """ log_file = None root = logging.getLogger() for h in root.__dict__['handlers']: if h.__class__.__name__ in ('TimedRotatingFileHandler','RotatingFileHandler','FileHandler'): log_file = h.baseFilename return log_file def get_log() -> str: filename = _find_basefilename() if filename is None: return None with open(filename) as log: return log.read() def _logger_config(name: str, logger: logging.Logger) -> Dict[str,Dict[str,str]]: return { name: { 'configuredLevel': levelMap[logger.getEffectiveLevel()], 'effectiveLevel': levelMap[logger.getEffectiveLevel()] } } def get_loggers() -> Dict: # add levels and root logger loggers = {'levels':levels, 'loggers': { 'ROOT' : { 'configuredLevel': levelMap[logging.getLogger().getEffectiveLevel()], 'effectiveLevel': levelMap[logging.getLogger().getEffectiveLevel()] } }} # get all configured loggers for name, logger in logging.Logger.manager.loggerDict.items(): if hasattr(logger, 'getEffectiveLevel'): loggers['loggers'].update(_logger_config(name, logger)) return loggers def set_logger_level(logger: str, level: str): log = logging.getLogger(__name__) if logger == 'ROOT': log.debug('Setting level for root logger') logging.getLogger().setLevel(reverseMap[level]) return if logger not in logging.Logger.manager.loggerDict: log.error(f'No logger {logger} is defined') raise LogRetrievalException(f'Could find logger {logger} is defined set of logs') log.debug('Returning logger from ') logging.Logger.manager.loggerDict[logger].setLevel(reverseMap[level]) PK!c solenoid/solenoids/runtime.pyimport platform, sys, socket from solenoid.config import ServiceConfig #TODO needs to be depth first traversal to capture a.b.c config props def _config(key, value): return { key: { 'value': value, 'origin': 'Python dictionary config' } } def get_runtime(config: ServiceConfig): env = { 'activeProfiles': [], 'propertySources': [ { "name": "server.ports", "properties": { "local.server.port": { "value": config.get_port() } } }, { 'name': 'systemProperties', 'properties': { 'runtime': { 'value': 'Python' }, 'python.vm.version': { 'value': platform.python_version() }, 'python.version.string': { 'value': 'Python %s on %s' % (sys.version, sys.platform) }, 'python.implementation.name': { 'value': platform.python_implementation() }, 'platform.architecture': { 'value': platform.architecture()[0] }, 'python.vm.vendor': { 'value': 'Python Software Foundation' } } }, { 'name': 'systemEnvironment', 'properties': { 'PYTHON_EXECUTABLE': { 'value': sys.executable, 'origin': 'sys module' }, 'PYTHON_HOME': { 'value': sys.exec_prefix, 'origin': 'sys module' } } }, { "name": "springCloudClientHostInfo", "properties": { "spring.cloud.client.hostname": { "value": platform.node() }, "spring.cloud.client.ip-address": { "value": socket.gethostbyname(socket.gethostname()) } } }, { "name": "defaultProperties", "properties": {} } ]} return env PK!HlUTsolenoid-0.2.0.dist-info/WHEEL HM K-*ϳR03rOK-J,/R(O-)T0343 /, (-JLR()*M IL*4KM̫PK!H+8c!solenoid-0.2.0.dist-info/METADATATmO0_aiH$-`mK% }8/wv1iʗswy̳FS29S@3E0˓e-%3UKP=&$ "x2aXtuLi 25v,7V6,JiVȡv_ _5w7ϙT !z\3^AH_K7˴'s>5bcNA1!)YRΓU%j.Zc?y"s7 b9Šp))(Zs.k/>;|"}s0:Iޑ/{J"%ꧫ.Inoo[d"Ls !YA4( x @qܩzB+` qQ`,oug^]/ܼƽhxCm ٣dC hv'Q.џI](k+yjGH4 vs!؀I_M*]`)`gxnGLki1fiao %k8y{&qt3M#qsH-{d׸P g!"s!Fz&{o_|OY`frY ( IvdERIc̹14+Kv("F Xe.% u.ZhǬӋf}@|)[vM}+ Tٝep,\b-chN=P:8#ш̼dZ#;7 4/t%*r{昢n?p\\'N'.bY+QF K@8ޫ-}> Ģ^sP94Xh.-=~+j(QnVj%JQS8fV䴎}тY,?:k RcC|ĉ !6a꿜(H8>HM$ЦÝmAgT-^-˽5p20I Q +35u8^["S#Y60PK!hpsolenoid/__init__.pyPK!#_11Gsolenoid/app.pyPK!W#)#)solenoid/config.pyPK!ଋ7  9solenoid/eureka.pyPK!-D((/Zsolenoid/solenoid.pyPK!isolenoid/solenoids/__init__.pyPK!շDDisolenoid/solenoids/health.pyPK!mClsolenoid/solenoids/info.pyPK!k, , msolenoid/solenoids/log.pyPK!c vsolenoid/solenoids/runtime.pyPK!HlUTsolenoid-0.2.0.dist-info/WHEELPK!H+8c!solenoid-0.2.0.dist-info/METADATAPK!H7\Vsolenoid-0.2.0.dist-info/RECORDPK !