diff --git a/AUTHORS.txt b/AUTHORS.txt index 6e4e7377e..e417da915 100644 --- a/AUTHORS.txt +++ b/AUTHORS.txt @@ -54,3 +54,4 @@ Raúl Medina - raulmgcontact [at] gmail (dot] com Matthew Rademaker - matthew.rademaker [at] gmail [dot] com Valentin Iovene - val [at] too [dot] gy Julian Wollrath +Mattori Birnbaum - me [at] mattori [dot] com - https://mattori.com diff --git a/CHANGELOG.rst b/CHANGELOG.rst index fdf730a31..c9dfc4f61 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -17,6 +17,7 @@ not released yet * NEW properties of ikhal themes (dark and light) can now be overriden from the config file (via the new [palette] section, check the documenation) * NEW timedelta strings can now have a leading `+`, e.g. `+1d` +* NEW Add `--json` option to output event data as JSON objects 0.11.2 ====== diff --git a/doc/source/usage.rst b/doc/source/usage.rst index f1ffa9f05..a1c2f67d2 100644 --- a/doc/source/usage.rst +++ b/doc/source/usage.rst @@ -171,8 +171,21 @@ Several options are common to almost all of :program:`khal`'s commands url-separator A separator: " :: " that appears when there is a url. + duration + The duration of the event in terms of days, hours, months, and seconds + (abbreviated to `d`, `h`, `m`, and `s` respectively). + + repeat-pattern + The raw iCal recurrence rule if the event is repeating. + + all-day + A boolean indicating whether it is an all-day event or not. + + categories + The categories of the event. + By default, all-day events have no times. To see a start and end time anyway simply - add `-full` to the end of any template with start/end, for instance + add `-full` to the end of any template with start/end or duration, for instance `start-time` becomes `start-time-full` and will always show start and end times (instead of being empty for all-day events). @@ -191,6 +204,40 @@ Several options are common to almost all of :program:`khal`'s commands khal list --format "{title} {description}" +.. option:: --json FIELD ... + + Works similar to :option:`--format`, but instead of defining a format string a JSON + object is created for each specified field. The matching events are collected into + a JSON array. This option accepts the following subset of :option:`--format` + template options + + :: + + title, description, uid, start, start-long, start-date, + start-date-long, start-time, end, end-long, end-date, + end-date-long, end-time, start-full, start-long-full, + start-date-full, start-date-long-full, start-time-full, + end-full, end-long-full, end-date-full, end-date-long-full, + end-time-full, repeat-symbol, location, calendar, + calendar-color, start-style, to-style, end-style, + start-end-time-style, end-necessary, end-necessary-long, + status, cancelled, organizer, url, duration, duration-full, + repeat-pattern, all-day, categories + + + Note that `calendar-color` will be the actual color name rather than the ANSI color code, + and the `repeat-symbol`, `status`, and `cancelled` values will have leading/trailing + whitespace stripped. Additionally, if only the special value `all` is specified then + all fields will be enabled. + + Below is an example command which prints a JSON list of objects containing the title and + description of all events today. + + :: + + khal list --json title --json description + + .. option:: --day-format DAYFORMAT works similar to :option:`--format`, but for day headings. It only has a few @@ -231,8 +278,9 @@ shows all events scheduled for a given date (or datetime) range, with custom formatting: :: - khal list [-a CALENDAR ... | -d CALENDAR ...] [--format FORMAT] - [--day-format DAYFORMAT] [--once] [--notstarted] [START [END | DELTA] ] + khal list [-a CALENDAR ... | -d CALENDAR ...] + [--format FORMAT] [--json FIELD ...] [--day-format DAYFORMAT] + [--once] [--notstarted] [START [END | DELTA] ] START and END can both be given as dates, datetimes or times (it is assumed today is meant in the case of only a given time) in the formats configured in @@ -270,7 +318,8 @@ start. :: - khal at [-a CALENDAR ... | -d CALENDAR ...] [--format FORMAT] + khal at [-a CALENDAR ... | -d CALENDAR ...] + [--format FORMAT] [--json FIELD ...] [--notstarted] [[START DATE] TIME | now] calendar diff --git a/khal/api.py b/khal/api.py new file mode 100644 index 000000000..845876842 --- /dev/null +++ b/khal/api.py @@ -0,0 +1,10 @@ +from typing import Callable, Dict + +from .ui.colors import register_color_theme + +_plugin_commands: Dict[str, Callable] = {} + +def register_command(name: str, command: Callable): + _plugin_commands[name] = command + +__all__ = ["register_color_theme", "register_command"] diff --git a/khal/cli.py b/khal/cli.py index 538ff9d4d..bd1bb0ae1 100644 --- a/khal/cli.py +++ b/khal/cli.py @@ -29,11 +29,14 @@ import click import click_log +import xdg from . import __version__, controllers, khalendar +from .api import _plugin_commands from .exceptions import FatalError from .settings import InvalidSettingsError, NoConfigFile, get_config from .terminal import colored +from .utils import human_formatter, json_formatter try: from setproctitle import setproctitle @@ -58,10 +61,6 @@ def time_args(f): def multi_calendar_select(ctx, include_calendars, exclude_calendars): if include_calendars and exclude_calendars: raise click.UsageError('Can\'t use both -a and -d.') - # if not isinstance(include_calendars, tuple): - # include_calendars = (include_calendars,) - # if not isinstance(exclude_calendars, tuple): - # exclude_calendars = (exclude_calendars,) selection = set() @@ -238,264 +237,300 @@ def stringify_conf(conf): return '\n'.join(out) -def _get_cli(): - @click.group() - @click_log.simple_verbosity_option('khal') - @global_options - @click.pass_context - def cli(ctx, config): - # setting the process title so it looks nicer in ps - # shows up as 'khal' under linux and as 'python: khal (python2.7)' - # under FreeBSD, which is still nicer than the default - setproctitle('khal') - if ctx.logfilepath: - logger = logging.getLogger('khal') - logger.handlers = [logging.FileHandler(ctx.logfilepath)] - prepare_context(ctx, config) - - @cli.command() - @multi_calendar_option - @click.option('--format', '-f', - help=('The format of the events.')) - @click.option('--day-format', '-df', - help=('The format of the day line.')) - @click.option( - '--once', '-o', - help=('Print each event only once (even if it is repeated or spans multiple days).'), - is_flag=True) - @click.option('--notstarted', help=('Print only events that have not started.'), - is_flag=True) - @click.argument('DATERANGE', nargs=-1, required=False) - @click.pass_context - def calendar(ctx, include_calendar, exclude_calendar, daterange, once, - notstarted, format, day_format): - '''Print calendar with agenda.''' - try: - rows = controllers.calendar( - build_collection( - ctx.obj['conf'], - multi_calendar_select(ctx, include_calendar, exclude_calendar) - ), - agenda_format=format, - day_format=day_format, - once=once, - notstarted=notstarted, - daterange=daterange, - conf=ctx.obj['conf'], - firstweekday=ctx.obj['conf']['locale']['firstweekday'], - locale=ctx.obj['conf']['locale'], - weeknumber=ctx.obj['conf']['locale']['weeknumbers'], - monthdisplay=ctx.obj['conf']['view']['monthdisplay'], - hmethod=ctx.obj['conf']['highlight_days']['method'], - default_color=ctx.obj['conf']['highlight_days']['default_color'], - multiple=ctx.obj['conf']['highlight_days']['multiple'], - multiple_on_overflow=ctx.obj['conf']['highlight_days']['multiple_on_overflow'], - color=ctx.obj['conf']['highlight_days']['color'], - highlight_event_days=ctx.obj['conf']['default']['highlight_event_days'], - bold_for_light_color=ctx.obj['conf']['view']['bold_for_light_color'], - env={"calendars": ctx.obj['conf']['calendars']} - ) - click.echo('\n'.join(rows)) - except FatalError as error: - logger.debug(error, exc_info=True) - logger.fatal(error) - sys.exit(1) - - @cli.command("list") - @multi_calendar_option - @click.option('--format', '-f', - help=('The format of the events.')) - @click.option('--day-format', '-df', - help=('The format of the day line.')) - @click.option('--once', '-o', is_flag=True, - help=('Print each event only once ' - '(even if it is repeated or spans multiple days).') - ) - @click.option('--notstarted', help=('Print only events that have not started.'), - is_flag=True) - @click.argument('DATERANGE', nargs=-1, required=False, - metavar='[DATETIME [DATETIME | RANGE]]') - @click.pass_context - def klist(ctx, include_calendar, exclude_calendar, - daterange, once, notstarted, format, day_format): - """List all events between a start (default: today) and (optional) - end datetime.""" - try: - event_column = controllers.khal_list( - build_collection( - ctx.obj['conf'], - multi_calendar_select(ctx, include_calendar, exclude_calendar) - ), - agenda_format=format, - day_format=day_format, - daterange=daterange, - once=once, - notstarted=notstarted, - conf=ctx.obj['conf'], - env={"calendars": ctx.obj['conf']['calendars']} - ) - if event_column: - click.echo('\n'.join(event_column)) - else: - logger.debug('No events found') +class _KhalGroup(click.Group): + def list_commands(self, ctx): + return super().list_commands(ctx) + list(_plugin_commands.keys()) + + def get_command(self, ctx, name): + if name in _plugin_commands: + print(f'found command {name} as a plugin') + return _plugin_commands[name] + return super().get_command(ctx, name) + + + +@click.group(cls=_KhalGroup) +@click_log.simple_verbosity_option('khal') +@global_options +@click.pass_context +def cli(ctx, config): + # setting the process title so it looks nicer in ps + # shows up as 'khal' under linux and as 'python: khal (python2.7)' + # under FreeBSD, which is still nicer than the default + setproctitle('khal') + if ctx.logfilepath: + logger = logging.getLogger('khal') + logger.handlers = [logging.FileHandler(ctx.logfilepath)] + prepare_context(ctx, config) + find_load_plugins() + +@cli.command() +@multi_calendar_option +@click.option('--format', '-f', + help=('The format of the events.')) +@click.option('--day-format', '-df', + help=('The format of the day line.')) +@click.option( + '--once', '-o', + help=('Print each event only once (even if it is repeated or spans multiple days).'), + is_flag=True) +@click.option('--notstarted', help=('Print only events that have not started.'), + is_flag=True) +@click.argument('DATERANGE', nargs=-1, required=False) +@click.pass_context +def calendar(ctx, include_calendar, exclude_calendar, daterange, once, + notstarted, format, day_format): + '''Print calendar with agenda.''' + try: + rows = controllers.calendar( + build_collection( + ctx.obj['conf'], + multi_calendar_select(ctx, include_calendar, exclude_calendar) + ), + agenda_format=format, + day_format=day_format, + once=once, + notstarted=notstarted, + daterange=daterange, + conf=ctx.obj['conf'], + firstweekday=ctx.obj['conf']['locale']['firstweekday'], + locale=ctx.obj['conf']['locale'], + weeknumber=ctx.obj['conf']['locale']['weeknumbers'], + monthdisplay=ctx.obj['conf']['view']['monthdisplay'], + hmethod=ctx.obj['conf']['highlight_days']['method'], + default_color=ctx.obj['conf']['highlight_days']['default_color'], + multiple=ctx.obj['conf']['highlight_days']['multiple'], + multiple_on_overflow=ctx.obj['conf']['highlight_days']['multiple_on_overflow'], + color=ctx.obj['conf']['highlight_days']['color'], + highlight_event_days=ctx.obj['conf']['default']['highlight_event_days'], + bold_for_light_color=ctx.obj['conf']['view']['bold_for_light_color'], + env={"calendars": ctx.obj['conf']['calendars']} + ) + click.echo('\n'.join(rows)) + except FatalError as error: + logger.debug(error, exc_info=True) + logger.fatal(error) + sys.exit(1) - except FatalError as error: - logger.debug(error, exc_info=True) - logger.fatal(error) - sys.exit(1) - - @cli.command() - @calendar_option - @click.option('--interactive', '-i', help=('Add event interactively'), - is_flag=True) - @click.option('--location', '-l', - help=('The location of the new event.')) - @click.option('--categories', '-g', - help=('The categories of the new event, comma separated.')) - @click.option('--repeat', '-r', - help=('Repeat event: daily, weekly, monthly or yearly.')) - @click.option('--until', '-u', - help=('Stop an event repeating on this date.')) - @click.option('--format', '-f', - help=('The format to print the event.')) - @click.option('--alarms', '-m', - help=('Alarm times for the new event as DELTAs comma separated')) - @click.option('--url', help=("URI for the event.")) - @click.argument('info', metavar='[START [END | DELTA] [TIMEZONE] [SUMMARY] [:: DESCRIPTION]]', - nargs=-1) - @click.pass_context - def new(ctx, calendar, info, location, categories, repeat, until, alarms, url, format, - interactive): - '''Create a new event from arguments. - - START and END can be either dates, times or datetimes, please have a - look at the man page for details. - Everything that cannot be interpreted as a (date)time or a timezone is - assumed to be the event's summary, if two colons (::) are present, - everything behind them is taken as the event's description. - ''' - if not info and not interactive: +@cli.command("list") +@multi_calendar_option +@click.option('--format', '-f', + help=('The format of the events.')) +@click.option('--day-format', '-df', + help=('The format of the day line.')) +@click.option('--once', '-o', is_flag=True, + help=('Print each event only once ' + '(even if it is repeated or spans multiple days).') + ) +@click.option('--notstarted', help=('Print only events that have not started.'), + is_flag=True) +@click.option('--json', help=("Fields to output in json"), multiple=True) +@click.argument('DATERANGE', nargs=-1, required=False, + metavar='[DATETIME [DATETIME | RANGE]]') +@click.pass_context +def klist(ctx, include_calendar, exclude_calendar, + daterange, once, notstarted, json, format, day_format): + """List all events between a start (default: today) and (optional) + end datetime.""" + try: + event_column = controllers.khal_list( + build_collection( + ctx.obj['conf'], + multi_calendar_select(ctx, include_calendar, exclude_calendar) + ), + agenda_format=format, + day_format=day_format, + daterange=daterange, + once=once, + notstarted=notstarted, + conf=ctx.obj['conf'], + env={"calendars": ctx.obj['conf']['calendars']}, + json=json + ) + if event_column: + click.echo('\n'.join(event_column)) + else: + logger.debug('No events found') + + except FatalError as error: + logger.debug(error, exc_info=True) + logger.fatal(error) + sys.exit(1) + +@cli.command() +@calendar_option +@click.option('--interactive', '-i', help=('Add event interactively'), + is_flag=True) +@click.option('--location', '-l', + help=('The location of the new event.')) +@click.option('--categories', '-g', + help=('The categories of the new event, comma separated.')) +@click.option('--repeat', '-r', + help=('Repeat event: daily, weekly, monthly or yearly.')) +@click.option('--until', '-u', + help=('Stop an event repeating on this date.')) +@click.option('--format', '-f', + help=('The format to print the event.')) +@click.option('--json', help=("Fields to output in json"), multiple=True) +@click.option('--alarms', '-m', + help=('Alarm times for the new event as DELTAs comma separated')) +@click.option('--url', help=("URI for the event.")) +@click.argument('info', metavar='[START [END | DELTA] [TIMEZONE] [SUMMARY] [:: DESCRIPTION]]', + nargs=-1) +@click.pass_context +def new(ctx, calendar, info, location, categories, repeat, until, alarms, url, format, + json, interactive): + '''Create a new event from arguments. + + START and END can be either dates, times or datetimes, please have a + look at the man page for details. + Everything that cannot be interpreted as a (date)time or a timezone is + assumed to be the event's summary, if two colons (::) are present, + everything behind them is taken as the event's description. + ''' + if not info and not interactive: + raise click.BadParameter( + 'no details provided, did you mean to use --interactive/-i?' + ) + + calendar = calendar or ctx.obj['conf']['default']['default_calendar'] + if calendar is None: + if interactive: + while calendar is None: + calendar = click.prompt('calendar') + if calendar == '?': + for calendar in ctx.obj['conf']['calendars']: + click.echo(calendar) + calendar = None + elif calendar not in ctx.obj['conf']['calendars']: + click.echo('unknown calendar enter ? for list') + calendar = None + else: raise click.BadParameter( - 'no details provided, did you mean to use --interactive/-i?' + 'No default calendar is configured, ' + 'please provide one explicitly.' ) + try: + new_func = controllers.new_from_string + if interactive: + new_func = controllers.new_interactive + new_func( + build_collection(ctx.obj['conf'], ctx.obj.get('calendar_selection', None)), + calendar, + ctx.obj['conf'], + info=' '.join(info), + location=location, + categories=categories, + repeat=repeat, + env={"calendars": ctx.obj['conf']['calendars']}, + until=until, + alarms=alarms, + url=url, + format=format, + json=json + ) + except FatalError as error: + logger.debug(error, exc_info=True) + logger.fatal(error) + sys.exit(1) - calendar = calendar or ctx.obj['conf']['default']['default_calendar'] - if calendar is None: - if interactive: - while calendar is None: - calendar = click.prompt('calendar') - if calendar == '?': - for calendar in ctx.obj['conf']['calendars']: - click.echo(calendar) - calendar = None - elif calendar not in ctx.obj['conf']['calendars']: - click.echo('unknown calendar enter ? for list') - calendar = None +@cli.command('import') +@click.option('--include-calendar', '-a', help=('The calendar to use.'), + callback=_select_one_calendar_callback, multiple=True) +@click.option('--batch', help=('do not ask for any confirmation.'), + is_flag=True) +@click.option('--random_uid', '-r', help=('Select a random uid.'), + is_flag=True) +@click.argument('ics', type=click.File('rb'), nargs=-1) +@click.option('--format', '-f', help=('The format to print the event.')) +@click.pass_context +def import_ics(ctx, ics, include_calendar, batch, random_uid, format): + '''Import events from an .ics file (or stdin). + + If an event with the same UID is already present in the (implicitly) + selected calendar import will ask before updating (i.e. overwriting) + that old event with the imported one, unless --batch is given, than it + will always update. If this behaviour is not desired, use the + `--random-uid` flag to generate a new, random UID. + If no calendar is specified (and not `--batch`), you will be asked + to choose a calendar. You can either enter the number printed behind + each calendar's name or any unique prefix of a calendar's name. + + ''' + if include_calendar: + ctx.obj['calendar_selection'] = {include_calendar, } + collection = build_collection(ctx.obj['conf'], ctx.obj.get('calendar_selection', None)) + if batch and len(collection.names) > 1 and \ + ctx.obj['conf']['default']['default_calendar'] is None: + raise click.UsageError( + 'When using batch import, please specify a calendar to import ' + 'into or set the `default_calendar` in the config file.') + rvalue = 0 + # Default to stdin: + if not ics: + ics_strs = ((sys.stdin.read(), 'stdin'),) + if not batch: + + def isatty(_file): + try: + return _file.isatty() + except Exception: + return False + + if isatty(sys.stdin) and os.stat('/dev/tty').st_mode & stat.S_IFCHR > 0: + sys.stdin = open('/dev/tty') else: - raise click.BadParameter( - 'No default calendar is configured, ' - 'please provide one explicitly.' - ) + logger.warning('/dev/tty does not exist, importing might not work') + else: + ics_strs = ((ics_file.read(), ics_file.name) for ics_file in ics) + + for ics_str, filename in ics_strs: try: - new_func = controllers.new_from_string - if interactive: - new_func = controllers.new_interactive - new_func( - build_collection(ctx.obj['conf'], ctx.obj.get('calendar_selection', None)), - calendar, + controllers.import_ics( + collection, ctx.obj['conf'], - info=' '.join(info), - location=location, - categories=categories, - repeat=repeat, + ics=ics_str, + batch=batch, + random_uid=random_uid, env={"calendars": ctx.obj['conf']['calendars']}, - until=until, - alarms=alarms, - url=url, - format=format, ) except FatalError as error: logger.debug(error, exc_info=True) - logger.fatal(error) - sys.exit(1) - - @cli.command('import') - @click.option('--include-calendar', '-a', help=('The calendar to use.'), - callback=_select_one_calendar_callback, multiple=True) - @click.option('--batch', help=('do not ask for any confirmation.'), - is_flag=True) - @click.option('--random_uid', '-r', help=('Select a random uid.'), - is_flag=True) - @click.argument('ics', type=click.File('rb'), nargs=-1) - @click.option('--format', '-f', help=('The format to print the event.')) - @click.pass_context - def import_ics(ctx, ics, include_calendar, batch, random_uid, format): - '''Import events from an .ics file (or stdin). - - If an event with the same UID is already present in the (implicitly) - selected calendar import will ask before updating (i.e. overwriting) - that old event with the imported one, unless --batch is given, than it - will always update. If this behaviour is not desired, use the - `--random-uid` flag to generate a new, random UID. - If no calendar is specified (and not `--batch`), you will be asked - to choose a calendar. You can either enter the number printed behind - each calendar's name or any unique prefix of a calendar's name. - - ''' - if include_calendar: - ctx.obj['calendar_selection'] = {include_calendar, } - collection = build_collection(ctx.obj['conf'], ctx.obj.get('calendar_selection', None)) - if batch and len(collection.names) > 1 and \ - ctx.obj['conf']['default']['default_calendar'] is None: - raise click.UsageError( - 'When using batch import, please specify a calendar to import ' - 'into or set the `default_calendar` in the config file.') - rvalue = 0 - # Default to stdin: - if not ics: - ics_strs = ((sys.stdin.read(), 'stdin'),) + logger.fatal(f"An error occurred when trying to import the file from {filename}") + logger.fatal("Events from it will not be available in khal") if not batch: + sys.exit(1) + rvalue = 1 + sys.exit(rvalue) + +@cli.command() +@multi_calendar_option +@mouse_option +@click.pass_context +def interactive(ctx, include_calendar, exclude_calendar, mouse): + '''Interactive UI. Also launchable via `ikhal`.''' + if mouse is not None: + ctx.obj['conf']['default']['enable_mouse'] = mouse + controllers.interactive( + build_collection( + ctx.obj['conf'], + multi_calendar_select(ctx, include_calendar, exclude_calendar) + ), + ctx.obj['conf'] + ) - def isatty(_file): - try: - return _file.isatty() - except Exception: - return False - - if isatty(sys.stdin) and os.stat('/dev/tty').st_mode & stat.S_IFCHR > 0: - sys.stdin = open('/dev/tty') - else: - logger.warning('/dev/tty does not exist, importing might not work') - else: - ics_strs = ((ics_file.read(), ics_file.name) for ics_file in ics) - - for ics_str, filename in ics_strs: - try: - controllers.import_ics( - collection, - ctx.obj['conf'], - ics=ics_str, - batch=batch, - random_uid=random_uid, - env={"calendars": ctx.obj['conf']['calendars']}, - ) - except FatalError as error: - logger.debug(error, exc_info=True) - logger.fatal(f"An error occurred when trying to import the file from {filename}") - logger.fatal("Events from it will not be available in khal") - if not batch: - sys.exit(1) - rvalue = 1 - sys.exit(rvalue) - - @cli.command() - @multi_calendar_option - @mouse_option - @click.pass_context - def interactive(ctx, include_calendar, exclude_calendar, mouse): - '''Interactive UI. Also launchable via `ikhal`.''' - if mouse is not None: - ctx.obj['conf']['default']['enable_mouse'] = mouse +@click.command() +@global_options +@multi_calendar_option +@mouse_option +@click.pass_context +def interactive_cli(ctx, config, include_calendar, exclude_calendar, mouse): + '''Interactive UI. Also launchable via `khal interactive`.''' + prepare_context(ctx, config) + find_load_plugins() + if mouse is not None: + ctx.obj['conf']['default']['enable_mouse'] = mouse + try: controllers.interactive( build_collection( ctx.obj['conf'], @@ -503,200 +538,207 @@ def interactive(ctx, include_calendar, exclude_calendar, mouse): ), ctx.obj['conf'] ) + except FatalError as error: + logger.debug(error, exc_info=True) + logger.fatal(error) + sys.exit(1) - @click.command() - @global_options - @multi_calendar_option - @mouse_option - @click.pass_context - def interactive_cli(ctx, config, include_calendar, exclude_calendar, mouse): - '''Interactive UI. Also launchable via `khal interactive`.''' - prepare_context(ctx, config) - if mouse is not None: - ctx.obj['conf']['default']['enable_mouse'] = mouse - controllers.interactive( +@cli.command() +@multi_calendar_option +@click.pass_context +def printcalendars(ctx, include_calendar, exclude_calendar): + '''List all calendars.''' + try: + click.echo('\n'.join(build_collection( + ctx.obj['conf'], + multi_calendar_select(ctx, include_calendar, exclude_calendar) + ).names)) + except FatalError as error: + logger.debug(error, exc_info=True) + logger.fatal(error) + sys.exit(1) + +@cli.command() +@click.pass_context +def printformats(ctx): + '''Print a date in all formats. + + Print the date 2013-12-21 21:45 in all configured date(time) + formats to check if these locale settings are configured to ones + liking.''' + time = dt.datetime(2013, 12, 21, 21, 45) + try: + for strftime_format in [ + 'longdatetimeformat', 'datetimeformat', 'longdateformat', + 'dateformat', 'timeformat']: + dt_str = time.strftime(ctx.obj['conf']['locale'][strftime_format]) + click.echo(f'{strftime_format}: {dt_str}') + except FatalError as error: + logger.debug(error, exc_info=True) + logger.fatal(error) + sys.exit(1) + +@cli.command() +@click.argument('ics', type=click.File('rb'), required=False) +@click.option('--format', '-f', + help=('The format to print the event.')) +@click.pass_context +def printics(ctx, ics, format): + '''Print an ics file (or read from stdin) without importing it. + + Just print the ics file, do nothing else.''' + try: + if ics: + ics_str = ics.read() + name = ics.name + else: + ics_str = sys.stdin.read() + name = 'stdin input' + controllers.print_ics(ctx.obj['conf'], name, ics_str, format) + except FatalError as error: + logger.debug(error, exc_info=True) + logger.fatal(error) + sys.exit(1) + +@cli.command() +@multi_calendar_option +@click.option('--format', '-f', + help=('The format of the events.')) +@click.option('--json', help=("Fields to output in json"), multiple=True) +@click.argument('search_string') +@click.pass_context +def search(ctx, format, json, search_string, include_calendar, exclude_calendar): + '''Search for events matching SEARCH_STRING. + + For recurring events, only the master event and different overwritten + events are shown. + ''' + # TODO support for time ranges, location, description etc + if format is None: + format = ctx.obj['conf']['view']['event_format'] + try: + collection = build_collection( + ctx.obj['conf'], + multi_calendar_select(ctx, include_calendar, exclude_calendar) + ) + events = sorted(collection.search(search_string)) + event_column = [] + term_width, _ = get_terminal_size() + now = dt.datetime.now() + env = {"calendars": ctx.obj['conf']['calendars']} + if len(json) == 0: + formatter = human_formatter(format) + else: + formatter = json_formatter(json) + for event in events: + desc = textwrap.wrap(formatter( + event.attributes(relative_to=now, env=env)), term_width) + event_column.extend( + [colored(d, event.color, + bold_for_light_color=ctx.obj['conf']['view']['bold_for_light_color']) + for d in desc] + ) + if event_column: + click.echo('\n'.join(event_column)) + else: + logger.debug('No events found') + except FatalError as error: + logger.debug(error, exc_info=True) + logger.fatal(error) + sys.exit(1) + +@cli.command() +@multi_calendar_option +@click.option('--format', '-f', + help=('The format of the events.')) +@click.option('--show-past', help=('Show events that have already occurred as options'), + is_flag=True) +@click.argument('search_string', nargs=-1) +@click.pass_context +def edit(ctx, format, search_string, show_past, include_calendar, exclude_calendar): + '''Interactively edit (or delete) events matching the search string.''' + try: + controllers.edit( build_collection( ctx.obj['conf'], multi_calendar_select(ctx, include_calendar, exclude_calendar) ), - ctx.obj['conf'] + ' '.join(search_string), + format=format, + allow_past=show_past, + locale=ctx.obj['conf']['locale'], + conf=ctx.obj['conf'] ) + except FatalError as error: + logger.debug(error, exc_info=True) + logger.fatal(error) + sys.exit(1) - @cli.command() - @multi_calendar_option - @click.pass_context - def printcalendars(ctx, include_calendar, exclude_calendar): - '''List all calendars.''' - try: - click.echo('\n'.join(build_collection( - ctx.obj['conf'], - multi_calendar_select(ctx, include_calendar, exclude_calendar) - ).names)) - except FatalError as error: - logger.debug(error, exc_info=True) - logger.fatal(error) - sys.exit(1) - - @cli.command() - @click.pass_context - def printformats(ctx): - '''Print a date in all formats. - - Print the date 2013-12-21 21:45 in all configured date(time) - formats to check if these locale settings are configured to ones - liking.''' - time = dt.datetime(2013, 12, 21, 21, 45) - try: - for strftime_format in [ - 'longdatetimeformat', 'datetimeformat', 'longdateformat', - 'dateformat', 'timeformat']: - dt_str = time.strftime(ctx.obj['conf']['locale'][strftime_format]) - click.echo(f'{strftime_format}: {dt_str}') - except FatalError as error: - logger.debug(error, exc_info=True) - logger.fatal(error) - sys.exit(1) - - @cli.command() - @click.argument('ics', type=click.File('rb'), required=False) - @click.option('--format', '-f', - help=('The format to print the event.')) - @click.pass_context - def printics(ctx, ics, format): - '''Print an ics file (or read from stdin) without importing it. - - Just print the ics file, do nothing else.''' - try: - if ics: - ics_str = ics.read() - name = ics.name - else: - ics_str = sys.stdin.read() - name = 'stdin input' - controllers.print_ics(ctx.obj['conf'], name, ics_str, format) - except FatalError as error: - logger.debug(error, exc_info=True) - logger.fatal(error) - sys.exit(1) - - @cli.command() - @multi_calendar_option - @click.option('--format', '-f', - help=('The format of the events.')) - @click.argument('search_string') - @click.pass_context - def search(ctx, format, search_string, include_calendar, exclude_calendar): - '''Search for events matching SEARCH_STRING. - - For recurring events, only the master event and different overwritten - events are shown. - ''' - # TODO support for time ranges, location, description etc - if format is None: - format = ctx.obj['conf']['view']['event_format'] - try: - collection = build_collection( +@cli.command() +@multi_calendar_option +@click.option('--format', '-f', + help=('The format of the events.')) +@click.option('--day-format', '-df', + help=('The format of the day line.')) +@click.option('--notstarted', help=('Print only events that have not started'), + is_flag=True) +@click.option('--json', help=("Fields to output in json"), multiple=True) +@click.argument('DATETIME', nargs=-1, required=False, metavar='[[START DATE] TIME | now]') +@click.pass_context +def at(ctx, datetime, notstarted, format, day_format, json, include_calendar, exclude_calendar): + '''Print all events at a specific datetime (defaults to now).''' + if not datetime: + datetime = ("now",) + if format is None: + format = ctx.obj['conf']['view']['event_format'] + try: + rows = controllers.khal_list( + build_collection( ctx.obj['conf'], multi_calendar_select(ctx, include_calendar, exclude_calendar) - ) - events = sorted(collection.search(search_string)) - event_column = [] - term_width, _ = get_terminal_size() - now = dt.datetime.now() - env = {"calendars": ctx.obj['conf']['calendars']} - for event in events: - desc = textwrap.wrap(event.format(format, relative_to=now, env=env), term_width) - event_column.extend( - [colored(d, event.color, - bold_for_light_color=ctx.obj['conf']['view']['bold_for_light_color']) - for d in desc] - ) - if event_column: - click.echo('\n'.join(event_column)) - else: - logger.debug('No events found') - except FatalError as error: - logger.debug(error, exc_info=True) - logger.fatal(error) - sys.exit(1) - - @cli.command() - @multi_calendar_option - @click.option('--format', '-f', - help=('The format of the events.')) - @click.option('--show-past', help=('Show events that have already occurred as options'), - is_flag=True) - @click.argument('search_string', nargs=-1) - @click.pass_context - def edit(ctx, format, search_string, show_past, include_calendar, exclude_calendar): - '''Interactively edit (or delete) events matching the search string.''' - try: - controllers.edit( - build_collection( - ctx.obj['conf'], - multi_calendar_select(ctx, include_calendar, exclude_calendar) - ), - ' '.join(search_string), - format=format, - allow_past=show_past, - locale=ctx.obj['conf']['locale'], - conf=ctx.obj['conf'] - ) - except FatalError as error: - logger.debug(error, exc_info=True) - logger.fatal(error) - sys.exit(1) - - @cli.command() - @multi_calendar_option - @click.option('--format', '-f', - help=('The format of the events.')) - @click.option('--day-format', '-df', - help=('The format of the day line.')) - @click.option('--notstarted', help=('Print only events that have not started'), - is_flag=True) - @click.argument('DATETIME', nargs=-1, required=False, metavar='[[START DATE] TIME | now]') - @click.pass_context - def at(ctx, datetime, notstarted, format, day_format, include_calendar, exclude_calendar): - '''Print all events at a specific datetime (defaults to now).''' - if not datetime: - datetime = ("now",) - if format is None: - format = ctx.obj['conf']['view']['event_format'] - try: - rows = controllers.khal_list( - build_collection( - ctx.obj['conf'], - multi_calendar_select(ctx, include_calendar, exclude_calendar) - ), - agenda_format=format, - day_format=day_format, - datepoint=list(datetime), - once=True, - notstarted=notstarted, - conf=ctx.obj['conf'], - env={"calendars": ctx.obj['conf']['calendars']} - ) - if rows: - click.echo('\n'.join(rows)) - except FatalError as error: - logger.debug(error, exc_info=True) - logger.fatal(error) - sys.exit(1) - - @cli.command() - @click.pass_context - def configure(ctx): - """Helper for initial configuration of khal.""" - from . import configwizard - try: - configwizard.configwizard() - except FatalError as error: - logger.debug(error, exc_info=True) - logger.fatal(error) - sys.exit(1) + ), + agenda_format=format, + day_format=day_format, + datepoint=list(datetime), + once=True, + notstarted=notstarted, + conf=ctx.obj['conf'], + env={"calendars": ctx.obj['conf']['calendars']}, + json=json + ) + if rows: + click.echo('\n'.join(rows)) + except FatalError as error: + logger.debug(error, exc_info=True) + logger.fatal(error) + sys.exit(1) + +@cli.command() +@click.pass_context +def configure(ctx): + """Helper for initial configuration of khal.""" + from . import configwizard + try: + configwizard.configwizard() + except FatalError as error: + logger.debug(error, exc_info=True) + logger.fatal(error) + sys.exit(1) + - return cli, interactive_cli +def find_load_plugins(): + """Find and load all plugins in the plugin directory.""" + # check all folders in $XDG_DATA_HOME/khal/plugins if they contain a + # __init__.py file. If so, import them. + plugin_dir = os.path.join(xdg.BaseDirectory.xdg_data_home, 'khal', 'plugins') + if not os.path.isdir(plugin_dir): + return + sys.path.append(plugin_dir) + for plugin in os.listdir(plugin_dir): + if os.path.isfile(os.path.join(plugin_dir, plugin, '__init__.py')): + logger.debug(f'loading plugin {plugin} at {plugin_dir}') + __import__(plugin) +find_load_plugins() -main_khal, main_ikhal = _get_cli() +main_khal, main_ikhal = cli, interactive_cli diff --git a/khal/controllers.py b/khal/controllers.py index cb789301c..a850b33c8 100644 --- a/khal/controllers.py +++ b/khal/controllers.py @@ -27,12 +27,12 @@ import textwrap from collections import OrderedDict, defaultdict from shutil import get_terminal_size -from typing import List, Optional +from typing import Callable, List, Optional import pytz from click import confirm, echo, prompt, style -from khal import __productname__, __version__, calendar_display, parse_datetime, utils +from khal import __productname__, __version__, calendar_display, parse_datetime from khal.custom_types import ( EventCreationTypes, LocaleConfiguration, @@ -49,6 +49,7 @@ from .icalendar import sort_key as sort_vevent_key from .khalendar.vdir import Item from .terminal import merge_columns +from .utils import human_formatter, json_formatter logger = logging.getLogger('khal') @@ -95,6 +96,7 @@ def calendar( bold_for_light_color: bool=True, env=None, ): + term_width, _ = get_terminal_size() lwidth = 27 if conf['locale']['weeknumbers'] == 'right' else 25 rwidth = term_width - lwidth - 4 @@ -170,24 +172,27 @@ def get_events_between( locale: dict, start: dt.datetime, end: dt.datetime, - agenda_format: str, + formatter: Callable, notstarted: bool, env: dict, - width: Optional[int], - seen, original_start: dt.datetime, + seen=None, + colors: bool = True, ) -> List[str]: """returns a list of events scheduled between start and end. Start and end are strings or datetimes (of some kind). :param collection: + :param locale: :param start: the start datetime :param end: the end datetime - :param agenda_format: a format string that can be used in python string formatting - :param env: a collection of "static" values like calendar names and color + :param formatter: the formatter (see :class:`.utils.human_formatter`) :param nostarted: True if each event should start after start (instead of - be active between start and end) + be active between start and end) + :param env: a collection of "static" values like calendar names and color :param original_start: start datetime to compare against of notstarted is set + :param seen: + :param colors: :returns: a list to be printed as the agenda for the given days """ assert not (notstarted and not original_start) @@ -218,23 +223,20 @@ def get_events_between( continue try: - event_string = event.format(agenda_format, relative_to=(start, end), env=env) + event_attributes = event.attributes(relative_to=(start, end), env=env, colors=colors) except KeyError as error: raise FatalError(error) - if width: - event_list += utils.color_wrap(event_string, width) - else: - event_list.append(event_string) + event_list.append(event_attributes) if seen is not None: seen.add(event.uid) - return event_list + return formatter(event_list) def khal_list( collection, - daterange: Optional[List[str]]=None, + daterange: Optional[List[str]] = None, conf: Optional[dict] = None, agenda_format=None, day_format: Optional[str]=None, @@ -243,6 +245,7 @@ def khal_list( width: Optional[int] = None, env=None, datepoint=None, + json: Optional[List] = None, ): """returns a list of all events in `daterange`""" assert daterange is not None or datepoint is not None @@ -252,6 +255,14 @@ def khal_list( if agenda_format is None: agenda_format = conf['view']['agenda_event_format'] + json_mode = json is not None and len(json) > 0 + if json_mode: + formatter = json_formatter(json) + colors = False + else: + formatter = human_formatter(agenda_format, width) + colors = True + if daterange is not None: if day_format is None: day_format = conf['view']['agenda_day_format'] @@ -296,13 +307,13 @@ def khal_list( else: day_end = dt.datetime.combine(start.date(), dt.time.max) current_events = get_events_between( - collection, locale=conf['locale'], agenda_format=agenda_format, start=start, + collection, locale=conf['locale'], formatter=formatter, start=start, end=day_end, notstarted=notstarted, original_start=original_start, env=env, seen=once, - width=width, + colors=colors, ) - if day_format and (conf['default']['show_all_days'] or current_events): + if day_format and (conf['default']['show_all_days'] or current_events) and not json_mode: if len(event_column) != 0 and conf['view']['blank_line_before_day']: event_column.append('') event_column.append(format_day(start.date(), day_format, conf['locale'])) @@ -314,7 +325,7 @@ def khal_list( def new_interactive(collection, calendar_name, conf, info, location=None, categories=None, repeat=None, until=None, alarms=None, - format=None, env=None, url=None): + format=None, json=None, env=None, url=None): info: EventCreationTypes try: info = parse_datetime.eventinfofstr( @@ -381,6 +392,7 @@ def new_interactive(collection, calendar_name, conf, info, location=None, format=format, env=env, calendar_name=calendar_name, + json=json, ) echo("event saved") @@ -390,7 +402,7 @@ def new_interactive(collection, calendar_name, conf, info, location=None, def new_from_string(collection, calendar_name, conf, info, location=None, categories=None, repeat=None, until=None, alarms=None, - url=None, format=None, env=None): + url=None, format=None, json=None, env=None): """construct a new event from a string and add it""" info = parse_datetime.eventinfofstr( info, conf['locale'], @@ -406,7 +418,15 @@ def new_from_string(collection, calendar_name, conf, info, location=None, 'alarms': alarms, 'url': url, }) - new_from_dict(info, collection, conf=conf, format=format, env=env, calendar_name=calendar_name) + new_from_dict( + info, + collection, + conf=conf, + format=format, + env=env, + calendar_name=calendar_name, + json=json, + ) def new_from_dict( @@ -416,6 +436,7 @@ def new_from_dict( calendar_name: Optional[str]=None, format=None, env=None, + json=None, ) -> Event: """Create a new event from arguments and save in vdirs @@ -437,9 +458,13 @@ def new_from_dict( ) if conf['default']['print_new'] == 'event': - if format is None: - format = conf['view']['event_format'] - echo(event.format(format, dt.datetime.now(), env=env)) + if json is None or len(json) == 0: + if format is None: + format = conf['view']['event_format'] + formatter = human_formatter(format) + else: + formatter = json_formatter(json) + echo(formatter(event.attributes(dt.datetime.now(), env=env))) elif conf['default']['print_new'] == 'path': assert event.href path = os.path.join(collection._calendars[event.calendar]['path'], event.href) @@ -504,7 +529,7 @@ def edit_event(event, collection, locale, allow_quit=False, width=80): collection.delete(event.href, event.etag, event.calendar) return True elif choice == "datetime range": - current = event.format("{start} {end}", relative_to=now) + current = human_formatter("{start} {end}")(event.attributes(relative_to=now)) value = prompt("datetime range", default=current) try: start, end, allday = parse_datetime.guessrangefstr(ansi.sub('', value), locale) @@ -588,7 +613,8 @@ def edit(collection, search_string, locale, format=None, allow_past=False, conf= continue elif not event.allday and event.end_local < now: continue - event_text = textwrap.wrap(event.format(format, relative_to=now), term_width) + event_text = textwrap.wrap(human_formatter(format)( + event.attributes(relative_to=now)), term_width) echo(''.join(event_text)) if not edit_event(event, collection, locale, allow_quit=True, width=term_width): return @@ -640,7 +666,7 @@ def import_event(vevent, collection, locale, batch, format=None, env=None): if item.name == 'VEVENT': event = Event.fromVEvents( [item], calendar=collection.default_calendar_name, locale=locale) - echo(event.format(format, dt.datetime.now(), env=env)) + echo(human_formatter(format)(event.attributes(dt.datetime.now(), env=env))) # get the calendar to insert into if not collection.writable_names: @@ -697,4 +723,4 @@ def print_ics(conf, name, ics, format): echo(f'{len(vevents)} events found in {name}') for sub_event in vevents: event = Event.fromVEvents(sub_event, locale=conf['locale']) - echo(event.format(format, dt.datetime.now())) + echo(human_formatter(format)(event.attributes(dt.datetime.now()))) diff --git a/khal/khalendar/event.py b/khal/khalendar/event.py index 010da1ede..fe506b234 100644 --- a/khal/khalendar/event.py +++ b/khal/khalendar/event.py @@ -38,7 +38,6 @@ from ..exceptions import FatalError from ..icalendar import cal_from_ics, delete_instance, invalid_timezone from ..parse_datetime import timedelta2str -from ..terminal import get_color from ..utils import generate_random_uid, is_aware, to_naive_utc, to_unix_time logger = logging.getLogger('khal') @@ -555,10 +554,9 @@ def _alarm_str(self) -> str: alarmstr = '' return alarmstr - def format(self, format_string: str, relative_to, env=None, colors: bool=True): + def attributes(self, relative_to, env=None, colors: bool=True): """ :param colors: determines if colors codes should be printed or not - :type colors: bool """ env = env or {} @@ -699,7 +697,7 @@ def format(self, format_string: str, relative_to, env=None, colors: bool=True): if "calendars" in env and self.calendar in env["calendars"]: cal = env["calendars"][self.calendar] - attributes["calendar-color"] = get_color(cal.get('color', '')) + attributes["calendar-color"] = cal.get('color', '') attributes["calendar"] = cal.get("displayname", self.calendar) else: attributes["calendar-color"] = attributes["calendar"] = '' @@ -722,7 +720,7 @@ def format(self, format_string: str, relative_to, env=None, colors: bool=True): attributes['status'] = self.status + ' ' if self.status else '' attributes['cancelled'] = 'CANCELLED ' if self.status == 'CANCELLED' else '' - return format_string.format(**dict(attributes)) + attributes["reset"] + return attributes def duplicate(self) -> 'Event': """duplicate this event's PROTO event""" diff --git a/khal/settings/khal.spec b/khal/settings/khal.spec index ec4e8eefd..951a169aa 100644 --- a/khal/settings/khal.spec +++ b/khal/settings/khal.spec @@ -258,7 +258,7 @@ blank_line_before_day = boolean(default=False) # # __ http://urwid.org/manual/displayattributes.html # .. _github: https://github.com/pimutils/khal/issues -theme = option('dark', 'light', default='dark') +theme = string(default='dark') # Whether to show a visible frame (with *box drawing* characters) around some # (groups of) elements or not. There are currently several different frame diff --git a/khal/ui/__init__.py b/khal/ui/__init__.py index 806501b43..dc8839922 100644 --- a/khal/ui/__init__.py +++ b/khal/ui/__init__.py @@ -31,7 +31,7 @@ from .. import utils from ..khalendar import CalendarCollection -from ..khalendar.exceptions import ReadOnlyCalendarError +from ..khalendar.exceptions import FatalError, ReadOnlyCalendarError from . import colors from .base import Pane, Window from .editor import EventEditor, ExportDialog @@ -205,13 +205,14 @@ def set_title(self, mark: str=' ') -> None: format_ = self._conf['view']['agenda_event_format'] else: format_ = self._conf['view']['event_format'] + formatter_ = utils.human_formatter(format_, colors=False) if self.this_date: date_ = self.this_date elif self.event.allday: date_ = self.event.start else: date_ = self.event.start.date() - text = self.event.format(format_, date_, colors=False) + text = formatter_(self.event.attributes(date_, colors=False)) if self._conf['locale']['unicode_symbols']: newline = ' \N{LEFTWARDS ARROW WITH HOOK} ' else: @@ -1356,6 +1357,17 @@ def start_pane( color_mode: Literal['rgb', '256colors']='rgb', ): """Open the user interface with the given initial pane.""" + + # we can't use configobj to do validation of theme for us, because when the + # config is passed, we haven't loaded plugins yet. We could load plugins + # earlier, but then logging wouldn't work at plugin loading time. + # We do this early, so that logger messages still get shown in a regular way + theme = colors.themes.get(pane._conf['view']['theme']) + if theme is None: + logger.fatal(f'Invalid theme {pane._conf["view"]["theme"]} configured') + logger.fatal(f'Available themes are: {", ".join(colors.themes.keys())}') + raise FatalError + quit_keys = quit_keys or ['q'] frame = Window( @@ -1406,7 +1418,6 @@ def emit(self, record): logger.addHandler(header_handler) frame.open(pane, callback) - theme = getattr(colors, pane._conf['view']['theme']) palette = _add_calendar_colors( theme, pane.collection, color_mode=color_mode, base='calendar', attr_template='calendar {}', diff --git a/khal/ui/colors.py b/khal/ui/colors.py index 5cffb116d..055a56364 100644 --- a/khal/ui/colors.py +++ b/khal/ui/colors.py @@ -107,3 +107,7 @@ ] themes: Dict[str, List[Tuple[str, ...]]] = {'light': light, 'dark': dark} + + +def register_color_theme(name: str, theme: List[Tuple[str, ...]]): + themes[name] = theme diff --git a/khal/utils.py b/khal/utils.py index cac1226ef..fae820b20 100644 --- a/khal/utils.py +++ b/khal/utils.py @@ -23,6 +23,7 @@ import datetime as dt +import json import random import re import string @@ -32,6 +33,9 @@ import pytz import urwid +from click import style + +from .terminal import get_color def generate_random_uid() -> str: @@ -180,3 +184,74 @@ def relative_timedelta_str(day: dt.date) -> str: def get_wrapped_text(widget: urwid.AttrMap) -> str: return widget.original_widget.get_edit_text() + + +def human_formatter(format_string, width=None, colors=True): + """Create a formatter that formats events to be human readable.""" + def fmt(rows): + single = type(rows) == dict + if single: + rows = [rows] + results = [] + for row in rows: + if 'calendar-color' in row: + row['calendar-color'] = get_color(row['calendar-color']) + + s = format_string.format(**row) + + if colors: + s += style('', reset=True) + + if width: + results += color_wrap(s, width) + else: + results.append(s) + if single: + return results[0] + else: + return results + return fmt + + +CONTENT_ATTRIBUTES = ['start', 'start-long', 'start-date', 'start-date-long', + 'start-time', 'end', 'end-long', 'end-date', 'end-date-long', 'end-time', + 'duration', 'start-full', 'start-long-full', 'start-date-full', + 'start-date-long-full', 'start-time-full', 'end-full', 'end-long-full', + 'end-date-full', 'end-date-long-full', 'end-time-full', 'duration-full', + 'start-style', 'end-style', 'to-style', 'start-end-time-style', + 'end-necessary', 'end-necessary-long', 'repeat-symbol', 'repeat-pattern', + 'title', 'organizer', 'description', 'location', 'all-day', 'categories', + 'uid', 'url', 'calendar', 'calendar-color', 'status', 'cancelled'] + + +def json_formatter(fields): + """Create a formatter that formats events in JSON.""" + + if len(fields) == 1 and fields[0] == 'all': + fields = CONTENT_ATTRIBUTES + + def fmt(rows): + single = type(rows) == dict + if single: + rows = [rows] + + filtered = [] + for row in rows: + f = dict(filter(lambda e: e[0] in fields and e[0] in CONTENT_ATTRIBUTES, row.items())) + + if f.get('repeat-symbol', '') != '': + f["repeat-symbol"] = f["repeat-symbol"].strip() + if f.get('status', '') != '': + f["status"] = f["status"].strip() + if f.get('cancelled', '') != '': + f["cancelled"] = f["cancelled"].strip() + + filtered.append(f) + + results = [json.dumps(filtered, ensure_ascii=False)] + + if single: + return results[0] + else: + return results + return fmt diff --git a/tests/cli_test.py b/tests/cli_test.py index f2b827103..5f90dbaaf 100644 --- a/tests/cli_test.py +++ b/tests/cli_test.py @@ -1,13 +1,16 @@ import datetime as dt +import json import os import re import sys +import traceback import pytest from click.testing import CliRunner from freezegun import freeze_time from khal.cli import main_ikhal, main_khal +from khal.utils import CONTENT_ATTRIBUTES from .utils import _get_ics_filepath, _get_text @@ -387,6 +390,46 @@ def test_at(runner): assert result.output.startswith('myevent') +def test_at_json(runner): + runner = runner(days=2) + now = dt.datetime.now().strftime('%d.%m.%Y') + end_date = dt.datetime.now() + dt.timedelta(days=10) + result = runner.invoke( + main_khal, + 'new {} {} 18:00 myevent'.format(now, end_date.strftime('%d.%m.%Y')).split()) + args = ['--color', 'at', '--json', 'start-time', '--json', 'title', '18:30'] + result = runner.invoke(main_khal, args) + assert not result.exception + assert result.output.startswith('[{"start-time": "", "title": "myevent"}]') + + +def test_at_json_default_fields(runner): + runner = runner(days=2) + now = dt.datetime.now().strftime('%d.%m.%Y') + end_date = dt.datetime.now() + dt.timedelta(days=10) + result = runner.invoke( + main_khal, + 'new {} {} 18:00 myevent'.format(now, end_date.strftime('%d.%m.%Y')).split()) + args = ['--color', 'at', '--json', 'all', '18:30'] + result = runner.invoke(main_khal, args) + assert not result.exception + output_fields = json.loads(result.output)[0].keys() + assert all(x in output_fields for x in CONTENT_ATTRIBUTES) + + +def test_at_json_strip(runner): + runner = runner() + result = runner.invoke(main_khal, ['import', _get_ics_filepath( + 'event_rrule_recuid_cancelled')], input='0\ny\n') + assert not result.exception + result = runner.invoke(main_khal, ['at', '--json', 'repeat-symbol', + '--json', 'status', '--json', 'cancelled', '14.07.2014', '07:00']) + traceback.print_tb(result.exc_info[2]) + assert not result.exception + assert result.output.startswith( + '[{"repeat-symbol": "⟳", "status": "CANCELLED", "cancelled": "CANCELLED"}]') + + def test_at_day_format(runner): runner = runner(days=2) now = dt.datetime.now().strftime('%d.%m.%Y') @@ -414,6 +457,20 @@ def test_list(runner): assert result.output.startswith(expected) +def test_list_json(runner): + runner = runner(days=2) + now = dt.datetime.now().strftime('%d.%m.%Y') + result = runner.invoke( + main_khal, + f'new {now} 18:00 myevent'.split()) + args = ['list', '--json', 'start-end-time-style', + '--json', 'title', '--json', 'description', '18:30'] + result = runner.invoke(main_khal, args) + expected = '[{"start-end-time-style": "18:00-19:00", "title": "myevent", "description": ""}]' + assert not result.exception + assert result.output.startswith(expected) + + def test_search(runner): runner = runner(days=2) now = dt.datetime.now().strftime('%d.%m.%Y') @@ -424,6 +481,16 @@ def test_search(runner): assert result.output.startswith('\x1b[34m\x1b[31m18:00') +def test_search_json(runner): + runner = runner(days=2) + now = dt.datetime.now().strftime('%d.%m.%Y') + result = runner.invoke(main_khal, f'new {now} 18:00 myevent'.split()) + result = runner.invoke(main_khal, ['search', '--json', 'start-end-time-style', + '--json', 'title', '--json', 'description', 'myevent']) + assert not result.exception + assert result.output.startswith('[{"start-end-time-style": "18:00') + + def test_no_default_new(runner): runner = runner(default_calendar=False) result = runner.invoke(main_khal, 'new 18:00 beer'.split()) @@ -838,7 +905,27 @@ def test_new(runner): assert result.output.startswith(str(runner.tmpdir)) -@freeze_time('2015-6-1 8:00') +def test_new_format(runner): + runner = runner(print_new='event') + + format = '{start-end-time-style}: {title}' + result = runner.invoke(main_khal, ['new', '13.03.2016 12:00', '3d', + '--format', format, 'Visit']) + assert not result.exception + assert result.output.startswith('→12:00: Visit') + + +def test_new_json(runner): + runner = runner(print_new='event') + + result = runner.invoke(main_khal, ['new', '13.03.2016 12:00', '3d', + '--json', 'start-end-time-style', '--json', 'title', 'Visit']) + assert not result.exception + assert result.output.startswith( + '[{"start-end-time-style": "→12:00", "title": "Visit"}]') + + +@ freeze_time('2015-6-1 8:00') def test_new_interactive(runner): runner = runner(print_new='path') diff --git a/tests/event_test.py b/tests/event_test.py index 34e330439..585e25a50 100644 --- a/tests/event_test.py +++ b/tests/event_test.py @@ -8,6 +8,7 @@ from icalendar import Parameters, vCalAddress, vRecur, vText from packaging import version +from khal.controllers import human_formatter from khal.khalendar.event import AllDayEvent, Event, FloatingEvent, LocalizedEvent, create_timezone from .utils import ( @@ -25,10 +26,13 @@ EVENT_KWARGS = {'calendar': 'foobar', 'locale': LOCALE_BERLIN} LIST_FORMAT = '{calendar-color}{cancelled}{start-end-time-style} {title}{repeat-symbol}' +LIST_FORMATTER = human_formatter(LIST_FORMAT) SEARCH_FORMAT = '{calendar-color}{cancelled}{start-long}{to-style}' + \ '{end-necessary-long} {title}{repeat-symbol}' -FORMAT_CALENDAR = ('{calendar-color}{cancelled}{start-end-time-style} ({calendar}) ' +CALENDAR_FORMAT = ('{calendar-color}{cancelled}{start-end-time-style} ({calendar}) ' '{title} [{location}]{repeat-symbol}') +CALENDAR_FORMATTER = human_formatter(CALENDAR_FORMAT) +SEARCH_FORMATTER = human_formatter(SEARCH_FORMAT) def test_no_initialization(): @@ -51,8 +55,9 @@ def test_raw_dt(): normalize_component(_get_text('event_dt_simple_inkl_vtimezone')) event = Event.fromString(event_dt, **EVENT_KWARGS) - assert event.format(LIST_FORMAT, dt.date(2014, 4, 9)) == '09:30-10:30 An Event\x1b[0m' - assert event.format(SEARCH_FORMAT, dt.date(2014, 4, 9)) == \ + assert LIST_FORMATTER(event.attributes( + dt.date(2014, 4, 9))) == '09:30-10:30 An Event\x1b[0m' + assert SEARCH_FORMATTER(event.attributes(dt.date(2014, 4, 9))) == \ '09.04.2014 09:30-10:30 An Event\x1b[0m' assert event.recurring is False assert event.duration == dt.timedelta(hours=1) @@ -69,7 +74,7 @@ def test_calendar_in_format(): start = BERLIN.localize(dt.datetime(2014, 4, 9, 9, 30)) end = BERLIN.localize(dt.datetime(2014, 4, 9, 10, 30)) event = Event.fromString(event_dt, start=start, end=end, **EVENT_KWARGS) - assert event.format(FORMAT_CALENDAR, dt.date(2014, 4, 9)) == \ + assert CALENDAR_FORMATTER(event.attributes(dt.date(2014, 4, 9))) == \ '09:30-10:30 (foobar) An Event []\x1b[0m' @@ -99,7 +104,7 @@ def test_no_end(): event = Event.fromString(_get_text('event_dt_no_end'), **EVENT_KWARGS) # TODO make sure the event also gets converted to an all day event, as we # usually do - assert event.format(SEARCH_FORMAT, dt.date(2014, 4, 12)) == \ + assert SEARCH_FORMATTER(event.attributes(dt.date(2014, 4, 12))) == \ '16.01.2016 08:00-17.01.2016 08:00 Test\x1b[0m' @@ -150,8 +155,8 @@ def test_raw_d(): event_d = _get_text('event_d') event = Event.fromString(event_d, **EVENT_KWARGS) assert event.raw.split('\r\n') == _get_text('cal_d').split('\n') - assert event.format(LIST_FORMAT, dt.date(2014, 4, 9)) == ' An Event\x1b[0m' - assert event.format(SEARCH_FORMAT, dt.date(2014, 4, 9)) == '09.04.2014 An Event\x1b[0m' + assert LIST_FORMATTER(event.attributes(dt.date(2014, 4, 9))) == ' An Event\x1b[0m' + assert SEARCH_FORMATTER(event.attributes(dt.date(2014, 4, 9))) == '09.04.2014 An Event\x1b[0m' def test_update_sequence(): @@ -177,8 +182,8 @@ def test_transform_event(): end = BERLIN.localize(dt.datetime(2014, 4, 9, 10, 30)) event.update_start_end(start, end) assert isinstance(event, LocalizedEvent) - assert event.format(LIST_FORMAT, dt.date(2014, 4, 9)) == '09:30-10:30 An Event\x1b[0m' - assert event.format(SEARCH_FORMAT, dt.date(2014, 4, 9)) == \ + assert LIST_FORMATTER(event.attributes(dt.date(2014, 4, 9))) == '09:30-10:30 An Event\x1b[0m' + assert SEARCH_FORMATTER(event.attributes(dt.date(2014, 4, 9))) == \ '09.04.2014 09:30-10:30 An Event\x1b[0m' analog_event = Event.fromString(_get_text('event_dt_simple'), **EVENT_KWARGS) assert normalize_component(event.raw) == normalize_component(analog_event.raw) @@ -191,10 +196,10 @@ def test_update_event_d(): event_d = _get_text('event_d') event = Event.fromString(event_d, **EVENT_KWARGS) event.update_start_end(dt.date(2014, 4, 20), dt.date(2014, 4, 22)) - assert event.format(LIST_FORMAT, dt.date(2014, 4, 20)) == '↦ An Event\x1b[0m' - assert event.format(LIST_FORMAT, dt.date(2014, 4, 21)) == '↔ An Event\x1b[0m' - assert event.format(LIST_FORMAT, dt.date(2014, 4, 22)) == '⇥ An Event\x1b[0m' - assert event.format(SEARCH_FORMAT, dt.date(2014, 4, 20)) == \ + assert LIST_FORMATTER(event.attributes(dt.date(2014, 4, 20))) == '↦ An Event\x1b[0m' + assert LIST_FORMATTER(event.attributes(dt.date(2014, 4, 21))) == '↔ An Event\x1b[0m' + assert LIST_FORMATTER(event.attributes(dt.date(2014, 4, 22))) == '⇥ An Event\x1b[0m' + assert SEARCH_FORMATTER(event.attributes(dt.date(2014, 4, 20))) == \ '20.04.2014-22.04.2014 An Event\x1b[0m' assert 'DTSTART;VALUE=DATE:20140420' in event.raw.split('\r\n') assert 'DTEND;VALUE=DATE:20140423' in event.raw.split('\r\n') @@ -226,8 +231,8 @@ def test_dt_two_tz(): # local (Berlin) time! assert event.start_local == BERLIN.localize(dt.datetime(2014, 4, 9, 9, 30)) assert event.end_local == BERLIN.localize(dt.datetime(2014, 4, 9, 16, 30)) - assert event.format(LIST_FORMAT, dt.date(2014, 4, 9)) == '09:30-16:30 An Event\x1b[0m' - assert event.format(SEARCH_FORMAT, dt.date(2014, 4, 9)) == \ + assert LIST_FORMATTER(event.attributes(dt.date(2014, 4, 9))) == '09:30-16:30 An Event\x1b[0m' + assert SEARCH_FORMATTER(event.attributes(dt.date(2014, 4, 9))) == \ '09.04.2014 09:30-16:30 An Event\x1b[0m' @@ -237,10 +242,11 @@ def test_event_dt_duration(): event = Event.fromString(event_dt_duration, **EVENT_KWARGS) assert event.start == BERLIN.localize(dt.datetime(2014, 4, 9, 9, 30)) assert event.end == BERLIN.localize(dt.datetime(2014, 4, 9, 10, 30)) - assert event.format(LIST_FORMAT, dt.date(2014, 4, 9)) == '09:30-10:30 An Event\x1b[0m' - assert event.format(SEARCH_FORMAT, dt.date(2014, 4, 9)) == \ + assert LIST_FORMATTER(event.attributes(dt.date(2014, 4, 9))) == '09:30-10:30 An Event\x1b[0m' + assert SEARCH_FORMATTER(event.attributes(dt.date(2014, 4, 9))) == \ '09.04.2014 09:30-10:30 An Event\x1b[0m' - assert event.format('{duration}', relative_to=dt.date.today()) == '1h\x1b[0m' + assert human_formatter('{duration}')(event.attributes( + relative_to=dt.date.today())) == '1h\x1b[0m' def test_event_dt_floating(): @@ -248,9 +254,10 @@ def test_event_dt_floating(): event_str = _get_text('event_dt_floating') event = Event.fromString(event_str, **EVENT_KWARGS) assert isinstance(event, FloatingEvent) - assert event.format(LIST_FORMAT, dt.date(2014, 4, 9)) == '09:30-10:30 An Event\x1b[0m' - assert event.format('{duration}', relative_to=dt.date.today()) == '1h\x1b[0m' - assert event.format(SEARCH_FORMAT, dt.date(2014, 4, 9)) == \ + assert LIST_FORMATTER(event.attributes(dt.date(2014, 4, 9))) == '09:30-10:30 An Event\x1b[0m' + assert human_formatter('{duration}')(event.attributes( + relative_to=dt.date.today())) == '1h\x1b[0m' + assert SEARCH_FORMATTER(event.attributes(dt.date(2014, 4, 9))) == \ '09.04.2014 09:30-10:30 An Event\x1b[0m' assert event.start == dt.datetime(2014, 4, 9, 9, 30) assert event.end == dt.datetime(2014, 4, 9, 10, 30) @@ -272,7 +279,8 @@ def test_event_dt_tz_missing(): assert event.end == BERLIN.localize(dt.datetime(2014, 4, 9, 10, 30)) assert event.start_local == BERLIN.localize(dt.datetime(2014, 4, 9, 9, 30)) assert event.end_local == BERLIN.localize(dt.datetime(2014, 4, 9, 10, 30)) - assert event.format('{duration}', relative_to=dt.date.today()) == '1h\x1b[0m' + assert human_formatter('{duration}')(event.attributes( + relative_to=dt.date.today())) == '1h\x1b[0m' event = Event.fromString(event_str, calendar='foobar', locale=LOCALE_MIXED) assert event.start == BERLIN.localize(dt.datetime(2014, 4, 9, 9, 30)) @@ -286,27 +294,29 @@ def test_event_dt_rr(): event = Event.fromString(event_dt_rr, **EVENT_KWARGS) assert event.recurring is True - assert event.format(LIST_FORMAT, dt.date(2014, 4, 9)) == '09:30-10:30 An Event ⟳\x1b[0m' - assert event.format(SEARCH_FORMAT, dt.date(2014, 4, 9)) == \ + assert LIST_FORMATTER(event.attributes(dt.date(2014, 4, 9))) == '09:30-10:30 An Event ⟳\x1b[0m' + assert SEARCH_FORMATTER(event.attributes(dt.date(2014, 4, 9))) == \ '09.04.2014 09:30-10:30 An Event ⟳\x1b[0m' - assert event.format('{repeat-pattern}', dt.date(2014, 4, 9)) == 'FREQ=DAILY;COUNT=10\x1b[0m' + assert human_formatter('{repeat-pattern}')(event.attributes(dt.date(2014, 4, 9)) + ) == 'FREQ=DAILY;COUNT=10\x1b[0m' def test_event_d_rr(): event_d_rr = _get_text('event_d_rr') event = Event.fromString(event_d_rr, **EVENT_KWARGS) assert event.recurring is True - assert event.format(LIST_FORMAT, dt.date(2014, 4, 9)) == ' Another Event ⟳\x1b[0m' - assert event.format(SEARCH_FORMAT, dt.date(2014, 4, 9)) == \ + assert LIST_FORMATTER(event.attributes(dt.date(2014, 4, 9))) == ' Another Event ⟳\x1b[0m' + assert SEARCH_FORMATTER(event.attributes(dt.date(2014, 4, 9))) == \ '09.04.2014 Another Event ⟳\x1b[0m' - assert event.format('{repeat-pattern}', dt.date(2014, 4, 9)) == 'FREQ=DAILY;COUNT=10\x1b[0m' + assert human_formatter('{repeat-pattern}')(event.attributes(dt.date(2014, 4, 9)) + ) == 'FREQ=DAILY;COUNT=10\x1b[0m' start = dt.date(2014, 4, 10) end = dt.date(2014, 4, 11) event = Event.fromString(event_d_rr, start=start, end=end, **EVENT_KWARGS) assert event.recurring is True - assert event.format(LIST_FORMAT, dt.date(2014, 4, 10)) == ' Another Event ⟳\x1b[0m' - assert event.format(SEARCH_FORMAT, dt.date(2014, 4, 10)) == \ + assert LIST_FORMATTER(event.attributes(dt.date(2014, 4, 10))) == ' Another Event ⟳\x1b[0m' + assert SEARCH_FORMATTER(event.attributes(dt.date(2014, 4, 10))) == \ '10.04.2014 Another Event ⟳\x1b[0m' @@ -319,23 +329,24 @@ def test_event_rd(): def test_event_d_long(): event_d_long = _get_text('event_d_long') event = Event.fromString(event_d_long, **EVENT_KWARGS) - assert event.format(LIST_FORMAT, dt.date(2014, 4, 9)) == '↦ Another Event\x1b[0m' - assert event.format(LIST_FORMAT, dt.date(2014, 4, 10)) == '↔ Another Event\x1b[0m' - assert event.format(LIST_FORMAT, dt.date(2014, 4, 11)) == '⇥ Another Event\x1b[0m' - assert event.format(LIST_FORMAT, dt.date(2014, 4, 12)) == ' Another Event\x1b[0m' - assert event.format(SEARCH_FORMAT, dt.date(2014, 4, 16)) == \ + assert LIST_FORMATTER(event.attributes(dt.date(2014, 4, 9))) == '↦ Another Event\x1b[0m' + assert LIST_FORMATTER(event.attributes(dt.date(2014, 4, 10))) == '↔ Another Event\x1b[0m' + assert LIST_FORMATTER(event.attributes(dt.date(2014, 4, 11))) == '⇥ Another Event\x1b[0m' + assert LIST_FORMATTER(event.attributes(dt.date(2014, 4, 12))) == ' Another Event\x1b[0m' + assert SEARCH_FORMATTER(event.attributes(dt.date(2014, 4, 16))) == \ '09.04.2014-11.04.2014 Another Event\x1b[0m' - assert event.format('{duration}', relative_to=dt.date(2014, 4, 11)) == '3d\x1b[0m' + assert human_formatter('{duration}')(event.attributes( + relative_to=dt.date(2014, 4, 11))) == '3d\x1b[0m' def test_event_d_two_days(): event_d_long = _get_text('event_d_long') event = Event.fromString(event_d_long, **EVENT_KWARGS) event.update_start_end(dt.date(2014, 4, 9), dt.date(2014, 4, 10)) - assert event.format(LIST_FORMAT, dt.date(2014, 4, 9)) == '↦ Another Event\x1b[0m' - assert event.format(LIST_FORMAT, dt.date(2014, 4, 10)) == '⇥ Another Event\x1b[0m' - assert event.format(LIST_FORMAT, dt.date(2014, 4, 12)) == ' Another Event\x1b[0m' - assert event.format(SEARCH_FORMAT, dt.date(2014, 4, 10)) == \ + assert LIST_FORMATTER(event.attributes(dt.date(2014, 4, 9))) == '↦ Another Event\x1b[0m' + assert LIST_FORMATTER(event.attributes(dt.date(2014, 4, 10))) == '⇥ Another Event\x1b[0m' + assert LIST_FORMATTER(event.attributes(dt.date(2014, 4, 12))) == ' Another Event\x1b[0m' + assert SEARCH_FORMATTER(event.attributes(dt.date(2014, 4, 10))) == \ '09.04.2014-10.04.2014 Another Event\x1b[0m' @@ -343,10 +354,10 @@ def test_event_dt_long(): event_dt_long = _get_text('event_dt_long') event = Event.fromString(event_dt_long, **EVENT_KWARGS) - assert event.format(LIST_FORMAT, dt.date(2014, 4, 9)) == '09:30→ An Event\x1b[0m' - assert event.format(LIST_FORMAT, dt.date(2014, 4, 10)) == '↔ An Event\x1b[0m' - assert event.format(LIST_FORMAT, dt.date(2014, 4, 12)) == '→10:30 An Event\x1b[0m' - assert event.format(SEARCH_FORMAT, dt.date(2014, 4, 10)) == \ + assert LIST_FORMATTER(event.attributes(dt.date(2014, 4, 9))) == '09:30→ An Event\x1b[0m' + assert LIST_FORMATTER(event.attributes(dt.date(2014, 4, 10))) == '↔ An Event\x1b[0m' + assert LIST_FORMATTER(event.attributes(dt.date(2014, 4, 12))) == '→10:30 An Event\x1b[0m' + assert SEARCH_FORMATTER(event.attributes(dt.date(2014, 4, 10))) == \ '09.04.2014 09:30-12.04.2014 10:30 An Event\x1b[0m' @@ -368,7 +379,7 @@ def test_event_no_dst(): ) assert normalize_component(event.raw) == normalize_component(cal_no_dst) - assert event.format(SEARCH_FORMAT, dt.date(2014, 4, 10)) == \ + assert SEARCH_FORMATTER(event.attributes(dt.date(2014, 4, 10))) == \ '09.04.2014 09:30-10:30 An Event\x1b[0m' @@ -415,10 +426,10 @@ def test_multi_uid(): def test_cancelled_instance(): orig_event_str = _get_text('event_rrule_recuid_cancelled') event = Event.fromString(orig_event_str, ref='1405314000', **EVENT_KWARGS) - assert event.format(SEARCH_FORMAT, dt.date(2014, 7, 14)) == \ + assert SEARCH_FORMATTER(event.attributes(dt.date(2014, 7, 14))) == \ 'CANCELLED 14.07.2014 07:00-12:00 Arbeit ⟳\x1b[0m' event = Event.fromString(orig_event_str, ref='PROTO', **EVENT_KWARGS) - assert event.format(SEARCH_FORMAT, dt.date(2014, 7, 14)) == \ + assert SEARCH_FORMATTER(event.attributes(dt.date(2014, 7, 14))) == \ '30.06.2014 07:00-12:00 Arbeit ⟳\x1b[0m' @@ -487,7 +498,8 @@ def test_format_24(): event = Event.fromString(event_dt, **EVENT_KWARGS) event.update_start_end(start, end) format_ = '{start-end-time-style} {title}{repeat-symbol}' - assert event.format(format_, dt.date(2014, 4, 9)) == '19:30-24:00 An Event\x1b[0m' + assert human_formatter(format_)(event.attributes(dt.date(2014, 4, 9)) + ) == '19:30-24:00 An Event\x1b[0m' def test_invalid_format_string(): @@ -495,14 +507,16 @@ def test_invalid_format_string(): event = Event.fromString(event_dt, **EVENT_KWARGS) format_ = '{start-end-time-style} {title}{foo}' with pytest.raises(KeyError): - event.format(format_, dt.date(2014, 4, 9)) + human_formatter(format_)(event.attributes(dt.date(2014, 4, 9))) def test_format_colors(): event = Event.fromString(_get_text('event_dt_simple'), **EVENT_KWARGS) format_ = '{red}{title}{reset}' - assert event.format(format_, dt.date(2014, 4, 9)) == '\x1b[31mAn Event\x1b[0m\x1b[0m' - assert event.format(format_, dt.date(2014, 4, 9), colors=False) == 'An Event' + assert human_formatter(format_)(event.attributes(dt.date(2014, 4, 9)) + ) == '\x1b[31mAn Event\x1b[0m\x1b[0m' + assert human_formatter(format_, colors=False)( + event.attributes(dt.date(2014, 4, 9), colors=False)) == 'An Event' def test_event_alarm(): diff --git a/tests/khalendar_test.py b/tests/khalendar_test.py index 2915b62c9..b09ef152a 100644 --- a/tests/khalendar_test.py +++ b/tests/khalendar_test.py @@ -10,6 +10,7 @@ import khal.khalendar.exceptions import khal.utils from khal import icalendar as icalendar_helpers +from khal.controllers import human_formatter from khal.khalendar import CalendarCollection from khal.khalendar.backend import CouldNotCreateDbDir from khal.khalendar.event import Event @@ -314,10 +315,10 @@ def test_search_recurrence_id_only_multi(self, coll_vdirs): coll.insert(event, cal1) events = sorted(coll.search('Event')) assert len(events) == 2 - assert events[0].format( - '{start} {end} {title}', dt.date.today()) == '30.06. 07:30 30.06. 12:00 Arbeit\x1b[0m' - assert events[1].format( - '{start} {end} {title}', dt.date.today()) == '07.07. 08:30 07.07. 12:00 Arbeit\x1b[0m' + assert human_formatter('{start} {end} {title}')(events[0].attributes( + dt.date.today())) == '30.06. 07:30 30.06. 12:00 Arbeit\x1b[0m' + assert human_formatter('{start} {end} {title}')(events[1].attributes( + dt.date.today())) == '07.07. 08:30 07.07. 12:00 Arbeit\x1b[0m' def test_delete_two_events(self, coll_vdirs, sleep_time): """testing if we can delete any of two events in two different @@ -374,7 +375,7 @@ def test_invalid_timezones(self, coll_vdirs): coll.insert(event, cal1) events = sorted(coll.search('Event')) assert len(events) == 1 - assert events[0].format('{start} {end} {title}', dt.date.today()) == \ + assert human_formatter('{start} {end} {title}')(events[0].attributes(dt.date.today())) == \ '02.12. 08:00 02.12. 09:30 Some event\x1b[0m' def test_multi_uid_vdir(self, coll_vdirs, caplog, fix_caplog, sleep_time): diff --git a/tests/utils_test.py b/tests/utils_test.py index d19f3bede..d9f09bf36 100644 --- a/tests/utils_test.py +++ b/tests/utils_test.py @@ -1,6 +1,7 @@ """testing functions from the khal.utils""" import datetime as dt +from click import style from freezegun import freeze_time from khal import utils @@ -119,3 +120,9 @@ def test_get_weekday_occurrence(): assert utils.get_weekday_occurrence(dt.date(2017, 5, 8)) == (0, 2) assert utils.get_weekday_occurrence(dt.date(2017, 5, 28)) == (6, 4) assert utils.get_weekday_occurrence(dt.date(2017, 5, 29)) == (0, 5) + + +def test_human_formatter_width(): + formatter = utils.human_formatter('{red}{title}', width=10) + output = formatter({'title': 'morethan10characters', 'red': style('', reset=False, fg='red')}) + assert output.startswith('\x1b[31mmoret\x1b[0m')