PK}cN?tppessex/__init__.py"""A command line interface for creating & managing s6 services, using the s6 toolset""" __version__ = '2.0.2' PK}cNR<]]essex/essex.py#!/usr/bin/env python3 import re, sys from contextlib import suppress from hashlib import md5 from plumbum import local, CommandNotFound, ProcessExecutionError from plumbum.cli import Application, Flag, SwitchAttr, Range, Set from plumbum.colors import blue, magenta, green, red, yellow from plumbum.cmd import ( s6_log, s6_svc, s6_svscan, s6_svscanctl, s6_svstat, fdmove, lsof, pstree, tail, getent, readlink, id as uid ) # TO DO / CONSIDER: # .s6-svscan/crash? # .s6-svscan/finish poweroff? (kill after timeout?) # check env var for SVCS_PATHS? # EssexOff doesn't wait for hanging procs? def warn(out='', err=''): print('\n'.join(filter(None, (out, err))).strip() | red, file=sys.stderr) def fail(r, out='', err=''): warn(out, err) sys.exit(r) class ColorApp(Application): PROGNAME = green VERSION = '2.0.2' | blue COLOR_USAGE = green COLOR_GROUPS = { 'Meta-switches': magenta, 'Switches': yellow, 'Subcommands': blue } ALLOW_ABBREV = True class Essex(ColorApp): """Simply manage services""" SUBCOMMAND_HELPMSG = False DEFAULT_PATHS = ('./svcs', '~/svcs', '/var/svcs', '/svcs') svcs_dir = SwitchAttr( ['d', 'directory'], local.path, argname='SERVICES_DIRECTORY', help=( "folder of services to manage; " f"the default is the first existing match from {DEFAULT_PATHS}, " "unless a colon-delimited SERVICES_PATHS env var exists;" ) ) logs_dir = SwitchAttr( ['l', 'logs-directory'], local.path, argname='SERVICES_LOGS_DIRECTORY', help=( "folder of services' log files; " f"the default is SERVICES_DIRECTORY/../svcs-logs" ) ) def main(self): if not self.svcs_dir: try: svcs_paths = local.env['SERVICES_PATHS'].split(':') except KeyError: svcs_paths = self.DEFAULT_PATHS for folder in map(local.path, svcs_paths): if folder.is_dir(): self.svcs_dir = folder break else: self.svcs_dir = local.path(svcs_paths[0]) self.svcs_dir.mkdir() self.logs_dir = self.logs_dir or self.svcs_dir.up() / 'svcs-logs' self.svcs = tuple(f for f in self.svcs_dir if 'run' in f) def fail_if_unsupervised(self): r, out, err = s6_svscanctl[self.svcs_dir].run(retcode=None) if r == 100: fail(1, f"{self.svcs_dir} not currently supervised.") elif r: fail(r, out, err) def svc_map(self, svc_names): return (self.svcs_dir / sn for sn in svc_names) @property def root_pid(self): try: readlink(lsof) except: # real lsof return lsof('-t', self.svcs_dir / '.s6-svscan' / 'control').splitlines()[0] else: # busybox lsof return next(filter( lambda p: p.endswith('/.s6-svscan/control'), lsof(self.svcs_dir / '.s6-svscan' / 'control').splitlines() )).split()[0] class Stopper(ColorApp): fail_after = SwitchAttr( ['f', 'fail-after'], float, argname='SECONDS', help=( "exit with code 1 if a service hasn't died after SECONDS seconds; " f"if 0, will not move on until the process dies" ), default=0, excludes=['kill-after'] ) kill_after = SwitchAttr( ['k', 'kill-after'], float, argname='SECONDS', help=( "send a kill signal (9) if a service hasn't died after SECONDS seconds; " f"if 0, will not move on until the process dies" ), default=0, excludes=['fail-after'] ) def stop(self, svc, announce=False): if announce: print("Stopping", svc, ". . .") self.fail_after = self.fail_after or self.kill_after r, out, err = s6_svc['-wD', '-d', '-T', int(self.fail_after * 1000), svc].run(retcode=None) if r == 99: warn(f"{svc} didn't stop in time!") if self.kill_after: warn(f"Sending kill signal to {svc}!") s6_svc['-k', svc].run_fg() else: fail(1, "Aborting!") elif r: fail(r, out, err) def is_up(self, svc): try: return s6_svstat('-o', 'up', svc).strip() == 'true' except ProcessExecutionError as e: warn(f"{e}") return False class Starter(ColorApp): def start(self, svc, announce=False, timeout=0): # timeout not currently used if announce: print("Starting", svc) # s6_svc['-u', '-wu', '-T', timeout * 1000, svc].run_fg() r, out, err = s6_svc['-u', '-wu', '-T', timeout * 1000, svc].run(retcode=None) if r: fail(r, out, err) # warn(out, err) @Essex.subcommand('print') class EssexPrint(ColorApp): """View (all or specified) services' run, finish, and log commands""" no_color = Flag( ['n', 'no-color'], help="do not colorize the output (for piping)" ) run_only = Flag( ['r', 'run-only'], help="only print each service's runfile, ignoring any finish, crash, or logger scripts" ) enabled_only = Flag( ['e', 'enabled'], help="only print contents of enabled services (configured to be running)" ) def display(self, docpath): title_cat = tail['-vn', '+1', docpath] if self.no_color: title_cat.run_fg() else: try: ( title_cat | local['highlight'][ '--stdout', '-O', 'truecolor', '-s', 'moria', '-S', 'sh' ] ).run_fg() except CommandNotFound: try: ( title_cat | local['bat']['-p', '-l', 'sh'] ).run_fg() except CommandNotFound: title_cat.run_fg() print('\n') def main(self, *svc_names): errors = False for svc in self.parent.svc_map(svc_names or self.parent.svcs): if self.enabled_only and 'down' in svc: continue found = False for file in ('run',) if self.run_only else ('run', 'finish', 'crash'): # if (runfile := svc / file).is_file(): runfile = svc / file # if runfile.is_file(): # self.display(runfile) found = True # if not self.run_only and (logger := svc / 'log' / 'run').is_file(): logger = svc / 'log' / 'run' # if not self.run_only and logger.is_file(): # self.display(logger) found = True if not found: warn(f"{svc} doesn't exist") errors = True if errors: fail(1) @Essex.subcommand('cat') class EssexCat(EssexPrint): """View (all or specified) services' run, finish, and log commands; Alias for print""" @Essex.subcommand('start') class EssexStart(Starter): """Start (all or specified) services""" def main(self, *svc_names): self.parent.fail_if_unsupervised() s6_svscanctl('-a', self.parent.svcs_dir) for svc in self.parent.svc_map(svc_names or self.parent.svcs): self.start(svc) @Essex.subcommand('stop') class EssexStop(Stopper): """Stop (all or specified) services""" def main(self, *svc_names): for svc in self.parent.svc_map(svc_names or self.parent.svcs): self.stop(svc, announce=True) @Essex.subcommand('list') class EssexList(ColorApp): """List all known services""" enabled_only = Flag( ['e', 'enabled'], help="only list enabled services (configured to be running)" ) def main(self): if self.parent.svcs_dir.is_dir(): if self.enabled_only: print(*(s for s in self.parent.svcs if 'down' not in s), sep='\n') else: print(*self.parent.svcs, sep='\n') @Essex.subcommand('status') class EssexStatus(ColorApp): """View the current states of (all or specified) services""" enabled_only = Flag( ['e', 'enabled'], help="only list enabled services (configured to be running)" ) def main(self, *svc_names): self.parent.fail_if_unsupervised() s6_svscanctl('-a', self.parent.svcs_dir) cols = ( 'up', 'wantedup', 'normallyup', 'ready', 'paused', 'pid', 'exitcode', 'signal', 'signum', 'updownsince', 'readysince', 'updownfor', 'readyfor' ) errors = False for svc in self.parent.svc_map(svc_names or self.parent.svcs): if 'run' in svc: if self.enabled_only and 'down' in svc: continue stats = { col: False if val == 'false' else val for col, val in zip( cols, s6_svstat('-o', ','.join(cols), svc).split() ) } statline = f"{svc.name:<20} {'up' if stats['up'] else 'down':<5} {stats['updownfor'] + 's':<10} {stats['pid'] if stats['pid'] != '-1' else stats['exitcode']:<6} {'autorestarts' if stats['wantedup'] else '':<13} {'autostarts' if stats['normallyup'] else '':<11}" print(statline | (green if stats['up'] else red)) else: warn(f"{svc} doesn't exist") errors = True if errors: fail(1) @Essex.subcommand('pid') class EssexPid(ColorApp): """Print the PIDs of running services, or s6-svscan (supervision root) if none specified""" def main(self, *svc_names): self.parent.fail_if_unsupervised() if not svc_names: print(self.parent.root_pid) else: errors = False for svc in self.parent.svc_map(svc_names): try: pid = s6_svstat('-p', svc).strip() except ProcessExecutionError as e: warn(f"{e}") errors = True else: if pid == '-1': warn(f"{svc} is not running") errors = True else: print(pid) if errors: fail(1) @Essex.subcommand('tree') class EssexTree(ColorApp): """View the process tree from the supervision root""" quiet = Flag( ['q', 'quiet'], help=( "don't print childless supervisors, s6-log processes, or s6-log supervisors; " "has no effect when pstree is provided by busybox" ) ) def main(self): self.parent.fail_if_unsupervised() try: readlink(pstree) except: # real pstree tree = pstree['-apT', self.parent.root_pid]() if self.quiet: tl = tree.splitlines() whitelist = set(range(len(tl))) for i, line in enumerate(tl): if re.match(r'^ +(\||`)-s6-supervise,', line): # supervisor if i + 1 == len(tl) or re.match(r'^ +(\||`)-s6-supervise,', tl[i + 1]): whitelist.discard(i) elif re.match(r'^ +\| +`-s6-log,', line): # logger whitelist.discard(i) whitelist.discard(i - 1) tree = '\n'.join(tl[i] for i in sorted(whitelist)) else: # busybox pstree tree = pstree['-p', self.parent.root_pid]() print(tree) @Essex.subcommand('enable') class EssexEnable(ColorApp): """Configure (all or specified) services to be up, without actually starting them""" def main(self, *svc_names): errors = False for svc in self.parent.svc_map(svc_names or self.parent.svcs): if svc.is_dir(): (svc / 'down').delete() else: warn(f"{svc} doesn't exist") errors = True if errors: fail(1) @Essex.subcommand('disable') class EssexDisable(ColorApp): """Configure (all or specified) services to be down, without actually stopping them""" def main(self, *svc_names): errors = False for svc in self.parent.svc_map(svc_names or self.parent.svcs): if svc.is_dir(): (svc / 'down').touch() else: warn(f"{svc} doesn't exist") errors = True if errors: fail(1) @Essex.subcommand('on') class EssexOn(ColorApp): """Start supervising all services""" def main(self): self.parent.logs_dir.mkdir() r, out, err = s6_svscanctl[self.parent.svcs_dir].run(retcode=None) if r == 100: ( fdmove['-c', '2', '1'][s6_svscan][self.parent.svcs_dir] | s6_log['T', self.parent.logs_dir / '.s6-svscan'] ).run_bg() elif r: fail(r, out, err) else: warn(f"{self.parent.svcs_dir} already supervised") @Essex.subcommand('off') class EssexOff(Stopper): """Stop all services and their supervision""" def main(self): self.parent.fail_if_unsupervised() for svc in self.parent.svcs: self.stop(svc, announce=self.is_up(svc)) # yes, even when not is_up, to catch failed-start loops s6_svscanctl['-anpt', self.parent.svcs_dir].run_fg() @Essex.subcommand('sync') class EssexSync(Stopper, Starter): """Start or stop services to match their configuration""" def main(self, *svc_names): self.parent.fail_if_unsupervised() s6_svscanctl['-an', self.parent.svcs_dir].run_fg() for svc in self.parent.svc_map(svc_names or self.parent.svcs): is_up = self.is_up(svc) if (svc / 'down').exists(): self.stop(svc, announce=is_up) # yes, even when not is_up, to catch failed-start loops elif not is_up: self.start(svc, announce=True) @Essex.subcommand('upgrade') class EssexUpgrade(Stopper, Starter): """Restart (all or specified) running services whose run scripts have changed; Depends on the runfile generating an adjacent run.md5 file, like essex-generated runfiles do""" def main(self, *svc_names): self.parent.fail_if_unsupervised() for svc in self.parent.svc_map(svc_names or self.parent.svcs): if self.is_up(svc): for run, run_md5 in ( (svc / 'run', svc / 'run.md5'), (svc / 'log' / 'run', svc / 'log' / 'run.md5') ): if run_md5.is_file(): if md5(run.read().encode()).hexdigest() != run_md5.read().split()[0]: self.stop(run.up(), announce=True) self.start(run.up(), announce=True) break @Essex.subcommand('reload') class EssexReload(EssexUpgrade): """Restart (all or specified) running services whose run scripts have changed; Depends on the runfile generating an adjacent run.md5 file, like essex-generated runfiles do; Alias for upgrade; Deprecated""" @Essex.subcommand('pt') class EssexPapertrail(ColorApp): """Print a sample Papertrail log_files.yml""" interactive = Flag( ['i', 'interactive'], help="interactively ask the user for host and port" ) def main(self, host='{{ PAPERTRAIL_HOST }}', port='{{ PAPERTRAIL_PORT }}'): if self.interactive: host = input("Papertrail host: ") port = input("Papertrail port: ") entries = '\n'.join( f" - tag: {svc.name}\n" f" path: {self.parent.logs_dir / svc.name / 'current'}" for svc in self.parent.svcs ) print( f"files:", f"{entries}", f"destination:", f" host: {host}", f" port: {port}", f" protocol: tls", sep='\n' ) @Essex.subcommand('log') class EssexLog(ColorApp): """View (all or specified) services' current log files""" lines = SwitchAttr( ['n', 'lines'], argname='LINES', help=( "print only the last LINES lines from the service's current log file, " "or prepend a '+' to start at line LINES" ), default='+1' ) follow = Flag( ['f', 'follow'], help="continue printing new lines as they are added to the log file" ) debug = Flag( ['d', 'debug'], help="view the s6-svscan log file" ) def main(self, *svc_names): logs = [ self.parent.logs_dir / svc.name / 'current' for svc in self.parent.svc_map(svc_names or self.parent.svcs) ] if self.debug: logs.append(self.parent.logs_dir / '.s6-svscan' / 'current') if self.follow: with suppress(KeyboardInterrupt): try: mtail = local.get('lnav', 'multitail') except CommandNotFound: tail[['-n', self.lines, '-F'] + logs].run_fg() else: mtail[logs].run_fg() else: for log in logs: if log.is_file(): tail['-vn', self.lines, log].run_fg() print('\n') @Essex.subcommand('sig') class EssexSignal(ColorApp): """Send a signal to (all or specified) services""" sigs = { 'alrm': 'a', 'abrt': 'b', 'quit': 'q', 'hup': 'h', 'kill': 'k', 'term': 't', 'int': 'i', 'usr1': '1', 'usr2': '2', 'stop': 'p', 'cont': 'c', 'winch': 'y' } def main(self, signal: Set(*sigs), *svc_names): self.parent.fail_if_unsupervised() sig = self.sigs[signal.lower()] for svc in self.parent.svc_map(svc_names or self.parent.svcs): s6_svc[f'-{sig}', svc].run_fg() def columnize_comments(*line_pairs): col2_at = max(len(code) for code, comment in line_pairs) + 2 return '\n'.join( f"{code}{' ' * (col2_at - len(code))}{'# ' * bool(comment)}{comment}" for code, comment in line_pairs ) @Essex.subcommand('new') class EssexNew(ColorApp): """Create a new service""" working_dir = SwitchAttr( ['d', 'working-dir'], local.path, argname='WORKING_DIRECTORY', help=( "run the process from inside this folder; " "the default is SERVICES_DIRECTORY/svc_name" ) ) as_user = SwitchAttr( ['u', 'as-user'], argname='USERNAME', help="non-root user to run the new service as (only works for root)" ) enabled = Flag( ['e', 'enable'], help="enable the new service after creation" ) on_finish = SwitchAttr( ['f', 'finish'], argname='FINISH_CMD', help=( "command to run whenever the supervised process dies " "(must complete in under 5 seconds)" ) ) rotate_at = SwitchAttr( ['r', 'rotate-at'], Range(1, 256), argname='MEBIBYTES', help="archive each log file when it reaches MEBIBYTES mebibytes", default=4 ) prune_at = SwitchAttr( ['p', 'prune-at'], Range(0, 1024), argname='MEBIBYTES', help=( "keep up to MEBIBYTES mebibytes of logs before deleting the oldest; " "0 means never prune" ), default=40 ) on_rotate = SwitchAttr( ['o', 'on-rotate'], argname='PROCESSOR_CMD', help=( "processor command to run when rotating logs; " "receives log via stdin; " "its stdout is archived; " "PROCESSOR_CMD will be double-quoted" ) ) store = SwitchAttr( ['s', 'store'], argname='VARNAME=CMD', help=("run CMD and store its output in env var VARNAME before main cmd is run"), list=True ) # TODO: use skabus-dyntee for socket-logging? maybe def main(self, svc_name, cmd): self.svc = self.parent.svcs_dir / svc_name if self.svc.exists(): fail(1, f"{self.svc} already exists!") self.cmd = cmd if self.as_user and ':' in self.as_user: user, group = self.as_user.split(':', 1) if not user.isnumeric(): user = uid('-u', user).strip() if not group.isnumeric(): group = getent('group', group).split(':')[2] self.as_user = f"{user}:{group}" self.mk_runfile() self.mk_logger() if not self.enabled: (self.svc / 'down').touch() def mk_runfile(self): self.svc.mkdir() runfile = self.svc / 'run' shebang = ('#!/bin/execlineb -P', '') cmd = (self.cmd, "Do the thing") err_to_out = ('fdmove -c 2 1', "Send stderr to stdout") hash_run = ( 'foreground { redirfd -w 1 run.md5 md5sum run }', "Generate hashfile, to detect changes since launch" ) set_user = ( f's6-setuidgid {self.as_user}', "Run as this user" ) if self.as_user else None working_dir = ( f'cd {self.working_dir}', "Enter working directory" ) if self.working_dir else None store_vars = [] for store_var in self.store: var, store_cmd = store_var.split('=', 1) store_vars.append((f'backtick -n {var} {{ {store_cmd} }} importas -u {var} {var}', "Store command output")) runfile.write(columnize_comments(*filter(None, ( shebang, err_to_out, hash_run, set_user, working_dir, *store_vars, cmd )))) runfile.chmod(0o755) if self.on_finish: runfile = self.svc / 'finish' shebang = ('#!/bin/execlineb', '') cmd = (self.on_finish, "Do the thing") runfile.write(columnize_comments(*filter(None, ( shebang, err_to_out, set_user, cmd )))) runfile.chmod(0o755) def mk_logger(self): logger = self.svc / 'log' logger.mkdir() runfile = logger / 'run' shebang = ('#!/bin/execlineb -P', '') hash_run = ( 'foreground { redirfd -w 1 run.md5 md5sum run }', "Generate hashfile, to detect changes since launch" ) receive = ('s6-log', "Receive process output") timestamp = (' T', "Start each line with an ISO 8601 timestamp") rotate = ( f' s{self.rotate_at * 1024 ** 2}', "Archive log when it gets this big (bytes)" ) prune = ( f' S{self.prune_at * 1024 ** 2}', "Purge oldest archived logs when the archive gets this big (bytes)" ) process = ( f'!"{self.on_rotate}"', "Processor (log --stdin--> processor --stdout--> archive)" ) if self.on_rotate else None logfile = (f' {self.parent.logs_dir / self.svc.name}', "Store logs here") runfile.write(columnize_comments(*filter(None, ( shebang, hash_run, receive, timestamp, rotate, prune, process, logfile )))) runfile.chmod(0o755) def main(): for app in ( EssexCat, EssexDisable, EssexEnable, EssexList, EssexLog, EssexNew, EssexOff, EssexOn, EssexPid, EssexPrint, EssexReload, EssexSignal, EssexStart, EssexStatus, EssexStop, EssexSync, EssexTree, EssexUpgrade ): app.unbind_switches('help-all', 'v', 'version') Essex() if __name__ == '__main__': main() PKzVNacc  essex/essex_complete.py#!/usr/bin/env python3 import shlex from sys import argv from itertools import count from collections import defaultdict from plumbum import local subcommands = ( 'cat', 'disable', 'enable', 'list', 'log', 'new', 'off', 'on', 'pid', 'print', 'reload', 'sig', 'start', 'status', 'stop', 'sync', 'tree', 'upgrade' ) signals = ( 'alrm', 'abrt', 'quit', 'hup', 'kill', 'term', 'int', 'usr1', 'usr2', 'stop', 'cont', 'winch' ) # Declare switches, which take arguments stop_cmds = ('off', 'reload', 'stop', 'sync', 'upgrade') opts = defaultdict(tuple, { sc: ('-f', '--fail-after', '-k', '--kill-after') for sc in stop_cmds }) opts.update({ 'essex': ('-d', '--directory', '-l', '--logs-directory'), 'log': ('-n', '--lines'), 'new': ( '-d', '--working-dir', '-f', '--finish', '-o', '--on-rotate', '-p', '--prune-at', '-r', '--rotate-at', '-u', '--as-user', '-s', '--store' ) }) # Declare flags, which take no arguments. All svcs have -h, --help hlp = ('-h', '--help') flags = defaultdict(lambda: hlp) flags['cat'] += ('-n', '--no-color', '-r', '--run-only', '-e', '--enabled') flags['log'] += ('-f', '--follow', '-d', '--debug') flags['new'] += ('-e', '--enable') flags['list'] += ('-e', '--enabled') flags['print'] += ('-n', '--no-color', '-r', '--run-only', '-e', '--enabled') flags['status'] += ('-e', '--enabled') flags['tree'] += ('-q', '--quiet') def get_subcmd(words): subcmd = 'essex' for idx in count(1): if idx == len(words): return subcmd if all(( words[idx] not in (*opts['essex'], *flags['essex']), words[idx - 1] not in opts['essex'], words[idx] in subcommands )): return words[idx] def get_svcs_dir(words, defaults=('./svcs', '~/svcs', '/var/svcs', '/svcs')): for idx in (1, 3): if idx < len(words) and words[idx] in ('-d', '--directory'): return local.path(words[idx + 1]) try: svcs_paths = local.env['SERVICES_PATHS'].split(':') except KeyError: svcs_paths = defaults for folder in map(local.path, svcs_paths): if folder.is_dir(): return folder return local.path(svcs_paths[0]) def get_svcs(words): return tuple(f for f in get_svcs_dir(words) if 'run' in f) def main(): cmd, partial_word, prev_word = argv[1:] line = local.env['COMP_LINE'] suggestions = [] words = shlex.split(line) subcmd = get_subcmd(words) suggestions.extend( opt for opt in (*opts[subcmd], *flags[subcmd]) if opt.startswith(partial_word) ) if prev_word in (*opts[subcmd], *hlp): suggestions.clear() elif subcmd == 'essex': suggestions.extend( sc for sc in subcommands if sc.startswith(partial_word) ) elif subcmd == 'sig' and prev_word == 'sig': suggestions.extend( sig for sig in signals if sig.startswith(partial_word) ) elif subcmd not in ('list', 'new', 'off', 'on', 'tree'): suggestions.extend( svc.name for svc in get_svcs(words) if svc.name.startswith(partial_word) ) if subcmd == 'new' and prev_word in ('-u', '--as-user'): suggestions.extend( line.split(':')[0] for line in local.path('/etc/passwd').read().splitlines() ) print('\n'.join(suggestions)) if __name__ == '__main__': main() PK]o$Nessex/requirements.in# python 3 plumbum PKtHN7Fessex/requirements.txtplumbum==1.6.7 \ --hash=sha256:d143f079bfb60b11e9bec09a49695ce2e55ce5ca0246877bdb0818ab7c7fc312 \ --hash=sha256:df96a5facf621db4a6d682bdc93afa5ed6b107a8667c73c3f0a0f0fab4217c81 PK!H'BM3K&essex-2.0.2.dist-info/entry_points.txtN+I/N.,()O-.Nz`2>9? '$*713 C"PK]o$N|essex-2.0.2.dist-info/LICENSE DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE Version 2, December 2004 Copyright (C) 2004 Sam Hocevar Everyone is permitted to copy and distribute verbatim or modified copies of this license document, and changing it is allowed as long as the name is changed. DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 0. You just DO WHAT THE FUCK YOU WANT TO. PK!HMuSaessex-2.0.2.dist-info/WHEEL HM K-*ϳR03rOK-J,/RH,szd&Y)r$[)T&UD"PK!Hg%essex-2.0.2.dist-info/METADATAXko\lQX(z Rԯ4k&(MP9)ɑ>{.gM-$p8={ye{弮쀾I_dW3ylR(09miLѸr9%aWn3{x PUƫ VJj9vBD|#N[ށXQ*[ L\T9M~5X E ӏ(%ڴ_ 3#.Osv>~c3Qؐ\kp,gaX04)鏯_BD^#@V6W6]B-K<a5#`_{TRj6~ו7I|GQC\cW :@Cܺ9PuwĖcag#~Qn 1py!zۘ[ WvIN5ΡnZbWIȽqYмd +'fL ,"Ӯc.:߆zL1/ O\VlO}&-F1ي)WLE?_%V[N'ڄʔQ?,aP\B`}L6Bӂij r(+UWV~KO6vఔ4QVe+h2.FfVVҭ)QH]R875j\Eqq&2Ғ[KM'ܝ ~VXnc p?N[> ڬ]mO?VYmL>-k3n֝m؋npCxM(s3"XCF^ \u)I&B ev(Z ;qHΤO,tMIιRLNCUB+yڭZwR&j\r N~[ HmMOk{˰$\7#eg')&#;1*竫&ZңNNU:Ds0W'@䋟][-nxA5-|oqOt `ny}М2봽zy:m[k`/2`D׹z\|-IJt~8]>;x~Ǒ$B!8aZ?x~vBA9 *3G5YB}iO萗ɂ N=LZ֛vm(?PK!Haessex-2.0.2.dist-info/RECORDuMs0}  Q)\6PS>Dө3Ν,9/ BU [nPgO=C?\V);y6"m1q1bu~zV+iNc؄Lr2GbJacLMv} 4I}/o,AV*[S3ӓ&su\5nOǡRH&?A; W`;'=]%n`tta["wC+onmV69A^"Jɧ