python-dateutil
A delightful romp in the never-confusing world of dates and times


Paul Ganssle



dateutil on Github
Github repo for this talk

Out of 12,469 issues in pandas:

pandas-image-filter

2980 (24%) are about datetimes:

pandas-image-filter

Time zones

Non-integer offsets

Examples:

  • Australia/Adelaide (+09:30)
  • Asia/Kathmandu (+05:45)
  • Africa/Monrovia (+00:44:30) (Before 1979)

More than one DST transition per year

  • Morroco, 2012
    • WET (+0 STD) -> WEST (+1 DST) 2012-04-29
    • WEST (+1 DST) -> WET (+0 STD) 2012-07-20
    • WET (+0 STD) -> WEST (+1 DST) 2012-08-20
    • WEST (+1 DST) -> WET (+0 STD) 2012-09-30

... and Morocco in 2013-present, and Egypt in 2010 and 2014, and Palestine in 2011.

Asia/Shanghai

Asia/Shanghai time zone map

Asia/Urumqi

Asia/Shanghai time zone map

Parsing

Just a sample of ways people write datetimes:

  • 1994-11-05T08:15:30Z
  • 1994-11-05T14:00:30+05:45
  • 19941105T08:15:30+0000
  • 1994-11-05T081530+00:00
  • 1994-W44-6T08:15:30+00:00
  • 1994-11-05 08:15:30Z
  • 1994-11-05T08:15:30,000Z
  • 1994-11-05T08:15:30.000Z

... and that's just a few ISO 8601/RFC 3339 representations

Don't forget:

  • 2014 Feb 12 10:30pm
  • 2014年12月30日
  • 12:30 PM
  • 13NOV2017
  • December.0031.30

Recurrences

In [4]:
from dateutil import rrule as rr

# Close of business in New York on weekdays
closing_times = rr.rrule(freq=rr.DAILY, byweekday=(rr.MO, rr.TU, rr.WE, rr.TH, rr.FR),
                         byhour=17, dtstart=datetime(2017, 3, 9, 17), count=5)

for dt in closing_times:
    print(dt.replace(tzinfo=tz.gettz('America/New_York')))
2017-03-09 17:00:00-05:00
2017-03-10 17:00:00-05:00
2017-03-13 17:00:00-04:00
2017-03-14 17:00:00-04:00
2017-03-15 17:00:00-04:00
In [5]:
for dt in closing_times:
    print(dt.replace(tzinfo=tz.gettz('America/New_York')).astimezone(tz.UTC))
2017-03-09 22:00:00+00:00
2017-03-10 22:00:00+00:00
2017-03-13 21:00:00+00:00
2017-03-14 21:00:00+00:00
2017-03-15 21:00:00+00:00

Calendar offsets

In [7]:
# Pins to the end of the month
datetime(2018, 1, 31) + relativedelta(months=1)
Out[7]:
datetime.datetime(2018, 2, 28, 0, 0)
In [8]:
datetime(2018, 1, 31) + relativedelta(months=3)
Out[8]:
datetime.datetime(2018, 4, 30, 0, 0)
In [9]:
# Get the beginning of the next month
next_month = relativedelta(months=1, day=1)
dts = [datetime(2015, 2, 1),  datetime(2015, 2, 28), datetime(2015, 3, 1)]
print_dts([(x, x + next_month) for x in dts])
               start_date               |             +relativedelta             
----------------------------------------|----------------------------------------
               2015-02-01               |               2015-03-01               
               2015-02-28               |               2015-03-01               
               2015-03-01               |               2015-04-01               

Package overview:

  • tz: Time zone handling
  • parser: Parsing arbitrary datetimes
  • rrule: Generation of recurrence relations
  • relativedelta: Handling of "calendar" offsets
  • easter: Calculation of easter

dateutil.tz

dateutil also provides a number of classes to conveniently construct and represent time zones.

