Repository: pimutils/khal
Pull request #1308 (2023-10-27 20:45:06)

Support json as an output option for most commands
Replacement for https://github.com/pimutils/khal/pull/1037

This is @PsychicNoodles' work, I just rebased it.

geier: I rebased the PR (again) and would like to merge it soon. Seems to work, but I'll have a more detailed look.

Patch:
diff --git a/AUTHORS.txt b/AUTHORS.txt
index 6e4e737..e417da9 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 fdf730a..c9dfc4f 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 f1ffa9f..a1c2f67 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/cli.py b/khal/cli.py
index 538ff9d..ec6cbac 100644
--- a/khal/cli.py
+++ b/khal/cli.py
@@ -34,6 +34,7 @@ from . import __version__, controllers, khalendar
 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
@@ -313,11 +314,12 @@ def _get_cli():
                   )
     @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, format, day_format):
+              daterange, once, notstarted, json, format, day_format):
         """List all events between a start (default: today) and (optional)
         end datetime."""
         try:
@@ -332,7 +334,8 @@ def _get_cli():
                 once=once,
                 notstarted=notstarted,
                 conf=ctx.obj['conf'],
-                env={"calendars": ctx.obj['conf']['calendars']}
+                env={"calendars": ctx.obj['conf']['calendars']},
+                json=json
             )
             if event_column:
                 click.echo('\n'.join(event_column))
@@ -358,6 +361,7 @@ def _get_cli():
                   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."))
@@ -365,7 +369,7 @@ def _get_cli():
                     nargs=-1)
     @click.pass_context
     def new(ctx, calendar, info, location, categories, repeat, until, alarms, url, format,
-            interactive):
+            json, interactive):
         '''Create a new event from arguments.
 
         START and END can be either dates, times or datetimes, please have a
@@ -413,6 +417,7 @@ def _get_cli():
                 alarms=alarms,
                 url=url,
                 format=format,
+                json=json
             )
         except FatalError as error:
             logger.debug(error, exc_info=True)
@@ -583,9 +588,10 @@ def _get_cli():
     @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, search_string, include_calendar, exclude_calendar):
+    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
@@ -604,8 +610,13 @@ def _get_cli():
             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(event.format(format, relative_to=now, env=env), term_width)
+                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'])
@@ -655,9 +666,10 @@ def _get_cli():
                   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, include_calendar, exclude_calendar):
+    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",)
@@ -675,7 +687,8 @@ def _get_cli():
                 once=True,
                 notstarted=notstarted,
                 conf=ctx.obj['conf'],
-                env={"calendars": ctx.obj['conf']['calendars']}
+                env={"calendars": ctx.obj['conf']['calendars']},
+                json=json
             )
             if rows:
                 click.echo('\n'.join(rows))
diff --git a/khal/controllers.py b/khal/controllers.py
index cb78930..a850b33 100644
--- a/khal/controllers.py
+++ b/khal/controllers.py
@@ -27,12 +27,12 @@ import re
 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 cal_from_ics, split_ics
 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 010da1e..fe506b2 100644
--- a/khal/khalendar/event.py
+++ b/khal/khalendar/event.py
@@ -38,7 +38,6 @@ from ..custom_types import LocaleConfiguration
 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 @@ class Event:
             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 @@ class Event:
 
         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 @@ class Event:
 
         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/ui/__init__.py b/khal/ui/__init__.py
index 806501b..795f8b1 100644
--- a/khal/ui/__init__.py
+++ b/khal/ui/__init__.py
@@ -205,13 +205,14 @@ class U_Event(urwid.Text):
             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:
diff --git a/khal/utils.py b/khal/utils.py
index cac1226..fae820b 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 @@ from typing import Iterator, List, Optional, Tuple
 
 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


Test patch:
diff --git a/tests/cli_test.py b/tests/cli_test.py
index f2b8271..5f90dba 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 34e3304..585e25a 100644
--- a/tests/event_test.py
+++ b/tests/event_test.py
@@ -8,6 +8,7 @@ from hypothesis.strategies import datetimes
 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 @@ from .utils import (
 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 2915b62..b09ef15 100644
--- a/tests/khalendar_test.py
+++ b/tests/khalendar_test.py
@@ -10,6 +10,7 @@ from freezegun import freeze_time
 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 @@ class TestCollection:
         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 @@ class TestCollection:
         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 d19f3be..d9f09bf 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')