wp-cal-integration/main.py
2023-11-14 18:47:22 +01:00

197 lines
6.4 KiB
Python
Executable file

#!/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', '<NO SUMMARY PROVIDED>')} ({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()