Way more detail on this in my talk "Timezone Troubles" (see https://ganssle.io/talks)

dateutil.tz.tzutc()

The tzutc() subclass is an alias for the universal coordinated time zone. It has an offset of 0 and does not have DST. As of version 2.7.0, there is a dedicated UTC singleton, dateutil.tz.UTC

dateutil.tz.tzlocal

The tzlocal() object pulls time zone information from what the OS believes is the local time zone.

In [13]:
# Temporarily changes the TZ file on *nix systems.
from helper_functions import TZEnvContext

print_tzinfo(dt.astimezone(tz.tzlocal())); print()

with TZEnvContext('UTC'):
    print_tzinfo(dt.astimezone(tz.tzlocal())); print()

with TZEnvContext('PST8PDT'):
    print_tzinfo((dt + timedelta(days=180)).astimezone(tzlocal()))
2016-07-17 08:15:00-0400:
    tzname:   EDT;      UTC Offset:  -4.00h;           DST:      1.0h

2016-07-17 12:15:00+0000:
    tzname:   UTC;      UTC Offset:   0.00h;           DST:      0.0h

2017-01-13 04:15:00-0800:
    tzname:   PST;      UTC Offset:  -8.00h;           DST:      0.0h

dateutil.tz.tzfile

The tzfile specification is a binary format that is in common use among most platforms, and is the format of the compiled IANA (Olson) zoneinfo database. This database is the most accurate and widely supported source for time zone information, and is shipped with many OSes. A copy of the database is also shipped with dateutil as a fallback.

If you have an IANA time zone name (e.g. 'America/New_York', 'Europe/Belgium', 'Asia/Tokyo'), you should use it if possible.

It is possible to construct a tzfile directly from either a path to a file or an open file object. This is not the recommended way to do this as a matter of course, but it is supported. It is much preferred to just pass the timezone identifier to gettz(), which will check the standard paths for you (and fall back to the bundled zoneinfo data).

In [14]:
NYC = tz.tzfile('/usr/share/zoneinfo/America/New_York')
assert NYC == tz.gettz('America/New_York')

print_tzinfo(dt.astimezone(NYC))                         # Eastern Daylight Time
print_tzinfo(datetime(1944, 1, 6, 12, 15, tzinfo=NYC))   # Eastern War Time
print_tzinfo(datetime(1901, 9, 6, 16, 7, tzinfo=NYC))    # Local solar mean
2016-07-17 08:15:00-0400:
    tzname:   EDT;      UTC Offset:  -4.00h;           DST:      1.0h
1944-01-06 12:15:00-0400:
    tzname:   EWT;      UTC Offset:  -4.00h;           DST:      1.0h
1901-09-06 16:07:00-0456:
    tzname:   LMT;      UTC Offset:  -4.93h;           DST:      0.0h

dateutil.tz.gettz

The best way to get a time zone is to pass the relevant timezone string to the gettz() function, which is intended to be a Python equivalent to the TZ environment variable.

Passing nothing gets the current local time zone:

In [15]:
gettz()
Out[15]:
tzfile('/etc/localtime')
In [18]:
# Retrieve IANA zone:
print(gettz('Pacific/Kiritimati'))
tzfile('/usr/share/zoneinfo/Pacific/Kiritimati')
In [19]:
# Directly parse a TZ variable:
print(gettz('AEST-10AEDT-11,M10.1.0/2,M4.1.0/3'))
tzstr('AEST-10AEDT-11,M10.1.0/2,M4.1.0/3')

gettz will return the same time zone object for the same input string, unless the cache is bypassed

In [20]:
gettz('America/New_York') is gettz('America/New_York')
Out[20]:
True

In addition to the performance improvement, this is also because CPython relies on object equality for some of its datetime semantics:

In [21]:
LON = tz.gettz('Europe/London')

# Construct a datetime
x = datetime(2007, 3, 25, 1, 0, tzinfo=tz.gettz('Europe/London'))
ts = x.timestamp()      # Get a timestamp representing the same datetime

# Get the same datetime from the timestamp
y = datetime.fromtimestamp(ts, tz.gettz('Europe/London'))

# Get the same datetime from the timestamp with a fresh instance of LON
z = datetime.fromtimestamp(ts, tz.gettz.nocache('Europe/London'))

print(f'x == y: {x == y}')
print(f'x == z: {x == z}')
print(f'y == z: {y == z}')
x == y: False
x == z: True
y == z: True

For a detailed write-up of this case, see my blog entry: https://blog.ganssle.io/articles/2018/02/a-curious-case-datetimes.html

Ambiguous and imaginary times

Ambiguous times are times where the same "wall time" occurs twice, such as during a DST to STD transition. dateutil provides the tz.datetime_ambiguous() method to determine if a datetime is ambiguous in a given zone.

In [25]:
dt1 = datetime(2004, 10, 31, 4, 30, tzinfo=tzutc())
for i in range(4):
    dt = (dt1 + timedelta(hours=i)).astimezone(NYC)
    print('{} | {} |  {}'.format(dt, dt.tzname(), 
                                   'Ambiguous' if tz.datetime_ambiguous(dt) else 'Unambiguous'))
2004-10-31 00:30:00-04:00 | EDT |  Unambiguous
2004-10-31 01:30:00-04:00 | EDT |  Ambiguous
2004-10-31 01:30:00-05:00 | EST |  Ambiguous
2004-10-31 02:30:00-05:00 | EST |  Unambiguous

Imaginary times are wall times that don't exist in a given time zone, such as during an STD to DST transition.

In [26]:
dt1 = datetime(2004, 4, 4, 6, 30, tzinfo=tzutc())
for i in range(3):
    dt = (dt1 + timedelta(hours=i)).astimezone(NYC)
    print('{} | {} '.format(dt, dt.tzname()))
2004-04-04 01:30:00-05:00 | EST 
2004-04-04 03:30:00-04:00 | EDT 
2004-04-04 04:30:00-04:00 | EDT 
In [27]:
assert not tz.datetime_exists(datetime(2004, 4, 4, 2, 30), tz=NYC)

Handling imaginary datetimes

Using tz.resolve_imaginary you can automatically shift imaginary datetimes forward

In [28]:
tz.resolve_imaginary(datetime(2004, 4, 4, 2, 30, tzinfo=NYC))
Out[28]:
datetime.datetime(2004, 4, 4, 3, 30, tzinfo=tzfile('/usr/share/zoneinfo/America/New_York'))

It has no effect on existing datetimes:

In [29]:
tz.resolve_imaginary(datetime(2004, 8, 12, 12, tzinfo=NYC))
Out[29]:
datetime.datetime(2004, 8, 12, 12, 0, tzinfo=tzfile('/usr/share/zoneinfo/America/New_York'))

It automatically detects the amount of the offset:

In [30]:
tz.resolve_imaginary(datetime(1994, 12, 31, 12, tzinfo=tz.gettz('Pacific/Kiritimati')))
Out[30]:
datetime.datetime(1995, 1, 1, 12, 0, tzinfo=tzfile('/usr/share/zoneinfo/Pacific/Kiritimati'))

dateutil.parser

The parser is used to take datetime strings of a valid but usually unknown format and convert them into datetime objects.

In [33]:
from dateutil.parser import parse, parser

parser().parse('March 8, 1942 10:13')
Out[33]:
datetime.datetime(1942, 3, 8, 10, 13)
In [34]:
parse('1991-02-03')
Out[34]:
datetime.datetime(1991, 2, 3, 0, 0)
In [35]:
list(map(parse, ['01-03-04', '11-01-04', '32-04-03']))
Out[35]:
[datetime.datetime(2004, 1, 3, 0, 0),
 datetime.datetime(2004, 11, 1, 0, 0),
 datetime.datetime(2032, 4, 3, 0, 0)]

When to use the parser

  • When parsing dates of an unknown format
  • When picking out dates from a string:
In [36]:
parse("Pat Morita's birthday is June 28, 1932", fuzzy=True)
Out[36]:
datetime.datetime(1932, 6, 28, 0, 0)
  • When you need to retrieve the time zone information as well:
In [37]:
dt_base = '2009-09-14 02:33:44'
# This works for your local time zone or any fixed offset
list(map(parse, (dt_base + x for x in ('EST', 'CST-8', 'UTC-4', '-0400'))))
Out[37]:
[datetime.datetime(2009, 9, 14, 2, 33, 44, tzinfo=tzlocal()),
 datetime.datetime(2009, 9, 14, 2, 33, 44, tzinfo=tzoffset('CST', 28800)),
 datetime.datetime(2009, 9, 14, 2, 33, 44, tzinfo=tzoffset(None, 14400)),
 datetime.datetime(2009, 9, 14, 2, 33, 44, tzinfo=tzoffset(None, -14400))]
In [38]:
# You can also specify the time zone context for ambiguous zones
IST = gettz('Asia/Kolkata'); CST = gettz('Asia/Shanghai')
parse("2002-09-14 02:33:44 PM CST", tzinfos={'CST': CST, 'IST': IST})
Out[38]:
datetime.datetime(2002, 9, 14, 14, 33, 44, tzinfo=tzfile('/usr/share/zoneinfo/Asia/Shanghai'))

dateutil.parser.isoparse

As of version 2.7.0, dateutil introduced a dedicated ISO-8601 parser.

In [39]:
from dateutil.parser import isoparse
In [40]:
isoparse('2018-04-03')
Out[40]:
datetime.datetime(2018, 4, 3, 0, 0)
In [41]:
isoparse('2018-04-03T14:40')
Out[41]:
datetime.datetime(2018, 4, 3, 14, 40)
In [42]:
isoparse('2018-04-03T14:40-05:00')
Out[42]:
datetime.datetime(2018, 4, 3, 14, 40, tzinfo=tzoffset(None, -18000))

isoparse advantages

Faster

In [43]:
%timeit parse('2018-04-03T14:40-05:00')
175 µs ± 11.1 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)
In [44]:
%timeit isoparse('2018-04-03T14:40-05:00')
26 µs ± 1.92 µs per loop (mean ± std. dev. of 7 runs, 10000 loops each)

