-
Notifications
You must be signed in to change notification settings - Fork 0
/
riet
executable file
·1450 lines (1289 loc) · 56 KB
/
riet
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
import itertools as it, operator as op, functools as ft
import datetime as dt, zoneinfo as zi, collections as cs, pathlib as pl
import subprocess as sp, contextlib as cl, unicodedata as ud, hashlib as hl
import tempfile, stat, base64, hashlib, json
import os, sys, logging, re, time, math
# As-needed deps: docutils, icalendar, feedparser
err_fmt = lambda err: f'[{err.__class__.__name__}] {err}'
class LogMessage:
def __init__(self, fmt, a, k): self.fmt, self.a, self.k = fmt, a, k
def __str__(self): return self.fmt.format(*self.a, **self.k) if self.a or self.k else self.fmt
class LogStyleAdapter(logging.LoggerAdapter):
def __init__(self, logger, extra=None):
super(LogStyleAdapter, self).__init__(logger, extra or {})
def log(self, level, msg, *args, **kws):
if not self.isEnabledFor(level): return
log_kws = {} if 'exc_info' not in kws else dict(exc_info=kws.pop('exc_info'))
msg, kws = self.process(msg, kws)
self.logger._log(level, LogMessage(msg, args, kws), (), **log_kws)
get_logger = lambda name: LogStyleAdapter(logging.getLogger(name))
log = get_logger('main')
class adict(dict):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.__dict__ = self
def enum_func(opts, default=None, mutex_flags=False, **opts_kws):
'''Returns factory func for smart adict-based enums.
Example:
font_width = enum_func('fixed var')
fw = font_width(fixed=True) # or font_width(True), font_width(True, False)
if fw.fixed: ... elif fw.var: ... else: raise ValueError
font_width = enum_func(fixed=False, var=lambda fixed: not fixed)
font_width = enum_func('fixed var', mutex_flags=True) # auto-fill other one
fw = font_width.var # same as font_width(var=True), only set with mutex_flags
mutex_flags will auto-fill all opts that are missing values with
"not X" where X is first passed or auto-calculated one that is not None.'''
if isinstance(opts, str): opts = opts.split()
if isinstance(opts, (tuple, list)): opts = dict.fromkeys(opts, default)
opts.update(opts_kws)
def make_enum(*args, **kws):
res, opt_kws, bool_fill = opts.copy(), kws.copy(), None
for n, k in enumerate(opts.keys()):
if len(args) > n:
if k in opt_kws: raise KeyError(f'Same opt in args/kws: {k}')
arg = args[n]
elif k in opt_kws: arg = opt_kws.pop(k)
else: arg = res[k]
if callable(arg):
arg = arg(**dict((k, v) for k, v in res.items() if not callable(v)))
if mutex_flags and arg is not None: bool_fill = k
res[k] = arg
if len(args) > n + 1 or opt_kws:
raise ValueError( 'Mismatch between'
f' args/kws and opts: {args}/{kws} vs {list(opts.keys())}' )
if bool_fill:
fill_v, res['_v'] = not res[bool_fill], bool_fill
for k, v in res.items():
if res[k] is None: res[k] = fill_v
return adict(res)
if mutex_flags:
for k, v in opts.items():
setattr(make_enum, k, make_enum(**{k: True}))
make_enum._null = make_enum()
return make_enum
@cl.contextmanager
def safe_replacement(path, *open_args, mode=None, **open_kws):
path = str(path)
if mode is None:
with cl.suppress(OSError):
mode = stat.S_IMODE(os.lstat(path).st_mode)
open_kws.update( delete=False,
dir=os.path.dirname(path), prefix=os.path.basename(path)+'.' )
if not open_args: open_kws['mode'] = 'w'
with tempfile.NamedTemporaryFile(*open_args, **open_kws) as tmp:
try:
if mode is not None: os.fchmod(tmp.fileno(), mode)
yield tmp
if not tmp.closed: tmp.flush()
os.rename(tmp.name, path)
finally:
with cl.suppress(OSError): os.unlink(tmp.name)
def retries_within_timeout(
tries, timeout, acc=True, reverse=False,
backoff_func=lambda e,n: 1.01*e*(n**1.3), slack_factor=1e-3 ):
'''Return list of delays to make exactly n tires within timeout.
Monotonic backoff_func(e, n) should return delay values in range
of ~0 (n=0) to infinity (n=inf), with backoff_func(e, 1) giving value >e.
slack_factor is how close to timeout sum of resulting delays will be.'''
if timeout <= 0: return [0]
if tries <= 0: return [timeout]
a, b, slack = 0, timeout, timeout * slack_factor
while True:
m = (a + b) / 2
delays = list(backoff_func(m, n) for n in range(tries))
error = sum(delays) - timeout
if abs(error) < slack: break
elif error > 0: b = m
else: a = m
if acc:
delta = 0
for n, d in enumerate(delays):
delta += d
delays[n] = delta
if reverse: delays = list((timeout - d) for d in reversed(delays))
return delays
str_hash = lambda p: base64.urlsafe_b64encode(
hl.blake2s(str(p).encode(), key=b'riet.path_hash').digest() ).decode()[:12]
def tuple_hash(*data):
if len(data) == 1 and isinstance(data[0], (tuple, list)): data = data[0]
src = list()
for v in data:
if v is None: src.append('\ue003')
elif isinstance(v, (int, str, dt.tzinfo)): src.append(str(v))
elif isinstance(v, (tuple, list)): src.append(tuple_hash(v))
elif isinstance(v, dt.datetime):
src.append(conv_ts_utc(v).strftime('%Y-%m-%dT%H:%M:%S'))
elif isinstance(v, dt.timedelta): src.append('\ue002{v.total_seconds()}')
elif isinstance(v, set): src.append(tuple_hash(sorted(v)))
else: raise ValueError(type(v), v)
return str_hash('\ue000'.join(
'\ue001{}\ue001'.format(v.replace('\ue001', '\ue001'*2)) for v in src ))
bb = lambda v: v.encode() if isinstance(v, str) else v
ss = lambda v: v.decode() if isinstance(v, bytes) else v
cc = lambda v: ud.normalize('NFKC', v).casefold()
### Persistent state tracking
class PersistentStateError(Exception): pass
class PersistentState(cs.UserDict):
version = 2
def __init__(self, *args, **kws):
super().__init__(*args, **kws)
self.ts = self.ts_init = time.time()
def key(self, ns, *tt): return f'{ns}.{tuple_hash(tt)}'
def __setitem__(self, k, v):
return super().__setitem__(k, (self.ts, v))
def __getitem__(self, k):
v = super().__getitem__(k)[1]
self[k] = v # update ts
return v
def load(self, dump):
if not dump or not dump.get('version'):
dump['ts_init'], dump['data'] = self.ts_init, dump or dict()
if dump['version'] < 2:
for k in list(dump['data'].keys()):
if k.startswith('cal.ts.'): continue
dump['data'][f'cal.ts.{k}'] = dump['data'].pop(k)
dump['version'] = 2
if dump['version'] != self.version: # only this one recognized so far
raise PersistentStateError(
f'Unrecognized state-dump version: {dump["version"]}' )
self.ts_init, self.data = dump['ts_init'], dump['data']
def dump(self):
return dict(version=self.version, ts_init=self.ts_init, data=self.data)
def cleanup(self, ts_min=None, timeout=2*365*24*3600):
if ts_min is None: ts_min = self.ts - timeout
for k, v in list(self.data.items()):
if v[0] < ts_min: self.data.pop(k)
### D-Bus helpers
class SDBus:
_lib_info, _lib_info_t = None, cs.namedtuple(
'sd_bus_lib', 'ct lib bus_t bus err_t err msg_t msg' )
@classmethod
def _get_lib_info(cls):
if not cls._lib_info:
import ctypes as ct
lib = ct.CDLL('libsystemd.so')
class sd_bus(ct.Structure): pass
class sd_bus_error(ct.Structure):
_fields_ = [('name', ct.c_char_p), ('message', ct.c_char_p), ('need_free', ct.c_int)]
class sd_bus_msg(ct.Structure): pass
cls._lib_info = cls._lib_info_t( ct, lib,
sd_bus, ct.POINTER(sd_bus)(),
sd_bus_error, sd_bus_error(),
sd_bus_msg, ct.POINTER(sd_bus_msg)() )
return cls._lib_info
def _run(self, lib, call, *args, sig=None, check=True):
func = getattr(lib, call)
if sig: func.argtypes = sig
res = func(*args)
if check and res < 0: raise OSError(-res, os.strerror(-res))
return res
def call( self,
dst, path=None, iface=None, method=None, sig='', args=None,
reply='', system=False, _call='sd_bus_call_method' ):
if not method: dst, method = dst.rsplit('.', 1)
if not path: path = '/' + dst.replace('.', '/')
if not iface: iface = dst
ct, lib, sd_bus, bus, sd_bus_error, err, sd_bus_msg, msg = self._get_lib_info()
args, args_sig = args or [(), ()]
if reply: # one value only
reply_sig = ss(reply)
reply_t = dict(i=ct.c_int32, u=ct.c_uint32, s=ct.c_char_p)[reply_sig]
reply_sig, reply = bb(reply_sig), reply_t()
else: reply_sig = None
bus_name = 'user' if not system else 'system'
self._run( lib, f'sd_bus_open_{bus_name}',
ct.byref(bus), sig=[ct.POINTER(ct.POINTER(sd_bus))] )
try:
self._run( lib, _call, bus,
bb(dst), bb(path), bb(iface), bb(method),
ct.byref(err), ct.byref(msg), bb(sig), *args,
sig=[
ct.POINTER(sd_bus),
ct.c_char_p, ct.c_char_p, ct.c_char_p, ct.c_char_p,
ct.POINTER(sd_bus_error),
ct.POINTER(ct.POINTER(sd_bus_msg)), ct.c_char_p, *args_sig ] )
if reply_sig:
n = self._run(
lib, 'sd_bus_message_read', msg, reply_sig, ct.byref(reply),
sig=[ct.POINTER(sd_bus_msg), ct.c_char_p, ct.POINTER(reply_t)] )
if n <= 0: raise ValueError(n)
finally: self._run(lib, 'sd_bus_flush_close_unref', bus, check=False)
if reply: return reply.value
def get(self, dst, path=None, iface=None, name=None, sig='s', system=False):
return self.call( dst, path, iface, name, sig=sig,
reply=sig, system=system, _call='sd_bus_get_property' )
def get_local_tz():
# Cached on first access to avoid dbus call on import
if get_local_tz.cache: return get_local_tz.cache
if tz := os.environ.get('TZ', '').strip():
get_local_tz.cache = zi.ZoneInfo(tz)
return get_local_tz.cache
tz = SDBus().get('org.freedesktop.timedate1.Timezone', system=True)
get_local_tz.cache = zi.ZoneInfo(tz.decode())
return get_local_tz.cache
get_local_tz.cache = None
def get_dbus_notify_func(**defaults):
kws, defaults = defaults, dict(
app='', replaces_id=0, icon='',
summary='', body='', actions=None, hints=None, timeout=-1 )
for k in defaults:
if k in kws: defaults[k] = kws.pop(k)
assert not kws, kws
import ctypes as ct
sdbus = SDBus()
def encode_array(v):
if not v: sig, args = [ct.c_void_p], [None]
elif isinstance(v, list): # list of str
sig, args = [ct.c_int, [ct.c_char_p] * len(v)], [len(v), *map(bb, v)]
elif isinstance(v, dict): # str keys, int/str values
sig, args = [ct.c_int], [len(v)]
for ak, av in v.items():
sig.extend([ct.c_char_p, ct.c_char_p]) # key, type
args.append(bb(ak))
if isinstance(av, (str, bytes)):
av_sig, av_args = [ct.c_char_p], [b's', bb(av)]
elif isinstance(av, int): av_sig, av_args = [ct.c_int32], [b'i', av]
else: av_sig, av_args = av
args.extend(av_args)
sig.extend(av_sig)
else: raise ValueError(v)
return sig, args
def notify_func(
summary=None, body=None, app=None, icon=None,
replaces_id=None, actions=None, hints=None, timeout=None ):
args, kws, sig_arrays = list(), locals(), list()
for k, default in defaults.items():
if (v := kws.get(k)) is None: v = default
if k in ['actions', 'hints']:
arr_sig, arr_args = encode_array(v)
sig_arrays.extend(arr_sig)
args.extend(arr_args)
else: args.append(bb(v))
args = args, [ ct.c_char_p, ct.c_uint32, ct.c_char_p,
ct.c_char_p, ct.c_char_p, *sig_arrays, ct.c_int32 ]
return sdbus.call(
'org.freedesktop.Notifications.Notify',
sig='susssasa{sv}i', args=args, reply='u' )
return notify_func
### Date/time processing helpers
class TSParseError(Exception): pass
class TSOverflow(Exception): pass
# Common timezone abbrevs which tz db does not handle
# Use make-tza-map.py script to produce these mappings from timezonedb.com/download csv
tza_map = dict((cc(k), v) for k, v in (line.split('=', 1) for line in '''
EMT=Pacific/Easter PPT=America/Los_Angeles YST=America/Anchorage JST=Asia/Tokyo
RMT=Europe/Rome PKT=Asia/Karachi AMT=Europe/Amsterdam EST=America/New_York
QMT=America/Guayaquil PDDT=America/Inuvik WEST=Europe/Paris JDT=Asia/Tokyo
NDDT=America/St_Johns AWDT=Australia/Perth CST=America/Chicago KMT=Europe/Kiev
SDMT=America/Santo_Domingo AEDT=Australia/Sydney CDT=America/Chicago
EDDT=America/Iqaluit AWST=Australia/Perth PKST=Asia/Karachi CET=Europe/Berlin
AKDT=America/Anchorage HDT=Pacific/Honolulu CEST=Europe/Berlin LST=Europe/Riga
CWT=America/Chicago CPT=America/Chicago IMT=Europe/Istanbul SST=Pacific/Midway
MST=America/Phoenix EET=Europe/Sofia EEST=Europe/Sofia PLMT=Asia/Ho_Chi_Minh
PST=America/Los_Angeles PDT=America/Los_Angeles HKT=Asia/Hong_Kong
HST=Pacific/Honolulu SJMT=America/Costa_Rica HMT=Europe/Helsinki KST=Asia/Seoul
GMT=Europe/London WEMT=Europe/Paris AEST=Australia/Sydney IST=Asia/Jerusalem
JMT=Asia/Jerusalem SET=Europe/Stockholm IDT=Asia/Jerusalem WAT=Africa/Lagos
CEMT=Europe/Berlin NZMT=Pacific/Auckland FMT=Atlantic/Madeira WIT=Asia/Jayapura
MDT=America/Phoenix WAST=Africa/Ndjamena EPT=America/New_York WMT=Europe/Warsaw
AKST=America/Anchorage CMT=America/Argentina/Buenos_Aires TMT=Asia/Tehran
BST=Europe/London MSD=Europe/Moscow AHDT=America/Anchorage WET=Europe/Paris
MSK=Europe/Moscow MMT=Europe/Moscow PWT=America/Los_Angeles MDST=Europe/Moscow
TBMT=Asia/Tbilisi PPMT=America/Port-au-Prince FFMT=America/Martinique
KDT=Asia/Seoul NZDT=Pacific/Auckland EWT=America/New_York HKST=Asia/Hong_Kong
SAST=Africa/Johannesburg ACDT=Australia/Adelaide ACST=Australia/Adelaide
NZST=Pacific/Auckland EDT=America/New_York IDDT=Asia/Jerusalem
DMT=Europe/Dublin AHST=America/Anchorage PT=America/Los_Angeles
ET=America/New_York MT=America/Phoenix CT=America/Chicago UTC=UTC'''.split()))
IntervalFilter = cs.namedtuple('interval_filter', 'mo_days weekdays time tz')
IntervalDelta = cs.namedtuple('interval_delta', 'y mo w d h m s')
weekday_names = 'mon tue wed thu fri sat sun'.title().split()
delta_1s, delta_1d = dt.timedelta(seconds=1), dt.timedelta(days=1)
_short_ts_days = dict(
y=365.25, yr=365.25, year=365.25,
mo=30.5, month=30.5, w=7, week=7, d=1, day=1 )
_short_ts_s = dict(
h=3600, hr=3600, hour=3600,
m=60, min=60, minute=60,
s=1, sec=1, second=1 )
def _short_ts_re():
sub_sort = lambda d: sorted(
d.items(), key=lambda kv: (kv[1], len(kv[0])), reverse=True )
ts_re = ['^'] + [
r'(?P<{0}>\d*{0}\s*)?'.format(k)
for k, v in it.chain.from_iterable(
map(sub_sort, [_short_ts_days, _short_ts_s]) ) ] + ['$']
return re.compile(''.join(ts_re), re.I | re.U)
_short_ts_re = _short_ts_re()
_filter_weekdays = list(map(str.split,
[ 'mon monday', 'tu tue tuesday tues', 'wed wednesday',
'th thu thursday thur thurs', 'fri friday', 'sat saturday', 'sun sunday' ] ))
_filter_weekdays_re = '(?:{})'.format(
'|'.join('|'.join(wd) for wd in _filter_weekdays) )
def _delta_keys():
ks = dict()
for delta_k in IntervalDelta._fields:
for vs in _short_ts_days, _short_ts_s:
if delta_k not in vs: continue
v_chk = vs[delta_k]
for k, v in vs.items():
if v == v_chk: ks[k] = delta_k
break
else: raise TSParseError(delta_k)
return ks
_delta_keys = _delta_keys()
_delta_keys_dt = dict(
y=(1, 'year'), mo=(1, 'month'), w=dt.timedelta(weeks=1), d=dt.timedelta(days=1),
h=dt.timedelta(hours=1), m=dt.timedelta(minutes=1), s=dt.timedelta(seconds=1) )
_parse_int = lambda v: int(''.join(c for c in v if c.isdigit()) or 1)
def conv_ts_utc(ts):
'Interpret any naive datetime as local-tz and convert to UTC.'
if ts is None: return
if isinstance(ts, (int, float)): ts = dt.datetime.fromtimestamp(ts)
if not ts.tzinfo: ts = ts.replace(tzinfo=get_local_tz())
return ts.astimezone(dt.timezone.utc)
def conv_ts_local(ts):
'Convert datetime to local timezone for human output.'
if not ts: return
if isinstance(ts, (int, float)): ts = dt.datetime.fromtimestamp(ts)
return ts.astimezone(get_local_tz())
def parse_delta_spec(ts_str):
m = _short_ts_re.search(ts_str)
if not m or not any(m.groups()): raise TSParseError(ts_str)
delta = dict.fromkeys(IntervalDelta._fields, 0)
for k, delta_k in _delta_keys.items():
try: n = _parse_int(m.group(k)) if m.group(k) else 0
except IndexError: n = 0
delta[delta_k] += n
for delta_k, n in delta.items():
v = _delta_keys_dt[delta_k]
if isinstance(v, dt.timedelta): v *= n
else: v = v[0]*n, v[1]
delta[delta_k] = v
return IntervalDelta(**delta)
def parse_duration(ts_str):
if ( not (m := _short_ts_re.search(ts_str))
or not any(m.groups()) ): raise TSParseError(ts_str)
delta = list()
for units in _short_ts_days, _short_ts_s:
val = 0
for k, v in units.items():
try:
if not m.group(k): continue
n = _parse_int(m.group(k))
except IndexError: continue
val += n * v
delta.append(val)
return dt.timedelta(*delta)
def parse_ts_or_interval(ts_str, multiple=False):
m = re.search(r'(?i)^every\s+(.*)', ts_str)
ts = ( parse_ts(ts_str, multiple=multiple)
if not m else parse_ts_interval(m.group(1)) )
if multiple and not isinstance(ts, list): ts = [ts]
return ts
def parse_tz(tz_str):
if m := re.search(r'^([-+])(\d{1,2}):?(\d\d)?$', tz_str):
s, hh, mm = m.groups()
s = int(f'{s}1')
tz = dt.timezone(
dt.timedelta(hours=s*int(hh), minutes=s*int(mm or 0)),
name=f'{s}{int(hh):02d}{int(mm or 0):02d}' )
else: tz = zi.ZoneInfo(tza_map.get(cc(tz_str), tz_str))
return tz
def parse_ts(ts_str, in_future=True, multiple=False):
assert isinstance(ts_str, str), [type(ts_str), repr(ts_str)]
ts = tz = None
if m := re.search(r'(.*)?\s+\[([^\]]+)\]\s*$', ts_str):
ts_str, tz = m.groups()
tz = tz.strip()
tz = parse_tz(tz)
if not ts and (m := re.search( # common BE date format, with optional time
r'^(?P<date>(?:\d{2}|(?P<Y>\d{4}))-\d{2}-\d{2})'
r'(?:[ T](?P<time>\d{2}(?::\d{2}(?::\d{2})?)?)?)?$', ts_str )):
tpl = 'y' if not m.group('Y') else 'Y'
tpl, tss = f'%{tpl}-%m-%d', m.group('date')
if m.group('time'):
tpl_time = ['%H', '%M', '%S']
tss += ' ' + ':'.join(tss_time := m.group('time').split(':'))
tpl += ' ' + ':'.join(tpl_time[:len(tss_time)])
try: ts = dt.datetime.strptime(tss, tpl)
except ValueError: pass
if not ts and (m := re.search( # just time without AM/PM - treat as 24h format
r'^\d{1,2}:\d{2}(?::\d{2}(?P<us>\.\d+)?)?$', ts_str )):
us, tpl = 0, ':'.join(['%H', '%M', '%S'][:len(ts_str.split(':'))])
if m.group('us'):
tss, us = ts_str.rsplit('.', 1)
us = us[:6] + '0'*max(0, 6 - len(us))
else: tss = ts_str
try: ts_time = dt.datetime.strptime(tss, tpl)
except ValueError: pass
else:
ts0 = dt.datetime.now()
ts = dt.datetime.now().replace( hour=ts_time.hour,
minute=ts_time.minute, second=ts_time.second, microsecond=int(us) )
if ts0 > ts: ts += dt.timedelta(days=1)
if not ts:
# coreutils' "date" parses virtually everything, but is more expensive to use
ts_ext, ts_list = [ts_str], list()
if in_future: ts_now_sec, ts_now_date = time.time(), dt.date.today()
while True:
ts_str_ext, tz_env = ' '.join(ts_ext), dict(os.environ)
if tz: tz_env['TZ'] = tz.key # to fix in_future checks before tz adjustment
res = sp.run( ['date', '+%s', '-d', ts_str_ext],
stdout=sp.PIPE, stderr=sp.DEVNULL, env=tz_env )
if not res.returncode:
val = int(res.stdout.strip())
# Try to add +1 day to simple timestamps like 3:00 if they're in the past
# Whitelisted cases: 1:00, 4am, 5am GMT, 3:30 UTC-4
if in_future and 0 < ts_now_sec - val <= 24*3600 and re.search(
r'(?i)^[\d:]+\s*(am|pm)?\s*([-+][\d:]+|\w+|\w+[-+][\d:]+)?$', ts_str.strip() ):
ts_list.append(ts)
ts_ext.append('+ 1 day') # note: just val+=24*3600 can be incorrect due to DST
continue
ts = dt.datetime.fromtimestamp(val, tz=tz) # note: can be naive dt in local tz
# Try to add +1 year to timestamps that don't
# have a YYYY spec and resolve to past dates
if ( in_future and 0 < ts_now_sec - val <= 365*24*3600
and ts.date() != ts_now_date and not re.search(r'\b\d{4}\b', ts_str) ):
ts_list.append(ts)
ts = ts.replace(year=ts.year+1)
elif ',' in ts_str_ext:
# "date -d" seem to have issues with commas, but removing them works
ts_ext = list(str(v).replace(',', ' ') for v in ts_ext)
continue
break
if multiple: ts = ts_list + [ts]
if ts:
if not isinstance(ts, list): ts = [ts]
if tz: ts = list(ts.replace(tzinfo=tz) for ts in ts)
if not multiple: ts = ts[-1]
return ts
raise TSParseError(ts_str)
def parse_ts_interval(ts_str):
# EVERY:
# "every" {
# [ ( NN[suffix][ "-" MM[suffix]] )+ | DELTA-DATE-SPEC ]
# [WD[-WD]]+
# ["at"] [TIME ["[" TZ "]"]]
# | DELTA-SPEC "interval" }
# suffix: st, nd, rd, th
# example: every 1st-11th at 5am [UTC]
# DELTA-SPEC:
# ( N || unit )+
# units:
# y, yr, year, mo, month, w, week, d, day,
# h, hr, hour, m, min, minute, s, sec, second
# example: 3mo 1d 5hrs 10minutes 30s
# DELTA-DATE-SPEC:
# subset of DELTA-SPEC wrt allowed units
# units: y, yr, year, mo, month, w, week, d, day
# WD:
# monday mon, tuesday tu tue tues, wednesday wed,
# thursday th thu thur thurs, friday fri, saturday sat, sunday sun
# TZ:
# ( "UTC" | ("+"|"-") HH:MM | region/place | abbrev )
# examples: +05:00, America/Los_Angeles, BST
# (anything that parse_tz can handle, basically)
# TIME:
# ( [H]H[:MM[:SS]] ["am"|"pm"] | "noon" | "midnight" )
# DELTA-SPEC "interval"
m = re.search(r'(?i)^(.*)\binterval$', ts_str)
if m: return parse_delta_spec(m.group(1).strip())
# More conventional specification
ts_str_base, dt_filter = ts_str, dict.fromkeys(IntervalFilter._fields)
mo_days = dt_filter['mo_days'] = set()
weekdays = dt_filter['weekdays'] = set()
ts_str_re = r'(\s+(?P<rest>.*)|$)'
# ( NN[suffix][ "-" MM[suffix]] )+
while True:
if not (m := re.search( r'(?i)^(?P<a>\d+)(st|nd|rd|th)?'
r'(\s*-\s*(?P<b>\d+)(st|nd|rd|th)?)?' + ts_str_re, ts_str )): break
a, b, ts_str = m.group('a'), m.group('b'), m.group('rest') or ''
if b: mo_days.update(range(int(a), int(b)+1))
else: mo_days.add(int(a))
# DELTA-DATE-SPEC
spec, delta, ts_str = ts_str.split(), None, list()
while True:
if not spec: break
try: delta = parse_delta_spec(' '.join(spec))
except TSParseError: pass
else: break
ts_str.append(spec.pop())
if delta:
if mo_days: raise TSParseError(' '.join(spec), mo_days, delta)
if delta.h or delta.m or delta.s: raise TSParseError(' '.join(spec), delta)
dt_filter['mo_days'] = delta
ts_str = ' '.join(reversed(ts_str))
# [WD[-WD]]+
wd_re = re.compile( r'(?i)^(?P<a>{0})(\s*-\s*'
r'(?P<b>{0}))?{1}'.format(_filter_weekdays_re, ts_str_re) )
wd_match = lambda s: next( n for n, wd in
enumerate(_filter_weekdays, 1) if s.startswith(wd[0]) )
while True:
if not (m := wd_re.search(ts_str)): break
a, b, ts_str = m.group('a'), m.group('b'), m.group('rest') or ''
if b: weekdays.update(range(wd_match(a.lower()), wd_match(b.lower())+1))
else: weekdays.add(wd_match(a.lower()))
# "at"
if m := re.search(r'(?i)^\s*at' + ts_str_re, ts_str): ts_str = m.group('rest') or ''
# TIME
if m := re.search(r'(?i)^(noon|midnight)\b' + ts_str_re, ts_str):
spec = m.group(1)
ts_str = dict(noon='12:00', midnight='00:00')[spec]
if m.group('rest'): ts_str += ' ' + m.group('rest')
if m := re.search( r'(?i)^(?P<h>\d{1,2})(:(?P<m>\d{2})'
r'(:(?P<s>\d{2}))?)?(\s*(?P<x>am|pm))?' + ts_str_re, ts_str ):
x, ts_str = m.group('x'), m.group('rest') or ''
h, m, s = (int(m.group(k) or 0) for k in 'h m s'.split())
if x and x.lower() == 'pm': h = h%12 + 12
dt_filter['time'] = h, m, s
# TZ
if m := re.search(r'^\[\s*(\S.*\S)\s*\]' + ts_str_re, ts_str):
dt_filter['tz'], ts_str = zi.ZoneInfo(m.group(1).replace(' ', '_')), m.group('rest')
# Leftovers
if ts_str: raise TSParseError(ts_str)
return IntervalFilter(**dt_filter)
def repr_ts(ts):
if not ts: return str(ts)
ts = conv_ts_local(ts)
us = '.%f' if ts.microsecond else ''
return ts.strftime(f'[%a %Y-%m-%d %H:%M:%S{us} %z]')
def repr_ts_diff( ts, ts0=None,
ext=None, units_max=2, units_res=None,
_units=dict( h=3600, m=60, s=1,
y=365.25*86400, mo=30.5*86400, w=7*86400, d=1*86400 ) ):
if isinstance(ts, dt.timedelta): delta = abs(ts)
else:
if not ts0:
ts0 = dt.datetime.now()
if ts.tzinfo: ts0 = conv_ts_utc(ts0)
delta = abs(ts - ts0)
res, s, n_last = list(), delta.total_seconds(), units_max - 1
for unit, unit_s in sorted(_units.items(), key=op.itemgetter(1), reverse=True):
if not (val := math.floor(s / unit_s)):
if units_res == unit: break
continue
if len(res) == n_last or units_res == unit:
val, n_last = round(s / unit_s), True
res.append(f'{val:.0f}{unit}')
if n_last is True: break
s -= val * unit_s
if not res: return '-'
else:
if ext: res.append(ext)
return ' '.join(res)
def apply_ts_delta( ts, delta,
ts_limit=conv_ts_utc(dt.datetime.now()) + dt.timedelta(365) ):
if isinstance(delta, IntervalDelta):
delta_has_time = False
for offset in delta:
if isinstance(offset, tuple):
n, k = offset
if n != 0:
n = getattr(ts, k) + n
if k == 'month' and n > 12:
while n > 12: ts, n = ts.replace(year=ts.year+1), n - 12
if k != 'month': ts = ts.replace(**{k: n})
else: # handle day overflows on month replacement
while True:
try:
ts = ts.replace(month=n)
break
except ValueError:
if ts.day <= 28: raise
ts = ts.replace(day=ts.day-1)
elif offset.total_seconds() != 0:
ts += offset
if offset.seconds: delta_has_time = True
if not delta_has_time:
ts = conv_ts_utc( conv_ts_local(ts)\
.replace(hour=0, minute=0, second=0, microsecond=0) )
elif isinstance(delta, IntervalFilter):
ts0, ts_ref = ts, ts - delta_1d
while True:
if ts_ref > ts_limit: raise TSOverflow(ts_limit, delta)
ts_ref += delta_1d
if delta.tz: ts = ts_ref.replace(tzinfo=delta.tz)
else: ts = conv_ts_local(ts_ref)
if ( delta.mo_days
and isinstance(delta.mo_days, set)
and ts.day not in delta.mo_days ): continue
if delta.weekdays and ts.isoweekday() not in delta.weekdays: continue
ts = ts.replace( microsecond=0,
**dict(zip('hour minute second'.split(), delta.time or (0, 0, 0))) )
if (ts := conv_ts_utc(ts)) > ts0: break
else: raise NotImplementedError(delta)
return ts
### ReST parser
def parse_data_from_rst(rst_str):
from docutils import frontend as rst_front
from docutils.parsers import rst
import docutils.utils as rst_utils
class RstLookupError(Exception): pass
class RstLookupFound(Exception): pass
def rst_lookup( root, tag,
default=RstLookupError, recursive=False, one=True, text=True ):
'''Find tagged element(s) inside a "root" element.
"tag" can be multiple space-separated tags for path or end
with "..." to use one=False (find all elements, not just the first one).
RstLookupError is raised if nothing was found.
one=False returns non-empty list instead of element/text.
text=True does element.astext() conversion.'''
res, opts = list(), dict(one=one, recursive=recursive)
tag = tag.split()
if len(tag) > 1:
opts_path = dict(opts.items(), text=False)
for tag_path in tag[:-1]:
if not root: break
root = rst_lookup(root, tag_path, **opts_path)
tag = tag[-1]
else: tag = tag[0]
if tag.endswith('...'): one, tag = False, tag[:-3]
def collect(e):
if one: raise RstLookupFound(e)
if isinstance(e, list): res.extend(e)
else: res.append(e)
if not root: root = list()
es = ( root.children if not isinstance(root, list)
else it.chain.from_iterable(e.children for e in root) )
try:
for e in es:
if e.tagname == tag: collect(e)
if recursive:
if e := rst_lookup(e, tag, **opts): collect(e)
except RstLookupFound as res:
res = res.args[0]
return res if not text else res.astext()
if one or not res:
if default is not RstLookupError: return default
raise RstLookupError(root, tag)
if text: res = list(e.astext() for e in res)
return res
def rst_cal_info(root):
try: fields = rst_lookup(root, 'field_list...', text=False)
except RstLookupError: return
res = dict()
for e in rst_lookup(fields, 'field...', text=False):
key = rst_lookup(e, 'field_name').replace('-', '_')
res.setdefault(key, list()).append(rst_lookup(e, 'field_body paragraph'))
if not (res.get('ts') or res.get('ts_start')): return
ts_str_norm = lambda ts: ts.replace('_', ' ').strip()
if res.get('ts'):
ts_str_list, res['ts'] = res['ts'], list()
res['raw_ts'] = ts_str_list
for ts in ts_str_list:
if not (ts := ts_str_norm(ts)): continue
res['ts'].extend(parse_ts_or_interval(ts, multiple=True))
else: res['ts'] = res['raw_ts'] = list()
for k in 'ts_start', 'ts_end', 'duration':
k_raw = f'raw_{k}'
if not res.get(k):
res[k] = res[k_raw] = None
continue
assert len(res[k]) == 1, [k, res[k]]
res[k_raw], res[k] = res[k], ( parse_ts
if k != 'duration' else parse_duration )(ts_str_norm(res[k][0]))
return res
def rst_config(root):
conf = dict()
try: fields = rst_lookup(root, 'field_list...', text=False)
except RstLookupError: return conf
for e in rst_lookup(fields, 'field...', text=False):
key = rst_lookup(e, 'field_name').replace('-', '_')
conf.setdefault(key, list()).append(rst_lookup(e, 'field_body paragraph'))
return conf
def rst_cal_list(root, depth=0, sections=None, cals=None, conf=None):
if not sections: sections = list()
if cals is None: cals = list()
if conf is None: conf = dict()
for e in root.children:
e_sec = None
if e.tagname == 'section':
e_sec = sections.copy() + [rst_lookup(e, 'title')]
if cal_info := rst_cal_info(e):
if not cal_info.get('title'):
cal_info['title'] = ( e_sec[-1]
if e_sec else rst_lookup(e, 'paragraph') )
cal_info['path'] = sections
cals.append(cal_info)
else: conf.update(rst_config(e))
rst_cal_list(e, depth+1, e_sec or sections, cals, conf)
return cals, conf
settings = rst_front.get_default_settings(rst.Parser)
rst_tree = rst_utils.new_document('cal_list', settings)
rst.Parser().parse(rst_str, rst_tree)
return rst_cal_list(rst_tree)
### Calendar data processing
EventInfo = cs.namedtuple('EventInfo', 'ts0 ts1 cal ts_type ts0_last ts_interval')
EventInfoTSType = enum_func('fixed interval', mutex_flags=True)
conf_defaults = dict(
version='1.0',
# Custom accept_header is for github specifically
# (it returns html instead of feed if */* is in there)
feed_user_agent='riet/{ver} (rst-icalendar-event-tracker) feedparser/{ver_fp}',
feed_accept_header='application/atom+xml,application/rdf+xml,'
'application/rss+xml,application/x-netcdf,application/xml;q=0.9,text/xml;q=0.2',
feed_interval_checks=5,
feed_check_before_fixed_ts=delta_1d,
feed_check_after_fixed_ts=delta_1d,
feed_check_min_interval=dt.timedelta(seconds=60),
note_ts_window=dt.timedelta(hours=1),
note_ts_window_past=dt.timedelta(days=3),
dnote_enabled=True, dnote_icon='', dnote_timeout=-1, dnote_app='riet', dnote_urgency=1 )
def conf_parse_bool(v, _states={
'1': True, 'yes': True, 'y': True, 'true': True, 'on': True,
'0': False, 'no': False, 'n': False, 'false': False, 'off': False }):
try: return _states[v.strip().lower()]
except KeyError: raise ValueError(v)
def conf_update(conf, update):
conf = adict(conf.copy())
for k, v in (update or dict()).items():
if k.startswith('_'): continue
v_def = conf_defaults.get(k := k.replace('-', '_'))
if v_def is None: continue
if isinstance(v, list): v = v[-1]
if isinstance(v_def, str): pass
elif isinstance(v_def, bool): v = conf_parse_bool(v)
elif isinstance(v_def, int): v = int(v)
elif isinstance(v_def, dt.timedelta): v = parse_duration(v)
else: continue
conf[k] = v
if conf.get('feed_interval_checks', 1) <= 0: conf.feed_interval_checks = 0
return conf
def cal_list_to_events(cal_list, ts_min, ts_max, state):
'''Process list of RST event sections
(dicts of string values) to a list of relevant EventInfo timespans.'''
ev_list = list()
for cal in map(adict, cal_list):
ts_list, ts0, ts1, ts_keys = cal.ts, cal.ts_start, cal.ts_end, set()
ts0, ts1 = map(conv_ts_utc, [ts0, ts1])
if ts1 and ts1 < ts0: ts1 = ts1.replace(year=ts1.year + 1)
if not ts_list: ts_list = [ts0]
ts_base = ts0, ts1
for n, ts in enumerate(ts_list):
if not ts: continue
(ts0, ts1), ts_list[n], ts0_last = ts_base, None, None
ts_type = EventInfoTSType(fixed=isinstance(ts, dt.datetime))
if ts_type.fixed:
if ts0 and ts1 and ts != ts0:
log.warning( 'Ignoring fixed-time "ts" field for entry,'
' as "ts-start"/"ts-end" are used instead: {!r} [{}]', cal.title, ts )
elif not ts0: ts0 = conv_ts_utc(ts)
elif ts_type.interval:
ts0_k = state.key('cal.ts', cal.title, ts0, ts)
ts_keys.add(ts0_k)
if cal.get('_state_reset'): state.pop(ts0_k, None)
ts0_last = conv_ts_utc(state.get(ts0_k))
ts0 = ts0_pre = ts0_last or ts0 or ts_min
if ts1 and ts0 > ts1: # event ending in the past
ts0 = None
break
try:
while ts0 <= ts_min:
ts0_pre, ts0 = ts0, apply_ts_delta(ts0, ts, ts_max)
except TSOverflow: # no events within timespan
ts0 = None
break
if ts0_pre != ts0_last: state[ts0_k] = ts0_last = ts0_pre.timestamp()
if isinstance(ts, IntervalFilter) and not ts.time: # day spans
ts1 = ts0
while True: # iterate until span ends
try: ts1_next = apply_ts_delta(ts1, ts, ts_max)
except TSOverflow: break
if ts1_next != ts1 + delta_1d: break
ts1 = ts1_next
ts1 += delta_1d - delta_1s # extend until end of last day
else: raise ValueError(ts_type)
if not ts0: continue
if not ts1:
ts1 = ts0
if cal.duration: ts1 += cal.duration
if ts1 < ts_min or ts0 > ts_max: continue
ts_list[n] = ts0, ts1, ts_type, ts0_last, ts_type.interval and ts
ts_list = list(filter(None, ts_list))
if log.isEnabledFor(logging.DEBUG):
log.debug('')
log.debug('----- title: {}', cal.title)
log.debug(' raw: ts={} start={} end={}', cal.raw_ts, cal.raw_ts_start, cal.raw_ts_end)
if ts_keys:
log.debug( ' ts-keys: {}',
', '.join(f'{k} [{conv_ts_local(state.get(k))}]' for k in sorted(ts_keys)) )
for ts0, ts1, ts_type, ts0_last, *ev_info in ts_list:
log.debug(' result [{}]:', repr_ts_diff(ts1 - ts0))
if ts0_last: log.debug(' last: {}', repr_ts(ts0_last))
log.debug(' from: {}', repr_ts(ts0))
log.debug(' to: {}', repr_ts(ts1))
for url in cal.get('url', list()): log.debug(' url: {}', url)
for url in cal.get('feed_rss', list()): log.debug(' url: {} [feed]', url)
for ts0, ts1, *ev_info in ts_list:
ev_list.append(EventInfo(conv_ts_local(ts0), conv_ts_local(ts1), cal, *ev_info))
return ev_list
### Approaching-time notification events
def issue_notifications(ev_list, conf, state):
ts_now = dt.datetime.now(dt.timezone.utc)
ts_min, ts_max = ts_now - conf.note_ts_window_past, ts_now + conf.note_ts_window
err_warn_dedup = set()
method_funcs = dict(dnote=note_ts_dnote)
for ev in ev_list:
methods, methods_all = set(' '.join(ev.cal.get('note_ts') or '').split()), method_funcs.keys()
methods, methods_err = methods & methods_all, methods - methods_all
if methods_err and ev.cal.title not in err_warn_dedup:
err_warn_dedup.add(ev.cal.title)
log.warning( 'Unknown ts-note methods for entry:'
' {!r} [{}]', ev.cal.title, ' '.join(sorted(methods_err)) )
if not methods: continue
if not (ts_min <= ev.ts0 <= ts_max or ts_min <= ev.ts1 <= ts_max): continue
note_ev_k = state.key('note.ts.ev', ev.cal.title)
ts_ev_last_done = (ts_ev_last_done := state.get(note_ev_k)) and conv_ts_utc(ts_ev_last_done)
if ts_ev_last_done and ev.ts0 <= ts_ev_last_done: continue # already notified about this one
ts_interval = parse_duration(ev.cal.get('note_ts_interval') or '0s')
log.debug( 'Processing notifications for: {} [{}{}]', ev.cal.title, ' '.join(methods),
f', interval={repr_ts_diff(ts_interval)}' if ts_interval.total_seconds() >= 1 else '' )
ts_interval = ts_now - ts_interval
for k in methods:
note_ts_k = state.key('note.ts.method', ev.cal.title, k)
ts_last = (ts_last := state.get(note_ts_k)) and conv_ts_utc(ts_last)
if ts_last and ts_last > ts_interval:
log.debug( 'Skipping notification method due to min-interval:'
' {} [{}], ts_last=[{}] ts_max=[{}]', ev.cal.title, k, ts_last, ts_interval )
continue
method_funcs[k](ev, conf, ts_now)
state[note_ts_k] = ts_now.timestamp()
state[note_ev_k] = ev.ts0.timestamp()
def note_ts_dnote(ev, conf, ts_now):
if not conf.dnote_enabled: return
def _note_ts_repr(ts):
ts_str = ts.strftime('%Y-%m-%d')
ts_str_ext = ts.strftime('%H:%M:%S')
if ts_str_ext not in ['00:00:00', '23:59:59']: ts_str = f'{ts_str} {ts_str_ext}'
return '{} (<b>{}</b>)'.format( ts_str,
repr_ts_diff(ts, ts_now, ext=None if (ts - ts_now).total_seconds() >= 0 else 'ago') )
log.debug(' notification: dnote [{}]', ev.ts0)
ts_span, label_len = ev.ts0 != ev.ts1, 14
body = [('Start' if ts_span else 'Event time', _note_ts_repr(ev.ts0))]
if ts_span: body.append(('End', _note_ts_repr(ev.ts1)))
body.append(('Current time', conv_ts_local(ts_now).strftime('%Y-%m-%d %H:%M')))
body = '\n'.join(
(f'<tt>{{:<{label_len}s}}</tt>{{}}'.format(t + ':', v) if t else v) for t, v in body )
dnote_conf, dnote_kws = dict(), dict()
for k, v in ev.cal.items():
if not k.startswith('dnote_'): continue
dnote_conf[k], dnote_kws[k] = conf[k], v
dnote_kws = dict((k[6:], v) for k, v in conf_update(dnote_conf, dnote_kws).items())
conf.dnote_func(f'Event: {ev.cal.title}', body, **dnote_kws)
### Feeds
def check_feeds(ev_list, conf, state, ts_max, socket_timeout=...):
import feedparser, socket
if socket_timeout is not ...: socket.setdefaulttimeout(socket_timeout)
ts_now = dt.datetime.now(dt.timezone.utc)
for ev in ev_list: _check_feed(feedparser, ts_now, ev, conf, state, ts_max)
def _check_feed(fp, ts_now, ev, conf_base, state, ts_max):
if not ev.cal.get('feed_rss'): return
conf = conf_update(conf_base, ev.cal)
feed_url, feed_ua = ( ev.cal.feed_rss[-1],
conf.feed_user_agent.format(ver=conf.version, ver_fp=fp.__version__) )
fk_ts, fk_ts_ev, fk_ts_entry = (
state.key(k, feed_url) for k in ['feed.ts', 'feed.ts-ev', 'feed.ts-entry'] )
if ev.cal.get('_state_reset'):