inputs: provide a `moment` input type which accepts lisp expression for dates

The expressions are evaluated at de-serialization time (which means in
practice within the task when the inputs are read).
3 files changed, 76 insertions(+), 2 deletions(-)

M rework/input.py
M setup.py
M tests/test_api.py
M rework/input.py +46 -1
@@ 1,6 1,13 @@ 
 import json
+import calendar as cal
+from datetime import datetime as dt
 
-from dateutil.parser import isoparse, parse as defaultparse
+from dateutil.parser import (
+    isoparse,
+    parse as defaultparse
+)
+from dateutil.relativedelta import relativedelta
+from psyl import lisp
 
 
 def parsedatetime(strdt):

          
@@ 105,3 112,41 @@ class datetime(inputio):
             return isoparse(val)
         except ValueError:
             return defaultparse(val)
+
+
+def last_day_of_month(dt):
+    return cal.monthrange(dt.year, dt.month)[1]
+
+
+_MOMENT_ENV = lisp.Env({
+    'date': lambda strdate: parsedatetime(strdate),
+    'today': lambda: dt.now(),
+    'monthstart': lambda dt: dt.replace(day=1),
+    'monthend': lambda dt: dt.replace(day=last_day_of_month(dt)),
+    'yearstart': lambda dt: dt.replace(day=1, month=1),
+    'yearend': lambda dt: dt.replace(day=31, month=12),
+    'delta': lambda dt, **kw: dt + relativedelta(**kw),
+})
+
+
+class moment(inputio):
+
+    def binary_encode(self, args):
+        val = self.val(args)
+        if val is None:
+            return
+        try:
+            # validate the expression
+            lisp.evaluate(val, env=_MOMENT_ENV)
+        except:
+            import traceback as tb; tb.print_exc()
+            raise
+
+        return val.encode('utf-8')
+
+    def binary_decode(self, args):
+        val = args.get(self.name)
+        if val is None:
+            return
+        val = val.decode('utf-8')
+        return lisp.evaluate(val, env=_MOMENT_ENV)

          
M setup.py +2 -1
@@ 27,7 27,8 @@ setup(name='rework',
           'inireader',
           'apscheduler',
           'zstd',
-          'dateutil'
+          'dateutils',
+          'psyl'
       ],
       package_data={'rework': [
           'schema.sql'

          
M tests/test_api.py +28 -0
@@ 65,6 65,10 @@ def register_tasks():
     def noinput(task):
         pass
 
+    @api.task(inputs=(input.moment('when'),))
+    def happy_days(task):
+        pass
+
 
 
 def test_freeze_ops(engine, cleanup):

          
@@ 82,6 86,9 @@ def test_freeze_ops(engine, cleanup):
     assert res == [
         ('cheesy', 'cheese', None),
         ('foo', 'default', None),
+        ('happy_days', 'default', [
+            {'choices': None, 'name': 'when', 'required': False, 'type': 'moment'}
+        ]),
         ('noinput', 'default', []),
         ('yummy', 'default', [
             {'choices': None, 'name': 'myfile.txt', 'required': True, 'type': 'file'},

          
@@ 104,6 111,7 @@ def test_freeze_ops(engine, cleanup):
     ).fetchall()
     assert res == [
         ('foo', 'default'),
+        ('happy_days', 'default'),
         ('noinput', 'default'),
         ('yummy', 'default'),
         ('hammy', 'ham')

          
@@ 177,6 185,26 @@ def test_with_inputs(engine, cleanup):
     }
 
 
+def test_moment_input(engine, cleanup):
+    register_tasks()
+    api.freeze_operations(engine)
+    t = api.schedule(
+        engine,
+        'happy_days',
+        inputdata={'when': '(delta (today) #:days 1)'}
+    )
+    when = t.input['when']
+    assert when > dt.now()
+
+    t = api.schedule(
+        engine,
+        'happy_days',
+        inputdata={'when': '(date "2021-1-1 09:00")'}
+    )
+    when = t.input['when']
+    assert when == dt(2021, 1, 1, 9, 0)
+
+
 def test_prepare_with_inputs(engine, cleanup):
     reset_ops(engine)
     register_tasks()