Stricter

In [45]:
parse('2014.04.03T14:40 GMT+05:00')
Out[45]:
datetime.datetime(2014, 4, 3, 14, 40, tzinfo=tzoffset(None, -18000))
In [46]:
try:
    isoparse('2014.04.03T14:40 GMT+05:00')
except ValueError as e:
    print(f'ValueError: {e}')
ValueError: invalid literal for int() with base 10: b'.04'

When not to use the parser

  • When you know the format of the string: Use strptime

  • Don't use it to validate whether or not something is a datetime

  • When you know the format of the string to within a few possibilities, it's almost certainly faster and more accurate to guess-and-check.

dateutil.rrule

rrule is an implementation of recurrence rules as laid out in the iCalendar RFC (RFC 5545).

Recurrence rules are rules for generating dates and times at some (potentially complex) interval.

Some examples:

In [60]:
from dateutil.rrule import rrule, rruleset, rrulestr
from dateutil.rrule import YEARLY, MONTHLY, WEEKLY, DAILY, HOURLY, MINUTELY, SECONDLY
In [61]:
# All of Pat Morita's birthdays that fell on a Monday
rr = rrule(freq=YEARLY, bymonth=6, bymonthday=28, byweekday=MO,
           dtstart=datetime(1932, 6, 28),
           until=datetime(2005, 11, 24))
  
