#!/usr/bin/env python3 # -*- coding: utf-8 -*- import os import sys import logging import datetime import yaml from schema import Schema, And, Or, Use, Optional, Regex import click import adapters from adapters import * logging.VERBOSE = (logging.DEBUG + logging.INFO) // 2 logger = logging.getLogger(f'wp_cal') def range_str_to_timedelta(value): valid_units = 'smhdw' current_int = 0 values = {} for c in value: if c in '0123456789': current_int *= 10 current_int += int(c) elif c in valid_units: if c in values: logger.warning('unit %s already in values, overwriting', c) values[c] = current_int current_int = 0 c = 's' if current_int != 0: if c in values: logger.warning('unit %s already in values, overwriting', c) values['s'] = current_int for valid_unit in valid_units: values.setdefault(valid_unit, 0) return datetime.timedelta( seconds=values['s'], minutes=values['m'], hours=values['h'], days=values['d'], weeks=values['w'] ) def multiple_timedeltas(value): mappings = {} for v in value.split(','): kv = v.split(':') if len(kv) == 1: mappings[-1] = range_str_to_timedelta(kv[0]) continue mappings[int(kv[0])] = range_str_to_timedelta(kv[1]) return mappings config_schema = Schema({ 'sources': [dict], 'sinks': [dict], Optional('logging', default={'level': ':WARNING,wp_cal:INFO'}): { Optional('level', default=':WARNING,wp_cal:INFO'): Use(str), }, Optional('range', default='365d'): Or( dict, And( Use(str), Regex(r'(([0-9]+:)?[0-9]+[smhdw],?)*'), Use(multiple_timedeltas), ), ), }) def load_config(file): if file == '-': config = yaml.safe_load(sys.stdin) else: if os.stat(file).st_mode & 0o777 & ~0o600: raise Exception('refusing to load insecure configuration file, file must have permission 0o600') with open(file) as fp: config = yaml.safe_load(fp) return config_schema.validate(config) def init_logging(): logging.getLogger().addHandler( logging.StreamHandler(), ) def set_logging_level(level: str): levels = { 'NOTSET': logging.NOTSET, 'DEBUG': logging.DEBUG, 'VERBOSE': logging.VERBOSE, 'INFO': logging.INFO, 'WARNING': logging.WARNING, 'WARN': logging.WARNING, 'ERROR': logging.ERROR, 'CRITICAL': logging.CRITICAL, } log_levels = [x.split(':') for x in level.split(',')] for module, level in log_levels: module = module.strip() if module else None level = level.strip().upper() if level not in levels: raise ValueError(f'invalid log level, allowed values: {repr(set(levels.keys()))}') logging.getLogger(module).setLevel(levels[level]) @click.command() @click.option('--config', '-c', envvar='WP_CAL_CONFIG', default='-', help='The configuration file') @click.option('--dryrun', '-d', envvar='WP_CAL_DRYRUN', is_flag=True, help="Don't actually post any data, just show it") @click.option('--level', '-l', envvar='WP_CAL_LEVEL', default=None, help='The log level for the application') @click.option('--range', '-r', envvar='WP_CAL_RANGE', default=None, help='The time range from start to start + range to synchronize events') def main(config, dryrun, level, range): init_logging() set_logging_level(':DEBUG') config = load_config(config) if level: config['logging']['level'] = level if range: config['range'] = range config = config_schema.validate(config) set_logging_level(config['logging']['level']) sources = set() sinks = set() for source in config['sources']: assert len(source.keys()) == 1 for aname, a in adapters.ADAPTERS.items(): if aname in source: sources |= {a.new(source[aname])} break else: logger.error('couldn\'t find valid adapter for source configuration %s', source) sys.exit(1) for sink in config['sinks']: assert len(sink.keys()) == 1 for aname, a in adapters.ADAPTERS.items(): if aname in sink: sinks |= {a.new(sink[aname])} break else: logger.error('couldn\'t find valid adapter for sink configuration %s', sink) sys.exit(1) if not all([isinstance(x, adapters.Source) for x in sources]): logger.error('one or more source configurations do not implement being a source') sys.exit(1) if not all([isinstance(x, adapters.Sink) for x in sinks]): logger.error('one or more sink configurations do not implement being a sink') sys.exit(1) # log in if not all([x.login() for x in sources | sinks]): logger.error('failed to log into one or more sinks or sources') sys.exit(1) # gather events events = [] source_results = [] for i, source in enumerate(sources): try: events += source.get_events_resolved(until=config['range'].get(i, config['range'][-1])) source_results += [True] except Exception: logger.exception('failed to get events from source %s', source) source_results += [False] if not any(source_results): logger.error('event get failed for all sources') sys.exit(1) # filter cancelled events logger.info('found %d events', len(events)) logger.info('not syncing cancelled events') events = [e for e in events if e['status'] != 'cancelled'] logger.info('syncing %d events', len(events)) logger.log(logging.VERBOSE, 'events are: %s', [f"{e.get('summary', '')} ({e.get('start')})" for e in events]) # post events if not dryrun: sink_results = [] for sink in sinks: try: sink.post_events(events, until=max(config['range'].values())) sink_results += [True] except Exception: logger.exception('failed to post to sink %s', sink) sink_results += [False] if not any(sink_results): logger.error('event post failed for all sinks') sys.exit(1) logger.info("done") sys.exit(0) if __name__ == '__main__': main()