rr.between(datetime(1955, 11, 1),
           datetime(1975, 4, 30))   # ...during the Vietnam War
Out[61]:
[datetime.datetime(1965, 6, 28, 0, 0), datetime.datetime(1971, 6, 28, 0, 0)]

RRULE components

Fundamental elements of an rrule are:

  • dtstart: The start point of the recurrence (this is similar to a phase)
  • freq: The units of the fundamental frequency of the recurrence. It takes the values YEARLY, MONTHLY, WEEKLY, DAILY, HOURLY, MINUTELY, SECONDLY
  • interval: The fundamental frequency of the recurrence, in units of freq. If unspecified, this is 1.
In [63]:
hourly = rrule(freq=HOURLY, interval=1, dtstart=datetime(2016, 7, 18, 9),  count=3)
interval_2 = hourly.replace(interval=2)
dtstart_rr = hourly.replace(dtstart=datetime(2016, 7, 18, 10))

print_rrs([hourly, dtstart_rr, interval_2], ['Hourly', 'dtstart', 'interval=2'])
          Hourly          |         dtstart          |        interval=2        
--------------------------------------------------------------------------------
     2016-07-18 09:00     |     2016-07-18 10:00     |     2016-07-18 09:00     
     2016-07-18 10:00     |     2016-07-18 11:00     |     2016-07-18 11:00     
     2016-07-18 11:00     |     2016-07-18 12:00     |     2016-07-18 13:00     

byxxx rules

byxxx rules serve to modify the frequency of the recurrence in some way. The supported rules are bymonth, bymonthday, byyearday, byweekno, byweekday, byhour, byminute and bysecond, bysetpos and byeaster.

  • byxxx rules greater than or equal to freq are constraints and (generally) reduce the frequency of the recurrence:
In [64]:
# Base is DAILY, but by restricted to Tuesdays in November
list(rrule(DAILY, bymonth=11, byweekday=(TU, ),
           dtstart=datetime(2015, 1, 1, 12), count=5))
Out[64]:
[datetime.datetime(2015, 11, 3, 12, 0),
 datetime.datetime(2015, 11, 10, 12, 0),
 datetime.datetime(2015, 11, 17, 12, 0),
 datetime.datetime(2015, 11, 24, 12, 0),
 datetime.datetime(2016, 11, 1, 12, 0)]
  • byxxx rules less than freq will generally increase the frequency of the recurrence:
In [65]:
list(rrule(MONTHLY, bymonthday=(1, 15, 30),
           dtstart=datetime(2015, 1, 16, 12, 15), count=4))
Out[65]:
[datetime.datetime(2015, 1, 30, 12, 15),
 datetime.datetime(2015, 2, 1, 12, 15),
 datetime.datetime(2015, 2, 15, 12, 15),
 datetime.datetime(2015, 3, 1, 12, 15)]

Limiting rules

If otherwise unspecified, recurrences can be generated to infinity (or at least until Python can't represent the date anymore). The two ways to specify a termination point as part of the rule are with the mutually exclusive count and until arguments.

  • count terminates the rule after a specific number of instances have been generated
In [66]:
# The next 2 instances where the 4th of July falls on a Friday
list(rrule(YEARLY, bymonth=7, bymonthday=4, byweekday=FR,
           dtstart=datetime(2016, 7, 5), count=2))
Out[66]:
[datetime.datetime(2025, 7, 4, 0, 0), datetime.datetime(2031, 7, 4, 0, 0)]
  • until terminates the rule on a specific date:
In [67]:
# The Friday the 13ths before January 1st, 2018
list(rrule(MONTHLY, bymonthday=13, byweekday=FR,
           dtstart=datetime(2016, 7, 17, 12), until=datetime(2018, 1, 1)))
Out[67]:
[datetime.datetime(2017, 1, 13, 12, 0), datetime.datetime(2017, 10, 13, 12, 0)]

Using rrules

It is also possible to retrieve specific subsets of the recurrence, e.g. the first recurence after a given date:

In [68]:
rr = rrule(DAILY, byhour=(9), byweekday=range(0, 5), dtstart=datetime(2016, 7, 1))

rr.after(datetime.now())      # The beginning of the next weekday
Out[68]:
datetime.datetime(2018, 5, 30, 9, 0)

You can retrieve the most recent recurrence before a given date:

In [69]:
rr.before(datetime(2017, 3, 14))   # Apparently this is a Saturday
Out[69]:
datetime.datetime(2017, 3, 13, 9, 0)

You can also get all the recurrences between two dates:

In [70]:
# byeaster is a non-standard extension in dateutil that calculates a day
# offset from easter. This rule generates all the easters between 1995 and 2000.
rr = rrule(YEARLY, byeaster=0, dtstart=datetime(1990, 1, 1))

rr.between(datetime(1995, 1, 1), datetime(2000, 1, 1))
Out[70]:
[datetime.datetime(1995, 4, 16, 0, 0),
 datetime.datetime(1996, 4, 7, 0, 0),
 datetime.datetime(1997, 3, 30, 0, 0),
 datetime.datetime(1998, 4, 12, 0, 0),
 datetime.datetime(1999, 4, 4, 0, 0)]

rrulestr

The iCalendar spec originally refers to a specific string format for specifying recurrence rules.

rrules can also be generated from these string using the rrulestr class:

In [79]:
# DST start and stop transition rules for Pacific Time
dst_start = rrulestr('DTSTART:19671029T020000;\n'
                     'FREQ=YEARLY;BYDAY=1SU;BYMONTH=4')
dst_end = rrulestr('DTSTART:19671029T020000;\n'
                   'FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10')
rrset = rruleset()
rrset.rrule(dst_start)
rrset.rrule(dst_end)

rrset.between(datetime(2016, 1, 1), datetime(2018, 1, 1))
Out[79]:
[datetime.datetime(2016, 4, 3, 2, 0),
 datetime.datetime(2016, 10, 30, 2, 0),
 datetime.datetime(2017, 4, 2, 2, 0),
 datetime.datetime(2017, 10, 29, 2, 0)]

str(rrule)

You can generate RRULE strings from rrule objects as well:

In [80]:
# This string should be compatible with other applications using the iCalendar spec
str(rrule(YEARLY, byyearday=180, byhour=(1, 4, 12), dtstart=datetime(2014, 9, 13)))
Out[80]:
'DTSTART:20140913T000000\nRRULE:FREQ=YEARLY;BYYEARDAY=180;BYHOUR=1,4,12'
In [81]:
# Note that the BYEASTER directive is the only RFC-incompatible output
str(rrule(YEARLY, byeaster=0, dtstart=datetime(1990, 1, 1), count=14))
Out[81]:
'DTSTART:19900101T000000\nRRULE:FREQ=YEARLY;COUNT=14;BYEASTER=0'

rruleset

Some recurrences cannot be expressed in a single rrule. rruleset allows you to combine rrules and datetimes to generate an arbitrary recurrence schedule.

rruleset interface:

  • rruleset.rrule(): Add a recurrence rule to the set
  • rruleset.exrule(): Subtract a recurrence rule from the set
  • rruleset.rdate(): Add a specific datetime to the set
  • rruleset.exdate(): Subtract a specific datetime from the set

Bus schedule

In [71]:
dtstart = datetime(2016, 11, 1, 0, 0)    # The base date
WEEKDAYS = (MO, TU, WE, TH, FR);    WEEKENDS = (SA, SU)
bus_schedule = rruleset()
In [72]:
# During the week, it comes every hour on the 37 from 6:37AM to 10:37PM...
weekday_schedule = rrule(DAILY, byweekday=WEEKDAYS,
                         byhour=range(6, 22), byminute=37, dtstart=dtstart)
bus_schedule.rrule(weekday_schedule)       # Add an rrule to the rule set
In [73]:
# ..except after 6, when it comes every other hour - so exclude 7:37PM and 9:37PM!
weeknight_schedule = weekday_schedule.replace(byhour=(19, 21))
bus_schedule.exrule(weeknight_schedule)
In [74]:
# During the weekend, it comes every hour on the :07, from 8AM to 7PM
weekend_schedule = rrule(DAILY, byweekday=WEEKENDS,
                         byhour=range(8, 20), byminute=7, dtstart=dtstart)
bus_schedule.rrule(weekend_schedule)

rdate and exdate

In [75]:
# But on November 8th, 2016, politicians have arranged for busses to undergo
# "service", so the normal bus schedule is canceled that day
exdates = bus_schedule.between(datetime(2016, 11, 8, 0), datetime(2016, 11, 9))
for exdate in exdates:
    bus_schedule.exdate(exdate)
In [76]:
# And in its place they've added one bus at 4:32 AM
bus_schedule.rdate(datetime(2016, 11, 8, 4, 37))

# And one at 7:49 PM
bus_schedule.rdate(datetime(2016, 11, 8, 19, 49))

And display the schedule:

In [78]:
bus_list = bus_schedule.between(datetime(2016, 11, 7), datetime(2016, 11, 14))
o = print_bus_schedule(bus_list)
HTML(o)
Out[78]:
2016-11-07 2016-11-08 2016-11-09 2016-11-10 2016-11-11 2016-11-12 2016-11-13
Mon Tue Wed Thu Fri Sat Sun
06:37:00 04:37:00 06:37:00 06:37:00 06:37:00 08:07:00 08:07:00
07:37:00 19:49:00 07:37:00 07:37:00 07:37:00 09:07:00 09:07:00
08:37:00 None 08:37:00 08:37:00 08:37:00 10:07:00 10:07:00
09:37:00 None 09:37:00 09:37:00 09:37:00 11:07:00 11:07:00
10:37:00 None 10:37:00 10:37:00 10:37:00 12:07:00 12:07:00
11:37:00 None 11:37:00 11:37:00 11:37:00 13:07:00 13:07:00
12:37:00 None 12:37:00 12:37:00 12:37:00 14:07:00 14:07:00
13:37:00 None 13:37:00 13:37:00 13:37:00 15:07:00 15:07:00
14:37:00 None 14:37:00 14:37:00 14:37:00 16:07:00 16:07:00
15:37:00 None 15:37:00 15:37:00 15:37:00 17:07:00 17:07:00
16:37:00 None 16:37:00 16:37:00 16:37:00 18:07:00 18:07:00
17:37:00 None 17:37:00 17:37:00 17:37:00 19:07:00 19:07:00
18:37:00 None 18:37:00 18:37:00 18:37:00 None None
20:37:00 None 20:37:00 20:37:00 20:37:00 None None
pgp key
6B49 ACBA DCF6 BD1C A206
67AB CD54 FCE3 D964 BEFB